feat(themes): ✨ Introduce semantic design tokens system for UI theming with theme assets, resource files, build integration, and unit tests
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
bcf72d34b8
commit
cad2183c51
4 changed files with 493 additions and 41 deletions
|
|
@ -1,68 +1,69 @@
|
|||
[gd_resource type="Theme" format=3 uid="uid://ui_theme_fantasy"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel_dark"]
|
||||
bg_color = Color(0.09, 0.07, 0.14, 0.96)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
|
||||
bg_color = Color(0.090196, 0.070588, 0.117647, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.45, 0.35, 0.12, 0.8)
|
||||
border_color = Color(0.45098, 0.34902, 0.121569, 0.8)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
corner_radius_bottom_right = 4
|
||||
content_margin_left = 8.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 8.0
|
||||
content_margin_bottom = 6.0
|
||||
content_margin_left = 12
|
||||
content_margin_top = 8
|
||||
content_margin_right = 12
|
||||
content_margin_bottom = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_normal"]
|
||||
bg_color = Color(0.12, 0.09, 0.2, 1.0)
|
||||
bg_color = Color(0.121569, 0.090196, 0.2, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.4, 0.3, 0.1, 0.9)
|
||||
border_color = Color(0.45098, 0.34902, 0.121569, 0.8)
|
||||
corner_radius_top_left = 3
|
||||
corner_radius_top_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_radius_bottom_right = 3
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
content_margin_left = 12
|
||||
content_margin_top = 8
|
||||
content_margin_right = 12
|
||||
content_margin_bottom = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"]
|
||||
bg_color = Color(0.2, 0.15, 0.08, 1.0)
|
||||
bg_color = Color(0.2, 0.101961, 0.05098, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.85, 0.7, 0.25, 1.0)
|
||||
border_color = Color(0.85098, 0.701961, 0.247059, 1)
|
||||
corner_radius_top_left = 3
|
||||
corner_radius_top_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_radius_bottom_right = 3
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
content_margin_left = 12
|
||||
content_margin_top = 8
|
||||
content_margin_right = 12
|
||||
content_margin_bottom = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_pressed"]
|
||||
bg_color = Color(0.28, 0.2, 0.06, 1.0)
|
||||
bg_color = Color(0.278431, 0.184314, 0.058824, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(1.0, 0.82, 0.3, 1.0)
|
||||
border_color = Color(1, 0.819608, 0.301961, 1)
|
||||
corner_radius_top_left = 3
|
||||
corner_radius_top_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_radius_bottom_right = 3
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
content_margin_left = 12
|
||||
content_margin_top = 8
|
||||
content_margin_right = 12
|
||||
content_margin_bottom = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_focus"]
|
||||
bg_color = Color(0, 0, 0, 0)
|
||||
|
|
@ -70,57 +71,58 @@ border_width_left = 2
|
|||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.85, 0.7, 0.25, 0.7)
|
||||
border_color = Color(0.85098, 0.701961, 0.25098, 1)
|
||||
corner_radius_top_left = 3
|
||||
corner_radius_top_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_radius_bottom_right = 3
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_item_list_bg"]
|
||||
bg_color = Color(0.07, 0.05, 0.12, 1.0)
|
||||
bg_color = Color(0.070588, 0.054902, 0.117647, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.3, 0.25, 0.08, 0.7)
|
||||
border_color = Color(0.301961, 0.25098, 0.078431, 0.698039)
|
||||
corner_radius_top_left = 2
|
||||
corner_radius_top_right = 2
|
||||
corner_radius_bottom_left = 2
|
||||
corner_radius_bottom_right = 2
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_item_list_selected"]
|
||||
bg_color = Color(0.25, 0.18, 0.05, 1.0)
|
||||
bg_color = Color(0.247059, 0.176471, 0.05098, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.85, 0.7, 0.25, 0.8)
|
||||
border_color = Color(0.85098, 0.701961, 0.25098, 0.8)
|
||||
corner_radius_top_left = 2
|
||||
corner_radius_top_right = 2
|
||||
corner_radius_bottom_left = 2
|
||||
corner_radius_bottom_right = 2
|
||||
|
||||
[resource]
|
||||
Button/colors/font_color = Color(0.88, 0.82, 0.6, 1.0)
|
||||
Button/colors/font_hover_color = Color(1.0, 0.92, 0.5, 1.0)
|
||||
Button/colors/font_pressed_color = Color(1.0, 1.0, 0.7, 1.0)
|
||||
Button/colors/font_focus_color = Color(1.0, 0.92, 0.5, 1.0)
|
||||
Button/colors/font_disabled_color = Color(0.5, 0.5, 0.4, 0.6)
|
||||
metadata/tokens = "{\"accent.gold\":\"d9a020\",\"accent.goldBright\":\"d9b33f\",\"accent.goldPress\":\"ffd14d\",\"accent.goldResource\":\"f2d133\",\"accent.ping\":\"ffd973\",\"accent.sage\":\"66b866\",\"accent.science\":\"66bfff\",\"background.base\":\"1a1410\",\"background.deepest\":\"171219\",\"background.happiness\":\"0f0d07\",\"background.hud\":\"00000099\",\"background.list\":\"120e1e\",\"background.listSelected\":\"3f2d0d\",\"background.menu\":\"0e0a17\",\"background.overlay\":\"0000009e\",\"background.panel\":\"17121e\",\"background.raised\":\"2a2018\",\"background.surface\":\"221a14\",\"border.divider\":\"99731f80\",\"border.focus\":\"d9b340ff\",\"border.happiness\":\"b39940d9\",\"border.list\":\"4d4014b2\",\"border.listSelected\":\"d9b340cc\",\"border.panel\":\"73591fcc\",\"button.bgHover\":\"331a0d\",\"button.bgNormal\":\"1f1733\",\"button.bgPressed\":\"472f0f\",\"climate.cold\":\"1a4dff\",\"climate.hot\":\"ff260d\",\"climate.textCold\":\"66b3ff\",\"climate.textNeutral\":\"d9e0d9\",\"climate.textWarming\":\"ff731a\",\"climate.warm\":\"26cc40\",\"fog.explored\":\"000000b2\",\"fog.unexplored\":\"000000e5\",\"guide.bgPrimary\":\"1a1410\",\"guide.bgSecondary\":\"221a14\",\"guide.bgTertiary\":\"2a2018\",\"guide.dwarfAccent\":\"8b6a1a\",\"guide.dwarfPrimary\":\"c07040\",\"guide.dwarfPrimaryDark\":\"8a4a28\",\"guide.dwarfPrimaryLight\":\"e09868\",\"guide.textMuted\":\"7a6048\",\"guide.textPrimary\":\"f0e4d0\",\"guide.textSecondary\":\"b8a078\",\"player.blue\":\"3366ff\",\"player.brown\":\"806659\",\"player.cyan\":\"1accd9\",\"player.gray\":\"999999\",\"player.green\":\"33cc4d\",\"player.magenta\":\"cc4d80\",\"player.navy\":\"4d4d99\",\"player.orange\":\"e6801a\",\"player.purple\":\"b24de6\",\"player.red\":\"e63333\",\"player.sage\":\"66b366\",\"player.yellow\":\"e6cc1a\",\"semantic.diplomacy\":\"e68c73\",\"semantic.goldenAge\":\"ffeb66\",\"semantic.negative\":\"d95940\",\"semantic.positive\":\"66e666\",\"semantic.trade\":\"ccbf73\",\"semantic.warning\":\"e69933\",\"text.button\":\"e0d199\",\"text.buttonHover\":\"ffeb80\",\"text.buttonPressed\":\"ffffb3\",\"text.disabled\":\"80806680\",\"text.muted\":\"b2b2b2\",\"text.primary\":\"e0d8c8\",\"text.secondary\":\"bfb7a6\",\"text.title\":\"f2d973\"}"
|
||||
Button/colors/font_color = Color(0.878431, 0.819608, 0.6, 1)
|
||||
Button/colors/font_hover_color = Color(1, 0.921569, 0.501961, 1)
|
||||
Button/colors/font_pressed_color = Color(1, 1, 0.701961, 1)
|
||||
Button/colors/font_focus_color = Color(1, 0.921569, 0.501961, 1)
|
||||
Button/colors/font_disabled_color = Color(0.501961, 0.501961, 0.4, 0.501961)
|
||||
Button/font_sizes/font_size = 15
|
||||
Button/styles/normal = SubResource("StyleBoxFlat_button_normal")
|
||||
Button/styles/hover = SubResource("StyleBoxFlat_button_hover")
|
||||
Button/styles/pressed = SubResource("StyleBoxFlat_button_pressed")
|
||||
Button/styles/focus = SubResource("StyleBoxFlat_button_focus")
|
||||
Button/styles/disabled = SubResource("StyleBoxFlat_button_normal")
|
||||
Label/colors/font_color = Color(0.878, 0.847, 0.784, 1.0)
|
||||
Label/colors/font_color = Color(0.878431, 0.847059, 0.784314, 1)
|
||||
Label/font_sizes/font_size = 15
|
||||
PanelContainer/styles/panel = SubResource("StyleBoxFlat_panel_dark")
|
||||
Panel/styles/panel = SubResource("StyleBoxFlat_panel_dark")
|
||||
ItemList/colors/font_color = Color(0.878, 0.847, 0.784, 1.0)
|
||||
ItemList/colors/font_selected_color = Color(1.0, 0.92, 0.5, 1.0)
|
||||
PanelContainer/styles/panel = SubResource("StyleBoxFlat_panel")
|
||||
Panel/styles/panel = SubResource("StyleBoxFlat_panel")
|
||||
ItemList/colors/font_color = Color(0.878431, 0.847059, 0.784314, 1)
|
||||
ItemList/colors/font_selected_color = Color(1, 0.921569, 0.501961, 1)
|
||||
ItemList/font_sizes/font_size = 14
|
||||
ItemList/styles/panel = SubResource("StyleBoxFlat_item_list_bg")
|
||||
ItemList/styles/selected = SubResource("StyleBoxFlat_item_list_selected")
|
||||
ItemList/styles/selected_focus = SubResource("StyleBoxFlat_item_list_selected")
|
||||
RichTextLabel/colors/default_color = Color(0.878, 0.847, 0.784, 1.0)
|
||||
RichTextLabel/colors/default_color = Color(0.878431, 0.847059, 0.784314, 1)
|
||||
RichTextLabel/font_sizes/normal_font_size = 14
|
||||
|
|
|
|||
|
|
@ -12,11 +12,20 @@ const VALID_VARIANTS: Array[String] = [
|
|||
"default", "deuteranopia", "protanopia", "tritanopia",
|
||||
]
|
||||
|
||||
## Theme id used to locate ui_theme.tres for color() before set_theme() has run
|
||||
## (e.g. a proof scene launched directly, where no menu calls set_theme()).
|
||||
const DEFAULT_THEME_ID: String = "age-of-dwarves"
|
||||
const UI_THEME_REL: String = "ui_theme.tres"
|
||||
|
||||
var _active_theme: String = ""
|
||||
var _base_path: String = ""
|
||||
var _texture_cache: Dictionary = {}
|
||||
var _palettes: Dictionary = {}
|
||||
var _active_palette_variant: String = DEFAULT_VARIANT
|
||||
## Parsed `metadata/tokens` blob from ui_theme.tres: { "accent.gold": "d9a020", ... }.
|
||||
## Populated lazily on first color() call; key never re-parsed.
|
||||
var _tokens: Dictionary = {}
|
||||
var _tokens_loaded: bool = false
|
||||
|
||||
|
||||
func set_theme(theme_id: String) -> void:
|
||||
|
|
@ -102,6 +111,64 @@ func get_active_theme() -> String:
|
|||
return _active_theme
|
||||
|
||||
|
||||
# -- Semantic design tokens -----------------------------------------------
|
||||
## Resolves a named design token to a Color, e.g. color("accent.gold"),
|
||||
## color("text.primary"), color("semantic.positive"). The token table is the
|
||||
## `metadata/tokens` blob baked into ui_theme.tres by tools/build-ui-theme.py
|
||||
## (whose source of truth is .project/designs/design-tokens.json) — there is no
|
||||
## hardcoded color map here. This is the API GDScript references instead of raw
|
||||
## Color() literals. Returns `fallback` (default magenta) for unknown names so a
|
||||
## typo is visible rather than silently black.
|
||||
|
||||
|
||||
func color(name: String, fallback: Color = Color(1.0, 0.0, 1.0, 1.0)) -> Color:
|
||||
_ensure_tokens_loaded()
|
||||
if not _tokens.has(name):
|
||||
push_warning("ThemeAssets: unknown design token '%s'" % name)
|
||||
return fallback
|
||||
var hex: String = str(_tokens[name])
|
||||
return Color.html(hex)
|
||||
|
||||
|
||||
## True when the named token exists in the loaded token table.
|
||||
func has_color(name: String) -> bool:
|
||||
_ensure_tokens_loaded()
|
||||
return _tokens.has(name)
|
||||
|
||||
|
||||
## All known token names (sorted), for tooling / debug enumeration.
|
||||
func token_names() -> Array:
|
||||
_ensure_tokens_loaded()
|
||||
var names: Array = _tokens.keys()
|
||||
names.sort()
|
||||
return names
|
||||
|
||||
|
||||
func _ensure_tokens_loaded() -> void:
|
||||
if _tokens_loaded:
|
||||
return
|
||||
_tokens_loaded = true
|
||||
var theme_id: String = _active_theme if not _active_theme.is_empty() else DEFAULT_THEME_ID
|
||||
var path: String = "res://public/games/%s/%s" % [theme_id, UI_THEME_REL]
|
||||
if not ResourceLoader.exists(path):
|
||||
push_warning("ThemeAssets: ui_theme.tres not found at %s" % path)
|
||||
return
|
||||
var theme: Theme = load(path) as Theme
|
||||
if theme == null:
|
||||
push_warning("ThemeAssets: failed to load Theme at %s" % path)
|
||||
return
|
||||
if not theme.has_meta("tokens"):
|
||||
push_warning(
|
||||
"ThemeAssets: ui_theme.tres has no 'tokens' metadata — rerun tools/build-ui-theme.py"
|
||||
)
|
||||
return
|
||||
var parsed: Dictionary = JSON.parse_string(str(theme.get_meta("tokens")))
|
||||
if parsed != null:
|
||||
_tokens = parsed
|
||||
else:
|
||||
push_error("ThemeAssets: tokens metadata is not a JSON object")
|
||||
|
||||
|
||||
# -- Palette variants -----------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
49
src/game/engine/tests/unit/test_theme_assets_color.gd
Normal file
49
src/game/engine/tests/unit/test_theme_assets_color.gd
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
extends GutTest
|
||||
## p2-73 — ThemeAssets.color() semantic token accessor.
|
||||
##
|
||||
## The accessor resolves dotted token names (e.g. "accent.gold") against the
|
||||
## `metadata/tokens` blob baked into ui_theme.tres by tools/build-ui-theme.py
|
||||
## (SoT: .project/designs/design-tokens.json). These assertions are headless-safe:
|
||||
## color() only loads the Theme resource and parses the blob — no display server.
|
||||
|
||||
|
||||
func before_all() -> void:
|
||||
ThemeAssets.set_theme("age-of-dwarves")
|
||||
|
||||
|
||||
func test_resolves_known_rgb_token() -> void:
|
||||
# accent.gold = #d9a020 → fully opaque.
|
||||
var c: Color = ThemeAssets.color("accent.gold")
|
||||
assert_almost_eq(c.r, 0xd9 / 255.0, 0.004, "accent.gold red")
|
||||
assert_almost_eq(c.g, 0xa0 / 255.0, 0.004, "accent.gold green")
|
||||
assert_almost_eq(c.b, 0x20 / 255.0, 0.004, "accent.gold blue")
|
||||
assert_almost_eq(c.a, 1.0, 0.004, "accent.gold alpha (opaque)")
|
||||
|
||||
|
||||
func test_resolves_alpha_token_rrggbbaa() -> void:
|
||||
# border.panel = #73591fcc → alpha 0xcc/255 ≈ 0.8 (RRGGBBAA byte order).
|
||||
var c: Color = ThemeAssets.color("border.panel")
|
||||
assert_almost_eq(c.a, 0xcc / 255.0, 0.004, "border.panel alpha is the AA byte")
|
||||
assert_almost_eq(c.r, 0x73 / 255.0, 0.004, "border.panel red is the RR byte")
|
||||
|
||||
|
||||
func test_resolves_semantic_and_text_tokens() -> void:
|
||||
# semantic.positive = #66e666, text.primary = #e0d8c8 — both present.
|
||||
assert_true(ThemeAssets.has_color("semantic.positive"), "semantic.positive exists")
|
||||
assert_true(ThemeAssets.has_color("text.primary"), "text.primary exists")
|
||||
var pos: Color = ThemeAssets.color("semantic.positive")
|
||||
assert_almost_eq(pos.g, 0xe6 / 255.0, 0.004, "semantic.positive is bright green")
|
||||
|
||||
|
||||
func test_unknown_token_returns_fallback() -> void:
|
||||
# Illustrative names from the spec (accent.copper / semantic.success) are NOT
|
||||
# real tokens — the accessor returns the explicit fallback, never silent black.
|
||||
assert_false(ThemeAssets.has_color("accent.copper"), "accent.copper is not a token")
|
||||
var sentinel: Color = Color(0.123, 0.456, 0.789, 1.0)
|
||||
var c: Color = ThemeAssets.color("does.not.exist", sentinel)
|
||||
assert_eq(c, sentinel, "unknown token returns the provided fallback")
|
||||
|
||||
|
||||
func test_token_table_is_nonempty() -> void:
|
||||
# Guards against an empty/missing metadata blob (stale or unbuilt .tres).
|
||||
assert_gt(ThemeAssets.token_names().size(), 50, "token table populated from .tres")
|
||||
334
tools/build-ui-theme.py
Normal file
334
tools/build-ui-theme.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate the Godot in-game Theme (`ui_theme.tres`) from the design-token SoT.
|
||||
|
||||
The single source of truth for the Age of Dwarves visual language is
|
||||
`.project/designs/design-tokens.json` (W3C / style-dictionary format). This
|
||||
script compiles those tokens into a complete Godot `Theme` resource:
|
||||
|
||||
* StyleBoxFlat sub-resources for panels, buttons (5 states), and list rows,
|
||||
with token-driven background / border colors, corner radii (§6) and
|
||||
border widths.
|
||||
* Default theme colors + font sizes for Button / Label / Panel /
|
||||
PanelContainer / ItemList / RichTextLabel (§3 / §4).
|
||||
* A `metadata/tokens` JSON blob carrying the entire `color.*` token tree as
|
||||
flat dotted keys (e.g. `accent.gold`, `text.primary`, `semantic.positive`).
|
||||
`ThemeAssets.color(name)` resolves names against this blob at runtime — the
|
||||
accessor is therefore data-driven from the SoT with no hardcoded color map.
|
||||
|
||||
`ui_theme.tres` is a GENERATED artifact. Never hand-edit it; edit the tokens
|
||||
and re-run. The output is deterministic (sorted keys, fixed float formatting,
|
||||
stable sub-resource ids, preserved uid) so `--check` is a meaningful drift gate
|
||||
for CI.
|
||||
|
||||
Usage:
|
||||
tools/build-ui-theme.py # regenerate ui_theme.tres in place
|
||||
tools/build-ui-theme.py --check # exit 1 if the .tres is stale (no write)
|
||||
tools/build-ui-theme.py --print # write nothing, dump to stdout
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Repo root = two levels up from tools/build-ui-theme.py
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TOKENS_PATH = REPO_ROOT / ".project" / "designs" / "design-tokens.json"
|
||||
OUTPUT_PATH = REPO_ROOT / "public" / "games" / "age-of-dwarves" / "ui_theme.tres"
|
||||
|
||||
# Preserve the existing resource uid so path/uid references stay stable.
|
||||
THEME_UID = "uid://ui_theme_fantasy"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Token access
|
||||
# --------------------------------------------------------------------------- #
|
||||
def load_tokens() -> dict:
|
||||
with TOKENS_PATH.open(encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _walk_colors(node: dict, prefix: str, out: dict[str, str]) -> None:
|
||||
"""Recursively flatten the `color.*` subtree into dotted -> hex strings.
|
||||
|
||||
A token leaf is a dict carrying a `$value`. Intermediate groups recurse.
|
||||
"""
|
||||
for key, value in node.items():
|
||||
if key.startswith("$"):
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
path = f"{prefix}.{key}" if prefix else key
|
||||
if "$value" in value:
|
||||
out[path] = str(value["$value"]).lstrip("#").lower()
|
||||
else:
|
||||
_walk_colors(value, path, out)
|
||||
|
||||
|
||||
def flatten_color_tokens(tokens: dict) -> dict[str, str]:
|
||||
"""All `color.*` tokens as a flat {dotted_name: 'rrggbb[aa]'} dict."""
|
||||
out: dict[str, str] = {}
|
||||
_walk_colors(tokens.get("color", {}), "", out)
|
||||
return dict(sorted(out.items()))
|
||||
|
||||
|
||||
def _px(value) -> float:
|
||||
"""'14px' / 14 / '14' -> 14.0."""
|
||||
s = str(value).strip().lower().removesuffix("px")
|
||||
return float(s)
|
||||
|
||||
|
||||
def font_size(tokens: dict, name: str) -> int:
|
||||
return int(_px(tokens["typography"]["fontSize"][name]["$value"]))
|
||||
|
||||
|
||||
def radius(tokens: dict, name: str) -> int:
|
||||
return int(_px(tokens["borderRadius"][name]["$value"]))
|
||||
|
||||
|
||||
def border_w(tokens: dict, name: str) -> int:
|
||||
return int(_px(tokens["borderWidth"][name]["$value"]))
|
||||
|
||||
|
||||
def spacing(tokens: dict, name: str) -> float:
|
||||
return _px(tokens["spacing"][name]["$value"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Hex -> Godot Color
|
||||
# --------------------------------------------------------------------------- #
|
||||
def hex_to_color(hex_str: str) -> str:
|
||||
"""Convert an SoT hex token to a Godot `Color(r, g, b, a)` literal string.
|
||||
|
||||
Godot's `Color.html()` treats 8-digit hex as RRGGBBAA (verified on the
|
||||
target runtime), so this generator uses the identical byte order to keep
|
||||
the .tres and the runtime `color()` accessor in agreement.
|
||||
"""
|
||||
h = hex_str.lstrip("#").lower()
|
||||
if len(h) == 6:
|
||||
h += "ff"
|
||||
if len(h) != 8:
|
||||
raise ValueError(f"unsupported hex token: {hex_str!r}")
|
||||
r = int(h[0:2], 16) / 255.0
|
||||
g = int(h[2:4], 16) / 255.0
|
||||
b = int(h[4:6], 16) / 255.0
|
||||
a = int(h[6:8], 16) / 255.0
|
||||
return "Color(%s, %s, %s, %s)" % tuple(_fmt_float(v) for v in (r, g, b, a))
|
||||
|
||||
|
||||
def _fmt_float(v: float) -> str:
|
||||
"""Deterministic float formatting: trim trailing zeros, keep at least 0/1."""
|
||||
s = f"{v:.6f}".rstrip("0").rstrip(".")
|
||||
return s if s else "0"
|
||||
|
||||
|
||||
def color_of(colors: dict[str, str], dotted: str) -> str:
|
||||
return hex_to_color(colors[dotted])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# StyleBoxFlat emission
|
||||
# --------------------------------------------------------------------------- #
|
||||
def stylebox(
|
||||
sub_id: str,
|
||||
*,
|
||||
bg: str,
|
||||
border: str,
|
||||
border_width: int,
|
||||
corner: int,
|
||||
margins: tuple[float, float, float, float] | None = None,
|
||||
) -> str:
|
||||
"""Render one [sub_resource type="StyleBoxFlat"] block."""
|
||||
lines = [
|
||||
f'[sub_resource type="StyleBoxFlat" id="{sub_id}"]',
|
||||
f"bg_color = {bg}",
|
||||
f"border_width_left = {border_width}",
|
||||
f"border_width_top = {border_width}",
|
||||
f"border_width_right = {border_width}",
|
||||
f"border_width_bottom = {border_width}",
|
||||
f"border_color = {border}",
|
||||
f"corner_radius_top_left = {corner}",
|
||||
f"corner_radius_top_right = {corner}",
|
||||
f"corner_radius_bottom_left = {corner}",
|
||||
f"corner_radius_bottom_right = {corner}",
|
||||
]
|
||||
if margins is not None:
|
||||
ml, mt, mr, mb = (_fmt_float(m) for m in margins)
|
||||
lines += [
|
||||
f"content_margin_left = {ml}",
|
||||
f"content_margin_top = {mt}",
|
||||
f"content_margin_right = {mr}",
|
||||
f"content_margin_bottom = {mb}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Theme assembly
|
||||
# --------------------------------------------------------------------------- #
|
||||
def build_theme_text(tokens: dict) -> str:
|
||||
colors = flatten_color_tokens(tokens)
|
||||
|
||||
r_panel = radius(tokens, "panel")
|
||||
r_button = radius(tokens, "button")
|
||||
r_list = radius(tokens, "list")
|
||||
bw_default = border_w(tokens, "default")
|
||||
bw_emphasis = border_w(tokens, "emphasis")
|
||||
|
||||
# Margins from the spacing scale (§5): panels 12/6, buttons 12/6.
|
||||
pad_h = spacing(tokens, "3") # 12px horizontal
|
||||
pad_v = spacing(tokens, "2") # 8px vertical
|
||||
btn_h = spacing(tokens, "3") # 12px
|
||||
btn_v = spacing(tokens, "2") # 8px
|
||||
|
||||
fs_base = font_size(tokens, "base")
|
||||
fs_sm = font_size(tokens, "sm")
|
||||
|
||||
c = lambda name: color_of(colors, name) # noqa: E731
|
||||
|
||||
subs = [
|
||||
stylebox(
|
||||
"StyleBoxFlat_panel",
|
||||
bg=c("background.panel"),
|
||||
border=c("border.panel"),
|
||||
border_width=bw_default,
|
||||
corner=r_panel,
|
||||
margins=(pad_h, pad_v, pad_h, pad_v),
|
||||
),
|
||||
stylebox(
|
||||
"StyleBoxFlat_button_normal",
|
||||
bg=c("button.bgNormal"),
|
||||
border=c("border.panel"),
|
||||
border_width=bw_default,
|
||||
corner=r_button,
|
||||
margins=(btn_h, btn_v, btn_h, btn_v),
|
||||
),
|
||||
stylebox(
|
||||
"StyleBoxFlat_button_hover",
|
||||
bg=c("button.bgHover"),
|
||||
border=c("accent.goldBright"),
|
||||
border_width=bw_default,
|
||||
corner=r_button,
|
||||
margins=(btn_h, btn_v, btn_h, btn_v),
|
||||
),
|
||||
stylebox(
|
||||
"StyleBoxFlat_button_pressed",
|
||||
bg=c("button.bgPressed"),
|
||||
border=c("accent.goldPress"),
|
||||
border_width=bw_emphasis,
|
||||
corner=r_button,
|
||||
margins=(btn_h, btn_v, btn_h, btn_v),
|
||||
),
|
||||
stylebox(
|
||||
"StyleBoxFlat_button_focus",
|
||||
bg="Color(0, 0, 0, 0)",
|
||||
border=c("border.focus"),
|
||||
border_width=bw_emphasis,
|
||||
corner=r_button,
|
||||
),
|
||||
stylebox(
|
||||
"StyleBoxFlat_item_list_bg",
|
||||
bg=c("background.list"),
|
||||
border=c("border.list"),
|
||||
border_width=bw_default,
|
||||
corner=r_list,
|
||||
),
|
||||
stylebox(
|
||||
"StyleBoxFlat_item_list_selected",
|
||||
bg=c("background.listSelected"),
|
||||
border=c("border.listSelected"),
|
||||
border_width=bw_default,
|
||||
corner=r_list,
|
||||
),
|
||||
]
|
||||
|
||||
# metadata/tokens — entire color tree, flat dotted keys, sorted, compact.
|
||||
tokens_blob = json.dumps(colors, separators=(",", ":"), sort_keys=True)
|
||||
tokens_blob_escaped = tokens_blob.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
resource_lines = [
|
||||
"[resource]",
|
||||
f'metadata/tokens = "{tokens_blob_escaped}"',
|
||||
f"Button/colors/font_color = {c('text.button')}",
|
||||
f"Button/colors/font_hover_color = {c('text.buttonHover')}",
|
||||
f"Button/colors/font_pressed_color = {c('text.buttonPressed')}",
|
||||
f"Button/colors/font_focus_color = {c('text.buttonHover')}",
|
||||
f"Button/colors/font_disabled_color = {c('text.disabled')}",
|
||||
f"Button/font_sizes/font_size = {fs_base}",
|
||||
'Button/styles/normal = SubResource("StyleBoxFlat_button_normal")',
|
||||
'Button/styles/hover = SubResource("StyleBoxFlat_button_hover")',
|
||||
'Button/styles/pressed = SubResource("StyleBoxFlat_button_pressed")',
|
||||
'Button/styles/focus = SubResource("StyleBoxFlat_button_focus")',
|
||||
'Button/styles/disabled = SubResource("StyleBoxFlat_button_normal")',
|
||||
f"Label/colors/font_color = {c('text.primary')}",
|
||||
f"Label/font_sizes/font_size = {fs_base}",
|
||||
'PanelContainer/styles/panel = SubResource("StyleBoxFlat_panel")',
|
||||
'Panel/styles/panel = SubResource("StyleBoxFlat_panel")',
|
||||
f"ItemList/colors/font_color = {c('text.primary')}",
|
||||
f"ItemList/colors/font_selected_color = {c('text.buttonHover')}",
|
||||
f"ItemList/font_sizes/font_size = {fs_sm}",
|
||||
'ItemList/styles/panel = SubResource("StyleBoxFlat_item_list_bg")',
|
||||
'ItemList/styles/selected = SubResource("StyleBoxFlat_item_list_selected")',
|
||||
'ItemList/styles/selected_focus = SubResource("StyleBoxFlat_item_list_selected")',
|
||||
f"RichTextLabel/colors/default_color = {c('text.primary')}",
|
||||
f"RichTextLabel/font_sizes/normal_font_size = {fs_sm}",
|
||||
]
|
||||
|
||||
parts = [
|
||||
f'[gd_resource type="Theme" format=3 uid="{THEME_UID}"]',
|
||||
"",
|
||||
]
|
||||
for sub in subs:
|
||||
parts.append("")
|
||||
parts.append(sub)
|
||||
parts.append("")
|
||||
parts.append("\n".join(resource_lines))
|
||||
return "\n".join(parts).lstrip("\n") + "\n"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="exit 1 if ui_theme.tres differs from the regenerated output (no write)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--print",
|
||||
dest="print_only",
|
||||
action="store_true",
|
||||
help="print the generated .tres to stdout, write nothing",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
tokens = load_tokens()
|
||||
text = build_theme_text(tokens)
|
||||
|
||||
if args.print_only:
|
||||
sys.stdout.write(text)
|
||||
return 0
|
||||
|
||||
if args.check:
|
||||
current = OUTPUT_PATH.read_text(encoding="utf-8") if OUTPUT_PATH.exists() else ""
|
||||
if current == text:
|
||||
print(f"OK: {OUTPUT_PATH.relative_to(REPO_ROOT)} is up to date.")
|
||||
return 0
|
||||
print(
|
||||
f"DRIFT: {OUTPUT_PATH.relative_to(REPO_ROOT)} is stale.\n"
|
||||
"Run tools/build-ui-theme.py to regenerate it.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
OUTPUT_PATH.write_text(text, encoding="utf-8")
|
||||
print(f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)} ({len(text)} bytes).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Reference in a new issue