gdharness 1.1.44 → 1.1.46
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 +64 -26
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +97 -3
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +42 -4
- package/build/godot/addons/gdharness_runtime/runtime_paths.gd +160 -41
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +146 -6
- package/build/godot/addons/gdharness_runtime/runtime_words.gd +6 -2
- package/build/index.js +63 -25
- package/package.json +1 -1
|
@@ -83,19 +83,21 @@ const READS_ONLY: String = ", and a path, which a wait reads every frame, only c
|
|
|
83
83
|
## at a time rather than handed to that method, which answers null for a path that goes wrong
|
|
84
84
|
## halfway along and for one that ends on null.
|
|
85
85
|
##
|
|
86
|
-
## A step written as a call, "get_viewport()", calls a method of that name
|
|
87
|
-
##
|
|
88
|
-
##
|
|
89
|
-
##
|
|
90
|
-
##
|
|
91
|
-
##
|
|
92
|
-
##
|
|
86
|
+
## A step written as a call, "get_viewport()", calls a method of that name and walks into what it
|
|
87
|
+
## returned. Some of what hangs off a node is only reachable by asking: which control holds the
|
|
88
|
+
## focus is a question for the viewport a control is in, and the viewport is a method's answer
|
|
89
|
+
## rather than a property. Spelled with the brackets so a reader sees a call where one happens, and
|
|
90
|
+
## so a method can never shadow a property of the same name. A list or a map answers the calls that
|
|
91
|
+
## read it, "board:size()" or "inbox:keys()", listed in [constant LIST_CALLS] and
|
|
92
|
+
## [constant MAP_CALLS]. An engine method that reads takes literal arguments in the brackets,
|
|
93
|
+
## "get_theme_stylebox(\"panel\"):content_margin_top", since what a control's theme gives it is only
|
|
94
|
+
## reachable by naming what is asked for; see [method arguments_of].
|
|
93
95
|
##
|
|
94
96
|
## Answers with a `message` instead when a step along the way is not there or holds something that
|
|
95
97
|
## is not an object, naming the step rather than the whole path: "/root/Main:_game has no property
|
|
96
98
|
## clocks" is a typo found, and "no property _game:clocks:speed" is a puzzle.
|
|
97
99
|
static func walk_to(node: Node, node_path: String, reaching: String) -> Dictionary:
|
|
98
|
-
var parts: PackedStringArray = reaching
|
|
100
|
+
var parts: PackedStringArray = steps_of(reaching)
|
|
99
101
|
var holder: Variant = node
|
|
100
102
|
var called: String = node_path
|
|
101
103
|
for step: int in parts.size() - 1:
|
|
@@ -187,26 +189,33 @@ static func nothing_under(holder: Variant, named: String, called: String) -> Str
|
|
|
187
189
|
## first one. An index reads the way a property does, and a negative one counts from the end the
|
|
188
190
|
## way GDScript's own does, so reading the last of something does not mean asking how many first.
|
|
189
191
|
static func can_read(holder: Variant, named: String) -> bool:
|
|
192
|
+
var method: String = method_of(named)
|
|
193
|
+
var given: Dictionary = arguments_of(named) if not method.is_empty() else {"args": []}
|
|
194
|
+
if given.has("message"):
|
|
195
|
+
return false
|
|
196
|
+
var args: Array = given["args"]
|
|
190
197
|
if holder is Array:
|
|
191
198
|
var items: Array = holder
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
199
|
+
if not method.is_empty():
|
|
200
|
+
return (
|
|
201
|
+
args.is_empty()
|
|
202
|
+
and method in LIST_CALLS
|
|
203
|
+
and not (items.is_empty() and method in NEEDS_AN_ELEMENT)
|
|
204
|
+
)
|
|
195
205
|
return _index_in(named, items.size()) != -1
|
|
196
206
|
if packed(holder):
|
|
197
|
-
if not
|
|
198
|
-
return
|
|
207
|
+
if not method.is_empty():
|
|
208
|
+
return args.is_empty() and method in PACKED_CALLS
|
|
199
209
|
return _index_in(named, len(holder)) != -1
|
|
200
210
|
if holder is Dictionary:
|
|
201
211
|
var map: Dictionary = holder
|
|
202
|
-
if not
|
|
203
|
-
return
|
|
212
|
+
if not method.is_empty():
|
|
213
|
+
return args.is_empty() and method in MAP_CALLS
|
|
204
214
|
return not _key_in(map, named).is_empty()
|
|
205
215
|
if holder is Object:
|
|
206
216
|
var object: Object = holder
|
|
207
|
-
var method: String = method_of(named)
|
|
208
217
|
if not method.is_empty():
|
|
209
|
-
return call_refused(object, method, "").is_empty()
|
|
218
|
+
return call_refused(object, method, "", args.size()).is_empty()
|
|
210
219
|
return _has_property(object, named)
|
|
211
220
|
return false
|
|
212
221
|
|
|
@@ -236,24 +245,103 @@ static func _key_in(map: Dictionary, named: String) -> Array:
|
|
|
236
245
|
return []
|
|
237
246
|
|
|
238
247
|
|
|
239
|
-
##
|
|
240
|
-
##
|
|
248
|
+
## [param reaching] split into its steps at each colon, except one inside a call's brackets or a
|
|
249
|
+
## quoted argument: `get_node("/root/Main"):name` is two steps, and split at every colon a quoted
|
|
250
|
+
## argument holding one became a step that names nothing.
|
|
251
|
+
static func steps_of(reaching: String) -> PackedStringArray:
|
|
252
|
+
var steps: PackedStringArray = []
|
|
253
|
+
var step: String = ""
|
|
254
|
+
var depth: int = 0
|
|
255
|
+
var quoted: bool = false
|
|
256
|
+
var escaped: bool = false
|
|
257
|
+
for character: String in reaching:
|
|
258
|
+
if quoted:
|
|
259
|
+
if escaped:
|
|
260
|
+
escaped = false
|
|
261
|
+
elif character == "\\":
|
|
262
|
+
escaped = true
|
|
263
|
+
elif character == '"':
|
|
264
|
+
quoted = false
|
|
265
|
+
elif character == '"':
|
|
266
|
+
quoted = true
|
|
267
|
+
elif character == "(":
|
|
268
|
+
depth += 1
|
|
269
|
+
elif character == ")":
|
|
270
|
+
depth = maxi(depth - 1, 0)
|
|
271
|
+
elif character == ":" and depth == 0:
|
|
272
|
+
var _kept: bool = steps.push_back(step)
|
|
273
|
+
step = ""
|
|
274
|
+
continue
|
|
275
|
+
step += character
|
|
276
|
+
var _last: bool = steps.push_back(step)
|
|
277
|
+
return steps
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
## The method a step written as a call names, "get_viewport()" being get_viewport and
|
|
281
|
+
## `get_theme_constant("separation")` get_theme_constant, or "" for a step that is a name.
|
|
241
282
|
static func method_of(named: String) -> String:
|
|
242
|
-
|
|
283
|
+
if not named.ends_with(")"):
|
|
284
|
+
return ""
|
|
285
|
+
var opened: int = named.find("(")
|
|
286
|
+
if opened < 1:
|
|
287
|
+
return ""
|
|
288
|
+
var method: String = named.substr(0, opened)
|
|
289
|
+
return method if method.is_valid_ascii_identifier() else ""
|
|
243
290
|
|
|
244
291
|
|
|
245
|
-
##
|
|
246
|
-
##
|
|
247
|
-
##
|
|
248
|
-
##
|
|
249
|
-
|
|
292
|
+
## The arguments a call step gives in its brackets, as {"args"}, or {"message"} saying why they are
|
|
293
|
+
## not ones a path can give.
|
|
294
|
+
##
|
|
295
|
+
## Literals only: words in double quotes, numbers, true, false and null, read as JSON reads them, a
|
|
296
|
+
## whole number as an int. A path names what is read, and an argument that was itself a path or an
|
|
297
|
+
## object would make a read depend on other state in a way the step does not show.
|
|
298
|
+
static func arguments_of(named: String) -> Dictionary:
|
|
299
|
+
var inside: String = named.substr(named.find("(") + 1, named.length() - named.find("(") - 2).strip_edges()
|
|
300
|
+
if inside.is_empty():
|
|
301
|
+
return {"args": []}
|
|
302
|
+
var reader: JSON = JSON.new()
|
|
303
|
+
var parsed: Variant = reader.data if reader.parse("[%s]" % inside) == OK else null
|
|
304
|
+
if not parsed is Array:
|
|
305
|
+
return {"message": _not_literals(named)}
|
|
306
|
+
var args: Array = []
|
|
307
|
+
for given: Variant in parsed:
|
|
308
|
+
if given is float:
|
|
309
|
+
var number: float = given
|
|
310
|
+
if number == floorf(number) and absf(number) < 9.0e15:
|
|
311
|
+
args.append(int(number))
|
|
312
|
+
else:
|
|
313
|
+
args.append(number)
|
|
314
|
+
elif given == null or given is String or given is bool:
|
|
315
|
+
args.append(given)
|
|
316
|
+
else:
|
|
317
|
+
return {"message": _not_literals(named)}
|
|
318
|
+
return {"args": args}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
static func _not_literals(named: String) -> String:
|
|
322
|
+
return (
|
|
323
|
+
"%s: a path gives a call literal arguments only, words in double quotes, numbers, true, false or null"
|
|
324
|
+
% named
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
## How many arguments [param method] on [param object] takes, as {"least", "most", "any_more"}, or
|
|
329
|
+
## an empty map when there is no such method. Read off the method list rather than
|
|
330
|
+
## [method Object.has_method], because a method given too few or too many raises inside the game
|
|
331
|
+
## rather than answering.
|
|
332
|
+
static func _arity(object: Object, method: String) -> Dictionary:
|
|
250
333
|
for entry: Dictionary in object.get_method_list():
|
|
251
334
|
if str(entry.get("name", "")) != method:
|
|
252
335
|
continue
|
|
253
336
|
var params: Array = entry.get("args", [])
|
|
254
337
|
var defaults: Array = entry.get("default_args", [])
|
|
255
|
-
|
|
256
|
-
|
|
338
|
+
var flags: int = entry.get("flags", 0)
|
|
339
|
+
return {
|
|
340
|
+
"least": params.size() - defaults.size(),
|
|
341
|
+
"most": params.size(),
|
|
342
|
+
"any_more": flags & METHOD_FLAG_VARARG != 0,
|
|
343
|
+
}
|
|
344
|
+
return {}
|
|
257
345
|
|
|
258
346
|
|
|
259
347
|
## Why a path may not call [param method] on [param object], said about [param called_as], or ""
|
|
@@ -265,16 +353,36 @@ static func _arguments_required(object: Object, method: String) -> int:
|
|
|
265
353
|
## doing something and answering nothing, and one left untyped is trusted. Without this a find
|
|
266
354
|
## with property "queue_free()" freed every node it matched, and a wait on "advance_day()" called
|
|
267
355
|
## it once a frame and then reported the state it had driven the game to.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
356
|
+
##
|
|
357
|
+
## Given [param given] arguments, a call is the engine's and marked as reading, or it is refused. A
|
|
358
|
+
## game's method taking arguments is trusted on nothing at all: one answering a value can as well
|
|
359
|
+
## change what it was asked about, and runtime_invoke call is how one is called on purpose.
|
|
360
|
+
static func call_refused(object: Object, method: String, called_as: String, given: int = 0) -> String:
|
|
361
|
+
var arity: Dictionary = _arity(object, method)
|
|
362
|
+
if arity.is_empty():
|
|
271
363
|
return "%s has no method %s" % [called_as, method]
|
|
272
|
-
|
|
364
|
+
var least: int = arity["least"]
|
|
365
|
+
var most: int = arity["most"]
|
|
366
|
+
var any_more: bool = arity["any_more"]
|
|
367
|
+
var scripted: Dictionary = _script_method(object, method)
|
|
368
|
+
if not scripted.is_empty() and (given > 0 or least > 0):
|
|
369
|
+
var why: String = (
|
|
370
|
+
"since nothing marks one as only reading%s; runtime_invoke call takes them" % READS_ONLY
|
|
371
|
+
)
|
|
273
372
|
return (
|
|
274
|
-
"%s.%s
|
|
275
|
-
% [called_as, method,
|
|
373
|
+
"%s.%s is the game's own and a path gives a game's method no arguments, %s"
|
|
374
|
+
% [called_as, method, why]
|
|
375
|
+
)
|
|
376
|
+
if given < least or (given > most and not any_more):
|
|
377
|
+
var takes: String = "%d" % least
|
|
378
|
+
if any_more:
|
|
379
|
+
takes = "at least %d" % least
|
|
380
|
+
elif most != least:
|
|
381
|
+
takes = "%d to %d" % [least, most]
|
|
382
|
+
return (
|
|
383
|
+
'%s.%s takes %s argument%s and the path gives it %d, in the brackets as in %s("name")'
|
|
384
|
+
% [called_as, method, takes, "" if takes == "1" else "s", given, method]
|
|
276
385
|
)
|
|
277
|
-
var scripted: Dictionary = _script_method(object, method)
|
|
278
386
|
if not scripted.is_empty():
|
|
279
387
|
var returned: Dictionary = scripted.get("return", {})
|
|
280
388
|
var usage: int = returned.get("usage", 0)
|
|
@@ -350,7 +458,8 @@ static func read_under(holder: Variant, named: String) -> Variant:
|
|
|
350
458
|
var object: Object = holder
|
|
351
459
|
var method: String = method_of(named)
|
|
352
460
|
if not method.is_empty():
|
|
353
|
-
|
|
461
|
+
var args: Array = arguments_of(named)["args"]
|
|
462
|
+
return object.callv(method, args)
|
|
354
463
|
return object.get(named)
|
|
355
464
|
|
|
356
465
|
|
|
@@ -374,20 +483,25 @@ static func _index_in(named: String, size: int) -> int:
|
|
|
374
483
|
## the whole path, because the step is the typo.
|
|
375
484
|
static func nothing_there(holder: Variant, named: String, called_as: String) -> String:
|
|
376
485
|
var container_call: String = method_of(named)
|
|
486
|
+
var given: Dictionary = arguments_of(named) if not container_call.is_empty() else {"args": []}
|
|
487
|
+
if given.has("message"):
|
|
488
|
+
return "%s:%s" % [called_as, given["message"]]
|
|
489
|
+
var given_args: Array = given["args"]
|
|
490
|
+
var given_count: int = given_args.size()
|
|
377
491
|
if holder is Array:
|
|
378
492
|
var items: Array = holder
|
|
379
493
|
if container_call in NEEDS_AN_ELEMENT and items.is_empty():
|
|
380
494
|
return "%s is an empty list, so it has no %s" % [called_as, named]
|
|
381
495
|
if not container_call.is_empty():
|
|
382
|
-
return container_calls_refused(called_as, "a list", container_call)
|
|
496
|
+
return container_calls_refused(called_as, "a list", container_call, given_count)
|
|
383
497
|
return "%s is a list of %d, so there is no %s in it" % [called_as, items.size(), named]
|
|
384
498
|
if packed(holder):
|
|
385
499
|
if not container_call.is_empty():
|
|
386
|
-
return container_calls_refused(called_as, "a packed list", container_call)
|
|
500
|
+
return container_calls_refused(called_as, "a packed list", container_call, given_count)
|
|
387
501
|
return "%s is a packed list of %d, so there is no %s in it" % [called_as, len(holder), named]
|
|
388
502
|
if holder is Dictionary:
|
|
389
503
|
if not container_call.is_empty():
|
|
390
|
-
return container_calls_refused(called_as, "a map", container_call)
|
|
504
|
+
return container_calls_refused(called_as, "a map", container_call, given_count)
|
|
391
505
|
var map: Dictionary = holder
|
|
392
506
|
var keys: Array = map.keys()
|
|
393
507
|
# A key reading as the step and still not reached is one of a type a step cannot spell, and
|
|
@@ -410,7 +524,7 @@ static func nothing_there(holder: Variant, named: String, called_as: String) ->
|
|
|
410
524
|
var object: Object = holder
|
|
411
525
|
var method: String = method_of(named)
|
|
412
526
|
if not method.is_empty():
|
|
413
|
-
return call_refused(object, method, called_as)
|
|
527
|
+
return call_refused(object, method, called_as, given_count)
|
|
414
528
|
# A method spelled as a property is the likeliest thing behind a name the object has no property
|
|
415
529
|
# for, and the caller is one pair of brackets away from what they meant.
|
|
416
530
|
if object.has_method(named):
|
|
@@ -422,13 +536,18 @@ static func nothing_there(holder: Variant, named: String, called_as: String) ->
|
|
|
422
536
|
|
|
423
537
|
|
|
424
538
|
## Why [param method] is not a call a path makes on [param sort] ("a list", "a packed list" or "a
|
|
425
|
-
## map"), naming the calls it does make
|
|
426
|
-
|
|
539
|
+
## map"), naming the calls it does make, or that it makes this one with no arguments when
|
|
540
|
+
## [param given] were.
|
|
541
|
+
static func container_calls_refused(
|
|
542
|
+
called_as: String, sort: String, method: String, given: int = 0
|
|
543
|
+
) -> String:
|
|
427
544
|
var calls: Array[String] = MAP_CALLS
|
|
428
545
|
if sort == "a list":
|
|
429
546
|
calls = LIST_CALLS
|
|
430
547
|
elif sort == "a packed list":
|
|
431
548
|
calls = PACKED_CALLS
|
|
549
|
+
if given > 0 and method in calls:
|
|
550
|
+
return "%s is %s, and a path calls %s() on one with no arguments" % [called_as, sort, method]
|
|
432
551
|
var spelled: Array[String] = []
|
|
433
552
|
for each: String in calls:
|
|
434
553
|
spelled.append(each + "()")
|
|
@@ -42,12 +42,19 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
42
42
|
var said: Array[String] = []
|
|
43
43
|
var exact: Array[bool] = []
|
|
44
44
|
var hidden: int = 0
|
|
45
|
+
# Controls saying exactly the words that a click cannot reach, kept to say what a match on screen
|
|
46
|
+
# that only says them as part of more stands in for.
|
|
47
|
+
var exact_out_of_reach: Array[Node] = []
|
|
48
|
+
var out_of_reach_as: Array[String] = []
|
|
45
49
|
var pending: Array[Node] = [reached["node"]]
|
|
46
50
|
while not pending.is_empty():
|
|
47
51
|
var node: Node = pending.pop_front()
|
|
48
52
|
if node is Control and Says.says(node, wanted):
|
|
49
53
|
if not Queries.shown(node):
|
|
50
54
|
hidden += 1
|
|
55
|
+
if _says_exactly(Words.said_by(node), wanted):
|
|
56
|
+
exact_out_of_reach.append(node)
|
|
57
|
+
out_of_reach_as.append("hidden")
|
|
51
58
|
else:
|
|
52
59
|
var control: Control = node
|
|
53
60
|
var target: Control = _pressed_through(control)
|
|
@@ -74,12 +81,18 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
74
81
|
for index: int in matched.size():
|
|
75
82
|
if _off_screen(matched[index]):
|
|
76
83
|
outside += 1
|
|
84
|
+
if exact[index]:
|
|
85
|
+
exact_out_of_reach.append(matched[index])
|
|
86
|
+
out_of_reach_as.append("outside what the screen shows")
|
|
77
87
|
continue
|
|
78
88
|
var over: Node = _cover_of(matched[index], drawn, areas)
|
|
79
89
|
if over == null:
|
|
80
90
|
open.append(index)
|
|
81
91
|
else:
|
|
82
92
|
covered += 1
|
|
93
|
+
if exact[index]:
|
|
94
|
+
exact_out_of_reach.append(matched[index])
|
|
95
|
+
out_of_reach_as.append("under %s, which is drawn over it" % over.get_path())
|
|
83
96
|
if cover == null:
|
|
84
97
|
cover = over
|
|
85
98
|
# Stable, so matches of one rank keep the order they are drawn in.
|
|
@@ -92,10 +105,12 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
92
105
|
var targets: Array[Control] = []
|
|
93
106
|
var ranks: Array[int] = []
|
|
94
107
|
var lines: Array[String] = []
|
|
108
|
+
var wholes: Array[bool] = []
|
|
95
109
|
for index: int in open:
|
|
96
110
|
targets.append(matched[index])
|
|
97
111
|
ranks.append(_rank(matched[index], exact[index]))
|
|
98
112
|
lines.append(said[index])
|
|
113
|
+
wholes.append(exact[index])
|
|
99
114
|
|
|
100
115
|
var under: String = "" if root_path == "/root" else " under %s" % root_path
|
|
101
116
|
if targets.is_empty():
|
|
@@ -103,8 +118,15 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
103
118
|
"type": "error",
|
|
104
119
|
"message":
|
|
105
120
|
(
|
|
106
|
-
'no control on screen%s says "%s", so nothing was clicked%s%s%s'
|
|
107
|
-
% [
|
|
121
|
+
'no control on screen%s says "%s", so nothing was clicked%s%s%s%s'
|
|
122
|
+
% [
|
|
123
|
+
under,
|
|
124
|
+
wanted,
|
|
125
|
+
_hidden_note(hidden),
|
|
126
|
+
_outside_note(outside),
|
|
127
|
+
_covered_note(covered, cover),
|
|
128
|
+
_open_menu_note(root, wanted),
|
|
129
|
+
]
|
|
108
130
|
)
|
|
109
131
|
}
|
|
110
132
|
var best: int = ranks.count(ranks[0])
|
|
@@ -129,7 +151,33 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
129
151
|
% [str(which), targets.size(), plural, under, wanted, _candidates(targets, lines)]
|
|
130
152
|
)
|
|
131
153
|
}
|
|
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():
|
|
159
|
+
return {
|
|
160
|
+
"type": "error",
|
|
161
|
+
"message":
|
|
162
|
+
(
|
|
163
|
+
(
|
|
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'
|
|
166
|
+
)
|
|
167
|
+
% [
|
|
168
|
+
under,
|
|
169
|
+
wanted,
|
|
170
|
+
"the one on screen," if targets.size() == 1 else "the best match on screen,",
|
|
171
|
+
targets[index].get_path(),
|
|
172
|
+
_shortened(lines[index]),
|
|
173
|
+
_out_of_reach_exactly(exact_out_of_reach, out_of_reach_as),
|
|
174
|
+
index,
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
}
|
|
132
178
|
var found: Dictionary = {"says": wanted, "index": index, "of": targets.size()}
|
|
179
|
+
if not wholes[index]:
|
|
180
|
+
found["partOf"] = _shortened(lines[index])
|
|
133
181
|
if which == null and targets.size() > 1:
|
|
134
182
|
var others: String = (
|
|
135
183
|
"the other one on screen %s" % _passed_over(ranks[0], true)
|
|
@@ -450,6 +498,60 @@ static func _clipped_away(
|
|
|
450
498
|
return false
|
|
451
499
|
|
|
452
500
|
|
|
501
|
+
## Where [param label] draws the words [param wanted] matched in it, in the label's own space, when
|
|
502
|
+
## they are part of its text: {"point"} on their middle, {"unplaced"} saying why there is none, or
|
|
503
|
+
## {} when the words are the whole text, whose middle is the label's.
|
|
504
|
+
##
|
|
505
|
+
## A line of a dispatch names two people, each a link to their page, and a press on the label's
|
|
506
|
+
## middle landed on the second name when the first was asked for, so the game opened the wrong page.
|
|
507
|
+
## Godot answers which line a character is on, where that line is and how wide it is drawn, and
|
|
508
|
+
## nothing narrower, so the place along the line is the font's measure of the text before the
|
|
509
|
+
## words. A line in another font or holding an image measures off from what is drawn, which a wide
|
|
510
|
+
## line with nothing between the words and its start hardly does.
|
|
511
|
+
static func point_on_words(label: RichTextLabel, wanted: String) -> Dictionary:
|
|
512
|
+
var text: String = Words.said_by(label)
|
|
513
|
+
var start: int = -1
|
|
514
|
+
var length: int = 0
|
|
515
|
+
for words: String in Says.alternatives(wanted):
|
|
516
|
+
if not Says.is_plain(words):
|
|
517
|
+
continue
|
|
518
|
+
var at: int = text.findn(words)
|
|
519
|
+
if at >= 0 and (start < 0 or at < start):
|
|
520
|
+
start = at
|
|
521
|
+
length = words.length()
|
|
522
|
+
if start < 0:
|
|
523
|
+
return {"unplaced": "the words were matched as a pattern, which names no one place in the text"}
|
|
524
|
+
if text.strip_edges().length() == length:
|
|
525
|
+
return {}
|
|
526
|
+
var line: int = label.get_character_line(start)
|
|
527
|
+
var shown: Vector2i = label.get_line_range(line)
|
|
528
|
+
if line < 0 or shown.x > start:
|
|
529
|
+
return {"unplaced": "the label could not say which line the words are on"}
|
|
530
|
+
var on_line: int = mini(length, shown.y - start) if shown.y > start else length
|
|
531
|
+
var font: Font = label.get_theme_font("normal_font")
|
|
532
|
+
var font_size: int = label.get_theme_font_size("normal_font_size")
|
|
533
|
+
var before: float = _width(font, font_size, text.substr(shown.x, start - shown.x))
|
|
534
|
+
var across: float = _width(font, font_size, text.substr(start, on_line))
|
|
535
|
+
var frame: StyleBox = label.get_theme_stylebox("normal")
|
|
536
|
+
var left: float = frame.get_margin(SIDE_LEFT)
|
|
537
|
+
var room: float = label.size.x - left - frame.get_margin(SIDE_RIGHT)
|
|
538
|
+
var spare: float = maxf(room - label.get_line_width(line), 0.0)
|
|
539
|
+
var aligned: float = 0.0
|
|
540
|
+
if label.horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER:
|
|
541
|
+
aligned = spare * 0.5
|
|
542
|
+
elif label.horizontal_alignment == HORIZONTAL_ALIGNMENT_RIGHT:
|
|
543
|
+
aligned = spare
|
|
544
|
+
var scrolled: float = label.get_v_scroll_bar().value if label.scroll_active else 0.0
|
|
545
|
+
var x: float = clampf(left + aligned + before + across * 0.5, left, left + room)
|
|
546
|
+
var top: float = frame.get_margin(SIDE_TOP) + label.get_line_offset(line) - scrolled
|
|
547
|
+
return {"point": Vector2(x, top + label.get_line_height(line) * 0.5)}
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
## How wide [param font] at [param font_size] draws [param words] on one line.
|
|
551
|
+
static func _width(font: Font, font_size: int, words: String) -> float:
|
|
552
|
+
return font.get_string_size(words, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x
|
|
553
|
+
|
|
554
|
+
|
|
453
555
|
## The canvas layer [param control] is drawn on, 0 for the viewport's own canvas.
|
|
454
556
|
static func _layer_of(control: Control) -> int:
|
|
455
557
|
var layer: CanvasLayer = control.get_canvas_layer_node()
|
|
@@ -461,10 +563,48 @@ static func _layer_of(control: Control) -> int:
|
|
|
461
563
|
static func _candidates(targets: Array[Control], said: Array[String]) -> String:
|
|
462
564
|
var named: Array[String] = []
|
|
463
565
|
for index: int in mini(targets.size(), LISTED_CANDIDATES):
|
|
464
|
-
|
|
465
|
-
if words.length() > 60:
|
|
466
|
-
words = words.substr(0, 57) + "..."
|
|
467
|
-
named.append('%d %s ("%s")' % [index, targets[index].get_path(), words])
|
|
566
|
+
named.append('%d %s ("%s")' % [index, targets[index].get_path(), _shortened(said[index])])
|
|
468
567
|
if targets.size() > LISTED_CANDIDATES:
|
|
469
568
|
named.append("and %d more" % (targets.size() - LISTED_CANDIDATES))
|
|
470
569
|
return "; ".join(named)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
## What to add to a refusal that found nothing on screen, when an open menu has an item saying the
|
|
573
|
+
## words: a menu draws its items rather than holding a control for each, so a click finds none, while
|
|
574
|
+
## a wait for the words finds the item. A wait met on "Leave the hall" was followed by a click on it
|
|
575
|
+
## refused as nothing on screen saying it, with no word of the call that chooses it.
|
|
576
|
+
static func _open_menu_note(root: Node, wanted: String) -> String:
|
|
577
|
+
var pending: Array[Node] = [root]
|
|
578
|
+
while not pending.is_empty():
|
|
579
|
+
var node: Node = pending.pop_back()
|
|
580
|
+
var menu: PopupMenu = node as PopupMenu
|
|
581
|
+
if menu != null and menu.visible:
|
|
582
|
+
for item: int in menu.item_count:
|
|
583
|
+
var text: String = menu.get_item_text(item)
|
|
584
|
+
if Says.matches(text, wanted):
|
|
585
|
+
var owner: Node = menu.get_parent()
|
|
586
|
+
var named: Node = owner if owner is MenuButton or owner is OptionButton else menu
|
|
587
|
+
return (
|
|
588
|
+
(
|
|
589
|
+
'; the open menu %s has an item saying it, "%s", which a click cannot reach:'
|
|
590
|
+
+ ' choose it with runtime_input choose, path %s and text "%s"'
|
|
591
|
+
)
|
|
592
|
+
% [menu.get_path(), text, named.get_path(), text]
|
|
593
|
+
)
|
|
594
|
+
pending.append_array(node.get_children(true))
|
|
595
|
+
return ""
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
## [param words] on one line and at most sixty characters, for naming a control by what it says.
|
|
599
|
+
static func _shortened(words: String) -> String:
|
|
600
|
+
var flat: String = words.replace("\n", " ").strip_edges()
|
|
601
|
+
return flat if flat.length() <= 60 else flat.substr(0, 57) + "..."
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
## The controls in [param nodes], each saying the words exactly and out of a click's reach for the
|
|
605
|
+
## reason [param why] holds at the same place, as the end of a sentence.
|
|
606
|
+
static func _out_of_reach_exactly(nodes: Array[Node], why: Array[String]) -> String:
|
|
607
|
+
var first: String = "%s, %s" % [nodes[0].get_path(), why[0]]
|
|
608
|
+
if nodes.size() == 1:
|
|
609
|
+
return "1 control says exactly that and cannot be clicked: %s" % first
|
|
610
|
+
return "%d controls say exactly that and cannot be clicked, such as %s" % [nodes.size(), first]
|
|
@@ -139,12 +139,16 @@ static func _text_of(node: Node) -> String:
|
|
|
139
139
|
if not text is String:
|
|
140
140
|
return ""
|
|
141
141
|
var words: String = text
|
|
142
|
-
# What somebody typed is drawn as typed, never translated.
|
|
142
|
+
# What somebody typed is drawn as typed, never translated. An empty field draws its placeholder
|
|
143
|
+
# instead, which is what a player reads it by: a search field was found by nothing it showed.
|
|
143
144
|
if node is LineEdit:
|
|
144
145
|
var field: LineEdit = node
|
|
146
|
+
if words.is_empty():
|
|
147
|
+
return field.atr(field.placeholder_text).strip_edges()
|
|
145
148
|
return masked(field) if field.secret else words.strip_edges()
|
|
146
149
|
if node is TextEdit:
|
|
147
|
-
|
|
150
|
+
var area: TextEdit = node
|
|
151
|
+
return (area.atr(area.placeholder_text) if words.is_empty() else words).strip_edges()
|
|
148
152
|
words = node.atr(words)
|
|
149
153
|
if node is Label3D:
|
|
150
154
|
var sign_text: Label3D = node
|