gdharness 1.1.13 → 1.1.14

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.
@@ -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 Says = preload("runtime_says.gd")
8
9
  const Screen = preload("runtime_screen.gd")
9
10
  const Values = preload("runtime_values.gd")
10
11
  const Words = preload("runtime_words.gd")
@@ -103,13 +104,13 @@ func find_nodes(params: Dictionary) -> Dictionary:
103
104
  var truncated: bool = false
104
105
  # Counted while the tree is already being walked, for the answer below: how many nodes this
105
106
  # find would have matched if the name had been read the way it was probably meant.
106
- var literal: bool = _is_literal(wanted["name"])
107
+ var literal: bool = Says.is_plain(wanted["name"])
107
108
  var nearly: int = 0
108
109
  # The same for the words: a glob is matched against the whole text, and one written as a prefix
109
110
  # by habit, "Taken together*", missed the label whose sentence those words are in the middle of.
110
111
  # Nothing found then read as the words not being on screen, which is the wrong conclusion when
111
112
  # checking that a change to the text landed.
112
- var widened_says: String = _widened(wanted["says"])
113
+ var widened_says: String = Says.widened(wanted["says"])
113
114
  var widened: Dictionary[String, String] = {}
114
115
  for filter: String in wanted:
115
116
  widened[filter] = widened_says if filter == "says" else wanted[filter]
@@ -241,50 +242,6 @@ static func _named(node: Node, pattern: String) -> bool:
241
242
  return pattern.is_empty() or str(node.name).matchn(pattern)
242
243
 
243
244
 
244
- ## Whether [param pattern] is a name written as a whole word rather than as a glob, which is how a
245
- ## caller writes one when they mean "contains". [method String.matchn] answers nothing to it, and
246
- ## nothing is also what a name that is simply not there answers.
247
- static func _is_literal(pattern: String) -> bool:
248
- return not pattern.is_empty() and not pattern.contains("*") and not pattern.contains("?")
249
-
250
-
251
- ## Whether [param said] is what a find asked for in [param wanted].
252
- ##
253
- ## A plain word is a contains, which is what somebody looking for the row about a person means. A
254
- ## pattern is a glob, because [code]namePattern[/code] beside it is one and nobody writes `*Still*`
255
- ## in one field meaning a glob and in the other meaning those characters. Written as a contains
256
- ## only, a glob matched nothing at all and an empty answer reads as a control that is not on the
257
- ## screen: twice in one session here, over a button that was.
258
- static func _says(said: String, wanted: String) -> bool:
259
- var words: String = as_said(wanted)
260
- return said.containsn(words) if _is_literal(words) else said.matchn(words)
261
-
262
-
263
- ## Whether [param node]'s own words are what [param wanted] asks for, by the rules a find uses, so a
264
- ## click that names its control by its words finds the one a find would have answered with.
265
- static func says(node: Node, wanted: String) -> bool:
266
- return _says(Words.said_by(node), wanted)
267
-
268
-
269
- ## [param wanted], a glob, open at both ends so it matches its words anywhere in a text; "" when
270
- ## it is not a glob, or already open at both ends, and so has nothing to suggest.
271
- static func _widened(wanted: String) -> String:
272
- if wanted.is_empty() or _is_literal(wanted):
273
- return ""
274
- var open: String = "*" + wanted.lstrip("*").rstrip("*") + "*"
275
- return "" if open == wanted else open
276
-
277
-
278
- ## [param wanted] as words on a screen: a backslash followed by n is a line break.
279
- ##
280
- ## A button with two lines on it was asked for with the break written as the two characters, the
281
- ## way it is typed into a JSON string one escape short, and was answered as not there: 0 found,
282
- ## which reads as a control that is not on the screen. Nothing on a screen says a backslash and
283
- ## an n, so the two characters mean the break to everybody who writes them.
284
- static func as_said(wanted: String) -> String:
285
- return wanted.replace("\\n", "\n")
286
-
287
-
288
245
  ## Every filter but the name, so a find that came back empty can say how many nodes the name was
289
246
  ## the only thing standing between it and.
290
247
  func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) -> bool:
@@ -293,7 +250,7 @@ func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) ->
293
250
  # What this node says, rather than everything said underneath it. A row is then found by the
294
251
  # label in it, and the path answered is that label's, which is where the words a caller is
295
252
  # looking at actually are: matching every container above it would answer with the screen.
296
- if not wanted["says"].is_empty() and not _says(Words.said_by(node), wanted["says"]):
253
+ if not wanted["says"].is_empty() and not Says.says(node, wanted["says"]):
297
254
  return false
298
255
  var script: Variant = node.get_script()
299
256
  if not wanted["script"].is_empty():
@@ -327,10 +284,6 @@ static func _script_is(attached: Script, wanted: String) -> bool:
327
284
  return false
328
285
 
329
286
 
330
- ## Where a node is on screen: a Control's rectangle, a Node2D's position, or the place a 3D node
331
- ## is drawn in, in both the canvas coordinates the node reports and the window pixels input
332
- ## arrives in. The two differ whenever the project stretches its viewport, which is what made a
333
- ## rect unusable for a click.
334
287
  ## Every line of text under a node, in the order somebody reads the screen.
335
288
  ##
336
289
  ## The question a caller asks most often, and the one that cost the most to answer: reading a panel
@@ -414,6 +367,10 @@ static func _drawn(node: Node) -> bool:
414
367
  return window == null or window.visible
415
368
 
416
369
 
370
+ ## Where a node is on screen: a Control's rectangle, a Node2D's position, or the place a 3D node
371
+ ## is drawn in, in both the canvas coordinates the node reports and the window pixels input
372
+ ## arrives in. The two differ whenever the project stretches its viewport, which is what made a
373
+ ## rect unusable for a click.
417
374
  func get_rect(params: Dictionary) -> Dictionary:
418
375
  var node_path: String = str(params.get("path", ""))
419
376
  if node_path.is_empty():
@@ -0,0 +1,97 @@
1
+ extends RefCounted
2
+
3
+ ## Whether a node says what a caller asked for in `says`: the one set of rules a find, a click and a
4
+ ## wait all match words by, so the three never disagree about which control a word names.
5
+
6
+ const Words = preload("runtime_words.gd")
7
+
8
+ ## Stands in for a bar written as `\|` while `says` is split into its alternatives: a character from
9
+ ## the private use area, which no game's text uses.
10
+ const ESCAPED_BAR: String = ""
11
+
12
+ ## How many splits [method alternatives] keeps. A find asks about two at once, the words it was given
13
+ ## and the same words widened, and anything past a handful is a caller that has moved on.
14
+ const SPLITS_KEPT: int = 8
15
+
16
+ ## Each `says` split into alternatives, by what was written; see [method alternatives].
17
+ static var _splits: Dictionary[String, Array] = {}
18
+
19
+
20
+ ## Whether [param node]'s own words are what [param wanted] asks for.
21
+ static func says(node: Node, wanted: String) -> bool:
22
+ return matches(Words.said_by(node), wanted)
23
+
24
+
25
+ ## Whether [param said] is what [param wanted] asks for.
26
+ ##
27
+ ## A plain word is a contains, which is what somebody looking for the row about a person means. A
28
+ ## pattern is a glob, because [code]namePattern[/code] beside it is one and nobody writes `*Still*`
29
+ ## in one field meaning a glob and in the other meaning those characters. Written as a contains
30
+ ## only, a glob matched nothing at all and an empty answer reads as a control that is not on the
31
+ ## screen: twice in one session here, over a button that was.
32
+ ##
33
+ ## Several alternatives separated by `|` match when any one of them does, each by those rules: a
34
+ ## wait for the end of a turn is a wait for whichever of "won", "lost" or the next turn's words comes
35
+ ## first, and read as one glob with the bars in it, it waited out its whole timeout for words no
36
+ ## screen says.
37
+ 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
41
+ return false
42
+
43
+
44
+ ## The alternatives [param wanted] names, each as words on a screen: split at every `|` not written
45
+ ## as `\|`, which stays a bar in the words. An empty alternative, a stray bar at either end, is
46
+ ## dropped: it names no words, and a find that came back empty widened it to `**` and suggested a
47
+ ## pattern that matches every node there is.
48
+ ##
49
+ ## Kept once split, since a find, a click or a wait asks about the same words for every node on the
50
+ ## screen: split afresh for each of them, one look over a hall of twenty thousand nodes took 210ms
51
+ ## rather than 155ms. The array handed back is shared and not to be changed.
52
+ static func alternatives(wanted: String) -> Array[String]:
53
+ var kept: Variant = _splits.get(wanted)
54
+ if kept != null:
55
+ var split: Array[String] = kept
56
+ return split
57
+ var held: String = as_said(wanted).replace("\\|", ESCAPED_BAR)
58
+ var found: Array[String] = []
59
+ for part: String in held.split("|"):
60
+ var words: String = part.replace(ESCAPED_BAR, "|")
61
+ if not words.is_empty():
62
+ found.append(words)
63
+ if _splits.size() >= SPLITS_KEPT:
64
+ _splits.clear()
65
+ _splits[wanted] = found
66
+ return found
67
+
68
+
69
+ ## Whether [param pattern] is words written whole rather than as a glob, which is how a caller
70
+ ## writes them when they mean "contains". [method String.matchn] answers nothing to it, and nothing
71
+ ## is also what words that are simply not there answer.
72
+ static func is_plain(pattern: String) -> bool:
73
+ return not pattern.is_empty() and not pattern.contains("*") and not pattern.contains("?")
74
+
75
+
76
+ ## [param wanted], a glob, open at both ends so it matches its words anywhere in a text; "" when
77
+ ## it is not a glob, or already open at both ends, and so has nothing to suggest.
78
+ static func widened(wanted: String) -> String:
79
+ var open_ended: Array[String] = []
80
+ var changed: bool = false
81
+ for words: String in alternatives(wanted):
82
+ var open: String = words
83
+ if not is_plain(words):
84
+ open = "*" + words.lstrip("*").rstrip("*") + "*"
85
+ changed = changed or open != words
86
+ open_ended.append(open.replace("|", "\\|"))
87
+ return "|".join(open_ended) if changed else ""
88
+
89
+
90
+ ## [param wanted] as words on a screen: a backslash followed by n is a line break.
91
+ ##
92
+ ## A button with two lines on it was asked for with the break written as the two characters, the
93
+ ## way it is typed into a JSON string one escape short, and was answered as not there: 0 found,
94
+ ## which reads as a control that is not on the screen. Nothing on a screen says a backslash and
95
+ ## an n, so the two characters mean the break to everybody who writes them.
96
+ static func as_said(wanted: String) -> String:
97
+ return wanted.replace("\\n", "\n")
@@ -0,0 +1 @@
1
+ uid://cx6i38yml0dy7
@@ -4,6 +4,7 @@ extends RefCounted
4
4
 
5
5
  const Queries = preload("runtime_queries.gd")
6
6
  const Read = preload("reading.gd")
7
+ const Says = preload("runtime_says.gd")
7
8
  const Values = preload("runtime_values.gd")
8
9
  const Words = preload("runtime_words.gd")
9
10
 
@@ -43,7 +44,7 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
43
44
  var pending: Array[Node] = [reached["node"]]
44
45
  while not pending.is_empty():
45
46
  var node: Node = pending.pop_front()
46
- if node is Control and Queries.says(node, wanted):
47
+ if node is Control and Says.says(node, wanted):
47
48
  if not Queries.shown(node):
48
49
  hidden += 1
49
50
  else:
@@ -142,20 +143,35 @@ static func _rank(target: Control, whole: bool) -> int:
142
143
  return (2 if target is BaseButton else 0) + (1 if whole else 0)
143
144
 
144
145
 
145
- ## Whether [param said] is the whole of what [param wanted] asks for. A glob is matched against the
146
- ## whole text already, so every match of one is whole.
146
+ ## Whether one of [param wanted]'s alternatives is the whole of [param said], or the whole of one of
147
+ ## its lines. A glob is matched against the whole text already, so every match of one is whole.
148
+ ##
149
+ ## A line counts because a card is a title over a description: "WARD" over "wards 3" is the card
150
+ ## named WARD, and read as its words run together it said WARD only as part of more, level with
151
+ ## "WARDSPITE" over its own description, and a click on WARD was refused as not clear.
147
152
  static func _says_exactly(said: String, wanted: String) -> bool:
148
- var words: String = Queries.as_said(wanted)
149
- if words.contains("*") or words.contains("?"):
150
- return true
151
- return said.strip_edges().nocasecmp_to(words.strip_edges()) == 0
153
+ for words: String in Says.alternatives(wanted):
154
+ if not Says.is_plain(words):
155
+ if said.matchn(words):
156
+ return true
157
+ continue
158
+ var looked_for: String = words.strip_edges()
159
+ if said.strip_edges().nocasecmp_to(looked_for) == 0:
160
+ return true
161
+ for line: String in said.split("\n"):
162
+ if line.strip_edges().nocasecmp_to(looked_for) == 0:
163
+ return true
164
+ return false
152
165
 
153
166
 
154
167
  ## [param count] matches of [param rank], as the start of a sentence.
155
168
  static func _described(count: int, rank: int, wanted: String, under: String) -> String:
156
169
  var what: String = "button" if rank >= 2 else "control"
157
170
  var how: String = '"%s"' % wanted
158
- if not Queries.as_said(wanted).contains("*") and not Queries.as_said(wanted).contains("?"):
171
+ var plain: bool = true
172
+ for words: String in Says.alternatives(wanted):
173
+ plain = plain and Says.is_plain(words)
174
+ if plain:
159
175
  how = ('exactly "%s"' if rank % 2 == 1 else '"%s" as part of more') % wanted
160
176
  if count == 1:
161
177
  return "the one %s on screen%s saying %s" % [what, under, how]
@@ -247,9 +263,12 @@ static func _areas_of(drawn: Array[Node]) -> Array[Rect2]:
247
263
  ##
248
264
  ## The engine's own order for which control takes the pointer: an embedded window over everything
249
265
  ## in the viewport that holds it, then the higher canvas layer, then the later in the tree. A
250
- ## control holding the target or held by it is part of the same click. A target whose centre its
251
- ## own container clips away is judged not covered, since the click scrolls it into view first and
252
- ## what is drawn there now is not what it will land on.
266
+ ## control holding the target or held by it is part of the same click.
267
+ ##
268
+ ## A target whose centre its own container clips away is judged where the click will put it, since
269
+ ## the click scrolls it into view first: what is drawn over its place now is not what it will land
270
+ ## under. Judged not covered at all instead, a button below the fold of a roster that a full-screen
271
+ ## page covered was picked over the ones in view, scrolled up under the page and pressed there.
253
272
  ##
254
273
  ## [param areas] holds the box around each of [param drawn], so everything whose box misses the
255
274
  ## point is passed over before anything costlier is asked of it.
@@ -257,7 +276,7 @@ static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2])
257
276
  var viewport: Viewport = target.get_viewport()
258
277
  var point: Vector2 = target.get_global_transform_with_canvas() * (target.size * 0.5)
259
278
  if _clipped_away(target, point):
260
- return null
279
+ point = _where_it_lands(target, point)
261
280
  var layer: int = _layer_of(target)
262
281
  for at: int in drawn.size():
263
282
  if not areas[at].has_point(point):
@@ -296,6 +315,29 @@ static func _holds(control: Control, point: Vector2) -> bool:
296
315
  return Rect2(Vector2.ZERO, control.size).has_point(placed.affine_inverse() * point)
297
316
 
298
317
 
318
+ ## Where [param target]'s centre, now at [param point], will be once the click has scrolled it into
319
+ ## view: moved as little as puts the whole of it inside each container clipping it, innermost first,
320
+ ## which is how a scroll container brings a control into view. A target larger than a container is
321
+ ## centred in it.
322
+ static func _where_it_lands(target: Control, point: Vector2) -> Vector2:
323
+ var drawn: Transform2D = target.get_global_transform_with_canvas()
324
+ var half: Vector2 = (drawn * Rect2(Vector2.ZERO, target.size)).size * 0.5
325
+ var landed: Vector2 = point
326
+ var walk: Node = target.get_parent()
327
+ while walk != null:
328
+ var holder: Control = walk as Control
329
+ if holder != null and holder.clip_contents:
330
+ var shown: Rect2 = holder.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, holder.size)
331
+ var low: Vector2 = shown.position + half
332
+ var high: Vector2 = shown.end - half
333
+ landed = Vector2(
334
+ shown.get_center().x if low.x > high.x else clampf(landed.x, low.x, high.x),
335
+ shown.get_center().y if low.y > high.y else clampf(landed.y, low.y, high.y)
336
+ )
337
+ walk = walk.get_parent()
338
+ return landed
339
+
340
+
299
341
  ## Whether a container clipping what it holds cuts [param point] off from [param control].
300
342
  static func _clipped_away(control: Control, point: Vector2) -> bool:
301
343
  var walk: Node = control.get_parent()
@@ -6,6 +6,7 @@ extends RefCounted
6
6
  const Paths = preload("runtime_paths.gd")
7
7
  const Queries = preload("runtime_queries.gd")
8
8
  const Read = preload("reading.gd")
9
+ const Says = preload("runtime_says.gd")
9
10
  const Values = preload("runtime_values.gd")
10
11
 
11
12
  ## The longest one wait may last, whatever the request says: past this the server has long
@@ -249,7 +250,6 @@ static func _watched(node: Node, node_path: String, property: String) -> Diction
249
250
  ## caller sees what it cost.
250
251
  func _wait_until_said(node_path: String, said: String, timeout_ms: int, include_hidden: bool) -> Dictionary:
251
252
  var started: int = Time.get_ticks_msec()
252
- var words: String = Queries.as_said(said)
253
253
  var frames: int = 0
254
254
  var looks: int = 0
255
255
  var next_look: int = 0
@@ -257,7 +257,7 @@ func _wait_until_said(node_path: String, said: String, timeout_ms: int, include_
257
257
  while true:
258
258
  if Time.get_ticks_usec() >= next_look:
259
259
  var looking: int = Time.get_ticks_usec()
260
- found = _anything_says(node_path, words, include_hidden)
260
+ found = _anything_says(node_path, said, include_hidden)
261
261
  looks += 1
262
262
  next_look = Time.get_ticks_usec() + (Time.get_ticks_usec() - looking) * LOOKS_APART
263
263
  if found or Time.get_ticks_msec() - started >= timeout_ms:
@@ -267,7 +267,7 @@ func _wait_until_said(node_path: String, said: String, timeout_ms: int, include_
267
267
  # One more look at the end, so words that arrived between two paced looks are not answered as
268
268
  # never having come.
269
269
  if not found:
270
- found = _anything_says(node_path, words, include_hidden)
270
+ found = _anything_says(node_path, said, include_hidden)
271
271
  looks += 1
272
272
 
273
273
  return {
@@ -293,7 +293,7 @@ func _anything_says(node_path: String, said: String, include_hidden: bool) -> bo
293
293
  var pending: Array[Node] = [root]
294
294
  while not pending.is_empty():
295
295
  var node: Node = pending.pop_back()
296
- if Queries.says(node, said) and (include_hidden or Queries.shown(node)):
296
+ if Says.says(node, said) and (include_hidden or Queries.shown(node)):
297
297
  return true
298
298
  pending.append_array(node.get_children(true))
299
299
  return false