gdharness 1.2.27 → 1.2.29
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 +804 -464
- package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +378 -8
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +29 -7
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +33 -7
- package/build/godot/addons/gdharness_runtime/runtime_pad.gd +10 -3
- package/build/godot/addons/gdharness_runtime/runtime_paths.gd +89 -15
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +25 -3
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +120 -0
- package/build/index.js +669 -342
- package/build/keeper.js +20 -17
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ extends RefCounted
|
|
|
10
10
|
## told, names left empty included.
|
|
11
11
|
|
|
12
12
|
const Read = preload("reading.gd")
|
|
13
|
+
const Says = preload("runtime_says.gd")
|
|
13
14
|
|
|
14
15
|
## How long the client keeps walking the tree for it to settle: the engine answers the first question
|
|
15
16
|
## with its windows alone and fills them in over the next frames.
|
|
@@ -23,6 +24,9 @@ const START_MS: int = 15000
|
|
|
23
24
|
## Deep enough for any interface a game builds, and inside the hundred levels ConvertTo-Json stops
|
|
24
25
|
## at, which are two to a level of the tree.
|
|
25
26
|
const MOST_LEVELS: int = 40
|
|
27
|
+
## Where an element holds its node's instance id between matching and placing, removed before the
|
|
28
|
+
## answer goes.
|
|
29
|
+
const NODE_ID: String = "_node_instance"
|
|
26
30
|
|
|
27
31
|
## The client, as PowerShell, with $Handles, $Out, $SettleMs, $EmptyMs and $MostLevels set before it. Each
|
|
28
32
|
## element answers what UI Automation names its role, its name, its value where it has one, its
|
|
@@ -41,12 +45,45 @@ try {
|
|
|
41
45
|
if ($element.TryGetCurrentPattern($pattern, [ref]$found)) { return $found }
|
|
42
46
|
return $null
|
|
43
47
|
}
|
|
48
|
+
# FullDescription is where the description is put, and the managed client refuses every property
|
|
49
|
+
# newer than itself, so it is asked through the core call the client makes for the rest.
|
|
50
|
+
$script:unread = $null
|
|
51
|
+
try {
|
|
52
|
+
$client = [System.Windows.Automation.AutomationElement]
|
|
53
|
+
$core = $client.Assembly.GetType('MS.Internal.Automation.UiaCoreApi')
|
|
54
|
+
$ask = $core.GetMethod('UiaGetPropertyValue', [System.Reflection.BindingFlags]'NonPublic,Public,Static')
|
|
55
|
+
$nodeField = $client.GetField('_hnode', [System.Reflection.BindingFlags]'NonPublic,Instance')
|
|
56
|
+
if ($null -eq $ask -or $null -eq $nodeField) {
|
|
57
|
+
$script:unread = 'this Windows has no UiaGetPropertyValue to ask it through'
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
$script:unread = "$_"
|
|
61
|
+
}
|
|
62
|
+
function FullDescription($element) {
|
|
63
|
+
if ($null -ne $script:unread) { return '' }
|
|
64
|
+
try {
|
|
65
|
+
$asked = @($nodeField.GetValue($element), [int]30159, $null)
|
|
66
|
+
$null = $ask.Invoke($null, $asked)
|
|
67
|
+
if ($asked[2] -is [string]) { return $asked[2] }
|
|
68
|
+
} catch {
|
|
69
|
+
$script:unread = "$_"
|
|
70
|
+
}
|
|
71
|
+
return ''
|
|
72
|
+
}
|
|
44
73
|
function Describe($element, $ox, $oy, $depth) {
|
|
45
74
|
$c = $element.Current
|
|
46
75
|
$role = $c.ControlType.ProgrammaticName -replace '^ControlType[.]', ''
|
|
47
76
|
$role = ($role -creplace '(?<=[a-z])(?=[A-Z])', '_').ToLowerInvariant()
|
|
48
77
|
$node = [ordered]@{ role = $role; name = $c.Name }
|
|
49
|
-
|
|
78
|
+
$description = FullDescription $element
|
|
79
|
+
if ($description) { $node.description = $description }
|
|
80
|
+
if ($c.HelpText) { $node.placeholder = $c.HelpText }
|
|
81
|
+
# The element's own id, the last four parts of its runtime id, which the game reads back as the
|
|
82
|
+
# node that made it.
|
|
83
|
+
$runtime = $element.GetRuntimeId()
|
|
84
|
+
if ($null -ne $runtime -and $runtime.Length -ge 4) {
|
|
85
|
+
$node.element_id = @($runtime[-4], $runtime[-3], $runtime[-2], $runtime[-1])
|
|
86
|
+
}
|
|
50
87
|
$r = $c.BoundingRectangle
|
|
51
88
|
if (-not $r.IsEmpty) {
|
|
52
89
|
$node.bounds = [ordered]@{ x = [int]($r.X - $ox); y = [int]($r.Y - $oy) }
|
|
@@ -121,7 +158,9 @@ try {
|
|
|
121
158
|
}
|
|
122
159
|
$windows += , $tree
|
|
123
160
|
}
|
|
124
|
-
|
|
161
|
+
$answer = @{ windows = $windows }
|
|
162
|
+
if ($null -ne $script:unread) { $answer.description_unread = $script:unread }
|
|
163
|
+
Answer $answer
|
|
125
164
|
} catch {
|
|
126
165
|
Answer @{ error = "$_" }
|
|
127
166
|
}
|
|
@@ -135,12 +174,27 @@ func _init(host: Node) -> void:
|
|
|
135
174
|
|
|
136
175
|
|
|
137
176
|
## The game's windows as a screen reader is told them, each a tree of elements.
|
|
138
|
-
func get_accessibility(
|
|
177
|
+
func get_accessibility(params: Dictionary) -> Dictionary:
|
|
139
178
|
if DisplayServer.get_name() == "headless":
|
|
179
|
+
# Both needs at once: told only the window, a caller started a windowed run and was then
|
|
180
|
+
# refused for the tree the engine had not been asked to build.
|
|
181
|
+
var also: String = ""
|
|
182
|
+
if _setting() != "always":
|
|
183
|
+
also = (
|
|
184
|
+
(
|
|
185
|
+
' and engineArgs ["--accessibility", "always"], since this project\'s'
|
|
186
|
+
+ " accessibility/general/accessibility_support is %s and the engine builds the tree"
|
|
187
|
+
+ " only when asked"
|
|
188
|
+
)
|
|
189
|
+
% _setting()
|
|
190
|
+
)
|
|
140
191
|
return _refused(
|
|
141
192
|
(
|
|
142
|
-
|
|
143
|
-
|
|
193
|
+
(
|
|
194
|
+
"A headless run has no window, so a screen reader is told nothing: start the run with"
|
|
195
|
+
+ " headless: false%s."
|
|
196
|
+
)
|
|
197
|
+
% also
|
|
144
198
|
)
|
|
145
199
|
)
|
|
146
200
|
if OS.get_name() != "Windows":
|
|
@@ -211,14 +265,45 @@ func get_accessibility(_params: Dictionary) -> Dictionary:
|
|
|
211
265
|
return _refused("UI Automation could not read the game's windows: %s" % str(answer["error"]))
|
|
212
266
|
|
|
213
267
|
var windows: Array = answer.get("windows", [])
|
|
268
|
+
var made_by: Dictionary = {}
|
|
269
|
+
_note_elements(_host.get_tree().root, made_by)
|
|
270
|
+
var placing: Placing = Placing.new()
|
|
214
271
|
var elements: int = 0
|
|
215
272
|
var unnamed: int = 0
|
|
216
273
|
for at: int in windows.size():
|
|
217
274
|
var window: Dictionary = windows[at]
|
|
218
|
-
|
|
275
|
+
var window_id: int = ids[at] if at < ids.size() else -1
|
|
276
|
+
window["window_id"] = window_id
|
|
277
|
+
_match(window, made_by, placing)
|
|
278
|
+
for at: int in windows.size():
|
|
279
|
+
var window: Dictionary = windows[at]
|
|
280
|
+
var window_id: int = window["window_id"]
|
|
281
|
+
var holder: Window = _window_with(_host.get_tree().root, window_id)
|
|
282
|
+
if holder != null:
|
|
283
|
+
window["node"] = str(holder.get_path())
|
|
284
|
+
_attribute(window, holder, placing, true)
|
|
285
|
+
else:
|
|
286
|
+
_unmark(window)
|
|
219
287
|
elements += _count(window)
|
|
220
288
|
unnamed += _unnamed_text(window)
|
|
221
289
|
var result: Dictionary = {"windows": windows, "elements": elements}
|
|
290
|
+
if placing.tooltips > 0:
|
|
291
|
+
result["tooltip_note"] = (
|
|
292
|
+
"tooltip is the node's tooltip_text, which the engine puts in the tree, and UI Automation, as"
|
|
293
|
+
+ " AccessKit fills it in, has no property that carries it (measured on 4.7.2), so a screen"
|
|
294
|
+
+ " reader on Windows is not told it. Say what a reader needs in accessibility_description."
|
|
295
|
+
)
|
|
296
|
+
if placing.asked > 0 and placing.placed == 0:
|
|
297
|
+
# Nothing at all matched: the element ids are not the engine's element RIDs any more, which
|
|
298
|
+
# an AccessKit that numbers its elements another way would do, and every node would read null.
|
|
299
|
+
result["node_note"] = (
|
|
300
|
+
"No element could be matched to the node that made it: UI Automation's runtime ids did not"
|
|
301
|
+
+ " carry the engine's element ids, so node and part_of are the windows' alone."
|
|
302
|
+
)
|
|
303
|
+
if answer.has("description_unread"):
|
|
304
|
+
result["description_note"] = (
|
|
305
|
+
"Descriptions could not be read, so none is shown: %s" % str(answer["description_unread"])
|
|
306
|
+
)
|
|
222
307
|
if elements <= windows.size():
|
|
223
308
|
var setting: String = _setting()
|
|
224
309
|
# The command line's --accessibility is consumed by the engine and not handed on, so only
|
|
@@ -253,9 +338,114 @@ func get_accessibility(_params: Dictionary) -> Dictionary:
|
|
|
253
338
|
)
|
|
254
339
|
% unnamed
|
|
255
340
|
)
|
|
341
|
+
_narrow(result, params)
|
|
256
342
|
return result
|
|
257
343
|
|
|
258
344
|
|
|
345
|
+
## Cuts [param result] to what [param params] asks for: the elements made by a node and its
|
|
346
|
+
## descendants, the elements saying some words, the fields named, and no more than a number of them.
|
|
347
|
+
## Each window keeps as its children only the outermost elements that match, with what they hold, so
|
|
348
|
+
## a question about one panel reads one panel.
|
|
349
|
+
static func _narrow(result: Dictionary, params: Dictionary) -> void:
|
|
350
|
+
var under: String = str(params.get("node_path", "")).trim_suffix("/")
|
|
351
|
+
var said: String = str(params.get("says", ""))
|
|
352
|
+
var windows: Array = result.get("windows", [])
|
|
353
|
+
if not under.is_empty() or not said.is_empty():
|
|
354
|
+
var matched: int = 0
|
|
355
|
+
for window: Variant in windows:
|
|
356
|
+
if not window is Dictionary:
|
|
357
|
+
continue
|
|
358
|
+
var holder: Dictionary = window
|
|
359
|
+
if _matches(holder, under, said):
|
|
360
|
+
matched += 1
|
|
361
|
+
continue
|
|
362
|
+
var kept: Array = []
|
|
363
|
+
_outermost(holder, under, said, kept)
|
|
364
|
+
holder["children"] = kept
|
|
365
|
+
matched += kept.size()
|
|
366
|
+
result["matched"] = matched
|
|
367
|
+
var fields: Array = params.get("properties", [])
|
|
368
|
+
if not fields.is_empty():
|
|
369
|
+
for window: Variant in windows:
|
|
370
|
+
if window is Dictionary:
|
|
371
|
+
var holder: Dictionary = window
|
|
372
|
+
_keep_only(holder, fields)
|
|
373
|
+
var limit: int = Read.as_int(params.get("limit", 0), 0)
|
|
374
|
+
if limit > 0:
|
|
375
|
+
var left: Array[int] = [limit]
|
|
376
|
+
var omitted: Array[int] = [0]
|
|
377
|
+
for window: Variant in windows:
|
|
378
|
+
if window is Dictionary:
|
|
379
|
+
var holder: Dictionary = window
|
|
380
|
+
_cut(holder, left, omitted)
|
|
381
|
+
if omitted[0] > 0:
|
|
382
|
+
result["omitted"] = omitted[0]
|
|
383
|
+
var counted: int = 0
|
|
384
|
+
for window: Variant in windows:
|
|
385
|
+
if window is Dictionary:
|
|
386
|
+
var holder: Dictionary = window
|
|
387
|
+
counted += _count(holder)
|
|
388
|
+
result["elements"] = counted
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
## Whether [param element] is made by the node at [param under] or one below it, and says
|
|
392
|
+
## [param said], each where asked: words as find matches them, in the element's name or value.
|
|
393
|
+
static func _matches(element: Dictionary, under: String, said: String) -> bool:
|
|
394
|
+
if not under.is_empty():
|
|
395
|
+
var path: String = str(element.get("node", element.get("part_of", "")))
|
|
396
|
+
if path != under and not path.begins_with(under + "/"):
|
|
397
|
+
return false
|
|
398
|
+
if not said.is_empty():
|
|
399
|
+
var name: String = str(element.get("name", ""))
|
|
400
|
+
var value: String = str(element.get("value", ""))
|
|
401
|
+
if not Says.matches(name, said) and not Says.matches(value, said):
|
|
402
|
+
return false
|
|
403
|
+
return true
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
static func _outermost(element: Dictionary, under: String, said: String, kept: Array) -> void:
|
|
407
|
+
var children: Array = element.get("children", [])
|
|
408
|
+
for child: Variant in children:
|
|
409
|
+
if not child is Dictionary:
|
|
410
|
+
continue
|
|
411
|
+
var inner: Dictionary = child
|
|
412
|
+
if _matches(inner, under, said):
|
|
413
|
+
kept.append(inner)
|
|
414
|
+
else:
|
|
415
|
+
_outermost(inner, under, said, kept)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
## Leaves [param element] and everything under it only the [param fields] asked for, and the
|
|
419
|
+
## children that hold the rest of the tree.
|
|
420
|
+
static func _keep_only(element: Dictionary, fields: Array) -> void:
|
|
421
|
+
for key: Variant in element.keys():
|
|
422
|
+
if key != "children" and not fields.has(key):
|
|
423
|
+
var _dropped: bool = element.erase(key)
|
|
424
|
+
var children: Array = element.get("children", [])
|
|
425
|
+
for child: Variant in children:
|
|
426
|
+
if child is Dictionary:
|
|
427
|
+
var inner: Dictionary = child
|
|
428
|
+
_keep_only(inner, fields)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
## Keeps the first [param left] elements below [param element], depth first, and counts the ones
|
|
432
|
+
## cut into [param omitted]. One-element arrays, so the count carries across windows.
|
|
433
|
+
static func _cut(element: Dictionary, left: Array[int], omitted: Array[int]) -> void:
|
|
434
|
+
var children: Array = element.get("children", [])
|
|
435
|
+
var kept: Array = []
|
|
436
|
+
for child: Variant in children:
|
|
437
|
+
if not child is Dictionary:
|
|
438
|
+
continue
|
|
439
|
+
var inner: Dictionary = child
|
|
440
|
+
if left[0] <= 0:
|
|
441
|
+
omitted[0] += _count(inner)
|
|
442
|
+
continue
|
|
443
|
+
left[0] -= 1
|
|
444
|
+
kept.append(inner)
|
|
445
|
+
_cut(inner, left, omitted)
|
|
446
|
+
element["children"] = kept
|
|
447
|
+
|
|
448
|
+
|
|
259
449
|
## The project's accessibility mode, as the command line's --accessibility names them.
|
|
260
450
|
static func _setting() -> String:
|
|
261
451
|
match Read.as_int(ProjectSettings.get_setting("accessibility/general/accessibility_support", 0), 0):
|
|
@@ -276,8 +466,188 @@ static func _count(element: Dictionary) -> int:
|
|
|
276
466
|
return total
|
|
277
467
|
|
|
278
468
|
|
|
279
|
-
##
|
|
280
|
-
##
|
|
469
|
+
## Every node's element by its id, for the walk's elements to be read back as the node that made
|
|
470
|
+
## them. Internal children too: a dialog's buttons are elements like any other.
|
|
471
|
+
static func _note_elements(node: Node, made_by: Dictionary) -> void:
|
|
472
|
+
var element: RID = node.get_accessibility_element()
|
|
473
|
+
if element.is_valid():
|
|
474
|
+
made_by[element.get_id()] = node
|
|
475
|
+
for child: Node in node.get_children(true):
|
|
476
|
+
_note_elements(child, made_by)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
static func _window_with(node: Node, window_id: int) -> Window:
|
|
480
|
+
if node is Window:
|
|
481
|
+
var window: Window = node
|
|
482
|
+
if window.get_window_id() == window_id:
|
|
483
|
+
return window
|
|
484
|
+
for child: Node in node.get_children(true):
|
|
485
|
+
var found: Window = _window_with(child, window_id)
|
|
486
|
+
if found != null:
|
|
487
|
+
return found
|
|
488
|
+
return null
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
## Gives every element below [param element] whose id is a node's element that node, and notes
|
|
492
|
+
## which nodes UI Automation shows.
|
|
493
|
+
##
|
|
494
|
+
## UI Automation's runtime id for an element ends in the four halves of AccessKit's id for it, and
|
|
495
|
+
## Godot gives AccessKit the element's RID: measured on 4.7.2, /root/Main's element answered
|
|
496
|
+
## 114,1,0,0 with RID 114 << 32 | 1. Which pair holds it is not assumed, so both are tried.
|
|
497
|
+
static func _match(element: Dictionary, made_by: Dictionary, placing: Placing) -> void:
|
|
498
|
+
var children: Array = element.get("children", [])
|
|
499
|
+
for child: Variant in children:
|
|
500
|
+
if not child is Dictionary:
|
|
501
|
+
continue
|
|
502
|
+
var inner: Dictionary = child
|
|
503
|
+
var parts: Array = inner.get("element_id", [])
|
|
504
|
+
var _dropped: bool = inner.erase("element_id")
|
|
505
|
+
if parts.size() == 4:
|
|
506
|
+
placing.asked += 1
|
|
507
|
+
var node: Node = _made(parts, made_by)
|
|
508
|
+
if node != null:
|
|
509
|
+
placing.placed += 1
|
|
510
|
+
placing.shown[node.get_instance_id()] = true
|
|
511
|
+
inner["node"] = str(node.get_path())
|
|
512
|
+
inner[NODE_ID] = node.get_instance_id()
|
|
513
|
+
if node is Control:
|
|
514
|
+
var control: Control = node
|
|
515
|
+
if not control.tooltip_text.is_empty():
|
|
516
|
+
inner["tooltip"] = control.tooltip_text
|
|
517
|
+
placing.tooltips += 1
|
|
518
|
+
_match(inner, made_by, placing)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
## Gives every element below [param element] that is no node's own the node it is a part of.
|
|
522
|
+
##
|
|
523
|
+
## UI Automation's parent is not that node: AccessKit leaves every container out of the tree it
|
|
524
|
+
## gives UI Automation and hangs what the container held on the nearest element it keeps, and a
|
|
525
|
+
## Control, a RichTextLabel and each of its lines are containers. A RichTextLabel's text in a
|
|
526
|
+
## column of plain containers came out straight under the window. So the node is found where it
|
|
527
|
+
## must be: below the shown element's node, itself left out of the tree (a node that is shown would
|
|
528
|
+
## have the part under it), drawn around the part, and, for text, holding its words.
|
|
529
|
+
static func _attribute(element: Dictionary, holder: Node, placing: Placing, is_window: bool) -> void:
|
|
530
|
+
var children: Array = element.get("children", [])
|
|
531
|
+
for child: Variant in children:
|
|
532
|
+
if not child is Dictionary:
|
|
533
|
+
continue
|
|
534
|
+
var inner: Dictionary = child
|
|
535
|
+
if inner.has(NODE_ID):
|
|
536
|
+
var node: Node = instance_from_id(Read.as_int(inner[NODE_ID], 0))
|
|
537
|
+
var _dropped: bool = inner.erase(NODE_ID)
|
|
538
|
+
_attribute(inner, node if node != null else holder, placing, false)
|
|
539
|
+
continue
|
|
540
|
+
var owners: Array[Node] = _owners_of(inner, holder, placing)
|
|
541
|
+
if owners.size() == 1:
|
|
542
|
+
inner["part_of"] = str(owners[0].get_path())
|
|
543
|
+
else:
|
|
544
|
+
inner["part_of"] = str(holder.get_path())
|
|
545
|
+
var paths: Array[String] = []
|
|
546
|
+
for owner: Node in owners:
|
|
547
|
+
paths.append(str(owner.get_path()))
|
|
548
|
+
inner["part_of_candidates"] = paths
|
|
549
|
+
# The one text a window makes of itself, where it speaks what is announced: it is given no
|
|
550
|
+
# bounds, where every text a node draws has them.
|
|
551
|
+
var only_holder: bool = owners.size() == 1 and owners[0] == holder
|
|
552
|
+
if is_window and inner.get("role") == "text" and not inner.has("bounds") and only_holder:
|
|
553
|
+
inner["part"] = "announcement"
|
|
554
|
+
_attribute(inner, owners[0] if owners.size() == 1 else holder, placing, false)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
## The nodes that may have made [param element], below [param holder]: the deepest ones drawn
|
|
558
|
+
## around it whose own elements are not shown, narrowed to those holding its words when that leaves
|
|
559
|
+
## any. [param holder] alone when none is drawn around it, or when it has no bounds to look by.
|
|
560
|
+
static func _owners_of(element: Dictionary, holder: Node, placing: Placing) -> Array[Node]:
|
|
561
|
+
var bounds: Variant = element.get("bounds")
|
|
562
|
+
if not bounds is Dictionary:
|
|
563
|
+
return [holder]
|
|
564
|
+
var box: Dictionary = bounds
|
|
565
|
+
var drawn: Rect2 = Rect2(
|
|
566
|
+
Read.as_float(box.get("x", 0)),
|
|
567
|
+
Read.as_float(box.get("y", 0)),
|
|
568
|
+
Read.as_float(box.get("width", 0)),
|
|
569
|
+
Read.as_float(box.get("height", 0))
|
|
570
|
+
)
|
|
571
|
+
var found: Array[Node] = []
|
|
572
|
+
_drawn_around(holder, drawn, placing, found)
|
|
573
|
+
if found.is_empty():
|
|
574
|
+
return [holder]
|
|
575
|
+
# A RichTextLabel's line ends in a zero-width space its text does not have.
|
|
576
|
+
var words: String = str(element.get("name", "")).replace(String.chr(0x200b), "").strip_edges()
|
|
577
|
+
if found.size() > 1 and not words.is_empty():
|
|
578
|
+
var saying: Array[Node] = []
|
|
579
|
+
for node: Node in found:
|
|
580
|
+
if _text_of(node).contains(words):
|
|
581
|
+
saying.append(node)
|
|
582
|
+
if not saying.is_empty():
|
|
583
|
+
return saying
|
|
584
|
+
return found
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
## Where a Control is drawn is read off its screen transform, which for a window that is not
|
|
588
|
+
## embedded is in that window's own pixels, as the bounds are.
|
|
589
|
+
static func _drawn_around(node: Node, drawn: Rect2, placing: Placing, found: Array[Node]) -> void:
|
|
590
|
+
for child: Node in node.get_children(true):
|
|
591
|
+
# A node that is shown has its parts under it, and so do the nodes below it.
|
|
592
|
+
if placing.shown.has(child.get_instance_id()):
|
|
593
|
+
continue
|
|
594
|
+
if child is CanvasItem:
|
|
595
|
+
var item: CanvasItem = child
|
|
596
|
+
if not item.is_visible_in_tree():
|
|
597
|
+
continue
|
|
598
|
+
var before: int = found.size()
|
|
599
|
+
_drawn_around(child, drawn, placing, found)
|
|
600
|
+
if found.size() > before or not child is Control:
|
|
601
|
+
continue
|
|
602
|
+
var control: Control = child
|
|
603
|
+
var around: Rect2 = control.get_screen_transform() * Rect2(Vector2.ZERO, control.size)
|
|
604
|
+
# Two pixels either way, for the rounding of bounds to whole pixels on both sides.
|
|
605
|
+
if around.grow(2.0).encloses(drawn):
|
|
606
|
+
found.append(control)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
## Takes the instance ids [method _match] left out of a window no node holds.
|
|
610
|
+
static func _unmark(element: Dictionary) -> void:
|
|
611
|
+
var _dropped: bool = element.erase(NODE_ID)
|
|
612
|
+
var children: Array = element.get("children", [])
|
|
613
|
+
for child: Variant in children:
|
|
614
|
+
if child is Dictionary:
|
|
615
|
+
var inner: Dictionary = child
|
|
616
|
+
_unmark(inner)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
static func _text_of(node: Node) -> String:
|
|
620
|
+
if node is RichTextLabel:
|
|
621
|
+
var rich: RichTextLabel = node
|
|
622
|
+
return rich.get_parsed_text()
|
|
623
|
+
if "text" in node:
|
|
624
|
+
return str(node.get("text"))
|
|
625
|
+
return ""
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
static func _made(parts: Array, made_by: Dictionary) -> Node:
|
|
629
|
+
var halves: Array[int] = []
|
|
630
|
+
for part: Variant in parts:
|
|
631
|
+
halves.append(Read.as_int(part, 0) & 0xFFFFFFFF)
|
|
632
|
+
for at: int in [0, 2]:
|
|
633
|
+
var id: int = (halves[at] << 32) | halves[at + 1]
|
|
634
|
+
if made_by.has(id):
|
|
635
|
+
return made_by[id]
|
|
636
|
+
return null
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
## Counts kept while the elements are placed, and the nodes whose own elements UI Automation shows,
|
|
640
|
+
## by instance id.
|
|
641
|
+
class Placing:
|
|
642
|
+
extends RefCounted
|
|
643
|
+
var asked: int = 0
|
|
644
|
+
var placed: int = 0
|
|
645
|
+
var tooltips: int = 0
|
|
646
|
+
var shown: Dictionary = {}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
## Text elements with bounds and no name. The window's announcement element has no bounds and is
|
|
650
|
+
## left out with them: it is empty until something is announced.
|
|
281
651
|
static func _unnamed_text(element: Dictionary) -> int:
|
|
282
652
|
var total: int = 0
|
|
283
653
|
if element.get("role") == "text" and str(element.get("name", "")).is_empty() and element.has("bounds"):
|
|
@@ -32,15 +32,16 @@ func set_property(params: Dictionary) -> Dictionary:
|
|
|
32
32
|
if node_path.is_empty() or property.is_empty():
|
|
33
33
|
return {"type": "error", "message": "Node path and property required"}
|
|
34
34
|
|
|
35
|
-
var standing: Dictionary = Values.
|
|
35
|
+
var standing: Dictionary = Values.target_at(_host.get_tree().root, node_path)
|
|
36
36
|
if standing.has("message"):
|
|
37
37
|
return standing
|
|
38
38
|
var node: Node = standing["node"]
|
|
39
|
+
var target: Object = standing["object"]
|
|
39
40
|
|
|
40
41
|
# Through a path as well, for the reason [method Paths.walk_to] gives, and read back off the same
|
|
41
42
|
# holder afterwards: a set that does not take says so by answering with the old value, which is
|
|
42
43
|
# how a typed container refusing a write is told from one accepting it.
|
|
43
|
-
var reached: Dictionary = Paths.walk_to(
|
|
44
|
+
var reached: Dictionary = Paths.walk_to(target, node_path, property)
|
|
44
45
|
if reached.has("message"):
|
|
45
46
|
return reached
|
|
46
47
|
|
|
@@ -119,11 +120,28 @@ func set_property(params: Dictionary) -> Dictionary:
|
|
|
119
120
|
"%s.%s is a typed %s: %s." % [reached["called"], named, _sort_of(old_value), typed["message"]]
|
|
120
121
|
}
|
|
121
122
|
given = typed["value"]
|
|
123
|
+
# A component of a vector or a transform is written into a copy, which is put back where it was
|
|
124
|
+
# read from, setter and all; the read back is then taken off the game again, since the copy here
|
|
125
|
+
# says only what was written into it.
|
|
126
|
+
var chain: Array[Dictionary] = reached["chain"]
|
|
127
|
+
var copied: bool = Paths.composite(holder)
|
|
128
|
+
if copied:
|
|
129
|
+
var unwritable: String = Paths.unwritable_copy(chain)
|
|
130
|
+
if not unwritable.is_empty():
|
|
131
|
+
return {"type": "error", "message": unwritable}
|
|
122
132
|
# The write alone, setter included, which is where a property's cost is.
|
|
123
133
|
var began: int = Time.get_ticks_usec()
|
|
124
|
-
Paths.write(holder, named, given)
|
|
134
|
+
var written: Variant = Paths.write(holder, named, given)
|
|
135
|
+
if copied:
|
|
136
|
+
Paths.write_back(chain, written)
|
|
125
137
|
var elapsed: int = Time.get_ticks_usec() - began
|
|
126
138
|
var now: Variant = Paths.read_under(holder, named)
|
|
139
|
+
if copied:
|
|
140
|
+
var again: Dictionary = Paths.walk_to(target, node_path, property)
|
|
141
|
+
if again.has("message"):
|
|
142
|
+
return again
|
|
143
|
+
var reread: Variant = again["holder"]
|
|
144
|
+
now = Paths.read_under(reread, named) if Paths.can_read(reread, named) else null
|
|
127
145
|
# Read back rather than trusted. The engine drops a write it will not take without a word, and
|
|
128
146
|
# the answer then showed the old value as the new one, shaped as a success: a typed container
|
|
129
147
|
# refusing a plain list did exactly that. A value the property changed on the way in, a setter
|
|
@@ -211,15 +229,16 @@ func call_method(params: Dictionary) -> Dictionary:
|
|
|
211
229
|
if node_path.is_empty() or method.is_empty():
|
|
212
230
|
return {"type": "error", "message": "Node path and method required"}
|
|
213
231
|
|
|
214
|
-
var standing: Dictionary = Values.
|
|
232
|
+
var standing: Dictionary = Values.target_at(_host.get_tree().root, node_path)
|
|
215
233
|
if standing.has("message"):
|
|
216
234
|
return standing
|
|
217
235
|
var node: Node = standing["node"]
|
|
236
|
+
var target: Object = standing["object"]
|
|
218
237
|
|
|
219
238
|
# Through a path as well, for the reason [method Paths.walk_to] gives. What a game does hangs off its
|
|
220
239
|
# nodes as much as its state does, so reading `_game:run:day` while being unable to call
|
|
221
240
|
# `_game:run:advance` answers half of what a node holds and refuses the other half.
|
|
222
|
-
var reached: Dictionary = Paths.walk_to(
|
|
241
|
+
var reached: Dictionary = Paths.walk_to(target, node_path, method)
|
|
223
242
|
if reached.has("message"):
|
|
224
243
|
return reached
|
|
225
244
|
|
|
@@ -461,8 +480,11 @@ static func _walk_from(object: Object, path: String) -> Dictionary:
|
|
|
461
480
|
var holder: Variant = object
|
|
462
481
|
var called: String = "the result"
|
|
463
482
|
for named: String in Paths.steps_of(path):
|
|
464
|
-
if not
|
|
465
|
-
return {
|
|
483
|
+
if not Paths.can_hold(holder):
|
|
484
|
+
return {
|
|
485
|
+
"message":
|
|
486
|
+
"%s holds %s, which has nothing to read %s off" % [called, type_string(typeof(holder)), named]
|
|
487
|
+
}
|
|
466
488
|
if not Paths.can_read(holder, named):
|
|
467
489
|
return {"message": Paths.nothing_there(holder, named, called)}
|
|
468
490
|
holder = Paths.read_under(holder, named)
|
|
@@ -122,12 +122,16 @@ func inject_action(params: Dictionary) -> Dictionary:
|
|
|
122
122
|
|
|
123
123
|
if not InputMap.has_action(action):
|
|
124
124
|
return {"type": "error", "message": "Action not found: " + action}
|
|
125
|
+
var holding: Variant = Values.held_for(params)
|
|
126
|
+
if holding is String:
|
|
127
|
+
return {"type": "error", "message": holding}
|
|
128
|
+
var frames: int = holding
|
|
125
129
|
|
|
126
130
|
_say_action(action, whole or held, strength)
|
|
127
131
|
if whole:
|
|
128
|
-
# A frame between the halves, as a click has: a listener that acts on the press and
|
|
129
|
-
# listener that acts on the release both get their own frame to do it in.
|
|
130
|
-
await
|
|
132
|
+
# A frame between the halves at least, as a click has: a listener that acts on the press and
|
|
133
|
+
# a listener that acts on the release both get their own frame to do it in.
|
|
134
|
+
await _held(frames)
|
|
131
135
|
_say_action(action, false, strength)
|
|
132
136
|
await _host.get_tree().process_frame
|
|
133
137
|
|
|
@@ -137,9 +141,16 @@ func inject_action(params: Dictionary) -> Dictionary:
|
|
|
137
141
|
"action": action,
|
|
138
142
|
"pressed": held and not whole,
|
|
139
143
|
"whole": whole,
|
|
144
|
+
"held_frames": Values.held_answer(whole, frames),
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
|
|
148
|
+
## Waits out [param frames] process frames, which is how long a whole press is held down.
|
|
149
|
+
func _held(frames: int) -> void:
|
|
150
|
+
for _frame: int in frames:
|
|
151
|
+
await _host.get_tree().process_frame
|
|
152
|
+
|
|
153
|
+
|
|
143
154
|
func _say_action(action: String, down: bool, strength: float) -> void:
|
|
144
155
|
var event: InputEventAction = InputEventAction.new()
|
|
145
156
|
event.action = action
|
|
@@ -160,6 +171,10 @@ func inject_key(params: Dictionary) -> Dictionary:
|
|
|
160
171
|
if not named.is_empty() and key_label.is_empty():
|
|
161
172
|
key_label = named
|
|
162
173
|
var keycode: int = 0 if keycode_raw is String else Read.as_int(keycode_raw)
|
|
174
|
+
var holding: Variant = Values.held_for(params)
|
|
175
|
+
if holding is String:
|
|
176
|
+
return {"type": "error", "message": holding}
|
|
177
|
+
var frames: int = holding
|
|
163
178
|
|
|
164
179
|
var event: InputEventKey = InputEventKey.new()
|
|
165
180
|
event.pressed = whole or held
|
|
@@ -193,7 +208,7 @@ func inject_key(params: Dictionary) -> Dictionary:
|
|
|
193
208
|
|
|
194
209
|
Input.parse_input_event(event)
|
|
195
210
|
if whole:
|
|
196
|
-
await
|
|
211
|
+
await _held(frames)
|
|
197
212
|
var up: InputEventKey = event.duplicate()
|
|
198
213
|
up.pressed = false
|
|
199
214
|
Input.parse_input_event(up)
|
|
@@ -202,6 +217,7 @@ func inject_key(params: Dictionary) -> Dictionary:
|
|
|
202
217
|
return {
|
|
203
218
|
"type": "input_injected",
|
|
204
219
|
"input_type": "key",
|
|
220
|
+
"held_frames": Values.held_answer(whole, frames),
|
|
205
221
|
"keycode": event.keycode,
|
|
206
222
|
"physical_keycode": event.physical_keycode,
|
|
207
223
|
"shift": event.shift_pressed,
|
|
@@ -266,6 +282,15 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
|
|
|
266
282
|
# than under the pointer, landing true and doing nothing, until a release was sent by hand.
|
|
267
283
|
var notch: bool = held and _mask_of(button) == 0
|
|
268
284
|
var whole: bool = notch or not params.has("pressed")
|
|
285
|
+
var holding: Variant = Values.held_for(params)
|
|
286
|
+
if holding is String:
|
|
287
|
+
return {"type": "error", "message": holding}
|
|
288
|
+
var frames: int = holding
|
|
289
|
+
if notch and params.has("holdFrames"):
|
|
290
|
+
return {
|
|
291
|
+
"type": "error",
|
|
292
|
+
"message": "A wheel step is pressed and let go at once, so holdFrames cannot hold one."
|
|
293
|
+
}
|
|
269
294
|
|
|
270
295
|
# A button pressed somewhere puts the pointer there: the motion after a held press measures
|
|
271
296
|
# its drag from the press, which is where a real pointer would be.
|
|
@@ -274,9 +299,9 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
|
|
|
274
299
|
if notch:
|
|
275
300
|
Input.parse_input_event(_button(position, button, false, false))
|
|
276
301
|
elif whole:
|
|
277
|
-
# A frame between the halves, as inject_action has, so a control that acts on the
|
|
278
|
-
# one that acts on the release each get a frame to do it in.
|
|
279
|
-
await
|
|
302
|
+
# A frame between the halves at least, as inject_action has, so a control that acts on the
|
|
303
|
+
# press and one that acts on the release each get a frame to do it in.
|
|
304
|
+
await _held(frames)
|
|
280
305
|
Input.parse_input_event(_button(position, button, false, false))
|
|
281
306
|
await _host.get_tree().process_frame
|
|
282
307
|
|
|
@@ -284,6 +309,7 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
|
|
|
284
309
|
{
|
|
285
310
|
"type": "input_injected",
|
|
286
311
|
"input_type": "mouse_click",
|
|
312
|
+
"held_frames": Values.held_answer(whole and not notch, frames),
|
|
287
313
|
"position": [position.x, position.y],
|
|
288
314
|
"button": button,
|
|
289
315
|
"pressed": held and not whole,
|
|
@@ -9,6 +9,7 @@ extends RefCounted
|
|
|
9
9
|
## calling the game's own methods. The engine still fires the actions bound to a button from these.
|
|
10
10
|
|
|
11
11
|
const Read = preload("reading.gd")
|
|
12
|
+
const Values = preload("runtime_values.gd")
|
|
12
13
|
|
|
13
14
|
## Controller buttons by the names Godot gives them, for a caller naming one rather than numbering it.
|
|
14
15
|
const JOY_BUTTONS: Dictionary[String, int] = {
|
|
@@ -62,18 +63,24 @@ func inject_joypad_button(params: Dictionary) -> Dictionary:
|
|
|
62
63
|
var device: int = Read.as_int(params.get("device", 0), 0)
|
|
63
64
|
var held: bool = Read.as_bool(params.get("pressed", true), true)
|
|
64
65
|
var whole: bool = not params.has("pressed")
|
|
66
|
+
var holding: Variant = Values.held_for(params)
|
|
67
|
+
if holding is String:
|
|
68
|
+
return {"type": "error", "message": holding}
|
|
69
|
+
var frames: int = holding
|
|
65
70
|
|
|
66
71
|
Input.parse_input_event(_button(button, device, whole or held))
|
|
67
72
|
if whole:
|
|
68
|
-
# A frame between the halves, as a key has, so a game that acts on the press and one
|
|
69
|
-
# acts on the release each get a frame to do it in.
|
|
70
|
-
|
|
73
|
+
# A frame between the halves at least, as a key has, so a game that acts on the press and one
|
|
74
|
+
# that acts on the release each get a frame to do it in.
|
|
75
|
+
for _frame: int in frames:
|
|
76
|
+
await _host.get_tree().process_frame
|
|
71
77
|
Input.parse_input_event(_button(button, device, false))
|
|
72
78
|
await _host.get_tree().process_frame
|
|
73
79
|
|
|
74
80
|
return {
|
|
75
81
|
"type": "input_injected",
|
|
76
82
|
"input_type": "joypad_button",
|
|
83
|
+
"held_frames": Values.held_answer(whole, frames),
|
|
77
84
|
"button": button,
|
|
78
85
|
"button_name": _name_of(button, JOY_BUTTONS),
|
|
79
86
|
"device": device,
|