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

534 lines
19 KiB
GDScript

@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles UI-specific (Control) layout helpers: anchor presets, etc.
##
## Anchors/offsets are the worst part of Control layout to set one-property-at-a-time.
## This handler wraps Godot's built-in presets (FULL_RECT, CENTER, TOP_LEFT, ...) so
## callers can set a whole layout with one command, with proper undo.
var _undo_redo: EditorUndoRedoManager
const _PRESETS := {
"top_left": Control.PRESET_TOP_LEFT,
"top_right": Control.PRESET_TOP_RIGHT,
"bottom_left": Control.PRESET_BOTTOM_LEFT,
"bottom_right": Control.PRESET_BOTTOM_RIGHT,
"center_left": Control.PRESET_CENTER_LEFT,
"center_top": Control.PRESET_CENTER_TOP,
"center_right": Control.PRESET_CENTER_RIGHT,
"center_bottom": Control.PRESET_CENTER_BOTTOM,
"center": Control.PRESET_CENTER,
"left_wide": Control.PRESET_LEFT_WIDE,
"top_wide": Control.PRESET_TOP_WIDE,
"right_wide": Control.PRESET_RIGHT_WIDE,
"bottom_wide": Control.PRESET_BOTTOM_WIDE,
"vcenter_wide": Control.PRESET_VCENTER_WIDE,
"hcenter_wide": Control.PRESET_HCENTER_WIDE,
"full_rect": Control.PRESET_FULL_RECT,
}
const _RESIZE_MODES := {
"minsize": Control.PRESET_MODE_MINSIZE,
"keep_width": Control.PRESET_MODE_KEEP_WIDTH,
"keep_height": Control.PRESET_MODE_KEEP_HEIGHT,
"keep_size": Control.PRESET_MODE_KEEP_SIZE,
}
const _ANCHOR_OFFSET_PROPS := [
"anchor_left", "anchor_top", "anchor_right", "anchor_bottom",
"offset_left", "offset_top", "offset_right", "offset_bottom",
]
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
## Apply a Control layout preset (anchors + offsets) to a UI node.
##
## Params:
## path - scene path to a Control node (required)
## preset - preset name: full_rect, center, top_left, ... (required)
## resize_mode - minsize | keep_width | keep_height | keep_size (default: minsize)
## margin - integer margin in pixels from the anchor edges (default: 0)
func set_anchor_preset(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 preset_name: String = str(params.get("preset", "")).to_lower()
if preset_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset")
if not _PRESETS.has(preset_name):
var names := _PRESETS.keys()
names.sort()
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(names)]
)
var resize_mode_name: String = str(params.get("resize_mode", "minsize")).to_lower()
if not _RESIZE_MODES.has(resize_mode_name):
var names := _RESIZE_MODES.keys()
names.sort()
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown resize_mode '%s'. Valid: %s" % [resize_mode_name, ", ".join(names)]
)
var margin: int = int(params.get("margin", 0))
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:
var got_class: String = node.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a Control (got %s)%s" % [
node_path, got_class, _canvas_layer_overlay_hint(got_class)
]
)
var control := node as Control
var preset_value: int = _PRESETS[preset_name]
var resize_mode_value: int = _RESIZE_MODES[resize_mode_name]
# Snapshot before so we can undo every property the preset may have touched.
var before: Dictionary = {}
for prop in _ANCHOR_OFFSET_PROPS:
before[prop] = control.get(prop)
_undo_redo.create_action("MCP: Set %s anchor preset %s" % [control.name, preset_name])
_undo_redo.add_do_method(
control, "set_anchors_and_offsets_preset", preset_value, resize_mode_value, margin
)
for prop in _ANCHOR_OFFSET_PROPS:
_undo_redo.add_undo_property(control, prop, before[prop])
_undo_redo.commit_action()
var after: Dictionary = {}
for prop in _ANCHOR_OFFSET_PROPS:
after[prop] = control.get(prop)
return {
"data": {
"path": node_path,
"preset": preset_name,
"resize_mode": resize_mode_name,
"margin": margin,
"anchors": {
"left": after.anchor_left,
"top": after.anchor_top,
"right": after.anchor_right,
"bottom": after.anchor_bottom,
},
"offsets": {
"left": after.offset_left,
"top": after.offset_top,
"right": after.offset_right,
"bottom": after.offset_bottom,
},
"undoable": true,
}
}
## Set the visible `text` property on a UI Control (Label, Button + subclasses,
## LineEdit, TextEdit, RichTextLabel, LinkButton). Undoable.
func set_text(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")
if not params.has("text"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: text")
var text_value: Variant = params["text"]
if typeof(text_value) != TYPE_STRING:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "text must be a string")
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
var node_type := node.get_class()
if not node is Control:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a Control (got %s)" % [node_path, node_type]
)
# Scan get_property_list() (matches set_property / _apply_property in this
# repo) so we can both confirm `text` exists and that it's actually a String
# — guards against a custom Control whose `text` happens to be some other
# type, where set()-ing a String would silently mis-coerce.
var text_prop_type := TYPE_NIL
var has_text := false
for prop in node.get_property_list():
if prop.get("name", "") == "text":
has_text = true
text_prop_type = prop.get("type", TYPE_NIL)
break
if not has_text:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Control %s has no 'text' property (got %s)" % [node_path, node_type]
)
if text_prop_type != TYPE_STRING:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Control %s has a non-string 'text' property (got %s)" % [node_path, node_type]
)
var old_value: String = node.get("text")
_undo_redo.create_action("MCP: Set %s text" % node.name)
_undo_redo.add_do_property(node, "text", text_value)
_undo_redo.add_undo_property(node, "text", old_value)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"text": text_value,
"old_text": old_value,
"node_type": node_type,
"undoable": true,
}
}
# ============================================================================
# build_layout — declarative nested-dict → Control tree in one undo action
# ============================================================================
## Build a tree of Control nodes atomically.
##
## Params:
## tree - Dictionary describing the root node. Required fields: "type".
## Optional: "name", "properties" (dict), "anchor_preset",
## "anchor_margin", "theme" (res://, uid:// or user:// path), "children" (array).
## parent_path - Parent scene path. Empty or "/" = scene root.
##
## Validation is done before any scene mutation: class names, property
## existence, and res:// paths are all checked up-front. If anything is
## invalid, no node is created.
func build_layout(params: Dictionary) -> Dictionary:
var tree = params.get("tree")
if not params.has("tree"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: tree")
if typeof(tree) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tree must be a dictionary")
var _scene_check := McpNodeValidator.require_scene_or_error()
if _scene_check.has("error"):
return _scene_check
var scene_root: Node = _scene_check.scene_root
var parent_path: String = params.get("parent_path", "")
var parent: Node = scene_root
if not parent_path.is_empty() and parent_path != "/":
parent = McpScenePath.resolve(parent_path, scene_root)
if parent == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
# Validate + build in memory first; if anything fails, free and bail.
var built := _build_subtree(tree)
if built.has("error"):
return built
var root_node: Node = built.node
var created: Array[Node] = built.created
_undo_redo.create_action("MCP: Build UI layout (%d nodes)" % created.size())
_undo_redo.add_do_method(parent, "add_child", root_node, true)
_undo_redo.add_do_method(root_node, "set_owner", scene_root)
for n in created:
_undo_redo.add_do_method(n, "set_owner", scene_root)
_undo_redo.add_do_reference(n)
_undo_redo.add_undo_method(parent, "remove_child", root_node)
_undo_redo.commit_action()
return {
"data": {
"root_path": McpScenePath.from_node(root_node, scene_root),
"node_count": created.size(),
"undoable": true,
}
}
## Recursively instantiate + configure a node and its children in memory.
## Returns {"node": root, "created": [all descendants incl. root]} or {"error": ...}.
func _build_subtree(spec: Dictionary) -> Dictionary:
var node_type: String = spec.get("type", "")
if node_type.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Every layout node requires a 'type'")
if not ClassDB.class_exists(node_type):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown type: %s" % node_type)
if not ClassDB.is_parent_class(node_type, "Node"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type)
var node: Node = ClassDB.instantiate(node_type)
if node == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type)
var node_name: String = spec.get("name", "")
if not node_name.is_empty():
node.name = node_name
# Properties.
if spec.has("properties"):
var props = spec.get("properties")
if typeof(props) != TYPE_DICTIONARY:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "properties must be a dictionary")
for key in props:
var value = props[key]
var apply_err := _apply_property(node, str(key), value)
if apply_err != null:
node.free()
return apply_err
# Theme (res:// / uid:// / user:// path -> Resource).
if spec.has("theme"):
var theme_path: String = str(spec.get("theme", ""))
if not theme_path.is_empty():
var theme_path_err = McpPathValidator.loadable_error(theme_path, "theme")
if theme_path_err != null:
node.free()
return theme_path_err
if not ResourceLoader.exists(theme_path):
node.free()
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
var theme_res: Resource = ResourceLoader.load(theme_path)
if theme_res == null or not theme_res is Theme:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "theme path must point to a Theme resource: %s" % theme_path)
if not node is Control and not node is Window:
node.free()
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"theme can only be set on Control / Window (got %s)%s" % [
node_type, _canvas_layer_overlay_hint(node_type)
]
)
node.theme = theme_res as Theme
# Anchor preset — applied before children so children inherit sensible anchors.
if spec.has("anchor_preset"):
var preset_name: String = str(spec.get("anchor_preset", "")).to_lower()
if not _PRESETS.has(preset_name):
node.free()
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown anchor_preset: %s" % preset_name)
if not node is Control:
node.free()
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"anchor_preset requires a Control (got %s)%s" % [
node_type, _canvas_layer_overlay_hint(node_type)
]
)
var preset_value: int = _PRESETS[preset_name]
var margin: int = int(spec.get("anchor_margin", 0))
(node as Control).set_anchors_and_offsets_preset(preset_value, Control.PRESET_MODE_MINSIZE, margin)
var created: Array[Node] = [node]
if spec.has("children"):
var children = spec.get("children")
if typeof(children) != TYPE_ARRAY:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "children must be an array")
for child_spec in children:
if typeof(child_spec) != TYPE_DICTIONARY:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "each child must be a dictionary")
var child_result := _build_subtree(child_spec)
if child_result.has("error"):
node.free()
return child_result
var child_node: Node = child_result.node
node.add_child(child_node)
for n in child_result.created:
created.append(n)
return {"node": node, "created": created}
## Mapping from theme_override_* property prefixes to their add/remove methods.
const _THEME_OVERRIDE_MAP := {
"theme_override_colors/": {
"add": "add_theme_color_override",
"remove": "remove_theme_color_override",
"coerce_type": TYPE_COLOR,
},
"theme_override_constants/": {
"add": "add_theme_constant_override",
"remove": "remove_theme_constant_override",
"coerce_type": TYPE_INT,
},
"theme_override_font_sizes/": {
"add": "add_theme_font_size_override",
"remove": "remove_theme_font_size_override",
"coerce_type": TYPE_INT,
},
"theme_override_styles/": {
"add": "add_theme_stylebox_override",
"remove": "remove_theme_stylebox_override",
"coerce_type": TYPE_OBJECT,
},
}
## Apply a property to a newly-instantiated node. Handles Color/Vector2/NodePath
## coercion from JSON-friendly forms. Returns null on success, error dict on failure.
func _apply_property(node: Node, prop: String, value: Variant) -> Variant:
# Handle theme_override_* pseudo-properties before the regular property scan.
for prefix in _THEME_OVERRIDE_MAP:
if prop.begins_with(prefix):
if not node is Control:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"theme_override_* requires a Control node (got %s)" % node.get_class()
)
var override_name := prop.substr(prefix.length())
var info: Dictionary = _THEME_OVERRIDE_MAP[prefix]
var coerce_type: int = info.coerce_type
# For stylebox overrides, load from a res:// / uid:// / user:// path.
if coerce_type == TYPE_OBJECT:
if value is String and (value.begins_with("res://") or value.begins_with("uid://") or value.begins_with("user://")):
var style_path_err = McpPathValidator.loadable_error(value, "stylebox")
if style_path_err != null:
return style_path_err
var res := ResourceLoader.load(value)
if res == null or not res is StyleBox:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Style resource not found or not a StyleBox: %s" % value
)
node.call(info.add, override_name, res)
else:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"theme_override_styles/ expects a res:// / uid:// / user:// path to a StyleBox"
)
else:
var coercion := _coerce_for_type(value, coerce_type)
if not coercion.ok:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot coerce '%s' for %s" % [value, prop]
)
node.call(info.add, override_name, coercion.value)
return null
var found := false
var prop_type := TYPE_NIL
for p in node.get_property_list():
if p.name == prop:
found = true
prop_type = p.get("type", TYPE_NIL)
break
if not found:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
McpPropertyErrors.build_message(node, prop)
)
var coercion := _coerce_for_type(value, prop_type)
if not coercion.ok:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Property '%s' on %s expects type %s (cannot coerce %s)" % [
prop, node.get_class(), type_string(prop_type), value
]
)
node.set(prop, coercion.value)
return null
## Coerce a JSON-friendly value to the target Godot type. Returns
## {"ok": true, "value": coerced} on success, {"ok": false} on failure.
## For types we don't explicitly coerce, the value is returned as-is
## (Godot will typecheck at set() time and fail loudly if it disagrees).
static func _coerce_for_type(value: Variant, prop_type: int) -> Dictionary:
match prop_type:
TYPE_COLOR:
if value is Color:
return {"ok": true, "value": value}
if value is String:
var a := Color.from_string(value, Color(0, 0, 0, 0))
var b := Color.from_string(value, Color(1, 1, 1, 1))
if a == b:
return {"ok": true, "value": a}
return {"ok": false}
if value is Dictionary and value.has("r") and value.has("g") and value.has("b"):
return {
"ok": true,
"value": Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0))),
}
return {"ok": false}
TYPE_VECTOR2:
if value is Vector2:
return {"ok": true, "value": value}
if value is Dictionary and value.has("x") and value.has("y"):
return {"ok": true, "value": Vector2(float(value.x), float(value.y))}
if value is Array and value.size() == 2:
return {"ok": true, "value": Vector2(float(value[0]), float(value[1]))}
return {"ok": false}
TYPE_VECTOR2I:
if value is Vector2i:
return {"ok": true, "value": value}
if value is Dictionary and value.has("x") and value.has("y"):
return {"ok": true, "value": Vector2i(int(value.x), int(value.y))}
if value is Array and value.size() == 2:
return {"ok": true, "value": Vector2i(int(value[0]), int(value[1]))}
return {"ok": false}
TYPE_RECT2:
if value is Rect2:
return {"ok": true, "value": value}
if value is Array and value.size() == 4:
return {
"ok": true,
"value":
Rect2(float(value[0]), float(value[1]), float(value[2]), float(value[3])),
}
if value is Dictionary:
if value.has("x") and value.has("y") and value.has("w") and value.has("h"):
return {
"ok": true,
"value":
Rect2(float(value.x), float(value.y), float(value.w), float(value.h)),
}
if value.has("position") and value.has("size"):
var pos := _coerce_for_type(value.position, TYPE_VECTOR2)
var sz := _coerce_for_type(value.size, TYPE_VECTOR2)
if pos.ok and sz.ok:
return {"ok": true, "value": Rect2(pos.value, sz.value)}
return {"ok": false}
TYPE_NODE_PATH:
if value is NodePath:
return {"ok": true, "value": value}
if value is String:
return {"ok": true, "value": NodePath(value)}
return {"ok": false}
return {"ok": true, "value": value}
# CanvasLayer is the canonical HUD parent but isn't a Control, so applying
# Control-only properties (theme, anchor_preset) to it is a common mistake.
# The recovery shape is always the same: nest a Control child under the layer.
static func _canvas_layer_overlay_hint(node_class: String) -> String:
if node_class != "CanvasLayer":
return ""
return (
". CanvasLayer is not a Control — add a Control (e.g. Panel or Control "
+ "with anchor_preset=full_rect) as its child and apply theme / "
+ "anchor_preset to that overlay."
)