gdharness 1.1.12 → 1.1.13
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 +2 -2
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +77 -134
- package/build/godot/addons/gdharness_runtime/runtime_menus.gd +81 -0
- package/build/godot/addons/gdharness_runtime/runtime_menus.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +16 -6
- package/build/godot/addons/gdharness_runtime/runtime_screen.gd +50 -0
- package/build/godot/addons/gdharness_runtime/runtime_screen.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +28 -4
- package/build/index.js +2 -2
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -17091,7 +17091,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
17091
17091
|
requires: []
|
|
17092
17092
|
},
|
|
17093
17093
|
rect: {
|
|
17094
|
-
summary: "one node's rectangle or position, in canvas and in window pixels. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
|
|
17094
|
+
summary: "one node's rectangle or position, in canvas and in window pixels. Canvas is the node's own viewport; window is where it is drawn in the game's window, through any embedded dialog or SubViewport it sits in, which is what runtime_input mouse_click and mouse_motion take. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
|
|
17095
17095
|
requires: ["nodePath"]
|
|
17096
17096
|
},
|
|
17097
17097
|
property: {
|
|
@@ -17290,7 +17290,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
17290
17290
|
requires: []
|
|
17291
17291
|
},
|
|
17292
17292
|
choose: {
|
|
17293
|
-
summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen and what the button in front of it shows now",
|
|
17293
|
+
summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen, read before the press, what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick), and what the button in front of it shows now when it is still there",
|
|
17294
17294
|
requires: ["nodePath"]
|
|
17295
17295
|
},
|
|
17296
17296
|
action: { summary: "press an action, or hold it", requires: ["action"] },
|
|
@@ -10,6 +10,8 @@ const Values = preload("runtime_values.gd")
|
|
|
10
10
|
## reports are the same place by construction rather than by agreement.
|
|
11
11
|
const Queries = preload("runtime_queries.gd")
|
|
12
12
|
const Read = preload("reading.gd")
|
|
13
|
+
const Menus = preload("runtime_menus.gd")
|
|
14
|
+
const Screen = preload("runtime_screen.gd")
|
|
13
15
|
const Targets = preload("runtime_targets.gd")
|
|
14
16
|
const Words = preload("runtime_words.gd")
|
|
15
17
|
|
|
@@ -410,36 +412,6 @@ func inject_mouse_motion(params: Dictionary) -> Dictionary:
|
|
|
410
412
|
}
|
|
411
413
|
|
|
412
414
|
|
|
413
|
-
## The viewport a click aimed at [param control] has to be pushed into.
|
|
414
|
-
##
|
|
415
|
-
## Its own, unless that is an embedded [Window]. A ConfirmationDialog is a Window, and under
|
|
416
|
-
## `gui_embed_subwindows` a Window is drawn inside its parent rather than given one by the
|
|
417
|
-
## desktop: pushing into its own viewport delivers nothing, measured, with the pointer reading as
|
|
418
|
-
## over no control at all. What reaches it is the parent, which is what the engine does with a
|
|
419
|
-
## real pointer. Walked rather than stepped once, because a dialog can open a dialog.
|
|
420
|
-
static func _clicking_viewport(control: Control) -> Viewport:
|
|
421
|
-
var viewport: Viewport = control.get_viewport()
|
|
422
|
-
var window: Window = viewport as Window
|
|
423
|
-
while window != null and window.is_embedded() and window.get_parent() != null:
|
|
424
|
-
viewport = window.get_parent().get_viewport()
|
|
425
|
-
window = viewport as Window
|
|
426
|
-
return viewport
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
## Where [param control]'s centre is in the viewport [method _clicking_viewport] names.
|
|
430
|
-
##
|
|
431
|
-
## The transform is against the control's own viewport, so every embedded window between it and
|
|
432
|
-
## that one contributes its offset. The same walk, because the two answers have to agree about
|
|
433
|
-
## which viewport they are describing.
|
|
434
|
-
static func _centre_of(control: Control) -> Vector2:
|
|
435
|
-
var centre: Vector2 = control.get_global_transform_with_canvas() * (control.size * 0.5)
|
|
436
|
-
var window: Window = control.get_viewport() as Window
|
|
437
|
-
while window != null and window.is_embedded() and window.get_parent() != null:
|
|
438
|
-
centre += Vector2(window.position)
|
|
439
|
-
window = window.get_parent().get_viewport() as Window
|
|
440
|
-
return centre
|
|
441
|
-
|
|
442
|
-
|
|
443
415
|
## Scrolls whatever is holding [param control] until it is on screen, and answers whether
|
|
444
416
|
## anything moved.
|
|
445
417
|
##
|
|
@@ -521,8 +493,9 @@ func click(params: Dictionary) -> Dictionary:
|
|
|
521
493
|
if not control.is_visible_in_tree():
|
|
522
494
|
return {"type": "error", "message": "%s is not visible, so nothing can click it" % node_path}
|
|
523
495
|
|
|
524
|
-
|
|
525
|
-
var
|
|
496
|
+
# In the control's own viewport first, which is the space its scrolling happens in.
|
|
497
|
+
var own: Viewport = control.get_viewport()
|
|
498
|
+
var local: Vector2 = control.get_global_transform_with_canvas() * (control.size * 0.5)
|
|
526
499
|
|
|
527
500
|
# A control out of sight inside a ScrollContainer is not out of reach, it is one scroll away,
|
|
528
501
|
# which is what a person does without thinking about it before they click. Refusing it
|
|
@@ -534,19 +507,23 @@ func click(params: Dictionary) -> Dictionary:
|
|
|
534
507
|
# the click went to that instead and said so. Measured on a hall whose staff panel had been
|
|
535
508
|
# scrolled past: the button was at y 69 and its container started at y 166.
|
|
536
509
|
var scrolled: bool = false
|
|
537
|
-
if not _in_sight(control,
|
|
510
|
+
if not _in_sight(control, own, local):
|
|
538
511
|
scrolled = _scroll_into_view(control)
|
|
539
512
|
if scrolled:
|
|
540
513
|
# A container moves its child on the next layout pass rather than inside the call.
|
|
541
514
|
await _host.get_tree().process_frame
|
|
542
|
-
|
|
515
|
+
local = control.get_global_transform_with_canvas() * (control.size * 0.5)
|
|
543
516
|
|
|
517
|
+
var reached: Dictionary = Screen.reach(own, local)
|
|
518
|
+
var viewport: Viewport = reached["viewport"]
|
|
519
|
+
var centre: Vector2 = reached["point"]
|
|
544
520
|
var position: Vector2 = viewport.get_final_transform() * centre
|
|
545
521
|
# The GUI only delivers to what is inside the viewport, so a centre outside it would be a
|
|
546
522
|
# click that silently reached nothing. A game with no window has a 64 by 64 viewport
|
|
547
523
|
# whatever the project settings say, which is the usual reason to be here and is not
|
|
548
|
-
# something the caller can read off the rect on its own.
|
|
549
|
-
|
|
524
|
+
# something the caller can read off the rect on its own. Its own viewport as well, since a
|
|
525
|
+
# control outside its SubViewport is drawn nowhere.
|
|
526
|
+
if not viewport.get_visible_rect().has_point(centre) or not own.get_visible_rect().has_point(local):
|
|
550
527
|
var why: String = _no_window_note(viewport)
|
|
551
528
|
if scrolled:
|
|
552
529
|
why = ". It was scrolled as far as what holds it goes and is still out there"
|
|
@@ -583,20 +560,17 @@ func click(params: Dictionary) -> Dictionary:
|
|
|
583
560
|
hovered_path = str(hovered.get_path())
|
|
584
561
|
var landed: bool = hovered == control or (hovered != null and control.is_ancestor_of(hovered))
|
|
585
562
|
|
|
586
|
-
viewport
|
|
563
|
+
_press_button(viewport, _button(position, button, true, double))
|
|
587
564
|
await _host.get_tree().process_frame
|
|
588
|
-
viewport
|
|
565
|
+
_press_button(viewport, _button(position, button, false, false))
|
|
589
566
|
# The release is what a button acts on, and a queue_free it causes lands at the end of
|
|
590
567
|
# this frame; the frame passes so the answer describes the control as the click left it.
|
|
591
568
|
await _host.get_tree().process_frame
|
|
592
569
|
|
|
593
570
|
# What became of the control: still in the tree, taken out of it, or freed. A button that
|
|
594
571
|
# opened another screen is the second or the third, and the caller wants to hear that
|
|
595
|
-
# rather than guess it from a tree that has changed shape.
|
|
596
|
-
|
|
597
|
-
var afterwards: String = "freed"
|
|
598
|
-
if is_instance_valid(control):
|
|
599
|
-
afterwards = "in_tree" if control.is_inside_tree() else "removed"
|
|
572
|
+
# rather than guess it from a tree that has changed shape.
|
|
573
|
+
var afterwards: String = _afterwards(control)
|
|
600
574
|
|
|
601
575
|
var answer: Dictionary = {
|
|
602
576
|
"type": "clicked",
|
|
@@ -655,7 +629,7 @@ func choose(params: Dictionary) -> Dictionary:
|
|
|
655
629
|
return standing
|
|
656
630
|
var node: Node = standing["node"]
|
|
657
631
|
|
|
658
|
-
var menu: PopupMenu =
|
|
632
|
+
var menu: PopupMenu = Menus.menu_of(node)
|
|
659
633
|
if menu == null:
|
|
660
634
|
return {
|
|
661
635
|
"type": "error",
|
|
@@ -672,15 +646,15 @@ func choose(params: Dictionary) -> Dictionary:
|
|
|
672
646
|
"message":
|
|
673
647
|
(
|
|
674
648
|
"%s needs the item named, by text or index. It holds: %s"
|
|
675
|
-
% [node_path, ", ".join(
|
|
649
|
+
% [node_path, ", ".join(Menus.items_of(menu))]
|
|
676
650
|
)
|
|
677
651
|
}
|
|
678
652
|
|
|
679
|
-
var index: int =
|
|
653
|
+
var index: int = Menus.wanted_item(menu, params)
|
|
680
654
|
if index < 0:
|
|
681
655
|
return {
|
|
682
656
|
"type": "error",
|
|
683
|
-
"message": "%s has no such item. It holds: %s" % [node_path, ", ".join(
|
|
657
|
+
"message": "%s has no such item. It holds: %s" % [node_path, ", ".join(Menus.items_of(menu))]
|
|
684
658
|
}
|
|
685
659
|
if menu.is_item_separator(index):
|
|
686
660
|
var heading: String = Words.item_says(menu, index).strip_edges()
|
|
@@ -705,9 +679,22 @@ func choose(params: Dictionary) -> Dictionary:
|
|
|
705
679
|
# Shown first, because a menu nobody has opened has no focus to move and Enter would go to
|
|
706
680
|
# whatever is behind it. An OptionButton opens its own; a bare PopupMenu is popped where it
|
|
707
681
|
# already sits, which leaves a menu that was already open where it is.
|
|
708
|
-
var opened: bool =
|
|
682
|
+
var opened: bool = Menus.open_the_menu(node, menu)
|
|
709
683
|
await _host.get_tree().process_frame
|
|
710
684
|
|
|
685
|
+
# Read before the press, because the press can take the menu away: a game that rebuilds its
|
|
686
|
+
# settings screen on item_selected frees the button and its menu inside the pick, and reading
|
|
687
|
+
# the item afterwards was an engine error in the game's log and an empty answer to a pick that
|
|
688
|
+
# had taken.
|
|
689
|
+
var answer: Dictionary = {
|
|
690
|
+
"type": "chosen",
|
|
691
|
+
"path": node_path,
|
|
692
|
+
"index": index,
|
|
693
|
+
"text": Words.item_says(menu, index),
|
|
694
|
+
"id": menu.get_item_id(index),
|
|
695
|
+
"opened": opened,
|
|
696
|
+
"menu": str(menu.get_path()),
|
|
697
|
+
}
|
|
711
698
|
menu.scroll_to_item(index)
|
|
712
699
|
menu.set_focused_item(index)
|
|
713
700
|
# Through Input rather than pushed at the menu, which is how a keyboard reaches an open one: a
|
|
@@ -718,96 +705,49 @@ func choose(params: Dictionary) -> Dictionary:
|
|
|
718
705
|
Input.parse_input_event(_accept(false))
|
|
719
706
|
await _host.get_tree().process_frame
|
|
720
707
|
|
|
721
|
-
|
|
722
|
-
"type": "chosen",
|
|
723
|
-
"path": node_path,
|
|
724
|
-
"index": index,
|
|
725
|
-
"text": Words.item_says(menu, index),
|
|
726
|
-
"id": menu.get_item_id(index),
|
|
727
|
-
"opened": opened,
|
|
728
|
-
"menu": str(menu.get_path()),
|
|
729
|
-
}
|
|
708
|
+
answer["control_afterwards"] = _afterwards(node)
|
|
730
709
|
# What the button in front of the menu reads now, which is the answer to "did it take": a menu
|
|
731
|
-
# item that fired changes the thing holding it, and nothing else about the press says so.
|
|
732
|
-
|
|
733
|
-
if
|
|
710
|
+
# item that fired changes the thing holding it, and nothing else about the press says so. A
|
|
711
|
+
# button the pick took away has nothing to read, and that it went is the answer.
|
|
712
|
+
if answer["control_afterwards"] == "in_tree" and node is OptionButton:
|
|
713
|
+
var chooser: OptionButton = node
|
|
734
714
|
answer["selected"] = chooser.get_selected()
|
|
735
715
|
answer["shows"] = Words.said_by(chooser)
|
|
736
716
|
return answer
|
|
737
717
|
|
|
738
718
|
|
|
739
|
-
##
|
|
740
|
-
##
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
719
|
+
## Sends a click's press or release into [param viewport], through Input when that is the game's
|
|
720
|
+
## own window.
|
|
721
|
+
##
|
|
722
|
+
## Through Input so that Input knows a button is held, which is what a real mouse tells it. A
|
|
723
|
+
## PopupMenu opened by a press asks Input whether a button is down, and when one is, it lets the
|
|
724
|
+
## release that ends that press go by rather than choosing the item under it. Pushed straight into
|
|
725
|
+
## the viewport, the press left Input thinking nothing was held, so a dropdown near the bottom of
|
|
726
|
+
## the screen, whose menu opens over it and under the pointer, had its first item chosen by the
|
|
727
|
+
## click meant to open it. Games read that state too, through Input.is_mouse_button_pressed.
|
|
728
|
+
##
|
|
729
|
+
## Any other viewport is pushed into directly: Input reaches only the game's window. That is a
|
|
730
|
+
## viewport with no place on the screen, since `Screen.reach` carries every other one there.
|
|
731
|
+
func _press_button(viewport: Viewport, event: InputEventMouseButton) -> void:
|
|
732
|
+
if viewport == _host.get_tree().root:
|
|
733
|
+
Input.parse_input_event(event)
|
|
734
|
+
# Delivered now, as a push is, rather than at the next frame: the answer is read a frame
|
|
735
|
+
# after the release, and a button that frees itself on it has to be gone by then.
|
|
736
|
+
Input.flush_buffered_events()
|
|
737
|
+
else:
|
|
738
|
+
viewport.push_input(event)
|
|
750
739
|
|
|
751
740
|
|
|
752
|
-
##
|
|
753
|
-
##
|
|
754
|
-
##
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
return asked if asked >= 0 and asked < menu.get_item_count() else -1
|
|
763
|
-
var wanted: String = str(params.get("text", ""))
|
|
764
|
-
if wanted.is_empty():
|
|
765
|
-
return -1
|
|
766
|
-
# The items before the separators, because a heading can carry the same words as an item under
|
|
767
|
-
# it, and finding the heading first refused the choice the caller meant. A separator is still
|
|
768
|
-
# looked for after, so a heading asked for by name is refused as one rather than as missing.
|
|
769
|
-
var order: Array[int] = []
|
|
770
|
-
for separators: bool in [false, true]:
|
|
771
|
-
for index: int in menu.get_item_count():
|
|
772
|
-
if menu.is_item_separator(index) == separators:
|
|
773
|
-
order.append(index)
|
|
774
|
-
var shown: Array[String] = []
|
|
775
|
-
var held: Array[String] = []
|
|
776
|
-
for index: int in order:
|
|
777
|
-
shown.append(Words.item_says(menu, index))
|
|
778
|
-
held.append(menu.get_item_text(index))
|
|
779
|
-
for words: Array[String] in [shown, held]:
|
|
780
|
-
for at: int in words.size():
|
|
781
|
-
if words[at] == wanted:
|
|
782
|
-
return order[at]
|
|
783
|
-
for at: int in words.size():
|
|
784
|
-
if words[at].nocasecmp_to(wanted) == 0:
|
|
785
|
-
return order[at]
|
|
786
|
-
return -1
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
## What the menu shows, for a refusal that names the choices rather than the miss: a titled
|
|
790
|
-
## separator marked as the heading it is, and one with no title left out, since it shows nothing.
|
|
791
|
-
static func _items_of(menu: PopupMenu) -> Array[String]:
|
|
792
|
-
var said: Array[String] = []
|
|
793
|
-
for index: int in menu.get_item_count():
|
|
794
|
-
var words: String = Words.item_says(menu, index)
|
|
795
|
-
if not menu.is_item_separator(index):
|
|
796
|
-
said.append("%d: %s" % [index, words])
|
|
797
|
-
elif not words.strip_edges().is_empty():
|
|
798
|
-
said.append("%d: %s (a heading)" % [index, words])
|
|
799
|
-
return said
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
## Opens the menu if it is not already, and answers whether anything opened.
|
|
803
|
-
static func _open_the_menu(node: Node, menu: PopupMenu) -> bool:
|
|
804
|
-
if menu.visible:
|
|
805
|
-
return false
|
|
806
|
-
if node.has_method("show_popup"):
|
|
807
|
-
node.call("show_popup")
|
|
808
|
-
return true
|
|
809
|
-
menu.popup()
|
|
810
|
-
return true
|
|
741
|
+
## What became of [param node] once an input it was given has run: still in the tree, taken out
|
|
742
|
+
## of it, or freed. Untyped, because a freed reference handed to a typed parameter is itself the
|
|
743
|
+
## engine error this is here to avoid.
|
|
744
|
+
static func _afterwards(node: Variant) -> String:
|
|
745
|
+
if not is_instance_valid(node):
|
|
746
|
+
return "freed"
|
|
747
|
+
if not node is Node:
|
|
748
|
+
return "removed"
|
|
749
|
+
var placed: Node = node
|
|
750
|
+
return "in_tree" if placed.is_inside_tree() else "removed"
|
|
811
751
|
|
|
812
752
|
|
|
813
753
|
func _accept(pressed: bool) -> InputEventKey:
|
|
@@ -859,13 +799,16 @@ func _click_in_the_world(node_path: String, item: Node3D, params: Dictionary) ->
|
|
|
859
799
|
)
|
|
860
800
|
}
|
|
861
801
|
|
|
862
|
-
var
|
|
802
|
+
var reached: Dictionary = Screen.reach(viewport, aim)
|
|
803
|
+
var arriving: Viewport = reached["viewport"]
|
|
804
|
+
var reached_point: Vector2 = reached["point"]
|
|
805
|
+
var position: Vector2 = arriving.get_final_transform() * reached_point
|
|
863
806
|
var button: int = _resolve_mouse_button(params.get("button", MOUSE_BUTTON_LEFT))
|
|
864
807
|
if button < 0:
|
|
865
808
|
return _no_such_button(params.get("button"))
|
|
866
809
|
var double: bool = Read.as_bool(params.get("double", false))
|
|
867
810
|
|
|
868
|
-
|
|
811
|
+
arriving.push_input(_motion(position, _arrive(position)))
|
|
869
812
|
# Read before the press, for the reason the Control click reads it: what the caller needs to
|
|
870
813
|
# know is whether a panel is sitting over the room, and the press is what would change it.
|
|
871
814
|
var hovered: Control = viewport.gui_get_hovered_control()
|
|
@@ -873,9 +816,9 @@ func _click_in_the_world(node_path: String, item: Node3D, params: Dictionary) ->
|
|
|
873
816
|
if hovered != null:
|
|
874
817
|
hovered_path = str(hovered.get_path())
|
|
875
818
|
|
|
876
|
-
|
|
819
|
+
_press_button(arriving, _button(position, button, true, double))
|
|
877
820
|
await _host.get_tree().process_frame
|
|
878
|
-
|
|
821
|
+
_press_button(arriving, _button(position, button, false, false))
|
|
879
822
|
await _host.get_tree().process_frame
|
|
880
823
|
|
|
881
824
|
return {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
extends RefCounted
|
|
2
|
+
|
|
3
|
+
## The menu a choice is made from and the item in it, for a choice made by what an item says or by
|
|
4
|
+
## where it is in the list.
|
|
5
|
+
|
|
6
|
+
const Read = preload("reading.gd")
|
|
7
|
+
const Words = preload("runtime_words.gd")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## The menu [param node] is, or the one it holds. An [OptionButton] and a [MenuButton] both keep
|
|
11
|
+
## theirs as an internal child, which is a node a caller cannot name and should not have to.
|
|
12
|
+
static func menu_of(node: Node) -> PopupMenu:
|
|
13
|
+
var menu: PopupMenu = node as PopupMenu
|
|
14
|
+
if menu != null:
|
|
15
|
+
return menu
|
|
16
|
+
if node.has_method("get_popup"):
|
|
17
|
+
var held: Variant = node.call("get_popup")
|
|
18
|
+
if held is PopupMenu:
|
|
19
|
+
return held
|
|
20
|
+
return null
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Which item was asked for: `text`, matched exactly and then case-insensitively, or `index`.
|
|
24
|
+
## Minus one when neither names one that is there.
|
|
25
|
+
##
|
|
26
|
+
## `text` is matched against the words the item shows before the ones it holds. In a game with
|
|
27
|
+
## translations they differ, and the shown ones are what a caller has read off the screen: an item
|
|
28
|
+
## held as `ACT_ENGAGE` and shown as "Hire" refused "Hire" and listed the keys. The held words
|
|
29
|
+
## still count after them, for a caller that has the key from the source.
|
|
30
|
+
static func wanted_item(menu: PopupMenu, params: Dictionary) -> int:
|
|
31
|
+
if params.has("index"):
|
|
32
|
+
var asked: int = Read.as_int(params.get("index", -1), -1)
|
|
33
|
+
return asked if asked >= 0 and asked < menu.get_item_count() else -1
|
|
34
|
+
var wanted: String = str(params.get("text", ""))
|
|
35
|
+
if wanted.is_empty():
|
|
36
|
+
return -1
|
|
37
|
+
# The items before the separators, because a heading can carry the same words as an item under
|
|
38
|
+
# it, and finding the heading first refused the choice the caller meant. A separator is still
|
|
39
|
+
# looked for after, so a heading asked for by name is refused as one rather than as missing.
|
|
40
|
+
var order: Array[int] = []
|
|
41
|
+
for separators: bool in [false, true]:
|
|
42
|
+
for index: int in menu.get_item_count():
|
|
43
|
+
if menu.is_item_separator(index) == separators:
|
|
44
|
+
order.append(index)
|
|
45
|
+
var shown: Array[String] = []
|
|
46
|
+
var held: Array[String] = []
|
|
47
|
+
for index: int in order:
|
|
48
|
+
shown.append(Words.item_says(menu, index))
|
|
49
|
+
held.append(menu.get_item_text(index))
|
|
50
|
+
for words: Array[String] in [shown, held]:
|
|
51
|
+
for at: int in words.size():
|
|
52
|
+
if words[at] == wanted:
|
|
53
|
+
return order[at]
|
|
54
|
+
for at: int in words.size():
|
|
55
|
+
if words[at].nocasecmp_to(wanted) == 0:
|
|
56
|
+
return order[at]
|
|
57
|
+
return -1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## What the menu shows, for a refusal that names the choices rather than the miss: a titled
|
|
61
|
+
## separator marked as the heading it is, and one with no title left out, since it shows nothing.
|
|
62
|
+
static func items_of(menu: PopupMenu) -> Array[String]:
|
|
63
|
+
var said: Array[String] = []
|
|
64
|
+
for index: int in menu.get_item_count():
|
|
65
|
+
var words: String = Words.item_says(menu, index)
|
|
66
|
+
if not menu.is_item_separator(index):
|
|
67
|
+
said.append("%d: %s" % [index, words])
|
|
68
|
+
elif not words.strip_edges().is_empty():
|
|
69
|
+
said.append("%d: %s (a heading)" % [index, words])
|
|
70
|
+
return said
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
## Opens the menu if it is not already, and answers whether anything opened.
|
|
74
|
+
static func open_the_menu(node: Node, menu: PopupMenu) -> bool:
|
|
75
|
+
if menu.visible:
|
|
76
|
+
return false
|
|
77
|
+
if node.has_method("show_popup"):
|
|
78
|
+
node.call("show_popup")
|
|
79
|
+
return true
|
|
80
|
+
menu.popup()
|
|
81
|
+
return true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://iv31ebsnrf1i
|
|
@@ -5,6 +5,7 @@ extends RefCounted
|
|
|
5
5
|
|
|
6
6
|
const Paths = preload("runtime_paths.gd")
|
|
7
7
|
const Read = preload("reading.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
|
|
|
@@ -423,21 +424,30 @@ func get_rect(params: Dictionary) -> Dictionary:
|
|
|
423
424
|
return standing
|
|
424
425
|
var node: Node = standing["node"]
|
|
425
426
|
|
|
427
|
+
# In the window's pixels, which is what a mouse event names, through every embedded window and
|
|
428
|
+
# SubViewport between the node and the window: multiplied by its own viewport's transform alone,
|
|
429
|
+
# a control in a dialog lost the dialog's offset and one in a SubViewport was given its place in
|
|
430
|
+
# the SubViewport, and a mouse event sent there missed it.
|
|
426
431
|
if node is Control:
|
|
427
432
|
var control: Control = node
|
|
428
|
-
var to_window: Transform2D = control.get_viewport().get_final_transform()
|
|
429
433
|
var canvas_rect: Rect2 = control.get_global_rect()
|
|
430
434
|
return {
|
|
431
435
|
"type": "rect",
|
|
432
436
|
"path": node_path,
|
|
433
437
|
"visible": control.is_visible_in_tree(),
|
|
434
438
|
"canvas": _values.serialize(canvas_rect),
|
|
435
|
-
"window":
|
|
439
|
+
"window":
|
|
440
|
+
_values.serialize(
|
|
441
|
+
Screen.rect_in_window(
|
|
442
|
+
control.get_viewport(),
|
|
443
|
+
control.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, control.size)
|
|
444
|
+
)
|
|
445
|
+
),
|
|
436
446
|
}
|
|
437
447
|
if node is Node2D:
|
|
438
448
|
var item: Node2D = node
|
|
439
449
|
var canvas_position: Vector2 = item.get_global_transform_with_canvas().origin
|
|
440
|
-
var window_position: Vector2 = item.get_viewport()
|
|
450
|
+
var window_position: Vector2 = Screen.in_window(item.get_viewport(), canvas_position)
|
|
441
451
|
return {
|
|
442
452
|
"type": "point",
|
|
443
453
|
"path": node_path,
|
|
@@ -470,7 +480,7 @@ func _in_the_frame(node_path: String, item: Node3D) -> Dictionary:
|
|
|
470
480
|
"message": "%s is not in a viewport with a current Camera3D, so nothing is drawing it" % node_path
|
|
471
481
|
}
|
|
472
482
|
|
|
473
|
-
var
|
|
483
|
+
var drawn_in: Viewport = item.get_viewport()
|
|
474
484
|
var answer: Dictionary = {
|
|
475
485
|
"type": "point",
|
|
476
486
|
"path": node_path,
|
|
@@ -481,12 +491,12 @@ func _in_the_frame(node_path: String, item: Node3D) -> Dictionary:
|
|
|
481
491
|
if found.has("aim"):
|
|
482
492
|
var aim: Vector2 = found["aim"]
|
|
483
493
|
answer["canvas"] = _values.serialize(aim)
|
|
484
|
-
answer["window"] = _values.serialize(
|
|
494
|
+
answer["window"] = _values.serialize(Screen.in_window(drawn_in, aim))
|
|
485
495
|
if found.has("rect"):
|
|
486
496
|
var covered: Rect2 = found["rect"]
|
|
487
497
|
answer["covers"] = {
|
|
488
498
|
"canvas": _values.serialize(covered),
|
|
489
|
-
"window": _values.serialize(
|
|
499
|
+
"window": _values.serialize(Screen.rect_in_window(drawn_in, covered)),
|
|
490
500
|
}
|
|
491
501
|
return answer
|
|
492
502
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
extends RefCounted
|
|
2
|
+
|
|
3
|
+
## Where a point in some viewport is on the game's own screen, for a click to be sent there and for
|
|
4
|
+
## an answer to say where something is drawn.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## The viewport a pointer at [param point], in [param viewport]'s own coordinates, arrives through,
|
|
8
|
+
## and where it is there: {"viewport", "point"}.
|
|
9
|
+
##
|
|
10
|
+
## A real pointer arrives at the outermost viewport showing this one and is handed down, so that is
|
|
11
|
+
## where a click is sent. An embedded [Window], a ConfirmationDialog for one, is drawn inside its
|
|
12
|
+
## parent under `gui_embed_subwindows`: pushing into its own viewport delivered nothing, measured,
|
|
13
|
+
## with the pointer reading as over no control at all. A [SubViewport] shown by a
|
|
14
|
+
## [SubViewportContainer] is drawn inside the container, scaled by its shrink when it stretches, and
|
|
15
|
+
## pushing into it read the point as the container's: the click on a dropdown inside one landed on
|
|
16
|
+
## nothing. Walked rather than stepped once, because a dialog can open a dialog and a container can
|
|
17
|
+
## sit inside another viewport. A SubViewport drawn some other way, onto a mesh for one, has no
|
|
18
|
+
## place on the screen to carry the point to, and the point stays in it.
|
|
19
|
+
static func reach(viewport: Viewport, point: Vector2) -> Dictionary:
|
|
20
|
+
while true:
|
|
21
|
+
var window: Window = viewport as Window
|
|
22
|
+
if window != null and window.is_embedded() and window.get_parent() != null:
|
|
23
|
+
point += Vector2(window.position)
|
|
24
|
+
viewport = window.get_parent().get_viewport()
|
|
25
|
+
continue
|
|
26
|
+
var shown_by: SubViewportContainer = viewport.get_parent() as SubViewportContainer
|
|
27
|
+
if viewport is SubViewport and shown_by != null:
|
|
28
|
+
var scaled: Vector2 = point * float(shown_by.stretch_shrink) if shown_by.stretch else point
|
|
29
|
+
point = shown_by.get_global_transform_with_canvas() * scaled
|
|
30
|
+
viewport = shown_by.get_viewport()
|
|
31
|
+
continue
|
|
32
|
+
break
|
|
33
|
+
return {"viewport": viewport, "point": point}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## Where [param point], in [param viewport]'s own coordinates, is in the pixels of the window the
|
|
37
|
+
## game is shown in, which is what a mouse event names.
|
|
38
|
+
static func in_window(viewport: Viewport, point: Vector2) -> Vector2:
|
|
39
|
+
var reached: Dictionary = reach(viewport, point)
|
|
40
|
+
var outermost: Viewport = reached["viewport"]
|
|
41
|
+
var there: Vector2 = reached["point"]
|
|
42
|
+
return outermost.get_final_transform() * there
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
## [param rect], in [param viewport]'s own coordinates, in the window's pixels: the box its two
|
|
46
|
+
## corners make once carried there, which is the rect itself for anything not turned.
|
|
47
|
+
static func rect_in_window(viewport: Viewport, rect: Rect2) -> Rect2:
|
|
48
|
+
var start: Vector2 = in_window(viewport, rect.position)
|
|
49
|
+
var end: Vector2 = in_window(viewport, rect.end)
|
|
50
|
+
return Rect2(start.min(end), (end - start).abs())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://be3gcwt5krw7k
|
|
@@ -64,11 +64,12 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
64
64
|
pending.push_front(children[index])
|
|
65
65
|
|
|
66
66
|
var drawn: Array[Node] = _pointer_takers(root)
|
|
67
|
+
var areas: Array[Rect2] = _areas_of(drawn)
|
|
67
68
|
var covered: int = 0
|
|
68
69
|
var cover: Node = null
|
|
69
70
|
var open: Array[int] = []
|
|
70
71
|
for index: int in matched.size():
|
|
71
|
-
var over: Node = _cover_of(matched[index], drawn)
|
|
72
|
+
var over: Node = _cover_of(matched[index], drawn, areas)
|
|
72
73
|
if over == null:
|
|
73
74
|
open.append(index)
|
|
74
75
|
else:
|
|
@@ -224,6 +225,24 @@ static func _pointer_takers(root: Node) -> Array[Node]:
|
|
|
224
225
|
return found
|
|
225
226
|
|
|
226
227
|
|
|
228
|
+
## The box each of [param drawn] takes up in its viewport: a window's rectangle, and the box around
|
|
229
|
+
## a control's however it is turned or scaled.
|
|
230
|
+
##
|
|
231
|
+
## Worked out once for a click rather than once for each match. A plain word on a screen of prose
|
|
232
|
+
## matches a hundred labels, and asking all four thousand controls of a hall for their transform
|
|
233
|
+
## for each of them took a click by words 80 ms, where the matching alone takes half that.
|
|
234
|
+
static func _areas_of(drawn: Array[Node]) -> Array[Rect2]:
|
|
235
|
+
var areas: Array[Rect2] = []
|
|
236
|
+
for each: Node in drawn:
|
|
237
|
+
var window: Window = each as Window
|
|
238
|
+
if window != null:
|
|
239
|
+
areas.append(Rect2(window.position, window.size))
|
|
240
|
+
else:
|
|
241
|
+
var control: Control = each
|
|
242
|
+
areas.append(control.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, control.size))
|
|
243
|
+
return areas
|
|
244
|
+
|
|
245
|
+
|
|
227
246
|
## What a pointer at [param target]'s centre reaches instead of it, or null when nothing does.
|
|
228
247
|
##
|
|
229
248
|
## The engine's own order for which control takes the pointer: an embedded window over everything
|
|
@@ -231,20 +250,25 @@ static func _pointer_takers(root: Node) -> Array[Node]:
|
|
|
231
250
|
## control holding the target or held by it is part of the same click. A target whose centre its
|
|
232
251
|
## own container clips away is judged not covered, since the click scrolls it into view first and
|
|
233
252
|
## what is drawn there now is not what it will land on.
|
|
234
|
-
|
|
253
|
+
##
|
|
254
|
+
## [param areas] holds the box around each of [param drawn], so everything whose box misses the
|
|
255
|
+
## point is passed over before anything costlier is asked of it.
|
|
256
|
+
static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2]) -> Node:
|
|
235
257
|
var viewport: Viewport = target.get_viewport()
|
|
236
258
|
var point: Vector2 = target.get_global_transform_with_canvas() * (target.size * 0.5)
|
|
237
259
|
if _clipped_away(target, point):
|
|
238
260
|
return null
|
|
239
261
|
var layer: int = _layer_of(target)
|
|
240
|
-
for
|
|
262
|
+
for at: int in drawn.size():
|
|
263
|
+
if not areas[at].has_point(point):
|
|
264
|
+
continue
|
|
265
|
+
var other: Node = drawn[at]
|
|
241
266
|
var window: Window = other as Window
|
|
242
267
|
if window != null:
|
|
243
268
|
if (
|
|
244
269
|
window.get_parent() != null
|
|
245
270
|
and window.get_parent().get_viewport() == viewport
|
|
246
271
|
and not window.is_ancestor_of(target)
|
|
247
|
-
and Rect2(window.position, window.size).has_point(point)
|
|
248
272
|
):
|
|
249
273
|
return window
|
|
250
274
|
continue
|
package/build/index.js
CHANGED
|
@@ -30754,7 +30754,7 @@ var TOOL_SPECS = [
|
|
|
30754
30754
|
requires: []
|
|
30755
30755
|
},
|
|
30756
30756
|
rect: {
|
|
30757
|
-
summary: "one node's rectangle or position, in canvas and in window pixels. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
|
|
30757
|
+
summary: "one node's rectangle or position, in canvas and in window pixels. Canvas is the node's own viewport; window is where it is drawn in the game's window, through any embedded dialog or SubViewport it sits in, which is what runtime_input mouse_click and mouse_motion take. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
|
|
30758
30758
|
requires: ["nodePath"]
|
|
30759
30759
|
},
|
|
30760
30760
|
property: {
|
|
@@ -30953,7 +30953,7 @@ var TOOL_SPECS = [
|
|
|
30953
30953
|
requires: []
|
|
30954
30954
|
},
|
|
30955
30955
|
choose: {
|
|
30956
|
-
summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen and what the button in front of it shows now",
|
|
30956
|
+
summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen, read before the press, what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick), and what the button in front of it shows now when it is still there",
|
|
30957
30957
|
requires: ["nodePath"]
|
|
30958
30958
|
},
|
|
30959
30959
|
action: { summary: "press an action, or hold it", requires: ["action"] },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.13",
|
|
4
4
|
"mcpName": "io.github.Aureliolo/gdharness",
|
|
5
5
|
"description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
|
|
6
6
|
"type": "module",
|