534 lines
19 KiB
GDScript
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."
|
|
)
|