dataface 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- d3_format/__init__.py +14 -0
- d3_format/errors.py +19 -0
- d3_format/format.py +551 -0
- d3_format/spec.py +159 -0
- dataface/DATAFACE_SYNTAX.md +1135 -0
- dataface/__init__.py +93 -0
- dataface/_docs_site.py +20 -0
- dataface/_install_hint.py +26 -0
- dataface/agent_api/__init__.py +79 -0
- dataface/agent_api/_init_templates/__init__.py +0 -0
- dataface/agent_api/_init_templates/agents_dft_snippet.md +26 -0
- dataface/agent_api/_init_templates/dataface.yml +15 -0
- dataface/agent_api/_init_templates/faces-dataface.yml +144 -0
- dataface/agent_api/_init_templates/index.md +24 -0
- dataface/agent_api/_paths.py +118 -0
- dataface/agent_api/_project_agents_md.py +43 -0
- dataface/agent_api/_session_store.py +486 -0
- dataface/agent_api/_state.py +28 -0
- dataface/agent_api/chat.py +221 -0
- dataface/agent_api/dashboards.py +257 -0
- dataface/agent_api/describe.py +366 -0
- dataface/agent_api/describe_query.py +120 -0
- dataface/agent_api/docs/__init__.py +25 -0
- dataface/agent_api/docs/_loader.py +292 -0
- dataface/agent_api/docs/yaml-reference.md +2757 -0
- dataface/agent_api/file_refs.py +118 -0
- dataface/agent_api/init.py +126 -0
- dataface/agent_api/inspect.py +128 -0
- dataface/agent_api/mcp_install.py +170 -0
- dataface/agent_api/query.py +274 -0
- dataface/agent_api/schema.py +658 -0
- dataface/agent_api/schema_search.py +284 -0
- dataface/agent_api/search.py +270 -0
- dataface/agent_api/skill_install.py +141 -0
- dataface/agent_api/skill_render.py +90 -0
- dataface/agent_api/skills.py +293 -0
- dataface/agent_api/surface_aliases.yaml +128 -0
- dataface/agent_api/validate.py +175 -0
- dataface/agent_api/validate_query.py +84 -0
- dataface/ai/__init__.py +39 -0
- dataface/ai/agent.py +139 -0
- dataface/ai/context.py +45 -0
- dataface/ai/events.py +62 -0
- dataface/ai/external_mcp.py +610 -0
- dataface/ai/generate_sql.py +96 -0
- dataface/ai/llm.py +403 -0
- dataface/ai/mcp/__init__.py +51 -0
- dataface/ai/mcp/server.py +289 -0
- dataface/ai/memories.py +85 -0
- dataface/ai/prompts.py +177 -0
- dataface/ai/schema_context.py +138 -0
- dataface/ai/skills/before-after-comparison/SKILL.md +102 -0
- dataface/ai/skills/before-after-comparison/examples/before-after-comparison.yml +24 -0
- dataface/ai/skills/dashboard-build/SKILL.md +212 -0
- dataface/ai/skills/dashboard-build/examples/_smoke.yml +15 -0
- dataface/ai/skills/dashboard-design/SKILL.md +182 -0
- dataface/ai/skills/dashboard-review/SKILL.md +113 -0
- dataface/ai/skills/dashboard-structural-review/SKILL.md +173 -0
- dataface/ai/skills/dashboard-visual-review/SKILL.md +139 -0
- dataface/ai/skills/dataface-mcp-setup/SKILL.md +177 -0
- dataface/ai/skills/dataface-troubleshooting/SKILL.md +225 -0
- dataface/ai/skills/drill-down-link/SKILL.md +112 -0
- dataface/ai/skills/drill-down-link/examples/drill-down-link.yml +27 -0
- dataface/ai/skills/faceted-small-multiples/SKILL.md +116 -0
- dataface/ai/skills/faceted-small-multiples/examples/faceted-small-multiples.yml +33 -0
- dataface/ai/skills/filter-bar-with-variables/SKILL.md +105 -0
- dataface/ai/skills/filter-bar-with-variables/examples/filter-bar-with-variables.yml +49 -0
- dataface/ai/skills/kpi-row/SKILL.md +101 -0
- dataface/ai/skills/kpi-row/examples/kpi-row.yml +55 -0
- dataface/ai/skills/report-design/SKILL.md +184 -0
- dataface/ai/skills/single-metric-bignum/SKILL.md +90 -0
- dataface/ai/skills/single-metric-bignum/examples/single-metric-bignum.yml +27 -0
- dataface/ai/skills/table-heavy-ops-dashboard/SKILL.md +114 -0
- dataface/ai/skills/table-heavy-ops-dashboard/examples/table-heavy-ops-dashboard.yml +48 -0
- dataface/ai/skills/time-series-trend/SKILL.md +93 -0
- dataface/ai/skills/time-series-trend/examples/time-series-trend.yml +26 -0
- dataface/ai/skills/top-n-with-detail/SKILL.md +98 -0
- dataface/ai/skills/top-n-with-detail/examples/top-n-with-detail.yml +45 -0
- dataface/ai/skills/two-by-two-grid-overview/SKILL.md +78 -0
- dataface/ai/skills/two-by-two-grid-overview/examples/two-by-two-grid-overview.yml +64 -0
- dataface/ai/tool_schemas.py +132 -0
- dataface/ai/tools/__init__.py +312 -0
- dataface/ai/yaml_utils.py +57 -0
- dataface/cli/__init__.py +3 -0
- dataface/cli/_console.py +48 -0
- dataface/cli/_error_format.py +83 -0
- dataface/cli/_extras.py +190 -0
- dataface/cli/_json_output.py +8 -0
- dataface/cli/_parsing.py +17 -0
- dataface/cli/_version_info.py +56 -0
- dataface/cli/commands/__init__.py +3 -0
- dataface/cli/commands/_agent_input.py +205 -0
- dataface/cli/commands/_agent_server.py +115 -0
- dataface/cli/commands/chat.py +645 -0
- dataface/cli/commands/describe.py +107 -0
- dataface/cli/commands/docs.py +131 -0
- dataface/cli/commands/extension.py +179 -0
- dataface/cli/commands/init.py +240 -0
- dataface/cli/commands/inspect.py +94 -0
- dataface/cli/commands/mcp_init.py +167 -0
- dataface/cli/commands/query.py +386 -0
- dataface/cli/commands/render.py +291 -0
- dataface/cli/commands/schema.py +411 -0
- dataface/cli/commands/search.py +49 -0
- dataface/cli/commands/serve.py +114 -0
- dataface/cli/commands/skills.py +133 -0
- dataface/cli/commands/skills_init.py +161 -0
- dataface/cli/commands/validate.py +63 -0
- dataface/cli/main.py +1501 -0
- dataface/core/__init__.py +75 -0
- dataface/core/compile/__init__.py +244 -0
- dataface/core/compile/_jinja_helpers.py +78 -0
- dataface/core/compile/channel.py +222 -0
- dataface/core/compile/chart_focus.py +101 -0
- dataface/core/compile/chart_resolved.py +169 -0
- dataface/core/compile/chart_type_detection.py +489 -0
- dataface/core/compile/chart_update.py +261 -0
- dataface/core/compile/colors.py +64 -0
- dataface/core/compile/compiler.py +904 -0
- dataface/core/compile/config.py +823 -0
- dataface/core/compile/custom_chart_types.py +208 -0
- dataface/core/compile/data_table_attachment.py +1287 -0
- dataface/core/compile/detect.py +110 -0
- dataface/core/compile/errors.py +302 -0
- dataface/core/compile/filter_injection.py +319 -0
- dataface/core/compile/introspection.py +527 -0
- dataface/core/compile/jinja.py +511 -0
- dataface/core/compile/labels_env.py +52 -0
- dataface/core/compile/markdown.py +154 -0
- dataface/core/compile/meta.py +388 -0
- dataface/core/compile/models/__init__.py +0 -0
- dataface/core/compile/models/chart/__init__.py +0 -0
- dataface/core/compile/models/chart/authored.py +2137 -0
- dataface/core/compile/models/chart/compiled.py +398 -0
- dataface/core/compile/models/config.py +347 -0
- dataface/core/compile/models/face/__init__.py +0 -0
- dataface/core/compile/models/face/authored.py +659 -0
- dataface/core/compile/models/face/compiled.py +522 -0
- dataface/core/compile/models/factories.py +201 -0
- dataface/core/compile/models/markers.py +40 -0
- dataface/core/compile/models/palette.py +36 -0
- dataface/core/compile/models/primitives.py +415 -0
- dataface/core/compile/models/query/__init__.py +0 -0
- dataface/core/compile/models/query/authored.py +246 -0
- dataface/core/compile/models/query/compiled.py +710 -0
- dataface/core/compile/models/refs.py +137 -0
- dataface/core/compile/models/source.py +611 -0
- dataface/core/compile/models/style/__init__.py +0 -0
- dataface/core/compile/models/style/authored.py +481 -0
- dataface/core/compile/models/style/compiled.py +3399 -0
- dataface/core/compile/models/style/merged.py +1682 -0
- dataface/core/compile/models/theme.py +362 -0
- dataface/core/compile/models/variable/__init__.py +0 -0
- dataface/core/compile/models/variable/authored.py +254 -0
- dataface/core/compile/models/vega_lite/__init__.py +0 -0
- dataface/core/compile/models/vega_lite/config.py +510 -0
- dataface/core/compile/models/vega_lite/contracts.py +171 -0
- dataface/core/compile/normalize_charts.py +494 -0
- dataface/core/compile/normalize_layout.py +1000 -0
- dataface/core/compile/normalize_queries.py +297 -0
- dataface/core/compile/normalize_variables.py +489 -0
- dataface/core/compile/normalizer.py +543 -0
- dataface/core/compile/palette.py +1100 -0
- dataface/core/compile/parameterized.py +658 -0
- dataface/core/compile/parser.py +228 -0
- dataface/core/compile/schema.py +20 -0
- dataface/core/compile/schema_renderers/__init__.py +0 -0
- dataface/core/compile/schema_renderers/json_schema.py +163 -0
- dataface/core/compile/schema_renderers/prompt.py +152 -0
- dataface/core/compile/schema_renderers/vscode_schema.py +301 -0
- dataface/core/compile/sizing.py +2126 -0
- dataface/core/compile/sources.py +518 -0
- dataface/core/compile/sql_authoring_lint.py +56 -0
- dataface/core/compile/style_cascade.py +471 -0
- dataface/core/compile/typography.py +299 -0
- dataface/core/compile/validator.py +301 -0
- dataface/core/compile/variables.py +53 -0
- dataface/core/compile/vega_config.py +98 -0
- dataface/core/compile/vega_lite/__init__.py +6 -0
- dataface/core/compile/vega_lite/validation.py +95 -0
- dataface/core/compile/yaml_error_formatter.py +838 -0
- dataface/core/connections.py +38 -0
- dataface/core/dashboard.py +358 -0
- dataface/core/defaults/default_config.yml +101 -0
- dataface/core/defaults/palettes/categorical/category-10-dark.yml +32 -0
- dataface/core/defaults/palettes/categorical/category-10-light.yml +43 -0
- dataface/core/defaults/palettes/categorical/category-10.yml +31 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-blue.yml +22 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-brown.yml +29 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-green.yml +20 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-orange.yml +21 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-purple.yml +20 -0
- dataface/core/defaults/palettes/categorical/editorial-10-dark.yml +32 -0
- dataface/core/defaults/palettes/categorical/editorial-10.yml +40 -0
- dataface/core/defaults/palettes/categorical/hero-6.yml +17 -0
- dataface/core/defaults/palettes/categorical/single-blue.yml +11 -0
- dataface/core/defaults/palettes/categorical/tableau.yml +20 -0
- dataface/core/defaults/palettes/data/xkcd_colors.json +3803 -0
- dataface/core/defaults/palettes/diverging/blue-red.yml +25 -0
- dataface/core/defaults/palettes/diverging/coolwarm.yml +24 -0
- dataface/core/defaults/palettes/diverging/crimson-green.yml +23 -0
- dataface/core/defaults/palettes/diverging/orange-teal.yml +23 -0
- dataface/core/defaults/palettes/diverging/sunset.yml +24 -0
- dataface/core/defaults/palettes/scaffold/dft-creams.yml +38 -0
- dataface/core/defaults/palettes/scaffold/dft-grays.yml +53 -0
- dataface/core/defaults/palettes/sequential/amber.yml +22 -0
- dataface/core/defaults/palettes/sequential/blue.yml +22 -0
- dataface/core/defaults/palettes/sequential/brown.yml +22 -0
- dataface/core/defaults/palettes/sequential/gray.yml +22 -0
- dataface/core/defaults/palettes/sequential/green.yml +22 -0
- dataface/core/defaults/palettes/sequential/purple.yml +22 -0
- dataface/core/defaults/palettes/sequential/rust.yml +22 -0
- dataface/core/defaults/palettes/sequential/teal.yml +22 -0
- dataface/core/defaults/palettes/tone/negative.yml +32 -0
- dataface/core/defaults/palettes/tone/positive.yml +22 -0
- dataface/core/defaults/palettes/tone/warning.yml +22 -0
- dataface/core/defaults/themes/_base.yaml +786 -0
- dataface/core/defaults/themes/bi.yaml +16 -0
- dataface/core/defaults/themes/carbong100.yaml +41 -0
- dataface/core/defaults/themes/cream.yaml +122 -0
- dataface/core/defaults/themes/dark.yaml +40 -0
- dataface/core/defaults/themes/diagnostics-title-angle-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-baseline-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-baseline.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-center.yaml +8 -0
- dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +25 -0
- dataface/core/defaults/themes/diagnostics-title-left.yaml +8 -0
- dataface/core/defaults/themes/diagnostics-title-offset-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +24 -0
- dataface/core/defaults/themes/editorial.yaml +147 -0
- dataface/core/defaults/themes/light.yaml +30 -0
- dataface/core/defaults/themes/looker.yaml +17 -0
- dataface/core/defaults/themes/stark.yaml +134 -0
- dataface/core/errors/__init__.py +67 -0
- dataface/core/errors/codes_compile.py +56 -0
- dataface/core/errors/codes_execute.py +177 -0
- dataface/core/errors/codes_render.py +106 -0
- dataface/core/errors/codes_unknown.py +15 -0
- dataface/core/errors/hints.py +74 -0
- dataface/core/errors/registry.py +42 -0
- dataface/core/errors/structured.py +92 -0
- dataface/core/execute/__init__.py +91 -0
- dataface/core/execute/adapters/__init__.py +49 -0
- dataface/core/execute/adapters/adapter_registry.py +400 -0
- dataface/core/execute/adapters/base.py +245 -0
- dataface/core/execute/adapters/csv_adapter.py +239 -0
- dataface/core/execute/adapters/dbt_adapter.py +283 -0
- dataface/core/execute/adapters/dbt_adapter_factory.py +212 -0
- dataface/core/execute/adapters/dbt_macro_loader.py +95 -0
- dataface/core/execute/adapters/dbt_utils.py +150 -0
- dataface/core/execute/adapters/http_adapter.py +224 -0
- dataface/core/execute/adapters/metricflow_adapter.py +94 -0
- dataface/core/execute/adapters/schema_resolver_adapter.py +144 -0
- dataface/core/execute/adapters/sql_adapter.py +710 -0
- dataface/core/execute/adapters/values_adapter.py +58 -0
- dataface/core/execute/batch.py +744 -0
- dataface/core/execute/cache_backend.py +135 -0
- dataface/core/execute/cache_keys.py +66 -0
- dataface/core/execute/dbt_jinja.py +21 -0
- dataface/core/execute/dialects/__init__.py +121 -0
- dataface/core/execute/dialects/athena.py +75 -0
- dataface/core/execute/dialects/base.py +302 -0
- dataface/core/execute/dialects/bigquery.py +38 -0
- dataface/core/execute/dialects/databricks.py +68 -0
- dataface/core/execute/dialects/duckdb.py +35 -0
- dataface/core/execute/dialects/mysql.py +68 -0
- dataface/core/execute/dialects/postgres.py +39 -0
- dataface/core/execute/dialects/redshift.py +12 -0
- dataface/core/execute/dialects/snowflake.py +51 -0
- dataface/core/execute/dialects/sqlserver.py +92 -0
- dataface/core/execute/duckdb_cache.py +712 -0
- dataface/core/execute/duckdb_config.py +26 -0
- dataface/core/execute/errors.py +213 -0
- dataface/core/execute/executor.py +1249 -0
- dataface/core/execute/parallel.py +162 -0
- dataface/core/execute/setup_sql.py +58 -0
- dataface/core/execute/source_registry.py +72 -0
- dataface/core/execute/source_resolver.py +255 -0
- dataface/core/execute/sql_guard.py +387 -0
- dataface/core/execute/sql_literals.py +199 -0
- dataface/core/fonts.py +52 -0
- dataface/core/inspect/__init__.py +32 -0
- dataface/core/inspect/cache_factory.py +98 -0
- dataface/core/inspect/db_types.py +162 -0
- dataface/core/inspect/dbt_schema.py +96 -0
- dataface/core/inspect/defaults.yml +37 -0
- dataface/core/inspect/fanout_risk.py +109 -0
- dataface/core/inspect/manifest_utils.py +77 -0
- dataface/core/inspect/partials/categorical.yml +40 -0
- dataface/core/inspect/partials/date.yml +40 -0
- dataface/core/inspect/partials/numeric.yml +55 -0
- dataface/core/inspect/partition_types.py +38 -0
- dataface/core/inspect/query_validator.py +975 -0
- dataface/core/inspect/renderer.py +354 -0
- dataface/core/inspect/resolver.py +808 -0
- dataface/core/inspect/search.py +461 -0
- dataface/core/inspect/sources/__init__.py +32 -0
- dataface/core/inspect/sources/dbt.py +738 -0
- dataface/core/inspect/sources/duckdb_utils.py +66 -0
- dataface/core/inspect/templates/__init__.py +1 -0
- dataface/core/inspect/templates/categorical_column.yml +196 -0
- dataface/core/inspect/templates/charts.yml +109 -0
- dataface/core/inspect/templates/date_column.yml +248 -0
- dataface/core/inspect/templates/model.yml +138 -0
- dataface/core/inspect/templates/numeric_column.yml +261 -0
- dataface/core/inspect/templates/quality.yml +80 -0
- dataface/core/inspect/templates/string_column.yml +263 -0
- dataface/core/project_roots.py +165 -0
- dataface/core/render/__init__.py +87 -0
- dataface/core/render/board_links.py +176 -0
- dataface/core/render/chart/__init__.py +27 -0
- dataface/core/render/chart/arc_attached_table.py +251 -0
- dataface/core/render/chart/artifacts.py +16 -0
- dataface/core/render/chart/callout.py +225 -0
- dataface/core/render/chart/decisions.py +358 -0
- dataface/core/render/chart/geo.py +700 -0
- dataface/core/render/chart/kpi.py +916 -0
- dataface/core/render/chart/labels.py +76 -0
- dataface/core/render/chart/pipeline.py +818 -0
- dataface/core/render/chart/presentation.py +36 -0
- dataface/core/render/chart/profile.py +3438 -0
- dataface/core/render/chart/render_single.py +347 -0
- dataface/core/render/chart/renderers.py +193 -0
- dataface/core/render/chart/rendering.py +565 -0
- dataface/core/render/chart/serialization.py +90 -0
- dataface/core/render/chart/spark.py +496 -0
- dataface/core/render/chart/spark_bar.py +370 -0
- dataface/core/render/chart/spec_builders.py +154 -0
- dataface/core/render/chart/standard_renderer.py +2645 -0
- dataface/core/render/chart/table.py +2957 -0
- dataface/core/render/chart/table_support.py +1452 -0
- dataface/core/render/chart/tick_values.py +66 -0
- dataface/core/render/chart/time_unit_detect.py +809 -0
- dataface/core/render/chart/title_overflow.py +157 -0
- dataface/core/render/chart/type_inference.py +122 -0
- dataface/core/render/chart/validation.py +99 -0
- dataface/core/render/chart/vega_lite.py +125 -0
- dataface/core/render/chart/vega_lite_types.py +268 -0
- dataface/core/render/chart/vl_field_maps.py +346 -0
- dataface/core/render/chart_interactivity.py +24 -0
- dataface/core/render/control_registry.py +287 -0
- dataface/core/render/converters/__init__.py +24 -0
- dataface/core/render/converters/chart.py +276 -0
- dataface/core/render/converters/html.py +98 -0
- dataface/core/render/converters/pdf.py +40 -0
- dataface/core/render/converters/png.py +41 -0
- dataface/core/render/errors.py +144 -0
- dataface/core/render/face_api.py +160 -0
- dataface/core/render/faces.py +1194 -0
- dataface/core/render/font_measurement.py +48 -0
- dataface/core/render/font_support.py +197 -0
- dataface/core/render/fonts/DFTSansTabular-Regular.ttf +0 -0
- dataface/core/render/fonts/DFTSansTabular-Regular.woff2 +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.ttf +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.ttf +0 -0
- dataface/core/render/fonts/InterVariable.ttf +0 -0
- dataface/core/render/fonts/InterVariable.woff2 +0 -0
- dataface/core/render/fonts/NOTO_COLOR_EMOJI_LICENSE.txt +93 -0
- dataface/core/render/fonts/NOTO_EMOJI_LICENSE.txt +93 -0
- dataface/core/render/fonts/NotoColorEmoji-Regular.ttf +0 -0
- dataface/core/render/fonts/NotoColorEmoji-Regular.woff2 +0 -0
- dataface/core/render/fonts/NotoEmoji-Regular.ttf +0 -0
- dataface/core/render/fonts/NotoEmoji-Regular.woff2 +0 -0
- dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -0
- dataface/core/render/fonts/SOURCE_SERIF_4_LICENSE.txt +98 -0
- dataface/core/render/fonts/SourceCodePro-Regular.ttf +0 -0
- dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
- dataface/core/render/fonts/_emoji_font_face.css +43 -0
- dataface/core/render/fonts/source-serif-4-variable-latin.woff2 +0 -0
- dataface/core/render/format_utils.py +329 -0
- dataface/core/render/geo_defaults.yml +28 -0
- dataface/core/render/json_format.py +146 -0
- dataface/core/render/layout_sizing.py +865 -0
- dataface/core/render/layouts.py +541 -0
- dataface/core/render/markdown_defaults.yml +16 -0
- dataface/core/render/missing_vars_prompt.py +79 -0
- dataface/core/render/placeholder.py +389 -0
- dataface/core/render/render_result.py +14 -0
- dataface/core/render/renderer.py +467 -0
- dataface/core/render/script_embedding.py +16 -0
- dataface/core/render/svg_utils.py +212 -0
- dataface/core/render/template_loader.py +69 -0
- dataface/core/render/templates/controls/_styles.css +606 -0
- dataface/core/render/templates/controls/checkbox.html +16 -0
- dataface/core/render/templates/controls/date.html +16 -0
- dataface/core/render/templates/controls/number.html +19 -0
- dataface/core/render/templates/controls/readonly.html +9 -0
- dataface/core/render/templates/controls/select.html +21 -0
- dataface/core/render/templates/controls/slider.html +22 -0
- dataface/core/render/templates/controls/text.html +16 -0
- dataface/core/render/templates/scripts/chart_interactivity.js +191 -0
- dataface/core/render/templates/scripts/variables.js +976 -0
- dataface/core/render/templates/svg/grid_pattern.svg +3 -0
- dataface/core/render/templates/svg/styles.css +51 -0
- dataface/core/render/terminal.py +311 -0
- dataface/core/render/terminal_charts.py +563 -0
- dataface/core/render/terminal_defaults.yml +2 -0
- dataface/core/render/terminal_layouts.py +299 -0
- dataface/core/render/terminal_text.py +31 -0
- dataface/core/render/text/__init__.py +1 -0
- dataface/core/render/text/case.py +113 -0
- dataface/core/render/text_format.py +129 -0
- dataface/core/render/utils.py +106 -0
- dataface/core/render/variable_controls.py +946 -0
- dataface/core/render/variable_input_refinement.py +140 -0
- dataface/core/render/warnings/__init__.py +15 -0
- dataface/core/render/warnings/bar_color_1_to_1_with_x.py +80 -0
- dataface/core/render/warnings/base.py +44 -0
- dataface/core/render/warnings/fanout_risk.py +15 -0
- dataface/core/render/warnings/from_query_diagnostic.py +56 -0
- dataface/core/render/warnings/missing_join_predicate.py +13 -0
- dataface/core/render/warnings/query_parse_error.py +14 -0
- dataface/core/render/warnings/query_returned_zero_rows.py +42 -0
- dataface/core/render/warnings/reaggregation.py +14 -0
- dataface/core/render/warnings/registry.py +45 -0
- dataface/core/render/warnings/suppression.py +46 -0
- dataface/core/render/warnings/temporal_single_point.py +63 -0
- dataface/core/render/warnings/unreferenced_chart.py +15 -0
- dataface/core/render/warnings/y_encoding_mostly_null.py +76 -0
- dataface/core/render/yaml_format.py +167 -0
- dataface/core/resolve_face.py +195 -0
- dataface/core/schema/__init__.py +0 -0
- dataface/core/schema/guidance.py +151 -0
- dataface/core/scoped_paths.py +59 -0
- dataface/core/serve/__init__.py +14 -0
- dataface/core/serve/bootstrap.py +39 -0
- dataface/core/serve/embedded.py +57 -0
- dataface/core/serve/port.py +129 -0
- dataface/core/serve/server.py +938 -0
- dataface/core/serve/templates/__init__.py +0 -0
- dataface/core/serve/templates/directory.yml +6 -0
- dataface/core/serve/templates/error.html.j2 +217 -0
- dataface/core/utils.py +121 -0
- dataface/core/validate.py +64 -0
- dataface/integrations/__init__.py +0 -0
- dataface/integrations/highlighting.py +351 -0
- dataface/integrations/markdown.py +537 -0
- dataface/py.typed +0 -0
- dataface-0.1.2.dist-info/METADATA +375 -0
- dataface-0.1.2.dist-info/RECORD +455 -0
- dataface-0.1.2.dist-info/WHEEL +4 -0
- dataface-0.1.2.dist-info/entry_points.txt +2 -0
- dataface-0.1.2.dist-info/licenses/LICENSE +202 -0
- mdsvg/__init__.py +168 -0
- mdsvg/fonts.py +656 -0
- mdsvg/images.py +299 -0
- mdsvg/parser.py +629 -0
- mdsvg/playground.py +284 -0
- mdsvg/py.typed +2 -0
- mdsvg/renderer.py +1623 -0
- mdsvg/style.py +355 -0
- mdsvg/types.py +200 -0
- mdsvg/utils.py +86 -0
|
@@ -0,0 +1,2126 @@
|
|
|
1
|
+
"""Layout sizing calculation module.
|
|
2
|
+
|
|
3
|
+
Purpose: Calculate dimensions for layout items using static/compile-time defaults.
|
|
4
|
+
|
|
5
|
+
Entry Points:
|
|
6
|
+
- calculate_layout(face) -> Face
|
|
7
|
+
Compile-time sizing — uses aspect ratios and config defaults for all heights.
|
|
8
|
+
|
|
9
|
+
Data-aware sizing (render-first Vega sizing, table row counts) lives in
|
|
10
|
+
render/layout_sizing.py and injects a HeightProvider callback into these
|
|
11
|
+
algorithms at render time.
|
|
12
|
+
|
|
13
|
+
Inputs:
|
|
14
|
+
- Face with layout but no dimensions
|
|
15
|
+
|
|
16
|
+
Outputs:
|
|
17
|
+
- Face with calculated width/height on all layout items
|
|
18
|
+
|
|
19
|
+
Key Principles:
|
|
20
|
+
1. Height is determined by layout AND content type
|
|
21
|
+
- KPIs get smaller heights (100px default)
|
|
22
|
+
- Charts get standard heights (300px default)
|
|
23
|
+
- Titles/content heights are estimated based on text/font
|
|
24
|
+
- In cols: all items have the SAME height (max of required heights)
|
|
25
|
+
- In rows: each item gets its content-appropriate height
|
|
26
|
+
|
|
27
|
+
2. Width is determined by layout type
|
|
28
|
+
- In cols: width is divided among items equally (by default)
|
|
29
|
+
- In rows: all items get full width
|
|
30
|
+
|
|
31
|
+
3. Padding philosophy
|
|
32
|
+
- Charts have internal padding (handled by Vega-Lite config)
|
|
33
|
+
- Boards have NO padding unless explicitly set in style
|
|
34
|
+
- Root dashboard has page padding (handled in renderer)
|
|
35
|
+
|
|
36
|
+
4. Nested layouts follow these rules recursively
|
|
37
|
+
- A nested rows layout inside a cols item gets the full height of that item
|
|
38
|
+
- Items within that nested layout then divide that height
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
cols:
|
|
42
|
+
- chart1 # Gets 50% width, 100% height
|
|
43
|
+
- rows: # Gets 50% width, 100% height
|
|
44
|
+
- chart2 # Gets 100% width of parent, 50% of parent height
|
|
45
|
+
- chart3 # Gets 100% width of parent, 50% of parent height
|
|
46
|
+
|
|
47
|
+
Result with 800x400 container:
|
|
48
|
+
┌────────────────────┬────────────────────┐
|
|
49
|
+
│ │ chart2 │
|
|
50
|
+
│ chart1 │ 400x200px │
|
|
51
|
+
│ 400x400px ├────────────────────┤
|
|
52
|
+
│ │ chart3 │
|
|
53
|
+
│ │ 400x200px │
|
|
54
|
+
└────────────────────┴────────────────────┘
|
|
55
|
+
|
|
56
|
+
Dependencies:
|
|
57
|
+
- .models.face.compiled (Face, Layout, LayoutItem)
|
|
58
|
+
- .config (CompileConfig)
|
|
59
|
+
|
|
60
|
+
See also:
|
|
61
|
+
- compile/normalizer.py: Previous step
|
|
62
|
+
- compile/compiler.py: Orchestrates all steps
|
|
63
|
+
|
|
64
|
+
Cross-module API:
|
|
65
|
+
render/layout_sizing.py imports the following functions as part of the
|
|
66
|
+
HeightProvider injection pattern. They are sizing algorithm internals
|
|
67
|
+
intentionally exported for use by the data-aware render-time sizing pass:
|
|
68
|
+
calculate_layout_height, calculate_layout_items,
|
|
69
|
+
get_item_content_height
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
from __future__ import annotations
|
|
73
|
+
|
|
74
|
+
import functools
|
|
75
|
+
import re
|
|
76
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol
|
|
77
|
+
|
|
78
|
+
from PIL import ImageFont
|
|
79
|
+
|
|
80
|
+
if TYPE_CHECKING:
|
|
81
|
+
from dataface.core.compile.models.face.compiled import ResolvedLayoutItem
|
|
82
|
+
from dataface.core.compile.models.style.compiled import TextStyle
|
|
83
|
+
|
|
84
|
+
from dataface.core.compile.colors import is_dark_color
|
|
85
|
+
from dataface.core.compile.config import get_chart_rendering, get_config
|
|
86
|
+
from dataface.core.compile.jinja import resolve_jinja_template
|
|
87
|
+
from dataface.core.compile.models.chart.compiled import (
|
|
88
|
+
Chart,
|
|
89
|
+
)
|
|
90
|
+
from dataface.core.compile.models.face.compiled import (
|
|
91
|
+
Face,
|
|
92
|
+
Layout,
|
|
93
|
+
LayoutItem,
|
|
94
|
+
)
|
|
95
|
+
from dataface.core.compile.models.style.compiled import (
|
|
96
|
+
VariablesStyle,
|
|
97
|
+
compute_text_column_count,
|
|
98
|
+
)
|
|
99
|
+
from dataface.core.compile.models.style.merged import (
|
|
100
|
+
MergedStyle,
|
|
101
|
+
apply_emoji_to_family,
|
|
102
|
+
effective_padding as _effective_padding,
|
|
103
|
+
resolve_style,
|
|
104
|
+
)
|
|
105
|
+
from dataface.core.fonts import get_inter_font_path
|
|
106
|
+
|
|
107
|
+
# ============================================================================
|
|
108
|
+
# CONTENT-AWARE HEIGHT DEFAULTS
|
|
109
|
+
# ============================================================================
|
|
110
|
+
|
|
111
|
+
# Default heights for different content types
|
|
112
|
+
# WHY: compile-time static fallback used when no width for aspect-ratio sizing
|
|
113
|
+
# and no specific chart type match; real sizes come from width-driven aspect-ratio path.
|
|
114
|
+
DEFAULT_CHART_HEIGHT = 300.0
|
|
115
|
+
# WHY: compile-time placeholder only — replaced by data-aware row-count sizing at
|
|
116
|
+
# render time via HeightProvider; no cascade equivalent exists because table height
|
|
117
|
+
# is always data-driven.
|
|
118
|
+
DEFAULT_TABLE_HEIGHT = 250.0
|
|
119
|
+
# WHY: engine-intrinsic display floor — every layout item must be at least this tall
|
|
120
|
+
# to remain navigable. Not a theme value; it is a display-correctness invariant.
|
|
121
|
+
MIN_CONTENT_HEIGHT = 60.0
|
|
122
|
+
|
|
123
|
+
# Bottom breathing room under the title-inline header band before the first
|
|
124
|
+
# row of layout content. The band's natural height (max of title-block vs
|
|
125
|
+
# variable-controls heights) butts directly against the first card row, and
|
|
126
|
+
# the theme's effective inter-section gap (face.layout.gap, ~8px default) is
|
|
127
|
+
# too tight for a face title to read as a header rather than a column label.
|
|
128
|
+
# 24 px lands between the "8 px + theme gap reads tight" and "32 px reads
|
|
129
|
+
# disconnected" endpoints picked in the design exploration.
|
|
130
|
+
TITLE_INLINE_BAND_BOTTOM_PAD = 24.0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ============================================================================
|
|
134
|
+
# HEIGHT PROVIDER PROTOCOL
|
|
135
|
+
# ============================================================================
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class HeightProvider(Protocol):
|
|
139
|
+
"""Callback that returns the content height for a layout item.
|
|
140
|
+
|
|
141
|
+
The compile-time path uses the built-in get_item_content_height (static,
|
|
142
|
+
aspect-ratio-based). The render-time path injects a data-aware provider
|
|
143
|
+
from render/layout_sizing.py that renders Vega charts and queries data.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __call__(
|
|
147
|
+
self,
|
|
148
|
+
item: LayoutItem,
|
|
149
|
+
card_gap: float,
|
|
150
|
+
gap: float,
|
|
151
|
+
width: float,
|
|
152
|
+
variable_defaults: dict[str, Any] | None,
|
|
153
|
+
) -> float: ...
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _resolve_height(
|
|
157
|
+
item: LayoutItem,
|
|
158
|
+
card_gap: float,
|
|
159
|
+
gap: float,
|
|
160
|
+
width: float,
|
|
161
|
+
variable_defaults: dict[str, Any] | None,
|
|
162
|
+
height_provider: HeightProvider | None,
|
|
163
|
+
resolved_style: MergedStyle,
|
|
164
|
+
) -> float:
|
|
165
|
+
"""Dispatch to height_provider if set, else use static get_item_content_height."""
|
|
166
|
+
if height_provider is not None:
|
|
167
|
+
return height_provider(item, card_gap, gap, width, variable_defaults)
|
|
168
|
+
return get_item_content_height(
|
|
169
|
+
item,
|
|
170
|
+
card_gap,
|
|
171
|
+
gap,
|
|
172
|
+
width,
|
|
173
|
+
variable_defaults,
|
|
174
|
+
height_provider=None,
|
|
175
|
+
resolved_style=resolved_style,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _get_root_content_width(config: Any) -> float:
|
|
180
|
+
"""Return the root face content width after page padding is removed."""
|
|
181
|
+
return max(
|
|
182
|
+
float(config.style.board.width) - (2 * float(config.style.board.margin)), 0.0
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_chart_content_height(
|
|
187
|
+
chart: Chart | None,
|
|
188
|
+
*,
|
|
189
|
+
width: float | None = None,
|
|
190
|
+
resolved_style: MergedStyle,
|
|
191
|
+
) -> float:
|
|
192
|
+
"""Get the appropriate content height for a chart based on its type.
|
|
193
|
+
|
|
194
|
+
Resolution order for plot-style charts (bar, line, area, scatter, …):
|
|
195
|
+
1. chart.height — explicit chart-root override; returned as-is (no clamping).
|
|
196
|
+
2. chart.aspect_ratio — chart-root fallback; height = width / aspect_ratio,
|
|
197
|
+
clamped to theme min_height / max_height.
|
|
198
|
+
3. Per-family theme aspect_ratio (e.g. style.charts.bar.aspect_ratio).
|
|
199
|
+
4. Global theme aspect_ratio (style.charts.aspect_ratio).
|
|
200
|
+
5. DEFAULT_CHART_HEIGHT when no width is available.
|
|
201
|
+
|
|
202
|
+
SVG-family renderers own their own sizing contract:
|
|
203
|
+
- kpi → resolved_style.charts.kpi.default_height (theme cascade)
|
|
204
|
+
- table → DEFAULT_TABLE_HEIGHT (replaced by row-count sizing at render)
|
|
205
|
+
- callout → MIN_CONTENT_HEIGHT (replaced by natural render-first sizing)
|
|
206
|
+
- spark_bar → config-derived upper bound from max_bars/bar.height
|
|
207
|
+
|
|
208
|
+
This is the compile-time (static) height calculator. It uses aspect ratios
|
|
209
|
+
and config defaults only — no executor, no data, no rendering.
|
|
210
|
+
Layout slot height (``LayoutItem.layout_height``) wins at render time when
|
|
211
|
+
authored; the render-first pass in ``render/layout_sizing.py`` uses this
|
|
212
|
+
value as a fallback when no explicit slot height is declared.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
chart: The compiled chart to get height for
|
|
216
|
+
width: Available width in pixels; enables aspect-ratio-driven sizing
|
|
217
|
+
resolved_style: Face-resolved style; KPI height reads from
|
|
218
|
+
resolved_style.charts.kpi.default_height.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Appropriate height in pixels for this chart type
|
|
222
|
+
"""
|
|
223
|
+
if chart is None:
|
|
224
|
+
return DEFAULT_CHART_HEIGHT
|
|
225
|
+
|
|
226
|
+
chart_type = chart.type
|
|
227
|
+
if chart_type == "kpi":
|
|
228
|
+
return float(resolved_style.charts.kpi.default_height)
|
|
229
|
+
elif chart_type == "table":
|
|
230
|
+
return DEFAULT_TABLE_HEIGHT
|
|
231
|
+
elif chart_type == "callout":
|
|
232
|
+
# Render-first natural sizing overrides this at render time.
|
|
233
|
+
return MIN_CONTENT_HEIGHT
|
|
234
|
+
elif chart_type == "spark_bar":
|
|
235
|
+
# Compile-time upper bound using max_bars. Render-first data-aware sizing
|
|
236
|
+
# in layout_sizing.py overrides this with the actual natural height.
|
|
237
|
+
# Includes title_height when chart.title is set to match the renderer's
|
|
238
|
+
# body + title addition (spark_bar.py:265-269).
|
|
239
|
+
sb = get_config().style.charts.spark_bar
|
|
240
|
+
body = sb.max_bars * (sb.bar.height + sb.bar.padding) + sb.bar.padding
|
|
241
|
+
return (
|
|
242
|
+
body + get_chart_rendering().spark_bar.title_height if chart.title else body
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Step 1: explicit chart-root height wins everything (no clamping).
|
|
246
|
+
if chart.height is not None:
|
|
247
|
+
return float(chart.height)
|
|
248
|
+
|
|
249
|
+
# Steps 2–4: aspect-ratio-driven sizing for plot-style charts.
|
|
250
|
+
if width is not None and width > 0:
|
|
251
|
+
config = get_config()
|
|
252
|
+
# Resolve clamps: start from face/theme resolved_style, override with
|
|
253
|
+
# chart-root min_height / max_height when explicitly set by the author.
|
|
254
|
+
min_h = (
|
|
255
|
+
float(chart.min_height)
|
|
256
|
+
if chart.min_height is not None
|
|
257
|
+
else float(resolved_style.charts.min_height)
|
|
258
|
+
)
|
|
259
|
+
max_h = (
|
|
260
|
+
float(chart.max_height)
|
|
261
|
+
if chart.max_height is not None
|
|
262
|
+
else float(resolved_style.charts.max_height)
|
|
263
|
+
)
|
|
264
|
+
# Step 2: chart-root aspect_ratio (validated gt=0 at compile time).
|
|
265
|
+
if chart.aspect_ratio is not None:
|
|
266
|
+
h = width / chart.aspect_ratio
|
|
267
|
+
return max(min_h, min(max_h, h))
|
|
268
|
+
# Steps 3–4: per-family theme aspect_ratio, then global theme default.
|
|
269
|
+
style_key = chart_type
|
|
270
|
+
type_config = getattr(config.style.charts, style_key, None)
|
|
271
|
+
aspect = (
|
|
272
|
+
float(type_config.aspect_ratio)
|
|
273
|
+
if type_config
|
|
274
|
+
and hasattr(type_config, "aspect_ratio")
|
|
275
|
+
and type_config.aspect_ratio
|
|
276
|
+
else float(config.style.charts.aspect_ratio)
|
|
277
|
+
)
|
|
278
|
+
if aspect > 0:
|
|
279
|
+
h = width / aspect
|
|
280
|
+
return max(min_h, min(max_h, h))
|
|
281
|
+
|
|
282
|
+
return DEFAULT_CHART_HEIGHT
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def is_item_collapsed_summary(
|
|
286
|
+
item: LayoutItem,
|
|
287
|
+
variable_defaults: dict[str, Any] | None,
|
|
288
|
+
) -> bool:
|
|
289
|
+
return bool(
|
|
290
|
+
item.details_variable
|
|
291
|
+
and item.details_summary
|
|
292
|
+
and not _is_details_item_expanded(item, variable_defaults)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _is_details_item_expanded(
|
|
297
|
+
item: LayoutItem, variables: dict[str, Any] | None
|
|
298
|
+
) -> bool:
|
|
299
|
+
"""Check if a details item is expanded based on variable value or default."""
|
|
300
|
+
if variables and item.details_variable in variables:
|
|
301
|
+
return str(variables[item.details_variable]).lower() == "true"
|
|
302
|
+
# Fall back to default from face meta
|
|
303
|
+
if item.face and item.face.meta:
|
|
304
|
+
return bool(item.face.meta.get("details_expanded_default", False))
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def details_chrome_height(
|
|
309
|
+
item: LayoutItem | ResolvedLayoutItem, gap: float, card_gap: float
|
|
310
|
+
) -> float:
|
|
311
|
+
"""Vertical overhead the details chrome adds above the nested face content.
|
|
312
|
+
|
|
313
|
+
Returns summary_height + gap + card_gap for details items, 0 otherwise.
|
|
314
|
+
Both the sizing pass and render pass must use this so they stay in sync.
|
|
315
|
+
"""
|
|
316
|
+
if not (item.details_variable and item.details_summary):
|
|
317
|
+
return 0.0
|
|
318
|
+
return float(get_config().style.layout.details.summary_height) + gap + card_gap
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get_compact_style(
|
|
322
|
+
merged_style: MergedStyle | None = None,
|
|
323
|
+
text_align: Literal["left", "center", "right"] = "left",
|
|
324
|
+
heading_font_weight: int | str | None = None,
|
|
325
|
+
font_family: str | None = None,
|
|
326
|
+
h1_size: float | None = None,
|
|
327
|
+
) -> Any:
|
|
328
|
+
"""Get a compact mdsvg Style with reduced heading margins.
|
|
329
|
+
|
|
330
|
+
The default mdsvg style has large heading margins (1.5em top, 0.5em bottom)
|
|
331
|
+
which produces ~2x more vertical space than HTML. This compact style
|
|
332
|
+
reduces margins for tighter dashboard/UI layouts.
|
|
333
|
+
|
|
334
|
+
Heading sizes (h1–h6) are pinned to absolute pixel values from
|
|
335
|
+
``style.title.sizes`` rather than computed as multiples of the body font
|
|
336
|
+
size. Heading weight defaults to ``style.title.font.weight``; heading
|
|
337
|
+
color comes from the markdown color set or ``style.title.font.color``.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
merged_style: Optional MergedStyle for color resolution. Falls back to
|
|
341
|
+
the global config default when None.
|
|
342
|
+
heading_font_weight: Override the heading font weight. Face titles pass
|
|
343
|
+
the tier-specific weight (600 narrow, 500 medium/wide).
|
|
344
|
+
font_family: Override the body font family. Pass
|
|
345
|
+
``config.style.title.font.family`` to render prose in serif.
|
|
346
|
+
h1_size: Override the h1 pixel size. Face titles pass the
|
|
347
|
+
width-resolved pixel size from ``face_title_spec`` so the rendered
|
|
348
|
+
h1 lands exactly on the responsive target. When ``None``, h1 uses
|
|
349
|
+
``style.title.sizes[0]`` like the other levels.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
mdsvg Style object with compact margins and theme-appropriate colors
|
|
353
|
+
"""
|
|
354
|
+
from dataface.core.compile.typography import _coerce_weight
|
|
355
|
+
from mdsvg import Style
|
|
356
|
+
|
|
357
|
+
config = get_config()
|
|
358
|
+
_ms = merged_style or resolve_style(config.style)
|
|
359
|
+
markdown_colors = (
|
|
360
|
+
config.markdown.dark if is_dark_color(_ms.background) else config.markdown.light
|
|
361
|
+
)
|
|
362
|
+
_text_font_size = config.style.text.font.size
|
|
363
|
+
assert _text_font_size is not None, "style.text.font.size must be configured"
|
|
364
|
+
|
|
365
|
+
_root_family = config.style.font.family
|
|
366
|
+
assert _root_family is not None, "style.font.family must be configured"
|
|
367
|
+
_default_family = apply_emoji_to_family(_root_family, config.style.font.emoji)
|
|
368
|
+
|
|
369
|
+
title_sizes = config.style.title.sizes
|
|
370
|
+
if len(title_sizes) != 6:
|
|
371
|
+
raise ValueError(
|
|
372
|
+
f"style.title.sizes must have 6 entries (H1-H6); got {len(title_sizes)}"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
effective_weight = (
|
|
376
|
+
heading_font_weight
|
|
377
|
+
if heading_font_weight is not None
|
|
378
|
+
else _coerce_weight(config.style.title.font.weight or 500)
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
return Style(
|
|
382
|
+
font_family=font_family if font_family is not None else _default_family,
|
|
383
|
+
base_font_size=float(_text_font_size),
|
|
384
|
+
line_height=float(config.style.text.line_height),
|
|
385
|
+
heading_margin_top=0.3,
|
|
386
|
+
heading_margin_bottom=0.2,
|
|
387
|
+
paragraph_spacing=8.0,
|
|
388
|
+
text_color=getattr(markdown_colors, "text_color", _ms.font.color),
|
|
389
|
+
heading_color=(
|
|
390
|
+
getattr(markdown_colors, "heading_color", None) or _ms.title.font.color
|
|
391
|
+
),
|
|
392
|
+
link_color=markdown_colors.link_color,
|
|
393
|
+
code_color=markdown_colors.code_color,
|
|
394
|
+
code_background=markdown_colors.code_background,
|
|
395
|
+
blockquote_color=markdown_colors.blockquote_color,
|
|
396
|
+
text_align=text_align,
|
|
397
|
+
# Top-align letterboxed markdown images (default xMidYMid centers them).
|
|
398
|
+
image_preserve_aspect_ratio="xMidYMin meet",
|
|
399
|
+
h1_size=float(h1_size) if h1_size is not None else float(title_sizes[0]),
|
|
400
|
+
h2_size=float(title_sizes[1]),
|
|
401
|
+
h3_size=float(title_sizes[2]),
|
|
402
|
+
h4_size=float(title_sizes[3]),
|
|
403
|
+
h5_size=float(title_sizes[4]),
|
|
404
|
+
h6_size=float(title_sizes[5]),
|
|
405
|
+
heading_font_weight=effective_weight,
|
|
406
|
+
heading_line_height=float(config.style.title.line_height),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def greedy_column_fill(
|
|
411
|
+
block_heights: list[float],
|
|
412
|
+
n_cols: int,
|
|
413
|
+
para_gap: float,
|
|
414
|
+
) -> tuple[list[tuple[int, float]], float]:
|
|
415
|
+
"""Assign blocks to columns using a greedy balanced fill.
|
|
416
|
+
|
|
417
|
+
Returns (assignments, actual_col_height) where assignments[i] = (col_idx, y_within_col).
|
|
418
|
+
Never breaks from an empty column (current_y > 0 guard).
|
|
419
|
+
"""
|
|
420
|
+
if not block_heights:
|
|
421
|
+
return [], 0.0
|
|
422
|
+
total_content = sum(block_heights)
|
|
423
|
+
total_gaps = para_gap * (len(block_heights) - 1) if len(block_heights) > 1 else 0
|
|
424
|
+
target_col_height = (total_content + total_gaps) / n_cols
|
|
425
|
+
|
|
426
|
+
assignments: list[tuple[int, float]] = []
|
|
427
|
+
col_max_y = [0.0] * n_cols
|
|
428
|
+
current_col = 0
|
|
429
|
+
current_y = 0.0
|
|
430
|
+
for i, bh in enumerate(block_heights):
|
|
431
|
+
gap_before = para_gap if i > 0 and current_y > 0 else 0.0
|
|
432
|
+
if (
|
|
433
|
+
current_y > 0
|
|
434
|
+
and current_y + gap_before + bh > target_col_height
|
|
435
|
+
and current_col < n_cols - 1
|
|
436
|
+
):
|
|
437
|
+
current_col += 1
|
|
438
|
+
current_y = 0.0
|
|
439
|
+
gap_before = 0.0
|
|
440
|
+
y_in_col = current_y + gap_before
|
|
441
|
+
assignments.append((current_col, y_in_col))
|
|
442
|
+
col_max_y[current_col] = max(col_max_y[current_col], y_in_col + bh)
|
|
443
|
+
current_y = y_in_col + bh
|
|
444
|
+
|
|
445
|
+
return assignments, max(col_max_y)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def columned_text_height_estimate(
|
|
449
|
+
resolved_text: str,
|
|
450
|
+
width: float,
|
|
451
|
+
text_style: TextStyle,
|
|
452
|
+
style: Any,
|
|
453
|
+
font_path: str,
|
|
454
|
+
) -> float:
|
|
455
|
+
"""Compute multi-column body-text height using the same greedy block-fill algorithm as the renderer."""
|
|
456
|
+
from dataface.core.fonts import get_mono_font_path
|
|
457
|
+
from mdsvg import measure as measure_markdown, parse as parse_markdown
|
|
458
|
+
from mdsvg.renderer import SVGRenderer
|
|
459
|
+
|
|
460
|
+
mono_font_path = str(get_mono_font_path())
|
|
461
|
+
|
|
462
|
+
col = text_style.column
|
|
463
|
+
column_gap = (
|
|
464
|
+
col.gap if col.gap is not None else float(get_config().style.layout.rows.gap)
|
|
465
|
+
)
|
|
466
|
+
n_cols = compute_text_column_count(col, width, column_gap)
|
|
467
|
+
|
|
468
|
+
if n_cols <= 1:
|
|
469
|
+
size = measure_markdown(
|
|
470
|
+
resolved_text,
|
|
471
|
+
width=width,
|
|
472
|
+
padding=0.0,
|
|
473
|
+
style=style,
|
|
474
|
+
font_path=font_path,
|
|
475
|
+
mono_font_path=mono_font_path,
|
|
476
|
+
)
|
|
477
|
+
return size.height
|
|
478
|
+
|
|
479
|
+
per_col_width = max(
|
|
480
|
+
(width - (n_cols - 1) * column_gap) / n_cols,
|
|
481
|
+
1.0,
|
|
482
|
+
)
|
|
483
|
+
renderer = SVGRenderer(
|
|
484
|
+
style=style,
|
|
485
|
+
font_path=font_path,
|
|
486
|
+
mono_font_path=mono_font_path,
|
|
487
|
+
)
|
|
488
|
+
blocks = list(parse_markdown(resolved_text))
|
|
489
|
+
block_heights = [
|
|
490
|
+
renderer.measure([block], width=per_col_width, padding=0.0).height
|
|
491
|
+
for block in blocks
|
|
492
|
+
]
|
|
493
|
+
_, actual_col_height = greedy_column_fill(
|
|
494
|
+
block_heights, n_cols, style.paragraph_spacing
|
|
495
|
+
)
|
|
496
|
+
return actual_col_height
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def get_markdown_text_height(
|
|
500
|
+
text: str | None,
|
|
501
|
+
width: float,
|
|
502
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
503
|
+
*,
|
|
504
|
+
text_style: TextStyle | None = None,
|
|
505
|
+
) -> float:
|
|
506
|
+
"""Get the actual height needed for markdown content by measuring it.
|
|
507
|
+
|
|
508
|
+
Uses mdsvg's measure() to calculate height, which accounts for
|
|
509
|
+
word-wrapping and all markdown features.
|
|
510
|
+
|
|
511
|
+
When text_style specifies multi-column layout (column.number > 1 or
|
|
512
|
+
column.width), delegates to columned_text_height_estimate for an accurate
|
|
513
|
+
per-column-width measurement.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
text: The markdown text
|
|
517
|
+
width: Available width for the text
|
|
518
|
+
variable_defaults: Optional variable defaults for Jinja resolution
|
|
519
|
+
text_style: Optional face body-text style for multi-column layout
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
Actual height in pixels for the rendered content
|
|
523
|
+
"""
|
|
524
|
+
if not text:
|
|
525
|
+
return 0.0
|
|
526
|
+
|
|
527
|
+
from dataface.core.compile.jinja import resolve_jinja_template
|
|
528
|
+
from dataface.core.fonts import get_inter_font_path, get_mono_font_path
|
|
529
|
+
from mdsvg import measure as measure_markdown
|
|
530
|
+
|
|
531
|
+
resolved_content = resolve_jinja_template(
|
|
532
|
+
text, variable_defaults or {}, strict=False
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
font_path = str(get_inter_font_path())
|
|
536
|
+
style = get_compact_style(resolve_style(get_config().style))
|
|
537
|
+
|
|
538
|
+
if text_style is not None and text_style.column.has_overrides:
|
|
539
|
+
return columned_text_height_estimate(
|
|
540
|
+
resolved_content, width, text_style, style, font_path
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Measure the markdown to get actual dimensions (using compact style).
|
|
544
|
+
# text_align is intentionally omitted: mdsvg measure() only computes
|
|
545
|
+
# line-wrapped height, which is independent of horizontal alignment.
|
|
546
|
+
size = measure_markdown(
|
|
547
|
+
resolved_content,
|
|
548
|
+
width=width,
|
|
549
|
+
padding=0.0,
|
|
550
|
+
style=style,
|
|
551
|
+
font_path=font_path,
|
|
552
|
+
mono_font_path=str(get_mono_font_path()),
|
|
553
|
+
)
|
|
554
|
+
return size.height
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def get_title_height(
|
|
558
|
+
title: str | None,
|
|
559
|
+
width: float,
|
|
560
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
561
|
+
level: int = 1,
|
|
562
|
+
) -> float:
|
|
563
|
+
"""Get the actual height needed for a title by measuring it.
|
|
564
|
+
|
|
565
|
+
Uses mdsvg's measure() to calculate height for the title rendered
|
|
566
|
+
as a markdown heading, accounting for word-wrapping and font sizing.
|
|
567
|
+
Font size is determined by card pixel width (width-based tier).
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
title: The title text
|
|
571
|
+
width: Available width for the title (drives tier selection)
|
|
572
|
+
variable_defaults: Optional variable defaults for Jinja resolution
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Actual height in pixels for the rendered title
|
|
576
|
+
"""
|
|
577
|
+
if not title:
|
|
578
|
+
return 0.0
|
|
579
|
+
|
|
580
|
+
from dataface.core.compile.jinja import resolve_jinja_template
|
|
581
|
+
from dataface.core.fonts import get_inter_font_path, get_mono_font_path
|
|
582
|
+
from mdsvg import measure as measure_markdown
|
|
583
|
+
|
|
584
|
+
# Resolve any Jinja templates using variable defaults
|
|
585
|
+
resolved_title = resolve_jinja_template(
|
|
586
|
+
title, variable_defaults or {}, strict=False
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
from dataface.core.compile.typography import face_title_markdown
|
|
590
|
+
|
|
591
|
+
markdown_title, h1_size, heading_weight = face_title_markdown(
|
|
592
|
+
resolved_title, width, level=level
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
# Measure the markdown to get actual dimensions (using compact style).
|
|
596
|
+
# text_align is intentionally omitted: mdsvg measure() only computes
|
|
597
|
+
# line-wrapped height, which is independent of horizontal alignment.
|
|
598
|
+
font_path = str(get_inter_font_path())
|
|
599
|
+
size = measure_markdown(
|
|
600
|
+
markdown_title,
|
|
601
|
+
width=width,
|
|
602
|
+
padding=0.0,
|
|
603
|
+
style=get_compact_style(
|
|
604
|
+
h1_size=h1_size,
|
|
605
|
+
heading_font_weight=heading_weight,
|
|
606
|
+
),
|
|
607
|
+
font_path=font_path,
|
|
608
|
+
mono_font_path=str(get_mono_font_path()),
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
return size.height
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def get_face_gap(face: Face) -> float:
|
|
615
|
+
"""Return the row/col/grid gap for a face based on its layout type.
|
|
616
|
+
|
|
617
|
+
When card_gap is active the gap is always 0 — spacing is owned by the
|
|
618
|
+
margin, not the individual layout rows/cols/grid. Otherwise the gap comes
|
|
619
|
+
from the layout-type-specific config key.
|
|
620
|
+
"""
|
|
621
|
+
config = get_config()
|
|
622
|
+
if face.card_gap:
|
|
623
|
+
return 0.0
|
|
624
|
+
if face.layout.type == "rows":
|
|
625
|
+
return float(config.style.layout.rows.gap)
|
|
626
|
+
if face.layout.type == "cols":
|
|
627
|
+
return float(config.style.layout.cols.gap)
|
|
628
|
+
if face.layout.type == "grid":
|
|
629
|
+
return float(config.style.layout.grid.gap)
|
|
630
|
+
return 0.0
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# ============================================================================
|
|
634
|
+
# VARIABLE CONTROLS HEIGHT (moved from render/variable_controls.py)
|
|
635
|
+
# ============================================================================
|
|
636
|
+
|
|
637
|
+
# Minimum title column width when sharing a row with variables so titles never
|
|
638
|
+
# collapse to zero when the variable strip is very wide.
|
|
639
|
+
_MIN_TITLE_INLINE_TITLE_COLUMN_PX = 48.0
|
|
640
|
+
# Hard ceiling on title column width as a fraction of the band's inner width.
|
|
641
|
+
# At 0.8, pathologically long titles wrap before they can eliminate the
|
|
642
|
+
# variables column — the strip stays parseable even when authors give a face
|
|
643
|
+
# a 60-character title and 7 filters.
|
|
644
|
+
_TITLE_INLINE_TITLE_MAX_INNER_RATIO = 0.8
|
|
645
|
+
|
|
646
|
+
# Strip a leading `#+\s+` markdown heading prefix without eating standalone
|
|
647
|
+
# `#` characters or trailing punctuation — `lstrip("# ")` would mangle titles
|
|
648
|
+
# like "#1 Product" or "## Q3 ##".
|
|
649
|
+
_HEADING_PREFIX_RE = re.compile(r"^#+\s+")
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _measure_title_single_line_width(
|
|
653
|
+
title: str | None,
|
|
654
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
655
|
+
) -> float:
|
|
656
|
+
"""Width the title text needs to render on a single line at its natural size.
|
|
657
|
+
|
|
658
|
+
Uses PIL's freetype-backed ImageFont on the wheel-shipped Inter font at
|
|
659
|
+
the largest title-tier size — measuring at the widest tier gives the
|
|
660
|
+
worst-case natural width, which is what the title-inline band reserves
|
|
661
|
+
at full inner width. mdsvg's measure() can't be used here — it returns
|
|
662
|
+
the constraint width when passed an unbounded width, not the natural
|
|
663
|
+
content extent.
|
|
664
|
+
"""
|
|
665
|
+
if not title:
|
|
666
|
+
return 0.0
|
|
667
|
+
|
|
668
|
+
resolved = resolve_jinja_template(title, variable_defaults or {}, strict=False)
|
|
669
|
+
# Strip a leading `#+\s+` markdown heading prefix without eating standalone
|
|
670
|
+
# `#` characters or trailing punctuation — `lstrip("# ")` would mangle
|
|
671
|
+
# titles like "#1 Product" or "## Q3 ##".
|
|
672
|
+
plain = _HEADING_PREFIX_RE.sub("", resolved).strip()
|
|
673
|
+
|
|
674
|
+
h1_size = float(get_config().style.title.sizes[0])
|
|
675
|
+
return float(_load_title_font(h1_size).getlength(plain))
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@functools.lru_cache(maxsize=8)
|
|
679
|
+
def _load_title_font(size_px: float) -> ImageFont.FreeTypeFont:
|
|
680
|
+
"""Load the Inter title font at the requested size, cached.
|
|
681
|
+
|
|
682
|
+
`_measure_title_single_line_width` is called per-face during both sizing
|
|
683
|
+
and render; without caching, ImageFont.truetype reparses the .ttf file
|
|
684
|
+
on every call. The cache is bounded — `size_px` comes from the title
|
|
685
|
+
tier ramp (6 entries) plus the level-driven offsets, so `maxsize=8`
|
|
686
|
+
covers every realistic call site.
|
|
687
|
+
"""
|
|
688
|
+
return ImageFont.truetype(str(get_inter_font_path()), size_px)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def resolve_title_variables_inline_widths(
|
|
692
|
+
inner: float,
|
|
693
|
+
variables_style: VariablesStyle,
|
|
694
|
+
visible_variables: dict[str, Any],
|
|
695
|
+
title: str | None = None,
|
|
696
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
697
|
+
) -> tuple[float, float]:
|
|
698
|
+
"""Split inner title+variables width into (title_w, variables_w).
|
|
699
|
+
|
|
700
|
+
Title precedence: reserve the title's measured single-line width first,
|
|
701
|
+
give the rest to variables, and let the variables strip flex-wrap into
|
|
702
|
+
multiple rows when its share is too narrow for one row. ``compute_
|
|
703
|
+
variable_controls_height`` handles the multi-row height so the band
|
|
704
|
+
grows correctly.
|
|
705
|
+
|
|
706
|
+
``title_inline_title_max_width`` (theme) still caps the title when > 0;
|
|
707
|
+
a hard ceiling of 80% of inner protects pathologically long titles from
|
|
708
|
+
eliminating the variables column entirely (title wraps before that).
|
|
709
|
+
"""
|
|
710
|
+
col_gap = float(variables_style.gap)
|
|
711
|
+
if not visible_variables:
|
|
712
|
+
return max(inner, 1.0), 0.0
|
|
713
|
+
|
|
714
|
+
# Title's natural single-line width + a small breathing-room pad so the
|
|
715
|
+
# measured width doesn't wrap on sub-pixel rendering jitter.
|
|
716
|
+
natural = _measure_title_single_line_width(title, variable_defaults)
|
|
717
|
+
natural_with_pad = (
|
|
718
|
+
natural + 8.0 if natural > 0 else _MIN_TITLE_INLINE_TITLE_COLUMN_PX
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
# Hard ceiling: never let the title eat more than this fraction of inner,
|
|
722
|
+
# even when natural width is larger — keeps the variables column wide
|
|
723
|
+
# enough that authors see their filters before having to wrap.
|
|
724
|
+
title_w = min(natural_with_pad, inner * _TITLE_INLINE_TITLE_MAX_INNER_RATIO)
|
|
725
|
+
title_w = max(title_w, _MIN_TITLE_INLINE_TITLE_COLUMN_PX)
|
|
726
|
+
|
|
727
|
+
# Theme-level override still wins when set.
|
|
728
|
+
cap = float(variables_style.title_inline_title_max_width)
|
|
729
|
+
if cap > 0.0:
|
|
730
|
+
title_w = min(title_w, cap)
|
|
731
|
+
|
|
732
|
+
vars_w = max(inner - title_w - col_gap, 1.0)
|
|
733
|
+
return title_w, vars_w
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _estimate_control_width(
|
|
737
|
+
var_name: str, var_def: Any, variables_style: VariablesStyle
|
|
738
|
+
) -> float:
|
|
739
|
+
"""Estimate the rendered pixel width of a single variable control."""
|
|
740
|
+
label = (var_def.label or var_name.replace("_", " ").title()) + ":"
|
|
741
|
+
assert variables_style.font.size is not None
|
|
742
|
+
label_w = len(label) * variables_style.font.size * 0.6
|
|
743
|
+
input_cfg = variables_style.input
|
|
744
|
+
input_type = var_def.input
|
|
745
|
+
|
|
746
|
+
if input_type in ("select", "multiselect", "radio"):
|
|
747
|
+
input_w = 120.0
|
|
748
|
+
elif input_type in ("text", "input", "textarea"):
|
|
749
|
+
input_w = float(input_cfg.widths.text)
|
|
750
|
+
elif input_type == "number":
|
|
751
|
+
input_w = float(input_cfg.widths.number)
|
|
752
|
+
elif input_type in ("slider", "range"):
|
|
753
|
+
input_w = (
|
|
754
|
+
float(input_cfg.widths.range)
|
|
755
|
+
+ variables_style.control_gap
|
|
756
|
+
+ float(input_cfg.widths.slider_value_min)
|
|
757
|
+
)
|
|
758
|
+
elif input_type == "checkbox":
|
|
759
|
+
return float(input_cfg.widths.checkbox) + variables_style.control_gap + label_w
|
|
760
|
+
elif input_type in ("date", "datepicker"):
|
|
761
|
+
input_w = 120.0
|
|
762
|
+
elif input_type == "daterange":
|
|
763
|
+
input_w = float(input_cfg.widths.daterange)
|
|
764
|
+
else:
|
|
765
|
+
input_w = float(input_cfg.widths.text)
|
|
766
|
+
|
|
767
|
+
return label_w + variables_style.control_gap + input_w
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def compute_title_variables_inline_baseline_layout(
|
|
771
|
+
title_h: float,
|
|
772
|
+
vars_h: float,
|
|
773
|
+
label_font_size: float,
|
|
774
|
+
) -> tuple[float, float, float]:
|
|
775
|
+
"""Baseline-aligned layout for the title-inline band.
|
|
776
|
+
|
|
777
|
+
Returns ``(title_dy, vars_dy, band_h)`` — the y-translation each column
|
|
778
|
+
needs to land its first text baseline at the same shared baseline, plus
|
|
779
|
+
the total band height that results.
|
|
780
|
+
|
|
781
|
+
The ratios below are empirical, measured against the actual mdsvg /
|
|
782
|
+
foreignObject output for face titles at the default Inter weight:
|
|
783
|
+
|
|
784
|
+
- **Title baseline at 81.25% of title-block height.** mdsvg renders heading
|
|
785
|
+
text with a 1.3-line-height box plus its own block padding, putting the
|
|
786
|
+
first baseline at ``font_size * 1.3`` from the title-block top. Divided
|
|
787
|
+
by the block height (``font_size * 1.6`` for the default tier), that's
|
|
788
|
+
``0.8125``. Pinned to the measured ratio so the rendered title and the
|
|
789
|
+
variable-label baseline land within sub-pixel of each other across font
|
|
790
|
+
tiers; do not "guess" with a rounded 0.85.
|
|
791
|
+
- **Label baseline ≈ container vertical center + 35% of label font size.**
|
|
792
|
+
Variable controls use ``align-items: center`` inside a ``container_height``
|
|
793
|
+
flex row; the centered label's baseline lands roughly half a label-em
|
|
794
|
+
below the container's vertical center.
|
|
795
|
+
|
|
796
|
+
Both columns are then shifted so the deeper baseline becomes the shared
|
|
797
|
+
target, which guarantees neither column extends above the band's top edge.
|
|
798
|
+
"""
|
|
799
|
+
title_baseline = title_h * 0.8125
|
|
800
|
+
vars_baseline = vars_h / 2.0 + label_font_size * 0.35
|
|
801
|
+
target = max(title_baseline, vars_baseline)
|
|
802
|
+
title_dy = target - title_baseline
|
|
803
|
+
vars_dy = target - vars_baseline
|
|
804
|
+
band_h = max(title_dy + title_h, vars_dy + vars_h) + TITLE_INLINE_BAND_BOTTOM_PAD
|
|
805
|
+
return title_dy, vars_dy, band_h
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def compute_title_variables_inline_band_height(
|
|
809
|
+
face: Face,
|
|
810
|
+
content_width: float,
|
|
811
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
812
|
+
) -> float:
|
|
813
|
+
"""Band height for ``variables.position: title-inline``.
|
|
814
|
+
|
|
815
|
+
Computes the height the baseline-aligned band needs to fit both columns.
|
|
816
|
+
Delegates to :func:`compute_title_variables_inline_baseline_layout` so the
|
|
817
|
+
sizing pass and the render pass share one source of truth.
|
|
818
|
+
"""
|
|
819
|
+
vs = face.resolved_style.variables
|
|
820
|
+
card_pad = float(face.resolved_style.board.card_padding)
|
|
821
|
+
inner = max(content_width - 2 * card_pad, 1.0)
|
|
822
|
+
title_w, vars_w = resolve_title_variables_inline_widths(
|
|
823
|
+
inner,
|
|
824
|
+
vs,
|
|
825
|
+
face.visible_variables,
|
|
826
|
+
title=face.title,
|
|
827
|
+
variable_defaults=variable_defaults,
|
|
828
|
+
)
|
|
829
|
+
if not face.title:
|
|
830
|
+
return 0.0
|
|
831
|
+
title_h = max(
|
|
832
|
+
get_title_height(face.title, title_w, variable_defaults),
|
|
833
|
+
float(face.resolved_style.title.min_height),
|
|
834
|
+
)
|
|
835
|
+
if not face.visible_variables:
|
|
836
|
+
return title_h
|
|
837
|
+
var_h = compute_variable_controls_height(
|
|
838
|
+
face.visible_variables,
|
|
839
|
+
vars_w,
|
|
840
|
+
variables_style=vs,
|
|
841
|
+
)
|
|
842
|
+
assert vs.font.size is not None, "style.variables.font.size must be configured"
|
|
843
|
+
_title_dy, _vars_dy, band_h = compute_title_variables_inline_baseline_layout(
|
|
844
|
+
title_h, var_h, float(vs.font.size)
|
|
845
|
+
)
|
|
846
|
+
return band_h
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def compute_variable_controls_height(
|
|
850
|
+
variable_defs: dict[str, Any],
|
|
851
|
+
width: float,
|
|
852
|
+
*,
|
|
853
|
+
variables_style: VariablesStyle,
|
|
854
|
+
) -> float:
|
|
855
|
+
"""Compute the height needed for variable controls rendered at the given width.
|
|
856
|
+
|
|
857
|
+
Simulates flex-wrap layout to determine how many rows the controls occupy,
|
|
858
|
+
then returns the total foreignObject height including container padding.
|
|
859
|
+
Returns the single-row config height when all controls fit on one row.
|
|
860
|
+
|
|
861
|
+
Args:
|
|
862
|
+
variable_defs: Variable definitions dict
|
|
863
|
+
width: Available container width
|
|
864
|
+
variables_style: Variables style from the face's resolved style.
|
|
865
|
+
"""
|
|
866
|
+
if not variable_defs:
|
|
867
|
+
return 0.0
|
|
868
|
+
|
|
869
|
+
container_height = float(variables_style.container_height)
|
|
870
|
+
padding_x = float(variables_style.container_padding)
|
|
871
|
+
gap = float(variables_style.gap)
|
|
872
|
+
|
|
873
|
+
available = max(width - 2 * padding_x, 1.0)
|
|
874
|
+
widths = [
|
|
875
|
+
_estimate_control_width(name, v, variables_style)
|
|
876
|
+
for name, v in variable_defs.items()
|
|
877
|
+
]
|
|
878
|
+
|
|
879
|
+
# Simulate flex-wrap: first item always starts row 1. The strict ">"
|
|
880
|
+
# comparison is float-precision-sensitive: a row that fits exactly in
|
|
881
|
+
# CSS at integer pixel widths can read as overflow in Python after the
|
|
882
|
+
# vars_w / available subtractions accumulate ~1e-14 of drift. A 0.5px
|
|
883
|
+
# tolerance gives sub-pixel slop the benefit of the doubt and matches
|
|
884
|
+
# the browser's own pixel-snapping behaviour.
|
|
885
|
+
epsilon = 0.5
|
|
886
|
+
n_rows = 1
|
|
887
|
+
current_row_w = widths[0] if widths else 0.0
|
|
888
|
+
for w in widths[1:]:
|
|
889
|
+
if current_row_w + gap + w > available + epsilon:
|
|
890
|
+
n_rows += 1
|
|
891
|
+
current_row_w = w
|
|
892
|
+
else:
|
|
893
|
+
current_row_w += gap + w
|
|
894
|
+
|
|
895
|
+
if n_rows == 1:
|
|
896
|
+
return container_height
|
|
897
|
+
|
|
898
|
+
# Multi-row: each row gets full container height plus gap between rows
|
|
899
|
+
return n_rows * container_height + (n_rows - 1) * gap
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def calculate_layout(face: Face) -> Face:
|
|
903
|
+
"""Calculate dimensions for all layout items.
|
|
904
|
+
|
|
905
|
+
Stage: COMPILE (Step 4 of 4: Layout Calculation)
|
|
906
|
+
|
|
907
|
+
This is the final compilation step. It calculates pixel dimensions
|
|
908
|
+
for all charts and nested faces based on layout type and nesting.
|
|
909
|
+
|
|
910
|
+
The sizing is content-aware:
|
|
911
|
+
- KPIs get heights from resolved_style.charts.kpi.default_height
|
|
912
|
+
- Charts get standard heights (~300px)
|
|
913
|
+
- Titles are measured for text wrapping
|
|
914
|
+
- Nested faces accumulate their content heights
|
|
915
|
+
|
|
916
|
+
Args:
|
|
917
|
+
face: Face with layout structure
|
|
918
|
+
|
|
919
|
+
Returns:
|
|
920
|
+
Face with calculated dimensions on layout items
|
|
921
|
+
|
|
922
|
+
Example:
|
|
923
|
+
>>> face = normalize_face(parsed_face)
|
|
924
|
+
>>> face = calculate_layout(face)
|
|
925
|
+
>>> print(face.layout.width, face.layout.height)
|
|
926
|
+
1200.0 800.0
|
|
927
|
+
"""
|
|
928
|
+
config = get_config()
|
|
929
|
+
|
|
930
|
+
container_width = float(config.style.board.width)
|
|
931
|
+
content_width = _get_root_content_width(config)
|
|
932
|
+
min_height = float(config.style.board.min_height)
|
|
933
|
+
|
|
934
|
+
card_gap = float(config.style.board.card_gap) if face.card_gap else 0.0
|
|
935
|
+
gap = get_face_gap(face)
|
|
936
|
+
effective_gap = gap + card_gap
|
|
937
|
+
|
|
938
|
+
# Use pre-computed variable defaults for Jinja resolution
|
|
939
|
+
# This allows markdown content with {{ variable }} to size correctly
|
|
940
|
+
variable_defaults = face.variable_defaults
|
|
941
|
+
|
|
942
|
+
# Calculate required height based on content (content-aware)
|
|
943
|
+
container_height = calculate_layout_height(
|
|
944
|
+
face.layout,
|
|
945
|
+
card_gap,
|
|
946
|
+
gap,
|
|
947
|
+
min_height,
|
|
948
|
+
available_width=content_width,
|
|
949
|
+
variable_defaults=variable_defaults,
|
|
950
|
+
resolved_style=face.resolved_style,
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
# Add height for root face title if present. title renders at
|
|
954
|
+
# content_width - 2*card_padding (matching the card_padding inset applied in render).
|
|
955
|
+
vs = face.resolved_style.variables
|
|
956
|
+
use_title_inline = (
|
|
957
|
+
vs.position == "title-inline"
|
|
958
|
+
and bool(face.title)
|
|
959
|
+
and bool(face.visible_variables)
|
|
960
|
+
)
|
|
961
|
+
if use_title_inline:
|
|
962
|
+
band_h = compute_title_variables_inline_band_height(
|
|
963
|
+
face, content_width, variable_defaults
|
|
964
|
+
)
|
|
965
|
+
container_height += band_h + effective_gap
|
|
966
|
+
elif face.title:
|
|
967
|
+
card_pad = float(config.style.board.card_padding)
|
|
968
|
+
title_measure_width = max(content_width - 2 * card_pad, 0.0)
|
|
969
|
+
title_height = get_title_height(
|
|
970
|
+
face.title, title_measure_width, variable_defaults
|
|
971
|
+
)
|
|
972
|
+
container_height += title_height + effective_gap
|
|
973
|
+
|
|
974
|
+
# Set root layout dimensions
|
|
975
|
+
face.layout.width = container_width
|
|
976
|
+
face.layout.height = container_height
|
|
977
|
+
|
|
978
|
+
# Root layout items should be sized against the root content box, not the
|
|
979
|
+
# full outer board width.
|
|
980
|
+
face.layout.content_width = content_width
|
|
981
|
+
# Vertical spacing is finalized during render after title/content/control
|
|
982
|
+
# placement, so root sizing preserves the outer height contract here.
|
|
983
|
+
face.layout.content_height = container_height
|
|
984
|
+
|
|
985
|
+
# Calculate all item dimensions in a single pass
|
|
986
|
+
calculate_layout_items(
|
|
987
|
+
face.layout,
|
|
988
|
+
content_width,
|
|
989
|
+
container_height,
|
|
990
|
+
card_gap,
|
|
991
|
+
gap,
|
|
992
|
+
variable_defaults,
|
|
993
|
+
resolved_style=face.resolved_style,
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
return face
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def get_item_content_height(
|
|
1000
|
+
item: LayoutItem,
|
|
1001
|
+
card_gap: float,
|
|
1002
|
+
gap: float,
|
|
1003
|
+
width: float = 400.0,
|
|
1004
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
1005
|
+
height_provider: HeightProvider | None = None,
|
|
1006
|
+
*,
|
|
1007
|
+
resolved_style: MergedStyle,
|
|
1008
|
+
) -> float:
|
|
1009
|
+
"""Get the content-appropriate height for a single layout item (static/compile-time).
|
|
1010
|
+
|
|
1011
|
+
This is the compile-time height calculator using aspect ratios and config
|
|
1012
|
+
defaults only. No executor, no data, no rendering. The render-time path
|
|
1013
|
+
uses a HeightProvider callback instead.
|
|
1014
|
+
|
|
1015
|
+
Args:
|
|
1016
|
+
item: The layout item
|
|
1017
|
+
card_gap: Gap between cards (inter-item spacing, applied N-1 times)
|
|
1018
|
+
gap: Gap between items (used for nested layouts)
|
|
1019
|
+
width: Available width for the item (used for text wrapping estimates)
|
|
1020
|
+
variable_defaults: Optional variable defaults for Jinja resolution
|
|
1021
|
+
resolved_style: Face-resolved style for cascade-aware height calculations.
|
|
1022
|
+
|
|
1023
|
+
Returns:
|
|
1024
|
+
Appropriate height for this item's content
|
|
1025
|
+
"""
|
|
1026
|
+
if item.type == "chart" and item.chart:
|
|
1027
|
+
card_pad = float(get_config().style.board.card_padding)
|
|
1028
|
+
if item.chart.type == "callout":
|
|
1029
|
+
return get_chart_content_height(
|
|
1030
|
+
item.chart, width=width, resolved_style=resolved_style
|
|
1031
|
+
)
|
|
1032
|
+
# Vega-family charts render at full item width with card_pad as internal
|
|
1033
|
+
# Vega padding; SVG-family types ignore the width parameter anyway.
|
|
1034
|
+
# The + 2*card_pad accounts for Vega's top+bottom padding in its output height.
|
|
1035
|
+
return (
|
|
1036
|
+
get_chart_content_height(
|
|
1037
|
+
item.chart, width=width, resolved_style=resolved_style
|
|
1038
|
+
)
|
|
1039
|
+
+ 2 * card_pad
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
elif item.type == "face" and item.face:
|
|
1043
|
+
# Details (collapsible section): collapsed = summary bar only
|
|
1044
|
+
if is_item_collapsed_summary(item, variable_defaults):
|
|
1045
|
+
return float(get_config().style.layout.details.summary_height)
|
|
1046
|
+
|
|
1047
|
+
# For nested faces, calculate their content height using the nested face's
|
|
1048
|
+
# own resolved_style (each face can have its own theme cascade).
|
|
1049
|
+
nested_face = item.face
|
|
1050
|
+
nested_rs = nested_face.resolved_style
|
|
1051
|
+
|
|
1052
|
+
content_width, nested_non_layout_height, child_gap = nested_face_sizing_context(
|
|
1053
|
+
nested_face, width, card_gap, variable_defaults
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
nested_height = nested_non_layout_height
|
|
1057
|
+
# Add layout content height (only if there are items)
|
|
1058
|
+
if nested_face.layout.items:
|
|
1059
|
+
nested_height += calculate_layout_height(
|
|
1060
|
+
nested_face.layout,
|
|
1061
|
+
card_gap,
|
|
1062
|
+
gap=child_gap,
|
|
1063
|
+
min_height=0,
|
|
1064
|
+
available_width=content_width,
|
|
1065
|
+
variable_defaults=variable_defaults,
|
|
1066
|
+
height_provider=height_provider,
|
|
1067
|
+
resolved_style=nested_rs,
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
# Add effective padding and margin to total height
|
|
1071
|
+
nested_height += _effective_padding(nested_rs).vertical + (
|
|
1072
|
+
nested_rs.margin.vertical if nested_rs.margin else 0.0
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
content_h = max(nested_height, MIN_CONTENT_HEIGHT)
|
|
1076
|
+
|
|
1077
|
+
# Expanded details: add chrome (summary bar + gap) above content
|
|
1078
|
+
if item.details_variable and item.details_summary:
|
|
1079
|
+
return details_chrome_height(item, gap, card_gap) + content_h
|
|
1080
|
+
|
|
1081
|
+
return content_h
|
|
1082
|
+
|
|
1083
|
+
# Fallback (leaf item — treat as card)
|
|
1084
|
+
return DEFAULT_CHART_HEIGHT + 2 * float(get_config().style.board.card_padding)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def calculate_layout_height(
|
|
1088
|
+
layout: Layout,
|
|
1089
|
+
card_gap: float,
|
|
1090
|
+
gap: float,
|
|
1091
|
+
min_height: float,
|
|
1092
|
+
available_width: float = 400.0,
|
|
1093
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
1094
|
+
height_provider: HeightProvider | None = None,
|
|
1095
|
+
*,
|
|
1096
|
+
resolved_style: MergedStyle,
|
|
1097
|
+
) -> float:
|
|
1098
|
+
"""Calculate required height for a layout.
|
|
1099
|
+
|
|
1100
|
+
Uses mdsvg to render and measure actual content heights:
|
|
1101
|
+
- KPI charts get smaller heights
|
|
1102
|
+
- Standard charts get normal heights
|
|
1103
|
+
- Tables use default height (data-aware sizing via HeightProvider at render time)
|
|
1104
|
+
- Nested boards accumulate their content heights
|
|
1105
|
+
- Titles are rendered and measured
|
|
1106
|
+
- Markdown content with word-wrapping is rendered and measured
|
|
1107
|
+
|
|
1108
|
+
For rows layout: sum of each item's height + gaps
|
|
1109
|
+
For cols layout: max of item heights (all items share same height)
|
|
1110
|
+
For grid layout: based on rows and content types
|
|
1111
|
+
For tabs layout: max of tab content heights + tab bar
|
|
1112
|
+
|
|
1113
|
+
Args:
|
|
1114
|
+
layout: The layout to calculate height for
|
|
1115
|
+
card_gap: Gap between cards (inter-item spacing)
|
|
1116
|
+
gap: Gap between items
|
|
1117
|
+
min_height: Minimum height to return
|
|
1118
|
+
available_width: Available width for text wrapping calculations
|
|
1119
|
+
variable_defaults: Optional variable defaults for Jinja resolution
|
|
1120
|
+
|
|
1121
|
+
Returns:
|
|
1122
|
+
Required height in pixels
|
|
1123
|
+
"""
|
|
1124
|
+
if not layout.items:
|
|
1125
|
+
return min_height
|
|
1126
|
+
|
|
1127
|
+
if layout.type == "rows":
|
|
1128
|
+
measured_height = _measure_rows_layout_height(
|
|
1129
|
+
layout.items,
|
|
1130
|
+
card_gap,
|
|
1131
|
+
gap,
|
|
1132
|
+
available_width,
|
|
1133
|
+
variable_defaults,
|
|
1134
|
+
height_provider,
|
|
1135
|
+
resolved_style=resolved_style,
|
|
1136
|
+
)
|
|
1137
|
+
elif layout.type == "cols":
|
|
1138
|
+
measured_height = _measure_cols_layout_height(
|
|
1139
|
+
layout.items,
|
|
1140
|
+
card_gap,
|
|
1141
|
+
gap,
|
|
1142
|
+
available_width,
|
|
1143
|
+
variable_defaults,
|
|
1144
|
+
height_provider,
|
|
1145
|
+
resolved_style=resolved_style,
|
|
1146
|
+
)
|
|
1147
|
+
elif layout.type == "grid":
|
|
1148
|
+
measured_height = _measure_grid_layout_height(
|
|
1149
|
+
layout,
|
|
1150
|
+
card_gap,
|
|
1151
|
+
gap,
|
|
1152
|
+
available_width,
|
|
1153
|
+
variable_defaults,
|
|
1154
|
+
height_provider,
|
|
1155
|
+
resolved_style=resolved_style,
|
|
1156
|
+
)
|
|
1157
|
+
elif layout.type == "tabs":
|
|
1158
|
+
measured_height = _measure_tabs_layout_height(
|
|
1159
|
+
layout.items,
|
|
1160
|
+
card_gap,
|
|
1161
|
+
gap,
|
|
1162
|
+
available_width,
|
|
1163
|
+
variable_defaults,
|
|
1164
|
+
height_provider,
|
|
1165
|
+
resolved_style=resolved_style,
|
|
1166
|
+
)
|
|
1167
|
+
else:
|
|
1168
|
+
return min_height
|
|
1169
|
+
return max(measured_height, min_height)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _measure_rows_layout_height(
|
|
1173
|
+
items: list[LayoutItem],
|
|
1174
|
+
card_gap: float,
|
|
1175
|
+
gap: float,
|
|
1176
|
+
available_width: float,
|
|
1177
|
+
variable_defaults: dict[str, Any] | None,
|
|
1178
|
+
height_provider: HeightProvider | None = None,
|
|
1179
|
+
*,
|
|
1180
|
+
resolved_style: MergedStyle,
|
|
1181
|
+
) -> float:
|
|
1182
|
+
"""Measure total height for a rows layout."""
|
|
1183
|
+
total_height = 0.0
|
|
1184
|
+
for item in items:
|
|
1185
|
+
# User-specified height takes precedence over content-aware height.
|
|
1186
|
+
# Percentages resolve to 0 here (no available_height context) and
|
|
1187
|
+
# fall through; _calculate_rows_dimensions resolves them later.
|
|
1188
|
+
resolved = _resolve_layout_height(item, 0.0)
|
|
1189
|
+
if resolved is not None:
|
|
1190
|
+
total_height += resolved
|
|
1191
|
+
else:
|
|
1192
|
+
total_height += _resolve_height(
|
|
1193
|
+
item,
|
|
1194
|
+
card_gap,
|
|
1195
|
+
gap,
|
|
1196
|
+
available_width,
|
|
1197
|
+
variable_defaults,
|
|
1198
|
+
height_provider,
|
|
1199
|
+
resolved_style=resolved_style,
|
|
1200
|
+
)
|
|
1201
|
+
if len(items) > 1:
|
|
1202
|
+
total_height += (gap + card_gap) * (len(items) - 1)
|
|
1203
|
+
return total_height
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def _measure_cols_layout_height(
|
|
1207
|
+
items: list[LayoutItem],
|
|
1208
|
+
card_gap: float,
|
|
1209
|
+
gap: float,
|
|
1210
|
+
available_width: float,
|
|
1211
|
+
variable_defaults: dict[str, Any] | None,
|
|
1212
|
+
height_provider: HeightProvider | None = None,
|
|
1213
|
+
*,
|
|
1214
|
+
resolved_style: MergedStyle,
|
|
1215
|
+
) -> float:
|
|
1216
|
+
"""Measure shared row height for a cols layout."""
|
|
1217
|
+
max_height = MIN_CONTENT_HEIGHT
|
|
1218
|
+
n_items = len(items)
|
|
1219
|
+
total_gap = (gap + card_gap) * (n_items - 1) if n_items > 1 else 0
|
|
1220
|
+
content_width = available_width - total_gap
|
|
1221
|
+
|
|
1222
|
+
specified_widths: list[float | None] = []
|
|
1223
|
+
for item in items:
|
|
1224
|
+
parsed_width = parse_dimension(item.user_width, content_width)
|
|
1225
|
+
specified_widths.append(parsed_width)
|
|
1226
|
+
|
|
1227
|
+
assigned_widths = _resolve_cols_widths(
|
|
1228
|
+
specified_widths, content_width=content_width, item_count=n_items
|
|
1229
|
+
)
|
|
1230
|
+
for i, item in enumerate(items):
|
|
1231
|
+
# User-specified height takes precedence (px only here;
|
|
1232
|
+
# percentages need available_height, resolved in _calculate_cols_dimensions)
|
|
1233
|
+
resolved = _resolve_layout_height(item, 0.0)
|
|
1234
|
+
if resolved is not None:
|
|
1235
|
+
max_height = max(max_height, resolved)
|
|
1236
|
+
else:
|
|
1237
|
+
item_width = assigned_widths[i]
|
|
1238
|
+
item_height = _resolve_height(
|
|
1239
|
+
item,
|
|
1240
|
+
card_gap,
|
|
1241
|
+
gap,
|
|
1242
|
+
item_width,
|
|
1243
|
+
variable_defaults,
|
|
1244
|
+
height_provider,
|
|
1245
|
+
resolved_style=resolved_style,
|
|
1246
|
+
)
|
|
1247
|
+
max_height = max(max_height, item_height)
|
|
1248
|
+
return max_height
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _measure_grid_layout_height(
|
|
1252
|
+
layout: Layout,
|
|
1253
|
+
card_gap: float,
|
|
1254
|
+
gap: float,
|
|
1255
|
+
available_width: float,
|
|
1256
|
+
variable_defaults: dict[str, Any] | None,
|
|
1257
|
+
height_provider: HeightProvider | None = None,
|
|
1258
|
+
*,
|
|
1259
|
+
resolved_style: MergedStyle,
|
|
1260
|
+
) -> float:
|
|
1261
|
+
"""Measure required height for a grid layout."""
|
|
1262
|
+
columns = layout.columns or 24
|
|
1263
|
+
max_row_end = 1
|
|
1264
|
+
for item in layout.items:
|
|
1265
|
+
item_row = item.row or 0
|
|
1266
|
+
item_rows = item.row_span or 1
|
|
1267
|
+
max_row_end = max(max_row_end, item_row + item_rows)
|
|
1268
|
+
|
|
1269
|
+
effective_gap = gap + card_gap
|
|
1270
|
+
total_gap_x = effective_gap * (columns - 1)
|
|
1271
|
+
col_width = (available_width - total_gap_x) / columns
|
|
1272
|
+
|
|
1273
|
+
total_item_height = 0.0
|
|
1274
|
+
for item in layout.items:
|
|
1275
|
+
resolved = _resolve_layout_height(item, 0.0)
|
|
1276
|
+
if resolved is not None:
|
|
1277
|
+
total_item_height += resolved
|
|
1278
|
+
continue
|
|
1279
|
+
item_col_span = item.col_span or 1
|
|
1280
|
+
item_width = col_width * item_col_span + effective_gap * (item_col_span - 1)
|
|
1281
|
+
total_item_height += _resolve_height(
|
|
1282
|
+
item,
|
|
1283
|
+
card_gap,
|
|
1284
|
+
gap,
|
|
1285
|
+
item_width,
|
|
1286
|
+
variable_defaults,
|
|
1287
|
+
height_provider,
|
|
1288
|
+
resolved_style=resolved_style,
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
avg_item_height = (
|
|
1292
|
+
total_item_height / len(layout.items) if layout.items else DEFAULT_CHART_HEIGHT
|
|
1293
|
+
)
|
|
1294
|
+
total_gaps = effective_gap * (max_row_end - 1) if max_row_end > 1 else 0
|
|
1295
|
+
return (max_row_end * avg_item_height) + total_gaps
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _measure_tabs_layout_height(
|
|
1299
|
+
items: list[LayoutItem],
|
|
1300
|
+
card_gap: float,
|
|
1301
|
+
gap: float,
|
|
1302
|
+
available_width: float,
|
|
1303
|
+
variable_defaults: dict[str, Any] | None,
|
|
1304
|
+
height_provider: HeightProvider | None = None,
|
|
1305
|
+
*,
|
|
1306
|
+
resolved_style: MergedStyle,
|
|
1307
|
+
) -> float:
|
|
1308
|
+
"""Measure required height for a tabs layout."""
|
|
1309
|
+
tab_bar_height = float(get_config().style.layout.tabs.bar_height)
|
|
1310
|
+
max_content_height = MIN_CONTENT_HEIGHT
|
|
1311
|
+
for item in items:
|
|
1312
|
+
resolved = _resolve_layout_height(item, 0.0)
|
|
1313
|
+
if resolved is not None:
|
|
1314
|
+
max_content_height = max(max_content_height, resolved)
|
|
1315
|
+
else:
|
|
1316
|
+
item_height = _resolve_height(
|
|
1317
|
+
item,
|
|
1318
|
+
card_gap,
|
|
1319
|
+
gap,
|
|
1320
|
+
available_width,
|
|
1321
|
+
variable_defaults,
|
|
1322
|
+
height_provider,
|
|
1323
|
+
resolved_style=resolved_style,
|
|
1324
|
+
)
|
|
1325
|
+
max_content_height = max(max_content_height, item_height)
|
|
1326
|
+
return max_content_height + tab_bar_height
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _set_render_ready_sizing(item: LayoutItem) -> None:
|
|
1330
|
+
"""Set render-ready sizing fields (calculated_width, calculated_height, aspect_ratio).
|
|
1331
|
+
|
|
1332
|
+
These fields preserve the calculated pixel dimensions for consistent rendering
|
|
1333
|
+
across SVG and HTML outputs. The aspect_ratio is used for responsive CSS rendering.
|
|
1334
|
+
|
|
1335
|
+
Args:
|
|
1336
|
+
item: LayoutItem to set sizing fields on
|
|
1337
|
+
"""
|
|
1338
|
+
# Set calculated dimensions (same as width/height for now, but preserved separately)
|
|
1339
|
+
item.calculated_width = item.width if item.width > 0 else None
|
|
1340
|
+
item.calculated_height = item.height if item.height > 0 else None
|
|
1341
|
+
|
|
1342
|
+
# Calculate aspect ratio for responsive rendering
|
|
1343
|
+
if item.calculated_width and item.calculated_height and item.calculated_height > 0:
|
|
1344
|
+
item.aspect_ratio = item.calculated_width / item.calculated_height
|
|
1345
|
+
else:
|
|
1346
|
+
item.aspect_ratio = None
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def calculate_layout_items(
|
|
1350
|
+
layout: Layout,
|
|
1351
|
+
available_width: float,
|
|
1352
|
+
available_height: float,
|
|
1353
|
+
card_gap: float,
|
|
1354
|
+
gap: float,
|
|
1355
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
1356
|
+
height_provider: HeightProvider | None = None,
|
|
1357
|
+
*,
|
|
1358
|
+
resolved_style: MergedStyle,
|
|
1359
|
+
) -> None:
|
|
1360
|
+
"""Calculate dimensions for all items in a layout.
|
|
1361
|
+
|
|
1362
|
+
Modifies items in place, setting their dimensions and positions.
|
|
1363
|
+
|
|
1364
|
+
Args:
|
|
1365
|
+
layout: Layout to calculate dimensions for
|
|
1366
|
+
available_width: Available container width in pixels
|
|
1367
|
+
available_height: Available container height in pixels
|
|
1368
|
+
card_gap: Gap between cards (inter-item spacing)
|
|
1369
|
+
gap: Gap between items in pixels
|
|
1370
|
+
variable_defaults: Optional variable defaults for Jinja resolution
|
|
1371
|
+
resolved_style: Face-resolved style for cascade-aware height calculations.
|
|
1372
|
+
"""
|
|
1373
|
+
if not layout.items:
|
|
1374
|
+
return
|
|
1375
|
+
|
|
1376
|
+
_apply_layout_dimensions(
|
|
1377
|
+
layout,
|
|
1378
|
+
available_width,
|
|
1379
|
+
available_height,
|
|
1380
|
+
card_gap,
|
|
1381
|
+
gap,
|
|
1382
|
+
variable_defaults,
|
|
1383
|
+
height_provider,
|
|
1384
|
+
resolved_style=resolved_style,
|
|
1385
|
+
)
|
|
1386
|
+
|
|
1387
|
+
for item in layout.items:
|
|
1388
|
+
_calculate_nested_face_layout(
|
|
1389
|
+
item, card_gap, gap, variable_defaults, height_provider
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _apply_layout_dimensions(
|
|
1394
|
+
layout: Layout,
|
|
1395
|
+
available_width: float,
|
|
1396
|
+
available_height: float,
|
|
1397
|
+
card_gap: float,
|
|
1398
|
+
gap: float,
|
|
1399
|
+
variable_defaults: dict[str, Any] | None,
|
|
1400
|
+
height_provider: HeightProvider | None = None,
|
|
1401
|
+
*,
|
|
1402
|
+
resolved_style: MergedStyle,
|
|
1403
|
+
) -> None:
|
|
1404
|
+
"""Apply the correct layout dimension calculator based on layout type."""
|
|
1405
|
+
if layout.type == "rows":
|
|
1406
|
+
_calculate_rows_dimensions(
|
|
1407
|
+
layout.items,
|
|
1408
|
+
available_width,
|
|
1409
|
+
available_height,
|
|
1410
|
+
card_gap,
|
|
1411
|
+
gap,
|
|
1412
|
+
variable_defaults,
|
|
1413
|
+
height_provider,
|
|
1414
|
+
resolved_style=resolved_style,
|
|
1415
|
+
)
|
|
1416
|
+
return
|
|
1417
|
+
if layout.type == "cols":
|
|
1418
|
+
_calculate_cols_dimensions(
|
|
1419
|
+
layout.items,
|
|
1420
|
+
available_width,
|
|
1421
|
+
available_height,
|
|
1422
|
+
card_gap,
|
|
1423
|
+
gap,
|
|
1424
|
+
variable_defaults,
|
|
1425
|
+
height_provider,
|
|
1426
|
+
resolved_style=resolved_style,
|
|
1427
|
+
)
|
|
1428
|
+
return
|
|
1429
|
+
if layout.type == "grid":
|
|
1430
|
+
_calculate_grid_dimensions(
|
|
1431
|
+
layout.items,
|
|
1432
|
+
available_width,
|
|
1433
|
+
available_height,
|
|
1434
|
+
layout.columns or 24,
|
|
1435
|
+
card_gap,
|
|
1436
|
+
gap,
|
|
1437
|
+
variable_defaults,
|
|
1438
|
+
height_provider,
|
|
1439
|
+
resolved_style=resolved_style,
|
|
1440
|
+
)
|
|
1441
|
+
return
|
|
1442
|
+
if layout.type == "tabs":
|
|
1443
|
+
_calculate_tabs_dimensions(
|
|
1444
|
+
layout.items,
|
|
1445
|
+
available_width,
|
|
1446
|
+
available_height,
|
|
1447
|
+
card_gap,
|
|
1448
|
+
gap,
|
|
1449
|
+
variable_defaults,
|
|
1450
|
+
height_provider,
|
|
1451
|
+
resolved_style=resolved_style,
|
|
1452
|
+
)
|
|
1453
|
+
return
|
|
1454
|
+
_calculate_rows_dimensions(
|
|
1455
|
+
layout.items,
|
|
1456
|
+
available_width,
|
|
1457
|
+
available_height,
|
|
1458
|
+
card_gap,
|
|
1459
|
+
gap,
|
|
1460
|
+
variable_defaults,
|
|
1461
|
+
height_provider,
|
|
1462
|
+
resolved_style=resolved_style,
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def nested_face_sizing_context(
|
|
1467
|
+
nested_face: Face,
|
|
1468
|
+
width: float,
|
|
1469
|
+
card_gap: float,
|
|
1470
|
+
variable_defaults: dict[str, Any] | None,
|
|
1471
|
+
) -> tuple[float, float, float]:
|
|
1472
|
+
"""Return (content_width, non_layout_height, child_gap).
|
|
1473
|
+
|
|
1474
|
+
non_layout_height includes title + text + variables + gaps between them,
|
|
1475
|
+
plus the gap before the layout block if layout items follow.
|
|
1476
|
+
"""
|
|
1477
|
+
nrs = nested_face.resolved_style
|
|
1478
|
+
ep = _effective_padding(nrs)
|
|
1479
|
+
face_margin_horizontal = nrs.margin.horizontal if nrs.margin else 0.0
|
|
1480
|
+
content_width = width - ep.horizontal - face_margin_horizontal
|
|
1481
|
+
child_gap = nrs.gap if nrs.gap is not None else 0.0
|
|
1482
|
+
effective_child_gap = child_gap + card_gap
|
|
1483
|
+
|
|
1484
|
+
vs = nested_face.resolved_style.variables
|
|
1485
|
+
card_pad = float(nested_face.resolved_style.board.card_padding)
|
|
1486
|
+
inner = max(content_width - 2 * card_pad, 1.0)
|
|
1487
|
+
|
|
1488
|
+
use_title_inline = (
|
|
1489
|
+
vs.position == "title-inline"
|
|
1490
|
+
and bool(nested_face.title)
|
|
1491
|
+
and bool(nested_face.visible_variables)
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
non_layout_height = 0.0
|
|
1495
|
+
if use_title_inline:
|
|
1496
|
+
non_layout_height += compute_title_variables_inline_band_height(
|
|
1497
|
+
nested_face, content_width, variable_defaults
|
|
1498
|
+
)
|
|
1499
|
+
elif nested_face.title:
|
|
1500
|
+
title_height = max(
|
|
1501
|
+
get_title_height(nested_face.title, inner, variable_defaults),
|
|
1502
|
+
float(nested_face.resolved_style.title.min_height),
|
|
1503
|
+
)
|
|
1504
|
+
non_layout_height += title_height
|
|
1505
|
+
|
|
1506
|
+
if nested_face.text:
|
|
1507
|
+
text_height = get_markdown_text_height(
|
|
1508
|
+
nested_face.text,
|
|
1509
|
+
content_width,
|
|
1510
|
+
variable_defaults,
|
|
1511
|
+
text_style=nested_face.resolved_style.text,
|
|
1512
|
+
)
|
|
1513
|
+
if non_layout_height > 0:
|
|
1514
|
+
non_layout_height += effective_child_gap
|
|
1515
|
+
non_layout_height += text_height
|
|
1516
|
+
|
|
1517
|
+
# Add variable controls height if this nested face has visible variables
|
|
1518
|
+
if nested_face.visible_variables and not use_title_inline:
|
|
1519
|
+
if non_layout_height > 0:
|
|
1520
|
+
non_layout_height += effective_child_gap
|
|
1521
|
+
non_layout_height += compute_variable_controls_height(
|
|
1522
|
+
nested_face.visible_variables,
|
|
1523
|
+
content_width,
|
|
1524
|
+
variables_style=nested_face.resolved_style.variables,
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
if non_layout_height > 0 and nested_face.layout.items:
|
|
1528
|
+
non_layout_height += effective_child_gap
|
|
1529
|
+
|
|
1530
|
+
return content_width, non_layout_height, child_gap
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _calculate_nested_face_layout(
|
|
1534
|
+
item: LayoutItem,
|
|
1535
|
+
card_gap: float,
|
|
1536
|
+
gap: float,
|
|
1537
|
+
variable_defaults: dict[str, Any] | None,
|
|
1538
|
+
height_provider: HeightProvider | None = None,
|
|
1539
|
+
) -> None:
|
|
1540
|
+
"""Recursively size a nested face once the parent item dimensions are known."""
|
|
1541
|
+
if is_item_collapsed_summary(item, variable_defaults) or not item.face:
|
|
1542
|
+
return
|
|
1543
|
+
|
|
1544
|
+
nested_face = item.face
|
|
1545
|
+
nested_face.layout.width = item.width
|
|
1546
|
+
nested_face.layout.height = item.height
|
|
1547
|
+
|
|
1548
|
+
content_width, non_layout_height, child_gap = nested_face_sizing_context(
|
|
1549
|
+
nested_face, item.width, card_gap, variable_defaults
|
|
1550
|
+
)
|
|
1551
|
+
|
|
1552
|
+
nrs2 = nested_face.resolved_style
|
|
1553
|
+
layout_available_height = (
|
|
1554
|
+
item.height
|
|
1555
|
+
- details_chrome_height(item, gap, card_gap)
|
|
1556
|
+
- _effective_padding(nrs2).vertical
|
|
1557
|
+
- (nrs2.margin.vertical if nrs2.margin else 0.0)
|
|
1558
|
+
- non_layout_height
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
nested_face.layout.content_width = content_width
|
|
1562
|
+
nested_face.layout.content_height = max(layout_available_height, 0)
|
|
1563
|
+
|
|
1564
|
+
calculate_layout_items(
|
|
1565
|
+
nested_face.layout,
|
|
1566
|
+
content_width,
|
|
1567
|
+
max(layout_available_height, 0),
|
|
1568
|
+
card_gap,
|
|
1569
|
+
gap=child_gap,
|
|
1570
|
+
variable_defaults=variable_defaults,
|
|
1571
|
+
height_provider=height_provider,
|
|
1572
|
+
resolved_style=nested_face.resolved_style,
|
|
1573
|
+
)
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _calculate_rows_dimensions(
|
|
1577
|
+
items: list[LayoutItem],
|
|
1578
|
+
available_width: float,
|
|
1579
|
+
available_height: float,
|
|
1580
|
+
card_gap: float,
|
|
1581
|
+
gap: float,
|
|
1582
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
1583
|
+
height_provider: HeightProvider | None = None,
|
|
1584
|
+
*,
|
|
1585
|
+
resolved_style: MergedStyle,
|
|
1586
|
+
) -> None:
|
|
1587
|
+
"""Calculate dimensions for items in a rows layout.
|
|
1588
|
+
|
|
1589
|
+
In a rows layout:
|
|
1590
|
+
- Items stack vertically
|
|
1591
|
+
- All items get full width
|
|
1592
|
+
- Height respects user-specified values (e.g., "200px", "50%")
|
|
1593
|
+
- Remaining height is distributed among auto items based on content type
|
|
1594
|
+
- If auto items exceed remaining space, they scale proportionally
|
|
1595
|
+
- Specified items are never scaled; if they exceed available space the layout overflows
|
|
1596
|
+
|
|
1597
|
+
Args:
|
|
1598
|
+
items: List of layout items
|
|
1599
|
+
available_width: Available width in pixels
|
|
1600
|
+
available_height: Available height in pixels
|
|
1601
|
+
card_gap: Gap between cards (inter-item spacing)
|
|
1602
|
+
gap: Gap between items in pixels
|
|
1603
|
+
variable_defaults: Optional variable defaults for Jinja resolution
|
|
1604
|
+
"""
|
|
1605
|
+
n = len(items)
|
|
1606
|
+
if n == 0:
|
|
1607
|
+
return
|
|
1608
|
+
|
|
1609
|
+
# Calculate total gap space
|
|
1610
|
+
total_gap = (gap + card_gap) * (n - 1)
|
|
1611
|
+
available_content_height = available_height - total_gap
|
|
1612
|
+
|
|
1613
|
+
# First pass: parse user-specified heights and get content heights for auto items
|
|
1614
|
+
specified_heights: list[float | None] = []
|
|
1615
|
+
content_heights: list[float] = []
|
|
1616
|
+
total_specified = 0.0
|
|
1617
|
+
|
|
1618
|
+
for item in items:
|
|
1619
|
+
parsed_height = _resolve_layout_height(item, available_content_height)
|
|
1620
|
+
specified_heights.append(parsed_height)
|
|
1621
|
+
if parsed_height is not None:
|
|
1622
|
+
total_specified += parsed_height
|
|
1623
|
+
content_heights.append(parsed_height)
|
|
1624
|
+
else:
|
|
1625
|
+
height = _resolve_height(
|
|
1626
|
+
item,
|
|
1627
|
+
card_gap,
|
|
1628
|
+
gap,
|
|
1629
|
+
available_width,
|
|
1630
|
+
variable_defaults,
|
|
1631
|
+
height_provider,
|
|
1632
|
+
resolved_style=resolved_style,
|
|
1633
|
+
)
|
|
1634
|
+
content_heights.append(height)
|
|
1635
|
+
|
|
1636
|
+
# Distribute height: specified items keep their height, auto items share the rest
|
|
1637
|
+
remaining = max(available_content_height - total_specified, 0.0)
|
|
1638
|
+
auto_total = sum(
|
|
1639
|
+
h for h, s in zip(content_heights, specified_heights, strict=True) if s is None
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
item_heights: list[float] = []
|
|
1643
|
+
for i, _item in enumerate(items):
|
|
1644
|
+
spec_h = specified_heights[i]
|
|
1645
|
+
if spec_h is not None:
|
|
1646
|
+
item_heights.append(spec_h)
|
|
1647
|
+
elif auto_total > 0 and remaining < auto_total:
|
|
1648
|
+
# Auto items need scaling to fit
|
|
1649
|
+
item_heights.append(content_heights[i] * (remaining / auto_total))
|
|
1650
|
+
else:
|
|
1651
|
+
item_heights.append(content_heights[i])
|
|
1652
|
+
|
|
1653
|
+
# Second pass: assign dimensions
|
|
1654
|
+
current_y = 0.0
|
|
1655
|
+
for i, item in enumerate(items):
|
|
1656
|
+
item.width_fraction = 1.0
|
|
1657
|
+
item.width = available_width
|
|
1658
|
+
item.height = item_heights[i]
|
|
1659
|
+
item.x = 0.0
|
|
1660
|
+
item.y = current_y
|
|
1661
|
+
current_y += item.height + gap + card_gap
|
|
1662
|
+
# Set render-ready sizing fields
|
|
1663
|
+
_set_render_ready_sizing(item)
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
def parse_dimension(value: str | None, total: float) -> float | None:
|
|
1667
|
+
"""Parse a dimension string to pixels.
|
|
1668
|
+
|
|
1669
|
+
Supports:
|
|
1670
|
+
- Percentages: "30%", "70%" -> fraction of total
|
|
1671
|
+
- Pixels: "200px", "200" -> exact pixels
|
|
1672
|
+
- None -> returns None (auto-distribute)
|
|
1673
|
+
|
|
1674
|
+
Args:
|
|
1675
|
+
value: Dimension string or None
|
|
1676
|
+
total: Total available size for percentage calculations
|
|
1677
|
+
|
|
1678
|
+
Returns:
|
|
1679
|
+
Pixel value or None if not specified
|
|
1680
|
+
"""
|
|
1681
|
+
if value is None:
|
|
1682
|
+
return None
|
|
1683
|
+
|
|
1684
|
+
value = str(value).strip()
|
|
1685
|
+
|
|
1686
|
+
if value.endswith("%"):
|
|
1687
|
+
try:
|
|
1688
|
+
percent = float(value[:-1])
|
|
1689
|
+
return (percent / 100.0) * total
|
|
1690
|
+
except ValueError:
|
|
1691
|
+
return None
|
|
1692
|
+
|
|
1693
|
+
if value.endswith("px"):
|
|
1694
|
+
try:
|
|
1695
|
+
return float(value[:-2])
|
|
1696
|
+
except ValueError:
|
|
1697
|
+
return None
|
|
1698
|
+
|
|
1699
|
+
# Try as plain number (pixels)
|
|
1700
|
+
try:
|
|
1701
|
+
return float(value)
|
|
1702
|
+
except ValueError:
|
|
1703
|
+
return None
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
def _resolve_layout_height(item: LayoutItem, available: float) -> float | None:
|
|
1707
|
+
"""Return resolved layout-wrapper height in px, or None for auto.
|
|
1708
|
+
|
|
1709
|
+
Percentages resolve against ``available``; measurement functions pass
|
|
1710
|
+
``available=0.0`` so percentages fall through to content-aware sizing
|
|
1711
|
+
(``parse_dimension("50%", 0.0)`` returns 0.0, filtered by ``h > 0``).
|
|
1712
|
+
|
|
1713
|
+
Explicit ``height: 0`` is rejected at compile time in
|
|
1714
|
+
``normalize_layout._validate_dimension``.
|
|
1715
|
+
"""
|
|
1716
|
+
h = parse_dimension(item.layout_height, available)
|
|
1717
|
+
return h if h is not None and h > 0 else None
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def _resolve_cols_widths(
|
|
1721
|
+
specified_widths: list[float | None], content_width: float, item_count: int
|
|
1722
|
+
) -> list[float]:
|
|
1723
|
+
"""Resolve column widths, avoiding zero-width auto items in mixed layouts.
|
|
1724
|
+
|
|
1725
|
+
Normal case:
|
|
1726
|
+
- explicit widths keep their authored size
|
|
1727
|
+
- auto items split the leftover width equally
|
|
1728
|
+
|
|
1729
|
+
Defensive overflow case:
|
|
1730
|
+
- when explicit widths consume the full row and auto siblings exist,
|
|
1731
|
+
reserve one equal-share slot per auto item
|
|
1732
|
+
- scale explicit widths proportionally into the remaining space
|
|
1733
|
+
|
|
1734
|
+
This preserves authored ratios for explicit siblings while avoiding
|
|
1735
|
+
unreadable 0px auto columns in over-constrained mixed layouts.
|
|
1736
|
+
"""
|
|
1737
|
+
if item_count == 0:
|
|
1738
|
+
return []
|
|
1739
|
+
|
|
1740
|
+
total_specified = sum(width for width in specified_widths if width is not None)
|
|
1741
|
+
auto_count = sum(1 for width in specified_widths if width is None)
|
|
1742
|
+
|
|
1743
|
+
if auto_count == 0:
|
|
1744
|
+
return [width if width is not None else 0.0 for width in specified_widths]
|
|
1745
|
+
|
|
1746
|
+
remaining_width = content_width - total_specified
|
|
1747
|
+
if remaining_width > 0:
|
|
1748
|
+
auto_width = remaining_width / auto_count
|
|
1749
|
+
return [auto_width if width is None else width for width in specified_widths]
|
|
1750
|
+
|
|
1751
|
+
equal_share = content_width / item_count if item_count > 0 else 0.0
|
|
1752
|
+
auto_width = equal_share
|
|
1753
|
+
specified_budget = max(content_width - (auto_width * auto_count), 0.0)
|
|
1754
|
+
scale = specified_budget / total_specified if total_specified > 0 else 0.0
|
|
1755
|
+
|
|
1756
|
+
return [
|
|
1757
|
+
auto_width if width is None else width * scale for width in specified_widths
|
|
1758
|
+
]
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def _calculate_cols_dimensions(
|
|
1762
|
+
items: list[LayoutItem],
|
|
1763
|
+
available_width: float,
|
|
1764
|
+
available_height: float,
|
|
1765
|
+
card_gap: float,
|
|
1766
|
+
gap: float,
|
|
1767
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
1768
|
+
height_provider: HeightProvider | None = None,
|
|
1769
|
+
*,
|
|
1770
|
+
resolved_style: MergedStyle,
|
|
1771
|
+
) -> None:
|
|
1772
|
+
"""Calculate dimensions for items in a cols layout.
|
|
1773
|
+
|
|
1774
|
+
In a cols layout:
|
|
1775
|
+
- Items arrange horizontally
|
|
1776
|
+
- Width respects user-specified values (e.g., "30%", "200px")
|
|
1777
|
+
- Remaining width is distributed equally among items without specified widths
|
|
1778
|
+
- ALL items get the SAME height (max of content heights, capped at available)
|
|
1779
|
+
|
|
1780
|
+
This ensures alignment: all items in a row have the same height.
|
|
1781
|
+
The height is the max of what each item needs, but won't exceed available.
|
|
1782
|
+
|
|
1783
|
+
Args:
|
|
1784
|
+
items: List of layout items
|
|
1785
|
+
available_width: Available width in pixels
|
|
1786
|
+
available_height: Available height in pixels (upper bound)
|
|
1787
|
+
card_gap: Gap between cards (inter-item spacing)
|
|
1788
|
+
gap: Gap between items in pixels
|
|
1789
|
+
variable_defaults: Optional variable defaults for Jinja resolution
|
|
1790
|
+
"""
|
|
1791
|
+
n = len(items)
|
|
1792
|
+
if n == 0:
|
|
1793
|
+
return
|
|
1794
|
+
|
|
1795
|
+
# Calculate total gap space
|
|
1796
|
+
effective_gap = gap + card_gap
|
|
1797
|
+
total_gap = effective_gap * (n - 1)
|
|
1798
|
+
content_width = available_width - total_gap
|
|
1799
|
+
|
|
1800
|
+
# First pass: parse user-specified widths and calculate remaining space
|
|
1801
|
+
specified_widths: list[float | None] = []
|
|
1802
|
+
|
|
1803
|
+
for item in items:
|
|
1804
|
+
parsed_width = parse_dimension(item.user_width, content_width)
|
|
1805
|
+
specified_widths.append(parsed_width)
|
|
1806
|
+
|
|
1807
|
+
resolved_widths = _resolve_cols_widths(
|
|
1808
|
+
specified_widths, content_width=content_width, item_count=n
|
|
1809
|
+
)
|
|
1810
|
+
|
|
1811
|
+
# Find max content height (all items in cols share this height).
|
|
1812
|
+
# Track specified and content maxima separately so overflow semantics
|
|
1813
|
+
# are order-independent: if max_specified >= max_content, the user's
|
|
1814
|
+
# explicit height wins and the row overflows rather than being capped.
|
|
1815
|
+
max_specified = 0.0
|
|
1816
|
+
max_auto = MIN_CONTENT_HEIGHT
|
|
1817
|
+
for i, item in enumerate(items):
|
|
1818
|
+
# Percentages resolve against available_height (no vertical gap in cols)
|
|
1819
|
+
resolved = _resolve_layout_height(item, available_height)
|
|
1820
|
+
if resolved is not None:
|
|
1821
|
+
max_specified = max(max_specified, resolved)
|
|
1822
|
+
else:
|
|
1823
|
+
item_w = resolved_widths[i]
|
|
1824
|
+
ch = _resolve_height(
|
|
1825
|
+
item,
|
|
1826
|
+
card_gap,
|
|
1827
|
+
gap,
|
|
1828
|
+
item_w,
|
|
1829
|
+
variable_defaults,
|
|
1830
|
+
height_provider,
|
|
1831
|
+
resolved_style=resolved_style,
|
|
1832
|
+
)
|
|
1833
|
+
max_auto = max(max_auto, ch)
|
|
1834
|
+
|
|
1835
|
+
max_content_height = max(max_specified, max_auto)
|
|
1836
|
+
# Cap to available height unless a user-specified height drove the max.
|
|
1837
|
+
# >= so that a specified height equal to the content max still wins
|
|
1838
|
+
# (the user explicitly asked for this height, so don't clamp it).
|
|
1839
|
+
row_height = (
|
|
1840
|
+
max_content_height
|
|
1841
|
+
if max_specified >= max_auto
|
|
1842
|
+
else min(max_content_height, available_height)
|
|
1843
|
+
)
|
|
1844
|
+
|
|
1845
|
+
# Second pass: assign dimensions
|
|
1846
|
+
current_x = 0.0
|
|
1847
|
+
for i, item in enumerate(items):
|
|
1848
|
+
item_width = resolved_widths[i]
|
|
1849
|
+
item.width_fraction = (
|
|
1850
|
+
item_width / content_width if content_width > 0 else 1.0 / n
|
|
1851
|
+
)
|
|
1852
|
+
item.width = item_width
|
|
1853
|
+
item.height = row_height # All items get same height
|
|
1854
|
+
item.x = current_x
|
|
1855
|
+
item.y = 0.0
|
|
1856
|
+
# Add gap between items, but not after the last item
|
|
1857
|
+
if i < n - 1:
|
|
1858
|
+
current_x += item_width + effective_gap
|
|
1859
|
+
else:
|
|
1860
|
+
current_x += item_width
|
|
1861
|
+
# Set render-ready sizing fields
|
|
1862
|
+
_set_render_ready_sizing(item)
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def _calculate_grid_dimensions(
|
|
1866
|
+
items: list[LayoutItem],
|
|
1867
|
+
available_width: float,
|
|
1868
|
+
available_height: float,
|
|
1869
|
+
columns: int,
|
|
1870
|
+
card_gap: float,
|
|
1871
|
+
gap: float,
|
|
1872
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
1873
|
+
height_provider: HeightProvider | None = None,
|
|
1874
|
+
*,
|
|
1875
|
+
resolved_style: MergedStyle,
|
|
1876
|
+
) -> None:
|
|
1877
|
+
"""Calculate dimensions for items in a grid layout.
|
|
1878
|
+
|
|
1879
|
+
In a grid layout:
|
|
1880
|
+
- Items can have explicit row, col positions and row_span, col_span
|
|
1881
|
+
- Items WITHOUT explicit positions are auto-flowed like CSS grid
|
|
1882
|
+
- Width is based on column count
|
|
1883
|
+
- Row height is calculated based on content types
|
|
1884
|
+
|
|
1885
|
+
Args:
|
|
1886
|
+
items: List of layout items with grid positions
|
|
1887
|
+
available_width: Available width in pixels
|
|
1888
|
+
available_height: Available height in pixels
|
|
1889
|
+
columns: Number of grid columns
|
|
1890
|
+
card_gap: Gap between cards (inter-item spacing)
|
|
1891
|
+
gap: Gap between items in pixels
|
|
1892
|
+
variable_defaults: Optional variable defaults for Jinja resolution
|
|
1893
|
+
"""
|
|
1894
|
+
if not items:
|
|
1895
|
+
return
|
|
1896
|
+
|
|
1897
|
+
# Calculate column width
|
|
1898
|
+
effective_gap = gap + card_gap
|
|
1899
|
+
total_gap_x = effective_gap * (columns - 1)
|
|
1900
|
+
col_width = (available_width - total_gap_x) / columns
|
|
1901
|
+
|
|
1902
|
+
# Auto-flow items without explicit positions
|
|
1903
|
+
# Track occupied cells as a set of (col, row) tuples
|
|
1904
|
+
occupied: set = set()
|
|
1905
|
+
|
|
1906
|
+
# First pass: mark cells occupied by items with explicit positions
|
|
1907
|
+
for item in items:
|
|
1908
|
+
if item.col is not None and item.row is not None:
|
|
1909
|
+
item_col_span = item.col_span or 1
|
|
1910
|
+
item_row_span = item.row_span or 1
|
|
1911
|
+
for c in range(item.col, item.col + item_col_span):
|
|
1912
|
+
for r in range(item.row, item.row + item_row_span):
|
|
1913
|
+
occupied.add((c, r))
|
|
1914
|
+
|
|
1915
|
+
# Second pass: auto-place items without explicit positions
|
|
1916
|
+
current_col = 0
|
|
1917
|
+
current_row = 0
|
|
1918
|
+
|
|
1919
|
+
for item in items:
|
|
1920
|
+
if item.col is None or item.row is None:
|
|
1921
|
+
item_col_span = item.col_span or 1
|
|
1922
|
+
item_row_span = item.row_span or 1
|
|
1923
|
+
|
|
1924
|
+
# Find next available position that fits the item
|
|
1925
|
+
placed = False
|
|
1926
|
+
while not placed:
|
|
1927
|
+
# Check if item fits at current position
|
|
1928
|
+
fits = True
|
|
1929
|
+
if current_col + item_col_span > columns:
|
|
1930
|
+
# Doesn't fit, move to next row
|
|
1931
|
+
current_col = 0
|
|
1932
|
+
current_row += 1
|
|
1933
|
+
continue
|
|
1934
|
+
|
|
1935
|
+
# Check if all cells are available
|
|
1936
|
+
for c in range(current_col, current_col + item_col_span):
|
|
1937
|
+
for r in range(current_row, current_row + item_row_span):
|
|
1938
|
+
if (c, r) in occupied:
|
|
1939
|
+
fits = False
|
|
1940
|
+
break
|
|
1941
|
+
if not fits:
|
|
1942
|
+
break
|
|
1943
|
+
|
|
1944
|
+
if fits:
|
|
1945
|
+
# Place the item
|
|
1946
|
+
item.col = current_col
|
|
1947
|
+
item.row = current_row
|
|
1948
|
+
# Mark cells as occupied
|
|
1949
|
+
for c in range(current_col, current_col + item_col_span):
|
|
1950
|
+
for r in range(current_row, current_row + item_row_span):
|
|
1951
|
+
occupied.add((c, r))
|
|
1952
|
+
placed = True
|
|
1953
|
+
# Move to next column for next item
|
|
1954
|
+
current_col += item_col_span
|
|
1955
|
+
else:
|
|
1956
|
+
# Try next column
|
|
1957
|
+
current_col += 1
|
|
1958
|
+
|
|
1959
|
+
# Calculate number of rows
|
|
1960
|
+
max_row_end = 1
|
|
1961
|
+
for item in items:
|
|
1962
|
+
item_row = item.row or 0
|
|
1963
|
+
item_rows = item.row_span or 1
|
|
1964
|
+
row_end = item_row + item_rows
|
|
1965
|
+
max_row_end = max(max_row_end, row_end)
|
|
1966
|
+
|
|
1967
|
+
# Calculate content-aware row height
|
|
1968
|
+
# Find max content height per row, then average
|
|
1969
|
+
row_content_heights: dict = {} # row_index -> max_height
|
|
1970
|
+
|
|
1971
|
+
# Track which rows have heights driven by user-specified values.
|
|
1972
|
+
# A row with any specified-height item is exempt from scaling,
|
|
1973
|
+
# even if the row's max came from an auto item. This is intentional:
|
|
1974
|
+
# the user pinned at least one item in that row, so we respect the
|
|
1975
|
+
# resulting row height rather than squashing it.
|
|
1976
|
+
specified_rows: set[int] = set()
|
|
1977
|
+
|
|
1978
|
+
for item in items:
|
|
1979
|
+
item_row = item.row or 0
|
|
1980
|
+
item_row_span = item.row_span or 1
|
|
1981
|
+
|
|
1982
|
+
# User-specified height takes precedence
|
|
1983
|
+
resolved = _resolve_layout_height(item, available_height)
|
|
1984
|
+
if resolved is not None:
|
|
1985
|
+
content_height = resolved
|
|
1986
|
+
for r in range(item_row, item_row + item_row_span):
|
|
1987
|
+
specified_rows.add(r)
|
|
1988
|
+
else:
|
|
1989
|
+
content_height = _resolve_height(
|
|
1990
|
+
item,
|
|
1991
|
+
card_gap,
|
|
1992
|
+
gap,
|
|
1993
|
+
col_width,
|
|
1994
|
+
variable_defaults,
|
|
1995
|
+
height_provider,
|
|
1996
|
+
resolved_style=resolved_style,
|
|
1997
|
+
)
|
|
1998
|
+
# Distribute height across spanned rows
|
|
1999
|
+
height_per_row = content_height / item_row_span
|
|
2000
|
+
|
|
2001
|
+
for r in range(item_row, item_row + item_row_span):
|
|
2002
|
+
current_max = row_content_heights.get(r, MIN_CONTENT_HEIGHT)
|
|
2003
|
+
row_content_heights[r] = max(current_max, height_per_row)
|
|
2004
|
+
|
|
2005
|
+
# Calculate total content height
|
|
2006
|
+
total_content_height = sum(row_content_heights.values())
|
|
2007
|
+
total_gap_y = effective_gap * (max_row_end - 1)
|
|
2008
|
+
|
|
2009
|
+
# Use content height if it fits, otherwise scale auto rows to fit.
|
|
2010
|
+
# Rows with user-specified heights are exempt from scaling (overflow).
|
|
2011
|
+
if total_content_height + total_gap_y <= available_height:
|
|
2012
|
+
row_heights = row_content_heights
|
|
2013
|
+
else:
|
|
2014
|
+
specified_total = sum(
|
|
2015
|
+
row_content_heights[r] for r in specified_rows if r in row_content_heights
|
|
2016
|
+
)
|
|
2017
|
+
auto_total = total_content_height - specified_total
|
|
2018
|
+
auto_budget = max(available_height - total_gap_y - specified_total, 0.0)
|
|
2019
|
+
auto_scale = auto_budget / auto_total if auto_total > 0 else 1.0
|
|
2020
|
+
row_heights = {
|
|
2021
|
+
r: h if r in specified_rows else h * auto_scale
|
|
2022
|
+
for r, h in row_content_heights.items()
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
# Calculate row Y positions
|
|
2026
|
+
row_y_positions: dict = {}
|
|
2027
|
+
current_y = 0.0
|
|
2028
|
+
for r in range(max_row_end):
|
|
2029
|
+
row_y_positions[r] = current_y
|
|
2030
|
+
row_h = row_heights.get(r, DEFAULT_CHART_HEIGHT)
|
|
2031
|
+
current_y += row_h + effective_gap
|
|
2032
|
+
|
|
2033
|
+
# Position each item
|
|
2034
|
+
for item in items:
|
|
2035
|
+
item_col = item.col or 0
|
|
2036
|
+
item_row = item.row or 0
|
|
2037
|
+
item_col_span = item.col_span or 1
|
|
2038
|
+
item_row_span = item.row_span or 1
|
|
2039
|
+
|
|
2040
|
+
# Calculate pixel position
|
|
2041
|
+
item.x = item_col * (col_width + effective_gap)
|
|
2042
|
+
item.y = row_y_positions.get(item_row, 0.0)
|
|
2043
|
+
|
|
2044
|
+
# Calculate pixel dimensions
|
|
2045
|
+
item.width = item_col_span * col_width + (item_col_span - 1) * effective_gap
|
|
2046
|
+
|
|
2047
|
+
# Height spans multiple rows
|
|
2048
|
+
item_height = 0.0
|
|
2049
|
+
for r in range(item_row, item_row + item_row_span):
|
|
2050
|
+
item_height += row_heights.get(r, DEFAULT_CHART_HEIGHT)
|
|
2051
|
+
item_height += effective_gap * (item_row_span - 1) # Internal gaps
|
|
2052
|
+
item.height = item_height
|
|
2053
|
+
|
|
2054
|
+
# Calculate width fraction
|
|
2055
|
+
item.width_fraction = item_col_span / columns
|
|
2056
|
+
|
|
2057
|
+
# Set render-ready sizing fields
|
|
2058
|
+
_set_render_ready_sizing(item)
|
|
2059
|
+
|
|
2060
|
+
|
|
2061
|
+
def _calculate_tabs_dimensions(
|
|
2062
|
+
items: list[LayoutItem],
|
|
2063
|
+
available_width: float,
|
|
2064
|
+
available_height: float,
|
|
2065
|
+
card_gap: float,
|
|
2066
|
+
gap: float,
|
|
2067
|
+
variable_defaults: dict[str, Any] | None = None,
|
|
2068
|
+
height_provider: HeightProvider | None = None,
|
|
2069
|
+
*,
|
|
2070
|
+
resolved_style: MergedStyle,
|
|
2071
|
+
) -> None:
|
|
2072
|
+
"""Calculate dimensions for items in a tabs layout.
|
|
2073
|
+
|
|
2074
|
+
In a tabs layout:
|
|
2075
|
+
- Each tab gets the full container size (minus tab bar height)
|
|
2076
|
+
- Only one tab is visible at a time
|
|
2077
|
+
- Height is based on max content height of all tabs
|
|
2078
|
+
|
|
2079
|
+
Args:
|
|
2080
|
+
items: List of layout items (one per tab)
|
|
2081
|
+
available_width: Available width in pixels
|
|
2082
|
+
available_height: Available height in pixels
|
|
2083
|
+
card_gap: Gap between cards (inter-item spacing)
|
|
2084
|
+
gap: Gap (unused for tabs)
|
|
2085
|
+
variable_defaults: Optional variable defaults for Jinja resolution
|
|
2086
|
+
"""
|
|
2087
|
+
# Reserve space for tab bar
|
|
2088
|
+
tab_bar_height = float(get_config().style.layout.tabs.bar_height)
|
|
2089
|
+
|
|
2090
|
+
# Find max content height across all tabs.
|
|
2091
|
+
# Track specified and content maxima separately (order-independent).
|
|
2092
|
+
tab_available = available_height - tab_bar_height
|
|
2093
|
+
max_specified = 0.0
|
|
2094
|
+
max_auto = MIN_CONTENT_HEIGHT
|
|
2095
|
+
for item in items:
|
|
2096
|
+
resolved = _resolve_layout_height(item, tab_available)
|
|
2097
|
+
if resolved is not None:
|
|
2098
|
+
max_specified = max(max_specified, resolved)
|
|
2099
|
+
else:
|
|
2100
|
+
ch = _resolve_height(
|
|
2101
|
+
item,
|
|
2102
|
+
card_gap,
|
|
2103
|
+
gap,
|
|
2104
|
+
available_width,
|
|
2105
|
+
variable_defaults,
|
|
2106
|
+
height_provider,
|
|
2107
|
+
resolved_style=resolved_style,
|
|
2108
|
+
)
|
|
2109
|
+
max_auto = max(max_auto, ch)
|
|
2110
|
+
|
|
2111
|
+
max_content_height = max(max_specified, max_auto)
|
|
2112
|
+
# Cap to available height unless the max was driven by a user-specified height
|
|
2113
|
+
content_height = (
|
|
2114
|
+
max_content_height
|
|
2115
|
+
if max_specified >= max_auto
|
|
2116
|
+
else min(max_content_height, tab_available)
|
|
2117
|
+
)
|
|
2118
|
+
|
|
2119
|
+
for item in items:
|
|
2120
|
+
item.width_fraction = 1.0
|
|
2121
|
+
item.width = available_width
|
|
2122
|
+
item.height = content_height
|
|
2123
|
+
item.x = 0.0
|
|
2124
|
+
item.y = tab_bar_height
|
|
2125
|
+
# Set render-ready sizing fields
|
|
2126
|
+
_set_render_ready_sizing(item)
|