gdharness 1.1.21 → 1.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/build/cli.js +1470 -726
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +12 -2
  3. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  4. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  6. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  8. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  9. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  12. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  14. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  17. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +15 -5
  20. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +51 -5
  21. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  24. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +89 -16
  25. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +107 -343
  26. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  27. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  28. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  29. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  30. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  31. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +61 -28
  32. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  33. package/build/godot/operations/audio_buses.gd +47 -11
  34. package/build/godot/operations/class_cache.gd +18 -2
  35. package/build/godot/operations/classdb_queries.gd +28 -17
  36. package/build/godot/operations/dependencies.gd +128 -17
  37. package/build/godot/operations/file_walk.gd +20 -0
  38. package/build/godot/operations/gdscript_analysis.gd +59 -20
  39. package/build/godot/operations/gdscript_authoring.gd +127 -60
  40. package/build/godot/operations/godot_operations.gd +24 -5
  41. package/build/godot/operations/import_pipeline.gd +179 -19
  42. package/build/godot/operations/logger.gd +2 -2
  43. package/build/godot/operations/plugins.gd +88 -70
  44. package/build/godot/operations/project_config.gd +41 -6
  45. package/build/godot/operations/project_diagnostics.gd +27 -11
  46. package/build/godot/operations/reading.gd +17 -0
  47. package/build/godot/operations/resource_files.gd +60 -17
  48. package/build/godot/operations/serialisation.gd +116 -7
  49. package/build/index.js +1385 -553
  50. package/build/keeper.js +4 -0
  51. package/package.json +1 -1
@@ -5,6 +5,7 @@ extends RefCounted
5
5
  const Queries = preload("runtime_queries.gd")
6
6
  const Read = preload("reading.gd")
7
7
  const Says = preload("runtime_says.gd")
8
+ const Screen = preload("runtime_screen.gd")
8
9
  const Values = preload("runtime_values.gd")
9
10
  const Words = preload("runtime_words.gd")
10
11
 
@@ -68,8 +69,12 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
68
69
  var areas: Array[Rect2] = _areas_of(drawn)
69
70
  var covered: int = 0
70
71
  var cover: Node = null
72
+ var outside: int = 0
71
73
  var open: Array[int] = []
72
74
  for index: int in matched.size():
75
+ if _off_screen(matched[index]):
76
+ outside += 1
77
+ continue
73
78
  var over: Node = _cover_of(matched[index], drawn, areas)
74
79
  if over == null:
75
80
  open.append(index)
@@ -98,8 +103,8 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
98
103
  "type": "error",
99
104
  "message":
100
105
  (
101
- 'no control on screen%s says "%s", so nothing was clicked%s%s'
102
- % [under, wanted, _hidden_note(hidden), _covered_note(covered, cover)]
106
+ 'no control on screen%s says "%s", so nothing was clicked%s%s%s'
107
+ % [under, wanted, _hidden_note(hidden), _outside_note(outside), _covered_note(covered, cover)]
103
108
  )
104
109
  }
105
110
  var best: int = ranks.count(ranks[0])
@@ -137,10 +142,13 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
137
142
  return {"path": str(targets[index].get_path()), "found": found}
138
143
 
139
144
 
140
- ## How good a match [param target] is for a click: a button over what cannot be pressed, and then
141
- ## the whole of its words over a part of them.
145
+ ## How good a match [param target] is for a click: an enabled button over anything else, and then
146
+ ## the whole of its words over a part of them. A disabled button ranks with what is not a button,
147
+ ## since a click on it presses nothing: counted as a button, one tied with an enabled button saying
148
+ ## the same words and the click was refused as not clear.
142
149
  static func _rank(target: Control, whole: bool) -> int:
143
- return (2 if target is BaseButton else 0) + (1 if whole else 0)
150
+ var button: BaseButton = target as BaseButton
151
+ return (2 if button != null and not button.disabled else 0) + (1 if whole else 0)
144
152
 
145
153
 
146
154
  ## Whether one of [param wanted]'s alternatives is the whole of [param said], or the whole of one of
@@ -178,14 +186,17 @@ static func _described(count: int, rank: int, wanted: String, under: String) ->
178
186
  return "%d %ss on screen%s say %s" % [count, what, under, how]
179
187
 
180
188
 
181
- ## Why the matches below [param rank] were passed over, said of one of them when [param one].
189
+ ## Why the matches below [param rank] were passed over, said of one of them when [param one]. As
190
+ ## what the code knows, which is whether a control is an enabled button: a card taking clicks in its
191
+ ## own `gui_input` is not one, and calling it something that cannot be pressed was a guess.
182
192
  static func _passed_over(rank: int, one: bool) -> String:
183
193
  var says: String = "says" if one else "say"
194
+ var is_not: String = "is not an enabled button" if one else "are not enabled buttons"
184
195
  match rank:
185
196
  3:
186
- return "cannot be pressed or %s it as part of more" % says
197
+ return "%s or %s it as part of more" % [is_not, says]
187
198
  2:
188
- return "cannot be pressed"
199
+ return is_not
189
200
  _:
190
201
  return "%s it as part of more" % says
191
202
 
@@ -197,6 +208,14 @@ static func _hidden_note(hidden: int) -> String:
197
208
  return "; %d hidden control%s" % [hidden, " says it" if hidden == 1 else "s say it"]
198
209
 
199
210
 
211
+ ## What to add to a refusal that found nothing on screen, when words matched controls a click cannot
212
+ ## reach: off the viewport, or clipped away by a container it does not scroll.
213
+ static func _outside_note(outside: int) -> String:
214
+ if outside == 0:
215
+ return ""
216
+ return "; %d control%s it outside what the screen shows" % [outside, " says" if outside == 1 else "s say"]
217
+
218
+
200
219
  ## What to add to a refusal that found nothing on screen, when the controls saying it are covered.
201
220
  static func _covered_note(covered: int, cover: Node) -> String:
202
221
  if covered == 0:
@@ -271,26 +290,22 @@ static func _areas_of(drawn: Array[Node]) -> Array[Rect2]:
271
290
  ## page covered was picked over the ones in view, scrolled up under the page and pressed there.
272
291
  ##
273
292
  ## [param areas] holds the box around each of [param drawn], so everything whose box misses the
274
- ## point is passed over before anything costlier is asked of it.
293
+ ## point is passed over before anything costlier is asked of it. A window's box is in the space of
294
+ ## the viewport it is embedded in, which is not the target's when the target is in another window,
295
+ ## so a window is judged by [method _window_over] instead.
275
296
  static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2]) -> Node:
276
297
  var viewport: Viewport = target.get_viewport()
277
- var point: Vector2 = target.get_global_transform_with_canvas() * (target.size * 0.5)
278
- if _clipped_away(target, point):
279
- point = _where_it_lands(target, point)
298
+ var point: Vector2 = _where_it_is_clicked(target)
280
299
  var layer: int = _layer_of(target)
281
300
  for at: int in drawn.size():
282
- if not areas[at].has_point(point):
283
- continue
284
301
  var other: Node = drawn[at]
285
302
  var window: Window = other as Window
286
303
  if window != null:
287
- if (
288
- window.get_parent() != null
289
- and window.get_parent().get_viewport() == viewport
290
- and not window.is_ancestor_of(target)
291
- ):
304
+ if _window_over(target, point, window):
292
305
  return window
293
306
  continue
307
+ if not areas[at].has_point(point):
308
+ continue
294
309
  var control: Control = other
295
310
  if control == target or control.is_ancestor_of(target) or target.is_ancestor_of(control):
296
311
  continue
@@ -304,6 +319,42 @@ static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2])
304
319
  return null
305
320
 
306
321
 
322
+ ## Where a click at [param target] lands in its viewport: its centre, or where scrolling will bring
323
+ ## the centre when a ScrollContainer holding it clips it away, as the click scrolls it first.
324
+ static func _where_it_is_clicked(target: Control) -> Vector2:
325
+ var point: Vector2 = target.get_global_transform_with_canvas() * (target.size * 0.5)
326
+ if _clipped_away(target, point):
327
+ point = _where_it_lands(target, point)
328
+ return point
329
+
330
+
331
+ ## Whether the embedded [param window] is drawn over [param point], [param target]'s centre in its
332
+ ## own viewport, with that point carried out to the viewport the window is embedded in.
333
+ ##
334
+ ## Over it when the window holds the point there and is not the window the target is in, and, when
335
+ ## the target is in another window embedded in the same place, when it is stacked above that one:
336
+ ## the embedder's list of windows runs from the bottom up, measured on 4.7.2. Judged only in the
337
+ ## target's own viewport, a confirmation over a settings dialog did not cover the dialog's OK, and
338
+ ## a click by words tied it with the confirmation's own.
339
+ static func _window_over(target: Control, point: Vector2, window: Window) -> bool:
340
+ if window.get_parent() == null or window.is_ancestor_of(target):
341
+ return false
342
+ var host: Viewport = window.get_parent().get_viewport()
343
+ for step: Dictionary in Screen.steps_out(target.get_viewport(), point):
344
+ if step["viewport"] != host:
345
+ continue
346
+ var there: Vector2 = step["point"]
347
+ if not Rect2(window.position, window.size).has_point(there):
348
+ return false
349
+ var through: Variant = step["through"]
350
+ if not through is Window:
351
+ return true
352
+ var own: Window = through
353
+ var stacked: Array[Window] = host.get_embedded_subwindows()
354
+ return stacked.find(window) > stacked.find(own)
355
+ return false
356
+
357
+
307
358
  ## Whether [param control] holds [param point], given in its viewport's coordinates.
308
359
  static func _holds(control: Control, point: Vector2) -> bool:
309
360
  var placed: Transform2D = control.get_global_transform_with_canvas()
@@ -316,17 +367,18 @@ static func _holds(control: Control, point: Vector2) -> bool:
316
367
 
317
368
 
318
369
  ## Where [param target]'s centre, now at [param point], will be once the click has scrolled it into
319
- ## view: moved as little as puts the whole of it inside each container clipping it, innermost first,
320
- ## which is how a scroll container brings a control into view. A target larger than a container is
321
- ## centred in it.
370
+ ## view: moved as little as puts the whole of it inside each ScrollContainer holding it, innermost
371
+ ## first, which is how one brings a control into view. A target larger than a container is centred
372
+ ## in it. Only a ScrollContainer, because that is all a click scrolls: another container clipping
373
+ ## what it holds, a collapsed section for one, leaves the target where it is.
322
374
  static func _where_it_lands(target: Control, point: Vector2) -> Vector2:
323
375
  var drawn: Transform2D = target.get_global_transform_with_canvas()
324
376
  var half: Vector2 = (drawn * Rect2(Vector2.ZERO, target.size)).size * 0.5
325
377
  var landed: Vector2 = point
326
378
  var walk: Node = target.get_parent()
327
379
  while walk != null:
328
- var holder: Control = walk as Control
329
- if holder != null and holder.clip_contents:
380
+ var holder: ScrollContainer = walk as ScrollContainer
381
+ if holder != null:
330
382
  var shown: Rect2 = holder.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, holder.size)
331
383
  var low: Vector2 = shown.position + half
332
384
  var high: Vector2 = shown.end - half
@@ -338,6 +390,20 @@ static func _where_it_lands(target: Control, point: Vector2) -> Vector2:
338
390
  return landed
339
391
 
340
392
 
393
+ ## Whether a click could not reach [param target] however it scrolled: its centre, where the click
394
+ ## would bring it, outside its viewport or the one showing that on the screen, or clipped away by a
395
+ ## container the click does not scroll. A drawer parked off the side of the screen, or anything
396
+ ## outside the 64 by 64 viewport of a game with no window, was listed among the controls on screen.
397
+ static func _off_screen(target: Control) -> bool:
398
+ var point: Vector2 = _where_it_is_clicked(target)
399
+ if _clipped_away(target, point) or not target.get_viewport().get_visible_rect().has_point(point):
400
+ return true
401
+ var reached: Dictionary = Screen.reach(target.get_viewport(), point)
402
+ var viewport: Viewport = reached["viewport"]
403
+ var there: Vector2 = reached["point"]
404
+ return not viewport.get_visible_rect().has_point(there)
405
+
406
+
341
407
  ## Whether a container clipping what it holds cuts [param point] off from [param control].
342
408
  static func _clipped_away(control: Control, point: Vector2) -> bool:
343
409
  var walk: Node = control.get_parent()
@@ -0,0 +1,351 @@
1
+ extends RefCounted
2
+
3
+ ## What reaches the running game the way a keyboard reaches it: text typed into the field that has
4
+ ## the focus, and an item chosen out of a menu the way a keyboard chooses one.
5
+
6
+ const Menus = preload("runtime_menus.gd")
7
+ const Read = preload("reading.gd")
8
+ const Values = preload("runtime_values.gd")
9
+ const Words = preload("runtime_words.gd")
10
+
11
+ ## The two characters a field reads as keys rather than as text.
12
+ const NEWLINE: int = 10
13
+ const TAB: int = 9
14
+
15
+ var _host: Node
16
+
17
+
18
+ func _init(host: Node) -> void:
19
+ _host = host
20
+
21
+
22
+ ## Types [param text] wherever the focus is, one key event per character.
23
+ ##
24
+ ## A key on its own cannot do this and should not try: which character a key produces is the
25
+ ## keyboard layout's business, and shift over a digit is an exclamation mark on one layout and
26
+ ## something else on the next. Given the character instead there is nothing to guess, so a field
27
+ ## can be filled with anything a player could type, this project's own two typefaces included.
28
+ ##
29
+ ## A newline and a tab are the two characters a field reads as keys rather than as text, so they
30
+ ## are sent as those keys and carry no character of their own: typing a name and submitting it is
31
+ ## one call rather than two.
32
+ ##
33
+ ## Pushed into the viewport for the reason a click is, and it matters more here: the focus is what
34
+ ## decides where a character lands, so a caller that clicked a field and then typed would otherwise
35
+ ## have both waiting in the same queue with nothing said about the order.
36
+ ##
37
+ ## [code]replace[/code] is for a field that already says something, which is most of them: typing
38
+ ## lands at the caret, so a spin box reading 2.1 typed "0.3" at reads 2.10.3 and parses back to
39
+ ## 2.1, and the answer said four characters had gone in. What the field holds afterwards is read
40
+ ## back now rather than echoed from the request, so a call that typed somewhere unhelpful says so.
41
+ func inject_text(params: Dictionary) -> Dictionary:
42
+ var text: String = str(params.get("text", ""))
43
+ var over: bool = Read.as_bool(params.get("replace", false))
44
+ if text.is_empty() and not over:
45
+ return {"type": "error", "message": "text needs something to type, or replace to empty a field"}
46
+
47
+ var viewport: Viewport = _host.get_tree().root
48
+ var focused: Control = focus_owner(viewport)
49
+ var shut: String = _shut_to_typing(focused)
50
+ if not shut.is_empty():
51
+ return {"type": "error", "message": shut}
52
+
53
+ # Where it goes, which is the one thing a caller cannot see from here, read before the keys: a
54
+ # name field that swaps its panel out on Enter has left the tree by the time the keys are done,
55
+ # and a node out of the tree has no path to give. Null is a game reading keys for itself with
56
+ # nothing focused, which is a real thing to be typing at.
57
+ var into: Variant = null
58
+ if focused != null:
59
+ into = str(focused.get_path())
60
+
61
+ var replaced: bool = over and _select_everything(focused)
62
+ # Emptying a field is a real thing to ask for and the one shape of filling one in that types no
63
+ # characters. The selection is standing, so the key a player presses over one is what clears it.
64
+ if replaced and text.is_empty():
65
+ _press(viewport, _held_down(KEY_DELETE))
66
+ for index: int in text.length():
67
+ _press(viewport, _typed(text.unicode_at(index)))
68
+
69
+ var holds: Variant = null
70
+ if is_instance_valid(focused):
71
+ holds = _what_it_holds(focused)
72
+ return {
73
+ "type": "input_injected",
74
+ "input_type": "text",
75
+ "text": text,
76
+ "characters": text.length(),
77
+ "into": into,
78
+ "replaced": replaced,
79
+ "holds": holds,
80
+ }
81
+
82
+
83
+ ## The control keys pushed into [param viewport] go to: its focus, or the focus of the window
84
+ ## embedded in it that has the focus, followed down.
85
+ ##
86
+ ## The game's own viewport keeps the control it had focused when a dialog took the focus, and keys
87
+ ## pushed at it go on to the dialog, measured on 4.7.2: a root field was reported as typed into and
88
+ ## stayed empty while the characters landed in the dialog's field.
89
+ static func focus_owner(viewport: Viewport) -> Control:
90
+ var at: Viewport = viewport
91
+ var deeper: bool = true
92
+ while deeper:
93
+ deeper = false
94
+ for window: Window in at.get_embedded_subwindows():
95
+ if window.has_focus():
96
+ at = window
97
+ deeper = true
98
+ break
99
+ return at.gui_get_focus_owner()
100
+
101
+
102
+ ## Selects everything in [param focused] so the next character typed writes over it, and answers
103
+ ## whether there was a field to select in.
104
+ ##
105
+ ## Through the control rather than through a Ctrl+A, which is the one part of filling a field that
106
+ ## cannot honestly be sent as a key: the shortcut is Cmd+A on macOS and is an [InputMap] action a
107
+ ## project is free to unbind, and a caller asking for the field to be replaced would then be typing
108
+ ## on the end of what was there. The replacement itself is still every character a player types.
109
+ static func _select_everything(focused: Control) -> bool:
110
+ var field: LineEdit = focused as LineEdit
111
+ if field != null:
112
+ field.select_all()
113
+ return true
114
+ var box: TextEdit = focused as TextEdit
115
+ if box == null:
116
+ return false
117
+ box.select_all()
118
+ return true
119
+
120
+
121
+ ## What the field says now, or null where the focus is not a field at all.
122
+ ##
123
+ ## A spin box rewrites its own text out of the number it parsed, so this is the only thing that
124
+ ## says whether what was typed became what the field means. A secret field answers with its mask,
125
+ ## which still says how many characters it took.
126
+ static func _what_it_holds(focused: Control) -> Variant:
127
+ var field: LineEdit = focused as LineEdit
128
+ if field != null:
129
+ return Words.masked(field) if field.secret else field.text
130
+ var box: TextEdit = focused as TextEdit
131
+ if box == null:
132
+ return null
133
+ return box.text
134
+
135
+
136
+ ## Why nothing typed would reach [param focused], or "" when it would.
137
+ ##
138
+ ## A field that is not editable takes no characters however it is reached, and a click does not
139
+ ## open a read-only [LineEdit] for editing, measured on 4.7.2, so sending the caller to click it
140
+ ## would not help.
141
+ ##
142
+ ## Having the focus is not the same as being edited. Since Godot 4.4 a field is focused and shut
143
+ ## until something opens it, which is what a click does and what submitting undoes: press Enter in
144
+ ## a box and it keeps the focus and drops every key that arrives afterwards. Typing into one
145
+ ## answered that the characters had gone in and put nothing anywhere, which is the shape a refusal
146
+ ## exists to prevent. Measured on a spin box in a real game: `has_focus` true, `is_editing` false,
147
+ ## three characters reported and the field unchanged. Only a [LineEdit] is refused for that: it is
148
+ ## the one control the engine will say it about, since [TextEdit] has no editing state of its own.
149
+ ## A game reading keys for itself is typed at with nothing focused at all, and that is not this
150
+ ## tool's business to judge.
151
+ static func _shut_to_typing(focused: Control) -> String:
152
+ var field: LineEdit = focused as LineEdit
153
+ var box: TextEdit = focused as TextEdit
154
+ if (field != null and not field.editable) or (box != null and not box.editable):
155
+ return "%s has the focus and is read-only, so nothing typed lands in it." % focused.get_path()
156
+ if field == null or field.is_editing():
157
+ return ""
158
+ return (
159
+ "%s has the focus and is not being edited, so nothing typed lands in it. Click it first."
160
+ % focused.get_path()
161
+ )
162
+
163
+
164
+ ## A whole key press into [param viewport]: down, then the release, so nothing is left held down
165
+ ## behind the caller.
166
+ static func _press(viewport: Viewport, down: InputEventKey) -> void:
167
+ viewport.push_input(down)
168
+ var up: InputEventKey = down.duplicate()
169
+ up.pressed = false
170
+ viewport.push_input(up)
171
+
172
+
173
+ ## A key held down by its keycode, for the ones that stand for an edit rather than a character.
174
+ static func _held_down(code: Key) -> InputEventKey:
175
+ var event: InputEventKey = InputEventKey.new()
176
+ event.pressed = true
177
+ event.keycode = code
178
+ event.physical_keycode = code
179
+ event.key_label = code
180
+ return event
181
+
182
+
183
+ ## The key press that produces [param glyph], as a keyboard would send it.
184
+ static func _typed(glyph: int) -> InputEventKey:
185
+ var event: InputEventKey = InputEventKey.new()
186
+ event.pressed = true
187
+ if glyph == NEWLINE:
188
+ event.keycode = KEY_ENTER
189
+ elif glyph == TAB:
190
+ event.keycode = KEY_TAB
191
+ else:
192
+ var capital: int = String.chr(glyph).to_upper().unicode_at(0)
193
+ event.keycode = capital as Key
194
+ event.shift_pressed = capital != glyph
195
+ event.unicode = glyph
196
+ event.physical_keycode = event.keycode
197
+ event.key_label = event.keycode
198
+ return event
199
+
200
+
201
+ ## Chooses an item out of a menu, by what it says or by where it is in the list.
202
+ ##
203
+ ## A menu's items are drawn rather than built, so there is no node under the pointer to aim at and
204
+ ## no rectangle to ask for: [PopupMenu] exposes their text, their ids and which one has the focus,
205
+ ## and nothing about where any of them is. So a click cannot reach one, and a whole click on the
206
+ ## [OptionButton] in front of it opens the menu on the press and closes it again on the release.
207
+ ## Every language picker, every filter and every dropdown in a game was unreachable, and the way
208
+ ## past it was to call `select` and emit `item_selected`, which sets a number and runs none of the
209
+ ## engine's own path.
210
+ ##
211
+ ## Chosen the way a keyboard chooses: the item takes the focus and then `ui_accept` presses it,
212
+ ## which is the same route through [PopupMenu] a pointer takes and which needs no geometry, so it
213
+ ## works in a game with no window as well.
214
+ ##
215
+ ## [param path] may be the menu or the button in front of it. Naming the button is what a caller
216
+ ## has, since the menu is an internal child with a generated name that changes between runs.
217
+ func choose(params: Dictionary) -> Dictionary:
218
+ var node_path: String = str(params.get("path", ""))
219
+ if node_path.is_empty():
220
+ return {"type": "error", "message": "Node path required"}
221
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
222
+ if standing.has("message"):
223
+ return standing
224
+ var node: Node = standing["node"]
225
+
226
+ var menu: PopupMenu = Menus.menu_of(node)
227
+ if menu == null:
228
+ return {
229
+ "type": "error",
230
+ "message":
231
+ (
232
+ "%s is a %s, which is neither a PopupMenu nor something holding one"
233
+ % [node_path, node.get_class()]
234
+ )
235
+ }
236
+
237
+ if not params.has("index") and str(params.get("text", "")).is_empty():
238
+ return {
239
+ "type": "error",
240
+ "message":
241
+ (
242
+ "%s needs the item named, by text or index. It holds: %s"
243
+ % [node_path, ", ".join(Menus.items_of(menu))]
244
+ )
245
+ }
246
+
247
+ # A button a player cannot use is refused, as a click refuses one: the engine opens the menu of a
248
+ # disabled or hidden OptionButton when asked to, measured on 4.7.2, and the pick then changed a
249
+ # selection no player could have changed.
250
+ var holder: BaseButton = node as BaseButton
251
+ if holder != null and holder.disabled:
252
+ return {"type": "error", "message": "%s is disabled, so its menu cannot be opened" % node_path}
253
+ if holder != null and not holder.is_visible_in_tree():
254
+ return {"type": "error", "message": "%s is not visible, so its menu cannot be opened" % node_path}
255
+
256
+ # Shown first, because a menu nobody has opened has no focus to move and the press would go to
257
+ # whatever is behind it, and before the item is looked for, because opening is when a game fills
258
+ # a menu: one rebuilt on about_to_popup was read as it stood before, measured on 4.7.2, and the
259
+ # index found in the old items chose another item of the new ones. An OptionButton opens its own;
260
+ # a bare PopupMenu is popped where it already sits, which leaves a menu already open where it is.
261
+ var opened: bool = Menus.open_the_menu(node, menu)
262
+ await _host.get_tree().process_frame
263
+
264
+ var index: int = Menus.wanted_item(menu, params)
265
+ var refused: String = _not_a_choice(node_path, menu, index)
266
+ if not refused.is_empty():
267
+ if opened and is_instance_valid(menu):
268
+ menu.hide()
269
+ return {"type": "error", "message": refused}
270
+
271
+ # Read before the press, because the press can take the menu away: a game that rebuilds its
272
+ # settings screen on item_selected frees the button and its menu inside the pick, and reading
273
+ # the item afterwards was an engine error in the game's log and an empty answer to a pick that
274
+ # had taken.
275
+ var answer: Dictionary = {
276
+ "type": "chosen",
277
+ "path": node_path,
278
+ "index": index,
279
+ "text": Words.item_says(menu, index),
280
+ "id": menu.get_item_id(index),
281
+ "opened": opened,
282
+ "menu": str(menu.get_path()),
283
+ }
284
+ # Heard rather than assumed: an item pressed is the only evidence the menu took it.
285
+ var fired: Array[int] = []
286
+ var hear: Callable = func(at: int) -> void: fired.append(at)
287
+ var _listening: int = menu.index_pressed.connect(hear)
288
+ menu.scroll_to_item(index)
289
+ menu.set_focused_item(index)
290
+ # Through Input rather than pushed at the menu, which is how a keyboard reaches an open one: a
291
+ # popup is a Window, it takes the focus when it opens, and Input delivers to whichever window
292
+ # has it. Pushed straight at the menu the event arrived and nothing happened.
293
+ Input.parse_input_event(_accept(true))
294
+ await _host.get_tree().process_frame
295
+ Input.parse_input_event(_accept(false))
296
+ await _host.get_tree().process_frame
297
+ if is_instance_valid(menu) and menu.index_pressed.is_connected(hear):
298
+ menu.index_pressed.disconnect(hear)
299
+
300
+ if not fired.has(index):
301
+ if opened and is_instance_valid(menu) and menu.visible:
302
+ menu.hide()
303
+ return {
304
+ "type": "error",
305
+ "message":
306
+ (
307
+ (
308
+ "%s item %d, %s, had the focus and ui_accept was pressed, and the menu did not take it,"
309
+ + " so nothing was chosen"
310
+ )
311
+ % [node_path, index, answer["text"]]
312
+ )
313
+ }
314
+
315
+ answer["control_afterwards"] = Values.afterwards(node)
316
+ # What the button in front of the menu reads now: a menu item that fired changes the thing
317
+ # holding it. A button the pick took away has nothing to read, and that it went is the answer.
318
+ if answer["control_afterwards"] == "in_tree" and node is OptionButton:
319
+ var chooser: OptionButton = node
320
+ answer["selected"] = chooser.get_selected()
321
+ answer["shows"] = Words.said_by(chooser)
322
+ return answer
323
+
324
+
325
+ ## Why item [param index] of [param menu] is not something to choose, or "" when it is.
326
+ static func _not_a_choice(node_path: String, menu: PopupMenu, index: int) -> String:
327
+ if index < 0:
328
+ return "%s has no such item. It holds: %s" % [node_path, ", ".join(Menus.items_of(menu))]
329
+ if menu.is_item_separator(index):
330
+ var heading: String = Words.item_says(menu, index).strip_edges()
331
+ if heading.is_empty():
332
+ return "%s item %d is a separator, not a choice" % [node_path, index]
333
+ return (
334
+ "%s item %d, %s, is a separator heading the items below it, not a choice"
335
+ % [node_path, index, heading]
336
+ )
337
+ if menu.is_item_disabled(index):
338
+ return "%s item %d, %s, is disabled" % [node_path, index, Words.item_says(menu, index)]
339
+ return ""
340
+
341
+
342
+ ## The press or release of `ui_accept`, which is what [PopupMenu] takes its focused item on.
343
+ ##
344
+ ## The action rather than the Enter key it is usually bound to: a project is free to bind it to
345
+ ## something else, and with Enter taken off it the key did nothing, measured on 4.7.2, while the
346
+ ## action chose the item.
347
+ static func _accept(pressed: bool) -> InputEventAction:
348
+ var event: InputEventAction = InputEventAction.new()
349
+ event.action = "ui_accept"
350
+ event.pressed = pressed
351
+ return event
@@ -0,0 +1 @@
1
+ uid://3a2opgsbkmu