Add visual harmonization tests for editor UI components and ground finish chooser
- 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.
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user