gdharness 1.2.28 → 1.2.30
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 +395 -169
- package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +259 -31
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +168 -98
- 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 +3 -3
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +142 -0
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +1 -13
- package/build/index.js +384 -169
- 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
|
|
@@ -170,12 +174,27 @@ func _init(host: Node) -> void:
|
|
|
170
174
|
|
|
171
175
|
|
|
172
176
|
## The game's windows as a screen reader is told them, each a tree of elements.
|
|
173
|
-
func get_accessibility(
|
|
177
|
+
func get_accessibility(params: Dictionary) -> Dictionary:
|
|
174
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
|
+
)
|
|
175
191
|
return _refused(
|
|
176
192
|
(
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
179
198
|
)
|
|
180
199
|
)
|
|
181
200
|
if OS.get_name() != "Windows":
|
|
@@ -255,12 +274,16 @@ func get_accessibility(_params: Dictionary) -> Dictionary:
|
|
|
255
274
|
var window: Dictionary = windows[at]
|
|
256
275
|
var window_id: int = ids[at] if at < ids.size() else -1
|
|
257
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"]
|
|
258
281
|
var holder: Window = _window_with(_host.get_tree().root, window_id)
|
|
259
|
-
var holder_path: String = ""
|
|
260
282
|
if holder != null:
|
|
261
|
-
|
|
262
|
-
window
|
|
263
|
-
|
|
283
|
+
window["node"] = str(holder.get_path())
|
|
284
|
+
_attribute(window, holder, placing, true)
|
|
285
|
+
else:
|
|
286
|
+
_unmark(window)
|
|
264
287
|
elements += _count(window)
|
|
265
288
|
unnamed += _unnamed_text(window)
|
|
266
289
|
var result: Dictionary = {"windows": windows, "elements": elements}
|
|
@@ -315,9 +338,114 @@ func get_accessibility(_params: Dictionary) -> Dictionary:
|
|
|
315
338
|
)
|
|
316
339
|
% unnamed
|
|
317
340
|
)
|
|
341
|
+
_narrow(result, params)
|
|
318
342
|
return result
|
|
319
343
|
|
|
320
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
|
+
|
|
321
449
|
## The project's accessibility mode, as the command line's --accessibility names them.
|
|
322
450
|
static func _setting() -> String:
|
|
323
451
|
match Read.as_int(ProjectSettings.get_setting("accessibility/general/accessibility_support", 0), 0):
|
|
@@ -360,15 +488,13 @@ static func _window_with(node: Node, window_id: int) -> Window:
|
|
|
360
488
|
return null
|
|
361
489
|
|
|
362
490
|
|
|
363
|
-
## Gives
|
|
364
|
-
##
|
|
491
|
+
## Gives every element below [param element] whose id is a node's element that node, and notes
|
|
492
|
+
## which nodes UI Automation shows.
|
|
365
493
|
##
|
|
366
494
|
## UI Automation's runtime id for an element ends in the four halves of AccessKit's id for it, and
|
|
367
495
|
## Godot gives AccessKit the element's RID: measured on 4.7.2, /root/Main's element answered
|
|
368
496
|
## 114,1,0,0 with RID 114 << 32 | 1. Which pair holds it is not assumed, so both are tried.
|
|
369
|
-
static func
|
|
370
|
-
element: Dictionary, holder: String, made_by: Dictionary, placing: Placing, is_window: bool
|
|
371
|
-
) -> void:
|
|
497
|
+
static func _match(element: Dictionary, made_by: Dictionary, placing: Placing) -> void:
|
|
372
498
|
var children: Array = element.get("children", [])
|
|
373
499
|
for child: Variant in children:
|
|
374
500
|
if not child is Dictionary:
|
|
@@ -376,27 +502,127 @@ static func _place(
|
|
|
376
502
|
var inner: Dictionary = child
|
|
377
503
|
var parts: Array = inner.get("element_id", [])
|
|
378
504
|
var _dropped: bool = inner.erase("element_id")
|
|
379
|
-
var node: Node = null
|
|
380
505
|
if parts.size() == 4:
|
|
381
506
|
placing.asked += 1
|
|
382
|
-
node = _made(parts, made_by)
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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())
|
|
393
543
|
else:
|
|
394
|
-
inner["part_of"] = holder
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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 ""
|
|
400
626
|
|
|
401
627
|
|
|
402
628
|
static func _made(parts: Array, made_by: Dictionary) -> Node:
|
|
@@ -410,12 +636,14 @@ static func _made(parts: Array, made_by: Dictionary) -> Node:
|
|
|
410
636
|
return null
|
|
411
637
|
|
|
412
638
|
|
|
413
|
-
## Counts kept while the elements are placed
|
|
639
|
+
## Counts kept while the elements are placed, and the nodes whose own elements UI Automation shows,
|
|
640
|
+
## by instance id.
|
|
414
641
|
class Placing:
|
|
415
642
|
extends RefCounted
|
|
416
643
|
var asked: int = 0
|
|
417
644
|
var placed: int = 0
|
|
418
645
|
var tooltips: int = 0
|
|
646
|
+
var shown: Dictionary = {}
|
|
419
647
|
|
|
420
648
|
|
|
421
649
|
## Text elements with bounds and no name. The window's announcement element has no bounds and is
|