1057 lines
44 KiB
GDScript
1057 lines
44 KiB
GDScript
@tool
|
|
extends "res://addons/godot_ai/handlers/command_handler.gd"
|
|
|
|
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"
|
|
|
|
## Theme constants/font sizes and StyleBoxFlat int properties are 32-bit in the
|
|
## engine; GDScript ints are 64-bit, so an unvalidated 4294967296 narrows to 0
|
|
## (and 2147483648 to -2147483648) at the property assignment.
|
|
const _INT32_MIN := -2147483648
|
|
const _INT32_MAX := 2147483647
|
|
|
|
## Native storage kinds for numeric theme destinations, so validation models
|
|
## what the engine actually keeps:
|
|
## _NATIVE_INT32 C++ int32 (Theme constants/font sizes, shadow_size)
|
|
## _NATIVE_FLOAT32 C++ real_t (margins, shadow offsets, Color components)
|
|
## _NATIVE_INT32_FLOAT32 int accessors backed by real_t storage: StyleBoxFlat
|
|
## border widths and corner radii take ints but store
|
|
## real_t, so 16777217 rounds to 16777216 and
|
|
## 2147483647 reads back as -2147483648
|
|
const _NATIVE_INT32 := 0
|
|
const _NATIVE_FLOAT32 := 1
|
|
const _NATIVE_INT32_FLOAT32 := 2
|
|
|
|
## Top-level keys of the StyleBoxFlat patch vocabulary shared by
|
|
## set_stylebox_flat and stylebox_override. Nested dicts validate their own
|
|
## keys inside _apply_flat_props; this guards the outer dict so a typo
|
|
## (`bg_colour`) cannot install an unchanged override plus an undo entry while
|
|
## reporting success.
|
|
const _FLAT_PATCH_KEYS := ["bg_color", "border_color", "border", "corners", "margins", "shadow", "anti_aliasing"]
|
|
|
|
var _undo_redo: EditorUndoRedoManager
|
|
var _connection: McpConnection
|
|
|
|
|
|
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
|
|
_undo_redo = undo_redo
|
|
_connection = connection
|
|
|
|
|
|
# ============================================================================
|
|
# 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 := McpResourceIO.guarded_save(theme, path, _connection)
|
|
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 _parse_int32_value(v))
|
|
|
|
|
|
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 _parse_int32_value(v))
|
|
|
|
|
|
## Theme constant/font_size slots are 32-bit ints. Accepts int/float and
|
|
## valid-int strings, but rejects non-finite or out-of-int32 values before the
|
|
## engine narrowing can wrap them (4294967296 -> 0, 2147483648 -> -2147483648).
|
|
## In-range fractional values keep the existing truncation policy.
|
|
static func _parse_int32_value(v: Variant) -> Variant:
|
|
var number: float
|
|
if v is int or v is float:
|
|
number = float(v)
|
|
elif v is String and v.is_valid_int():
|
|
number = float(v.to_int())
|
|
else:
|
|
return null
|
|
if not is_finite(number) or number < float(_INT32_MIN) or number > float(_INT32_MAX):
|
|
return null
|
|
return int(number)
|
|
|
|
|
|
# Shared implementation for scalar Theme slots (color, constant, font_size).
|
|
# Captures old value, applies new value, persists it as a pre-flight save, then
|
|
# registers undo that restores the old value and saves again. A failed
|
|
# pre-flight save restores the old value and reports an error instead of
|
|
# leaving memory and disk out of sync.
|
|
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 a 32-bit 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
|
|
|
|
## Pre-flight: apply and persist before the undo action exists, so an
|
|
## unwritable theme file (read-only, permissions) fails here with the
|
|
## cached resource restored instead of reporting success while the file
|
|
## never changed.
|
|
var save_err: int = _apply_scalar(theme_path, setter, name, class_name_param, parsed)
|
|
if save_err != OK:
|
|
if had_before:
|
|
_apply_scalar(theme_path, setter, name, class_name_param, before_value)
|
|
else:
|
|
_clear_scalar(theme_path, clearer, name, class_name_param)
|
|
return _save_failure(theme_path, save_err)
|
|
|
|
_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)
|
|
## The do method already ran as the pre-flight save; register without
|
|
## re-executing so a second save's error cannot be discarded.
|
|
_undo_redo.commit_action(false)
|
|
|
|
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) -> int:
|
|
var theme: Theme = ResourceLoader.load(theme_path)
|
|
if theme == null:
|
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
|
return ERR_FILE_CANT_OPEN
|
|
setter.call(theme, name, class_name_param, value)
|
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
|
|
|
|
|
func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> int:
|
|
var theme: Theme = ResourceLoader.load(theme_path)
|
|
if theme == null:
|
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
|
return ERR_FILE_CANT_OPEN
|
|
clearer.call(theme, name, class_name_param)
|
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
|
|
|
|
|
## Error dict for a failed pre-flight save. The caller has already restored the
|
|
## previous slot in memory, so the theme file and the cached resource both hold
|
|
## the pre-request state and no undo action was committed.
|
|
static func _save_failure(theme_path: String, save_err: int) -> Dictionary:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
"Failed to save theme to %s: %s (error %d); the change was rolled back"
|
|
% [theme_path, error_string(save_err), save_err]
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# 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. Numeric values must be
|
|
## finite and within the native storage's range (32-bit ints, 32-bit floats,
|
|
## and the float32-backed ints behind border widths/corner radii) and flags
|
|
## real booleans; invalid input is refused with a structured error before
|
|
## anything is applied, so a refused call leaves the theme slot and undo
|
|
## history untouched. A slot change is persisted before the undo action is
|
|
## registered: if the theme file cannot be written, the previous slot is
|
|
## restored and the call fails with INTERNAL_ERROR.
|
|
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()
|
|
var applied := _apply_flat_props(sb, params)
|
|
if applied.has("error"):
|
|
return applied
|
|
|
|
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
|
|
## Pre-flight: apply and persist before the undo action exists, so an
|
|
## unwritable theme file fails here with the cached resource restored
|
|
## instead of reporting success while the file never changed.
|
|
var save_err: int = _apply_stylebox(theme_path, name, class_name_param, sb)
|
|
if save_err != OK:
|
|
if had_before:
|
|
_apply_stylebox(theme_path, name, class_name_param, before_sb)
|
|
else:
|
|
_clear_stylebox(theme_path, name, class_name_param)
|
|
return _save_failure(theme_path, save_err)
|
|
_commit_stylebox(theme_path, name, class_name_param, sb, before_sb, had_before)
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
|
|
## Apply the StyleBoxFlat property vocabulary shared by `set_stylebox_flat`
|
|
## and `stylebox_override` to an existing StyleBoxFlat. Returns `{ok: true}`
|
|
## or the error dict to surface.
|
|
func _apply_flat_props(sb: StyleBoxFlat, params: Dictionary) -> Dictionary:
|
|
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 (real_t storage)
|
|
if params.has("border"):
|
|
var border_result := _apply_sides(sb, params.border, "border",
|
|
["top", "bottom", "left", "right"],
|
|
"border_width_",
|
|
_NATIVE_INT32_FLOAT32)
|
|
if border_result.has("error"):
|
|
return border_result
|
|
|
|
# corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii (real_t storage)
|
|
if params.has("corners"):
|
|
var corners_result := _apply_sides(sb, params.corners, "corners",
|
|
["top_left", "top_right", "bottom_left", "bottom_right"],
|
|
"corner_radius_",
|
|
_NATIVE_INT32_FLOAT32)
|
|
if corners_result.has("error"):
|
|
return corners_result
|
|
|
|
# margins: {all, top, bottom, left, right} — float padding
|
|
if params.has("margins"):
|
|
var margins_result := _apply_sides(sb, params.margins, "margins",
|
|
["top", "bottom", "left", "right"],
|
|
"content_margin_",
|
|
_NATIVE_FLOAT32)
|
|
if margins_result.has("error"):
|
|
return margins_result
|
|
|
|
# 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"):
|
|
var size_result := _parse_number_field("shadow", "size", shadow.size, _NATIVE_INT32)
|
|
if size_result.has("error"):
|
|
return size_result
|
|
sb.shadow_size = size_result.value
|
|
if shadow.has("offset_x") or shadow.has("offset_y"):
|
|
## Missing components keep the stylebox's current offset: on the
|
|
## stylebox_override path `sb` is a duplicate of the resolved
|
|
## stylebox, and a patch must not silently reset the other axis.
|
|
var offset_x := sb.shadow_offset.x
|
|
var offset_y := sb.shadow_offset.y
|
|
if shadow.has("offset_x"):
|
|
var offset_x_result := _parse_number_field("shadow", "offset_x", shadow.offset_x, _NATIVE_FLOAT32)
|
|
if offset_x_result.has("error"):
|
|
return offset_x_result
|
|
offset_x = offset_x_result.value
|
|
if shadow.has("offset_y"):
|
|
var offset_y_result := _parse_number_field("shadow", "offset_y", shadow.offset_y, _NATIVE_FLOAT32)
|
|
if offset_y_result.has("error"):
|
|
return offset_y_result
|
|
offset_y = offset_y_result.value
|
|
sb.shadow_offset = Vector2(offset_x, offset_y)
|
|
|
|
if params.has("anti_aliasing"):
|
|
var anti_aliasing_result := _parse_bool_field("anti_aliasing", params.anti_aliasing)
|
|
if anti_aliasing_result.has("error"):
|
|
return anti_aliasing_result
|
|
sb.anti_aliasing = anti_aliasing_result.value
|
|
return {"ok": true}
|
|
|
|
|
|
## Top-level keys in `patch` that are not part of the StyleBoxFlat vocabulary.
|
|
static func _unknown_flat_keys(patch: Dictionary) -> Array:
|
|
var unknown: Array = []
|
|
for key in patch.keys():
|
|
if not _FLAT_PATCH_KEYS.has(key):
|
|
unknown.append(str(key))
|
|
return unknown
|
|
|
|
|
|
## Record one stylebox set as an undoable action (apply new, restore or clear
|
|
## the previous slot). Shared by set_stylebox_flat and set_stylebox_texture.
|
|
## The caller has already applied and persisted the new stylebox as its
|
|
## pre-flight save, so the action is committed without re-executing the do
|
|
## method.
|
|
func _commit_stylebox(
|
|
theme_path: String, name: String, class_name_param: String,
|
|
sb: StyleBox, before_sb: StyleBox, had_before: bool
|
|
) -> void:
|
|
_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(false)
|
|
|
|
|
|
# ============================================================================
|
|
# theme_set_stylebox_texture
|
|
# ============================================================================
|
|
|
|
## Compose a StyleBoxTexture (9-slice) and assign it to a theme slot.
|
|
##
|
|
## Parameters (beyond theme_path / class_name / name):
|
|
## texture_path res:// path to a Texture2D
|
|
## region {position: {x,y}, size: {x,y}} or [x,y,w,h]
|
|
## margins {all|left|top|right|bottom: float} texture margins
|
|
## axis_stretch_horizontal "stretch" | "tile" | "tile_fit"
|
|
## axis_stretch_vertical "stretch" | "tile" | "tile_fit"
|
|
## modulate_color Color
|
|
## draw_center bool
|
|
##
|
|
## Numeric values must be finite and within the native storage's range, and
|
|
## flags real booleans; invalid input is refused with a structured error before
|
|
## anything is applied. A slot change is persisted before the undo action is
|
|
## registered: if the theme file cannot be written, the previous slot is
|
|
## restored and the call fails with INTERNAL_ERROR.
|
|
func set_stylebox_texture(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 texture_path: String = params.get("texture_path", "")
|
|
if texture_path.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: texture_path")
|
|
var path_err = McpPathValidator.loadable_error(texture_path, "texture_path")
|
|
if path_err != null:
|
|
return path_err
|
|
if not ResourceLoader.exists(texture_path):
|
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path)
|
|
var texture := ResourceLoader.load(texture_path)
|
|
if texture == null or not (texture is Texture2D):
|
|
var got := texture.get_class() if texture != null else "null"
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
|
"Resource at %s is not a Texture2D (got %s)" % [texture_path, got])
|
|
|
|
var sb := StyleBoxTexture.new()
|
|
sb.texture = texture
|
|
if params.has("region"):
|
|
var region := _parse_rect2(params.region)
|
|
if region == null:
|
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"Invalid region: %s (expected {position, size} or [x,y,w,h])" % str(params.region))
|
|
sb.region_rect = region
|
|
if params.has("margins"):
|
|
var margin_result := _apply_texture_margins(sb, params.margins)
|
|
if margin_result.has("error"):
|
|
return margin_result
|
|
if params.has("modulate_color"):
|
|
var mc := _parse_color(params.modulate_color)
|
|
if mc == null:
|
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"Invalid modulate_color: %s (%s)" % [str(params.modulate_color), _COLOR_HINT])
|
|
sb.modulate_color = mc
|
|
if params.has("draw_center"):
|
|
var draw_center_result := _parse_bool_field("draw_center", params.draw_center)
|
|
if draw_center_result.has("error"):
|
|
return draw_center_result
|
|
sb.draw_center = draw_center_result.value
|
|
if params.has("axis_stretch_horizontal"):
|
|
var ash := _parse_axis_stretch(str(params.axis_stretch_horizontal))
|
|
if ash == null:
|
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"Invalid axis_stretch_horizontal '%s'. Valid: stretch, tile, tile_fit" % str(params.axis_stretch_horizontal))
|
|
sb.axis_stretch_horizontal = ash
|
|
if params.has("axis_stretch_vertical"):
|
|
var asv := _parse_axis_stretch(str(params.axis_stretch_vertical))
|
|
if asv == null:
|
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"Invalid axis_stretch_vertical '%s'. Valid: stretch, tile, tile_fit" % str(params.axis_stretch_vertical))
|
|
sb.axis_stretch_vertical = asv
|
|
|
|
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
|
|
## Pre-flight: apply and persist before the undo action exists, so an
|
|
## unwritable theme file fails here with the cached resource restored
|
|
## instead of reporting success while the file never changed.
|
|
var save_err: int = _apply_stylebox(theme_path, name, class_name_param, sb)
|
|
if save_err != OK:
|
|
if had_before:
|
|
_apply_stylebox(theme_path, name, class_name_param, before_sb)
|
|
else:
|
|
_clear_stylebox(theme_path, name, class_name_param)
|
|
return _save_failure(theme_path, save_err)
|
|
_commit_stylebox(theme_path, name, class_name_param, sb, before_sb, had_before)
|
|
|
|
return {
|
|
"data": {
|
|
"path": theme_path,
|
|
"class_name": class_name_param,
|
|
"name": name,
|
|
"stylebox_class": "StyleBoxTexture",
|
|
"texture_path": texture_path,
|
|
"region": _serialize_value(sb.region_rect),
|
|
"margins": {
|
|
"left": sb.texture_margin_left,
|
|
"top": sb.texture_margin_top,
|
|
"right": sb.texture_margin_right,
|
|
"bottom": sb.texture_margin_bottom,
|
|
},
|
|
"draw_center": sb.draw_center,
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# theme_set_font / theme_set_icon
|
|
# ============================================================================
|
|
|
|
## Assign a Font resource to a theme font slot (button/body/heading fonts).
|
|
func set_font(params: Dictionary) -> Dictionary:
|
|
return _set_resource_slot(params, "font", "font_path", "Font")
|
|
|
|
|
|
## Assign a Texture2D to a theme icon slot (checkbox marks, dropdown arrows).
|
|
func set_icon(params: Dictionary) -> Dictionary:
|
|
return _set_resource_slot(params, "icon", "texture_path", "Texture2D")
|
|
|
|
|
|
## Shared implementation for font/icon slots: load the resource, verify its
|
|
## class, then set/clear the slot as one undoable action.
|
|
func _set_resource_slot(
|
|
params: Dictionary, kind: String, path_param: String, expected_class: String
|
|
) -> 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 resource_path: String = params.get(path_param, "")
|
|
if resource_path.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % path_param)
|
|
var path_err = McpPathValidator.loadable_error(resource_path, path_param)
|
|
if path_err != null:
|
|
return path_err
|
|
if not ResourceLoader.exists(resource_path):
|
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "%s not found: %s" % [expected_class, resource_path])
|
|
var loaded := ResourceLoader.load(resource_path)
|
|
if loaded == null or not _is_instance_of_class(loaded, expected_class):
|
|
var got := loaded.get_class() if loaded != null else "null"
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
|
"Resource at %s is not a %s (got %s)" % [resource_path, expected_class, got])
|
|
|
|
var had_before := theme.has_font(name, class_name_param) if kind == "font" else theme.has_icon(name, class_name_param)
|
|
var before_value = theme.get_font(name, class_name_param) if kind == "font" else theme.get_icon(name, class_name_param)
|
|
if not had_before:
|
|
before_value = null
|
|
|
|
## Pre-flight: apply and persist before the undo action exists, so an
|
|
## unwritable theme file fails here with the cached resource restored
|
|
## instead of reporting success while the file never changed.
|
|
var save_err: int = _apply_slot(theme_path, kind, name, class_name_param, loaded)
|
|
if save_err != OK:
|
|
if had_before:
|
|
_apply_slot(theme_path, kind, name, class_name_param, before_value)
|
|
else:
|
|
_clear_slot(theme_path, kind, name, class_name_param)
|
|
return _save_failure(theme_path, save_err)
|
|
|
|
_undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
|
|
_undo_redo.add_do_method(self, "_apply_slot", theme_path, kind, name, class_name_param, loaded)
|
|
if had_before:
|
|
_undo_redo.add_undo_method(self, "_apply_slot", theme_path, kind, name, class_name_param, before_value)
|
|
else:
|
|
_undo_redo.add_undo_method(self, "_clear_slot", theme_path, kind, name, class_name_param)
|
|
## The do method already ran as the pre-flight save; register without
|
|
## re-executing so a second save's error cannot be discarded.
|
|
_undo_redo.commit_action(false)
|
|
|
|
return {
|
|
"data": {
|
|
"path": theme_path,
|
|
"kind": kind,
|
|
"class_name": class_name_param,
|
|
"name": name,
|
|
"resource_path": resource_path,
|
|
"resource_class": loaded.get_class(),
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
## True when `resource` is an instance of `class_name` (or a subclass).
|
|
static func _is_instance_of_class(resource: Resource, expected_class: String) -> bool:
|
|
return ClassDB.is_parent_class(resource.get_class(), expected_class)
|
|
|
|
|
|
# ============================================================================
|
|
# theme_stylebox_override — per-node override
|
|
# ============================================================================
|
|
|
|
## Duplicate the stylebox a Control resolves for `slot`, apply a StyleBoxFlat
|
|
## patch (same keys as set_stylebox_flat), and attach it as a per-node
|
|
## override. Undo restores the previous override, or removes the override when
|
|
## the node had none. Patch values are validated the same way as
|
|
## set_stylebox_flat — unknown top-level keys are refused — and a refused
|
|
## patch leaves the node's override untouched. The action is committed to the
|
|
## Control's scene history, so the editor's scene undo reverts it (a method
|
|
## bound to this handler object would land in GLOBAL_HISTORY instead).
|
|
func stylebox_override(params: Dictionary) -> Dictionary:
|
|
var node_path: String = params.get("path", "")
|
|
if node_path.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
|
|
var slot: String = params.get("slot", "")
|
|
if slot.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: slot")
|
|
var patch: Variant = params.get("patch", {})
|
|
if typeof(patch) != TYPE_DICTIONARY:
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "'patch' must be a dict of StyleBoxFlat properties")
|
|
var unknown_keys := _unknown_flat_keys(patch)
|
|
if not unknown_keys.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
|
|
"Unknown patch key(s): %s (valid: %s)" % [", ".join(unknown_keys), ", ".join(_FLAT_PATCH_KEYS)])
|
|
|
|
var resolved := McpNodeValidator.resolve_or_error(node_path, "path")
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var node: Node = resolved.node
|
|
if not node is Control:
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
|
"Node %s is not a Control (got %s)" % [node_path, node.get_class()])
|
|
var control := node as Control
|
|
|
|
var base: StyleBox = control.get_theme_stylebox(slot)
|
|
if base == null:
|
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
|
|
"No stylebox resolves for slot '%s' on %s" % [slot, node.get_class()])
|
|
var patched: StyleBox = base.duplicate()
|
|
if not patched is StyleBoxFlat:
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
|
"Slot '%s' resolves to %s; stylebox_override patches StyleBoxFlat slots only"
|
|
% [slot, patched.get_class()])
|
|
var applied := _apply_flat_props(patched as StyleBoxFlat, patch)
|
|
if applied.has("error"):
|
|
return applied
|
|
|
|
var had_override: bool = control.has_theme_stylebox_override(slot)
|
|
var scene_root: Node = resolved.scene_root
|
|
_undo_redo.create_action("MCP: Stylebox override %s on %s" % [slot, node.name],
|
|
UndoRedo.MERGE_DISABLE, scene_root)
|
|
_undo_redo.add_do_method(self, "_apply_node_stylebox", control, slot, patched)
|
|
if had_override:
|
|
_undo_redo.add_undo_method(self, "_apply_node_stylebox", control, slot, base)
|
|
else:
|
|
_undo_redo.add_undo_method(self, "_remove_node_stylebox", control, slot)
|
|
_undo_redo.commit_action()
|
|
|
|
return {
|
|
"data": {
|
|
"path": McpScenePath.from_node(node, resolved.scene_root),
|
|
"slot": slot,
|
|
"stylebox_class": patched.get_class(),
|
|
"overrode_existing": had_override,
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
func _apply_node_stylebox(control: Control, slot: String, sb: StyleBox) -> void:
|
|
control.add_theme_stylebox_override(slot, sb)
|
|
|
|
|
|
func _remove_node_stylebox(control: Control, slot: String) -> void:
|
|
control.remove_theme_stylebox_override(slot)
|
|
|
|
|
|
## Strict finite-number parser for stylebox numeric fields. Accepts int/float
|
|
## and numeric strings (the McpJsonValues.parse_float vocabulary) and returns
|
|
## `{"value": <int|float>}`; non-numeric input and non-finite results return a
|
|
## structured error instead, so a bad value can neither raise an engine
|
|
## conversion error nor be silently stored as 0.
|
|
##
|
|
## The value is validated against the *native destination* before narrowing
|
|
## (see the _NATIVE_* kinds), and the returned value is the narrowed one, so
|
|
## what was validated is what gets stored.
|
|
static func _parse_number_field(dict_name: String, key: String, raw: Variant, storage: int) -> Dictionary:
|
|
var parsed: Variant = McpJsonValues.parse_float(raw)
|
|
if parsed == null:
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
|
"'%s.%s' must be a number, got %s" % [dict_name, key, type_string(typeof(raw))])
|
|
var number := float(parsed)
|
|
if not is_finite(number):
|
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"'%s.%s' must be finite, got %s" % [dict_name, key, str(number)])
|
|
match storage:
|
|
_NATIVE_INT32:
|
|
if number < float(_INT32_MIN) or number > float(_INT32_MAX):
|
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"'%s.%s' is out of range for a 32-bit integer (got %s)" % [dict_name, key, str(number)])
|
|
return {"value": int(number)}
|
|
_NATIVE_FLOAT32:
|
|
var narrowed: float = PackedFloat32Array([number])[0]
|
|
if not is_finite(narrowed):
|
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"'%s.%s' is out of range for a 32-bit float (got %s)" % [dict_name, key, str(number)])
|
|
return {"value": narrowed}
|
|
_NATIVE_INT32_FLOAT32:
|
|
## Must survive the float32 round-trip and stay in int32, or the
|
|
## int getter reads back a rounded or wrapped value.
|
|
var narrowed: float = PackedFloat32Array([number])[0]
|
|
if not is_finite(narrowed) or narrowed < float(_INT32_MIN) or narrowed > float(_INT32_MAX) \
|
|
or int(narrowed) != int(number):
|
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"'%s.%s' is out of range for the float-backed 32-bit int storage (got %s)" % [dict_name, key, str(number)])
|
|
return {"value": int(narrowed)}
|
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unknown numeric storage kind: %d" % storage)
|
|
|
|
|
|
## Strict bool parser for stylebox flags. `bool("false")` is true in GDScript,
|
|
## so a stringified flag would silently invert the caller's intent.
|
|
static func _parse_bool_field(name: String, raw: Variant) -> Dictionary:
|
|
if raw is bool:
|
|
return {"value": raw}
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
|
"'%s' must be a boolean, got %s" % [name, type_string(typeof(raw))])
|
|
|
|
|
|
## Parse a {all, <side1>, <side2>, ...} dict into StyleBox numeric properties.
|
|
## Every value is parsed and validated before anything is assigned, so an
|
|
## invalid value can neither raise an engine conversion error nor leave a
|
|
## partially applied stylebox. `sb` is a StyleBoxFlat or StyleBoxTexture (both
|
|
## expose `set`); `storage` is one of the _NATIVE_* kinds. Returns
|
|
## {"ok": true} or the error dict to surface.
|
|
static func _apply_sides(sb: Object, sides_dict: Variant, dict_name: String,
|
|
side_names: Array, prop_prefix: String, storage: int) -> Dictionary:
|
|
if typeof(sides_dict) != TYPE_DICTIONARY:
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
|
"'%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 ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
|
|
"Unknown key in '%s': %s (valid: all, %s)" % [dict_name, k, ", ".join(side_names)])
|
|
var parsed_values := {}
|
|
if sides_dict.has("all"):
|
|
var all_result := _parse_number_field(dict_name, "all", sides_dict.all, storage)
|
|
if all_result.has("error"):
|
|
return all_result
|
|
parsed_values["all"] = all_result.value
|
|
for s in side_names:
|
|
if sides_dict.has(s):
|
|
var side_result := _parse_number_field(dict_name, s, sides_dict[s], storage)
|
|
if side_result.has("error"):
|
|
return side_result
|
|
parsed_values[s] = side_result.value
|
|
# Every value parsed: apply `all` first, then the side-specific overrides.
|
|
if parsed_values.has("all"):
|
|
for s in side_names:
|
|
sb.set(prop_prefix + s, parsed_values["all"])
|
|
for s in side_names:
|
|
if parsed_values.has(s):
|
|
sb.set(prop_prefix + s, parsed_values[s])
|
|
return {"ok": true}
|
|
|
|
|
|
func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> int:
|
|
var theme: Theme = ResourceLoader.load(theme_path)
|
|
if theme == null:
|
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
|
return ERR_FILE_CANT_OPEN
|
|
theme.set_stylebox(name, class_name_param, sb)
|
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
|
|
|
|
|
func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> int:
|
|
var theme: Theme = ResourceLoader.load(theme_path)
|
|
if theme == null:
|
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
|
return ERR_FILE_CANT_OPEN
|
|
theme.clear_stylebox(name, class_name_param)
|
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
|
|
|
|
|
# ============================================================================
|
|
# 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.
|
|
## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array
|
|
## support and strict key/component checking, same shapes as every other
|
|
## color-accepting handler. Color components are 32-bit floats, so a finite
|
|
## double like 1e40 narrows to INF at construction; the parsed value is
|
|
## re-checked for finiteness and refused instead of storing an infinite color.
|
|
static func _parse_color(value: Variant) -> Variant:
|
|
var parsed: Variant = McpJsonValues.parse_color(value)
|
|
if parsed == null:
|
|
return null
|
|
var color := parsed as Color
|
|
if not (is_finite(color.r) and is_finite(color.g) and is_finite(color.b) and is_finite(color.a)):
|
|
return null
|
|
return color
|
|
|
|
|
|
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}
|
|
if value is Rect2:
|
|
return {
|
|
"position": {"x": value.position.x, "y": value.position.y},
|
|
"size": {"x": value.size.x, "y": value.size.y},
|
|
}
|
|
return value
|
|
|
|
|
|
## Set a font/icon slot from inside an undo action. Stylebox slots use the
|
|
## dedicated `_apply_stylebox` path (they need the StyleBox-typed setter).
|
|
func _apply_slot(theme_path: String, kind: String, name: String, class_name_param: String, value: Variant) -> int:
|
|
var theme: Theme = ResourceLoader.load(theme_path)
|
|
if theme == null:
|
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
|
return ERR_FILE_CANT_OPEN
|
|
match kind:
|
|
"font":
|
|
theme.set_font(name, class_name_param, value)
|
|
"icon":
|
|
theme.set_icon(name, class_name_param, value)
|
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
|
|
|
|
|
func _clear_slot(theme_path: String, kind: String, name: String, class_name_param: String) -> int:
|
|
var theme: Theme = ResourceLoader.load(theme_path)
|
|
if theme == null:
|
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
|
return ERR_FILE_CANT_OPEN
|
|
match kind:
|
|
"font":
|
|
theme.clear_font(name, class_name_param)
|
|
"icon":
|
|
theme.clear_icon(name, class_name_param)
|
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
|
|
|
|
|
## Parse a 9-slice region from {position: {x,y}, size: {x,y}} or [x,y,w,h].
|
|
## Non-finite components are rejected (null), so a NaN/INF region can never be
|
|
## committed to a stylebox slot.
|
|
static func _parse_rect2(value: Variant) -> Variant:
|
|
if value is Rect2:
|
|
return value if _is_finite_rect2(value) else null
|
|
if value is Dictionary:
|
|
var d: Dictionary = value
|
|
if not (d.has("position") and d.has("size")):
|
|
return null
|
|
var pos := McpJsonValues.parse_vector2(d.position)
|
|
var size := McpJsonValues.parse_vector2(d.size)
|
|
if pos == null or size == null:
|
|
return null
|
|
var rect := Rect2(pos, size)
|
|
return rect if _is_finite_rect2(rect) else null
|
|
if value is Array:
|
|
var arr: Array = value
|
|
if arr.size() != 4:
|
|
return null
|
|
for item in arr:
|
|
if not (item is int or item is float):
|
|
return null
|
|
var rect := Rect2(float(arr[0]), float(arr[1]), float(arr[2]), float(arr[3]))
|
|
return rect if _is_finite_rect2(rect) else null
|
|
return null
|
|
|
|
|
|
static func _is_finite_rect2(rect: Rect2) -> bool:
|
|
return (
|
|
is_finite(rect.position.x)
|
|
and is_finite(rect.position.y)
|
|
and is_finite(rect.size.x)
|
|
and is_finite(rect.size.y)
|
|
)
|
|
|
|
|
|
## StyleBoxTexture axis stretch mode by name. Returns null on an unknown name.
|
|
static func _parse_axis_stretch(value: String) -> Variant:
|
|
match value:
|
|
"stretch":
|
|
return StyleBoxTexture.AXIS_STRETCH_MODE_STRETCH
|
|
"tile":
|
|
return StyleBoxTexture.AXIS_STRETCH_MODE_TILE
|
|
"tile_fit":
|
|
return StyleBoxTexture.AXIS_STRETCH_MODE_TILE_FIT
|
|
return null
|
|
|
|
|
|
## Apply {all, left, top, right, bottom} texture margins to a StyleBoxTexture.
|
|
## Shares the flat side parser's validate-then-apply contract: every value is
|
|
## parsed before any margin is assigned. Returns {"ok": true} or the error dict.
|
|
static func _apply_texture_margins(sb: StyleBoxTexture, margins: Variant) -> Dictionary:
|
|
return _apply_sides(sb, margins, "margins", ["left", "top", "right", "bottom"],
|
|
"texture_margin_", _NATIVE_FLOAT32)
|