Skip to content

Commit 87e6c89

Browse files
author
Joshua
committed
Add runtime UI element inspection
1 parent 6367717 commit 87e6c89

8 files changed

Lines changed: 273 additions & 5 deletions

File tree

docs/TOOLS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Calls take the form:
6565
| `camera_manage` | `create`, `configure`, `set_limits_2d`, `set_damping_2d`, `follow_2d`, `get`, `list`, `apply_preset` |
6666
| `signal_manage` | `list`, `connect`, `disconnect` |
6767
| `input_map_manage` | `list`, `add_action`, `remove_action`, `bind_event` |
68-
| `game_manage` | `get_scene_tree`, `get_node_info`, `input_key`, `input_mouse`, `input_gamepad`, `input_state` |
68+
| `game_manage` | `get_scene_tree`, `get_node_info`, `get_ui_elements`, `input_key`, `input_mouse`, `input_gamepad`, `input_state` |
6969
| `autoload_manage` | `list`, `add`, `remove` |
7070
| `filesystem_manage` | `read_text`, `write_text`, `reimport`, `search` |
7171
| `theme_manage` | `create`, `set_color`, `set_constant`, `set_font_size`, `set_stylebox_flat`, `apply` |

plugin/addons/godot_ai/runtime/game_helper.gd

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ func _handle_game_command(data: Array) -> void:
197197
result = _game_get_scene_tree(json.data)
198198
"get_node_info":
199199
result = _game_get_node_info(json.data)
200+
"get_ui_elements":
201+
result = _game_get_ui_elements(json.data)
200202
"input_key":
201203
result = _game_input_key(json.data)
202204
"input_mouse":
@@ -267,6 +269,94 @@ func _game_get_node_info(params: Dictionary) -> Dictionary:
267269
return info
268270

269271

272+
func _game_get_ui_elements(params: Dictionary) -> Dictionary:
273+
var max_depth := maxi(0, int(params.get("max_depth", 10)))
274+
var include_hidden := bool(params.get("include_hidden", false))
275+
var include_disabled := bool(params.get("include_disabled", true))
276+
var root_path := str(params.get("root_path", ""))
277+
var root := _resolve_runtime_node(root_path)
278+
if root == null:
279+
return {"root": "", "elements": [], "total_count": 0, "not_found": root_path}
280+
281+
var elements: Array[Dictionary] = []
282+
_collect_ui_elements(root, 0, max_depth, include_hidden, include_disabled, elements)
283+
return {
284+
"root": _runtime_path(root),
285+
"elements": elements,
286+
"total_count": elements.size(),
287+
}
288+
289+
290+
func _collect_ui_elements(
291+
node: Node,
292+
current_depth: int,
293+
max_depth: int,
294+
include_hidden: bool,
295+
include_disabled: bool,
296+
out: Array[Dictionary]
297+
) -> void:
298+
if node is Control:
299+
var control := node as Control
300+
var visible := _control_visible_in_tree(control)
301+
var disabled := _control_disabled(control)
302+
if (include_hidden or visible) and (include_disabled or not disabled):
303+
out.append(_ui_element_info(control, visible, disabled))
304+
305+
if current_depth >= max_depth:
306+
return
307+
for child in node.get_children():
308+
if child is Node:
309+
_collect_ui_elements(
310+
child,
311+
current_depth + 1,
312+
max_depth,
313+
include_hidden,
314+
include_disabled,
315+
out
316+
)
317+
318+
319+
func _ui_element_info(control: Control, visible: bool, disabled: bool) -> Dictionary:
320+
var info := {
321+
"path": _runtime_path(control),
322+
"name": control.name,
323+
"type": control.get_class(),
324+
"visible": visible,
325+
"disabled": disabled,
326+
"rect": _variant_to_json(control.get_rect()),
327+
"global_rect": _variant_to_json(control.get_global_rect()),
328+
}
329+
if _object_has_property(control, "text"):
330+
info["text"] = str(control.get("text"))
331+
return info
332+
333+
334+
func _control_disabled(control: Control) -> bool:
335+
if _object_has_property(control, "disabled"):
336+
return bool(control.get("disabled"))
337+
return false
338+
339+
340+
func _control_visible_in_tree(control: Control) -> bool:
341+
if not control.visible:
342+
return false
343+
var parent := control.get_parent()
344+
while parent != null:
345+
if parent is CanvasItem and not (parent as CanvasItem).visible:
346+
return false
347+
parent = parent.get_parent()
348+
if Engine.is_editor_hint():
349+
return true
350+
return control.is_visible_in_tree()
351+
352+
353+
func _object_has_property(obj: Object, property_name: String) -> bool:
354+
for prop in obj.get_property_list():
355+
if str(prop.get("name", "")) == property_name:
356+
return true
357+
return false
358+
359+
270360
func _runtime_node_properties(node: Node) -> Dictionary:
271361
var props := {}
272362
for p in node.get_property_list():
@@ -279,7 +369,7 @@ func _runtime_node_properties(node: Node) -> Dictionary:
279369

280370

281371
func _resolve_runtime_node(path: String) -> Node:
282-
var scene_root := get_tree().current_scene
372+
var scene_root := _current_scene_root()
283373
if scene_root == null:
284374
return null
285375
if path.is_empty() or path == "/":
@@ -298,14 +388,24 @@ func _resolve_runtime_node(path: String) -> Node:
298388

299389

300390
func _runtime_path(node: Node) -> String:
301-
var scene_root := get_tree().current_scene
391+
var scene_root := _current_scene_root()
302392
if scene_root == null:
303393
return str(node.get_path())
304394
if node == scene_root:
305395
return "/" + str(scene_root.name)
306396
return "/" + str(scene_root.name) + "/" + str(scene_root.get_path_to(node))
307397

308398

399+
func _current_scene_root() -> Node:
400+
var tree := get_tree()
401+
if tree == null:
402+
return null
403+
var scene_root := tree.current_scene
404+
if scene_root == null and Engine.is_editor_hint():
405+
scene_root = EditorInterface.get_edited_scene_root()
406+
return scene_root
407+
408+
309409
func _game_input_key(params: Dictionary) -> Dictionary:
310410
var key_name := str(params.get("key", ""))
311411
var keycode := OS.find_keycode_from_string(key_name)

src/godot_ai/handlers/game.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ async def game_get_node_info(
4040
)
4141

4242

43+
async def game_get_ui_elements(
44+
runtime: DirectRuntime,
45+
root_path: str = "",
46+
include_hidden: bool = False,
47+
include_disabled: bool = True,
48+
max_depth: int = 10,
49+
) -> dict:
50+
params: dict[str, Any] = {
51+
"include_hidden": include_hidden,
52+
"include_disabled": include_disabled,
53+
"max_depth": max_depth,
54+
}
55+
if root_path:
56+
params["root_path"] = root_path
57+
return await _game_command(runtime, "get_ui_elements", params)
58+
59+
4360
async def game_input_key(
4461
runtime: DirectRuntime,
4562
key: str,

src/godot_ai/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,8 @@ def _emit_startup() -> None:
195195
" follow_2d, get, list, apply_preset\n"
196196
" signal_manage list, connect, disconnect\n"
197197
" input_map_manage list, add_action, remove_action, bind_event\n"
198-
" game_manage get_scene_tree, get_node_info, input_key,\n"
199-
" input_mouse, input_gamepad, input_state\n"
198+
" game_manage get_scene_tree, get_node_info, get_ui_elements,\n"
199+
" input_key, input_mouse, input_gamepad, input_state\n"
200200
" autoload_manage list, add, remove\n"
201201
" filesystem_manage read_text, write_text, reimport, search\n"
202202
" theme_manage create, set_color, set_constant, set_font_size,\n"

src/godot_ai/tools/game.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
path or a scene-relative path rooted at the current scene.
2121
- get_node_info(path, include_properties=True)
2222
Inspect one running node's metadata and optional property snapshot.
23+
- get_ui_elements(root_path="", include_hidden=False,
24+
include_disabled=True, max_depth=10)
25+
Inspect visible runtime Control nodes for UI testing. Includes path,
26+
type, text where present, disabled state, and rect metadata.
2327
- input_key(key, pressed=True, echo=False)
2428
Send a key press/release to the running game.
2529
- input_mouse(event, position=None, button="left", pressed=True)
@@ -38,6 +42,7 @@ def register_game_tools(mcp: FastMCP) -> None:
3842
ops={
3943
"get_scene_tree": game_handlers.game_get_scene_tree,
4044
"get_node_info": game_handlers.game_get_node_info,
45+
"get_ui_elements": game_handlers.game_get_ui_elements,
4146
"input_key": game_handlers.game_input_key,
4247
"input_mouse": game_handlers.game_input_mouse,
4348
"input_gamepad": game_handlers.game_input_gamepad,
@@ -46,6 +51,7 @@ def register_game_tools(mcp: FastMCP) -> None:
4651
read_resource_forms={
4752
"get_scene_tree": None,
4853
"get_node_info": None,
54+
"get_ui_elements": None,
4955
"input_key": None,
5056
"input_mouse": None,
5157
"input_gamepad": None,
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
@tool
2+
extends McpTestSuite
3+
4+
const GameHelper := preload("res://addons/godot_ai/runtime/game_helper.gd")
5+
6+
const ROOT_NAME := "McpUiElementsRoot"
7+
8+
var _helper: Node
9+
var _root: Node
10+
11+
12+
func suite_name() -> String:
13+
return "game_helper"
14+
15+
16+
func suite_setup(_ctx: Dictionary) -> void:
17+
var scene_root := EditorInterface.get_edited_scene_root()
18+
if scene_root == null:
19+
fail_setup("current_scene required")
20+
return
21+
_helper = GameHelper.new()
22+
scene_root.add_child(_helper)
23+
_root = CanvasLayer.new()
24+
_root.name = ROOT_NAME
25+
scene_root.add_child(_root)
26+
27+
28+
func suite_teardown() -> void:
29+
if _root != null:
30+
_root.queue_free()
31+
_root = null
32+
if _helper != null:
33+
_helper.queue_free()
34+
_helper = null
35+
36+
37+
func setup() -> void:
38+
if _root == null:
39+
return
40+
for child in _root.get_children():
41+
_root.remove_child(child)
42+
child.free()
43+
44+
45+
func test_get_ui_elements_returns_controls_with_text_and_rects() -> void:
46+
assert_true(_helper.has_method("_game_get_ui_elements"),
47+
"game helper should expose get_ui_elements")
48+
var container := Node.new()
49+
container.name = "Container"
50+
_root.add_child(container)
51+
52+
var title := Label.new()
53+
title.name = "Title"
54+
title.text = "Score: 10"
55+
title.position = Vector2(10, 20)
56+
title.size = Vector2(120, 30)
57+
container.add_child(title)
58+
59+
var button := Button.new()
60+
button.name = "StartButton"
61+
button.text = "Start"
62+
button.disabled = true
63+
button.position = Vector2(20, 60)
64+
button.size = Vector2(90, 40)
65+
container.add_child(button)
66+
67+
var result = _helper.call("_game_get_ui_elements", {
68+
"root_path": "/Main/%s" % ROOT_NAME,
69+
"include_hidden": true,
70+
"max_depth": 4,
71+
})
72+
73+
assert_true(result is Dictionary, "get_ui_elements should return a Dictionary")
74+
assert_eq(result.root, "/Main/%s" % ROOT_NAME)
75+
assert_eq(result.total_count, 2)
76+
assert_eq(result.elements[0].name, "Title")
77+
assert_eq(result.elements[0].type, "Label")
78+
assert_eq(result.elements[0].text, "Score: 10")
79+
assert_has_key(result.elements[0], "visible")
80+
assert_eq(result.elements[0].disabled, false)
81+
assert_eq(result.elements[0].rect.position.x, 10.0)
82+
assert_eq(result.elements[0].rect.size.y, 30.0)
83+
assert_eq(result.elements[1].name, "StartButton")
84+
assert_eq(result.elements[1].disabled, true)
85+
assert_eq(result.elements[1].text, "Start")
86+
87+
88+
func test_get_ui_elements_can_filter_disabled_and_include_hidden() -> void:
89+
assert_true(_helper.has_method("_game_get_ui_elements"),
90+
"game helper should expose get_ui_elements")
91+
var visible_enabled := LineEdit.new()
92+
visible_enabled.name = "NameInput"
93+
visible_enabled.text = "Ada"
94+
_root.add_child(visible_enabled)
95+
96+
var disabled_button := Button.new()
97+
disabled_button.name = "DisabledButton"
98+
disabled_button.disabled = true
99+
_root.add_child(disabled_button)
100+
101+
var hidden_label := Label.new()
102+
hidden_label.name = "HiddenButIncluded"
103+
hidden_label.text = "Hidden"
104+
hidden_label.visible = false
105+
_root.add_child(hidden_label)
106+
107+
var result = _helper.call("_game_get_ui_elements", {
108+
"root_path": "/Main/%s" % ROOT_NAME,
109+
"include_hidden": true,
110+
"include_disabled": false,
111+
"max_depth": 1,
112+
})
113+
114+
assert_true(result is Dictionary, "get_ui_elements should return a Dictionary")
115+
assert_eq(result.total_count, 2)
116+
var names := [result.elements[0].name, result.elements[1].name]
117+
assert_true(names.has("NameInput"), "enabled control should be included")
118+
assert_true(names.has("HiddenButIncluded"), "hidden control should be included when requested")
119+
assert_false(names.has("DisabledButton"), "disabled control should be filtered when requested")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://ttc0dicgnaea

tests/unit/test_runtime_handlers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,31 @@ async def test_game_get_node_info_sends_game_command():
14081408
}
14091409

14101410

1411+
async def test_game_get_ui_elements_sends_game_command():
1412+
client = StubClient()
1413+
runtime = DirectRuntime(registry=SessionRegistry(), client=client)
1414+
1415+
await game_handlers.game_get_ui_elements(
1416+
runtime,
1417+
root_path="/Main/HUD",
1418+
include_hidden=True,
1419+
include_disabled=False,
1420+
max_depth=3,
1421+
)
1422+
1423+
assert client.calls[-1]["command"] == "game_command"
1424+
assert client.calls[-1]["params"] == {
1425+
"op": "get_ui_elements",
1426+
"params": {
1427+
"root_path": "/Main/HUD",
1428+
"include_hidden": True,
1429+
"include_disabled": False,
1430+
"max_depth": 3,
1431+
},
1432+
}
1433+
assert client.calls[-1]["timeout"] == game_handlers.GAME_COMMAND_TIMEOUT_SEC
1434+
1435+
14111436
async def test_game_input_key_sends_game_command():
14121437
client = StubClient()
14131438
runtime = DirectRuntime(registry=SessionRegistry(), client=client)

0 commit comments

Comments
 (0)