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": ""}, "comic": {"label": "Comic", "path": "res://resources/fonts/comic.ttf", "speech_base_size": 28}, "pixel": {"label": "Pixel", "path": "res://resources/fonts/pixel.ttf", "speech_base_size": 40}, "scary": {"label": "Scary", "path": "res://resources/fonts/scary.ttf", "speech_base_size": 28}}, "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, "timestamp_badge": 22, "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}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "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"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}}, "widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}, "speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"} } 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(): if preset_id == "ui": return ThemeDB.fallback_font var system_key: String = "system:" + preset_id if _font_cache.has(system_key): return _font_cache[system_key] var system_font := SystemFont.new() match preset_id: "comic": system_font.font_names = PackedStringArray(["Comic Sans MS", "Comic Neue", "Chalkboard", "Comic Sans", "sans-serif"]) "pixel": system_font.font_names = PackedStringArray(["Consolas", "Courier New", "Liberation Mono", "monospace"]) "scary": system_font.font_names = PackedStringArray(["Impact", "Chiller", "Papyrus", "fantasy", "sans-serif"]) _: system_font.font_names = PackedStringArray(["Arial", "Liberation Sans", "sans-serif"]) _font_cache[system_key] = system_font return system_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 speech_font(preset_id: String = "ui") -> Font: return font("", preset_id) func speech_font_size(preset_id: String, size_preset: String) -> int: var base_size: int = integer("fonts.presets.%s.speech_base_size" % preset_id, 28, 8, 96) var scale: float = 1.0 match size_preset: "small": scale = 0.72 "huge": scale = 1.43 return clampi(roundi(float(base_size) * scale), 8, 128) 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 speech_color(frame: String, channel: String, fallback: Color) -> Color: if not ["talk", "shout", "think", "whisper", "sfx"].has(frame) or not ["fill", "outline"].has(channel): return fallback return color("speech.styles.%s.%s" % [frame, channel], fallback) func speech_number(token: String, fallback: float, minimum: float, maximum: float) -> float: if not ["ui.panel_width", "ui.button_min_height", "ui.heading_size", "layout.max_width", "layout.stack_gap", "layout.screen_margin", "layout.bubble_padding", "animation.letter_interval", "animation.word_interval", "animation.pop_duration"].has(token): return fallback return number("speech." + token, fallback, minimum, maximum) func speech_toolbar_number(token: String, fallback: float, minimum: float, maximum: float) -> float: if not ["gap", "max_width", "min_hit_height"].has(token): return fallback return number("speech.toolbar." + token, fallback, minimum, maximum) func speech_text_colors() -> Array[Dictionary]: var result: Array[Dictionary] = [] var raw_colors: Variant = value("speech.text_colors", []) if not raw_colors is Array: return result for raw: Variant in raw_colors as Array: if not raw is Dictionary: continue var item: Dictionary = raw as Dictionary var name: String = String(item.get("name", "")).strip_edges() var color_text: String = String(item.get("color", "")) if not name.is_empty() and _is_valid_hex_color(color_text): result.append({"name": name, "color": Color.from_string(color_text, Color.BLACK)}) return result func _is_valid_hex_color(text: String) -> bool: if text.length() != 7 and text.length() != 9 or not text.begins_with("#"): return false for index: int in range(1, text.length()): if not "0123456789abcdefABCDEF".contains(text.substr(index, 1)): return false return true 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)