Files
tekton/addons/godot_ai/handlers/theme_handler.gd
T

489 lines
18 KiB
GDScript

@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles Theme resource authoring: creating, modifying color/constant/font-size/
## stylebox slots, and applying a theme to a Control subtree.
##
## Themes are Godot's equivalent of USS: a Theme holds (class, name) -> value
## entries (colors, constants, fonts, font_sizes, styleboxes, icons) which
## cascade down a Control subtree when the theme is assigned at any ancestor.
## One well-authored theme replaces hundreds of per-node property sets.
const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict"
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
# ============================================================================
# theme_create
# ============================================================================
func create_theme(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var overwrite: bool = params.get("overwrite", false)
var err := _validate_res_path(path, ".tres", "path", true)
if err != null:
return err
# Capture whether the file was already there BEFORE the save so we can
# report `overwritten` accurately (after save the file always exists).
var existed_before := FileAccess.file_exists(path)
if existed_before and not overwrite:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Theme already exists at %s (pass overwrite=true to replace)" % path
)
# Ensure parent directory exists. make_dir_recursive is idempotent —
# no need to check dir_exists first (avoids TOCTOU race).
var dir_path := path.get_base_dir()
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to create directory: %s (error %d)" % [dir_path, mkdir_err]
)
var theme := Theme.new()
var save_err := ResourceSaver.save(theme, path)
if save_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to save theme to %s: %s (error %d)" % [path, error_string(save_err), save_err]
)
# Make sure the editor's filesystem picks up the new file.
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
return {
"data": {
"path": path,
"overwritten": existed_before,
"undoable": false,
"reason": "File creation is persistent; delete the file manually to revert",
}
}
# ============================================================================
# theme_set_color / theme_set_constant / theme_set_font_size
# ============================================================================
func set_color(params: Dictionary) -> Dictionary:
return _set_scalar(params, "color", func(theme, name, cls): return theme.get_color(name, cls),
func(theme, name, cls, val): theme.set_color(name, cls, val),
func(theme, name, cls): theme.clear_color(name, cls),
func(theme, name, cls): return theme.has_color(name, cls),
func(v): return _parse_color(v))
# constant / font_size parsers validate before coercing: int("abc")/int({})/int([])
# all return 0 in GDScript (never null), so a bare `int(v)` would silently store
# garbage as 0 and report success. Returning null for non-numeric input lets
# _set_scalar's null guard surface a VALUE_OUT_OF_RANGE error, matching the
# color path's contract.
func set_constant(params: Dictionary) -> Dictionary:
return _set_scalar(params, "constant", func(theme, name, cls): return theme.get_constant(name, cls),
func(theme, name, cls, val): theme.set_constant(name, cls, int(val)),
func(theme, name, cls): theme.clear_constant(name, cls),
func(theme, name, cls): return theme.has_constant(name, cls),
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
func set_font_size(params: Dictionary) -> Dictionary:
return _set_scalar(params, "font_size", func(theme, name, cls): return theme.get_font_size(name, cls),
func(theme, name, cls, val): theme.set_font_size(name, cls, int(val)),
func(theme, name, cls): theme.clear_font_size(name, cls),
func(theme, name, cls): return theme.has_font_size(name, cls),
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
# Shared implementation for scalar Theme slots (color, constant, font_size).
# Captures old value, applies new value, saves to disk, registers undo that
# restores the old value and saves again.
func _set_scalar(
params: Dictionary,
kind: String,
getter: Callable,
setter: Callable,
clearer: Callable,
has_fn: Callable,
parser: Callable,
) -> Dictionary:
var load_result := _load_theme_from_params(params)
if load_result.has("error"):
return load_result
var theme: Theme = load_result.theme
var theme_path: String = load_result.path
var class_name_param: String = params.get("class_name", "")
if class_name_param.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
if not "value" in params:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
var raw_value = params.get("value")
if raw_value == null:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid %s value: null (pass a concrete value; use the appropriate clear command to remove a slot)" % kind
)
var parsed = parser.call(raw_value)
if parsed == null:
## color slots want a color hint; constant/font_size are integer slots.
var hint := _COLOR_HINT if kind == "color" else "expected an integer"
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid %s value: %s (%s)" % [kind, raw_value, hint])
var had_before: bool = has_fn.call(theme, name, class_name_param)
var before_value = getter.call(theme, name, class_name_param) if had_before else null
_undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
_undo_redo.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed)
if had_before:
_undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value)
else:
_undo_redo.add_undo_method(self, "_clear_scalar", theme_path, clearer, name, class_name_param)
_undo_redo.commit_action()
return {
"data": {
"path": theme_path,
"kind": kind,
"class_name": class_name_param,
"name": name,
"value": _serialize_value(parsed),
"previous_value": _serialize_value(before_value) if had_before else null,
"undoable": true,
}
}
func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
setter.call(theme, name, class_name_param, value)
ResourceSaver.save(theme, theme_path)
func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
clearer.call(theme, name, class_name_param)
ResourceSaver.save(theme, theme_path)
# ============================================================================
# theme_set_stylebox_flat
# ============================================================================
## Compose a StyleBoxFlat and assign it to a theme slot.
##
## Parameters (beyond theme_path / class_name / name):
## bg_color (Color, "#rrggbb", "#rrggbbaa", or {r,g,b,a})
## border_color (Color)
## border {all|top|bottom|left|right: int} — side keys override `all`
## corners {all|top_left|top_right|bottom_left|bottom_right: int}
## margins {all|top|bottom|left|right: float}
## shadow {color, size: int, offset_x: float, offset_y: float}
## anti_aliasing (bool)
##
## Unknown keys inside any nested dict are rejected with INVALID_PARAMS so
## typos fail loudly instead of silently being ignored.
func set_stylebox_flat(params: Dictionary) -> Dictionary:
var load_result := _load_theme_from_params(params)
if load_result.has("error"):
return load_result
var theme: Theme = load_result.theme
var theme_path: String = load_result.path
var class_name_param: String = params.get("class_name", "")
if class_name_param.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
var sb := StyleBoxFlat.new()
if params.has("bg_color"):
var bg := _parse_color(params.bg_color)
if bg == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT])
sb.bg_color = bg
if params.has("border_color"):
var bc := _parse_color(params.border_color)
if bc == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT])
sb.border_color = bc
# border: {all, top, bottom, left, right} — int widths
if params.has("border"):
var err := _apply_sides(sb, params.border, "border",
["top", "bottom", "left", "right"],
"border_width_",
TYPE_INT)
if err != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err)
# corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii
if params.has("corners"):
var err2 := _apply_sides(sb, params.corners, "corners",
["top_left", "top_right", "bottom_left", "bottom_right"],
"corner_radius_",
TYPE_INT)
if err2 != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err2)
# margins: {all, top, bottom, left, right} — float padding
if params.has("margins"):
var err3 := _apply_sides(sb, params.margins, "margins",
["top", "bottom", "left", "right"],
"content_margin_",
TYPE_FLOAT)
if err3 != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err3)
# shadow: {color, size, offset_x, offset_y}
if params.has("shadow"):
if typeof(params.shadow) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y")
var shadow: Dictionary = params.shadow
var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true}
for k in shadow.keys():
if not allowed_shadow_keys.has(k):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k)
if shadow.has("color"):
var sc := _parse_color(shadow.color)
if sc == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT])
sb.shadow_color = sc
if shadow.has("size"):
sb.shadow_size = int(shadow.size)
if shadow.has("offset_x") or shadow.has("offset_y"):
sb.shadow_offset = Vector2(
float(shadow.get("offset_x", 0)),
float(shadow.get("offset_y", 0)),
)
if params.has("anti_aliasing"):
sb.anti_aliasing = bool(params.anti_aliasing)
var had_before := theme.has_stylebox(name, class_name_param)
var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null
_undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name])
_undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb)
if had_before:
_undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb)
else:
_undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param)
_undo_redo.commit_action()
return {
"data": {
"path": theme_path,
"class_name": class_name_param,
"name": name,
"stylebox_class": "StyleBoxFlat",
"bg_color": _serialize_value(sb.bg_color),
"border": {
"top": sb.border_width_top,
"bottom": sb.border_width_bottom,
"left": sb.border_width_left,
"right": sb.border_width_right,
},
"corners": {
"top_left": sb.corner_radius_top_left,
"top_right": sb.corner_radius_top_right,
"bottom_left": sb.corner_radius_bottom_left,
"bottom_right": sb.corner_radius_bottom_right,
},
"margins": {
"top": sb.content_margin_top,
"bottom": sb.content_margin_bottom,
"left": sb.content_margin_left,
"right": sb.content_margin_right,
},
"undoable": true,
}
}
## Parse a {all, <side1>, <side2>, ...} dict and apply it to StyleBoxFlat via
## its set_<prop_prefix><side> properties. Returns "" on success, an error
## message on failure. Validates that only known keys are present.
func _apply_sides(sb: StyleBoxFlat, sides_dict: Variant, dict_name: String,
side_names: Array, prop_prefix: String, value_type: int) -> String:
if typeof(sides_dict) != TYPE_DICTIONARY:
return "'%s' must be a dict with 'all' and/or side-specific keys" % dict_name
var valid_keys := {"all": true}
for s in side_names:
valid_keys[s] = true
for k in sides_dict.keys():
if not valid_keys.has(k):
return "Unknown key in '%s': %s (valid: all, %s)" % [
dict_name, k, ", ".join(side_names)
]
# Apply `all` first, then override with side-specific keys.
if sides_dict.has("all"):
var all_val: Variant = sides_dict.all
for s in side_names:
var v: Variant = int(all_val) if value_type == TYPE_INT else float(all_val)
sb.set(prop_prefix + s, v)
for s in side_names:
if sides_dict.has(s):
var v2: Variant = int(sides_dict[s]) if value_type == TYPE_INT else float(sides_dict[s])
sb.set(prop_prefix + s, v2)
return ""
func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
theme.set_stylebox(name, class_name_param, sb)
ResourceSaver.save(theme, theme_path)
func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
theme.clear_stylebox(name, class_name_param)
ResourceSaver.save(theme, theme_path)
# ============================================================================
# theme_apply — assign a theme to a Control
# ============================================================================
func apply_theme(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
var theme_path: String = params.get("theme_path", "")
var theme: Theme = null
if not theme_path.is_empty():
var path_err := _validate_res_path(theme_path, ".tres")
if path_err != null:
return path_err
if not ResourceLoader.exists(theme_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
theme = ResourceLoader.load(theme_path)
if theme == null or not theme is Theme:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path)
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
if not node is Control and not node is Window:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a Control or Window (got %s)" % [node_path, node.get_class()]
)
var before_theme: Theme = node.theme
_undo_redo.create_action("MCP: Apply theme to %s" % node.name)
_undo_redo.add_do_property(node, "theme", theme)
_undo_redo.add_undo_property(node, "theme", before_theme)
_undo_redo.commit_action()
return {
"data": {
"node_path": node_path,
"theme_path": theme_path if theme != null else "",
"cleared": theme == null,
"undoable": true,
}
}
# ============================================================================
# Helpers
# ============================================================================
func _load_theme_from_params(params: Dictionary) -> Dictionary:
var theme_path: String = params.get("theme_path", "")
var err := _validate_res_path(theme_path, ".tres", "theme_path", true)
if err != null:
return err
if not ResourceLoader.exists(theme_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null or not theme is Theme:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path)
return {"theme": theme, "path": theme_path}
static func _validate_res_path(path: String, required_suffix: String, param_name: String = "theme_path", for_write: bool = false) -> Variant:
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
var path_err := McpPathValidator.validate_resource_path(path, for_write)
if not path_err.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err])
if not path.ends_with(required_suffix):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"%s must end with %s (got %s)" % [param_name, required_suffix, path]
)
return null
## Parse a color from Color, "#rrggbb", "#rrggbbaa", named (red/blue/...) or dict.
## Returns null if the input cannot be parsed.
static func _parse_color(value: Variant) -> Variant:
if value is Color:
return value
if value is String:
var s: String = value
# Color.from_string returns the default on parse failure, so call it twice
# with distinct sentinels — if both agree, parsing succeeded.
var sentinel_a := Color(0, 0, 0, 0)
var sentinel_b := Color(1, 1, 1, 1)
var a := Color.from_string(s, sentinel_a)
var b := Color.from_string(s, sentinel_b)
if a != b:
return null
return a
if value is Dictionary:
var d: Dictionary = value
if d.has("r") and d.has("g") and d.has("b"):
return Color(float(d.r), float(d.g), float(d.b), float(d.get("a", 1.0)))
return null
static func _serialize_value(value: Variant) -> Variant:
if value == null:
return null
if value is Color:
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
if value is Vector2:
return {"x": value.x, "y": value.y}
return value