gdharness 1.2.30 → 1.2.32

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 (37) hide show
  1. package/build/cli.js +7197 -6159
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +14 -0
  3. package/build/godot/addons/gdharness_editor/file_reads.gd +237 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd.uid +1 -0
  5. package/build/godot/addons/gdharness_editor/plugin.gd +27 -0
  6. package/build/godot/addons/gdharness_editor/property_values.gd +80 -54
  7. package/build/godot/addons/gdharness_editor/scene_file.gd +38 -1
  8. package/build/godot/addons/gdharness_editor/serialisation.gd +46 -0
  9. package/build/godot/addons/gdharness_editor/tool_executor.gd +1 -0
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +22 -0
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +64 -1
  12. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +82 -44
  13. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +178 -0
  14. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +73 -124
  16. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +39 -0
  17. package/build/godot/addons/gdharness_runtime/runtime_hover.gd +285 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_hover.gd.uid +1 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_input.gd +151 -106
  20. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +112 -0
  21. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd.uid +1 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +5 -4
  23. package/build/godot/addons/gdharness_runtime/runtime_values.gd +1 -47
  24. package/build/godot/addons/gdharness_runtime/serialisation.gd +46 -0
  25. package/build/godot/operations/audio_buses.gd +14 -1
  26. package/build/godot/operations/class_cache.gd +146 -35
  27. package/build/godot/operations/classdb_queries.gd +19 -12
  28. package/build/godot/operations/file_reads.gd +237 -0
  29. package/build/godot/operations/gdscript_authoring.gd +136 -19
  30. package/build/godot/operations/godot_operations.gd +24 -0
  31. package/build/godot/operations/project_config.gd +16 -6
  32. package/build/godot/operations/property_values.gd +767 -0
  33. package/build/godot/operations/rename_checks.gd +5 -2
  34. package/build/godot/operations/serialisation.gd +46 -0
  35. package/build/index.js +4896 -3872
  36. package/build/keeper.js +184 -71
  37. package/package.json +1 -1
@@ -517,6 +517,20 @@ func _handle_message(json_string: String) -> void:
517
517
  pass
518
518
 
519
519
 
520
+ ## Says [param tool_name] was not run because the editor is scanning or importing, for the server to
521
+ ## ask again once that is over. An older server reads it as an ordinary refusal.
522
+ func send_busy(request_id: String, tool_name: String) -> void:
523
+ _send_message(
524
+ {
525
+ "type": "tool_result",
526
+ "id": request_id,
527
+ "success": false,
528
+ "busy": true,
529
+ "error": "The editor is scanning or importing its files, so %s was not run." % tool_name,
530
+ }
531
+ )
532
+
533
+
520
534
  func send_tool_result(request_id: String, success: bool, result: Variant = null, error: String = "") -> void:
521
535
  var response: Dictionary = {"type": "tool_result", "id": request_id, "success": success}
522
536
 
@@ -0,0 +1,237 @@
1
+ extends RefCounted
2
+
3
+ ## Scenes and resources read from their files, by the editor's tools and by a headless engine with
4
+ ## none connected, so a read answers the same either way.
5
+ ##
6
+ ## A scene is read off its SceneState rather than instantiated. Instantiating outside the editor
7
+ ## runs every script's member initialisers and _init (measured on 4.7.2, with or without -e), so a
8
+ ## read with no editor would have run the game's code on every node; the editor gets placeholders
9
+ ## and runs none. The state holds everything the file says, and the scenes it instances theirs,
10
+ ## without making a node. Loading the file still does what loading does in the game: static
11
+ ## initialisers run, and a resource with a script is made with it.
12
+
13
+ const PropertyValues = preload("property_values.gd")
14
+ const Read = preload("reading.gd")
15
+ const Serialisation = preload("serialisation.gd")
16
+
17
+
18
+ ## The nodes of [param scene_path] as {name, type, path, children}, with instanceOf on a node that
19
+ ## instances another scene and every stored property when [param include_properties] is set, the
20
+ ## file's value or the default where it sets none. [param depth] -1 reads every level.
21
+ static func scene_tree(scene_path: String, depth: int, include_properties: bool) -> Dictionary:
22
+ if not FileAccess.file_exists(scene_path):
23
+ return {"ok": false, "error": "Scene not found: " + scene_path}
24
+ # Loaded without naming the type, so a resource handed in by mistake loads as what it is and the
25
+ # refusal can say so, where asking for a PackedScene printed the loader's error and a backtrace.
26
+ var loaded: Resource = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_IGNORE)
27
+ if loaded == null:
28
+ return {"ok": false, "error": "Failed to load %s as a scene" % scene_path}
29
+ if not loaded is PackedScene:
30
+ return {
31
+ "ok": false,
32
+ "error":
33
+ (
34
+ "%s is a %s, not a scene: resource_edit read reads its properties."
35
+ % [scene_path, loaded.get_class()]
36
+ )
37
+ }
38
+ var packed: PackedScene = loaded
39
+ var nodes: Dictionary = {}
40
+ var refused: String = _lay(packed.get_state(), ".", nodes, [scene_path])
41
+ if not refused.is_empty():
42
+ return {"ok": false, "error": refused}
43
+ if not nodes.has("."):
44
+ return {"ok": false, "error": "%s holds no nodes" % scene_path}
45
+ return {"ok": true, "tree": _entry(nodes, ".", include_properties, depth, 0)}
46
+
47
+
48
+ ## Lays the nodes of [param state] into [param nodes] under [param prefix], keyed by their path
49
+ ## from the outermost root, each instanced scene laid where it is instanced and the instancing
50
+ ## file's own entries for it applied over it. Answers a refusal, or "".
51
+ ##
52
+ ## [param within] is the files being laid, outermost first, so a scene that instances one of them
53
+ ## is refused rather than laid forever; the engine refuses to load such a scene, so this is only
54
+ ## reached through a state built some other way.
55
+ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: Array) -> String:
56
+ var base: SceneState = state.get_base_scene_state()
57
+ if base != null and not nodes.has(prefix):
58
+ var refused: String = _lay(base, prefix, nodes, within)
59
+ if not refused.is_empty():
60
+ return refused
61
+ for index: int in state.get_node_count():
62
+ var path: String = _joined(prefix, str(state.get_node_path(index)))
63
+ var fresh: bool = not nodes.has(path)
64
+ if fresh:
65
+ var instance: PackedScene = state.get_node_instance(index)
66
+ if instance != null:
67
+ if within.has(instance.resource_path):
68
+ return "%s instances %s, which holds it" % [within.back(), instance.resource_path]
69
+ var refused: String = _lay(
70
+ instance.get_state(), path, nodes, within + [instance.resource_path]
71
+ )
72
+ if not refused.is_empty():
73
+ return refused
74
+ if not nodes.has(path):
75
+ return "%s holds no nodes" % instance.resource_path
76
+ nodes[path]["instanceOf"] = instance.resource_path
77
+ elif state.is_node_instance_placeholder(index):
78
+ nodes[path] = _made("InstancePlaceholder")
79
+ nodes[path]["instanceOf"] = state.get_node_instance_placeholder(index)
80
+ elif str(state.get_node_type(index)).is_empty():
81
+ # An entry for a node of an instance that no longer has it, which the engine skips too.
82
+ continue
83
+ else:
84
+ nodes[path] = _made(str(state.get_node_type(index)))
85
+ # The name is the state's even for an entry laid already, since an inherited scene names its
86
+ # root itself; a root instanced inside another scene is renamed by that scene once laid.
87
+ nodes[path]["name"] = str(state.get_node_name(index))
88
+ if fresh:
89
+ # The root of what is laid here is placed by whoever instanced it.
90
+ if path != prefix:
91
+ _place(
92
+ nodes,
93
+ _joined(prefix, str(state.get_node_path(index, true))),
94
+ path,
95
+ state.get_node_index(index)
96
+ )
97
+ var held: Dictionary = nodes[path]["properties"]
98
+ for property: int in state.get_node_property_count(index):
99
+ held[str(state.get_node_property_name(index, property))] = state.get_node_property_value(
100
+ index, property
101
+ )
102
+ return ""
103
+
104
+
105
+ static func _made(type: String) -> Dictionary:
106
+ var children: Array[String] = []
107
+ return {"name": "", "type": type, "properties": {}, "children": children}
108
+
109
+
110
+ ## Puts [param path] among [param parent]'s children, at [param at] when the file says where (a node
111
+ ## added inside an instance) and last when it does not.
112
+ static func _place(nodes: Dictionary, parent: String, path: String, at: int) -> void:
113
+ if not nodes.has(parent):
114
+ return
115
+ var children: Array[String] = nodes[parent]["children"]
116
+ if at >= 0 and at < children.size():
117
+ var _placed: int = children.insert(at, path)
118
+ else:
119
+ children.append(path)
120
+
121
+
122
+ ## [param given], a path as a SceneState gives it ("." or "./Body/Tail"), under [param prefix].
123
+ static func _joined(prefix: String, given: String) -> String:
124
+ var relative: String = given.trim_prefix("./")
125
+ if relative == "." or relative.is_empty():
126
+ return prefix
127
+ if prefix == ".":
128
+ return relative
129
+ return prefix + "/" + relative
130
+
131
+
132
+ static func _entry(
133
+ nodes: Dictionary, path: String, include_properties: bool, depth: int, current_depth: int
134
+ ) -> Dictionary:
135
+ var node: Dictionary = nodes[path]
136
+ var children: Array[Dictionary] = []
137
+ var data: Dictionary = {"name": node["name"], "type": node["type"], "path": path, "children": children}
138
+ # The root's file is the scene being read, so only another node's names a second scene.
139
+ if path != "." and node.has("instanceOf"):
140
+ data["instanceOf"] = node["instanceOf"]
141
+ if include_properties:
142
+ var given: Dictionary = node["properties"]
143
+ data["properties"] = _stored(str(node["type"]), given)
144
+ if depth >= 0 and current_depth >= depth:
145
+ return data
146
+ for child: String in node["children"]:
147
+ children.append(_entry(nodes, child, include_properties, depth, current_depth + 1))
148
+ return data
149
+
150
+
151
+ ## Every property a node of [param type] stores, with the value [param given] sets or the default,
152
+ ## read as a node holding those values would answer them.
153
+ ##
154
+ ## The engine's half is read off a bare node of the class with the file's values set on it, which
155
+ ## runs no script: what a node lists depends on its values (a thread group's settings appear only
156
+ ## once it has one) and some setters set others (a Control's anchors preset sets how it grows), so
157
+ ## the class's defaults and the file's entries alone answered neither. The script's half is its
158
+ ## declared defaults under the file's values.
159
+ static func _stored(type: String, given: Dictionary) -> Dictionary:
160
+ var attached: Variant = given.get("script")
161
+ var script: Script = attached if attached is Script else null
162
+ var scripted: Dictionary = {}
163
+ if script != null:
164
+ for declared: Dictionary in script.get_script_property_list():
165
+ var property: String = str(declared.get("name", ""))
166
+ if not property.is_empty() and Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
167
+ scripted[property] = script.get_property_default_value(property)
168
+ var values: Dictionary = {}
169
+ if ClassDB.class_exists(type) and ClassDB.can_instantiate(type):
170
+ var bare: Object = ClassDB.instantiate(type)
171
+ for property: Variant in given:
172
+ if property != "script" and not scripted.has(property):
173
+ bare.set(str(property), given[property])
174
+ for declared: Dictionary in bare.get_property_list():
175
+ var property: String = str(declared.get("name", ""))
176
+ if not property.is_empty() and Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
177
+ values[property] = bare.get(property)
178
+ if bare is Node:
179
+ bare.free()
180
+ for property: String in scripted:
181
+ values[property] = scripted[property]
182
+ # Given last, which keeps what a bare node cannot hold: the script itself, and a path to a node
183
+ # for a property that holds the node once it is in a tree.
184
+ values.merge(given, true)
185
+ var serialiser: Serialisation = Serialisation.new()
186
+ var answered: Dictionary = {}
187
+ for property: Variant in values:
188
+ answered[property] = serialiser.serialize_value(values[property])
189
+ return answered
190
+
191
+
192
+ ## What a resource file holds, in the form modify takes, so a value read here can be written back.
193
+ ## Read from the file rather than a cached copy, since the file is what the writes change and what
194
+ ## the project loads.
195
+ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
196
+ if not FileAccess.file_exists(res_path):
197
+ return {"ok": false, "error": "No resource at " + res_path}
198
+ var loaded: Resource = ResourceLoader.load(res_path, "", ResourceLoader.CACHE_MODE_IGNORE)
199
+ if loaded == null:
200
+ return {"ok": false, "error": "%s did not load as a resource." % res_path}
201
+ # A scene's own properties are its packed bundle, which says nothing a caller can use.
202
+ if loaded is PackedScene:
203
+ return {
204
+ "ok": false,
205
+ "error":
206
+ "%s is a scene: scene_tree reads its nodes, and scene_node get one node's properties." % res_path
207
+ }
208
+
209
+ var script: Script = loaded.get_script()
210
+ var scripted: Dictionary = {}
211
+ if script != null:
212
+ for declared: Dictionary in script.get_script_property_list():
213
+ scripted[str(declared.get("name", ""))] = true
214
+ var serialiser: Serialisation = Serialisation.new()
215
+ var properties: Dictionary = {}
216
+ for p: Dictionary in loaded.get_property_list():
217
+ var property: String = str(p.get("name", ""))
218
+ if property.is_empty() or property == "script":
219
+ continue
220
+ if not (Read.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
221
+ continue
222
+ var value: Variant = loaded.get(property)
223
+ if not include_defaults:
224
+ var default: Variant = (
225
+ script.get_property_default_value(property)
226
+ if scripted.has(property)
227
+ else ClassDB.class_get_property_default_value(loaded.get_class(), property)
228
+ )
229
+ if PropertyValues.same(value, default):
230
+ continue
231
+ properties[property] = serialiser.serialize_value(value)
232
+ var answer: Dictionary = {
233
+ "ok": true, "resourcePath": res_path, "resourceType": loaded.get_class(), "properties": properties
234
+ }
235
+ if script != null:
236
+ answer["script"] = script.resource_path
237
+ return answer
@@ -0,0 +1 @@
1
+ uid://c1lf7gjr8v1ux
@@ -8,6 +8,17 @@ extends EditorPlugin
8
8
  const BridgeClient = preload("bridge_client.gd")
9
9
  const ToolExecutor = preload("tool_executor.gd")
10
10
 
11
+ ## The tools answered while the editor scans or imports, because none of them saves a file: the
12
+ ## ones a caller waits on a scan with, and the ones that only read or stop a played game.
13
+ const ANSWERED_WHILE_SCANNING: Array[String] = [
14
+ "scan_status",
15
+ "rescan_filesystem",
16
+ "reimport_files",
17
+ "playing_status",
18
+ "stop_playing",
19
+ "profile_read",
20
+ ]
21
+
11
22
  var _client: BridgeClient
12
23
  var _tool_executor: ToolExecutor
13
24
  var _status_label: Label
@@ -85,10 +96,26 @@ func _on_disconnected() -> void:
85
96
  _status_label.add_theme_color_override("font_color", Color.RED)
86
97
 
87
98
 
99
+ ## Whether [param tool_name] waits for the editor to finish scanning or importing. A save during a
100
+ ## scan updates the file list the scan's thread is writing out (see SceneFile.scan_later), and an
101
+ ## import can run frames of the editor from inside itself, the one handling this call among them.
102
+ func turned_away(tool_name: String) -> bool:
103
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
104
+ return (
105
+ not ANSWERED_WHILE_SCANNING.has(tool_name) and (filesystem.is_scanning() or filesystem.is_importing())
106
+ )
107
+
108
+
88
109
  func _on_tool_requested(request_id: String, tool_name: String, args: Dictionary) -> void:
89
110
  if _tool_executor == null or _client == null:
90
111
  return
91
112
 
113
+ # Turned away rather than held, so the server asks again: a call held here could run after the
114
+ # server had given up on it.
115
+ if turned_away(tool_name):
116
+ _client.send_busy(request_id, tool_name)
117
+ return
118
+
92
119
  var result: Dictionary = _tool_executor.execute_tool(tool_name, args)
93
120
  var success: bool = result.get("ok", false)
94
121
 
@@ -162,6 +162,9 @@ func cannot_hold(
162
162
  ## max_value) is set once more after the rest, so the order a caller wrote them in does not decide
163
163
  ## what is saved.
164
164
  ##
165
+ ## A name may be a colon path into a property's components, "position:x" or "region_rect:size:y",
166
+ ## spelled as the runtime tools spell it, so one axis can be written without restating the others.
167
+ ##
165
168
  ## Nothing here saves, so a refusal partway leaves the change in memory and the file as it was. The
166
169
  ## caller returns the refusal instead of saving.
167
170
  func write_all(target: Object, properties: Dictionary) -> String:
@@ -169,19 +172,23 @@ func write_all(target: Object, properties: Dictionary) -> String:
169
172
  for key: Variant in properties:
170
173
  var property: String = str(key)
171
174
  var declared: Dictionary = declaration(target, property)
175
+ if declared.is_empty() and ":" in property:
176
+ declared = _component_slot(target, property)
177
+ if declared.has("refused"):
178
+ return str(declared["refused"])
172
179
  if declared.is_empty():
173
180
  return "%s has no property %s" % [target.get_class(), property]
174
181
  var value: Array = value_for(target, declared, properties[key])
175
182
  if not value[0]:
176
183
  return str(value[1])
177
- target.set(property, value[1])
184
+ _write(target, property, value[1])
178
185
  given[property] = value[1]
179
186
 
180
187
  for property: String in given:
181
- if not same(target.get(property), given[property]):
182
- target.set(property, given[property])
188
+ if not same(read_at(target, property), given[property]):
189
+ _write(target, property, given[property])
183
190
  for property: String in given:
184
- var held: Variant = target.get(property)
191
+ var held: Variant = read_at(target, property)
185
192
  if not same(held, given[property]):
186
193
  return (
187
194
  (
@@ -193,6 +200,58 @@ func write_all(target: Object, properties: Dictionary) -> String:
193
200
  return ""
194
201
 
195
202
 
203
+ ## What [param target] holds under [param key], a property or a colon path into one's components.
204
+ func read_at(target: Object, key: String) -> Variant:
205
+ if ":" in key and declaration(target, key).is_empty():
206
+ return target.get_indexed(NodePath(key))
207
+ return target.get(key)
208
+
209
+
210
+ ## Writes [param value] under [param key], putting a changed component back into the property it
211
+ ## was read out of, and that one into the one above it.
212
+ func _write(target: Object, key: String, value: Variant) -> void:
213
+ if ":" in key and declaration(target, key).is_empty():
214
+ target.set_indexed(NodePath(key), value)
215
+ else:
216
+ target.set(key, value)
217
+
218
+
219
+ ## The declaration the last step of [param path] would have, typed as the component it reaches, so
220
+ ## a fraction for an integer vector's axis is refused as one for an int property is; or
221
+ ## {"refused": why} for the first step that is not there.
222
+ func _component_slot(target: Object, path: String) -> Dictionary:
223
+ var steps: PackedStringArray = path.split(":")
224
+ var owner: String = target.get_class()
225
+ if declaration(target, steps[0]).is_empty():
226
+ return {"refused": "%s has no property %s" % [owner, steps[0]]}
227
+ var value: Variant = target.get(steps[0])
228
+ var walked: String = steps[0]
229
+ for index: int in range(1, steps.size()):
230
+ var step: String = steps[index]
231
+ var has: PackedStringArray = Serialisation.components_of(value)
232
+ var kind: String = type_string(typeof(value))
233
+ var sort: String = ("an " if kind.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + kind
234
+ if has.is_empty():
235
+ return {
236
+ "refused":
237
+ (
238
+ (
239
+ "%s.%s is %s, which a path does not step into: a path reaches the components of a "
240
+ + "vector, a colour, a rectangle or a transform."
241
+ )
242
+ % [owner, walked, sort]
243
+ )
244
+ }
245
+ if not step in has:
246
+ return {
247
+ "refused":
248
+ "%s.%s is %s, so it has no %s; it has %s" % [owner, walked, sort, step, ", ".join(has)]
249
+ }
250
+ value = value[step]
251
+ walked += ":" + step
252
+ return {"name": path, "type": typeof(value), "usage": PROPERTY_USAGE_DEFAULT}
253
+
254
+
196
255
  ## [param raw] as the value [param declared] takes, as [ok, value] or [false, why not].
197
256
  func value_for(target: Object, declared: Dictionary, raw: Variant) -> Array:
198
257
  var property: String = str(declared.get("name", ""))
@@ -607,57 +666,24 @@ func _parse_new_resource(type_tag: String, value: Dictionary) -> Array:
607
666
  return [true, built]
608
667
 
609
668
 
610
- ## A dictionary with no tag, read against the type the property declares. Anything else is a
611
- ## dictionary the property genuinely wants, and its values are read as values in their own right:
612
- ## kept as the tags they arrived as, a Vector2 read off one node and written to another was saved as
613
- ## a dictionary holding the word "Vector2".
669
+ ## A dictionary with no tag, read against the type the property declares, by the fields the tagged
670
+ ## form of that type carries: the shape a read answers with, untagged, is a value too. A rectangle
671
+ ## written as the position and size a read gave was refused while the four numbers were taken, and
672
+ ## a Rect2i, an AABB or a transform had no untagged form at all. Anything else is a dictionary the
673
+ ## property genuinely wants, and its values are read as values in their own right: kept as the tags
674
+ ## they arrived as, a Vector2 read off one node and written to another was saved as a dictionary
675
+ ## holding the word "Vector2".
614
676
  func _parse_shaped_dictionary(value: Dictionary, expected_type: int, elements: Array) -> Variant:
615
- match expected_type:
616
- TYPE_VECTOR2:
617
- if value.has("x") and value.has("y"):
618
- return Vector2(Read.as_float(value["x"]), Read.as_float(value["y"]))
619
- TYPE_VECTOR2I:
620
- if value.has("x") and value.has("y"):
621
- return Vector2i(Read.as_int(value["x"]), Read.as_int(value["y"]))
622
- TYPE_VECTOR3:
623
- if value.has("x") and value.has("y") and value.has("z"):
624
- return Vector3(
625
- Read.as_float(value["x"]), Read.as_float(value["y"]), Read.as_float(value["z"])
626
- )
627
- TYPE_VECTOR3I:
628
- if value.has("x") and value.has("y") and value.has("z"):
629
- return Vector3i(Read.as_int(value["x"]), Read.as_int(value["y"]), Read.as_int(value["z"]))
630
- TYPE_VECTOR4:
631
- if value.has("x") and value.has("y") and value.has("z") and value.has("w"):
632
- return Vector4(
633
- Read.as_float(value["x"]),
634
- Read.as_float(value["y"]),
635
- Read.as_float(value["z"]),
636
- Read.as_float(value["w"])
637
- )
638
- TYPE_COLOR:
639
- if value.has("r") and value.has("g") and value.has("b"):
640
- return Color(
641
- Read.as_float(value["r"]),
642
- Read.as_float(value["g"]),
643
- Read.as_float(value["b"]),
644
- Read.as_float(value.get("a", 1), 1.0)
645
- )
646
- TYPE_RECT2:
647
- if value.has("x") and value.has("y") and value.has("width") and value.has("height"):
648
- return Rect2(
649
- Read.as_float(value["x"]),
650
- Read.as_float(value["y"]),
651
- Read.as_float(value["width"]),
652
- Read.as_float(value["height"])
653
- )
654
- TYPE_NODE_PATH:
655
- if value.has("path"):
656
- return NodePath(str(value["path"]))
657
- if expected_type != TYPE_NIL and expected_type != TYPE_DICTIONARY:
658
- return value
659
-
660
- return _parse_entries(value, elements)
677
+ if expected_type == TYPE_NIL or expected_type == TYPE_DICTIONARY:
678
+ return _parse_entries(value, elements)
679
+ var tag: String = type_string(expected_type)
680
+ if Serialisation.REQUIRED.has(tag):
681
+ var tagged: Dictionary = value.duplicate()
682
+ tagged["_type"] = tag
683
+ var built: Variant = _values.deserialize_value(tagged)
684
+ if typeof(built) == expected_type:
685
+ return built
686
+ return value
661
687
 
662
688
 
663
689
  ## The entries of a dictionary a property holds, each value read as a value in its own right, and
@@ -19,6 +19,10 @@ extends RefCounted
19
19
 
20
20
  const PropertyValues = preload("property_values.gd")
21
21
 
22
+ ## Whether a scan asked for through [method scan_later] is still to start, which a caller waiting
23
+ ## for the editor's files to settle has to wait through as well.
24
+ static var scan_due: bool = false
25
+
22
26
  var root: Node = null
23
27
  var path: String = ""
24
28
  var _base_paths: Dictionary = {}
@@ -249,11 +253,44 @@ static func save_packed(packed: PackedScene, scene_path: String) -> Dictionary:
249
253
  static func refresh(scene_path: String) -> void:
250
254
  if ResourceLoader.has_cached(scene_path):
251
255
  var _replaced: Resource = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_REPLACE)
252
- EditorInterface.get_resource_filesystem().scan()
256
+ scan_later()
253
257
  if EditorInterface.get_open_scenes().has(scene_path):
254
258
  EditorInterface.reload_scene_from_path(scene_path)
255
259
 
256
260
 
261
+ ## Has the editor scan its files once the call in hand has answered, rather than now.
262
+ ##
263
+ ## A scan's thread ends by writing the editor's file list to its cache, walking the list as it goes,
264
+ ## while each save made in the editor updates an entry of that list on the main thread and frees
265
+ ## what the entry held (4.7.2's [code]_save_filesystem_cache[/code] and
266
+ ## [code]EditorNode::_resource_saved[/code]). A scene saved 250 ms after a resource's scan began was
267
+ ## followed by garbage decoded as text and a dropped connection. Started at the end of the frame,
268
+ ## the scan begins after every save the call makes, and the plugin turns away calls that could save
269
+ ## until it is over.
270
+ static func scan_later() -> void:
271
+ if scan_due:
272
+ return
273
+ scan_due = true
274
+ _start_scan.call_deferred()
275
+
276
+
277
+ ## [method EditorFileSystem.scan] does nothing, and says nothing, while the thread of the scan
278
+ ## before it is still to be joined, which is a frame or two after that scan stops reporting itself,
279
+ ## so a scan that leaves the editor not scanning is asked for once more once that is over. Asked of
280
+ ## a scan that ended within the call, the second costs one more scan.
281
+ static func _start_scan() -> void:
282
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
283
+ var tree: SceneTree = Engine.get_main_loop()
284
+ while filesystem.is_scanning() or filesystem.is_importing():
285
+ await tree.process_frame
286
+ filesystem.scan()
287
+ if not filesystem.is_scanning():
288
+ await tree.process_frame
289
+ await tree.process_frame
290
+ filesystem.scan()
291
+ scan_due = false
292
+
293
+
257
294
  ## Whether the editor holds unsaved changes to [param scene_path], for a read to say that the file
258
295
  ## it answers from is not what the editor shows.
259
296
  static func unsaved(scene_path: String) -> bool:
@@ -246,6 +246,52 @@ func deserialize_value(value: Variant) -> Variant:
246
246
  return call(builder, fields)
247
247
 
248
248
 
249
+ ## The components a path reads off [param value], by the names GDScript gives them, or none for a
250
+ ## value that has none: a vector's axes, a colour's channels, a rectangle's corners and size, a
251
+ ## transform's basis and origin. A value of one of these is a copy wherever it is read, so a write
252
+ ## into a component is a write into the copy until it is put back where it was read from.
253
+ static func components_of(value: Variant) -> PackedStringArray:
254
+ match typeof(value):
255
+ TYPE_VECTOR2, TYPE_VECTOR2I:
256
+ return PackedStringArray(["x", "y"])
257
+ TYPE_VECTOR3, TYPE_VECTOR3I:
258
+ return PackedStringArray(["x", "y", "z"])
259
+ TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
260
+ return PackedStringArray(["x", "y", "z", "w"])
261
+ TYPE_COLOR:
262
+ return PackedStringArray(
263
+ [
264
+ "r",
265
+ "g",
266
+ "b",
267
+ "a",
268
+ "r8",
269
+ "g8",
270
+ "b8",
271
+ "a8",
272
+ "h",
273
+ "s",
274
+ "v",
275
+ "ok_hsl_h",
276
+ "ok_hsl_s",
277
+ "ok_hsl_l"
278
+ ]
279
+ )
280
+ TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
281
+ return PackedStringArray(["position", "size", "end"])
282
+ TYPE_PLANE:
283
+ return PackedStringArray(["normal", "x", "y", "z", "d"])
284
+ TYPE_BASIS:
285
+ return PackedStringArray(["x", "y", "z"])
286
+ TYPE_TRANSFORM2D:
287
+ return PackedStringArray(["x", "y", "origin"])
288
+ TYPE_TRANSFORM3D:
289
+ return PackedStringArray(["basis", "origin"])
290
+ TYPE_PROJECTION:
291
+ return PackedStringArray(["x", "y", "z", "w"])
292
+ return PackedStringArray()
293
+
294
+
249
295
  ## Whether [param entries] is a list of [key, value] pairs, as a Dictionary tag carries them.
250
296
  static func pairs(entries: Variant) -> bool:
251
297
  if not entries is Array:
@@ -95,6 +95,7 @@ func _init_tools() -> void:
95
95
  "rescan_filesystem": [_filesystem_tools, "rescan_filesystem"],
96
96
  "scan_status": [_filesystem_tools, "scan_status"],
97
97
  "reimport_files": [_filesystem_tools, "reimport_files"],
98
+ "files_moved": [_filesystem_tools, "files_moved"],
98
99
  "global_classes": [_class_tools, "global_classes"],
99
100
  "reload_script": [_class_tools, "reload_script"],
100
101
  # Settings, for a caller that asked the editor rather than the file on disk
@@ -117,9 +117,31 @@ func reload_script(args: Dictionary) -> Dictionary:
117
117
  answer["restoredAs"] = error_string(restored)
118
118
  answer["methods"] = _method_names(script)
119
119
  answer["constants"] = _constant_names(script)
120
+ _reread_open_tabs(path, answer)
120
121
  return answer
121
122
 
122
123
 
124
+ ## Has the script editor's tabs reread what changed on disk, as the editor does when its window takes
125
+ ## the focus, which a hidden editor's never does. A tab with unsaved edits is left alone and named:
126
+ ## saving it writes its text over the file, and rereading would ask a question nobody can answer in a
127
+ ## hidden editor's dialog.
128
+ func _reread_open_tabs(path: String, answer: Dictionary) -> void:
129
+ # The engine fixtures reload from a script run outside any editor, where there are no tabs and
130
+ # EditorInterface has none of its methods.
131
+ if not Engine.is_editor_hint():
132
+ return
133
+ var scripts: ScriptEditor = EditorInterface.get_script_editor()
134
+ var unsaved: PackedStringArray = scripts.get_unsaved_files()
135
+ if path in unsaved:
136
+ answer["unsavedInEditor"] = true
137
+ return
138
+ var rereads: Variant = EditorInterface.get_editor_settings().get_setting(
139
+ "text_editor/behavior/files/auto_reload_scripts_on_external_change"
140
+ )
141
+ if unsaved.is_empty() and rereads is bool and rereads:
142
+ scripts.reload_open_files()
143
+
144
+
123
145
  ## [param path] as the files on disk spell it, a step at a time, or as given where a step is not
124
146
  ## there. On a filesystem that ignores case, `res://Core/Rules.gd` opens the file at
125
147
  ## `res://core/rules.gd`, but the editor holds its copy under the spelling on disk, and a load by