class_name EditorVisualTheme extends RefCounted ## Validated semantic chrome tokens shared by the standalone editors. ## Canvas artwork and editor-specific drawing values stay with their owners. const SCHEMA_VERSION: String = "1.0" const DEFAULT_PATH: String = "res://editor_visual_theme.json" const DEFAULT_CONFIG: Dictionary = { "version": SCHEMA_VERSION, "chrome": { "colors": { "background": "#171B24F2", "surface": "#252B38FA", "surface_hover": "#30394A", "text": "#F5F7FA", "text_muted": "#AAB2C0", "edit": "#22C6FF", "preview": "#22C6FF66", "focus": "#8BD9FF", "action_primary": "#087DBA", "destructive": "#E66B75", "border": "#FFFFFF33", "disabled": "#77808F", "saved": "#8FD6A4", "dirty": "#FFD166", "error": "#FF8A8A", }, "fonts": { "body": 18, "helper": 16, "section": 22, "title": 26, "menu": 20, "status": 16, "mode": 24, "prop_name": 20, "context_menu": 20, }, "spacing": {"small": 8, "medium": 12, "large": 16}, "radii": {"control": 8, "panel": 10}, "borders": {"normal": 1, "focus": 2}, "targets": {"button_min_height": 44, "row_min_height": 44, "mode_button_min_height": 56}, }, "editors": {"prop": {}, "stickman": {}}, "stickman_canvas": { "grid_color": "#FFFFFF26", "selection_core": "#FFFFFF", "selection_halo": "#000000D9", }, } const COLOR_ROLE_SECTIONS: Dictionary = { "background": "colors", "surface": "colors", "surface_hover": "colors", "text": "colors", "text_muted": "colors", "edit": "colors", "preview": "colors", "focus": "colors", "action_primary": "colors", "destructive": "colors", "border": "colors", "disabled": "colors", "saved": "colors", "dirty": "colors", "error": "colors", } const SIZE_ROLE_SECTIONS: Dictionary = { "body": "fonts", "helper": "fonts", "section": "fonts", "title": "fonts", "menu": "fonts", "status": "fonts", "mode": "fonts", "prop_name": "fonts", "context_menu": "fonts", "small": "spacing", "medium": "spacing", "large": "spacing", "control": "radii", "panel": "radii", "normal_border": "borders", "focus_border": "borders", "button_min_height": "targets", "row_min_height": "targets", "mode_button_min_height": "targets", } const CONFIG_SIZE_KEYS: Dictionary = { "normal_border": "normal", "focus_border": "focus", } var _config: Dictionary = DEFAULT_CONFIG.duplicate(true) var _warned: Dictionary = {} func load_from(path: String = DEFAULT_PATH) -> bool: _warned.clear() _config = DEFAULT_CONFIG.duplicate(true) if not FileAccess.file_exists(path): _warn_once(path, "EditorVisualTheme: '%s' was not found; using defaults." % path) return false var file: FileAccess = FileAccess.open(path, FileAccess.READ) if file == null: _warn_once(path, "EditorVisualTheme: '%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, "EditorVisualTheme: '%s' is malformed; using defaults." % path) return false _config = _deep_merge(_config, parsed as Dictionary) return true func raw_config() -> Dictionary: return _config.duplicate(true) func chrome_color(role: StringName, editor: StringName = &"") -> Color: var role_key: String = String(role) if not COLOR_ROLE_SECTIONS.has(role_key): _warn_once("color:" + role_key, "EditorVisualTheme: unknown color role '%s'." % role_key) return Color.MAGENTA var section: String = COLOR_ROLE_SECTIONS[role_key] var raw: Variant = _resolved_value(section, role_key, editor) var fallback_config: Dictionary = DEFAULT_CONFIG["chrome"][section] var fallback: Color = Color.from_string(String(fallback_config[role_key]), Color.WHITE) if not raw is String: _warn_once("color:" + role_key, "EditorVisualTheme: invalid value for color role '%s'." % role_key) return fallback var hex: String = String(raw) if not hex.begins_with("#") or (hex.length() != 7 and hex.length() != 9): _warn_once("color:" + role_key, "EditorVisualTheme: invalid color at '%s'." % role_key) return fallback return Color.from_string(hex, fallback) func chrome_px(role: StringName, editor: StringName = &"") -> int: var role_key: String = String(role) if not SIZE_ROLE_SECTIONS.has(role_key): _warn_once("size:" + role_key, "EditorVisualTheme: unknown size role '%s'." % role_key) return 0 var section: String = SIZE_ROLE_SECTIONS[role_key] var config_key: String = String(CONFIG_SIZE_KEYS.get(role_key, role_key)) var raw: Variant = _resolved_value(section, config_key, editor) var fallback: int = int(DEFAULT_CONFIG["chrome"][section][config_key]) if not raw is int and not raw is float: _warn_once("size:" + role_key, "EditorVisualTheme: invalid value for size role '%s'." % role_key) return fallback return clampi(int(round(float(raw))), 0, 256) func stickman_canvas_color(role: StringName) -> Color: var role_key: String = String(role) var fallback_text: String = String(DEFAULT_CONFIG["stickman_canvas"].get(role_key, "#FFFFFF")) var fallback: Color = Color.from_string(fallback_text, Color.WHITE) var raw: Variant = _get_nested(_config, ["stickman_canvas", role_key], fallback_text) if not raw is String: _warn_once("stickman_canvas:" + role_key, "EditorVisualTheme: invalid Stickman canvas color '%s'." % role_key) return fallback var hex: String = String(raw) if not hex.begins_with("#") or (hex.length() != 7 and hex.length() != 9): _warn_once("stickman_canvas:" + role_key, "EditorVisualTheme: invalid Stickman canvas color '%s'." % role_key) return fallback return Color.from_string(hex, fallback) func control_theme(editor: StringName) -> Theme: var theme: Theme = Theme.new() var surface: Color = chrome_color(&"surface", editor) var background: Color = chrome_color(&"background", editor) var hover: Color = chrome_color(&"surface_hover", editor) var text: Color = chrome_color(&"text", editor) var muted: Color = chrome_color(&"text_muted", editor) var edit: Color = chrome_color(&"edit", editor) var focus: Color = chrome_color(&"focus", editor) var border: Color = chrome_color(&"border", editor) var radius: int = chrome_px(&"control", editor) var normal_width: int = chrome_px(&"normal_border", editor) var focus_width: int = maxi(2, chrome_px(&"focus_border", editor)) var normal_box: StyleBoxFlat = _make_box(surface, border, radius, normal_width) var hover_box: StyleBoxFlat = _make_box(hover, edit, radius, normal_width) var pressed_box: StyleBoxFlat = _make_box(surface.darkened(0.12), edit, radius, focus_width) var focus_box: StyleBoxFlat = _make_box(Color(0, 0, 0, 0), focus, radius, focus_width) var disabled_box: StyleBoxFlat = _make_box(background.darkened(0.1), border, radius, normal_width) for type_name: String in ["Button", "OptionButton", "ColorPickerButton", "CheckBox"]: theme.set_stylebox("normal", type_name, normal_box.duplicate()) theme.set_stylebox("hover", type_name, hover_box.duplicate()) theme.set_stylebox("pressed", type_name, pressed_box.duplicate()) theme.set_stylebox("focus", type_name, focus_box.duplicate()) theme.set_stylebox("disabled", type_name, disabled_box.duplicate()) theme.set_color("font_color", type_name, text) theme.set_color("font_hover_color", type_name, text) theme.set_color("font_pressed_color", type_name, edit) theme.set_color("font_disabled_color", type_name, muted) theme.set_font_size("font_size", type_name, chrome_px(&"body", editor)) var input_box: StyleBoxFlat = _make_box(background, border, radius, normal_width) var input_focus: StyleBoxFlat = _make_box(background, focus, radius, focus_width) for type_name: String in ["LineEdit", "SpinBox", "TextEdit"]: theme.set_stylebox("normal", type_name, input_box.duplicate()) theme.set_stylebox("focus", type_name, input_focus.duplicate()) theme.set_color("font_color", type_name, text) theme.set_color("font_placeholder_color", type_name, muted) theme.set_font_size("font_size", type_name, chrome_px(&"body", editor)) var panel_box: StyleBoxFlat = _make_box(surface, border, chrome_px(&"panel", editor), normal_width) theme.set_stylebox("panel", "PanelContainer", panel_box) theme.set_stylebox("normal", "PopupMenu", panel_box.duplicate()) theme.set_stylebox("panel", "MenuBar", panel_box.duplicate()) theme.set_font_size("font_size", "Label", chrome_px(&"body", editor)) theme.set_color("font_color", "Label", text) theme.set_color("font_hover_color", "Label", text) theme.set_font_size("font_size", "PopupMenu", chrome_px(&"menu", editor)) theme.set_font_size("font_size", "MenuBar", chrome_px(&"menu", editor)) theme.set_color("font_color", "MenuBar", text) return theme func style_button_role(button: Button, role: StringName, editor: StringName) -> void: button.custom_minimum_size.y = maxf(button.custom_minimum_size.y, float(chrome_px(&"button_min_height", editor))) if role != &"primary" and role != &"destructive": return var fill_role: StringName = &"action_primary" if role == &"primary" else &"destructive" var fill: Color = chrome_color(fill_role, editor) var focus: Color = chrome_color(&"focus", editor) var radius: int = chrome_px(&"control", editor) var border_width: int = chrome_px(&"normal_border", editor) var focus_width: int = maxi(2, chrome_px(&"focus_border", editor)) button.add_theme_stylebox_override("normal", _make_box(fill, fill.lightened(0.12), radius, border_width)) button.add_theme_stylebox_override("hover", _make_box(fill.lightened(0.12), focus, radius, border_width)) button.add_theme_stylebox_override("pressed", _make_box(fill.darkened(0.12), focus, radius, focus_width)) button.add_theme_stylebox_override("focus", _make_box(Color(0.0, 0.0, 0.0, 0.0), focus, radius, focus_width)) button.add_theme_color_override("font_color", Color.WHITE) button.add_theme_color_override("font_hover_color", Color.WHITE) button.add_theme_color_override("font_pressed_color", Color.WHITE) func _resolved_value(section: String, key: String, editor: StringName) -> Variant: if not editor.is_empty(): var editor_value: Variant = _get_nested(_config, ["editors", String(editor), section, key], null) if editor_value != null: return editor_value return _get_nested(_config, ["chrome", section, key], null) func _get_nested(source: Dictionary, keys: Array[String], fallback: Variant) -> Variant: var cursor: Variant = source for key: String in keys: if not cursor is Dictionary or not (cursor as Dictionary).has(key): return fallback cursor = (cursor as Dictionary)[key] return cursor func _make_box(fill: Color, outline: Color, radius: int, width: int) -> StyleBoxFlat: var box: StyleBoxFlat = StyleBoxFlat.new() box.bg_color = fill box.border_color = outline box.set_border_width_all(width) box.set_corner_radius_all(radius) box.content_margin_left = 8 box.content_margin_right = 8 box.content_margin_top = 6 box.content_margin_bottom = 6 return box 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] as Dictionary, overrides[key] as Dictionary) else: merged[key] = overrides[key] return merged func _warn_once(key: String, message: String) -> void: if not _warned.has(key): _warned[key] = true push_warning(message)