Add reusable UI components for Phase 7 UI Polish
- Introduced `EmojiQuickGrid`, `FontPresetPicker`, and `PresetSizeToggle` classes for emoji selection, font presets, and size toggles respectively. - Implemented configuration methods for each component to handle dynamic content and theming. - Created tests for the new components to ensure functionality and integration with the existing UI. - Added a comprehensive architecture specification for Phase 7 UI Polish detailing scope, implementation, and validation strategies. - Updated existing theme handling to support new UI elements and ensure consistent styling across the application.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
class_name SandboxTheme
|
||||
extends RefCounted
|
||||
## Validated, cached presentation data for the Sandbox Stage.
|
||||
|
||||
const SCHEMA_VERSION: String = "2.0"
|
||||
const DEFAULT_PATH: String = "res://sandbox_theme.json"
|
||||
|
||||
const DEFAULT_CONFIG: Dictionary = {
|
||||
"version": SCHEMA_VERSION,
|
||||
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
|
||||
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
|
||||
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
|
||||
"grid": {"snap_size": 15.0},
|
||||
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
|
||||
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
|
||||
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
|
||||
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
|
||||
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"medium", "label":"Medium", "value":28}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}
|
||||
}
|
||||
|
||||
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
||||
var _font_cache: Dictionary = {}
|
||||
var _style_cache: Dictionary = {}
|
||||
var _control_theme: Theme = null
|
||||
var _warned: Dictionary = {}
|
||||
|
||||
func load_from(path: String = DEFAULT_PATH) -> bool:
|
||||
invalidate_caches()
|
||||
_config = DEFAULT_CONFIG.duplicate(true)
|
||||
if not FileAccess.file_exists(path):
|
||||
_warn_once(path, "SandboxTheme: '%s' was not found; using defaults." % path)
|
||||
return false
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
_warn_once(path, "SandboxTheme: '%s' could not be read; using defaults." % path)
|
||||
return false
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not parsed is Dictionary:
|
||||
_warn_once(path, "SandboxTheme: '%s' is malformed; using defaults." % path)
|
||||
return false
|
||||
_apply_legacy_aliases(parsed as Dictionary)
|
||||
_config = _deep_merge(_config, parsed as Dictionary)
|
||||
return true
|
||||
|
||||
func raw_config() -> Dictionary:
|
||||
return _config.duplicate(true)
|
||||
|
||||
func value(path: String, fallback: Variant = null) -> Variant:
|
||||
var cursor: Variant = _config
|
||||
for piece: String in path.split("."):
|
||||
if not cursor is Dictionary or not (cursor as Dictionary).has(piece):
|
||||
return fallback
|
||||
cursor = (cursor as Dictionary)[piece]
|
||||
return cursor
|
||||
|
||||
func color(path: String, fallback: Color) -> Color:
|
||||
var raw: Variant = value(path, null)
|
||||
if not raw is String:
|
||||
return fallback
|
||||
var text: String = String(raw)
|
||||
if not text.begins_with("#") or (text.length() != 7 and text.length() != 9):
|
||||
_warn_once(path, "SandboxTheme: invalid color at '%s'." % path)
|
||||
return fallback
|
||||
return Color.from_string(text, fallback)
|
||||
|
||||
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float:
|
||||
var raw: Variant = value(path, fallback)
|
||||
if not raw is int and not raw is float:
|
||||
_warn_once(path, "SandboxTheme: invalid number at '%s'." % path)
|
||||
return fallback
|
||||
var n: float = float(raw)
|
||||
if not is_finite(n): return fallback
|
||||
return clampf(n, minimum, maximum)
|
||||
|
||||
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
|
||||
return clampi(int(roundi(number(path, float(fallback), float(minimum), float(maximum)))), minimum, maximum)
|
||||
|
||||
func font(component_path: String = "", preset_id: String = "ui") -> Font:
|
||||
var path: String = ""
|
||||
if not component_path.is_empty(): path = String(value(component_path + ".font_path", ""))
|
||||
if path.is_empty(): path = String(value("fonts.presets.%s.path" % preset_id, ""))
|
||||
if path.is_empty(): path = String(value("fonts.ui_font", ""))
|
||||
if path.is_empty(): return ThemeDB.fallback_font
|
||||
if _font_cache.has(path): return _font_cache[path]
|
||||
var resource: Resource = ResourceLoader.load(path) if ResourceLoader.exists(path) else null
|
||||
if resource is Font:
|
||||
_font_cache[path] = resource
|
||||
return resource as Font
|
||||
_warn_once("font." + path, "SandboxTheme: font '%s' is unavailable; using fallback." % path)
|
||||
return ThemeDB.fallback_font
|
||||
|
||||
func emoji_font() -> Font:
|
||||
var path: String = String(value("fonts.emoji_font", ""))
|
||||
if path.is_empty(): return font()
|
||||
return font("", "emoji") if ResourceLoader.exists(path) else font()
|
||||
|
||||
func style(component_path: String) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
var section: String = component_path.get_slice(".", 0)
|
||||
result = _deep_merge(result, value("global", {}))
|
||||
result = _deep_merge(result, value(section + ".style_defaults", {}))
|
||||
result = _deep_merge(result, value(component_path, {}))
|
||||
return result
|
||||
|
||||
func stylebox(component_path: String) -> StyleBoxFlat:
|
||||
var cfg: Dictionary = style(component_path)
|
||||
var key: String = JSON.stringify(cfg)
|
||||
if not _style_cache.has(key):
|
||||
var box := StyleBoxFlat.new()
|
||||
box.bg_color = _style_color(cfg, "bg_color", color("global.surface_color", Color("#252B38")))
|
||||
box.border_color = _style_color(cfg, "border_color", color("global.border_color", Color("#FFFFFF33")))
|
||||
box.set_border_width_all(int(cfg.get("border_width", integer("global.border_width", 1, 0, 16))))
|
||||
box.set_corner_radius_all(int(cfg.get("corner_radius", integer("global.corner_radius", 10, 0, 64))))
|
||||
var padding: int = int(cfg.get("padding", integer("global.padding", 8, 0, 64)))
|
||||
box.content_margin_left = padding; box.content_margin_right = padding
|
||||
box.content_margin_top = padding; box.content_margin_bottom = padding
|
||||
_style_cache[key] = box
|
||||
return (_style_cache[key] as StyleBoxFlat).duplicate()
|
||||
|
||||
func control_theme() -> Theme:
|
||||
if _control_theme == null:
|
||||
_control_theme = Theme.new()
|
||||
_control_theme.default_font = font()
|
||||
_control_theme.default_font_size = integer("global.font_size", 18, 8, 96)
|
||||
return _control_theme
|
||||
|
||||
func swatches() -> Array[Color]:
|
||||
var result: Array[Color] = []
|
||||
for item: Variant in value("widgets.swatches", []):
|
||||
if item is String: result.append(Color.from_string(String(item), Color.WHITE))
|
||||
return result
|
||||
|
||||
func size_presets() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for item: Variant in value("widgets.size_presets", []):
|
||||
if item is Dictionary and not String((item as Dictionary).get("id", "")).is_empty(): result.append((item as Dictionary).duplicate(true))
|
||||
if result.is_empty(): result.append({"id":"medium", "label":"Medium", "value":28})
|
||||
return result
|
||||
|
||||
func font_presets() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var presets: Dictionary = value("fonts.presets", {})
|
||||
for id: String in presets:
|
||||
var entry: Dictionary = presets[id] if presets[id] is Dictionary else {}
|
||||
result.append({"id": id, "label": String(entry.get("label", id.capitalize())), "font": font("", id)})
|
||||
if result.is_empty(): result.append({"id":"ui", "label":"Standard", "font":font()})
|
||||
return result
|
||||
|
||||
func emoji_items() -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for item: Variant in value("widgets.emoji", []): if item is String: result.append(String(item))
|
||||
return result
|
||||
|
||||
func invalidate_caches() -> void:
|
||||
_font_cache.clear(); _style_cache.clear(); _control_theme = null; _warned.clear()
|
||||
|
||||
func _apply_legacy_aliases(source: Dictionary) -> void:
|
||||
var modes: Dictionary = source.get("mode_colors", {})
|
||||
for pair: Array in [["edit_accent","edit"], ["direct_accent","direct"], ["play_accent","play"], ["guide_line","guide"]]:
|
||||
if modes.has(pair[0]) and not modes.has(pair[1]): modes[pair[1]] = modes[pair[0]]
|
||||
var fonts: Dictionary = source.get("fonts", {})
|
||||
var sizes: Dictionary = fonts.get("sizes", {})
|
||||
for name: String in ["action_popup", "action_popup_emoji", "assignment_badge", "rule_label", "status_pill", "tooltip", "queue_panel", "rule_panel", "action_editor", "rule_editor", "panel_row", "panel_title"]:
|
||||
var legacy: String = name + "_font_size"
|
||||
if fonts.has(legacy) and not sizes.has(name): sizes[name] = fonts[legacy]
|
||||
fonts["sizes"] = sizes; source["fonts"] = fonts; source["mode_colors"] = modes
|
||||
|
||||
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
|
||||
var merged: Dictionary = base.duplicate(true)
|
||||
for key: Variant in overrides:
|
||||
if merged.get(key) is Dictionary and overrides[key] is Dictionary: merged[key] = _deep_merge(merged[key], overrides[key])
|
||||
else: merged[key] = overrides[key]
|
||||
return merged
|
||||
|
||||
func _style_color(cfg: Dictionary, key: String, fallback: Color) -> Color:
|
||||
return Color.from_string(String(cfg.get(key, "")), fallback)
|
||||
|
||||
func _warn_once(key: String, message: String) -> void:
|
||||
if not _warned.has(key): _warned[key] = true; push_warning(message)
|
||||
Reference in New Issue
Block a user