- Implemented `test_editor_visual_harmonization.gd` to validate the Stickman editor and prop editor UI layouts, ensuring proper functionality and visual consistency. - Created `test_editor_visual_theme.gd` to verify the loading and application of visual themes in the editor, including semantic overrides and default values. - Introduced `test_phase19_ground_finish_chooser.gd` to test the ground finish chooser's functionality, including layout responsiveness, selection behavior, and interaction with the stage. - Added new scripts for ground finish preview and chooser UI components to enhance the editor's visual representation of ground finishes.
285 lines
11 KiB
GDScript
285 lines
11 KiB
GDScript
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)
|