gdharness 1.2.40 → 1.2.42

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 (31) hide show
  1. package/build/cli.js +103 -38
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +14 -2
  3. package/build/godot/addons/gdharness_editor/bridge_client.gd +2 -1
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +134 -10
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +56 -15
  6. package/build/godot/addons/gdharness_editor/property_values.gd +1 -1
  7. package/build/godot/addons/gdharness_editor/reading.gd +40 -0
  8. package/build/godot/addons/gdharness_editor/scene_file.gd +215 -12
  9. package/build/godot/addons/gdharness_editor/serialisation.gd +5 -1
  10. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +39 -5
  11. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +68 -15
  12. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +346 -0
  13. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd.uid +1 -0
  14. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +438 -0
  15. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd.uid +1 -0
  16. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +102 -60
  17. package/build/godot/addons/gdharness_runtime/reading.gd +40 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +3 -1
  19. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +2 -2
  20. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +11 -6
  21. package/build/godot/addons/gdharness_runtime/runtime_values.gd +3 -1
  22. package/build/godot/addons/gdharness_runtime/serialisation.gd +5 -1
  23. package/build/godot/operations/animation_reads.gd +14 -2
  24. package/build/godot/operations/editor_tools.gd +28 -1
  25. package/build/godot/operations/file_reads.gd +134 -10
  26. package/build/godot/operations/godot_operations.gd +2 -1
  27. package/build/godot/operations/property_values.gd +1 -1
  28. package/build/godot/operations/reading.gd +40 -0
  29. package/build/godot/operations/serialisation.gd +5 -1
  30. package/build/index.js +103 -38
  31. package/package.json +1 -1
@@ -30,13 +30,27 @@ const HeadlessCheck = preload("headless_check.gd")
30
30
  ## How many times what scripts changed is put back before a write is refused for it.
31
31
  const PUTTING_BACK_PASSES: int = 4
32
32
  const PropertyValues = preload("property_values.gd")
33
+ const Read = preload("reading.gd")
34
+ ## The names Object.ConnectFlags gives each bit. The number alone left a reader guessing that 2 is
35
+ ## the persistence every connection in a saved scene carries.
36
+ const CONNECT_FLAG_NAMES: Dictionary[int, String] = {
37
+ Object.CONNECT_DEFERRED: "deferred",
38
+ Object.CONNECT_PERSIST: "persist",
39
+ Object.CONNECT_ONE_SHOT: "one_shot",
40
+ Object.CONNECT_REFERENCE_COUNTED: "reference_counted",
41
+ Object.CONNECT_APPEND_SOURCE_OBJECT: "append_source_object",
42
+ }
33
43
 
34
44
  ## Whether a scan asked for through [method scan_later] is still to start, which a caller waiting
35
45
  ## for the editor's files to settle has to wait through as well.
36
46
  static var scan_due: bool = false
47
+ ## The files a headless save named by path alone because the uid cache holds no uid for them,
48
+ ## gathered for the answer to the call that saved, the one call its engine runs.
49
+ static var by_path: PackedStringArray = []
37
50
 
38
51
  var root: Node = null
39
52
  var path: String = ""
53
+ ## The path of each node a scene this one inherits gives, with the file that makes it.
40
54
  var _base_paths: Dictionary = {}
41
55
  var _state: SceneState = null
42
56
  ## What the file stores on each node it was opened with, by the node's instance id, so a node keeps
@@ -77,9 +91,16 @@ func open(scene_path: String, for_writing: bool) -> Dictionary:
77
91
  return {"ok": false, "error": "Failed to instantiate " + path}
78
92
  _state = packed.get_state()
79
93
  var base: SceneState = _state.get_base_scene_state()
94
+ # Each node a base gives, with the file that makes it: the deepest base listing it as more than
95
+ # values set on a node from further down.
80
96
  while base != null:
81
97
  for index: int in base.get_node_count():
82
- _base_paths[_relative(str(base.get_node_path(index)))] = true
98
+ var given: String = _relative(str(base.get_node_path(index)))
99
+ var made: bool = (
100
+ base.get_node_instance(index) != null or not str(base.get_node_type(index)).is_empty()
101
+ )
102
+ if made or not _base_paths.has(given):
103
+ _base_paths[given] = base.get_path()
83
104
  base = base.get_base_scene_state()
84
105
  # On a read as well, which would otherwise answer what the scripts set as what the file holds.
85
106
  if not Engine.is_editor_hint():
@@ -122,6 +143,13 @@ func asked(node: Node, names: Array) -> void:
122
143
  _reached.merge(HeadlessCheck.reached(node, property))
123
144
 
124
145
 
146
+ ## Takes [param resource], built into the scene, as one the tool changed, which a write saves as it is.
147
+ func reached_into(resource: Resource) -> void:
148
+ var id: String = resource.get_scene_unique_id()
149
+ if not id.is_empty():
150
+ _reached[id] = true
151
+
152
+
125
153
  ## Takes each copy in [param copies], keyed by the node it was made from, as holding what that node's
126
154
  ## file entry holds.
127
155
  func copied(copies: Dictionary) -> void:
@@ -150,12 +178,74 @@ func refuse(error: String) -> Dictionary:
150
178
  return {"ok": false, "error": error}
151
179
 
152
180
 
181
+ ## Each bit set in [param flags], a connection's, by name, and whatever bits are left unnamed as one
182
+ ## number.
183
+ static func flag_names(flags: int) -> Array[String]:
184
+ var names: Array[String] = []
185
+ var rest: int = flags
186
+ for bit: int in CONNECT_FLAG_NAMES:
187
+ if flags & bit:
188
+ names.append(CONNECT_FLAG_NAMES[bit])
189
+ rest &= ~bit
190
+ if rest != 0:
191
+ names.append(str(rest))
192
+ return names
193
+
194
+
195
+ ## [param said], the refusal for [param node_path] found nowhere in this scene, with how a path is
196
+ ## read when it starts with the root's own name, and otherwise with the last node of the path the
197
+ ## scene has and what is under it, as after a node was moved or renamed.
198
+ func not_found(said: String, node_path: String) -> String:
199
+ if root == null:
200
+ return said
201
+ if not root.has_node(NodePath(node_path.get_slice("/", 0))):
202
+ var hinted: String = Read.from_the_root(said, node_path, str(root.name))
203
+ if hinted != said:
204
+ return hinted
205
+ var parts: PackedStringArray = node_path.trim_prefix("./").split("/", false)
206
+ var at: Node = root
207
+ var reached: PackedStringArray = []
208
+ for part: String in parts:
209
+ var below: Node = at.get_node_or_null(NodePath(part))
210
+ if below == null:
211
+ break
212
+ at = below
213
+ var _walked: bool = reached.append(part)
214
+ if reached.size() >= parts.size():
215
+ return said
216
+ var names: PackedStringArray = []
217
+ for child: Node in at.get_children():
218
+ var _named: bool = names.append(str(child.name))
219
+ return Read.missing_below(said, str(root.name), reached, parts[reached.size()], names)
220
+
221
+
153
222
  func close() -> void:
154
223
  if root != null:
155
224
  root.free()
156
225
  root = null
157
226
 
158
227
 
228
+ ## What the file holds under the instance at [param at] that goes once its children are not
229
+ ## editable, as {values, nodes}: the paths of the instance's nodes it sets values on, which stay with
230
+ ## the instance's own values, and of the nodes the scene added below one of them, which are gone,
231
+ ## since the packer skips everything below a node of an instance that is not editable. A node added
232
+ ## straight under [param at] is the scene's own and stays.
233
+ func held_under(at: String) -> Dictionary:
234
+ var values: Array[String] = []
235
+ var nodes: Array[String] = []
236
+ for index: int in _state.get_node_count():
237
+ var entry: String = _relative(str(_state.get_node_path(index)))
238
+ var parent: String = _relative(str(_state.get_node_path(index, true)))
239
+ var values_only: bool = (
240
+ _state.get_node_instance(index) == null and str(_state.get_node_type(index)).is_empty()
241
+ )
242
+ if values_only and entry.begins_with(at + "/"):
243
+ values.append(entry)
244
+ elif not values_only and parent.begins_with(at + "/"):
245
+ nodes.append(entry)
246
+ return {"values": values, "nodes": nodes}
247
+
248
+
159
249
  ## The path of [param node] as a tool names it: "." for the root.
160
250
  func path_of(node: Node) -> String:
161
251
  return str(root.get_path_to(node))
@@ -166,16 +256,46 @@ func path_of(node: Node) -> String:
166
256
  func foreign(node: Node) -> String:
167
257
  if node == root or node.owner == root:
168
258
  return ""
169
- if node.owner != null and root.is_editable_instance(node.owner):
259
+ if node.owner == null:
260
+ return (
261
+ "%s is made by a script as the scene is instanced, and no scene file saves anything about it."
262
+ % path_of(node)
263
+ )
264
+ if root.is_editable_instance(node.owner):
170
265
  return ""
171
- var owner_path: String = path_of(node.owner) if node.owner != null else "?"
172
- return (
266
+ var said: String = (
173
267
  (
174
268
  "%s belongs to the scene instanced at %s (%s), and this scene saves nothing about it. Edit "
175
- + "that scene instead."
269
+ + "that scene instead, which changes every instance of it"
176
270
  )
177
- % [path_of(node), owner_path, node.owner.scene_file_path if node.owner != null else "?"]
271
+ % [path_of(node), path_of(node.owner), node.owner.scene_file_path]
178
272
  )
273
+ var steps: PackedStringArray = []
274
+ for closed: String in _closed_around(node):
275
+ var _added: bool = steps.append("nodePath %s editableChildren true" % closed)
276
+ if steps.is_empty():
277
+ return said + "."
278
+ return (
279
+ said
280
+ + ", or have this scene keep its own values for this instance with scene_node set "
281
+ + ", then ".join(steps)
282
+ + "."
283
+ )
284
+
285
+
286
+ ## The instances around [param node] whose children are not editable, outermost first: each must be
287
+ ## made editable, in that order, before this scene keeps values of its own for [param node], since
288
+ ## the packer skips every node under an instance that is not. Empty when nothing would let it.
289
+ func _closed_around(node: Node) -> PackedStringArray:
290
+ var closed: PackedStringArray = []
291
+ var instance: Node = node.owner
292
+ while instance != root:
293
+ if instance == null:
294
+ return []
295
+ if not root.is_editable_instance(instance):
296
+ var _at: int = closed.insert(0, path_of(instance))
297
+ instance = instance.owner
298
+ return closed
179
299
 
180
300
 
181
301
  ## Why [param node] cannot be moved, renamed, copied or deleted, or "": the scene may only
@@ -192,10 +312,10 @@ func fixed(node: Node) -> String:
192
312
  if _base_paths.has(at):
193
313
  return (
194
314
  (
195
- "%s comes from the scene %s inherits from, which creates it again on load. Edit that "
315
+ "%s is made by %s, a scene %s inherits, which creates it again on load. Edit that "
196
316
  + "scene instead."
197
317
  )
198
- % [at, path]
318
+ % [at, _base_paths[at], path]
199
319
  )
200
320
  return ""
201
321
 
@@ -323,10 +443,25 @@ func unusable_name(parent: Node, child_name: String, moving: Node = null) -> Str
323
443
 
324
444
  ## The names of the properties this scene's own file sets on [param node], whatever their values.
325
445
  func set_here(node: Node) -> Dictionary:
446
+ return set_at(path_of(node))
447
+
448
+
449
+ ## Where the values of [param node], at [param at] in the file, come from when the file does not set
450
+ ## them: "" for a node this scene makes, which holds them all, or the scene that makes the node, the
451
+ ## one it is instanced from or one this scene inherits.
452
+ func values_from(node: Node, at: String) -> String:
453
+ if node != root and not node.scene_file_path.is_empty():
454
+ return node.scene_file_path
455
+ if node != root and node.owner != root:
456
+ return node.owner.scene_file_path if node.owner != null else ""
457
+ return str(_base_paths.get(at, ""))
458
+
459
+
460
+ ## The names of the properties this scene's own file sets on the node at [param at].
461
+ func set_at(at: String) -> Dictionary:
326
462
  var names: Dictionary = {}
327
463
  if _state == null:
328
464
  return names
329
- var at: String = path_of(node)
330
465
  for index: int in _state.get_node_count():
331
466
  if _relative(str(_state.get_node_path(index))) != at:
332
467
  continue
@@ -438,17 +573,85 @@ static func save_packed(packed: PackedScene, scene_path: String) -> Dictionary:
438
573
  static func save_keeping_uid(resource: Resource, file_path: String) -> Error:
439
574
  if Engine.is_editor_hint():
440
575
  return ResourceSaver.save(resource, file_path)
441
- var uid: int = _uid_in(file_path)
576
+ var uid: int = uid_in(file_path)
442
577
  var saving: Error = ResourceSaver.save(resource, file_path)
443
578
  if saving != OK:
444
579
  return saving
445
580
  if uid == ResourceUID.INVALID_ID:
446
581
  uid = ResourceUID.create_id()
447
- return ResourceSaver.set_uid(file_path, uid)
582
+ var marked: Error = ResourceSaver.set_uid(file_path, uid)
583
+ if marked != OK:
584
+ return marked
585
+ return _with_external_uids(file_path)
586
+
587
+
588
+ ## Puts back the uid on each external resource the text file at [param file_path] names. Only the
589
+ ## editor's file system tells the saver a path's uid, so a headless save writes every reference by
590
+ ## path alone, and each breaks when the file it names is moved.
591
+ static func _with_external_uids(file_path: String) -> Error:
592
+ if not (file_path.ends_with(".tscn") or file_path.ends_with(".tres")):
593
+ return OK
594
+ var lines: PackedStringArray = FileAccess.get_file_as_string(file_path).split("\n")
595
+ var pattern: RegEx = RegEx.create_from_string(' path="([^"]+)"')
596
+ var changed: bool = false
597
+ for at: int in lines.size():
598
+ var line: String = lines[at]
599
+ if not line.begins_with("[ext_resource ") or line.contains(' uid="'):
600
+ continue
601
+ var named: RegExMatch = pattern.search(line)
602
+ if named == null:
603
+ continue
604
+ var uid: String = reference_uid(named.get_string(1))
605
+ if not uid.is_empty():
606
+ lines[at] = line.replace(' path="', uid + ' path="')
607
+ changed = true
608
+ if not changed:
609
+ return OK
610
+ var file: FileAccess = FileAccess.open(file_path, FileAccess.WRITE)
611
+ if file == null:
612
+ return FileAccess.get_open_error()
613
+ if not file.store_string("\n".join(lines)):
614
+ return ERR_FILE_CANT_WRITE
615
+ file.close()
616
+ return OK
617
+
618
+
619
+ ## [method uid_attribute] for a reference about to be written, noting [param named] under
620
+ ## [member by_path] when it is left to be named by path.
621
+ static func reference_uid(named: String) -> String:
622
+ var uid: String = uid_attribute(named)
623
+ if uid.is_empty():
624
+ note_by_path(named)
625
+ return uid
626
+
627
+
628
+ ## Notes [param named], a reference just written by path alone, under [member by_path] when the uid
629
+ ## cache holds nothing for it: a file made since the project was last scanned, whose uid the cache
630
+ ## learns from the next scan. Not a file whose uid the cache gives to another, which a scan leaves.
631
+ static func note_by_path(named: String) -> void:
632
+ if Engine.is_editor_hint() or not FileAccess.file_exists(named) or by_path.has(named):
633
+ return
634
+ var own: int = uid_in(named)
635
+ if own == ResourceUID.INVALID_ID and FileAccess.file_exists(named + ".uid"):
636
+ own = ResourceUID.text_to_id(FileAccess.get_file_as_string(named + ".uid").strip_edges())
637
+ if own == ResourceUID.INVALID_ID or not ResourceUID.has_id(own):
638
+ var _noted: bool = by_path.append(named)
639
+
640
+
641
+ ## The uid attribute a reference to [param named] is written with, as ' uid="uid://..."', or "".
642
+ ##
643
+ ## Only a uid the uid cache gives that path, which is what the loader resolves it by. A uid the cache
644
+ ## does not know (a file made with no editor since the last scan) is answered with a warning on every
645
+ ## load, and one it gives another file (a scene copied whole keeps its original's) loads that file.
646
+ static func uid_attribute(named: String) -> String:
647
+ var id: int = ResourceLoader.get_resource_uid(named)
648
+ if id == ResourceUID.INVALID_ID or not ResourceUID.has_id(id) or ResourceUID.get_id_path(id) != named:
649
+ return ""
650
+ return ' uid="%s"' % ResourceUID.id_to_text(id)
448
651
 
449
652
 
450
653
  ## The uid the file at [param file_path] declares, or [constant ResourceUID.INVALID_ID].
451
- static func _uid_in(file_path: String) -> int:
654
+ static func uid_in(file_path: String) -> int:
452
655
  if not FileAccess.file_exists(file_path):
453
656
  return ResourceUID.INVALID_ID
454
657
  var file: FileAccess = FileAccess.open(file_path, FileAccess.READ)
@@ -664,8 +664,12 @@ func _serialize_array(value: Array) -> Array:
664
664
 
665
665
  # Base64 rather than a list of numbers. A byte array is a texture, a sound or a save file often
666
666
  # enough that the list would be the whole answer, megabytes of it, and unreadable either way.
667
+ #
668
+ # The engine's encoder raises an error on an empty array, and an empty tile_map_data is every
669
+ # TileMapLayer with nothing painted yet, so a read of one carried an engine error in its answer.
667
670
  func _serialize_bytes(value: PackedByteArray) -> Dictionary:
668
- return {"base64": Marshalls.raw_to_base64(value), "size": value.size(), "_type": "PackedByteArray"}
671
+ var text: String = "" if value.is_empty() else Marshalls.raw_to_base64(value)
672
+ return {"base64": text, "size": value.size(), "_type": "PackedByteArray"}
669
673
 
670
674
 
671
675
  # The packed vector and colour arrays, each element serialised the way one of them on its own is.
@@ -1,9 +1,11 @@
1
1
  @tool
2
2
  extends Node
3
3
 
4
- ## Animations and animation trees, edited in the open editor.
4
+ ## Animations and animation trees, edited in the open editor or, for a project no editor serves, by a
5
+ ## headless engine.
5
6
 
6
7
  const AnimationReads = preload("../animation_reads.gd")
8
+ const HeadlessCheck = preload("../headless_check.gd")
7
9
  const PropertyValues = preload("../property_values.gd")
8
10
  const Read = preload("../reading.gd")
9
11
  const SceneFile = preload("../scene_file.gd")
@@ -46,7 +48,9 @@ func _open_at(scene_path: String, node_path: String, kind: String) -> Array:
46
48
  return [scene, null, refused]
47
49
  var node: Node = _find_node(scene.root, node_path)
48
50
  if node == null or not node.is_class(kind):
49
- return [scene, null, scene.refuse("%s not found at: %s" % [kind, node_path])]
51
+ return [
52
+ scene, null, scene.refuse(scene.not_found("%s not found at: %s" % [kind, node_path], node_path))
53
+ ]
50
54
  var foreign: String = scene.foreign(node)
51
55
  if not foreign.is_empty():
52
56
  return [scene, null, scene.refuse(foreign)]
@@ -62,25 +66,55 @@ func _read_at(scene_path: String, node_path: String, kind: String) -> Array:
62
66
  return [scene, null, refused]
63
67
  var node: Node = _find_node(scene.root, node_path)
64
68
  if node == null or not node.is_class(kind):
65
- return [scene, null, scene.refuse("%s not found at: %s" % [kind, node_path])]
69
+ return [
70
+ scene, null, scene.refuse(scene.not_found("%s not found at: %s" % [kind, node_path], node_path))
71
+ ]
66
72
  return [scene, node, {}]
67
73
 
68
74
 
69
75
  ## Writes [param scene] once [param check] passes, then the files of [param homes], answering
70
76
  ## [param answer] or a refusal.
71
77
  func _finish(scene: SceneFile, check: Callable, homes: Dictionary, answer: Dictionary) -> Dictionary:
78
+ if not Engine.is_editor_hint():
79
+ for file: Variant in homes:
80
+ var home: Resource = homes[file]
81
+ if _scripted(home):
82
+ return (
83
+ scene
84
+ . refuse(
85
+ (
86
+ (
87
+ (
88
+ "Nothing was written: %s holds a resource with a script that is not a tool, and what "
89
+ + "that script changed when a headless engine loaded it cannot be told apart from the "
90
+ + "edit. "
91
+ )
92
+ % file
93
+ )
94
+ + HeadlessCheck.REMEDY
95
+ )
96
+ )
97
+ )
72
98
  var written: Dictionary = scene.write(check)
73
99
  if not written.is_empty():
74
100
  return written
75
101
  for file: Variant in homes:
76
102
  var resource: Resource = homes[file]
77
- var saving: Error = ResourceSaver.save(resource, str(file))
103
+ var saving: Error = SceneFile.save_keeping_uid(resource, str(file))
78
104
  if saving != OK:
79
105
  return {"ok": false, "error": "Failed to save %s: %s" % [file, error_string(saving)]}
80
- EditorInterface.get_resource_filesystem().update_file(str(file))
106
+ if Engine.is_editor_hint():
107
+ EditorInterface.get_resource_filesystem().update_file(str(file))
81
108
  return answer
82
109
 
83
110
 
111
+ ## Whether [param resource], or one it embeds, has a script that is not a tool, which a headless
112
+ ## engine ran as it loaded the file.
113
+ func _scripted(resource: Resource) -> bool:
114
+ var script: Script = resource.get_script()
115
+ return (script != null and not script.is_tool()) or HeadlessCheck.holds_scripted_embedded(resource, {})
116
+
117
+
84
118
  func _parse_json_maybe(value: Variant) -> Variant:
85
119
  if typeof(value) != TYPE_STRING:
86
120
  return value
@@ -399,6 +399,19 @@ func _atlas(entry: Variant, index: int) -> Array:
399
399
  if texture_path.split("/").has(".."):
400
400
  return [false, "sources[%d].texture leaves the project: %s" % [index, texture_path]]
401
401
  if not ResourceLoader.exists(texture_path):
402
+ # An image the engine has not imported loads as nothing, where "no texture" would send a caller
403
+ # looking for a file that is there.
404
+ if FileAccess.file_exists(texture_path) and not FileAccess.file_exists(texture_path + ".import"):
405
+ return [
406
+ false,
407
+ (
408
+ (
409
+ "sources[%d].texture: %s has not been imported, so the engine has no texture for it "
410
+ + "yet. project_import reimport with resourcePath %s imports it."
411
+ )
412
+ % [index, texture_path, texture_path]
413
+ )
414
+ ]
402
415
  return [false, "sources[%d].texture: no texture at %s" % [index, texture_path]]
403
416
  var texture: Texture2D = load(texture_path) as Texture2D
404
417
  if texture == null:
@@ -720,13 +733,15 @@ func set_theme_color(args: Dictionary) -> Dictionary:
720
733
  return {"ok": false, "error": "The theme saved without %s/%s" % [control_type, color_name]}
721
734
 
722
735
  var stored: Color = saved.get_color(color_name, control_type)
723
- return {
724
- "ok": true,
725
- "themePath": theme_path,
726
- "controlType": control_type,
727
- "colorName": color_name,
728
- "color": {"r": stored.r, "g": stored.g, "b": stored.b, "a": stored.a}
729
- }
736
+ return _noting_variation(
737
+ {
738
+ "ok": true,
739
+ "themePath": theme_path,
740
+ "controlType": control_type,
741
+ "colorName": color_name,
742
+ "color": {"r": stored.r, "g": stored.g, "b": stored.b, "a": stored.a}
743
+ }
744
+ )
730
745
 
731
746
 
732
747
  func set_theme_font_size(args: Dictionary) -> Dictionary:
@@ -743,7 +758,23 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
743
758
  if theme == null:
744
759
  return {"ok": false, "error": "No Theme at %s. Create one there first." % theme_path}
745
760
 
746
- theme.set_font_size(font_size_name, control_type, Read.as_int(args.get("size", 0)))
761
+ # Godot reads a size of 0 or less as unset and answers the theme's default, so -3 would be read
762
+ # back as 16.
763
+ var pixels: float = Read.as_float(args.get("size"), 0.0)
764
+ if not is_finite(pixels) or pixels < 1 or pixels != floorf(pixels):
765
+ var shown: String = str(int(pixels)) if pixels == floorf(pixels) else str(pixels)
766
+ return {
767
+ "ok": false,
768
+ "error":
769
+ (
770
+ (
771
+ "size must be a whole number of pixels, 1 or more, not %s: Godot reads 0 or less as no size "
772
+ % shown
773
+ )
774
+ + "and uses the theme's default instead."
775
+ )
776
+ }
777
+ theme.set_font_size(font_size_name, control_type, int(pixels))
747
778
 
748
779
  var unsaved: String = _save_theme(theme, theme_path, "%s/font_sizes/%s" % [control_type, font_size_name])
749
780
  if not unsaved.is_empty():
@@ -753,10 +784,32 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
753
784
  if saved == null or not saved.has_font_size(font_size_name, control_type):
754
785
  return {"ok": false, "error": "The theme saved without %s/%s" % [control_type, font_size_name]}
755
786
 
756
- return {
757
- "ok": true,
758
- "themePath": theme_path,
759
- "controlType": control_type,
760
- "fontSizeName": font_size_name,
761
- "size": saved.get_font_size(font_size_name, control_type)
762
- }
787
+ return _noting_variation(
788
+ {
789
+ "ok": true,
790
+ "themePath": theme_path,
791
+ "controlType": control_type,
792
+ "fontSizeName": font_size_name,
793
+ "size": saved.get_font_size(font_size_name, control_type)
794
+ }
795
+ )
796
+
797
+
798
+ ## [param answer] with a note when its controlType names no class a theme styles. Any name is a
799
+ ## theme type, so one that is no class is a type variation, which styles only a control whose
800
+ ## theme_type_variation names it, and a misspelt "Buton" would otherwise read as Button set.
801
+ func _noting_variation(answer: Dictionary) -> Dictionary:
802
+ var type: String = str(answer.get("controlType", ""))
803
+ if (
804
+ ClassDB.class_exists(type)
805
+ and (ClassDB.is_parent_class(type, "Control") or ClassDB.is_parent_class(type, "Window"))
806
+ ):
807
+ return answer
808
+ answer["note"] = (
809
+ (
810
+ "%s is not a Control or Window class, so this is a type variation: it styles only a control "
811
+ + "whose theme_type_variation is %s."
812
+ )
813
+ % [type, type]
814
+ )
815
+ return answer