gdharness 1.1.45 → 1.1.47

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.
@@ -41,26 +41,38 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
41
41
  # The words of the control that matched, which a button pressed on a label's behalf has none of.
42
42
  var said: Array[String] = []
43
43
  var exact: Array[bool] = []
44
+ # Whether the words are only inside longer words there, "out" in "Scout".
45
+ var inside: Array[bool] = []
44
46
  var hidden: int = 0
47
+ # Controls saying exactly the words that a click cannot reach, kept to say what a match on screen
48
+ # that only says them as part of more stands in for.
49
+ var exact_out_of_reach: Array[Node] = []
50
+ var out_of_reach_as: Array[String] = []
45
51
  var pending: Array[Node] = [reached["node"]]
46
52
  while not pending.is_empty():
47
53
  var node: Node = pending.pop_front()
48
54
  if node is Control and Says.says(node, wanted):
49
55
  if not Queries.shown(node):
50
56
  hidden += 1
57
+ if _says_exactly(Words.said_by(node), wanted):
58
+ exact_out_of_reach.append(node)
59
+ out_of_reach_as.append("hidden")
51
60
  else:
52
61
  var control: Control = node
53
62
  var target: Control = _pressed_through(control)
54
63
  var words: String = Words.said_by(node)
55
64
  var whole: bool = _says_exactly(words, wanted)
65
+ var buried: bool = not whole and _inside_a_word(words, wanted)
56
66
  var at: int = matched.find(target)
57
67
  if at == -1:
58
68
  matched.append(target)
59
69
  said.append(words)
60
70
  exact.append(whole)
61
- elif whole and not exact[at]:
71
+ inside.append(buried)
72
+ elif (whole and not exact[at]) or (inside[at] and not buried):
62
73
  said[at] = words
63
- exact[at] = true
74
+ exact[at] = whole
75
+ inside[at] = buried
64
76
  var children: Array[Node] = node.get_children(true)
65
77
  for index: int in range(children.size() - 1, -1, -1):
66
78
  pending.push_front(children[index])
@@ -74,17 +86,27 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
74
86
  for index: int in matched.size():
75
87
  if _off_screen(matched[index]):
76
88
  outside += 1
89
+ if exact[index]:
90
+ exact_out_of_reach.append(matched[index])
91
+ out_of_reach_as.append("outside what the screen shows")
77
92
  continue
78
93
  var over: Node = _cover_of(matched[index], drawn, areas)
79
94
  if over == null:
80
95
  open.append(index)
81
96
  else:
82
97
  covered += 1
98
+ if exact[index]:
99
+ exact_out_of_reach.append(matched[index])
100
+ out_of_reach_as.append("under %s, which is drawn over it" % over.get_path())
83
101
  if cover == null:
84
102
  cover = over
85
- # Stable, so matches of one rank keep the order they are drawn in.
103
+ # Stable, so matches of one rank keep the order they are drawn in. Words only inside longer words
104
+ # come after everything else: ranked as a button saying them as part of more, "Scout, 90" was
105
+ # pressed for "Out" over a control saying exactly "Out", and spent a game's silver.
86
106
  open.sort_custom(
87
107
  func(a: int, b: int) -> bool:
108
+ if inside[a] != inside[b]:
109
+ return inside[b]
88
110
  var first: int = _rank(matched[a], exact[a])
89
111
  var second: int = _rank(matched[b], exact[b])
90
112
  return first > second or (first == second and a < b)
@@ -92,10 +114,14 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
92
114
  var targets: Array[Control] = []
93
115
  var ranks: Array[int] = []
94
116
  var lines: Array[String] = []
117
+ var wholes: Array[bool] = []
118
+ var insides: Array[bool] = []
95
119
  for index: int in open:
96
120
  targets.append(matched[index])
97
121
  ranks.append(_rank(matched[index], exact[index]))
98
122
  lines.append(said[index])
123
+ wholes.append(exact[index])
124
+ insides.append(inside[index])
99
125
 
100
126
  var under: String = "" if root_path == "/root" else " under %s" % root_path
101
127
  if targets.is_empty():
@@ -103,11 +129,21 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
103
129
  "type": "error",
104
130
  "message":
105
131
  (
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)]
132
+ 'no control on screen%s says "%s", so nothing was clicked%s%s%s%s'
133
+ % [
134
+ under,
135
+ wanted,
136
+ _hidden_note(hidden),
137
+ _outside_note(outside),
138
+ _covered_note(covered, cover),
139
+ _open_menu_note(root, wanted),
140
+ ]
108
141
  )
109
142
  }
110
- var best: int = ranks.count(ranks[0])
143
+ var best: int = 0
144
+ for at: int in targets.size():
145
+ if ranks[at] == ranks[0] and insides[at] == insides[0]:
146
+ best += 1
111
147
  if which == null and best > 1:
112
148
  var order: String = "" if best == targets.size() else ", best matches first"
113
149
  return {
@@ -129,7 +165,77 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
129
165
  % [str(which), targets.size(), plural, under, wanted, _candidates(targets, lines)]
130
166
  )
131
167
  }
168
+ # A match saying the words as part of more, while another control says exactly them, is a stand-in
169
+ # for that one, not the control meant: a tab reading "Out" sat in a collapsed drawer and a dispatch
170
+ # saying somebody "fell out in the hall" was pressed, and with the tab's own words on screen in a
171
+ # control that is not a button, a card reading "Scout, 90" was. Pressed only when asked for by
172
+ # index, which is the caller saying the match is what they meant.
173
+ var exact_elsewhere: Array[Node] = exact_out_of_reach.duplicate()
174
+ var elsewhere_as: Array[String] = out_of_reach_as.duplicate()
175
+ # On screen only when it takes clicks itself: a heading saying exactly "Onward" beside a button
176
+ # saying "Onward now" is not what a click on "Onward" is for, and the button is pressed.
177
+ for other: int in targets.size():
178
+ var taking: bool = targets[other].get_mouse_filter_with_override() != Control.MOUSE_FILTER_IGNORE
179
+ if other != index and wholes[other] and taking:
180
+ exact_elsewhere.append(targets[other])
181
+ elsewhere_as.append("on screen, taking clicks but not an enabled button")
182
+ var best_one: String = "the one on screen," if targets.size() == 1 else "the best match on screen,"
183
+ if which == null and not wholes[index] and not exact_elsewhere.is_empty():
184
+ return {
185
+ "type": "error",
186
+ "message":
187
+ (
188
+ (
189
+ 'no %s on screen%s says exactly "%s", so nothing was clicked: %s %s says it as part of'
190
+ + ' "%s", while %s; pass index %d to press that one anyway'
191
+ )
192
+ % [
193
+ "button" if exact_elsewhere.size() > exact_out_of_reach.size() else "control",
194
+ under,
195
+ wanted,
196
+ best_one,
197
+ targets[index].get_path(),
198
+ _shortened(lines[index]),
199
+ _out_of_reach_exactly(exact_elsewhere, elsewhere_as),
200
+ index,
201
+ ]
202
+ )
203
+ }
204
+ if which == null and insides[index]:
205
+ return {
206
+ "type": "error",
207
+ "message":
208
+ (
209
+ (
210
+ 'no control on screen%s says "%s" as a word of its own, so nothing was clicked: %s %s has'
211
+ + ' it only inside a longer word, "%s"; pass index %d to press that one anyway'
212
+ )
213
+ % [under, wanted, best_one, targets[index].get_path(), _shortened(lines[index]), index]
214
+ )
215
+ }
216
+ # Words in a sentence on a control that lets the pointer through: the press lands on whatever is
217
+ # under it, which is nothing the words name. A line saying what "is out there" was the best match
218
+ # left for "Out" once the cards saying "Scout" were passed over.
219
+ if (
220
+ which == null
221
+ and not wholes[index]
222
+ and targets[index].get_mouse_filter_with_override() == Control.MOUSE_FILTER_IGNORE
223
+ ):
224
+ return {
225
+ "type": "error",
226
+ "message":
227
+ (
228
+ (
229
+ 'no control on screen%s that takes a click says "%s", so nothing was clicked: %s %s says it'
230
+ + ' as part of "%s" and lets the pointer through to what is under it; pass index %d to'
231
+ + " click there anyway"
232
+ )
233
+ % [under, wanted, best_one, targets[index].get_path(), _shortened(lines[index]), index]
234
+ )
235
+ }
132
236
  var found: Dictionary = {"says": wanted, "index": index, "of": targets.size()}
237
+ if not wholes[index]:
238
+ found["partOf"] = _shortened(lines[index])
133
239
  if which == null and targets.size() > 1:
134
240
  var others: String = (
135
241
  "the other one on screen %s" % _passed_over(ranks[0], true)
@@ -157,7 +263,8 @@ static func _rank(target: Control, whole: bool) -> int:
157
263
  ## A line counts because a card is a title over a description: "WARD" over "wards 3" is the card
158
264
  ## named WARD, and read as its words run together it said WARD only as part of more, level with
159
265
  ## "WARDSPITE" over its own description, and a click on WARD was refused as not clear.
160
- static func _says_exactly(said: String, wanted: String) -> bool:
266
+ static func _says_exactly(written: String, wanted: String) -> bool:
267
+ var said: String = Says.spaced(written)
161
268
  for words: String in Says.alternatives(wanted):
162
269
  if not Says.is_plain(words):
163
270
  if said.matchn(words):
@@ -172,6 +279,32 @@ static func _says_exactly(said: String, wanted: String) -> bool:
172
279
  return false
173
280
 
174
281
 
282
+ ## Whether [param written] has the plain words [param wanted] asks for only inside longer words,
283
+ ## never starting where a word starts: "out" in "Scout". Words starting a longer one count as said,
284
+ ## "Recruit" in "Recruits", and so does anything matched by a pattern.
285
+ static func _inside_a_word(written: String, wanted: String) -> bool:
286
+ var said: String = Says.spaced(written).to_lower()
287
+ var found: bool = false
288
+ for words: String in Says.alternatives(wanted):
289
+ var looked_for: String = words.to_lower()
290
+ if not Says.is_plain(words) or not _in_a_word(looked_for.unicode_at(0)):
291
+ return false
292
+ var at: int = said.find(looked_for)
293
+ while at >= 0:
294
+ found = true
295
+ if at == 0 or not _in_a_word(said.unicode_at(at - 1)):
296
+ return false
297
+ at = said.find(looked_for, at + 1)
298
+ return found
299
+
300
+
301
+ ## Whether the character [param code] is part of a word: a letter, a digit or an underscore. A letter
302
+ ## is told by having a case, which leaves scripts without one counted as breaks between words.
303
+ static func _in_a_word(code: int) -> bool:
304
+ var character: String = String.chr(code)
305
+ return character.to_lower() != character.to_upper() or character.is_valid_int() or character == "_"
306
+
307
+
175
308
  ## [param count] matches of [param rank], as the start of a sentence.
176
309
  static func _described(count: int, rank: int, wanted: String, under: String) -> String:
177
310
  var what: String = "button" if rank >= 2 else "control"
@@ -461,7 +594,7 @@ static func _clipped_away(
461
594
  ## words. A line in another font or holding an image measures off from what is drawn, which a wide
462
595
  ## line with nothing between the words and its start hardly does.
463
596
  static func point_on_words(label: RichTextLabel, wanted: String) -> Dictionary:
464
- var text: String = Words.said_by(label)
597
+ var text: String = Says.spaced(Words.said_by(label))
465
598
  var start: int = -1
466
599
  var length: int = 0
467
600
  for words: String in Says.alternatives(wanted):
@@ -515,10 +648,48 @@ static func _layer_of(control: Control) -> int:
515
648
  static func _candidates(targets: Array[Control], said: Array[String]) -> String:
516
649
  var named: Array[String] = []
517
650
  for index: int in mini(targets.size(), LISTED_CANDIDATES):
518
- var words: String = said[index].replace("\n", " ")
519
- if words.length() > 60:
520
- words = words.substr(0, 57) + "..."
521
- named.append('%d %s ("%s")' % [index, targets[index].get_path(), words])
651
+ named.append('%d %s ("%s")' % [index, targets[index].get_path(), _shortened(said[index])])
522
652
  if targets.size() > LISTED_CANDIDATES:
523
653
  named.append("and %d more" % (targets.size() - LISTED_CANDIDATES))
524
654
  return "; ".join(named)
655
+
656
+
657
+ ## What to add to a refusal that found nothing on screen, when an open menu has an item saying the
658
+ ## words: a menu draws its items rather than holding a control for each, so a click finds none, while
659
+ ## a wait for the words finds the item. A wait met on "Leave the hall" was followed by a click on it
660
+ ## refused as nothing on screen saying it, with no word of the call that chooses it.
661
+ static func _open_menu_note(root: Node, wanted: String) -> String:
662
+ var pending: Array[Node] = [root]
663
+ while not pending.is_empty():
664
+ var node: Node = pending.pop_back()
665
+ var menu: PopupMenu = node as PopupMenu
666
+ if menu != null and menu.visible:
667
+ for item: int in menu.item_count:
668
+ var text: String = menu.get_item_text(item)
669
+ if Says.matches(text, wanted):
670
+ var owner: Node = menu.get_parent()
671
+ var named: Node = owner if owner is MenuButton or owner is OptionButton else menu
672
+ return (
673
+ (
674
+ '; the open menu %s has an item saying it, "%s", which a click cannot reach:'
675
+ + ' choose it with runtime_input choose, path %s and text "%s"'
676
+ )
677
+ % [menu.get_path(), text, named.get_path(), text]
678
+ )
679
+ pending.append_array(node.get_children(true))
680
+ return ""
681
+
682
+
683
+ ## [param words] on one line and at most sixty characters, for naming a control by what it says.
684
+ static func _shortened(words: String) -> String:
685
+ var flat: String = words.replace("\n", " ").strip_edges()
686
+ return flat if flat.length() <= 60 else flat.substr(0, 57) + "..."
687
+
688
+
689
+ ## The controls in [param nodes], each saying the words exactly and out of a click's reach for the
690
+ ## reason [param why] holds at the same place, as the end of a sentence.
691
+ static func _out_of_reach_exactly(nodes: Array[Node], why: Array[String]) -> String:
692
+ var first: String = "%s, %s" % [nodes[0].get_path(), why[0]]
693
+ if nodes.size() == 1:
694
+ return "1 control says exactly that and cannot be clicked: %s" % first
695
+ return "%d controls say exactly that and cannot be clicked, such as %s" % [nodes.size(), first]
@@ -25,7 +25,14 @@ static func said_by(node: Node) -> String:
25
25
  return _label_says(label)
26
26
  if _draws_a_list(node):
27
27
  return "\n".join(lines_said_by(node))
28
- return _text_of(node)
28
+ var own: String = _text_of(node)
29
+ # A control showing a picture rather than words is named for a player who cannot see it, and that
30
+ # name is the one the words a caller asks by are meant for: the rail's "Out" tab is a square with
31
+ # an icon on it, and a click on "Out" found only the words around it.
32
+ if own.is_empty() and node is Control:
33
+ var control: Control = node
34
+ return control.atr(control.accessibility_name).strip_edges()
35
+ return own
29
36
 
30
37
 
31
38
  ## Every line [param node] draws for a player to read, one to an entry: a label's one line, or each
@@ -139,12 +146,16 @@ static func _text_of(node: Node) -> String:
139
146
  if not text is String:
140
147
  return ""
141
148
  var words: String = text
142
- # What somebody typed is drawn as typed, never translated.
149
+ # What somebody typed is drawn as typed, never translated. An empty field draws its placeholder
150
+ # instead, which is what a player reads it by: a search field was found by nothing it showed.
143
151
  if node is LineEdit:
144
152
  var field: LineEdit = node
153
+ if words.is_empty():
154
+ return field.atr(field.placeholder_text).strip_edges()
145
155
  return masked(field) if field.secret else words.strip_edges()
146
156
  if node is TextEdit:
147
- return words.strip_edges()
157
+ var area: TextEdit = node
158
+ return (area.atr(area.placeholder_text) if words.is_empty() else words).strip_edges()
148
159
  words = node.atr(words)
149
160
  if node is Label3D:
150
161
  var sign_text: Label3D = node