class_name PropTheme extends RefCounted ## Validated, cached presentation data for the Prop Editor. ## ## Mirrors SandboxTheme's loader mechanics (deep-merge, dotted-path accessors, ## warn-once, clamp, invalid-input fallback) but scoped to prop-editor tokens. ## Fallback hierarchy: prop_theme.json -> DEFAULT_CONFIG -> hardcoded ## PropBlock constants. Physics values (mass/friction/bounce) are deliberately ## NOT themeable; PropBlock's static preset tables remain the single source of ## truth. const PropBlockScript := preload("res://scripts/props/prop_block.gd") const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd") const SCHEMA_VERSION: String = "1.0" const DEFAULT_PATH: String = "res://prop_theme.json" const PRESET_KEYS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"] const DEFAULT_CONFIG: Dictionary = { "version": SCHEMA_VERSION, "outline_widths": {"default": 2.0, "min": 0.5, "max": 32.0}, "material_tints": { "wood": {"fill": "#8C6133", "outline": "#40290F"}, "rubber": {"fill": "#E63333", "outline": "#590D0D"}, "cardboard": {"fill": "#D9B873", "outline": "#66521D"}, "metal": {"fill": "#808C99", "outline": "#262E38"}, "none": {"fill": "#8C6133", "outline": "#26201E"}, }, "primitive_defaults": { "box": {"size_x": 48.0, "size_y": 48.0}, "ball": {"radius": 24.0}, "plank": {"length": 160.0, "thickness": 16.0}, "triangle": {"base": 56.0, "height": 48.0}, "line": {"length": 120.0}, "circle": {"radius": 40.0, "segments": 12}, }, "canvas": { "grid_size": 15, "background_color": "#1F1F1F", "grid_color": "#FFFFFF26", }, } var _config: Dictionary = DEFAULT_CONFIG.duplicate(true) var _warned: Dictionary = {} var _editor_visual_theme: EditorVisualTheme = EditorVisualThemeScript.new() func load_from(path: String = DEFAULT_PATH) -> bool: invalidate_caches() _editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH) _config = DEFAULT_CONFIG.duplicate(true) if not FileAccess.file_exists(path): _warn_once(path, "PropTheme: '%s' was not found; using defaults." % path) return false var file: FileAccess = FileAccess.open(path, FileAccess.READ) if file == null: _warn_once(path, "PropTheme: '%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, "PropTheme: '%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 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, "PropTheme: 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, "PropTheme: 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) ## Material tint for a PropBlock.MaterialPreset value and channel ("fill" or ## "outline"). Hierarchy: theme file -> DEFAULT_CONFIG -> PropBlock.tint_for ## (outline's final fallback is the tint darkened 0.55). func material_tint(preset: int, channel: String) -> Color: var key: String = PRESET_KEYS[preset] if preset >= 0 and preset < PRESET_KEYS.size() else "none" var tint: Color = PropBlockScript.tint_for(preset) var fallback: Color = tint if channel == "fill" else tint.darkened(0.55) return color("material_tints.%s.%s" % [key, channel], fallback) func outline_width_default() -> float: return number("outline_widths.default", PropBlockScript.DEFAULT_OUTLINE_WIDTH, 0.0, 256.0) func outline_width_min() -> float: return number("outline_widths.min", 0.5, 0.0, 256.0) func outline_width_max() -> float: return number("outline_widths.max", 32.0, 0.0, 1024.0) ## Primitive default dimension, e.g. primitive_default("box", "size_x"). ## Falls back to the DEFAULT_CONFIG primitive table (which mirrors the ## PropUtils.create_* generator defaults). func primitive_default(primitive: String, key: String, fallback: float = 0.0) -> float: var table: Dictionary = value("primitive_defaults.%s" % primitive, {}) if table is Dictionary and (table as Dictionary).has(key): return number("primitive_defaults.%s.%s" % [primitive, key], fallback) var defaults: Dictionary = DEFAULT_CONFIG.get("primitive_defaults", {}) var entry: Dictionary = defaults.get(primitive, {}) return float(entry.get(key, fallback)) func grid_size() -> int: return integer("canvas.grid_size", 15, 1, 512) func canvas_background_color() -> Color: return color("canvas.background_color", Color("#1F1F1F")) func canvas_grid_color() -> Color: return color("canvas.grid_color", Color(1.0, 1.0, 1.0, 0.15)) # --------------------------------------------------------------------------- # Compatibility accessors: editor chrome is owned by EditorVisualTheme. # --------------------------------------------------------------------------- func font_body() -> int: return _editor_visual_theme.chrome_px(&"body", &"prop") func font_header() -> int: return _editor_visual_theme.chrome_px(&"section", &"prop") func font_menu() -> int: return _editor_visual_theme.chrome_px(&"menu", &"prop") func font_status() -> int: return _editor_visual_theme.chrome_px(&"status", &"prop") func font_mode() -> int: return _editor_visual_theme.chrome_px(&"mode", &"prop") func font_menu_bar() -> int: return _editor_visual_theme.chrome_px(&"menu", &"prop") func font_prop_name() -> int: return _editor_visual_theme.chrome_px(&"prop_name", &"prop") func font_context_menu() -> int: return _editor_visual_theme.chrome_px(&"context_menu", &"prop") func button_min_height() -> int: return _editor_visual_theme.chrome_px(&"button_min_height", &"prop") func row_min_height() -> int: return _editor_visual_theme.chrome_px(&"row_min_height", &"prop") func mode_button_min_height() -> int: return _editor_visual_theme.chrome_px(&"mode_button_min_height", &"prop") func text_color() -> Color: return _editor_visual_theme.chrome_color(&"text", &"prop") func text_muted_color() -> Color: return _editor_visual_theme.chrome_color(&"text_muted", &"prop") func border_color() -> Color: return _editor_visual_theme.chrome_color(&"border", &"prop") func accent_color() -> Color: return _editor_visual_theme.chrome_color(&"edit", &"prop") func inspector_background_color() -> Color: return _editor_visual_theme.chrome_color(&"surface", &"prop") func invalidate_caches() -> void: _warned.clear() 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 _warn_once(key: String, message: String) -> void: if not _warned.has(key): _warned[key] = true push_warning(message)