Files
stickman/scripts/shared/editor_ui/editor_visual_theme.gd
T

298 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",
"selected_surface": "#103B4D",
"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,
"menu_bar": 22,
"menu_popup": 26,
"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,
"context_menu_row_height": 48,
"context_menu_min_width": 208,
},
},
"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",
"selected_surface": "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",
"menu_bar": "fonts",
"menu_popup": "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",
"context_menu_row_height": "targets",
"context_menu_min_width": "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("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_popup", editor))
theme.set_font_size("font_size", "MenuBar", chrome_px(&"menu_bar", 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)