gdharness 1.2.39 → 1.2.41

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.
Files changed (27) hide show
  1. package/build/cli.js +1435 -1410
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +11 -2
  3. package/build/godot/addons/gdharness_editor/bridge_client.gd +2 -1
  4. package/build/godot/addons/gdharness_editor/headless_check.gd +22 -11
  5. package/build/godot/addons/gdharness_editor/property_values.gd +1 -1
  6. package/build/godot/addons/gdharness_editor/reading.gd +15 -0
  7. package/build/godot/addons/gdharness_editor/scene_file.gd +9 -0
  8. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +39 -5
  9. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +68 -15
  10. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +12 -12
  11. package/build/godot/addons/gdharness_runtime/reading.gd +15 -0
  12. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +3 -1
  13. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +2 -2
  14. package/build/godot/addons/gdharness_runtime/runtime_input.gd +15 -2
  15. package/build/godot/addons/gdharness_runtime/runtime_items.gd +175 -0
  16. package/build/godot/addons/gdharness_runtime/runtime_items.gd.uid +1 -0
  17. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +43 -10
  18. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +61 -20
  19. package/build/godot/addons/gdharness_runtime/runtime_values.gd +3 -1
  20. package/build/godot/addons/gdharness_runtime/runtime_words.gd +48 -18
  21. package/build/godot/operations/animation_reads.gd +11 -2
  22. package/build/godot/operations/editor_tools.gd +9 -0
  23. package/build/godot/operations/godot_operations.gd +2 -1
  24. package/build/godot/operations/property_values.gd +1 -1
  25. package/build/godot/operations/reading.gd +15 -0
  26. package/build/index.js +2789 -2765
  27. package/package.json +1 -1
@@ -0,0 +1,175 @@
1
+ extends RefCounted
2
+
3
+ ## Where a control drawing a list draws one of its items, for a click by words: a tab, a list entry,
4
+ ## a tree row or a menu bar title. Each is drawn by the control rather than held as a node, so a click
5
+ ## on the control's path presses its middle, which is whichever item happens to sit there.
6
+
7
+ const Says = preload("runtime_says.gd")
8
+ const Values = preload("runtime_values.gd")
9
+ const Words = preload("runtime_words.gd")
10
+
11
+
12
+ ## Where [param control] draws [param item], one of [method Words.items_of], in the control's own
13
+ ## space, or an empty rectangle while it draws it nowhere a pointer reaches: a tab scrolled off its
14
+ ## bar, an entry or a row scrolled out of its list, a menu bar the operating system draws. A tree
15
+ ## row is the cell saying [param wanted] when one does, and the whole row otherwise.
16
+ ##
17
+ ## Each one checked against the control's own answer to which item is under a point, where it has
18
+ ## one, since an entry's rectangle is kept in the list's unscrolled space and a row's is computed
19
+ ## from the scroll as of the last draw.
20
+ static func item_rect(control: Control, item: Variant, wanted: String) -> Rect2:
21
+ var bounds: Rect2 = Rect2(Vector2.ZERO, control.size)
22
+ var bar: TabBar = control as TabBar
23
+ if bar != null:
24
+ var tab: int = item
25
+ var placed: Rect2 = bar.get_tab_rect(tab)
26
+ return placed if bar.get_tab_idx_at_point(placed.get_center()) == tab else Rect2()
27
+ var list: ItemList = control as ItemList
28
+ if list != null:
29
+ var entry: int = item
30
+ var scrolled: Vector2 = Vector2(list.get_h_scroll_bar().value, list.get_v_scroll_bar().value)
31
+ var placed: Rect2 = list.get_item_rect(entry, false)
32
+ placed.position -= scrolled
33
+ var reached: bool = bounds.has_point(placed.get_center())
34
+ return (
35
+ placed if reached and list.get_item_at_position(placed.get_center(), true) == entry else Rect2()
36
+ )
37
+ var tree: Tree = control as Tree
38
+ if tree != null:
39
+ var row: TreeItem = item
40
+ var column: int = tree_column(tree, row, wanted)
41
+ var placed: Rect2 = tree.get_item_area_rect(row, column)
42
+ var centre: Vector2 = placed.get_center()
43
+ if not bounds.has_point(centre) or tree.get_item_at_position(centre) != row:
44
+ return Rect2()
45
+ return placed if column == -1 or tree.get_column_at_position(centre) == column else Rect2()
46
+ var menus: MenuBar = control as MenuBar
47
+ if menus != null and not menus.is_native_menu():
48
+ var menu: int = item
49
+ return _menu_title_rect(menus, menu)
50
+ return Rect2()
51
+
52
+
53
+ ## The column of [param row] whose cell says [param wanted], or -1 when only the row as a whole
54
+ ## does, its cells read together.
55
+ static func tree_column(tree: Tree, row: TreeItem, wanted: String) -> int:
56
+ for column: int in tree.columns:
57
+ if Says.matches(Words.cell_says(tree, row, column), wanted):
58
+ return column
59
+ return -1
60
+
61
+
62
+ ## [param item] of [param control] as an answer names it: what it is, its number where the control
63
+ ## numbers its items, and what it says.
64
+ static func item_named(control: Control, item: Variant, says: String) -> Dictionary:
65
+ if control is Tree:
66
+ return {"kind": "row", "says": says}
67
+ var kind: String = "tab" if control is TabBar else ("menu" if control is MenuBar else "item")
68
+ return {"kind": kind, "index": item, "says": says}
69
+
70
+
71
+ ## Where in [param control] to press for [param item], the one of its items a click by
72
+ ## [param wanted] matched, as {"point", "scrolled"}, or a refusal saying why there is nowhere.
73
+ ##
74
+ ## Scrolled into the control's own view first when it is drawn nowhere yet, the way a player brings
75
+ ## a tab or an entry into view before pressing it. A disabled item is refused as a disabled button
76
+ ## is. A tree row is pressed on the cell saying the words, whose column goes into [param found].
77
+ static func in_view(
78
+ scene: SceneTree, control: Control, node_path: String, item: Variant, wanted: String, found: Dictionary
79
+ ) -> Dictionary:
80
+ var named: Dictionary = found.get("item", {})
81
+ var naming: String = 'the %s "%s" of %s' % [named.get("kind", "item"), named.get("says", ""), node_path]
82
+ var index: int = item if item is int else -1
83
+ var bar: TabBar = control as TabBar
84
+ var list: ItemList = control as ItemList
85
+ var menus: MenuBar = control as MenuBar
86
+ if (
87
+ (bar != null and bar.is_tab_disabled(index))
88
+ or (list != null and list.is_item_disabled(index))
89
+ or (menus != null and menus.is_menu_disabled(index))
90
+ ):
91
+ return {"type": "error", "message": "%s is disabled, so clicking it presses nothing" % naming}
92
+ var placed: Rect2 = item_rect(control, item, wanted)
93
+ var scrolled: bool = false
94
+ if placed.size == Vector2.ZERO:
95
+ scrolled = _scroll_to(control, item)
96
+ if scrolled:
97
+ # A tree places its rows by the scroll it last drew with.
98
+ await scene.process_frame
99
+ var left: String = Values.afterwards(control)
100
+ if left != "in_tree" or (typeof(item) == TYPE_OBJECT and not is_instance_valid(item)):
101
+ return {
102
+ "type": "error",
103
+ "message":
104
+ "%s went while it was being scrolled into view, so nothing was clicked" % naming
105
+ }
106
+ placed = item_rect(control, item, wanted)
107
+ if placed.size == Vector2.ZERO:
108
+ var tried: String = " even scrolled to" if scrolled else ""
109
+ return {
110
+ "type": "error",
111
+ "message": "%s is drawn nowhere a pointer reaches%s, so nothing was clicked" % [naming, tried]
112
+ }
113
+ var tree: Tree = control as Tree
114
+ if tree != null:
115
+ var row: TreeItem = item
116
+ var column: int = tree_column(tree, row, wanted)
117
+ if column != -1:
118
+ named["column"] = column
119
+ return {"point": placed.get_center(), "scrolled": scrolled}
120
+
121
+
122
+ ## Where [param menus] draws the title of [param menu], worked out as the engine works it out,
123
+ ## since the bar keeps it to itself: each title shaped in the bar's font, padded by its normal style
124
+ ## and spaced by its separation, counting only the menus it shows.
125
+ static func _menu_title_rect(menus: MenuBar, menu: int) -> Rect2:
126
+ var style: StyleBox = menus.get_theme_stylebox("normal")
127
+ var separation: int = menus.get_theme_constant("h_separation")
128
+ var offset: int = 0
129
+ for before: int in menu:
130
+ if not menus.is_menu_hidden(before):
131
+ offset += int(_menu_title_size(menus, before, style).x + separation)
132
+ var size: Vector2 = _menu_title_size(menus, menu, style)
133
+ var x: float = menus.size.x - offset - size.x if menus.is_layout_rtl() else float(offset)
134
+ return Rect2(Vector2(x, 0), size)
135
+
136
+
137
+ static func _menu_title_size(menus: MenuBar, menu: int, style: StyleBox) -> Vector2:
138
+ var line: TextLine = TextLine.new()
139
+ var _shaped: bool = line.add_string(
140
+ menus.atr(menus.get_menu_title(menu)),
141
+ menus.get_theme_font("font"),
142
+ menus.get_theme_font_size("font_size"),
143
+ menus.language
144
+ )
145
+ return line.get_size() + style.get_minimum_size()
146
+
147
+
148
+ ## Scrolls [param control] until it draws [param item], and answers whether it asked anything to
149
+ ## move. A tab bar and a tree have a call for it; a list is scrolled by its own bar, by as little as
150
+ ## brings the whole entry in.
151
+ static func _scroll_to(control: Control, item: Variant) -> bool:
152
+ var index: int = item if item is int else -1
153
+ var bar: TabBar = control as TabBar
154
+ if bar != null:
155
+ var before: int = bar.get_tab_offset()
156
+ bar.ensure_tab_visible(index)
157
+ return bar.get_tab_offset() != before
158
+ var tree: Tree = control as Tree
159
+ if tree != null:
160
+ var row: TreeItem = item
161
+ var was: float = tree.get_scroll().y
162
+ tree.scroll_to_item(row)
163
+ return tree.get_scroll().y != was
164
+ var list: ItemList = control as ItemList
165
+ if list == null:
166
+ return false
167
+ var rows: VScrollBar = list.get_v_scroll_bar()
168
+ var entry: Rect2 = list.get_item_rect(index, false)
169
+ var top: float = entry.position.y - list.get_theme_stylebox("panel").get_offset().y
170
+ var was_at: float = rows.value
171
+ if top < rows.value:
172
+ rows.value = top
173
+ elif top + entry.size.y > rows.value + rows.page:
174
+ rows.value = top + entry.size.y - rows.page
175
+ return rows.value != was_at
@@ -0,0 +1 @@
1
+ uid://mynpfgtjyipe
@@ -254,12 +254,17 @@ static func can_read(holder: Variant, named: String) -> bool:
254
254
  ## reports an engine error for one of another type, so a step into a map typed by an enum read the
255
255
  ## right entry and left four errors in the game's log for each of the two walks, and the run was
256
256
  ## counted as unclean for a read.
257
+ ##
258
+ ## A step written in double quotes is the key inside them, so a key holding a colon is one step:
259
+ ## Alchemill keys its stock by namespaced ids, "stock:\"base:dew\"", and split at that colon the
260
+ ## step named a key "base" the map had never had.
257
261
  static func _key_in(map: Dictionary, named: String) -> Array:
258
- var spellings: Array = [named, StringName(named)]
259
- if named.is_valid_int():
260
- spellings.append(int(named))
261
- if named.is_valid_float():
262
- spellings.append(float(named))
262
+ var word: String = key_written(named)
263
+ var spellings: Array = [word, StringName(word)]
264
+ if word.is_valid_int():
265
+ spellings.append(int(word))
266
+ if word.is_valid_float():
267
+ spellings.append(float(word))
263
268
  var typed: int = map.get_typed_key_builtin() if map.is_typed_key() else TYPE_NIL
264
269
  for key: Variant in spellings:
265
270
  if typed != TYPE_NIL and typeof(key) != typed:
@@ -269,9 +274,35 @@ static func _key_in(map: Dictionary, named: String) -> Array:
269
274
  return []
270
275
 
271
276
 
277
+ ## The key a step names: the words inside the quotes of one written in double quotes, read as JSON
278
+ ## reads a string so a quote inside is written \", and the step itself otherwise.
279
+ static func key_written(named: String) -> String:
280
+ if named.length() < 2 or not named.begins_with('"') or not named.ends_with('"'):
281
+ return named
282
+ var reader: JSON = JSON.new()
283
+ if reader.parse(named) != OK or not reader.data is String:
284
+ return named
285
+ return reader.data
286
+
287
+
288
+ ## [param key] written as a step that names it: in double quotes when it holds what a bare step
289
+ ## cannot, a colon a path splits at, a bracket that reads as a call, a quote, or nothing at all.
290
+ static func step_naming(key: Variant) -> String:
291
+ var text: String = str(key)
292
+ if not (key is String or key is StringName):
293
+ return text
294
+ if text.is_empty():
295
+ return JSON.stringify(text)
296
+ for character: String in [":", "(", ")", '"']:
297
+ if text.contains(character):
298
+ return JSON.stringify(text)
299
+ return text
300
+
301
+
272
302
  ## [param reaching] split into its steps at each colon, except one inside a call's brackets or a
273
- ## quoted argument: `get_node("/root/Main"):name` is two steps, and split at every colon a quoted
274
- ## argument holding one became a step that names nothing.
303
+ ## quoted argument or key: `get_node("/root/Main"):name` is two steps, and so is
304
+ ## `stock:"base:dew"`; split at every colon a quoted argument holding one became a step that names
305
+ ## nothing.
275
306
  static func steps_of(reaching: String) -> PackedStringArray:
276
307
  var steps: PackedStringArray = []
277
308
  var step: String = ""
@@ -569,17 +600,19 @@ static func nothing_there(holder: Variant, named: String, called_as: String) ->
569
600
  var keys: Array = map.keys()
570
601
  # A key reading as the step and still not reached is one of a type a step cannot spell, and
571
602
  # saying the map has no key 3 while listing 3 among its keys contradicts itself.
603
+ var word: String = key_written(named)
572
604
  for key: Variant in keys:
573
- if str(key) == named:
605
+ if str(key) == word:
574
606
  return (
575
607
  "%s has a key that reads %s, but it is %s, which a path step cannot name"
576
608
  % [called_as, named, type_string(typeof(key))]
577
609
  )
578
610
  # Eight of them, because a map keyed by something unexpected is told by the first few and a
579
- # map of two hundred would bury the sentence saying which key was missing.
611
+ # map of two hundred would bury the sentence saying which key was missing. Each written as a
612
+ # step reaching it, so the list says how to name a key holding a colon.
580
613
  var some: Array[String] = []
581
614
  for key: Variant in keys.slice(0, 8):
582
- some.append(str(key))
615
+ some.append(step_naming(key))
583
616
  var rest: String = ""
584
617
  if keys.size() > 8:
585
618
  rest = " and %d more" % [keys.size() - 8]
@@ -2,6 +2,7 @@ extends RefCounted
2
2
 
3
3
  ## Which control a click means when it names the control by the words on it rather than by path.
4
4
 
5
+ const Items = preload("runtime_items.gd")
5
6
  const Queries = preload("runtime_queries.gd")
6
7
  const Read = preload("reading.gd")
7
8
  const Says = preload("runtime_says.gd")
@@ -24,7 +25,8 @@ const WHOLE: int = 1
24
25
 
25
26
  ## The control a click by [param wanted] words presses, found under [param root_path] in the tree
26
27
  ## [param root] heads, or the refusal saying why there is none or which of several:
27
- ## {"path", "found"} or {"type", "message"}.
28
+ ## {"path", "found"} or {"type", "message"}, with `item` as well when the match is an item of a
29
+ ## control drawing a list, which [method Items.item_rect] places.
28
30
  ##
29
31
  ## Matched the way a find matches `says`, against a control's own words, and only among controls
30
32
  ## the player can see. A match inside a button presses the button, because the label is what says
@@ -57,6 +59,10 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
57
59
  # that only says them as part of more stands in for.
58
60
  var exact_out_of_reach: Array[Node] = []
59
61
  var out_of_reach_as: Array[String] = []
62
+ # The item of a control drawing a list that matched, null for a control matched as a whole, and
63
+ # the point in the control a pointer would go to for it.
64
+ var items: Array = []
65
+ var aims: Array[Vector2] = []
60
66
  var pending: Array[Node] = [reached["node"]]
61
67
  while not pending.is_empty():
62
68
  var node: Node = pending.pop_front()
@@ -66,6 +72,26 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
66
72
  if _says_exactly(Words.said_by(node), wanted):
67
73
  exact_out_of_reach.append(node)
68
74
  out_of_reach_as.append("hidden")
75
+ elif Words.draws_a_list(node):
76
+ # Each item a match of its own, so the words of one tab are matched as a whole and the
77
+ # index picks between two items saying them as it does between two buttons.
78
+ var drawing: Control = node
79
+ for entry: Dictionary in Words.items_of(drawing):
80
+ var words: String = entry["says"]
81
+ if not Says.matches(words, wanted):
82
+ continue
83
+ var whole: bool = _says_exactly(words, wanted)
84
+ var drawn_at_now: Rect2 = Items.item_rect(drawing, entry["item"], wanted)
85
+ matched.append(drawing)
86
+ said.append(words)
87
+ exact.append(whole)
88
+ inside.append(not whole and _inside_a_word(words, wanted))
89
+ items.append(entry["item"])
90
+ # An item drawn nowhere yet, scrolled out of its list, is judged at the list's middle,
91
+ # which is where the click scrolls it towards.
92
+ aims.append(
93
+ drawing.size * 0.5 if drawn_at_now.size == Vector2.ZERO else drawn_at_now.get_center()
94
+ )
69
95
  else:
70
96
  var control: Control = node
71
97
  var target: Control = _pressed_through(control)
@@ -78,6 +104,8 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
78
104
  said.append(words)
79
105
  exact.append(whole)
80
106
  inside.append(buried)
107
+ items.append(null)
108
+ aims.append(target.size * 0.5)
81
109
  elif (whole and not exact[at]) or (inside[at] and not buried):
82
110
  said[at] = words
83
111
  exact[at] = whole
@@ -93,13 +121,13 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
93
121
  var outside: int = 0
94
122
  var open: Array[int] = []
95
123
  for index: int in matched.size():
96
- if _off_screen(matched[index]):
124
+ if _off_screen(matched[index], aims[index]):
97
125
  outside += 1
98
126
  if exact[index]:
99
127
  exact_out_of_reach.append(matched[index])
100
128
  out_of_reach_as.append("outside what the screen shows")
101
129
  continue
102
- var over: Node = _cover_of(matched[index], drawn, areas)
130
+ var over: Node = _cover_of(matched[index], drawn, areas, aims[index])
103
131
  if over == null:
104
132
  open.append(index)
105
133
  else:
@@ -125,12 +153,14 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
125
153
  var lines: Array[String] = []
126
154
  var wholes: Array[bool] = []
127
155
  var insides: Array[bool] = []
156
+ var picked_items: Array = []
128
157
  for index: int in open:
129
158
  targets.append(matched[index])
130
159
  ranks.append(_rank(matched[index], exact[index]))
131
160
  lines.append(said[index])
132
161
  wholes.append(exact[index])
133
162
  insides.append(inside[index])
163
+ picked_items.append(items[index])
134
164
 
135
165
  var under: String = "" if root_path == "/root" else " under %s" % root_path
136
166
  if targets.is_empty():
@@ -165,14 +195,19 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
165
195
  }
166
196
  var index: int = 0 if which == null else Read.as_int(which, -1)
167
197
  if index < 0 or index >= targets.size():
168
- var plural: String = "" if targets.size() == 1 else "s"
198
+ # A number arrives from JSON as a float, and 5 was quoted back as 5.0.
199
+ var given: String = str(index) if index >= 0 else str(which)
200
+ var among: String = (
201
+ 'the only match on screen%s saying "%s" is index 0' % [under, wanted]
202
+ if targets.size() == 1
203
+ else (
204
+ 'the %d matches on screen%s saying "%s" are 0 to %d'
205
+ % [targets.size(), under, wanted, targets.size() - 1]
206
+ )
207
+ )
169
208
  return {
170
209
  "type": "error",
171
- "message":
172
- (
173
- 'index %s is not one of the %d control%s on screen%s saying "%s": %s'
174
- % [str(which), targets.size(), plural, under, wanted, _candidates(targets, lines)]
175
- )
210
+ "message": "index %s picks nothing: %s: %s" % [given, among, _candidates(targets, lines)]
176
211
  }
177
212
  # A match saying the words as part of more, while another control says exactly them, is a stand-in
178
213
  # for that one, not the control meant: a tab reading "Out" sat in a collapsed drawer and a dispatch
@@ -257,7 +292,11 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
257
292
  found["picked"] = "%s; %s" % [_described(1, ranks[0], wanted, under), others]
258
293
  if covered > 0:
259
294
  found["covered"] = covered
260
- return {"path": str(targets[index].get_path()), "found": found}
295
+ var answer: Dictionary = {"path": str(targets[index].get_path()), "found": found}
296
+ if picked_items[index] != null:
297
+ answer["item"] = picked_items[index]
298
+ found["item"] = Items.item_named(targets[index], picked_items[index], lines[index])
299
+ return answer
261
300
 
262
301
 
263
302
  ## How good a match [param target] is for a click: a control the pointer can reach over one it
@@ -451,7 +490,7 @@ static func _pressed_through(control: Control) -> Control:
451
490
  ## What a pointer at [param target]'s centre would reach instead of it, drawn over it, or null.
452
491
  static func drawn_over(target: Control) -> Node:
453
492
  var drawn: Array[Node] = _pointer_takers(target.get_tree().root)
454
- return _cover_of(target, drawn, _areas_of(drawn))
493
+ return _cover_of(target, drawn, _areas_of(drawn), target.size * 0.5)
455
494
 
456
495
 
457
496
  ## Why [param node], which is not visible in the tree, is not, as a parenthesis to follow its path:
@@ -631,7 +670,8 @@ static func _areas_of(drawn: Array[Node]) -> Array[Rect2]:
631
670
  return areas
632
671
 
633
672
 
634
- ## What a pointer at [param target]'s centre reaches instead of it, or null when nothing does.
673
+ ## What a pointer at [param aim] in [param target], its centre or the middle of the item a click is
674
+ ## for, reaches instead of it, or null when nothing does.
635
675
  ##
636
676
  ## The engine's own order for which control takes the pointer: an embedded window over everything
637
677
  ## in the viewport that holds it, then the higher canvas layer, then the later in the tree. A
@@ -650,10 +690,10 @@ static func _areas_of(drawn: Array[Node]) -> Array[Rect2]:
650
690
  ## point is passed over before anything costlier is asked of it. A window's box is in the space of
651
691
  ## the viewport it is embedded in, which is not the target's when the target is in another window,
652
692
  ## so a window is judged by [method _window_over] instead.
653
- static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2]) -> Node:
693
+ static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2], aim: Vector2) -> Node:
654
694
  var viewport: Viewport = target.get_viewport()
655
695
  var moves: Dictionary[ScrollContainer, Vector2] = _scrolls_for(target)
656
- var point: Vector2 = _centre_of(target) + _moved_by(target, moves)
696
+ var point: Vector2 = target.get_global_transform_with_canvas() * aim + _moved_by(target, moves)
657
697
  var layer: int = _layer_of(target)
658
698
  for at: int in drawn.size():
659
699
  var other: Node = drawn[at]
@@ -770,13 +810,14 @@ static func _where_it_lands(target: Control, point: Vector2) -> Dictionary[Scrol
770
810
  return moves
771
811
 
772
812
 
773
- ## Whether a click could not reach [param target] however it scrolled: its centre, where the click
774
- ## would bring it, outside its viewport or the one showing that on the screen, or clipped away by a
775
- ## container the click does not scroll. A drawer parked off the side of the screen, or anything
776
- ## outside the viewport of a game with no window, was listed among the controls on screen.
777
- static func _off_screen(target: Control) -> bool:
813
+ ## Whether a click could not reach [param aim] in [param target] however it scrolled: the point,
814
+ ## where the click would bring it, outside its viewport or the one showing that on the screen, or
815
+ ## clipped away by a container the click does not scroll. A drawer parked off the side of the
816
+ ## screen, or anything outside the viewport of a game with no window, was listed among the controls
817
+ ## on screen.
818
+ static func _off_screen(target: Control, aim: Vector2) -> bool:
778
819
  var moves: Dictionary[ScrollContainer, Vector2] = _scrolls_for(target)
779
- var point: Vector2 = _centre_of(target) + _moved_by(target, moves)
820
+ var point: Vector2 = target.get_global_transform_with_canvas() * aim + _moved_by(target, moves)
780
821
  if _clipped_away(target, point, moves) or not target.get_viewport().get_visible_rect().has_point(point):
781
822
  return true
782
823
  var reached: Dictionary = Screen.reach(target.get_viewport(), point)
@@ -477,5 +477,7 @@ func _element(item: Variant, builtin: int, declared: String, script: Script, res
477
477
  var wanted: String = type_string(builtin)
478
478
  if builtin == TYPE_OBJECT:
479
479
  wanted = declared if script == null else script_named(script)
480
- return {"message": "is %s, which cannot become %s" % [JSON.stringify(serialize(item)), wanted]}
480
+ return {
481
+ "message": "is %s, which cannot become %s" % [JSON.stringify(serialize(item), "", false), wanted]
482
+ }
481
483
  return {"value": fitted_item}
@@ -23,7 +23,7 @@ static func said_by(node: Node) -> String:
23
23
  var label: Label = node as Label
24
24
  if label != null:
25
25
  return _label_says(label)
26
- if _draws_a_list(node):
26
+ if draws_a_list(node):
27
27
  return "\n".join(lines_said_by(node))
28
28
  var own: String = _text_of(node)
29
29
  # A control showing a picture rather than words is named for a player who cannot see it, and that
@@ -41,37 +41,54 @@ static func said_by(node: Node) -> String:
41
41
  ## every label on it and none of those, and a wait for a tab's title or a list item was never met.
42
42
  static func lines_said_by(node: Node) -> Array[String]:
43
43
  var lines: Array[String] = []
44
+ if not draws_a_list(node):
45
+ _keep(_text_of(node), lines)
46
+ return lines
47
+ var tree: Tree = node as Tree
48
+ if tree != null and tree.column_titles_visible:
49
+ var titles: Array[String] = []
50
+ for column: int in tree.columns:
51
+ _keep(node.atr(tree.get_column_title(column)), titles)
52
+ _keep(" ".join(titles), lines)
53
+ for entry: Dictionary in items_of(node):
54
+ lines.append(str(entry["says"]))
55
+ return lines
56
+
57
+
58
+ ## Each item [param node] draws as one of a list, as {"says", "item"}: a tab, a menu title, a list
59
+ ## entry or a menu item by its index, and a tree row by its TreeItem. Empty for a node drawing none.
60
+ ## Held apart rather than joined, so a click on a tab's title presses that tab: the bar's middle is
61
+ ## whichever tab happens to sit there.
62
+ static func items_of(node: Node) -> Array[Dictionary]:
63
+ var items: Array[Dictionary] = []
44
64
  if node is TabBar:
45
65
  var bar: TabBar = node
46
66
  for tab: int in bar.tab_count:
47
67
  if not bar.is_tab_hidden(tab):
48
- _keep(node.atr(bar.get_tab_title(tab)), lines)
68
+ _keep_item(node.atr(bar.get_tab_title(tab)), tab, items)
49
69
  elif node is MenuBar:
50
70
  var menus: MenuBar = node
51
71
  for menu: int in menus.get_menu_count():
52
72
  if not menus.is_menu_hidden(menu):
53
- _keep(node.atr(menus.get_menu_title(menu)), lines)
73
+ _keep_item(node.atr(menus.get_menu_title(menu)), menu, items)
54
74
  elif node is ItemList:
55
75
  var list: ItemList = node
56
76
  for item: int in list.item_count:
57
- _keep(_as_drawn(node, list.get_item_text(item), list.get_item_auto_translate_mode(item)), lines)
77
+ _keep_item(
78
+ _as_drawn(node, list.get_item_text(item), list.get_item_auto_translate_mode(item)),
79
+ item,
80
+ items
81
+ )
58
82
  elif node is PopupMenu:
59
83
  # A separator too, by its title: the popup draws a titled one as a heading over the items
60
84
  # below it, and one without a title says nothing and is dropped as an empty line.
61
85
  var menu: PopupMenu = node
62
86
  for item: int in menu.item_count:
63
- _keep(item_says(menu, item), lines)
87
+ _keep_item(item_says(menu, item), item, items)
64
88
  elif node is Tree:
65
89
  var tree: Tree = node
66
- if tree.column_titles_visible:
67
- var titles: Array[String] = []
68
- for column: int in tree.columns:
69
- _keep(node.atr(tree.get_column_title(column)), titles)
70
- _keep(" ".join(titles), lines)
71
- _tree_rows(tree, tree.get_root(), tree.hide_root, lines)
72
- else:
73
- _keep(_text_of(node), lines)
74
- return lines
90
+ _tree_rows(tree, tree.get_root(), tree.hide_root, items)
91
+ return items
75
92
 
76
93
 
77
94
  ## What item [param index] of [param menu] shows: the translation where the item has one. A menu is
@@ -86,7 +103,9 @@ static func masked(field: LineEdit) -> String:
86
103
  return field.secret_character.repeat(field.text.length())
87
104
 
88
105
 
89
- static func _draws_a_list(node: Node) -> bool:
106
+ ## Whether [param node] draws a list of items rather than holding words in a `text`; see
107
+ ## [method items_of].
108
+ static func draws_a_list(node: Node) -> bool:
90
109
  return node is TabBar or node is MenuBar or node is ItemList or node is PopupMenu or node is Tree
91
110
 
92
111
 
@@ -96,6 +115,12 @@ static func _keep(line: String, into: Array[String]) -> void:
96
115
  into.append(trimmed)
97
116
 
98
117
 
118
+ static func _keep_item(line: String, item: Variant, into: Array[Dictionary]) -> void:
119
+ var trimmed: String = line.strip_edges()
120
+ if not trimmed.is_empty():
121
+ into.append({"says": trimmed, "item": item})
122
+
123
+
99
124
  ## [param words] as [param node] draws them, which for a game with translations is the translation
100
125
  ## rather than the key it holds: a control set to `UI_HIRE` shows "Hire", and a wait for "Hire" was
101
126
  ## never met. [param mode] is an item's own setting, which the engine reads before the node's.
@@ -113,14 +138,14 @@ static func _as_drawn(
113
138
  ## The rows of a tree from [param item] down, a row being its columns' text side by side, and
114
139
  ## nothing under a collapsed row or a hidden one, since neither is drawn. A hidden root is only the
115
140
  ## place its children hang from.
116
- static func _tree_rows(tree: Tree, item: TreeItem, skip: bool, into: Array[String]) -> void:
141
+ static func _tree_rows(tree: Tree, item: TreeItem, skip: bool, into: Array[Dictionary]) -> void:
117
142
  if item == null or not item.visible:
118
143
  return
119
144
  if not skip:
120
145
  var cells: Array[String] = []
121
146
  for column: int in tree.columns:
122
- _keep(_as_drawn(tree, item.get_text(column), item.get_auto_translate_mode(column)), cells)
123
- _keep(" ".join(cells), into)
147
+ _keep(cell_says(tree, item, column), cells)
148
+ _keep_item(" ".join(cells), item, into)
124
149
  if item.collapsed:
125
150
  return
126
151
  var child: TreeItem = item.get_first_child()
@@ -129,6 +154,11 @@ static func _tree_rows(tree: Tree, item: TreeItem, skip: bool, into: Array[Strin
129
154
  child = child.get_next()
130
155
 
131
156
 
157
+ ## What [param column] of the row [param item] of [param tree] shows.
158
+ static func cell_says(tree: Tree, item: TreeItem, column: int) -> String:
159
+ return _as_drawn(tree, item.get_text(column), item.get_auto_translate_mode(column))
160
+
161
+
132
162
  ## What a node with a `text` says, as it is drawn.
133
163
  static func _text_of(node: Node) -> String:
134
164
  var label: Label = node as Label
@@ -5,6 +5,7 @@ extends RefCounted
5
5
  ## stores, which runs no script of the scene's, so a read answers the same either way.
6
6
 
7
7
  const FileReads = preload("file_reads.gd")
8
+ const Read = preload("reading.gd")
8
9
  const Serialisation = preload("serialisation.gd")
9
10
 
10
11
  const LOOP_MODE_NAMES: Dictionary[int, String] = {
@@ -60,7 +61,12 @@ static func _bare(scene_path: String, node_path: String, kind: String) -> Array:
60
61
  return [null, read]
61
62
  var type: String = str(read.get("type", ""))
62
63
  if not ClassDB.class_exists(type) or not ClassDB.is_parent_class(type, kind):
63
- return [null, {"ok": false, "error": "%s not found at: %s" % [kind, node_path]}]
64
+ var said: String = "%s not found at: %s" % [kind, node_path]
65
+ if read.is_empty() and FileReads.scene_node(scene_path, node_path.get_slice("/", 0)).is_empty():
66
+ said = Read.from_the_root(
67
+ said, node_path, str(FileReads.scene_node(scene_path, ".").get("name", ""))
68
+ )
69
+ return [null, {"ok": false, "error": said}]
64
70
  var bare: Node = ClassDB.instantiate(type)
65
71
  bare.name = str(read["name"])
66
72
  var given: Dictionary = read["properties"]
@@ -164,7 +170,10 @@ static func state_machine(tree: AnimationTree, state_machine_path: String) -> An
164
170
  ## the path that failed, and what is there instead.
165
171
  static func no_machine(tree: AnimationTree, state_machine_path: String) -> String:
166
172
  if tree == null or tree.tree_root == null:
167
- return "The AnimationTree has no tree_root."
173
+ return (
174
+ "The AnimationTree has no tree_root. Make one with resource_edit create of an "
175
+ + "AnimationNodeStateMachine, then give its res:// path to tree_root with scene_node set."
176
+ )
168
177
  var current: AnimationNodeStateMachine = tree.tree_root as AnimationNodeStateMachine
169
178
  if current == null:
170
179
  return (
@@ -7,6 +7,7 @@ extends RefCounted
7
7
  ## made here passes the same checks and answers in the same shape. What the engine does that an
8
8
  ## editor does not, running every script, is put back in scene_file.gd and headless_check.gd.
9
9
 
10
+ const AnimationTools = preload("../addons/gdharness_editor/tools/animation_tools.gd")
10
11
  const ResourceTools = preload("../addons/gdharness_editor/tools/resource_tools.gd")
11
12
  const SceneTools = preload("../addons/gdharness_editor/tools/scene_tools.gd")
12
13
 
@@ -32,6 +33,12 @@ const RESOURCE_COMMANDS: Array[String] = [
32
33
  "set_theme_color",
33
34
  "set_theme_font_size",
34
35
  ]
36
+ const ANIMATION_COMMANDS: Array[String] = [
37
+ "create_animation",
38
+ "add_animation_track",
39
+ "add_animation_state",
40
+ "connect_animation_states",
41
+ ]
35
42
 
36
43
 
37
44
  ## Runs the tool [code]command[/code] names with [code]arguments[/code], answering what it answers.
@@ -46,6 +53,8 @@ static func run(params: Dictionary) -> Dictionary:
46
53
  tools = SceneTools.new()
47
54
  elif command in RESOURCE_COMMANDS:
48
55
  tools = ResourceTools.new()
56
+ elif command in ANIMATION_COMMANDS:
57
+ tools = AnimationTools.new()
49
58
  else:
50
59
  return {"ok": false, "error": "No editor tool runs headless as " + command}
51
60
  var answer: Variant = tools.call(command, arguments)
@@ -129,7 +129,8 @@ func _answer(operation: String, params: Dictionary, answer_path: String) -> void
129
129
  )
130
130
  quit(1)
131
131
  return
132
- var written: bool = answer.store_string(JSON.stringify(payload))
132
+ # Keys in the order they were put in, which for a map read is the order the resource keeps.
133
+ var written: bool = answer.store_string(JSON.stringify(payload, "", false))
133
134
  answer.close()
134
135
  if not written:
135
136
  _log.error("Could not write the answer to " + answer_path)