Skip to content

Commit 012ee30

Browse files
author
Joshua
committed
Add runtime UI element inspection
1 parent 6367717 commit 012ee30

8 files changed

Lines changed: 341 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: 118 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,110 @@ 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+
static var _property_name_cache: Dictionary = {}
354+
355+
356+
func _object_has_property(obj: Object, property_name: String) -> bool:
357+
var key := _property_cache_key(obj)
358+
if not _property_name_cache.has(key):
359+
var names := {}
360+
for prop in obj.get_property_list():
361+
names[str(prop.get("name", ""))] = true
362+
_property_name_cache[key] = names
363+
return (_property_name_cache[key] as Dictionary).has(property_name)
364+
365+
366+
func _property_cache_key(obj: Object) -> String:
367+
var script = obj.get_script()
368+
if script == null:
369+
return obj.get_class()
370+
var script_id := str(script.get_instance_id())
371+
if not script.resource_path.is_empty():
372+
script_id = script.resource_path
373+
return "%s:%s" % [obj.get_class(), script_id]
374+
375+
270376
func _runtime_node_properties(node: Node) -> Dictionary:
271377
var props := {}
272378
for p in node.get_property_list():
@@ -279,7 +385,7 @@ func _runtime_node_properties(node: Node) -> Dictionary:
279385

280386

281387
func _resolve_runtime_node(path: String) -> Node:
282-
var scene_root := get_tree().current_scene
388+
var scene_root := _current_scene_root()
283389
if scene_root == null:
284390
return null
285391
if path.is_empty() or path == "/":
@@ -298,14 +404,24 @@ func _resolve_runtime_node(path: String) -> Node:
298404

299405

300406
func _runtime_path(node: Node) -> String:
301-
var scene_root := get_tree().current_scene
407+
var scene_root := _current_scene_root()
302408
if scene_root == null:
303409
return str(node.get_path())
304410
if node == scene_root:
305411
return "/" + str(scene_root.name)
306412
return "/" + str(scene_root.name) + "/" + str(scene_root.get_path_to(node))
307413

308414

415+
func _current_scene_root() -> Node:
416+
var tree := get_tree()
417+
if tree == null:
418+
return null
419+
var scene_root := tree.current_scene
420+
if scene_root == null and Engine.is_editor_hint():
421+
scene_root = EditorInterface.get_edited_scene_root()
422+
return scene_root
423+
424+
309425
func _game_input_key(params: Dictionary) -> Dictionary:
310426
var key_name := str(params.get("key", ""))
311427
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: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
9+
class PropertyProbe:
10+
extends Object
11+
12+
static var property_list_calls := 0
13+
14+
func _get_property_list() -> Array[Dictionary]:
15+
property_list_calls += 1
16+
return [{
17+
"name": "probe_value",
18+
"type": TYPE_STRING,
19+
"usage": PROPERTY_USAGE_DEFAULT,
20+
}]
21+
22+
23+
class AlphaProbe:
24+
extends Object
25+
26+
func _get_property_list() -> Array[Dictionary]:
27+
return [{
28+
"name": "alpha_value",
29+
"type": TYPE_STRING,
30+
"usage": PROPERTY_USAGE_DEFAULT,
31+
}]
32+
33+
34+
class BetaProbe:
35+
extends Object
36+
37+
func _get_property_list() -> Array[Dictionary]:
38+
return [{
39+
"name": "beta_value",
40+
"type": TYPE_STRING,
41+
"usage": PROPERTY_USAGE_DEFAULT,
42+
}]
43+
44+
45+
var _helper: Node
46+
var _root: Node
47+
48+
49+
func suite_name() -> String:
50+
return "game_helper"
51+
52+
53+
func suite_setup(_ctx: Dictionary) -> void:
54+
var scene_root := EditorInterface.get_edited_scene_root()
55+
if scene_root == null:
56+
fail_setup("current_scene required")
57+
return
58+
_helper = GameHelper.new()
59+
scene_root.add_child(_helper)
60+
_root = CanvasLayer.new()
61+
_root.name = ROOT_NAME
62+
scene_root.add_child(_root)
63+
64+
65+
func suite_teardown() -> void:
66+
if _root != null:
67+
_root.queue_free()
68+
_root = null
69+
if _helper != null:
70+
_helper.queue_free()
71+
_helper = null
72+
73+
74+
func setup() -> void:
75+
if _root == null:
76+
return
77+
for child in _root.get_children():
78+
_root.remove_child(child)
79+
child.free()
80+
81+
82+
func test_object_has_property_caches_property_lists_by_script() -> void:
83+
_helper._property_name_cache.clear()
84+
PropertyProbe.property_list_calls = 0
85+
var probe := PropertyProbe.new()
86+
87+
assert_true(_helper.call("_object_has_property", probe, "probe_value"))
88+
assert_true(_helper.call("_object_has_property", probe, "probe_value"))
89+
assert_eq(PropertyProbe.property_list_calls, 1)
90+
91+
assert_true(_helper.call("_object_has_property", AlphaProbe.new(), "alpha_value"))
92+
assert_false(_helper.call("_object_has_property", AlphaProbe.new(), "beta_value"))
93+
assert_true(_helper.call("_object_has_property", BetaProbe.new(), "beta_value"))
94+
assert_false(_helper.call("_object_has_property", BetaProbe.new(), "alpha_value"))
95+
96+
97+
func test_get_ui_elements_returns_controls_with_text_and_rects() -> void:
98+
assert_true(_helper.has_method("_game_get_ui_elements"),
99+
"game helper should expose get_ui_elements")
100+
var container := Node.new()
101+
container.name = "Container"
102+
_root.add_child(container)
103+
104+
var title := Label.new()
105+
title.name = "Title"
106+
title.text = "Score: 10"
107+
title.position = Vector2(10, 20)
108+
title.size = Vector2(120, 30)
109+
container.add_child(title)
110+
111+
var button := Button.new()
112+
button.name = "StartButton"
113+
button.text = "Start"
114+
button.disabled = true
115+
button.position = Vector2(20, 60)
116+
button.size = Vector2(90, 40)
117+
container.add_child(button)
118+
119+
var result = _helper.call("_game_get_ui_elements", {
120+
"root_path": "/Main/%s" % ROOT_NAME,
121+
"include_hidden": true,
122+
"max_depth": 4,
123+
})
124+
125+
assert_true(result is Dictionary, "get_ui_elements should return a Dictionary")
126+
assert_eq(result.root, "/Main/%s" % ROOT_NAME)
127+
assert_eq(result.total_count, 2)
128+
assert_eq(result.elements[0].name, "Title")
129+
assert_eq(result.elements[0].type, "Label")
130+
assert_eq(result.elements[0].text, "Score: 10")
131+
assert_has_key(result.elements[0], "visible")
132+
assert_eq(result.elements[0].disabled, false)
133+
assert_eq(result.elements[0].rect.position.x, 10.0)
134+
assert_eq(result.elements[0].rect.size.y, 30.0)
135+
assert_eq(result.elements[1].name, "StartButton")
136+
assert_eq(result.elements[1].disabled, true)
137+
assert_eq(result.elements[1].text, "Start")
138+
139+
140+
func test_get_ui_elements_can_filter_disabled_and_include_hidden() -> void:
141+
assert_true(_helper.has_method("_game_get_ui_elements"),
142+
"game helper should expose get_ui_elements")
143+
var visible_enabled := LineEdit.new()
144+
visible_enabled.name = "NameInput"
145+
visible_enabled.text = "Ada"
146+
_root.add_child(visible_enabled)
147+
148+
var disabled_button := Button.new()
149+
disabled_button.name = "DisabledButton"
150+
disabled_button.disabled = true
151+
_root.add_child(disabled_button)
152+
153+
var hidden_label := Label.new()
154+
hidden_label.name = "HiddenButIncluded"
155+
hidden_label.text = "Hidden"
156+
hidden_label.visible = false
157+
_root.add_child(hidden_label)
158+
159+
var result = _helper.call("_game_get_ui_elements", {
160+
"root_path": "/Main/%s" % ROOT_NAME,
161+
"include_hidden": true,
162+
"include_disabled": false,
163+
"max_depth": 1,
164+
})
165+
166+
assert_true(result is Dictionary, "get_ui_elements should return a Dictionary")
167+
assert_eq(result.total_count, 2)
168+
var names := [result.elements[0].name, result.elements[1].name]
169+
assert_true(names.has("NameInput"), "enabled control should be included")
170+
assert_true(names.has("HiddenButIncluded"), "hidden control should be included when requested")
171+
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

0 commit comments

Comments
 (0)