gdharness 1.1.21 → 1.1.22

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 (47) hide show
  1. package/build/cli.js +1036 -525
  2. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  3. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  4. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  6. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  7. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  8. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  9. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  10. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  13. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  14. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  15. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  17. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +8 -4
  19. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  20. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  22. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +28 -8
  23. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +7 -299
  24. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  25. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  26. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  27. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  28. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  29. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +20 -9
  30. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  31. package/build/godot/operations/audio_buses.gd +47 -11
  32. package/build/godot/operations/class_cache.gd +18 -2
  33. package/build/godot/operations/dependencies.gd +128 -17
  34. package/build/godot/operations/file_walk.gd +20 -0
  35. package/build/godot/operations/gdscript_analysis.gd +59 -20
  36. package/build/godot/operations/gdscript_authoring.gd +127 -60
  37. package/build/godot/operations/godot_operations.gd +24 -5
  38. package/build/godot/operations/import_pipeline.gd +179 -19
  39. package/build/godot/operations/logger.gd +2 -2
  40. package/build/godot/operations/plugins.gd +88 -70
  41. package/build/godot/operations/project_config.gd +41 -6
  42. package/build/godot/operations/project_diagnostics.gd +25 -10
  43. package/build/godot/operations/reading.gd +17 -0
  44. package/build/godot/operations/resource_files.gd +60 -17
  45. package/build/godot/operations/serialisation.gd +116 -7
  46. package/build/index.js +949 -435
  47. package/package.json +1 -1
@@ -198,7 +198,7 @@ static func can_read(holder: Variant, named: String) -> bool:
198
198
  var map: Dictionary = holder
199
199
  if not method_of(named).is_empty():
200
200
  return method_of(named) in MAP_CALLS
201
- return map.has(named) or map.has(StringName(named))
201
+ return not _key_in(map, named).is_empty()
202
202
  if holder is Object:
203
203
  var object: Object = holder
204
204
  var method: String = method_of(named)
@@ -208,6 +208,23 @@ static func can_read(holder: Variant, named: String) -> bool:
208
208
  return false
209
209
 
210
210
 
211
+ ## The key of [param map] a step names, in a list of one, or an empty list when it names none.
212
+ ##
213
+ ## A step is text, and a map is keyed by whatever the game put in it: text, a StringName, or a
214
+ ## number, which a map of units by id is. Keyed by 3, "3" is not a key of it, measured on 4.7.2, so a
215
+ ## step that reads as a number is tried as one after the two spellings of text.
216
+ static func _key_in(map: Dictionary, named: String) -> Array:
217
+ var spellings: Array = [named, StringName(named)]
218
+ if named.is_valid_int():
219
+ spellings.append(int(named))
220
+ if named.is_valid_float():
221
+ spellings.append(float(named))
222
+ for key: Variant in spellings:
223
+ if map.has(key):
224
+ return [key]
225
+ return []
226
+
227
+
211
228
  ## The method a step written as a call names, "get_viewport()" being get_viewport, or "" for a
212
229
  ## step that is a name.
213
230
  static func method_of(named: String) -> String:
@@ -243,10 +260,7 @@ static func write(holder: Variant, named: String, value: Variant) -> void:
243
260
  var map: Dictionary = holder
244
261
  # Written back under the key it was found under, since a map keyed by StringName and one
245
262
  # keyed by String both read the same way and a write to the wrong one adds a second entry.
246
- if map.has(named):
247
- map[named] = value
248
- else:
249
- map[StringName(named)] = value
263
+ map[_key_in(map, named)[0]] = value
250
264
  return
251
265
  var object: Object = holder
252
266
  object.set(named, value)
@@ -265,9 +279,7 @@ static func read_under(holder: Variant, named: String) -> Variant:
265
279
  return list[_index_in(named, len(holder))]
266
280
  if holder is Dictionary:
267
281
  var map: Dictionary = holder
268
- if map.has(named):
269
- return map[named]
270
- return map[StringName(named)]
282
+ return map[_key_in(map, named)[0]]
271
283
  var object: Object = holder
272
284
  var method: String = method_of(named)
273
285
  if not method.is_empty():
@@ -311,6 +323,14 @@ static func nothing_there(holder: Variant, named: String, called_as: String) ->
311
323
  return container_calls_refused(called_as, "a map", container_call)
312
324
  var map: Dictionary = holder
313
325
  var keys: Array = map.keys()
326
+ # A key reading as the step and still not reached is one of a type a step cannot spell, and
327
+ # saying the map has no key 3 while listing 3 among its keys contradicts itself.
328
+ for key: Variant in keys:
329
+ if str(key) == named:
330
+ return (
331
+ "%s has a key that reads %s, but it is %s, which a path step cannot name"
332
+ % [called_as, named, type_string(typeof(key))]
333
+ )
314
334
  # Eight of them, because a map keyed by something unexpected is told by the first few and a
315
335
  # map of two hundred would bury the sentence saying which key was missing.
316
336
  var some: Array[String] = []
@@ -1,7 +1,8 @@
1
1
  extends RefCounted
2
2
 
3
3
  ## What the server asks about the running tree: its shape, one node, the nodes matching a
4
- ## question, where one is on screen, a property set, a method called, the metrics.
4
+ ## question, where one is on screen, a property's value, the metrics. What it changes, a property
5
+ ## written or a method called, is in runtime_changes.gd.
5
6
 
6
7
  const Paths = preload("runtime_paths.gd")
7
8
  const Read = preload("reading.gd")
@@ -360,6 +361,11 @@ static func _drawn(node: Node) -> bool:
360
361
  var control: CanvasItem = node as CanvasItem
361
362
  if control != null:
362
363
  return control.visible
364
+ # A hidden layer hides everything drawn on it, a pause menu's for one, while each of those keeps
365
+ # its own visible flag, so read as shown it matched the words of a menu nobody could see.
366
+ var layer: CanvasLayer = node as CanvasLayer
367
+ if layer != null:
368
+ return layer.visible
363
369
  var spatial: Node3D = node as Node3D
364
370
  if spatial != null:
365
371
  return spatial.visible
@@ -562,304 +568,6 @@ func get_property(params: Dictionary) -> Dictionary:
562
568
  }
563
569
 
564
570
 
565
- func set_property(params: Dictionary) -> Dictionary:
566
- var node_path: String = str(params.get("path", ""))
567
- var property: String = str(params.get("property", ""))
568
- var value: Variant = params.get("value")
569
-
570
- if node_path.is_empty() or property.is_empty():
571
- return {"type": "error", "message": "Node path and property required"}
572
-
573
- var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
574
- if standing.has("message"):
575
- return standing
576
- var node: Node = standing["node"]
577
-
578
- # Through a path as well, for the reason [method Paths.walk_to] gives, and read back off the same
579
- # holder afterwards: a set that does not take says so by answering with the old value, which is
580
- # how a typed container refusing a write is told from one accepting it.
581
- var reached: Dictionary = Paths.walk_to(node, node_path, property)
582
- if reached.has("message"):
583
- return reached
584
-
585
- var holder: Variant = reached["holder"]
586
- var named: String = reached["name"]
587
- # A call can be walked through and read, and is not a place: what it returns is the method's
588
- # to hand out, and writing "into" it would set nothing the game keeps.
589
- if not Paths.method_of(named).is_empty():
590
- return {
591
- "type": "error",
592
- "message":
593
- (
594
- "%s:%s is a call, and a call is not a place to write: name a property"
595
- % [reached["called"], named]
596
- )
597
- }
598
- if not Paths.can_read(holder, named):
599
- return {"type": "error", "message": Paths.nothing_there(holder, named, str(reached["called"]))}
600
- var old_value: Variant = Paths.read_under(holder, named)
601
- # The same rule the call path holds: a value that cannot become what the property holds is
602
- # refused rather than written. Writing it means the engine picks something, and what it picks
603
- # for a word where a number goes is zero, which the answer then reports as the new value.
604
- var slot: Dictionary = Values.slot_declared(holder, named)
605
- var wanted: int = slot["type"] if slot["type"] != TYPE_NIL else typeof(old_value)
606
- var given: Variant
607
- if wanted == TYPE_OBJECT and (value is String or value is Dictionary):
608
- var named_object: Dictionary = _object_named(node, node_path, value, str(slot["class"]))
609
- if named_object.has("message"):
610
- return {
611
- "type": "error",
612
- "message": "%s.%s holds an object: %s" % [reached["called"], named, named_object["message"]]
613
- }
614
- given = named_object["object"]
615
- else:
616
- given = _values.fitted(value, wanted)
617
- if not Values.acceptable(given, wanted):
618
- return {
619
- "type": "error",
620
- "message":
621
- (
622
- "%s.%s holds %s and the value given is %s, which cannot become one."
623
- % [reached["called"], named, type_string(wanted), type_string(typeof(given))]
624
- )
625
- }
626
- var typed: Dictionary = _values.typed_like(given, old_value, _element_object.bind(node, node_path))
627
- if typed.has("message"):
628
- return {
629
- "type": "error",
630
- "message":
631
- "%s.%s is a typed %s: %s." % [reached["called"], named, _sort_of(old_value), typed["message"]]
632
- }
633
- given = typed["value"]
634
- # The write alone, setter included, which is where a property's cost is.
635
- var began: int = Time.get_ticks_usec()
636
- Paths.write(holder, named, given)
637
- var elapsed: int = Time.get_ticks_usec() - began
638
- var now: Variant = Paths.read_under(holder, named)
639
- # Read back rather than trusted. The engine drops a write it will not take without a word, and
640
- # the answer then showed the old value as the new one, shaped as a success: a typed container
641
- # refusing a plain list did exactly that. A value the property changed on the way in, a setter
642
- # clamping it, still changed it, so only a write that left the property as it was is refused.
643
- # And only one that asked for a change: the value the property already held, written again, left
644
- # it as it was because nothing was asked of it.
645
- var already: bool = _held_already(given, old_value)
646
- if (
647
- Values.comparable(given, old_value)
648
- and not already
649
- and Values.comparable(now, old_value)
650
- and now == old_value
651
- ):
652
- return {
653
- "type": "error",
654
- "message":
655
- (
656
- (
657
- "%s.%s was given %s and still holds %s: the engine kept what it had,"
658
- + " which a property it will not write does."
659
- )
660
- % [
661
- reached["called"],
662
- named,
663
- JSON.stringify(_values.serialize(given)),
664
- JSON.stringify(_values.serialize(now))
665
- ]
666
- )
667
- }
668
-
669
- var answer: Dictionary = {
670
- "type": "property_set",
671
- "path": node_path,
672
- "property": property,
673
- "old_value": _values.serialize(old_value),
674
- "new_value": _values.serialize(now),
675
- "elapsed_usec": elapsed
676
- }
677
- if already and Values.comparable(now, old_value) and now == old_value:
678
- answer["unchanged"] = true
679
- return answer
680
-
681
-
682
- ## Whether [param given] is the value [param held] already is, at the precision the property keeps.
683
- ##
684
- ## A float arrives as 64 bits and most engine properties keep 32: Camera3D.h_offset given -1.1
685
- ## reads back as -1.10000002384186, so comparing the two exactly took a write of the value already
686
- ## held for one the engine refused (#780). Rounding both to 32 bits is exact for a 32-bit property.
687
- ## A 64-bit one can take a write this calls the same and read back changed, and the caller calls a
688
- ## write unchanged only when it reads back as it was. What that cannot tell apart is a 64-bit
689
- ## property whose setter ignored a value differing from the held one below 32 bits: it is answered
690
- ## unchanged rather than refused. Vectors and colours hold 32-bit parts in the value itself, so they
691
- ## already compare at the property's precision.
692
- static func _held_already(given: Variant, held: Variant) -> bool:
693
- if not Values.comparable(given, held):
694
- return false
695
- if given == held:
696
- return true
697
- if typeof(given) != TYPE_FLOAT or typeof(held) != TYPE_FLOAT:
698
- return false
699
- return PackedFloat32Array([given])[0] == PackedFloat32Array([held])[0]
700
-
701
-
702
- func call_method(params: Dictionary) -> Dictionary:
703
- var node_path: String = str(params.get("path", ""))
704
- var method: String = str(params.get("method", ""))
705
- var args: Array = params.get("args", [])
706
-
707
- if node_path.is_empty() or method.is_empty():
708
- return {"type": "error", "message": "Node path and method required"}
709
-
710
- var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
711
- if standing.has("message"):
712
- return standing
713
- var node: Node = standing["node"]
714
-
715
- # Through a path as well, for the reason [method Paths.walk_to] gives. What a game does hangs off its
716
- # nodes as much as its state does, so reading `_game:run:day` while being unable to call
717
- # `_game:run:advance` answers half of what a node holds and refuses the other half.
718
- var reached: Dictionary = Paths.walk_to(node, node_path, method)
719
- if reached.has("message"):
720
- return reached
721
-
722
- # A list or a map answers the calls a path makes on one, and nothing else: said in those terms
723
- # rather than as "has no method", which reads as a misspelling of a method that was never going
724
- # to be there, and with the element spelled out, since a method on what it holds is the usual aim.
725
- if not reached["holder"] is Object:
726
- var sort: String = "a list"
727
- if reached["holder"] is Dictionary:
728
- sort = "a map"
729
- elif Paths.packed(reached["holder"]):
730
- sort = "a packed list"
731
- var spelled: String = str(reached["name"])
732
- if Paths.method_of(spelled).is_empty():
733
- spelled += "()"
734
- if args.is_empty() and Paths.can_read(reached["holder"], spelled):
735
- var read_from: int = Time.get_ticks_usec()
736
- var answered: Variant = Paths.read_under(reached["holder"], spelled)
737
- var read_for: int = Time.get_ticks_usec() - read_from
738
- return {
739
- "type": "method_result",
740
- "path": node_path,
741
- "method": method,
742
- "result": _values.serialize(answered),
743
- "elapsed_usec": read_for,
744
- }
745
- var why: String = (
746
- "%s:%s takes no arguments" % [reached["called"], spelled]
747
- if Paths.can_read(reached["holder"], spelled)
748
- else Paths.nothing_there(reached["holder"], spelled, str(reached["called"]))
749
- )
750
- return {
751
- "type": "error",
752
- "message":
753
- (
754
- "%s. A method of what %s holds is called on the element, as in %s:0:%s"
755
- % [why, sort, reached["called"], reached["name"]]
756
- )
757
- }
758
- var holder: Object = reached["holder"]
759
- var named: String = reached["name"]
760
- # The method a call ends on is the one being called whether or not it carries the brackets a
761
- # step along the way would: "get_viewport():gui_get_focus_owner()" is the same call as without
762
- # the last pair.
763
- if not Paths.method_of(named).is_empty():
764
- named = Paths.method_of(named)
765
- if not holder.has_method(named):
766
- return {"type": "error", "message": "%s has no method %s" % [reached["called"], named]}
767
-
768
- # Checked before the call rather than left to it. `callv` raises inside the game when an
769
- # argument cannot be converted, and an error raised in a game somebody is playing holds it at a
770
- # debugger break: every later call then reports a game that is not responding, which points
771
- # nowhere near the argument. A wrong argument costs a refusal, never the session's game.
772
- var deserialized_args: Array = []
773
- for index: int in args.size():
774
- var wants: int = _values.parameter_type(holder, named, index)
775
- if wants == TYPE_OBJECT and (args[index] is String or args[index] is Dictionary):
776
- var declared: String = _values.parameter_class(holder, named, index)
777
- var named_object: Dictionary = _object_named(node, node_path, args[index], declared)
778
- if named_object.has("message"):
779
- return {
780
- "type": "error",
781
- "message":
782
- "%s.%s argument %d: %s" % [reached["called"], named, index + 1, named_object["message"]]
783
- }
784
- deserialized_args.append(named_object["object"])
785
- continue
786
- var given: Variant = _values.fitted(args[index], wants)
787
- if not Values.acceptable(given, wants):
788
- return {
789
- "type": "error",
790
- "message":
791
- (
792
- "%s.%s takes %s as argument %d and was given %s, which cannot be converted."
793
- % [reached["called"], named, type_string(wants), index + 1, type_string(typeof(given))]
794
- )
795
- }
796
- # A typed list or map is built as one, since a plain one handed to such a parameter raises
797
- # inside the game, which is what every check here exists to stop.
798
- var container: Variant = _values.parameter_container(holder, named, index)
799
- if container != null:
800
- var typed: Dictionary = _values.typed_like(
801
- given, container, _element_object.bind(node, node_path)
802
- )
803
- if typed.has("message"):
804
- return {
805
- "type": "error",
806
- "message":
807
- (
808
- "%s.%s takes a typed %s as argument %d: %s."
809
- % [reached["called"], named, _sort_of(container), index + 1, typed["message"]]
810
- )
811
- }
812
- given = typed["value"]
813
- deserialized_args.append(given)
814
-
815
- # Timed around the call alone, since the bridge's round trip is a second or more and swamps what
816
- # one click's work costs; the frame metrics read whichever frame is last, which is not this one.
817
- var began: int = Time.get_ticks_usec()
818
- var result: Variant = holder.callv(named, deserialized_args)
819
- var elapsed: int = Time.get_ticks_usec() - began
820
-
821
- return {
822
- "type": "method_result",
823
- "path": node_path,
824
- "method": method,
825
- "result": _values.serialize(result),
826
- "elapsed_usec": elapsed
827
- }
828
-
829
-
830
- ## [method _object_named] with the path first, which is the order [method Values.typed_like] calls
831
- ## an element resolver in.
832
- func _element_object(given: Variant, declared: String, node: Node, node_path: String) -> Dictionary:
833
- return _object_named(node, node_path, given, declared)
834
-
835
-
836
- ## "list" or "map", for a refusal about a typed container.
837
- static func _sort_of(container: Variant) -> String:
838
- return "list" if container is Array else "map"
839
-
840
-
841
- ## The object [param given] names for a slot or a parameter declared as [param declared], as
842
- ## `{"object": ...}`, or `{"message": ...}` saying why it names none that fits.
843
- ##
844
- ## JSON cannot carry an object, so one the game holds is named by its path, read from the node the
845
- ## call or the write was made on, and handed over as that instance rather than a copy: a method
846
- ## given a piece of the game's state acts on the piece the game keeps, and a slot written with one
847
- ## holds the game's own.
848
- ##
849
- ## The text null names no object, which is what an object slot or parameter takes to be emptied. A
850
- ## caller's null arrived as that text in #772 and #783, and was looked up as a node called "null".
851
- func _object_named(node: Node, node_path: String, given: Variant, declared: String) -> Dictionary:
852
- if given is String and str(given).strip_edges() == "null":
853
- return {"object": null}
854
- var found: Dictionary = Paths.object_at(_host.get_tree().root, node, node_path, given)
855
- if found.has("message"):
856
- return found
857
- var instance: Object = found["object"]
858
- if not declared.is_empty() and not Values.is_a(instance, declared):
859
- return {"message": "%s is %s, not a %s" % [str(given), Values.class_of(instance), declared]}
860
- return found
861
-
862
-
863
571
  func get_metrics(params: Dictionary) -> Dictionary:
864
572
  var metrics: Array = params.get("metrics", [])
865
573
  var all: Dictionary = {
@@ -17,20 +17,32 @@ extends RefCounted
17
17
  ## sit inside another viewport. A SubViewport drawn some other way, onto a mesh for one, has no
18
18
  ## place on the screen to carry the point to, and the point stays in it.
19
19
  static func reach(viewport: Viewport, point: Vector2) -> Dictionary:
20
+ var out: Array[Dictionary] = steps_out(viewport, point)
21
+ return out.back()
22
+
23
+
24
+ ## Every viewport a pointer at [param point] in [param viewport] passes through on the way out to
25
+ ## the one it arrives at, innermost first, as {"viewport", "point", "through"}: the point in that
26
+ ## viewport's space, and the embedded [Window] or the [SubViewportContainer] in it that shows the
27
+ ## step before, null for the first. What [method reach] answers is the last of them.
28
+ static func steps_out(viewport: Viewport, point: Vector2) -> Array[Dictionary]:
29
+ var out: Array[Dictionary] = [{"viewport": viewport, "point": point, "through": null}]
20
30
  while true:
21
31
  var window: Window = viewport as Window
22
32
  if window != null and window.is_embedded() and window.get_parent() != null:
23
33
  point += Vector2(window.position)
24
34
  viewport = window.get_parent().get_viewport()
35
+ out.append({"viewport": viewport, "point": point, "through": window})
25
36
  continue
26
37
  var shown_by: SubViewportContainer = viewport.get_parent() as SubViewportContainer
27
38
  if viewport is SubViewport and shown_by != null:
28
39
  var scaled: Vector2 = point * float(shown_by.stretch_shrink) if shown_by.stretch else point
29
40
  point = shown_by.get_global_transform_with_canvas() * scaled
30
41
  viewport = shown_by.get_viewport()
42
+ out.append({"viewport": viewport, "point": point, "through": shown_by})
31
43
  continue
32
44
  break
33
- return {"viewport": viewport, "point": point}
45
+ return out
34
46
 
35
47
 
36
48
  ## Where [param point], in [param viewport]'s own coordinates, is in the pixels of the window the
@@ -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()