- Implement tests for PropFile, including preset conversion, file dictionary conversion, round-trip serialization, version rejection, default values for missing keys, flattening, sanitization, and save/load functionality. - Create tests for PropEditor, covering window title formatting, mode button text, safe saving, and recent file list management. - Add tests for PropTheme, validating fallback behavior, invalid input handling, and default values for primitives and UI tokens. - Introduce tests for ThumbnailCache, ensuring proper key formatting, PNG handling, and behavior with missing files.
239 lines
7.5 KiB
GDScript
239 lines
7.5 KiB
GDScript
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/prop_block.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",
|
|
},
|
|
"ui": {
|
|
"fonts": {"body": 18, "header": 24, "menu": 22, "menu_bar": 22, "mode": 26, "status": 18, "prop_name": 20, "context_menu": 22},
|
|
"hit_targets": {"button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 56},
|
|
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF"},
|
|
},
|
|
}
|
|
|
|
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
|
var _warned: Dictionary = {}
|
|
|
|
|
|
func load_from(path: String = DEFAULT_PATH) -> bool:
|
|
invalidate_caches()
|
|
_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))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kid-friendly UI tokens (Round 2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func font_body() -> int:
|
|
return integer("ui.fonts.body", 18, 8, 72)
|
|
|
|
|
|
func font_header() -> int:
|
|
return integer("ui.fonts.header", 24, 8, 96)
|
|
|
|
|
|
func font_menu() -> int:
|
|
return integer("ui.fonts.menu", 22, 8, 72)
|
|
|
|
|
|
func font_status() -> int:
|
|
return integer("ui.fonts.status", 18, 8, 72)
|
|
|
|
|
|
func font_mode() -> int:
|
|
return integer("ui.fonts.mode", 26, 8, 96)
|
|
|
|
|
|
func font_menu_bar() -> int:
|
|
return integer("ui.fonts.menu_bar", 22, 8, 72)
|
|
|
|
|
|
func font_prop_name() -> int:
|
|
return integer("ui.fonts.prop_name", 20, 8, 96)
|
|
|
|
|
|
func font_context_menu() -> int:
|
|
return integer("ui.fonts.context_menu", 22, 8, 96)
|
|
|
|
|
|
func button_min_height() -> int:
|
|
return integer("ui.hit_targets.button_min_height", 36, 16, 200)
|
|
|
|
|
|
func row_min_height() -> int:
|
|
return integer("ui.hit_targets.row_min_height", 36, 16, 200)
|
|
|
|
|
|
func mode_button_min_height() -> int:
|
|
return integer("ui.hit_targets.mode_button_min_height", 56, 16, 200)
|
|
|
|
|
|
func text_color() -> Color:
|
|
return color("ui.colors.text", Color("#F2F2F2"))
|
|
|
|
|
|
func text_muted_color() -> Color:
|
|
return color("ui.colors.text_muted", Color("#9A9A9A"))
|
|
|
|
|
|
func border_color() -> Color:
|
|
return color("ui.colors.border", Color(1.0, 1.0, 1.0, 0.2))
|
|
|
|
|
|
func accent_color() -> Color:
|
|
return color("ui.colors.accent", Color("#4FA3FF"))
|
|
|
|
|
|
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)
|