2026-10-09 12:49:07 +02:00

209 lines
7.2 KiB
GDScript

@tool
extends RefCounted
## theme.* command handlers — create/modify Theme resources.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const FileGuard = Modules.FileGuard
const Helpers = Modules.CommandHelpers
const _VALID_PROPERTY_TYPES := ["color", "constant", "font", "font_size", "icon", "stylebox"]
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
registry.add("theme.edit", func(parameters: Dictionary) -> Dictionary:
return await _cmd_theme_edit(parameters)
, MCPToolkitCommandOptions.new().mark_scene_independent())
# -- Commands -----------------------------------------------------------------
static func _cmd_theme_edit(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path", "edits"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
file_path = Helpers.normalize_editor_path(file_path)
var edits_raw = parameters.get("edits", null)
if typeof(edits_raw) != TYPE_ARRAY:
return MCPToolkitError.fail("INVALID_PARAMS", "edits must be an Array of edit descriptors")
var guard := FileGuard.resolve_safe(file_path)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
if not file_path.get_extension().to_lower() in ["tres", "res"]:
return MCPToolkitError.fail("INVALID_PATH",
"theme.edit writes .tres/.res files (got %s)" % file_path)
# Load existing or create new Theme.
var theme: Theme
if FileAccess.file_exists(file_path):
var loaded = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_IGNORE)
if loaded == null or not (loaded is Theme):
return MCPToolkitError.fail("INVALID_CLASS",
"Resource at %s is not a Theme" % file_path)
theme = loaded as Theme
else:
theme = Theme.new()
var edits: Array = edits_raw
var edits_applied := 0
for i in range(edits.size()):
var edit = edits[i]
if typeof(edit) != TYPE_DICTIONARY:
return MCPToolkitError.fail("INVALID_PARAMS",
"edits[%d] must be a dictionary" % i)
# Validate required keys per edit.
for key in ["type_name", "property_type", "property", "value"]:
if not edit.has(key):
return MCPToolkitError.fail("INVALID_PARAMS",
"edits[%d] missing required key '%s'" % [i, key])
var type_name := str(edit["type_name"])
var property_type := str(edit["property_type"])
var property_name := str(edit["property"])
var value = edit["value"]
if property_type not in _VALID_PROPERTY_TYPES:
return MCPToolkitError.fail("INVALID_PARAMS",
"edits[%d] invalid property_type '%s'; must be one of: %s" % [
i, property_type, ", ".join(_VALID_PROPERTY_TYPES)])
var apply_err := _apply_edit(theme, type_name, property_type, property_name, value, i)
if not apply_err.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", apply_err)
edits_applied += 1
# Save.
var dir_result := Helpers.ensure_parent_dir(file_path, "theme.edit")
if dir_result.has("error"):
return dir_result
var save_err := ResourceSaver.save(theme, file_path)
if save_err != OK:
return MCPToolkitError.fail("SAVE_FAILED",
"ResourceSaver.save returned %d (path=%s)" % [save_err, file_path])
ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_REPLACE)
await Helpers.ensure_file_indexed(file_path)
return MCPToolkitSuccess.ok({
"path": file_path,
"edits_applied": edits_applied,
})
# -- Edit helpers -------------------------------------------------------------
## Project a {r,g,b,a} dict onto a Color with opaque-BLACK channel defaults
## (paint semantics: r/g/b default 0.0, a defaults 1.0). Distinct from
## Coerce.color_from_dict (opaque-WHITE / tint) — do not conflate. Non-dict -> opaque black.
static func _color_from_dict_opaque(d: Variant) -> Color:
if typeof(d) != TYPE_DICTIONARY:
return Color(0, 0, 0, 1)
return Color(
float(d.get("r", 0.0)), float(d.get("g", 0.0)),
float(d.get("b", 0.0)), float(d.get("a", 1.0)))
static func _apply_edit(
theme: Theme, type_name: String, property_type: String,
property_name: String, value: Variant, index: int,
) -> String:
match property_type:
"color":
if typeof(value) != TYPE_DICTIONARY:
return "edits[%d] color value must be {r, g, b, a?}" % index
theme.set_color(property_name, type_name, _color_from_dict_opaque(value))
"constant":
theme.set_constant(property_name, type_name, int(value))
"font":
var font_path := str(value)
var font = load(font_path)
if font == null or not (font is Font):
return "edits[%d] font not found or not a Font: %s" % [index, font_path]
theme.set_font(property_name, type_name, font as Font)
"font_size":
theme.set_font_size(property_name, type_name, int(value))
"icon":
var icon_path := str(value)
var icon = load(icon_path)
if icon == null or not (icon is Texture2D):
return "edits[%d] icon not found or not a Texture2D: %s" % [index, icon_path]
theme.set_icon(property_name, type_name, icon as Texture2D)
"stylebox":
if typeof(value) != TYPE_DICTIONARY:
return "edits[%d] stylebox value must be a dictionary with 'type' key" % index
var sb_type := str(value.get("type", ""))
var sb = _create_stylebox(sb_type, value, index)
if sb is String:
return sb as String
theme.set_stylebox(property_name, type_name, sb as StyleBox)
return ""
static func _create_stylebox(sb_type: String, value: Dictionary, index: int) -> Variant:
match sb_type:
"StyleBoxFlat":
var sb := StyleBoxFlat.new()
if value.has("bg_color") and typeof(value["bg_color"]) == TYPE_DICTIONARY:
sb.bg_color = _color_from_dict_opaque(value["bg_color"])
if value.has("corner_radius"):
var r := int(value["corner_radius"])
sb.corner_radius_top_left = r
sb.corner_radius_top_right = r
sb.corner_radius_bottom_left = r
sb.corner_radius_bottom_right = r
if value.has("content_margin"):
var m := float(value["content_margin"])
sb.content_margin_left = m
sb.content_margin_top = m
sb.content_margin_right = m
sb.content_margin_bottom = m
if value.has("border_width"):
var w := int(value["border_width"])
sb.border_width_left = w
sb.border_width_top = w
sb.border_width_right = w
sb.border_width_bottom = w
if value.has("border_color") and typeof(value["border_color"]) == TYPE_DICTIONARY:
sb.border_color = _color_from_dict_opaque(value["border_color"])
return sb
"StyleBoxTexture":
var sb := StyleBoxTexture.new()
if value.has("texture"):
var tex = load(str(value["texture"]))
if tex == null or not (tex is Texture2D):
return "edits[%d] stylebox texture not found: %s" % [index, str(value["texture"])]
sb.texture = tex as Texture2D
if value.has("content_margin"):
var m := float(value["content_margin"])
sb.content_margin_left = m
sb.content_margin_top = m
sb.content_margin_right = m
sb.content_margin_bottom = m
return sb
"StyleBoxLine":
var sb := StyleBoxLine.new()
if value.has("color") and typeof(value["color"]) == TYPE_DICTIONARY:
sb.color = _color_from_dict_opaque(value["color"])
if value.has("thickness"):
sb.thickness = int(value["thickness"])
if value.has("vertical"):
sb.vertical = bool(value["vertical"])
return sb
_:
return "edits[%d] unknown stylebox type '%s'; must be StyleBoxFlat, StyleBoxTexture, or StyleBoxLine" % [index, sb_type]