489 lines
18 KiB
GDScript
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
|