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.
- package/build/cli.js +1470 -726
- package/build/godot/addons/gdharness_editor/bridge_client.gd +12 -2
- package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
- package/build/godot/addons/gdharness_editor/reading.gd +17 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
- package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
- package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
- package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
- package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
- package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
- package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
- package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +15 -5
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd +51 -5
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
- package/build/godot/addons/gdharness_runtime/runtime_paths.gd +89 -16
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +107 -343
- package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
- package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
- package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +61 -28
- package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
- package/build/godot/operations/audio_buses.gd +47 -11
- package/build/godot/operations/class_cache.gd +18 -2
- package/build/godot/operations/classdb_queries.gd +28 -17
- package/build/godot/operations/dependencies.gd +128 -17
- package/build/godot/operations/file_walk.gd +20 -0
- package/build/godot/operations/gdscript_analysis.gd +59 -20
- package/build/godot/operations/gdscript_authoring.gd +127 -60
- package/build/godot/operations/godot_operations.gd +24 -5
- package/build/godot/operations/import_pipeline.gd +179 -19
- package/build/godot/operations/logger.gd +2 -2
- package/build/godot/operations/plugins.gd +88 -70
- package/build/godot/operations/project_config.gd +41 -6
- package/build/godot/operations/project_diagnostics.gd +27 -11
- package/build/godot/operations/reading.gd +17 -0
- package/build/godot/operations/resource_files.gd +60 -17
- package/build/godot/operations/serialisation.gd +116 -7
- package/build/index.js +1385 -553
- package/build/keeper.js +4 -0
- 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:
|
|
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
|
-
|
|
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 "
|
|
197
|
+
return "%s or %s it as part of more" % [is_not, says]
|
|
187
198
|
2:
|
|
188
|
-
return
|
|
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 =
|
|
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
|
|
320
|
-
## which is how
|
|
321
|
-
##
|
|
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:
|
|
329
|
-
if holder != null
|
|
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
|