gdharness 1.1.46 → 1.1.48

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.
@@ -502,9 +502,11 @@ func click(params: Dictionary) -> Dictionary:
502
502
  var landed: bool = hovered == control or (hovered != null and control.is_ancestor_of(hovered))
503
503
 
504
504
  # The link a rich label says was pressed, which is its own word for what the press reached.
505
- var links: Array[String] = []
506
- var heard: Callable = func(meta: Variant) -> void: links.append(str(meta))
507
- if rich != null:
505
+ var links: Array = []
506
+ var heard: Callable = func(meta: Variant) -> void: links.append(meta)
507
+ # Read before the press, which may free the label.
508
+ var on_rich_text: bool = rich != null
509
+ if on_rich_text:
508
510
  var _listening: int = rich.meta_clicked.connect(heard)
509
511
  _press_button(viewport, _button(position, button, true, double))
510
512
  await _host.get_tree().process_frame
@@ -533,8 +535,12 @@ func click(params: Dictionary) -> Dictionary:
533
535
  }
534
536
  if found != null:
535
537
  answer["found"] = found
536
- if not links.is_empty():
537
- answer["link_pressed"] = links.back()
538
+ # Whether a link was pressed apart from what it carries: a bare [url] reports an empty meta,
539
+ # and given as the meta alone that press read exactly like one that reached no link.
540
+ if on_rich_text:
541
+ answer["link_pressed"] = not links.is_empty()
542
+ if not links.is_empty():
543
+ answer["link_meta"] = _values.serialize(links.back())
538
544
  return _pointer_noted(answer)
539
545
 
540
546
 
@@ -61,7 +61,7 @@ static func wanted_items(menu: PopupMenu, params: Dictionary) -> Array[int]:
61
61
  if words[at] == wanted:
62
62
  return _items([order[at]])
63
63
  for at: int in words.size():
64
- if words[at].nocasecmp_to(wanted) == 0:
64
+ if Says.spaced(words[at]).nocasecmp_to(Says.spaced(wanted)) == 0:
65
65
  return _items([order[at]])
66
66
  # Separators are left out here: a heading is refused when named in full, and a pattern that
67
67
  # matched one beside the item it heads would tie the two.
@@ -16,6 +16,12 @@ const SPLITS_KEPT: int = 8
16
16
  ## Each `says` split into alternatives, by what was written; see [method alternatives].
17
17
  static var _splits: Dictionary[String, Array] = {}
18
18
 
19
+ ## Each `says` as the globs [method matches] tries first, by what was written; see [method _loose].
20
+ static var _globs: Dictionary[String, Array] = {}
21
+
22
+ ## Every space separator Unicode has besides the plain space, built once; see [method spaced].
23
+ static var _spaces: RegEx = null
24
+
19
25
 
20
26
  ## Whether [param node]'s own words are what [param wanted] asks for.
21
27
  static func says(node: Node, wanted: String) -> bool:
@@ -34,13 +40,66 @@ static func says(node: Node, wanted: String) -> bool:
34
40
  ## wait for the end of a turn is a wait for whichever of "won", "lost" or the next turn's words comes
35
41
  ## first, and read as one glob with the bars in it, it waited out its whole timeout for words no
36
42
  ## screen says.
43
+ ##
44
+ ## A space in the words matches any space separator in [param said], which is how they were typed.
45
+ ## Each node's words are spaced only once a looser match says they may be the ones: this runs for
46
+ ## every node on every look a wait takes, and spacing them all made a look over 16,000 labels half
47
+ ## as slow again, which took a game under half its speed on a macOS runner.
37
48
  static func matches(said: String, wanted: String) -> bool:
38
- for words: String in alternatives(wanted):
39
- if said.containsn(words) if is_plain(words) else said.matchn(words):
40
- return true
49
+ for glob: String in _loose(wanted):
50
+ if said.matchn(glob):
51
+ var shown: String = spaced(said)
52
+ for words: String in alternatives(wanted):
53
+ if shown.containsn(words) if is_plain(words) else shown.matchn(words):
54
+ return true
55
+ return false
41
56
  return false
42
57
 
43
58
 
59
+ ## Each alternative of [param wanted] as a glob, open at both ends for words written plain, with
60
+ ## every space as `?`. Words that match once spaced match this as written, since spacing changes
61
+ ## only the characters a `?` stands for, so a text that does not is answered without being spaced.
62
+ ## Kept by what was written, as [method alternatives] keeps its split.
63
+ static func _loose(wanted: String) -> Array[String]:
64
+ var kept: Variant = _globs.get(wanted)
65
+ if kept != null:
66
+ var globs: Array[String] = kept
67
+ return globs
68
+ var found: Array[String] = []
69
+ for words: String in alternatives(wanted):
70
+ found.append((("*%s*" % words) if is_plain(words) else words).replace(" ", "?"))
71
+ if _globs.size() >= SPLITS_KEPT:
72
+ _globs.clear()
73
+ _globs[wanted] = found
74
+ return found
75
+
76
+
77
+ ## [param text] with every Unicode space separator written as a plain space, which is what a caller
78
+ ## types for any of them. A game keeping a name on one line writes it with a non-breaking space, and
79
+ ## "Deskanem Bonituk" typed with a plain one found nothing while the screen showed exactly those
80
+ ## words. One character for one, so a place found in the result is the same place in [param text].
81
+ ##
82
+ ## Text that is all ASCII holds none of them and is handed back as it is, which is most of a screen.
83
+ ## Its UTF-8 is one byte to a character only when every character is ASCII, and counting that costs
84
+ ## a quarter of the substitution.
85
+ static func spaced(text: String) -> String:
86
+ if text.to_utf8_buffer().size() == text.length():
87
+ return text
88
+ if _spaces == null:
89
+ var separators: String = (
90
+ String.chr(0x00A0)
91
+ + String.chr(0x1680)
92
+ + String.chr(0x2000)
93
+ + "-"
94
+ + String.chr(0x200A)
95
+ + String.chr(0x202F)
96
+ + String.chr(0x205F)
97
+ + String.chr(0x3000)
98
+ )
99
+ _spaces = RegEx.create_from_string("[%s]" % separators)
100
+ return _spaces.sub(text, " ", true)
101
+
102
+
44
103
  ## The alternatives [param wanted] names, each as words on a screen: split at every `|` not written
45
104
  ## as `\|`, which stays a bar in the words. An empty alternative, a stray bar at either end, is
46
105
  ## dropped: it names no words, and a find that came back empty widened it to `**` and suggested a
@@ -110,4 +169,4 @@ static func widened(wanted: String) -> String:
110
169
  ## which reads as a control that is not on the screen. Nothing on a screen says a backslash and
111
170
  ## an n, so the two characters mean the break to everybody who writes them.
112
171
  static func as_said(wanted: String) -> String:
113
- return wanted.replace("\\n", "\n")
172
+ return spaced(wanted.replace("\\n", "\n"))
@@ -41,6 +41,8 @@ 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
45
47
  # Controls saying exactly the words that a click cannot reach, kept to say what a match on screen
46
48
  # that only says them as part of more stands in for.
@@ -60,14 +62,17 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
60
62
  var target: Control = _pressed_through(control)
61
63
  var words: String = Words.said_by(node)
62
64
  var whole: bool = _says_exactly(words, wanted)
65
+ var buried: bool = not whole and _inside_a_word(words, wanted)
63
66
  var at: int = matched.find(target)
64
67
  if at == -1:
65
68
  matched.append(target)
66
69
  said.append(words)
67
70
  exact.append(whole)
68
- elif whole and not exact[at]:
71
+ inside.append(buried)
72
+ elif (whole and not exact[at]) or (inside[at] and not buried):
69
73
  said[at] = words
70
- exact[at] = true
74
+ exact[at] = whole
75
+ inside[at] = buried
71
76
  var children: Array[Node] = node.get_children(true)
72
77
  for index: int in range(children.size() - 1, -1, -1):
73
78
  pending.push_front(children[index])
@@ -95,9 +100,13 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
95
100
  out_of_reach_as.append("under %s, which is drawn over it" % over.get_path())
96
101
  if cover == null:
97
102
  cover = over
98
- # 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.
99
106
  open.sort_custom(
100
107
  func(a: int, b: int) -> bool:
108
+ if inside[a] != inside[b]:
109
+ return inside[b]
101
110
  var first: int = _rank(matched[a], exact[a])
102
111
  var second: int = _rank(matched[b], exact[b])
103
112
  return first > second or (first == second and a < b)
@@ -106,11 +115,13 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
106
115
  var ranks: Array[int] = []
107
116
  var lines: Array[String] = []
108
117
  var wholes: Array[bool] = []
118
+ var insides: Array[bool] = []
109
119
  for index: int in open:
110
120
  targets.append(matched[index])
111
121
  ranks.append(_rank(matched[index], exact[index]))
112
122
  lines.append(said[index])
113
123
  wholes.append(exact[index])
124
+ insides.append(inside[index])
114
125
 
115
126
  var under: String = "" if root_path == "/root" else " under %s" % root_path
116
127
  if targets.is_empty():
@@ -129,7 +140,10 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
129
140
  ]
130
141
  )
131
142
  }
132
- 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
133
147
  if which == null and best > 1:
134
148
  var order: String = "" if best == targets.size() else ", best matches first"
135
149
  return {
@@ -151,30 +165,74 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
151
165
  % [str(which), targets.size(), plural, under, wanted, _candidates(targets, lines)]
152
166
  )
153
167
  }
154
- # A word inside a sentence on screen, while a control saying exactly that word is out of reach: the
155
- # sentence is a stand-in, not the control meant. A tab reading "Out" sat in a collapsed drawer, and
156
- # a click on it pressed a dispatch saying somebody "fell out in the hall". Pressed only when asked
157
- # for by index, which is the caller saying the sentence is what they meant.
158
- if which == null and not wholes[index] and not exact_out_of_reach.is_empty():
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():
159
184
  return {
160
185
  "type": "error",
161
186
  "message":
162
187
  (
163
188
  (
164
- 'no control on screen%s says exactly "%s", so nothing was clicked: %s %s says it as part of'
165
- + ' "%s", while %s; pass index %d to press that one anyway, or bring the other into view'
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'
166
191
  )
167
192
  % [
193
+ "button" if exact_elsewhere.size() > exact_out_of_reach.size() else "control",
168
194
  under,
169
195
  wanted,
170
- "the one on screen," if targets.size() == 1 else "the best match on screen,",
196
+ best_one,
171
197
  targets[index].get_path(),
172
198
  _shortened(lines[index]),
173
- _out_of_reach_exactly(exact_out_of_reach, out_of_reach_as),
199
+ _out_of_reach_exactly(exact_elsewhere, elsewhere_as),
174
200
  index,
175
201
  ]
176
202
  )
177
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
+ }
178
236
  var found: Dictionary = {"says": wanted, "index": index, "of": targets.size()}
179
237
  if not wholes[index]:
180
238
  found["partOf"] = _shortened(lines[index])
@@ -205,7 +263,8 @@ static func _rank(target: Control, whole: bool) -> int:
205
263
  ## A line counts because a card is a title over a description: "WARD" over "wards 3" is the card
206
264
  ## named WARD, and read as its words run together it said WARD only as part of more, level with
207
265
  ## "WARDSPITE" over its own description, and a click on WARD was refused as not clear.
208
- 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)
209
268
  for words: String in Says.alternatives(wanted):
210
269
  if not Says.is_plain(words):
211
270
  if said.matchn(words):
@@ -220,6 +279,32 @@ static func _says_exactly(said: String, wanted: String) -> bool:
220
279
  return false
221
280
 
222
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
+
223
308
  ## [param count] matches of [param rank], as the start of a sentence.
224
309
  static func _described(count: int, rank: int, wanted: String, under: String) -> String:
225
310
  var what: String = "button" if rank >= 2 else "control"
@@ -509,7 +594,7 @@ static func _clipped_away(
509
594
  ## words. A line in another font or holding an image measures off from what is drawn, which a wide
510
595
  ## line with nothing between the words and its start hardly does.
511
596
  static func point_on_words(label: RichTextLabel, wanted: String) -> Dictionary:
512
- var text: String = Words.said_by(label)
597
+ var text: String = Says.spaced(Words.said_by(label))
513
598
  var start: int = -1
514
599
  var length: int = 0
515
600
  for words: String in Says.alternatives(wanted):
@@ -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