gdharness 1.3.66 → 1.3.68

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 (25) hide show
  1. package/README.md +1 -1
  2. package/build/cli.js +19209 -17814
  3. package/build/godot/addons/gdharness_editor/bridge_client.gd +18 -5
  4. package/build/godot/addons/gdharness_editor/headless_check.gd +52 -0
  5. package/build/godot/addons/gdharness_editor/picture_saves.gd +48 -0
  6. package/build/godot/addons/gdharness_editor/picture_saves.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/plugin.gd +84 -3
  8. package/build/godot/addons/gdharness_editor/scene_file.gd +133 -12
  9. package/build/godot/addons/gdharness_editor/tool_executor.gd +16 -1
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +3 -1
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +9 -13
  12. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +87 -13
  13. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +20 -4
  14. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +51 -17
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +473 -8
  16. package/build/godot/addons/gdharness_editor/tools/view_tools.gd +121 -0
  17. package/build/godot/addons/gdharness_editor/tools/view_tools.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_runtime/picture_saves.gd +48 -0
  19. package/build/godot/addons/gdharness_runtime/picture_saves.gd.uid +1 -0
  20. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +5 -43
  21. package/build/godot/operations/editor_tools.gd +1 -0
  22. package/build/godot/operations/gdscript_analysis.gd +17 -0
  23. package/build/godot/operations/picture_saves.gd +48 -0
  24. package/build/keeper.js +116 -114
  25. package/package.json +1 -1
@@ -48,6 +48,9 @@ const DAP_ASKED: String = "GDHARNESS_DAP_PORT"
48
48
  ## What a server that opened this editor says about itself, so that being opened by one is stated
49
49
  ## rather than guessed at from the ports. Kept in step with `OPENED_BY_A_SERVER` in src/launch.ts.
50
50
  const OPENED_BY_A_SERVER: String = "GDHARNESS_OPENED_BY_A_SERVER"
51
+ ## Set for an editor a server started on its hidden desktop. Kept in step with `DESKTOP_VARIABLE`
52
+ ## in src/desktop.ts.
53
+ const DESKTOP_VARIABLE: String = "GDHARNESS_DESKTOP"
51
54
  ## Put into this editor's own environment as it loads, so every game it plays inherits it and
52
55
  ## the runtime addon can announce which editor played it. A game of the same project that some
53
56
  ## other server started has no such variable, or another editor's number, and that is what
@@ -357,8 +360,10 @@ func _handle_connect() -> void:
357
360
  # Whether a session opening on the adapter is told this editor's breakpoints or takes
358
361
  # them away, so a server can say which before it sets one.
359
362
  "syncs_breakpoints": _syncs_breakpoints(),
360
- # Whether this editor runs with no window, which for one opened by hand only it can say.
361
- "hidden": DisplayServer.get_name() == "headless",
363
+ # Whether this editor runs with no window anybody sees, which for one opened by hand
364
+ # only
365
+ # it can say: headless, or a window on the hidden desktop a server started it on.
366
+ "hidden": DisplayServer.get_name() == "headless" or _on_the_hidden_desktop(),
362
367
  # Whether a server started this editor, which is the only kind that can be started again
363
368
  # as itself: the engine hands back none of the arguments it was given, so the arguments
364
369
  # have to come from whoever wrote them. See `restart_editor` in play_tools.gd.
@@ -442,6 +447,12 @@ func _keep_breakpoints_through_sessions() -> void:
442
447
  )
443
448
 
444
449
 
450
+ ## Whether a server started this editor on its hidden desktop, where its window draws and never
451
+ ## shows.
452
+ static func _on_the_hidden_desktop() -> bool:
453
+ return OS.get_name() == "Windows" and not OS.get_environment(DESKTOP_VARIABLE).is_empty()
454
+
455
+
445
456
  ## Whether the editor shares its breakpoints with a session as it opens. See the setting.
446
457
  func _syncs_breakpoints() -> bool:
447
458
  if not Engine.is_editor_hint():
@@ -562,15 +573,17 @@ func _handle_message(json_string: String) -> void:
562
573
 
563
574
  ## Says [param tool_name] was not run because the editor is scanning or importing, for the server to
564
575
  ## ask again once that is over.
565
- func send_busy(request_id: String, tool_name: String) -> void:
576
+ ## [param doing] is what the editor is busy with, and [param mark] changes as it gets on with it.
577
+ func send_busy(request_id: String, tool_name: String, doing: String, mark: String) -> void:
566
578
  _send_message(
567
579
  {
568
580
  "type": "tool_result",
569
581
  "id": request_id,
570
582
  "success": false,
571
583
  "busy": true,
572
- "error":
573
- "The editor is scanning or importing its files, so %s was not run." % tool_name,
584
+ "busyWith": doing,
585
+ "progress": mark,
586
+ "error": "The editor is %s, so %s was not run." % [doing, tool_name],
574
587
  }
575
588
  )
576
589
 
@@ -66,6 +66,16 @@ static func through_script(object: Object, names: Dictionary) -> bool:
66
66
  return false
67
67
 
68
68
 
69
+ ## Sets [param name] on [param object] back to [param value]. A metadata entry put back to nothing
70
+ ## is removed. Set to null through its property, Godot keeps the entry holding null, and the saver
71
+ ## writes a line that erases what the node's script sets each time the scene loads.
72
+ static func restore(object: Object, name: String, value: Variant) -> void:
73
+ if value == null and name.begins_with("metadata/"):
74
+ object.remove_meta(name.trim_prefix("metadata/"))
75
+ return
76
+ object.set(name, value)
77
+
78
+
69
79
  ## Whether [param object] still holds [param held] for [param name] once set to [param wanted]
70
80
  ## because the engine derives it, as it keeps layout_mode at Container under a container: an engine
71
81
  ## property, so no script code ran for the set, and an editor saves the same.
@@ -574,3 +584,45 @@ class GdhSaved:
574
584
  path = at
575
585
  before = held
576
586
  resave = again
587
+
588
+
589
+ ## Every script that failed to compile while it is attached, as {file, line, message} in order.
590
+ class GdhParseErrors:
591
+ extends Logger
592
+
593
+ var raised: Array[Dictionary] = []
594
+
595
+ func _log_message(_message: String, _error: bool) -> void:
596
+ pass
597
+
598
+ # The engine's own signature for a Logger, eight parameters and not this project's to shape.
599
+ # gdstyle:ignore=quality/max-parameters
600
+ func _log_error(
601
+ _function: String,
602
+ file: String,
603
+ line: int,
604
+ code: String,
605
+ _rationale: String,
606
+ _editor_notify: bool,
607
+ error_type: int,
608
+ _script_backtraces: Array[ScriptBacktrace],
609
+ ) -> void:
610
+ if error_type == Logger.ERROR_TYPE_SCRIPT and code.begins_with("Parse Error: "):
611
+ raised.append(
612
+ { "file": file, "line": line, "message": code.trim_prefix("Parse Error: ") }
613
+ )
614
+
615
+ ## Why a write is refused for what was raised, or "" when nothing was.
616
+ func refusal(scene_path: String) -> String:
617
+ if raised.is_empty():
618
+ return ""
619
+ var first: Dictionary = raised[0]
620
+ return (
621
+ (
622
+ "%s does not compile in a headless engine (line %d: %s), and it loads with %s, "
623
+ + "so the scene's scripts would not run as they do in the game. A class_name added "
624
+ + "since the class cache was built is taken in by project_import refresh_classes. "
625
+ + "Fix the script or refresh the classes, then ask again."
626
+ )
627
+ % [first["file"], first["line"], first["message"], scene_path]
628
+ )
@@ -0,0 +1,48 @@
1
+ extends RefCounted
2
+
3
+ # How a captured picture is sized and saved, for the game's captures and the editor's alike.
4
+ #
5
+ # This file is the one beside the operations, and `bun run sync:gd` copies it into each addon,
6
+ # because an addon is installed as a directory and cannot preload out of one.
7
+
8
+ ## Measured on a downstream game's 1280x720 frame: its interface text and pixel art read as they do
9
+ ## in the PNG, at a fifth of the PNG's size.
10
+ const JPEG_QUALITY: float = 0.8
11
+
12
+
13
+ ## The size a capture drawn at [param drawn] is scaled to, from the width and height asked for, 0
14
+ ## being not asked. One side alone keeps the picture's proportions, since that is what asking for a
15
+ ## smaller picture means: it was ignored unless both came, so the full-size picture was sent back.
16
+ static func scaled_to(drawn: Vector2i, width: int, height: int) -> Vector2i:
17
+ if width > 0 and height > 0:
18
+ return Vector2i(width, height)
19
+ if width > 0 and drawn.x > 0:
20
+ return Vector2i(width, maxi(1, roundi(float(drawn.y) * width / drawn.x)))
21
+ if height > 0 and drawn.y > 0:
22
+ return Vector2i(maxi(1, roundi(float(drawn.x) * height / drawn.y)), height)
23
+ return drawn
24
+
25
+
26
+ ## [param image] saved as a PNG at [param png_path], and as a JPEG beside it, which the server sends
27
+ ## instead when it is the smaller: a rendered frame is several times smaller as one, and every
28
+ ## picture sent stays in the caller's session. Not for a picture with transparency, which a JPEG
29
+ ## would flatten.
30
+ static func saved(image: Image, png_path: String) -> Dictionary:
31
+ var save_error: Error = image.save_png(png_path)
32
+ if save_error != OK:
33
+ return {
34
+ "type": "error",
35
+ "message": "Failed to save screenshot as PNG: " + str(save_error),
36
+ }
37
+ var answer: Dictionary = {
38
+ "type": "screenshot_file",
39
+ "format": "png",
40
+ "width": image.get_width(),
41
+ "height": image.get_height(),
42
+ "path": png_path,
43
+ }
44
+ if image.detect_alpha() == Image.ALPHA_NONE:
45
+ var jpeg_path: String = png_path.get_basename() + ".jpg"
46
+ if image.save_jpg(jpeg_path, JPEG_QUALITY) == OK:
47
+ answer["jpegPath"] = jpeg_path
48
+ return answer
@@ -0,0 +1 @@
1
+ uid://c2lleb9sm7gnl
@@ -7,6 +7,7 @@ extends EditorPlugin
7
7
  const GdhBridgeClient = preload("bridge_client.gd")
8
8
  const GdhToolExecutor = preload("tool_executor.gd")
9
9
  const GdhReleaseExport = preload("release_export.gd")
10
+ const GdhSceneFile = preload("scene_file.gd")
10
11
  ## The tools answered while the editor scans or imports, because none of them saves a file: the
11
12
  ## ones a caller waits on a scan with, and the ones that only read or stop a played game.
12
13
  const ANSWERED_WHILE_SCANNING: Array[String] = [
@@ -17,11 +18,29 @@ const ANSWERED_WHILE_SCANNING: Array[String] = [
17
18
  "stop_playing",
18
19
  "profile_read",
19
20
  ]
21
+ ## The tools answered inside a task of the editor's progress dialog, because none of them writes
22
+ ## anything the editor's task could be in the middle of.
23
+ const ANSWERED_DURING_A_TASK: Array[String] = [
24
+ "scan_status",
25
+ "playing_status",
26
+ "stop_playing",
27
+ "profile_read",
28
+ ]
29
+ ## What an editor opened to quit once a process has gone carries after `--`, with that pid after it.
30
+ ## Kept in step with `UNTIL_MARK` in src/launch.ts.
31
+ const UNTIL_MARK: String = "--gdharness-until="
32
+ ## Where the holder of such an editor asks it to save and quit, a file named for its pid. Kept in
33
+ ## step with `QUITS_FOLDER` in src/editor-open.ts.
34
+ const QUITS_FOLDER: String = "res://.godot/gdharness-editor-quits/"
35
+ ## How often such an editor looks for the request, in seconds.
36
+ const QUIT_LOOK_S: float = 1.0
20
37
 
21
38
  var _client: GdhBridgeClient
22
39
  var _tool_executor: GdhToolExecutor
23
40
  var _status_label: Label
24
41
  var _release_export: GdhReleaseExport
42
+ var _quit_look: Timer
43
+ var _loaded_at: int = 0
25
44
 
26
45
 
27
46
  func _enter_tree() -> void:
@@ -43,6 +62,7 @@ func _enter_tree() -> void:
43
62
 
44
63
  _setup_status_indicator()
45
64
  _client.connect_to_server()
65
+ _look_for_a_quit_request()
46
66
 
47
67
 
48
68
  func _exit_tree() -> void:
@@ -71,6 +91,41 @@ func _exit_tree() -> void:
71
91
  _status_label = null
72
92
 
73
93
 
94
+ ## Looks for its holder's request once a second, in an editor opened to quit once a process has
95
+ ## gone. Godot cannot watch that process itself, since `OS.is_process_running` answers false for any
96
+ ## pid it did not start, so a gdharness holder outside the editor watches it and writes the request.
97
+ func _look_for_a_quit_request() -> void:
98
+ var held: bool = false
99
+ for arg: String in OS.get_cmdline_user_args():
100
+ held = held or arg.begins_with(UNTIL_MARK)
101
+ if not held:
102
+ return
103
+ _loaded_at = int(Time.get_unix_time_from_system())
104
+ _quit_look = Timer.new()
105
+ _quit_look.wait_time = QUIT_LOOK_S
106
+ _quit_look.autostart = true
107
+ _listen(_quit_look.timeout, _on_quit_look)
108
+ add_child(_quit_look)
109
+
110
+
111
+ ## Saves and quits on a request written since this addon loaded, as quit_editor does. One older
112
+ ## than that was written for an earlier process with this pid. Not while the editor scans, imports
113
+ ## or runs a task, which every save waits out.
114
+ func _on_quit_look() -> void:
115
+ var request: String = ProjectSettings.globalize_path(QUITS_FOLDER + str(OS.get_process_id()))
116
+ if not FileAccess.file_exists(request) or FileAccess.get_modified_time(request) < _loaded_at:
117
+ return
118
+ if turned_away("quit_editor"):
119
+ return
120
+ _quit_look.stop()
121
+ var answer: Dictionary = await _tool_executor.execute_tool("quit_editor", {})
122
+ if answer.get("ok", false) != true:
123
+ push_warning(
124
+ "gdharness: the process holding this editor has gone, and it stays open: %s"
125
+ % str(answer.get("error", ""))
126
+ )
127
+
128
+
74
129
  # A failed connect is a mistake in this plugin rather than anything the editor did, so it goes to
75
130
  # the error stream instead of being dropped: a project holding return_value_discarded at error level
76
131
  # refuses to compile a script that throws the answer away.
@@ -106,14 +161,39 @@ func _on_disconnected() -> void:
106
161
  ## Whether [param tool_name] waits for the editor to finish scanning or importing. A save during a
107
162
  ## scan updates the file list the scan's thread is writing out (see SceneFile.scan_later), and an
108
163
  ## import can run frames of the editor from inside itself, the one handling this call among them.
164
+ ## Every call waits out a capture, which shows a scene for frames before it reads the view.
165
+ ##
166
+ ## A task in the editor's progress dialog runs frames of the editor from inside itself at every
167
+ ## step, so a call handled then runs in the middle of that task. A save there asked for the script
168
+ ## classes again while the editor was registering them, and the two registrations walked one list,
169
+ ## which crashed the editor at its next scan.
109
170
  func turned_away(tool_name: String) -> bool:
171
+ if _tool_executor != null and _tool_executor.capturing():
172
+ return true
173
+ if not ANSWERED_DURING_A_TASK.has(tool_name) and GdhSceneFile.in_a_task():
174
+ return true
110
175
  var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
111
176
  return (
112
177
  not ANSWERED_WHILE_SCANNING.has(tool_name)
113
- and (filesystem.is_scanning() or filesystem.is_importing())
178
+ and (GdhSceneFile.scan_unfinished(filesystem) or filesystem.is_importing())
114
179
  )
115
180
 
116
181
 
182
+ ## What the editor is doing that turns calls away, and a mark that changes as it gets on with it,
183
+ ## as [what, mark]. The server keeps asking while the mark changes: registering forty classes in a
184
+ ## hidden editor took over thirty seconds, every one of them a step.
185
+ func busy_with() -> Array[String]:
186
+ if _tool_executor != null and _tool_executor.capturing():
187
+ return ["capturing its view", ""]
188
+ var labels: Array[String] = GdhSceneFile.task_labels()
189
+ if not labels.is_empty():
190
+ return ["running its task \"%s\"" % labels[0].trim_suffix("..."), "\n".join(labels)]
191
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
192
+ if filesystem.is_importing():
193
+ return ["importing its files", ""]
194
+ return ["scanning its files", str(filesystem.get_scanning_progress())]
195
+
196
+
117
197
  func _on_tool_requested(request_id: String, tool_name: String, args: Dictionary) -> void:
118
198
  if _tool_executor == null or _client == null:
119
199
  return
@@ -121,10 +201,11 @@ func _on_tool_requested(request_id: String, tool_name: String, args: Dictionary)
121
201
  # Turned away rather than held, so the server asks again: a call held here could run after the
122
202
  # server had given up on it.
123
203
  if turned_away(tool_name):
124
- _client.send_busy(request_id, tool_name)
204
+ var busy: Array[String] = busy_with()
205
+ _client.send_busy(request_id, tool_name, busy[0], busy[1])
125
206
  return
126
207
 
127
- var result: Dictionary = _tool_executor.execute_tool(tool_name, args)
208
+ var result: Dictionary = await _tool_executor.execute_tool(tool_name, args)
128
209
  var success: bool = result.get("ok", false)
129
210
 
130
211
  if success:
@@ -30,6 +30,7 @@ const PUTTING_BACK_PASSES: int = 4
30
30
  const GdhRead = preload("reading.gd")
31
31
  const GdhSame = preload("same_values.gd")
32
32
  const GdhSerialisation = preload("serialisation.gd")
33
+ const GdhSceneFile = preload("scene_file.gd")
33
34
  const GdhSceneUids = preload("scene_uids.gd")
34
35
 
35
36
  ## Whether a scan asked for through [method scan_later] is still to start, which a caller waiting
@@ -41,6 +42,10 @@ static var also_saved: Array[Dictionary] = []
41
42
  ## What a write said of the connections it kept in the file though the engine cannot make them,
42
43
  ## gathered for that call's answer as [member also_saved] is.
43
44
  static var unmade_note: String = ""
45
+ ## The editor's progress dialog, which Godot keeps for the editor's whole life and exposes no other
46
+ ## way. Looked for once, and again only if it has gone.
47
+ static var _progress: Node = null
48
+ static var _progress_sought: bool = false
44
49
 
45
50
  var root: Node = null
46
51
  var path: String = ""
@@ -102,12 +107,21 @@ func open(scene_path: String, for_writing: bool) -> Dictionary:
102
107
  var missing: String = missing_said(path, FileAccess.get_file_as_string(path))
103
108
  if not missing.is_empty():
104
109
  return { "ok": false, "error": missing }
110
+ # A script the scene's scripts load that fails to compile names no node, so nothing else sees
111
+ # it.
112
+ var failed: GdhHeadlessCheck.GdhParseErrors = GdhHeadlessCheck.GdhParseErrors.new()
113
+ var watching: bool = for_writing and not Engine.is_editor_hint()
114
+ if watching:
115
+ OS.add_logger(failed)
105
116
  var packed: PackedScene = (
106
117
  ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_IGNORE) as PackedScene
107
118
  )
119
+ if packed != null:
120
+ root = packed.instantiate(PackedScene.GEN_EDIT_STATE_MAIN)
121
+ if watching:
122
+ OS.remove_logger(failed)
108
123
  if packed == null:
109
124
  return { "ok": false, "error": "Failed to load %s as a scene" % path }
110
- root = packed.instantiate(PackedScene.GEN_EDIT_STATE_MAIN)
111
125
  if root == null:
112
126
  return { "ok": false, "error": "Failed to instantiate " + path }
113
127
  _state = packed.get_state()
@@ -124,6 +138,8 @@ func open(scene_path: String, for_writing: bool) -> Dictionary:
124
138
  if for_writing:
125
139
  _text = FileAccess.get_file_as_string(path)
126
140
  var unsettled: String = settle()
141
+ if unsettled.is_empty() and for_writing:
142
+ unsettled = failed.refusal(path)
127
143
  if not unsettled.is_empty():
128
144
  return refuse(("Nothing was written: " if for_writing else "Not read: ") + unsettled)
129
145
  return {}
@@ -416,6 +432,64 @@ func copied(copies: Dictionary) -> void:
416
432
  _copied_from[copy.get_instance_id()] = original.get_instance_id()
417
433
 
418
434
 
435
+ ## A scene to write at [param branch_path] whose root is [param top], a node of this one about to be
436
+ ## taken out of it, keeping what this file stores on each node of [param top] and what the tool
437
+ ## asked of them. A connection the engine could not make goes with it when both its ends are in
438
+ ## [param top]. One with a single end there is taken out of this scene's and answered, that end
439
+ ## given as its path from [param top] under sourceBelow or targetBelow, for [method rejoin_unmade].
440
+ ## Answers [scene, those connections].
441
+ func split_off(top: Node, branch_path: String) -> Array:
442
+ var branch: GdhSceneFile = GdhSceneFile.new()
443
+ branch.root = top
444
+ branch.path = branch_path
445
+ branch._text = _text
446
+ branch._reached = _reached.duplicate()
447
+ var nodes: Array[Node] = [top]
448
+ nodes.append_array(top.find_children("*", "", true, false))
449
+ for node: Node in nodes:
450
+ var id: int = node.get_instance_id()
451
+ if _filed.has(id):
452
+ branch._filed[id] = _filed[id]
453
+ if _asked.has(id):
454
+ branch._asked[id] = _asked[id]
455
+ if _before.has(id):
456
+ branch._before[id] = _before[id]
457
+ var crossing: Array[Dictionary] = []
458
+ var still: Array[Dictionary] = []
459
+ for held: Dictionary in _unmade:
460
+ var source_in: bool = _within(held["source"], top)
461
+ var target_in: bool = _within(held["target"], top)
462
+ if source_in and target_in:
463
+ branch._unmade.append(held)
464
+ continue
465
+ if not source_in and not target_in:
466
+ still.append(held)
467
+ continue
468
+ var end: String = "source" if source_in else "target"
469
+ var below: Node = held[end]
470
+ held[end + "Below"] = str(top.get_path_to(below))
471
+ crossing.append(held)
472
+ _unmade = still
473
+ return [branch, crossing]
474
+
475
+
476
+ ## Puts back each of [param crossing], from [method split_off], among the connections a write keeps,
477
+ ## its end below the branch now on the node at that path below [param instance].
478
+ func rejoin_unmade(crossing: Array[Dictionary], instance: Node) -> void:
479
+ for held: Dictionary in crossing:
480
+ for end: String in ["source", "target"]:
481
+ if held.has(end + "Below"):
482
+ held[end] = instance.get_node_or_null(NodePath(str(held[end + "Below"])))
483
+ _unmade.append(held)
484
+
485
+
486
+ static func _within(held: Variant, top: Node) -> bool:
487
+ if not is_instance_valid(held) or not held is Node:
488
+ return false
489
+ var node: Node = held
490
+ return node == top or top.is_ancestor_of(node)
491
+
492
+
419
493
  ## Why [param scene_path] cannot be written while the editor has it open, or "".
420
494
  static func unsaved_in_editor(scene_path: String) -> String:
421
495
  if not Engine.is_editor_hint() or not EditorInterface.get_unsaved_scenes().has(scene_path):
@@ -1075,23 +1149,63 @@ static func scan_later() -> void:
1075
1149
  _start_scan.call_deferred()
1076
1150
 
1077
1151
 
1078
- ## [method EditorFileSystem.scan] does nothing, and says nothing, while the thread of the scan
1079
- ## before it is still to be joined, which is a frame or two after that scan stops reporting itself,
1080
- ## so a scan that leaves the editor not scanning is asked for once more once that is over. Asked of
1081
- ## a scan that ended within the call, the second costs one more scan.
1152
+ ## It waits out a task of the progress dialog too, whose every step runs a frame of the editor from
1153
+ ## inside itself. A scan's end registers the classes it found in such a task (4.7.2's
1154
+ ## [code]EditorFileSystem::_notification[/code]). A scan started from that frame ran beside the end
1155
+ ## of the one before, which crashed the editor or left it scanning.
1082
1156
  static func _start_scan() -> void:
1083
1157
  var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
1084
1158
  var tree: SceneTree = Engine.get_main_loop()
1085
- while filesystem.is_scanning() or filesystem.is_importing():
1159
+ while scan_unfinished(filesystem) or filesystem.is_importing() or in_a_task():
1086
1160
  await tree.process_frame
1087
1161
  filesystem.scan()
1088
- if not filesystem.is_scanning():
1089
- await tree.process_frame
1090
- await tree.process_frame
1091
- filesystem.scan()
1092
1162
  scan_due = false
1093
1163
 
1094
1164
 
1165
+ ## Whether the editor scans its files, counting the frames after a scan stops reporting itself and
1166
+ ## before the editor handles its end. That end swaps in the new file list and registers the classes
1167
+ ## the scan found, from a frame of the editor. A save in those frames registered the classes as
1168
+ ## well,
1169
+ ## inside its own task, and the editor crashed or logged "Task 'update_scripts_classes' already
1170
+ ## exists". [method EditorFileSystem.scan] also does nothing then, without a word. The editor
1171
+ ## processes from the start of a scan until it has handled the end (4.7.2's
1172
+ ## [code]EditorFileSystem::scan[/code] and [code]_notification[/code]).
1173
+ static func scan_unfinished(filesystem: EditorFileSystem) -> bool:
1174
+ return filesystem.is_scanning() or filesystem.is_processing()
1175
+
1176
+
1177
+ ## Whether the editor's progress dialog is showing, which it does for as long as a task is open.
1178
+ static func in_a_task() -> bool:
1179
+ if _progress != null and not is_instance_valid(_progress):
1180
+ _progress = null
1181
+ _progress_sought = false
1182
+ if not _progress_sought:
1183
+ _progress_sought = true
1184
+ _progress = _found_progress((Engine.get_main_loop() as SceneTree).root)
1185
+ return _progress is CanvasItem and (_progress as CanvasItem).visible
1186
+
1187
+
1188
+ ## The labels of the progress dialog's open tasks, each task's title then the step it is on, which
1189
+ ## change as the task goes on. Empty when no task is open.
1190
+ static func task_labels() -> Array[String]:
1191
+ var labels: Array[String] = []
1192
+ if not in_a_task():
1193
+ return labels
1194
+ for label: Node in _progress.find_children("*", "Label", true, false):
1195
+ labels.append(str(label.get("text")))
1196
+ return labels
1197
+
1198
+
1199
+ static func _found_progress(top: Node) -> Node:
1200
+ var pending: Array[Node] = [top]
1201
+ while not pending.is_empty():
1202
+ var node: Node = pending.pop_back()
1203
+ if node.is_class("ProgressDialog"):
1204
+ return node
1205
+ pending.append_array(node.get_children(true))
1206
+ return null
1207
+
1208
+
1095
1209
  ## Whether the editor holds unsaved changes to [param scene_path], for a read to say that the file
1096
1210
  ## it answers from is not what the editor shows.
1097
1211
  static func unsaved(scene_path: String) -> bool:
@@ -1123,7 +1237,7 @@ func _settled(packed: PackedScene) -> Array:
1123
1237
  var name: String = found[1]
1124
1238
  var wanted: Variant = found[3] if found[2] else _unset_value(node, name)
1125
1239
  var held: Variant = node.get(name)
1126
- node.set(name, wanted)
1240
+ GdhHeadlessCheck.restore(node, name, wanted)
1127
1241
  if GdhHeadlessCheck.kept_by_engine(node, name, wanted, held):
1128
1242
  _engine_kept["%d:%s" % [node.get_instance_id(), name]] = true
1129
1243
  for found: Array in moved:
@@ -1131,7 +1245,9 @@ func _settled(packed: PackedScene) -> Array:
1131
1245
  var before: Dictionary = _before[node.get_instance_id()]
1132
1246
  var restored: Dictionary = restored_by_write.get_or_add(node.get_instance_id(), {})
1133
1247
  for name: String in found[1]:
1134
- node.set(name, before.get(name, GdhHeadlessCheck.default_of(node, name)))
1248
+ GdhHeadlessCheck.restore(
1249
+ node, name, before.get(name, GdhHeadlessCheck.default_of(node, name))
1250
+ )
1135
1251
  restored[name] = true
1136
1252
  current = PackedScene.new()
1137
1253
  var packing: Error = current.pack(root)
@@ -1197,6 +1313,11 @@ func _strays(state: SceneState) -> Array[Array]:
1197
1313
  continue
1198
1314
  if not held and GdhHeadlessCheck.at_default(node, name):
1199
1315
  continue
1316
+ # Set in the file to what the node would have anyway, such as an instance naming the
1317
+ # script its own scene gives it. The pack leaves it out, as the editor's does, and the
1318
+ # node loads the same.
1319
+ if held and not stored.has(name) and GdhSame.same(node.get(name), filed[name]):
1320
+ continue
1200
1321
  if _engine_kept.has("%d:%s" % [id, name]):
1201
1322
  continue
1202
1323
  stray.append([node, name, held, filed.get(name)])
@@ -17,6 +17,7 @@ const GdhClassTools = preload("tools/class_tools.gd")
17
17
  const GdhSettingsTools = preload("tools/settings_tools.gd")
18
18
  const GdhFilesystemTools = preload("tools/filesystem_tools.gd")
19
19
  const GdhProfileTools = preload("tools/profile_tools.gd")
20
+ const GdhViewTools = preload("tools/view_tools.gd")
20
21
 
21
22
  var _editor_plugin: EditorPlugin = null
22
23
  var _scene_tools: GdhSceneTools = null
@@ -31,6 +32,7 @@ var _class_tools: GdhClassTools = null
31
32
  var _settings_tools: GdhSettingsTools = null
32
33
  var _filesystem_tools: GdhFilesystemTools = null
33
34
  var _profile_tools: GdhProfileTools = null
35
+ var _view_tools: GdhViewTools = null
34
36
  var _tool_map: Dictionary = {}
35
37
  var _initialized: bool = false
36
38
 
@@ -48,6 +50,7 @@ func set_editor_plugin(plugin: EditorPlugin) -> void:
48
50
  _class_tools.set_editor_plugin(plugin)
49
51
  _filesystem_tools.set_editor_plugin(plugin)
50
52
  _profile_tools.set_editor_plugin(plugin)
53
+ _view_tools.set_editor_plugin(plugin)
51
54
 
52
55
 
53
56
  func _init_tools() -> void:
@@ -103,6 +106,10 @@ func _init_tools() -> void:
103
106
  _profile_tools.name = "ProfileTools"
104
107
  add_child(_profile_tools)
105
108
 
109
+ _view_tools = GdhViewTools.new()
110
+ _view_tools.name = "ViewTools"
111
+ add_child(_view_tools)
112
+
106
113
  _tool_map = {
107
114
  # Scene tools
108
115
  "create_scene": [_scene_tools, "create_scene"],
@@ -111,6 +118,7 @@ func _init_tools() -> void:
111
118
  "delete_node": [_scene_tools, "delete_node"],
112
119
  "duplicate_node": [_scene_tools, "duplicate_node"],
113
120
  "reparent_node": [_scene_tools, "reparent_node"],
121
+ "save_branch": [_scene_tools, "save_branch"],
114
122
  "set_node_properties": [_scene_tools, "set_node_properties"],
115
123
  "get_node_properties": [_scene_tools, "get_node_properties"],
116
124
  "bake_navigation": [_scene_tools, "bake_navigation"],
@@ -148,6 +156,7 @@ func _init_tools() -> void:
148
156
  # The script profiler on a game the editor plays
149
157
  "profile_start": [_profile_tools, "profile_start"],
150
158
  "profile_read": [_profile_tools, "profile_read"],
159
+ "capture_editor_view": [_view_tools, "capture_editor_view"],
151
160
  # Animation tools
152
161
  "read_animations": [_animation_tools, "read_animations"],
153
162
  "read_animation_states": [_animation_tools, "read_animation_states"],
@@ -164,6 +173,11 @@ func _init_tools() -> void:
164
173
  }
165
174
 
166
175
 
176
+ ## Whether a capture is waiting for the editor to draw, which another call must not move it from.
177
+ func capturing() -> bool:
178
+ return _view_tools != null and _view_tools.capturing
179
+
180
+
167
181
  func execute_tool(tool_name: String, args: Dictionary) -> Dictionary:
168
182
  if not _tool_map.has(tool_name):
169
183
  return { "ok": false, "error": "Unknown tool: " + tool_name }
@@ -177,7 +191,8 @@ func execute_tool(tool_name: String, args: Dictionary) -> Dictionary:
177
191
 
178
192
  GdhSceneFile.also_saved.clear()
179
193
  GdhSceneFile.unmade_note = ""
180
- var result: Variant = node.call(method, args)
194
+ # Awaited for the tools that wait for the editor to draw. The rest answer at once.
195
+ var result: Variant = await node.call(method, args)
181
196
  if result is Dictionary:
182
197
  var answered: Dictionary = result
183
198
  if not GdhSceneFile.also_saved.is_empty():
@@ -21,6 +21,8 @@ extends Node
21
21
  ## A file the scan skipped is either missing from the tree or recorded with no class name against
22
22
  ## it, and both come back as a class the editor does not have, which is the whole of the question.
23
23
 
24
+ const GdhSceneFile = preload("../scene_file.gd")
25
+
24
26
  var _editor_plugin: EditorPlugin = null
25
27
 
26
28
 
@@ -282,7 +284,7 @@ func global_classes(_args: Dictionary) -> Dictionary:
282
284
  return {
283
285
  "ok": true,
284
286
  "classes": names,
285
- "scanning": filesystem.is_scanning(),
287
+ "scanning": GdhSceneFile.scan_unfinished(filesystem),
286
288
  "importing": filesystem.is_importing(),
287
289
  }
288
290