gdharness 1.2.28 → 1.2.29

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.
@@ -10,6 +10,7 @@ extends RefCounted
10
10
  ## told, names left empty included.
11
11
 
12
12
  const Read = preload("reading.gd")
13
+ const Says = preload("runtime_says.gd")
13
14
 
14
15
  ## How long the client keeps walking the tree for it to settle: the engine answers the first question
15
16
  ## with its windows alone and fills them in over the next frames.
@@ -23,6 +24,9 @@ const START_MS: int = 15000
23
24
  ## Deep enough for any interface a game builds, and inside the hundred levels ConvertTo-Json stops
24
25
  ## at, which are two to a level of the tree.
25
26
  const MOST_LEVELS: int = 40
27
+ ## Where an element holds its node's instance id between matching and placing, removed before the
28
+ ## answer goes.
29
+ const NODE_ID: String = "_node_instance"
26
30
 
27
31
  ## The client, as PowerShell, with $Handles, $Out, $SettleMs, $EmptyMs and $MostLevels set before it. Each
28
32
  ## element answers what UI Automation names its role, its name, its value where it has one, its
@@ -170,12 +174,27 @@ func _init(host: Node) -> void:
170
174
 
171
175
 
172
176
  ## The game's windows as a screen reader is told them, each a tree of elements.
173
- func get_accessibility(_params: Dictionary) -> Dictionary:
177
+ func get_accessibility(params: Dictionary) -> Dictionary:
174
178
  if DisplayServer.get_name() == "headless":
179
+ # Both needs at once: told only the window, a caller started a windowed run and was then
180
+ # refused for the tree the engine had not been asked to build.
181
+ var also: String = ""
182
+ if _setting() != "always":
183
+ also = (
184
+ (
185
+ ' and engineArgs ["--accessibility", "always"], since this project\'s'
186
+ + " accessibility/general/accessibility_support is %s and the engine builds the tree"
187
+ + " only when asked"
188
+ )
189
+ % _setting()
190
+ )
175
191
  return _refused(
176
192
  (
177
- "A headless run has no window, so a screen reader is told nothing: start the run with"
178
- + " headless: false."
193
+ (
194
+ "A headless run has no window, so a screen reader is told nothing: start the run with"
195
+ + " headless: false%s."
196
+ )
197
+ % also
179
198
  )
180
199
  )
181
200
  if OS.get_name() != "Windows":
@@ -255,12 +274,16 @@ func get_accessibility(_params: Dictionary) -> Dictionary:
255
274
  var window: Dictionary = windows[at]
256
275
  var window_id: int = ids[at] if at < ids.size() else -1
257
276
  window["window_id"] = window_id
277
+ _match(window, made_by, placing)
278
+ for at: int in windows.size():
279
+ var window: Dictionary = windows[at]
280
+ var window_id: int = window["window_id"]
258
281
  var holder: Window = _window_with(_host.get_tree().root, window_id)
259
- var holder_path: String = ""
260
282
  if holder != null:
261
- holder_path = str(holder.get_path())
262
- window["node"] = holder_path
263
- _place(window, holder_path, made_by, placing, true)
283
+ window["node"] = str(holder.get_path())
284
+ _attribute(window, holder, placing, true)
285
+ else:
286
+ _unmark(window)
264
287
  elements += _count(window)
265
288
  unnamed += _unnamed_text(window)
266
289
  var result: Dictionary = {"windows": windows, "elements": elements}
@@ -315,9 +338,114 @@ func get_accessibility(_params: Dictionary) -> Dictionary:
315
338
  )
316
339
  % unnamed
317
340
  )
341
+ _narrow(result, params)
318
342
  return result
319
343
 
320
344
 
345
+ ## Cuts [param result] to what [param params] asks for: the elements made by a node and its
346
+ ## descendants, the elements saying some words, the fields named, and no more than a number of them.
347
+ ## Each window keeps as its children only the outermost elements that match, with what they hold, so
348
+ ## a question about one panel reads one panel.
349
+ static func _narrow(result: Dictionary, params: Dictionary) -> void:
350
+ var under: String = str(params.get("node_path", "")).trim_suffix("/")
351
+ var said: String = str(params.get("says", ""))
352
+ var windows: Array = result.get("windows", [])
353
+ if not under.is_empty() or not said.is_empty():
354
+ var matched: int = 0
355
+ for window: Variant in windows:
356
+ if not window is Dictionary:
357
+ continue
358
+ var holder: Dictionary = window
359
+ if _matches(holder, under, said):
360
+ matched += 1
361
+ continue
362
+ var kept: Array = []
363
+ _outermost(holder, under, said, kept)
364
+ holder["children"] = kept
365
+ matched += kept.size()
366
+ result["matched"] = matched
367
+ var fields: Array = params.get("properties", [])
368
+ if not fields.is_empty():
369
+ for window: Variant in windows:
370
+ if window is Dictionary:
371
+ var holder: Dictionary = window
372
+ _keep_only(holder, fields)
373
+ var limit: int = Read.as_int(params.get("limit", 0), 0)
374
+ if limit > 0:
375
+ var left: Array[int] = [limit]
376
+ var omitted: Array[int] = [0]
377
+ for window: Variant in windows:
378
+ if window is Dictionary:
379
+ var holder: Dictionary = window
380
+ _cut(holder, left, omitted)
381
+ if omitted[0] > 0:
382
+ result["omitted"] = omitted[0]
383
+ var counted: int = 0
384
+ for window: Variant in windows:
385
+ if window is Dictionary:
386
+ var holder: Dictionary = window
387
+ counted += _count(holder)
388
+ result["elements"] = counted
389
+
390
+
391
+ ## Whether [param element] is made by the node at [param under] or one below it, and says
392
+ ## [param said], each where asked: words as find matches them, in the element's name or value.
393
+ static func _matches(element: Dictionary, under: String, said: String) -> bool:
394
+ if not under.is_empty():
395
+ var path: String = str(element.get("node", element.get("part_of", "")))
396
+ if path != under and not path.begins_with(under + "/"):
397
+ return false
398
+ if not said.is_empty():
399
+ var name: String = str(element.get("name", ""))
400
+ var value: String = str(element.get("value", ""))
401
+ if not Says.matches(name, said) and not Says.matches(value, said):
402
+ return false
403
+ return true
404
+
405
+
406
+ static func _outermost(element: Dictionary, under: String, said: String, kept: Array) -> void:
407
+ var children: Array = element.get("children", [])
408
+ for child: Variant in children:
409
+ if not child is Dictionary:
410
+ continue
411
+ var inner: Dictionary = child
412
+ if _matches(inner, under, said):
413
+ kept.append(inner)
414
+ else:
415
+ _outermost(inner, under, said, kept)
416
+
417
+
418
+ ## Leaves [param element] and everything under it only the [param fields] asked for, and the
419
+ ## children that hold the rest of the tree.
420
+ static func _keep_only(element: Dictionary, fields: Array) -> void:
421
+ for key: Variant in element.keys():
422
+ if key != "children" and not fields.has(key):
423
+ var _dropped: bool = element.erase(key)
424
+ var children: Array = element.get("children", [])
425
+ for child: Variant in children:
426
+ if child is Dictionary:
427
+ var inner: Dictionary = child
428
+ _keep_only(inner, fields)
429
+
430
+
431
+ ## Keeps the first [param left] elements below [param element], depth first, and counts the ones
432
+ ## cut into [param omitted]. One-element arrays, so the count carries across windows.
433
+ static func _cut(element: Dictionary, left: Array[int], omitted: Array[int]) -> void:
434
+ var children: Array = element.get("children", [])
435
+ var kept: Array = []
436
+ for child: Variant in children:
437
+ if not child is Dictionary:
438
+ continue
439
+ var inner: Dictionary = child
440
+ if left[0] <= 0:
441
+ omitted[0] += _count(inner)
442
+ continue
443
+ left[0] -= 1
444
+ kept.append(inner)
445
+ _cut(inner, left, omitted)
446
+ element["children"] = kept
447
+
448
+
321
449
  ## The project's accessibility mode, as the command line's --accessibility names them.
322
450
  static func _setting() -> String:
323
451
  match Read.as_int(ProjectSettings.get_setting("accessibility/general/accessibility_support", 0), 0):
@@ -360,15 +488,13 @@ static func _window_with(node: Node, window_id: int) -> Window:
360
488
  return null
361
489
 
362
490
 
363
- ## Gives each child of [param element] the node that made it, or, for an element a node made as a
364
- ## part of itself (a line of a text box, a window's announcements), the node it is a part of.
491
+ ## Gives every element below [param element] whose id is a node's element that node, and notes
492
+ ## which nodes UI Automation shows.
365
493
  ##
366
494
  ## UI Automation's runtime id for an element ends in the four halves of AccessKit's id for it, and
367
495
  ## Godot gives AccessKit the element's RID: measured on 4.7.2, /root/Main's element answered
368
496
  ## 114,1,0,0 with RID 114 << 32 | 1. Which pair holds it is not assumed, so both are tried.
369
- static func _place(
370
- element: Dictionary, holder: String, made_by: Dictionary, placing: Placing, is_window: bool
371
- ) -> void:
497
+ static func _match(element: Dictionary, made_by: Dictionary, placing: Placing) -> void:
372
498
  var children: Array = element.get("children", [])
373
499
  for child: Variant in children:
374
500
  if not child is Dictionary:
@@ -376,27 +502,127 @@ static func _place(
376
502
  var inner: Dictionary = child
377
503
  var parts: Array = inner.get("element_id", [])
378
504
  var _dropped: bool = inner.erase("element_id")
379
- var node: Node = null
380
505
  if parts.size() == 4:
381
506
  placing.asked += 1
382
- node = _made(parts, made_by)
383
- var path: String = holder
384
- if node != null:
385
- placing.placed += 1
386
- path = str(node.get_path())
387
- inner["node"] = path
388
- if node is Control:
389
- var control: Control = node
390
- if not control.tooltip_text.is_empty():
391
- inner["tooltip"] = control.tooltip_text
392
- placing.tooltips += 1
507
+ var node: Node = _made(parts, made_by)
508
+ if node != null:
509
+ placing.placed += 1
510
+ placing.shown[node.get_instance_id()] = true
511
+ inner["node"] = str(node.get_path())
512
+ inner[NODE_ID] = node.get_instance_id()
513
+ if node is Control:
514
+ var control: Control = node
515
+ if not control.tooltip_text.is_empty():
516
+ inner["tooltip"] = control.tooltip_text
517
+ placing.tooltips += 1
518
+ _match(inner, made_by, placing)
519
+
520
+
521
+ ## Gives every element below [param element] that is no node's own the node it is a part of.
522
+ ##
523
+ ## UI Automation's parent is not that node: AccessKit leaves every container out of the tree it
524
+ ## gives UI Automation and hangs what the container held on the nearest element it keeps, and a
525
+ ## Control, a RichTextLabel and each of its lines are containers. A RichTextLabel's text in a
526
+ ## column of plain containers came out straight under the window. So the node is found where it
527
+ ## must be: below the shown element's node, itself left out of the tree (a node that is shown would
528
+ ## have the part under it), drawn around the part, and, for text, holding its words.
529
+ static func _attribute(element: Dictionary, holder: Node, placing: Placing, is_window: bool) -> void:
530
+ var children: Array = element.get("children", [])
531
+ for child: Variant in children:
532
+ if not child is Dictionary:
533
+ continue
534
+ var inner: Dictionary = child
535
+ if inner.has(NODE_ID):
536
+ var node: Node = instance_from_id(Read.as_int(inner[NODE_ID], 0))
537
+ var _dropped: bool = inner.erase(NODE_ID)
538
+ _attribute(inner, node if node != null else holder, placing, false)
539
+ continue
540
+ var owners: Array[Node] = _owners_of(inner, holder, placing)
541
+ if owners.size() == 1:
542
+ inner["part_of"] = str(owners[0].get_path())
393
543
  else:
394
- inner["part_of"] = holder
395
- # The one static text a window makes of itself: where it speaks what is announced, named
396
- # only while something is.
397
- if is_window and inner.get("role") == "text":
398
- inner["part"] = "announcement"
399
- _place(inner, path, made_by, placing, false)
544
+ inner["part_of"] = str(holder.get_path())
545
+ var paths: Array[String] = []
546
+ for owner: Node in owners:
547
+ paths.append(str(owner.get_path()))
548
+ inner["part_of_candidates"] = paths
549
+ # The one text a window makes of itself, where it speaks what is announced: it is given no
550
+ # bounds, where every text a node draws has them.
551
+ var only_holder: bool = owners.size() == 1 and owners[0] == holder
552
+ if is_window and inner.get("role") == "text" and not inner.has("bounds") and only_holder:
553
+ inner["part"] = "announcement"
554
+ _attribute(inner, owners[0] if owners.size() == 1 else holder, placing, false)
555
+
556
+
557
+ ## The nodes that may have made [param element], below [param holder]: the deepest ones drawn
558
+ ## around it whose own elements are not shown, narrowed to those holding its words when that leaves
559
+ ## any. [param holder] alone when none is drawn around it, or when it has no bounds to look by.
560
+ static func _owners_of(element: Dictionary, holder: Node, placing: Placing) -> Array[Node]:
561
+ var bounds: Variant = element.get("bounds")
562
+ if not bounds is Dictionary:
563
+ return [holder]
564
+ var box: Dictionary = bounds
565
+ var drawn: Rect2 = Rect2(
566
+ Read.as_float(box.get("x", 0)),
567
+ Read.as_float(box.get("y", 0)),
568
+ Read.as_float(box.get("width", 0)),
569
+ Read.as_float(box.get("height", 0))
570
+ )
571
+ var found: Array[Node] = []
572
+ _drawn_around(holder, drawn, placing, found)
573
+ if found.is_empty():
574
+ return [holder]
575
+ # A RichTextLabel's line ends in a zero-width space its text does not have.
576
+ var words: String = str(element.get("name", "")).replace(String.chr(0x200b), "").strip_edges()
577
+ if found.size() > 1 and not words.is_empty():
578
+ var saying: Array[Node] = []
579
+ for node: Node in found:
580
+ if _text_of(node).contains(words):
581
+ saying.append(node)
582
+ if not saying.is_empty():
583
+ return saying
584
+ return found
585
+
586
+
587
+ ## Where a Control is drawn is read off its screen transform, which for a window that is not
588
+ ## embedded is in that window's own pixels, as the bounds are.
589
+ static func _drawn_around(node: Node, drawn: Rect2, placing: Placing, found: Array[Node]) -> void:
590
+ for child: Node in node.get_children(true):
591
+ # A node that is shown has its parts under it, and so do the nodes below it.
592
+ if placing.shown.has(child.get_instance_id()):
593
+ continue
594
+ if child is CanvasItem:
595
+ var item: CanvasItem = child
596
+ if not item.is_visible_in_tree():
597
+ continue
598
+ var before: int = found.size()
599
+ _drawn_around(child, drawn, placing, found)
600
+ if found.size() > before or not child is Control:
601
+ continue
602
+ var control: Control = child
603
+ var around: Rect2 = control.get_screen_transform() * Rect2(Vector2.ZERO, control.size)
604
+ # Two pixels either way, for the rounding of bounds to whole pixels on both sides.
605
+ if around.grow(2.0).encloses(drawn):
606
+ found.append(control)
607
+
608
+
609
+ ## Takes the instance ids [method _match] left out of a window no node holds.
610
+ static func _unmark(element: Dictionary) -> void:
611
+ var _dropped: bool = element.erase(NODE_ID)
612
+ var children: Array = element.get("children", [])
613
+ for child: Variant in children:
614
+ if child is Dictionary:
615
+ var inner: Dictionary = child
616
+ _unmark(inner)
617
+
618
+
619
+ static func _text_of(node: Node) -> String:
620
+ if node is RichTextLabel:
621
+ var rich: RichTextLabel = node
622
+ return rich.get_parsed_text()
623
+ if "text" in node:
624
+ return str(node.get("text"))
625
+ return ""
400
626
 
401
627
 
402
628
  static func _made(parts: Array, made_by: Dictionary) -> Node:
@@ -410,12 +636,14 @@ static func _made(parts: Array, made_by: Dictionary) -> Node:
410
636
  return null
411
637
 
412
638
 
413
- ## Counts kept while the elements are placed.
639
+ ## Counts kept while the elements are placed, and the nodes whose own elements UI Automation shows,
640
+ ## by instance id.
414
641
  class Placing:
415
642
  extends RefCounted
416
643
  var asked: int = 0
417
644
  var placed: int = 0
418
645
  var tooltips: int = 0
646
+ var shown: Dictionary = {}
419
647
 
420
648
 
421
649
  ## Text elements with bounds and no name. The window's announcement element has no bounds and is
@@ -32,15 +32,16 @@ func set_property(params: Dictionary) -> Dictionary:
32
32
  if node_path.is_empty() or property.is_empty():
33
33
  return {"type": "error", "message": "Node path and property required"}
34
34
 
35
- var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
35
+ var standing: Dictionary = Values.target_at(_host.get_tree().root, node_path)
36
36
  if standing.has("message"):
37
37
  return standing
38
38
  var node: Node = standing["node"]
39
+ var target: Object = standing["object"]
39
40
 
40
41
  # Through a path as well, for the reason [method Paths.walk_to] gives, and read back off the same
41
42
  # holder afterwards: a set that does not take says so by answering with the old value, which is
42
43
  # how a typed container refusing a write is told from one accepting it.
43
- var reached: Dictionary = Paths.walk_to(node, node_path, property)
44
+ var reached: Dictionary = Paths.walk_to(target, node_path, property)
44
45
  if reached.has("message"):
45
46
  return reached
46
47
 
@@ -119,11 +120,28 @@ func set_property(params: Dictionary) -> Dictionary:
119
120
  "%s.%s is a typed %s: %s." % [reached["called"], named, _sort_of(old_value), typed["message"]]
120
121
  }
121
122
  given = typed["value"]
123
+ # A component of a vector or a transform is written into a copy, which is put back where it was
124
+ # read from, setter and all; the read back is then taken off the game again, since the copy here
125
+ # says only what was written into it.
126
+ var chain: Array[Dictionary] = reached["chain"]
127
+ var copied: bool = Paths.composite(holder)
128
+ if copied:
129
+ var unwritable: String = Paths.unwritable_copy(chain)
130
+ if not unwritable.is_empty():
131
+ return {"type": "error", "message": unwritable}
122
132
  # The write alone, setter included, which is where a property's cost is.
123
133
  var began: int = Time.get_ticks_usec()
124
- Paths.write(holder, named, given)
134
+ var written: Variant = Paths.write(holder, named, given)
135
+ if copied:
136
+ Paths.write_back(chain, written)
125
137
  var elapsed: int = Time.get_ticks_usec() - began
126
138
  var now: Variant = Paths.read_under(holder, named)
139
+ if copied:
140
+ var again: Dictionary = Paths.walk_to(target, node_path, property)
141
+ if again.has("message"):
142
+ return again
143
+ var reread: Variant = again["holder"]
144
+ now = Paths.read_under(reread, named) if Paths.can_read(reread, named) else null
127
145
  # Read back rather than trusted. The engine drops a write it will not take without a word, and
128
146
  # the answer then showed the old value as the new one, shaped as a success: a typed container
129
147
  # refusing a plain list did exactly that. A value the property changed on the way in, a setter
@@ -211,15 +229,16 @@ func call_method(params: Dictionary) -> Dictionary:
211
229
  if node_path.is_empty() or method.is_empty():
212
230
  return {"type": "error", "message": "Node path and method required"}
213
231
 
214
- var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
232
+ var standing: Dictionary = Values.target_at(_host.get_tree().root, node_path)
215
233
  if standing.has("message"):
216
234
  return standing
217
235
  var node: Node = standing["node"]
236
+ var target: Object = standing["object"]
218
237
 
219
238
  # Through a path as well, for the reason [method Paths.walk_to] gives. What a game does hangs off its
220
239
  # nodes as much as its state does, so reading `_game:run:day` while being unable to call
221
240
  # `_game:run:advance` answers half of what a node holds and refuses the other half.
222
- var reached: Dictionary = Paths.walk_to(node, node_path, method)
241
+ var reached: Dictionary = Paths.walk_to(target, node_path, method)
223
242
  if reached.has("message"):
224
243
  return reached
225
244
 
@@ -461,8 +480,11 @@ static func _walk_from(object: Object, path: String) -> Dictionary:
461
480
  var holder: Variant = object
462
481
  var called: String = "the result"
463
482
  for named: String in Paths.steps_of(path):
464
- if not (holder is Object or holder is Array or holder is Dictionary or Paths.packed(holder)):
465
- return {"message": "%s holds no object to read %s off" % [called, named]}
483
+ if not Paths.can_hold(holder):
484
+ return {
485
+ "message":
486
+ "%s holds %s, which has nothing to read %s off" % [called, type_string(typeof(holder)), named]
487
+ }
466
488
  if not Paths.can_read(holder, named):
467
489
  return {"message": Paths.nothing_there(holder, named, called)}
468
490
  holder = Paths.read_under(holder, named)
@@ -122,12 +122,16 @@ func inject_action(params: Dictionary) -> Dictionary:
122
122
 
123
123
  if not InputMap.has_action(action):
124
124
  return {"type": "error", "message": "Action not found: " + action}
125
+ var holding: Variant = Values.held_for(params)
126
+ if holding is String:
127
+ return {"type": "error", "message": holding}
128
+ var frames: int = holding
125
129
 
126
130
  _say_action(action, whole or held, strength)
127
131
  if whole:
128
- # A frame between the halves, as a click has: a listener that acts on the press and a
129
- # listener that acts on the release both get their own frame to do it in.
130
- await _host.get_tree().process_frame
132
+ # A frame between the halves at least, as a click has: a listener that acts on the press and
133
+ # a listener that acts on the release both get their own frame to do it in.
134
+ await _held(frames)
131
135
  _say_action(action, false, strength)
132
136
  await _host.get_tree().process_frame
133
137
 
@@ -137,9 +141,16 @@ func inject_action(params: Dictionary) -> Dictionary:
137
141
  "action": action,
138
142
  "pressed": held and not whole,
139
143
  "whole": whole,
144
+ "held_frames": Values.held_answer(whole, frames),
140
145
  }
141
146
 
142
147
 
148
+ ## Waits out [param frames] process frames, which is how long a whole press is held down.
149
+ func _held(frames: int) -> void:
150
+ for _frame: int in frames:
151
+ await _host.get_tree().process_frame
152
+
153
+
143
154
  func _say_action(action: String, down: bool, strength: float) -> void:
144
155
  var event: InputEventAction = InputEventAction.new()
145
156
  event.action = action
@@ -160,6 +171,10 @@ func inject_key(params: Dictionary) -> Dictionary:
160
171
  if not named.is_empty() and key_label.is_empty():
161
172
  key_label = named
162
173
  var keycode: int = 0 if keycode_raw is String else Read.as_int(keycode_raw)
174
+ var holding: Variant = Values.held_for(params)
175
+ if holding is String:
176
+ return {"type": "error", "message": holding}
177
+ var frames: int = holding
163
178
 
164
179
  var event: InputEventKey = InputEventKey.new()
165
180
  event.pressed = whole or held
@@ -193,7 +208,7 @@ func inject_key(params: Dictionary) -> Dictionary:
193
208
 
194
209
  Input.parse_input_event(event)
195
210
  if whole:
196
- await _host.get_tree().process_frame
211
+ await _held(frames)
197
212
  var up: InputEventKey = event.duplicate()
198
213
  up.pressed = false
199
214
  Input.parse_input_event(up)
@@ -202,6 +217,7 @@ func inject_key(params: Dictionary) -> Dictionary:
202
217
  return {
203
218
  "type": "input_injected",
204
219
  "input_type": "key",
220
+ "held_frames": Values.held_answer(whole, frames),
205
221
  "keycode": event.keycode,
206
222
  "physical_keycode": event.physical_keycode,
207
223
  "shift": event.shift_pressed,
@@ -266,6 +282,15 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
266
282
  # than under the pointer, landing true and doing nothing, until a release was sent by hand.
267
283
  var notch: bool = held and _mask_of(button) == 0
268
284
  var whole: bool = notch or not params.has("pressed")
285
+ var holding: Variant = Values.held_for(params)
286
+ if holding is String:
287
+ return {"type": "error", "message": holding}
288
+ var frames: int = holding
289
+ if notch and params.has("holdFrames"):
290
+ return {
291
+ "type": "error",
292
+ "message": "A wheel step is pressed and let go at once, so holdFrames cannot hold one."
293
+ }
269
294
 
270
295
  # A button pressed somewhere puts the pointer there: the motion after a held press measures
271
296
  # its drag from the press, which is where a real pointer would be.
@@ -274,9 +299,9 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
274
299
  if notch:
275
300
  Input.parse_input_event(_button(position, button, false, false))
276
301
  elif whole:
277
- # A frame between the halves, as inject_action has, so a control that acts on the press and
278
- # one that acts on the release each get a frame to do it in.
279
- await _host.get_tree().process_frame
302
+ # A frame between the halves at least, as inject_action has, so a control that acts on the
303
+ # press and one that acts on the release each get a frame to do it in.
304
+ await _held(frames)
280
305
  Input.parse_input_event(_button(position, button, false, false))
281
306
  await _host.get_tree().process_frame
282
307
 
@@ -284,6 +309,7 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
284
309
  {
285
310
  "type": "input_injected",
286
311
  "input_type": "mouse_click",
312
+ "held_frames": Values.held_answer(whole and not notch, frames),
287
313
  "position": [position.x, position.y],
288
314
  "button": button,
289
315
  "pressed": held and not whole,
@@ -9,6 +9,7 @@ extends RefCounted
9
9
  ## calling the game's own methods. The engine still fires the actions bound to a button from these.
10
10
 
11
11
  const Read = preload("reading.gd")
12
+ const Values = preload("runtime_values.gd")
12
13
 
13
14
  ## Controller buttons by the names Godot gives them, for a caller naming one rather than numbering it.
14
15
  const JOY_BUTTONS: Dictionary[String, int] = {
@@ -62,18 +63,24 @@ func inject_joypad_button(params: Dictionary) -> Dictionary:
62
63
  var device: int = Read.as_int(params.get("device", 0), 0)
63
64
  var held: bool = Read.as_bool(params.get("pressed", true), true)
64
65
  var whole: bool = not params.has("pressed")
66
+ var holding: Variant = Values.held_for(params)
67
+ if holding is String:
68
+ return {"type": "error", "message": holding}
69
+ var frames: int = holding
65
70
 
66
71
  Input.parse_input_event(_button(button, device, whole or held))
67
72
  if whole:
68
- # A frame between the halves, as a key has, so a game that acts on the press and one that
69
- # acts on the release each get a frame to do it in.
70
- await _host.get_tree().process_frame
73
+ # A frame between the halves at least, as a key has, so a game that acts on the press and one
74
+ # that acts on the release each get a frame to do it in.
75
+ for _frame: int in frames:
76
+ await _host.get_tree().process_frame
71
77
  Input.parse_input_event(_button(button, device, false))
72
78
  await _host.get_tree().process_frame
73
79
 
74
80
  return {
75
81
  "type": "input_injected",
76
82
  "input_type": "joypad_button",
83
+ "held_frames": Values.held_answer(whole, frames),
77
84
  "button": button,
78
85
  "button_name": _name_of(button, JOY_BUTTONS),
79
86
  "device": device,