gdharness 1.2.39 → 1.2.40

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.
@@ -12,6 +12,7 @@ const Values = preload("runtime_values.gd")
12
12
  const Queries = preload("runtime_queries.gd")
13
13
  const Read = preload("reading.gd")
14
14
  const Hover = preload("runtime_hover.gd")
15
+ const Items = preload("runtime_items.gd")
15
16
  const Screen = preload("runtime_screen.gd")
16
17
  const Targets = preload("runtime_targets.gd")
17
18
  const Mouse = preload("runtime_mouse.gd")
@@ -509,6 +510,7 @@ func click(params: Dictionary) -> Dictionary:
509
510
  var node_path: String = str(params.get("path", ""))
510
511
  var wanted: String = str(params.get("says", ""))
511
512
  var found: Variant = null
513
+ var item: Variant = null
512
514
  if not wanted.is_empty():
513
515
  var picked: Dictionary = Targets.control_saying(
514
516
  _host.get_tree().root, "/root" if node_path.is_empty() else node_path, wanted, params.get("index")
@@ -517,6 +519,7 @@ func click(params: Dictionary) -> Dictionary:
517
519
  return picked
518
520
  node_path = picked["path"]
519
521
  found = picked["found"]
522
+ item = picked.get("item")
520
523
  if node_path.is_empty():
521
524
  return {"type": "error", "message": "click needs a path, or says to find the control by its words"}
522
525
 
@@ -541,6 +544,16 @@ func click(params: Dictionary) -> Dictionary:
541
544
  # other words, a link among them. Said either way, so a press on the middle is never read as one
542
545
  # on the words.
543
546
  var aim: Vector2 = control.size * 0.5
547
+ var item_scrolled: bool = false
548
+ if item != null:
549
+ var found_by_words: Dictionary = found
550
+ var placed_item: Dictionary = await Items.in_view(
551
+ _host.get_tree(), control, node_path, item, wanted, found_by_words
552
+ )
553
+ if placed_item.has("message"):
554
+ return placed_item
555
+ aim = placed_item["point"]
556
+ item_scrolled = placed_item["scrolled"]
544
557
  var rich: RichTextLabel = control as RichTextLabel
545
558
  if found != null and rich != null:
546
559
  var placed: Dictionary = Targets.point_on_words(rich, wanted)
@@ -568,9 +581,9 @@ func click(params: Dictionary) -> Dictionary:
568
581
  # off the top of its container is inside the viewport, behind whatever is drawn up there, so
569
582
  # the click went to that instead and said so. Measured on a hall whose staff panel had been
570
583
  # scrolled past: the button was at y 69 and its container started at y 166.
571
- var scrolled: bool = false
584
+ var scrolled: bool = item_scrolled
572
585
  if not _in_sight(control, own, local):
573
- scrolled = await _scroll_into_view(control)
586
+ scrolled = await _scroll_into_view(control) or item_scrolled
574
587
  var left: String = Values.afterwards(control)
575
588
  if left != "in_tree":
576
589
  return {
@@ -0,0 +1,175 @@
1
+ extends RefCounted
2
+
3
+ ## Where a control drawing a list draws one of its items, for a click by words: a tab, a list entry,
4
+ ## a tree row or a menu bar title. Each is drawn by the control rather than held as a node, so a click
5
+ ## on the control's path presses its middle, which is whichever item happens to sit there.
6
+
7
+ const Says = preload("runtime_says.gd")
8
+ const Values = preload("runtime_values.gd")
9
+ const Words = preload("runtime_words.gd")
10
+
11
+
12
+ ## Where [param control] draws [param item], one of [method Words.items_of], in the control's own
13
+ ## space, or an empty rectangle while it draws it nowhere a pointer reaches: a tab scrolled off its
14
+ ## bar, an entry or a row scrolled out of its list, a menu bar the operating system draws. A tree
15
+ ## row is the cell saying [param wanted] when one does, and the whole row otherwise.
16
+ ##
17
+ ## Each one checked against the control's own answer to which item is under a point, where it has
18
+ ## one, since an entry's rectangle is kept in the list's unscrolled space and a row's is computed
19
+ ## from the scroll as of the last draw.
20
+ static func item_rect(control: Control, item: Variant, wanted: String) -> Rect2:
21
+ var bounds: Rect2 = Rect2(Vector2.ZERO, control.size)
22
+ var bar: TabBar = control as TabBar
23
+ if bar != null:
24
+ var tab: int = item
25
+ var placed: Rect2 = bar.get_tab_rect(tab)
26
+ return placed if bar.get_tab_idx_at_point(placed.get_center()) == tab else Rect2()
27
+ var list: ItemList = control as ItemList
28
+ if list != null:
29
+ var entry: int = item
30
+ var scrolled: Vector2 = Vector2(list.get_h_scroll_bar().value, list.get_v_scroll_bar().value)
31
+ var placed: Rect2 = list.get_item_rect(entry, false)
32
+ placed.position -= scrolled
33
+ var reached: bool = bounds.has_point(placed.get_center())
34
+ return (
35
+ placed if reached and list.get_item_at_position(placed.get_center(), true) == entry else Rect2()
36
+ )
37
+ var tree: Tree = control as Tree
38
+ if tree != null:
39
+ var row: TreeItem = item
40
+ var column: int = tree_column(tree, row, wanted)
41
+ var placed: Rect2 = tree.get_item_area_rect(row, column)
42
+ var centre: Vector2 = placed.get_center()
43
+ if not bounds.has_point(centre) or tree.get_item_at_position(centre) != row:
44
+ return Rect2()
45
+ return placed if column == -1 or tree.get_column_at_position(centre) == column else Rect2()
46
+ var menus: MenuBar = control as MenuBar
47
+ if menus != null and not menus.is_native_menu():
48
+ var menu: int = item
49
+ return _menu_title_rect(menus, menu)
50
+ return Rect2()
51
+
52
+
53
+ ## The column of [param row] whose cell says [param wanted], or -1 when only the row as a whole
54
+ ## does, its cells read together.
55
+ static func tree_column(tree: Tree, row: TreeItem, wanted: String) -> int:
56
+ for column: int in tree.columns:
57
+ if Says.matches(Words.cell_says(tree, row, column), wanted):
58
+ return column
59
+ return -1
60
+
61
+
62
+ ## [param item] of [param control] as an answer names it: what it is, its number where the control
63
+ ## numbers its items, and what it says.
64
+ static func item_named(control: Control, item: Variant, says: String) -> Dictionary:
65
+ if control is Tree:
66
+ return {"kind": "row", "says": says}
67
+ var kind: String = "tab" if control is TabBar else ("menu" if control is MenuBar else "item")
68
+ return {"kind": kind, "index": item, "says": says}
69
+
70
+
71
+ ## Where in [param control] to press for [param item], the one of its items a click by
72
+ ## [param wanted] matched, as {"point", "scrolled"}, or a refusal saying why there is nowhere.
73
+ ##
74
+ ## Scrolled into the control's own view first when it is drawn nowhere yet, the way a player brings
75
+ ## a tab or an entry into view before pressing it. A disabled item is refused as a disabled button
76
+ ## is. A tree row is pressed on the cell saying the words, whose column goes into [param found].
77
+ static func in_view(
78
+ scene: SceneTree, control: Control, node_path: String, item: Variant, wanted: String, found: Dictionary
79
+ ) -> Dictionary:
80
+ var named: Dictionary = found.get("item", {})
81
+ var naming: String = 'the %s "%s" of %s' % [named.get("kind", "item"), named.get("says", ""), node_path]
82
+ var index: int = item if item is int else -1
83
+ var bar: TabBar = control as TabBar
84
+ var list: ItemList = control as ItemList
85
+ var menus: MenuBar = control as MenuBar
86
+ if (
87
+ (bar != null and bar.is_tab_disabled(index))
88
+ or (list != null and list.is_item_disabled(index))
89
+ or (menus != null and menus.is_menu_disabled(index))
90
+ ):
91
+ return {"type": "error", "message": "%s is disabled, so clicking it presses nothing" % naming}
92
+ var placed: Rect2 = item_rect(control, item, wanted)
93
+ var scrolled: bool = false
94
+ if placed.size == Vector2.ZERO:
95
+ scrolled = _scroll_to(control, item)
96
+ if scrolled:
97
+ # A tree places its rows by the scroll it last drew with.
98
+ await scene.process_frame
99
+ var left: String = Values.afterwards(control)
100
+ if left != "in_tree" or (typeof(item) == TYPE_OBJECT and not is_instance_valid(item)):
101
+ return {
102
+ "type": "error",
103
+ "message":
104
+ "%s went while it was being scrolled into view, so nothing was clicked" % naming
105
+ }
106
+ placed = item_rect(control, item, wanted)
107
+ if placed.size == Vector2.ZERO:
108
+ var tried: String = " even scrolled to" if scrolled else ""
109
+ return {
110
+ "type": "error",
111
+ "message": "%s is drawn nowhere a pointer reaches%s, so nothing was clicked" % [naming, tried]
112
+ }
113
+ var tree: Tree = control as Tree
114
+ if tree != null:
115
+ var row: TreeItem = item
116
+ var column: int = tree_column(tree, row, wanted)
117
+ if column != -1:
118
+ named["column"] = column
119
+ return {"point": placed.get_center(), "scrolled": scrolled}
120
+
121
+
122
+ ## Where [param menus] draws the title of [param menu], worked out as the engine works it out,
123
+ ## since the bar keeps it to itself: each title shaped in the bar's font, padded by its normal style
124
+ ## and spaced by its separation, counting only the menus it shows.
125
+ static func _menu_title_rect(menus: MenuBar, menu: int) -> Rect2:
126
+ var style: StyleBox = menus.get_theme_stylebox("normal")
127
+ var separation: int = menus.get_theme_constant("h_separation")
128
+ var offset: int = 0
129
+ for before: int in menu:
130
+ if not menus.is_menu_hidden(before):
131
+ offset += int(_menu_title_size(menus, before, style).x + separation)
132
+ var size: Vector2 = _menu_title_size(menus, menu, style)
133
+ var x: float = menus.size.x - offset - size.x if menus.is_layout_rtl() else float(offset)
134
+ return Rect2(Vector2(x, 0), size)
135
+
136
+
137
+ static func _menu_title_size(menus: MenuBar, menu: int, style: StyleBox) -> Vector2:
138
+ var line: TextLine = TextLine.new()
139
+ var _shaped: bool = line.add_string(
140
+ menus.atr(menus.get_menu_title(menu)),
141
+ menus.get_theme_font("font"),
142
+ menus.get_theme_font_size("font_size"),
143
+ menus.language
144
+ )
145
+ return line.get_size() + style.get_minimum_size()
146
+
147
+
148
+ ## Scrolls [param control] until it draws [param item], and answers whether it asked anything to
149
+ ## move. A tab bar and a tree have a call for it; a list is scrolled by its own bar, by as little as
150
+ ## brings the whole entry in.
151
+ static func _scroll_to(control: Control, item: Variant) -> bool:
152
+ var index: int = item if item is int else -1
153
+ var bar: TabBar = control as TabBar
154
+ if bar != null:
155
+ var before: int = bar.get_tab_offset()
156
+ bar.ensure_tab_visible(index)
157
+ return bar.get_tab_offset() != before
158
+ var tree: Tree = control as Tree
159
+ if tree != null:
160
+ var row: TreeItem = item
161
+ var was: float = tree.get_scroll().y
162
+ tree.scroll_to_item(row)
163
+ return tree.get_scroll().y != was
164
+ var list: ItemList = control as ItemList
165
+ if list == null:
166
+ return false
167
+ var rows: VScrollBar = list.get_v_scroll_bar()
168
+ var entry: Rect2 = list.get_item_rect(index, false)
169
+ var top: float = entry.position.y - list.get_theme_stylebox("panel").get_offset().y
170
+ var was_at: float = rows.value
171
+ if top < rows.value:
172
+ rows.value = top
173
+ elif top + entry.size.y > rows.value + rows.page:
174
+ rows.value = top + entry.size.y - rows.page
175
+ return rows.value != was_at
@@ -0,0 +1 @@
1
+ uid://mynpfgtjyipe
@@ -254,12 +254,17 @@ static func can_read(holder: Variant, named: String) -> bool:
254
254
  ## reports an engine error for one of another type, so a step into a map typed by an enum read the
255
255
  ## right entry and left four errors in the game's log for each of the two walks, and the run was
256
256
  ## counted as unclean for a read.
257
+ ##
258
+ ## A step written in double quotes is the key inside them, so a key holding a colon is one step:
259
+ ## Alchemill keys its stock by namespaced ids, "stock:\"base:dew\"", and split at that colon the
260
+ ## step named a key "base" the map had never had.
257
261
  static func _key_in(map: Dictionary, named: String) -> Array:
258
- var spellings: Array = [named, StringName(named)]
259
- if named.is_valid_int():
260
- spellings.append(int(named))
261
- if named.is_valid_float():
262
- spellings.append(float(named))
262
+ var word: String = key_written(named)
263
+ var spellings: Array = [word, StringName(word)]
264
+ if word.is_valid_int():
265
+ spellings.append(int(word))
266
+ if word.is_valid_float():
267
+ spellings.append(float(word))
263
268
  var typed: int = map.get_typed_key_builtin() if map.is_typed_key() else TYPE_NIL
264
269
  for key: Variant in spellings:
265
270
  if typed != TYPE_NIL and typeof(key) != typed:
@@ -269,9 +274,35 @@ static func _key_in(map: Dictionary, named: String) -> Array:
269
274
  return []
270
275
 
271
276
 
277
+ ## The key a step names: the words inside the quotes of one written in double quotes, read as JSON
278
+ ## reads a string so a quote inside is written \", and the step itself otherwise.
279
+ static func key_written(named: String) -> String:
280
+ if named.length() < 2 or not named.begins_with('"') or not named.ends_with('"'):
281
+ return named
282
+ var reader: JSON = JSON.new()
283
+ if reader.parse(named) != OK or not reader.data is String:
284
+ return named
285
+ return reader.data
286
+
287
+
288
+ ## [param key] written as a step that names it: in double quotes when it holds what a bare step
289
+ ## cannot, a colon a path splits at, a bracket that reads as a call, a quote, or nothing at all.
290
+ static func step_naming(key: Variant) -> String:
291
+ var text: String = str(key)
292
+ if not (key is String or key is StringName):
293
+ return text
294
+ if text.is_empty():
295
+ return JSON.stringify(text)
296
+ for character: String in [":", "(", ")", '"']:
297
+ if text.contains(character):
298
+ return JSON.stringify(text)
299
+ return text
300
+
301
+
272
302
  ## [param reaching] split into its steps at each colon, except one inside a call's brackets or a
273
- ## quoted argument: `get_node("/root/Main"):name` is two steps, and split at every colon a quoted
274
- ## argument holding one became a step that names nothing.
303
+ ## quoted argument or key: `get_node("/root/Main"):name` is two steps, and so is
304
+ ## `stock:"base:dew"`; split at every colon a quoted argument holding one became a step that names
305
+ ## nothing.
275
306
  static func steps_of(reaching: String) -> PackedStringArray:
276
307
  var steps: PackedStringArray = []
277
308
  var step: String = ""
@@ -569,17 +600,19 @@ static func nothing_there(holder: Variant, named: String, called_as: String) ->
569
600
  var keys: Array = map.keys()
570
601
  # A key reading as the step and still not reached is one of a type a step cannot spell, and
571
602
  # saying the map has no key 3 while listing 3 among its keys contradicts itself.
603
+ var word: String = key_written(named)
572
604
  for key: Variant in keys:
573
- if str(key) == named:
605
+ if str(key) == word:
574
606
  return (
575
607
  "%s has a key that reads %s, but it is %s, which a path step cannot name"
576
608
  % [called_as, named, type_string(typeof(key))]
577
609
  )
578
610
  # Eight of them, because a map keyed by something unexpected is told by the first few and a
579
- # map of two hundred would bury the sentence saying which key was missing.
611
+ # map of two hundred would bury the sentence saying which key was missing. Each written as a
612
+ # step reaching it, so the list says how to name a key holding a colon.
580
613
  var some: Array[String] = []
581
614
  for key: Variant in keys.slice(0, 8):
582
- some.append(str(key))
615
+ some.append(step_naming(key))
583
616
  var rest: String = ""
584
617
  if keys.size() > 8:
585
618
  rest = " and %d more" % [keys.size() - 8]
@@ -2,6 +2,7 @@ extends RefCounted
2
2
 
3
3
  ## Which control a click means when it names the control by the words on it rather than by path.
4
4
 
5
+ const Items = preload("runtime_items.gd")
5
6
  const Queries = preload("runtime_queries.gd")
6
7
  const Read = preload("reading.gd")
7
8
  const Says = preload("runtime_says.gd")
@@ -24,7 +25,8 @@ const WHOLE: int = 1
24
25
 
25
26
  ## The control a click by [param wanted] words presses, found under [param root_path] in the tree
26
27
  ## [param root] heads, or the refusal saying why there is none or which of several:
27
- ## {"path", "found"} or {"type", "message"}.
28
+ ## {"path", "found"} or {"type", "message"}, with `item` as well when the match is an item of a
29
+ ## control drawing a list, which [method Items.item_rect] places.
28
30
  ##
29
31
  ## Matched the way a find matches `says`, against a control's own words, and only among controls
30
32
  ## the player can see. A match inside a button presses the button, because the label is what says
@@ -57,6 +59,10 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
57
59
  # that only says them as part of more stands in for.
58
60
  var exact_out_of_reach: Array[Node] = []
59
61
  var out_of_reach_as: Array[String] = []
62
+ # The item of a control drawing a list that matched, null for a control matched as a whole, and
63
+ # the point in the control a pointer would go to for it.
64
+ var items: Array = []
65
+ var aims: Array[Vector2] = []
60
66
  var pending: Array[Node] = [reached["node"]]
61
67
  while not pending.is_empty():
62
68
  var node: Node = pending.pop_front()
@@ -66,6 +72,26 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
66
72
  if _says_exactly(Words.said_by(node), wanted):
67
73
  exact_out_of_reach.append(node)
68
74
  out_of_reach_as.append("hidden")
75
+ elif Words.draws_a_list(node):
76
+ # Each item a match of its own, so the words of one tab are matched as a whole and the
77
+ # index picks between two items saying them as it does between two buttons.
78
+ var drawing: Control = node
79
+ for entry: Dictionary in Words.items_of(drawing):
80
+ var words: String = entry["says"]
81
+ if not Says.matches(words, wanted):
82
+ continue
83
+ var whole: bool = _says_exactly(words, wanted)
84
+ var drawn_at_now: Rect2 = Items.item_rect(drawing, entry["item"], wanted)
85
+ matched.append(drawing)
86
+ said.append(words)
87
+ exact.append(whole)
88
+ inside.append(not whole and _inside_a_word(words, wanted))
89
+ items.append(entry["item"])
90
+ # An item drawn nowhere yet, scrolled out of its list, is judged at the list's middle,
91
+ # which is where the click scrolls it towards.
92
+ aims.append(
93
+ drawing.size * 0.5 if drawn_at_now.size == Vector2.ZERO else drawn_at_now.get_center()
94
+ )
69
95
  else:
70
96
  var control: Control = node
71
97
  var target: Control = _pressed_through(control)
@@ -78,6 +104,8 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
78
104
  said.append(words)
79
105
  exact.append(whole)
80
106
  inside.append(buried)
107
+ items.append(null)
108
+ aims.append(target.size * 0.5)
81
109
  elif (whole and not exact[at]) or (inside[at] and not buried):
82
110
  said[at] = words
83
111
  exact[at] = whole
@@ -93,13 +121,13 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
93
121
  var outside: int = 0
94
122
  var open: Array[int] = []
95
123
  for index: int in matched.size():
96
- if _off_screen(matched[index]):
124
+ if _off_screen(matched[index], aims[index]):
97
125
  outside += 1
98
126
  if exact[index]:
99
127
  exact_out_of_reach.append(matched[index])
100
128
  out_of_reach_as.append("outside what the screen shows")
101
129
  continue
102
- var over: Node = _cover_of(matched[index], drawn, areas)
130
+ var over: Node = _cover_of(matched[index], drawn, areas, aims[index])
103
131
  if over == null:
104
132
  open.append(index)
105
133
  else:
@@ -125,12 +153,14 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
125
153
  var lines: Array[String] = []
126
154
  var wholes: Array[bool] = []
127
155
  var insides: Array[bool] = []
156
+ var picked_items: Array = []
128
157
  for index: int in open:
129
158
  targets.append(matched[index])
130
159
  ranks.append(_rank(matched[index], exact[index]))
131
160
  lines.append(said[index])
132
161
  wholes.append(exact[index])
133
162
  insides.append(inside[index])
163
+ picked_items.append(items[index])
134
164
 
135
165
  var under: String = "" if root_path == "/root" else " under %s" % root_path
136
166
  if targets.is_empty():
@@ -257,7 +287,11 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
257
287
  found["picked"] = "%s; %s" % [_described(1, ranks[0], wanted, under), others]
258
288
  if covered > 0:
259
289
  found["covered"] = covered
260
- return {"path": str(targets[index].get_path()), "found": found}
290
+ var answer: Dictionary = {"path": str(targets[index].get_path()), "found": found}
291
+ if picked_items[index] != null:
292
+ answer["item"] = picked_items[index]
293
+ found["item"] = Items.item_named(targets[index], picked_items[index], lines[index])
294
+ return answer
261
295
 
262
296
 
263
297
  ## How good a match [param target] is for a click: a control the pointer can reach over one it
@@ -451,7 +485,7 @@ static func _pressed_through(control: Control) -> Control:
451
485
  ## What a pointer at [param target]'s centre would reach instead of it, drawn over it, or null.
452
486
  static func drawn_over(target: Control) -> Node:
453
487
  var drawn: Array[Node] = _pointer_takers(target.get_tree().root)
454
- return _cover_of(target, drawn, _areas_of(drawn))
488
+ return _cover_of(target, drawn, _areas_of(drawn), target.size * 0.5)
455
489
 
456
490
 
457
491
  ## Why [param node], which is not visible in the tree, is not, as a parenthesis to follow its path:
@@ -631,7 +665,8 @@ static func _areas_of(drawn: Array[Node]) -> Array[Rect2]:
631
665
  return areas
632
666
 
633
667
 
634
- ## What a pointer at [param target]'s centre reaches instead of it, or null when nothing does.
668
+ ## What a pointer at [param aim] in [param target], its centre or the middle of the item a click is
669
+ ## for, reaches instead of it, or null when nothing does.
635
670
  ##
636
671
  ## The engine's own order for which control takes the pointer: an embedded window over everything
637
672
  ## in the viewport that holds it, then the higher canvas layer, then the later in the tree. A
@@ -650,10 +685,10 @@ static func _areas_of(drawn: Array[Node]) -> Array[Rect2]:
650
685
  ## point is passed over before anything costlier is asked of it. A window's box is in the space of
651
686
  ## the viewport it is embedded in, which is not the target's when the target is in another window,
652
687
  ## so a window is judged by [method _window_over] instead.
653
- static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2]) -> Node:
688
+ static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2], aim: Vector2) -> Node:
654
689
  var viewport: Viewport = target.get_viewport()
655
690
  var moves: Dictionary[ScrollContainer, Vector2] = _scrolls_for(target)
656
- var point: Vector2 = _centre_of(target) + _moved_by(target, moves)
691
+ var point: Vector2 = target.get_global_transform_with_canvas() * aim + _moved_by(target, moves)
657
692
  var layer: int = _layer_of(target)
658
693
  for at: int in drawn.size():
659
694
  var other: Node = drawn[at]
@@ -770,13 +805,14 @@ static func _where_it_lands(target: Control, point: Vector2) -> Dictionary[Scrol
770
805
  return moves
771
806
 
772
807
 
773
- ## Whether a click could not reach [param target] however it scrolled: its centre, where the click
774
- ## would bring it, outside its viewport or the one showing that on the screen, or clipped away by a
775
- ## container the click does not scroll. A drawer parked off the side of the screen, or anything
776
- ## outside the viewport of a game with no window, was listed among the controls on screen.
777
- static func _off_screen(target: Control) -> bool:
808
+ ## Whether a click could not reach [param aim] in [param target] however it scrolled: the point,
809
+ ## where the click would bring it, outside its viewport or the one showing that on the screen, or
810
+ ## clipped away by a container the click does not scroll. A drawer parked off the side of the
811
+ ## screen, or anything outside the viewport of a game with no window, was listed among the controls
812
+ ## on screen.
813
+ static func _off_screen(target: Control, aim: Vector2) -> bool:
778
814
  var moves: Dictionary[ScrollContainer, Vector2] = _scrolls_for(target)
779
- var point: Vector2 = _centre_of(target) + _moved_by(target, moves)
815
+ var point: Vector2 = target.get_global_transform_with_canvas() * aim + _moved_by(target, moves)
780
816
  if _clipped_away(target, point, moves) or not target.get_viewport().get_visible_rect().has_point(point):
781
817
  return true
782
818
  var reached: Dictionary = Screen.reach(target.get_viewport(), point)
@@ -23,7 +23,7 @@ static func said_by(node: Node) -> String:
23
23
  var label: Label = node as Label
24
24
  if label != null:
25
25
  return _label_says(label)
26
- if _draws_a_list(node):
26
+ if draws_a_list(node):
27
27
  return "\n".join(lines_said_by(node))
28
28
  var own: String = _text_of(node)
29
29
  # A control showing a picture rather than words is named for a player who cannot see it, and that
@@ -41,37 +41,54 @@ static func said_by(node: Node) -> String:
41
41
  ## every label on it and none of those, and a wait for a tab's title or a list item was never met.
42
42
  static func lines_said_by(node: Node) -> Array[String]:
43
43
  var lines: Array[String] = []
44
+ if not draws_a_list(node):
45
+ _keep(_text_of(node), lines)
46
+ return lines
47
+ var tree: Tree = node as Tree
48
+ if tree != null and tree.column_titles_visible:
49
+ var titles: Array[String] = []
50
+ for column: int in tree.columns:
51
+ _keep(node.atr(tree.get_column_title(column)), titles)
52
+ _keep(" ".join(titles), lines)
53
+ for entry: Dictionary in items_of(node):
54
+ lines.append(str(entry["says"]))
55
+ return lines
56
+
57
+
58
+ ## Each item [param node] draws as one of a list, as {"says", "item"}: a tab, a menu title, a list
59
+ ## entry or a menu item by its index, and a tree row by its TreeItem. Empty for a node drawing none.
60
+ ## Held apart rather than joined, so a click on a tab's title presses that tab: the bar's middle is
61
+ ## whichever tab happens to sit there.
62
+ static func items_of(node: Node) -> Array[Dictionary]:
63
+ var items: Array[Dictionary] = []
44
64
  if node is TabBar:
45
65
  var bar: TabBar = node
46
66
  for tab: int in bar.tab_count:
47
67
  if not bar.is_tab_hidden(tab):
48
- _keep(node.atr(bar.get_tab_title(tab)), lines)
68
+ _keep_item(node.atr(bar.get_tab_title(tab)), tab, items)
49
69
  elif node is MenuBar:
50
70
  var menus: MenuBar = node
51
71
  for menu: int in menus.get_menu_count():
52
72
  if not menus.is_menu_hidden(menu):
53
- _keep(node.atr(menus.get_menu_title(menu)), lines)
73
+ _keep_item(node.atr(menus.get_menu_title(menu)), menu, items)
54
74
  elif node is ItemList:
55
75
  var list: ItemList = node
56
76
  for item: int in list.item_count:
57
- _keep(_as_drawn(node, list.get_item_text(item), list.get_item_auto_translate_mode(item)), lines)
77
+ _keep_item(
78
+ _as_drawn(node, list.get_item_text(item), list.get_item_auto_translate_mode(item)),
79
+ item,
80
+ items
81
+ )
58
82
  elif node is PopupMenu:
59
83
  # A separator too, by its title: the popup draws a titled one as a heading over the items
60
84
  # below it, and one without a title says nothing and is dropped as an empty line.
61
85
  var menu: PopupMenu = node
62
86
  for item: int in menu.item_count:
63
- _keep(item_says(menu, item), lines)
87
+ _keep_item(item_says(menu, item), item, items)
64
88
  elif node is Tree:
65
89
  var tree: Tree = node
66
- if tree.column_titles_visible:
67
- var titles: Array[String] = []
68
- for column: int in tree.columns:
69
- _keep(node.atr(tree.get_column_title(column)), titles)
70
- _keep(" ".join(titles), lines)
71
- _tree_rows(tree, tree.get_root(), tree.hide_root, lines)
72
- else:
73
- _keep(_text_of(node), lines)
74
- return lines
90
+ _tree_rows(tree, tree.get_root(), tree.hide_root, items)
91
+ return items
75
92
 
76
93
 
77
94
  ## What item [param index] of [param menu] shows: the translation where the item has one. A menu is
@@ -86,7 +103,9 @@ static func masked(field: LineEdit) -> String:
86
103
  return field.secret_character.repeat(field.text.length())
87
104
 
88
105
 
89
- static func _draws_a_list(node: Node) -> bool:
106
+ ## Whether [param node] draws a list of items rather than holding words in a `text`; see
107
+ ## [method items_of].
108
+ static func draws_a_list(node: Node) -> bool:
90
109
  return node is TabBar or node is MenuBar or node is ItemList or node is PopupMenu or node is Tree
91
110
 
92
111
 
@@ -96,6 +115,12 @@ static func _keep(line: String, into: Array[String]) -> void:
96
115
  into.append(trimmed)
97
116
 
98
117
 
118
+ static func _keep_item(line: String, item: Variant, into: Array[Dictionary]) -> void:
119
+ var trimmed: String = line.strip_edges()
120
+ if not trimmed.is_empty():
121
+ into.append({"says": trimmed, "item": item})
122
+
123
+
99
124
  ## [param words] as [param node] draws them, which for a game with translations is the translation
100
125
  ## rather than the key it holds: a control set to `UI_HIRE` shows "Hire", and a wait for "Hire" was
101
126
  ## never met. [param mode] is an item's own setting, which the engine reads before the node's.
@@ -113,14 +138,14 @@ static func _as_drawn(
113
138
  ## The rows of a tree from [param item] down, a row being its columns' text side by side, and
114
139
  ## nothing under a collapsed row or a hidden one, since neither is drawn. A hidden root is only the
115
140
  ## place its children hang from.
116
- static func _tree_rows(tree: Tree, item: TreeItem, skip: bool, into: Array[String]) -> void:
141
+ static func _tree_rows(tree: Tree, item: TreeItem, skip: bool, into: Array[Dictionary]) -> void:
117
142
  if item == null or not item.visible:
118
143
  return
119
144
  if not skip:
120
145
  var cells: Array[String] = []
121
146
  for column: int in tree.columns:
122
- _keep(_as_drawn(tree, item.get_text(column), item.get_auto_translate_mode(column)), cells)
123
- _keep(" ".join(cells), into)
147
+ _keep(cell_says(tree, item, column), cells)
148
+ _keep_item(" ".join(cells), item, into)
124
149
  if item.collapsed:
125
150
  return
126
151
  var child: TreeItem = item.get_first_child()
@@ -129,6 +154,11 @@ static func _tree_rows(tree: Tree, item: TreeItem, skip: bool, into: Array[Strin
129
154
  child = child.get_next()
130
155
 
131
156
 
157
+ ## What [param column] of the row [param item] of [param tree] shows.
158
+ static func cell_says(tree: Tree, item: TreeItem, column: int) -> String:
159
+ return _as_drawn(tree, item.get_text(column), item.get_auto_translate_mode(column))
160
+
161
+
132
162
  ## What a node with a `text` says, as it is drawn.
133
163
  static func _text_of(node: Node) -> String:
134
164
  var label: Label = node as Label