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.
- package/build/cli.js +479 -157
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +11 -5
- package/build/godot/addons/gdharness_runtime/runtime_menus.gd +1 -1
- package/build/godot/addons/gdharness_runtime/runtime_says.gd +63 -4
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +100 -15
- package/build/godot/addons/gdharness_runtime/runtime_words.gd +8 -1
- package/build/index.js +520 -205
- package/package.json +1 -1
|
@@ -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
|
|
506
|
-
var heard: Callable = func(meta: Variant) -> void: links.append(
|
|
507
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
|
39
|
-
if said.
|
|
40
|
-
|
|
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
|
-
|
|
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] =
|
|
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 =
|
|
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
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
|
|
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
|
|
165
|
-
+ ' "%s", while %s; pass index %d to press that one anyway
|
|
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
|
-
|
|
196
|
+
best_one,
|
|
171
197
|
targets[index].get_path(),
|
|
172
198
|
_shortened(lines[index]),
|
|
173
|
-
_out_of_reach_exactly(
|
|
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(
|
|
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
|
-
|
|
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
|