Skip to content

Commit db72975

Browse files
authored
Add ui_set_anchor_preset — Control layout presets for HUD and menus (#15)
First entry in the ui_* tool family. Wraps Control.set_anchors_and_offsets_preset so agents can pin a HUD element, pause menu, or upgrade-draft panel to an edge / corner / full rect with one call instead of eight coupled property sets. - plugin/addons/godot_ai/handlers/ui_handler.gd maps string preset names and resize-mode names to Control enums, validates the node is a Control, and records an undo that restores all 8 anchor/offset properties. - Python tool ui_set_anchor_preset (defer-loaded, session-targetable, require_writable gating). - GDScript test_ui.gd covers happy path, validation errors, and undo restoration; Python unit + integration tests mirror the shim.
1 parent d3c601d commit db72975

8 files changed

Lines changed: 542 additions & 1 deletion

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
@tool
2+
class_name UiHandler
3+
extends RefCounted
4+
5+
## Handles UI-specific (Control) layout helpers: anchor presets, etc.
6+
##
7+
## Anchors/offsets are the worst part of Control layout to set one-property-at-a-time.
8+
## This handler wraps Godot's built-in presets (FULL_RECT, CENTER, TOP_LEFT, ...) so
9+
## callers can set a whole layout with one command, with proper undo.
10+
11+
var _undo_redo: EditorUndoRedoManager
12+
13+
14+
const _PRESETS := {
15+
"top_left": Control.PRESET_TOP_LEFT,
16+
"top_right": Control.PRESET_TOP_RIGHT,
17+
"bottom_left": Control.PRESET_BOTTOM_LEFT,
18+
"bottom_right": Control.PRESET_BOTTOM_RIGHT,
19+
"center_left": Control.PRESET_CENTER_LEFT,
20+
"center_top": Control.PRESET_CENTER_TOP,
21+
"center_right": Control.PRESET_CENTER_RIGHT,
22+
"center_bottom": Control.PRESET_CENTER_BOTTOM,
23+
"center": Control.PRESET_CENTER,
24+
"left_wide": Control.PRESET_LEFT_WIDE,
25+
"top_wide": Control.PRESET_TOP_WIDE,
26+
"right_wide": Control.PRESET_RIGHT_WIDE,
27+
"bottom_wide": Control.PRESET_BOTTOM_WIDE,
28+
"vcenter_wide": Control.PRESET_VCENTER_WIDE,
29+
"hcenter_wide": Control.PRESET_HCENTER_WIDE,
30+
"full_rect": Control.PRESET_FULL_RECT,
31+
}
32+
33+
const _RESIZE_MODES := {
34+
"minsize": Control.PRESET_MODE_MINSIZE,
35+
"keep_width": Control.PRESET_MODE_KEEP_WIDTH,
36+
"keep_height": Control.PRESET_MODE_KEEP_HEIGHT,
37+
"keep_size": Control.PRESET_MODE_KEEP_SIZE,
38+
}
39+
40+
const _ANCHOR_OFFSET_PROPS := [
41+
"anchor_left", "anchor_top", "anchor_right", "anchor_bottom",
42+
"offset_left", "offset_top", "offset_right", "offset_bottom",
43+
]
44+
45+
46+
func _init(undo_redo: EditorUndoRedoManager) -> void:
47+
_undo_redo = undo_redo
48+
49+
50+
## Apply a Control layout preset (anchors + offsets) to a UI node.
51+
##
52+
## Params:
53+
## path - scene path to a Control node (required)
54+
## preset - preset name: full_rect, center, top_left, ... (required)
55+
## resize_mode - minsize | keep_width | keep_height | keep_size (default: minsize)
56+
## margin - integer margin in pixels from the anchor edges (default: 0)
57+
func set_anchor_preset(params: Dictionary) -> Dictionary:
58+
var node_path: String = params.get("path", "")
59+
if node_path.is_empty():
60+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: path")
61+
62+
var preset_name: String = str(params.get("preset", "")).to_lower()
63+
if preset_name.is_empty():
64+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: preset")
65+
if not _PRESETS.has(preset_name):
66+
var names := _PRESETS.keys()
67+
names.sort()
68+
return McpErrorCodes.make(
69+
McpErrorCodes.INVALID_PARAMS,
70+
"Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(names)]
71+
)
72+
73+
var resize_mode_name: String = str(params.get("resize_mode", "minsize")).to_lower()
74+
if not _RESIZE_MODES.has(resize_mode_name):
75+
var names := _RESIZE_MODES.keys()
76+
names.sort()
77+
return McpErrorCodes.make(
78+
McpErrorCodes.INVALID_PARAMS,
79+
"Unknown resize_mode '%s'. Valid: %s" % [resize_mode_name, ", ".join(names)]
80+
)
81+
82+
var margin: int = int(params.get("margin", 0))
83+
84+
var scene_root := EditorInterface.get_edited_scene_root()
85+
if scene_root == null:
86+
return McpErrorCodes.make(McpErrorCodes.EDITOR_NOT_READY, "No scene open")
87+
88+
var node := ScenePath.resolve(node_path, scene_root)
89+
if node == null:
90+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Node not found: %s" % node_path)
91+
if not node is Control:
92+
return McpErrorCodes.make(
93+
McpErrorCodes.INVALID_PARAMS,
94+
"Node %s is not a Control (got %s)" % [node_path, node.get_class()]
95+
)
96+
97+
var control := node as Control
98+
var preset_value: int = _PRESETS[preset_name]
99+
var resize_mode_value: int = _RESIZE_MODES[resize_mode_name]
100+
101+
# Snapshot before so we can undo every property the preset may have touched.
102+
var before: Dictionary = {}
103+
for prop in _ANCHOR_OFFSET_PROPS:
104+
before[prop] = control.get(prop)
105+
106+
_undo_redo.create_action("MCP: Set %s anchor preset %s" % [control.name, preset_name])
107+
_undo_redo.add_do_method(
108+
control, "set_anchors_and_offsets_preset", preset_value, resize_mode_value, margin
109+
)
110+
for prop in _ANCHOR_OFFSET_PROPS:
111+
_undo_redo.add_undo_property(control, prop, before[prop])
112+
_undo_redo.commit_action()
113+
114+
var after: Dictionary = {}
115+
for prop in _ANCHOR_OFFSET_PROPS:
116+
after[prop] = control.get(prop)
117+
118+
return {
119+
"data": {
120+
"path": node_path,
121+
"preset": preset_name,
122+
"resize_mode": resize_mode_name,
123+
"margin": margin,
124+
"anchors": {
125+
"left": after.anchor_left,
126+
"top": after.anchor_top,
127+
"right": after.anchor_right,
128+
"bottom": after.anchor_bottom,
129+
},
130+
"offsets": {
131+
"left": after.offset_left,
132+
"top": after.offset_top,
133+
"right": after.offset_right,
134+
"bottom": after.offset_bottom,
135+
},
136+
"undoable": true,
137+
}
138+
}

plugin/addons/godot_ai/plugin.gd

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ func _enter_tree() -> void:
3131
var input_handler := InputHandler.new()
3232
var test_handler := TestHandler.new(get_undo_redo(), _log_buffer)
3333
var batch_handler := BatchHandler.new(_dispatcher, get_undo_redo())
34-
_handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler]
34+
var ui_handler := UiHandler.new(get_undo_redo())
35+
_handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler, ui_handler]
3536

3637
_dispatcher.register("get_editor_state", editor_handler.get_editor_state)
3738
_dispatcher.register("get_scene_tree", scene_handler.get_scene_tree)
@@ -93,6 +94,7 @@ func _enter_tree() -> void:
9394
_dispatcher.register("run_tests", test_handler.run_tests)
9495
_dispatcher.register("get_test_results", test_handler.get_test_results)
9596
_dispatcher.register("batch_execute", batch_handler.batch_execute)
97+
_dispatcher.register("set_anchor_preset", ui_handler.set_anchor_preset)
9698

9799
_connection.dispatcher = _dispatcher
98100
add_child(_connection)

src/godot_ai/handlers/ui.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Shared handlers for UI (Control layout) tools."""
2+
3+
from __future__ import annotations
4+
5+
from godot_ai.handlers._readiness import require_writable
6+
from godot_ai.runtime.interface import Runtime
7+
8+
9+
async def ui_set_anchor_preset(
10+
runtime: Runtime,
11+
path: str,
12+
preset: str,
13+
resize_mode: str = "minsize",
14+
margin: int = 0,
15+
) -> dict:
16+
require_writable(runtime)
17+
return await runtime.send_command(
18+
"set_anchor_preset",
19+
{
20+
"path": path,
21+
"preset": preset,
22+
"resize_mode": resize_mode,
23+
"margin": margin,
24+
},
25+
)

src/godot_ai/server.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from godot_ai.tools.session import register_session_tools
3131
from godot_ai.tools.signal import register_signal_tools
3232
from godot_ai.tools.testing import register_testing_tools
33+
from godot_ai.tools.ui import register_ui_tools
3334
from godot_ai.transport.websocket import GodotWebSocketServer
3435

3536
logger = logging.getLogger(__name__)
@@ -84,6 +85,7 @@ async def _lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
8485
" logs_* — read or clear the editor log buffer\n"
8586
" test_* — run GDScript test suites and fetch results\n"
8687
" batch_execute — compose multi-step scene edits atomically\n"
88+
" ui_* — Control layout helpers (anchor presets for HUD / menus)\n"
8789
" client_* — configure AI clients (Claude Code, Codex, Antigravity)\n\n"
8890
"Always connect to an editor session first (session_list / session_activate). "
8991
"Write operations require session readiness; check editor_state if a call is "
@@ -106,6 +108,7 @@ async def _lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
106108
register_input_map_tools(mcp)
107109
register_testing_tools(mcp)
108110
register_batch_tools(mcp)
111+
register_ui_tools(mcp)
109112
register_session_resources(mcp)
110113
register_scene_resources(mcp)
111114
register_editor_resources(mcp)

src/godot_ai/tools/ui.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""MCP tools for UI (Control) authoring — HUD, pause menu, upgrade screens, etc."""
2+
3+
from __future__ import annotations
4+
5+
from fastmcp import Context, FastMCP
6+
7+
from godot_ai.handlers import ui as ui_handlers
8+
from godot_ai.runtime.direct import DirectRuntime
9+
from godot_ai.tools import DEFER_META
10+
11+
12+
def register_ui_tools(mcp: FastMCP) -> None:
13+
@mcp.tool(meta=DEFER_META)
14+
async def ui_set_anchor_preset(
15+
ctx: Context,
16+
path: str,
17+
preset: str,
18+
resize_mode: str = "minsize",
19+
margin: int = 0,
20+
session_id: str = "",
21+
) -> dict:
22+
"""Apply a Control layout preset (anchors and offsets) to a UI node.
23+
24+
Wraps Godot's Control.set_anchors_and_offsets_preset — the fast way to
25+
pin a HUD element, panel, pause menu, upgrade draft screen, or game-over
26+
overlay to an edge, corner, or the full viewport. Much simpler than
27+
setting anchor_left / anchor_top / anchor_right / anchor_bottom and the
28+
four offset_* properties one at a time.
29+
30+
The target node must be a Control (or subclass — Panel, Label, Button,
31+
Container, VBoxContainer, HBoxContainer, MarginContainer, etc.). Change
32+
is undoable.
33+
34+
Args:
35+
path: Scene path to a Control node (e.g. "/Main/HUD/HealthBar").
36+
preset: Layout preset name. One of:
37+
top_left, top_right, bottom_left, bottom_right,
38+
center_left, center_top, center_right, center_bottom, center,
39+
left_wide, top_wide, right_wide, bottom_wide,
40+
vcenter_wide, hcenter_wide, full_rect.
41+
resize_mode: How the existing size is handled when applying the
42+
preset. One of: minsize (default — resize to minimum),
43+
keep_width, keep_height, keep_size.
44+
margin: Margin in pixels from the anchor edges. Default 0.
45+
session_id: Optional Godot session to target. Empty = active session.
46+
"""
47+
runtime = DirectRuntime.from_context(ctx, session_id=session_id or None)
48+
return await ui_handlers.ui_set_anchor_preset(
49+
runtime,
50+
path=path,
51+
preset=preset,
52+
resize_mode=resize_mode,
53+
margin=margin,
54+
)

0 commit comments

Comments
 (0)