gdharness 1.3.37 → 1.3.38

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.
package/build/cli.js CHANGED
@@ -16657,7 +16657,7 @@ var init_tool_definitions = __esm(() => {
16657
16657
  direction: {
16658
16658
  type: "string",
16659
16659
  enum: ["forward", "reverse"],
16660
- description: "forward: what this resource loads, with each reference closing a cycle marked circular and the cycle under circular_references, a script's dependencies including every script whose class_name it uses, marked by_class with the name, and the script of every autoload it names, marked by_autoload with the autoload's name. An ext_resource whose uid names another file than its path is followed to that file, as the engine loads it, with the path written under stale_path, and a shader's #include is followed as the preprocessor reads it, relative to the shader. A file the depth stops at carries beyond_depth, how many files it names that were not walked. summary.total_resources is the file asked about, so 1; total_dependencies counts each file reached, directly or through another, once however many places load it; total_references counts the entries in the tree, and missing names every file referenced that does not exist, with the files referring to it. reverse: every file that refers to it and how, for a file that is gone as well while anything still refers to it, answered with exists: false (a missing file nothing refers to is refused, naming the nearest files), a scene instancing it, a script extending, preloading or loading it, and for a script with a class_name every use of that name, and for an autoload's script every use of the autoload's name, with autoload naming it. Each reference carries a kind: ext_resource, extends, preload, load, include or path for a reference by path, with stale_path when an ext_resource reaches it by its uid under another path; setting, plugin, export_preset and import for one in project.godot, a plugin.cfg, export_presets.cfg or an import sidecar; class_name for a use of the declared name, the declaration itself being the script asked about; autoload for code naming the autoload. Three kinds are not uses: doc is a name inside a ## documentation comment, which renaming would break in the generated docs; comment is one in ordinary prose, which it would only make stale; and stale_path is an ext_resource naming it by a path the engine does not load, since its uid leads to the file under loads. summary.in_code is the count with those left out, which is the number to read when the question is whether anything still uses this. Default forward."
16660
+ description: "forward: what this resource loads, with each reference closing a cycle marked circular and the cycle under circular_references, a script's dependencies including every script whose class_name it uses, marked by_class with the name, and the script of every autoload it names, marked by_autoload with the autoload's name. An ext_resource whose uid names another file than its path is followed to that file, as the engine loads it, with the path written under stale_path; one whose uid names no file the project has is followed by its path, as the engine does after warning, with the uid under unknown_uid; and a load or preload of a uid the project does not know is listed under that uid as missing. A shader's #include is followed as the preprocessor reads it, relative to the shader. A file the depth stops at carries beyond_depth, how many files it names that were not walked. summary.total_resources is the file asked about, so 1; total_dependencies counts each file reached, directly or through another, once however many places load it; total_references counts the entries in the tree, and missing names every file referenced that does not exist, with the files referring to it. reverse: every file that refers to it and how, for a file that is gone as well while anything still refers to it, answered with exists: false (a missing file nothing refers to is refused, naming the nearest files), a scene instancing it, a script extending, preloading or loading it, and for a script with a class_name every use of that name, and for an autoload's script every use of the autoload's name, with autoload naming it. Each reference carries a kind: ext_resource, extends, preload, load, include or path for a reference by path, with stale_path when an ext_resource reaches it by its uid under another path, and unknown_uid when one names it by path beside a uid naming no file; setting, plugin, export_preset and import for one in project.godot, a plugin.cfg, export_presets.cfg or an import sidecar; class_name for a use of the declared name, the declaration itself being the script asked about; autoload for code naming the autoload. Three kinds are not uses: doc is a name inside a ## documentation comment, which renaming would break in the generated docs; comment is one in ordinary prose, which it would only make stale; and stale_path is an ext_resource naming it by a path the engine does not load, since its uid leads to the file under loads. summary.in_code is the count with those left out, which is the number to read when the question is whether anything still uses this. Default forward."
16661
16661
  },
16662
16662
  depth: { type: "number", description: "forward: how many levels to follow. Default unlimited." },
16663
16663
  includeBuiltin: {
@@ -16676,7 +16676,7 @@ var init_tool_definitions = __esm(() => {
16676
16676
  name: "project_import",
16677
16677
  title: "Asset imports",
16678
16678
  description: "The import pipeline: what needs importing, how a resource is imported, reimports, UIDs, and the global class list the editor and the engine read.",
16679
- answer: "reimport is answered from a second status read: reimported lists what is current now, notReimported what is not, one entry per status and reason with the paths sharing them, and via says which of the two did the work. importedByTheScan names what the editor's scan imported first, and alsoImported, on a reimport of one resourcePath, the others it imported. wroteInProject names, by source, what an import wrote into the project itself rather than under .godot, such as the .translation a CSV gives each language, which version control sees and internationalization/locale/translations names. When nothing needed reimporting, the two lists are empty, a note says so, and there is no via. A whole-project reimport also answers missingSource, the .import files whose source is not beside them, empty when there are none, even when nothing needed reimporting; one resourcePath leaves it out. stillImporting is an editor that had not finished within timeoutMs.",
16679
+ answer: "reimport is answered from a second status read: reimported lists what is current now, notReimported what is not, one entry per status and reason with the paths sharing them, and via says which of the two did the work. importedByTheScan names what the editor's scan imported first, and alsoImported, on a reimport of one resourcePath, the others it imported. wroteInProject names, by source, what an import wrote into the project itself rather than under .godot, such as the .translation a CSV gives each language, which version control sees and internationalization/locale/translations names. The import gives every script without a .uid sidecar one, as the editor's scan does, and uidsWritten names those scripts, counted per addon under uidsWrittenInAddons for the ones inside an addon. When nothing needed reimporting, the two lists are empty, a note says so, and there is no via. A whole-project reimport also answers missingSource, the .import files whose source is not beside them, empty when there are none, even when nothing needed reimporting; one resourcePath leaves it out. stillImporting is an editor that had not finished within timeoutMs.",
16680
16680
  parameters: {
16681
16681
  projectPath: PROJECT_PATH,
16682
16682
  resourcePath: { ...RESOURCE_PATH, ops: ["status", "options", "set_options", "uid", "reimport"] },
@@ -17343,7 +17343,7 @@ var init_tool_definitions = __esm(() => {
17343
17343
  operations: {
17344
17344
  structure: {
17345
17345
  effect: "read",
17346
- summary: "functions, variables (static ones marked is_static; one written with := has the type of a literal or built-in constructor it is given, marked inferred, as a constant always does), signals, constants, enums, inner_classes (each with its name, line and extends, and its own members in these same fields), class_name, extends and line_count, read from the file, and dependencies: the paths it loads or preloads as written, then the scripts declaring each project class_name its code uses, which classes_used names with the class; autoloads_used names each autoload the code uses by its global name, with the autoload's script, which the project loads rather than this script, so it is not under dependencies. A member or inner class with a ## documentation comment directly above it carries the comment's text as doc, and the script's own, the ## block above class_name or extends or one followed by a blank line before any member, comes back as class_doc",
17346
+ summary: `functions (each parameter's default the source text after its =, so = null reads "null", and null for a parameter with none), variables (static ones marked is_static; one written with := has the type of a literal or built-in constructor it is given, marked inferred, as a constant always does), signals, constants, enums, inner_classes (each with its name, line and extends, and its own members in these same fields), class_name, extends and line_count, read from the file, and dependencies: the paths it loads or preloads as written, then the scripts declaring each project class_name its code uses, which classes_used names with the class; autoloads_used names each autoload the code uses by its global name, with the autoload's script, which the project loads rather than this script, so it is not under dependencies. A member or inner class with a ## documentation comment directly above it carries the comment's text as doc, and the script's own, the ## block above class_name or extends or one followed by a blank line before any member, comes back as class_doc`,
17347
17347
  requires: []
17348
17348
  },
17349
17349
  symbols: {
@@ -18077,7 +18077,7 @@ var init_tool_definitions = __esm(() => {
18077
18077
  name: "runtime_input",
18078
18078
  title: "Send input to the game",
18079
18079
  description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button, mouse motion or drag. All of it works headless, where the viewport is the project's window size and the GUI only takes what is inside it. On gdharness's own desktop, where a windowed run goes, Windows refuses to say where the pointer is: every pointer answer there carries pointer_on_hidden_desktop, and the first of the run pointer_note saying why it matters: get_mouse_position() on the root window, and the mouse positions derived from it, read a meaningless position, while what reads the event itself (the control under it, hover, tooltips, input handlers) gets the injected position. A native window no real pointer has entered gets no hover from Godot, so the runtime supplies it there and the answers say hover_supplied.",
18080
- answer: `click: what was under the pointer, and what became of the control: in_tree, removed or freed. A button answers pressed_signal, whether its pressed signal fired during the click. Named by says, found says which match it was, its index being the index argument that presses the same match again, found.item names the tab, list entry, tree row or menu bar title pressed (kind; index, the item's own number in its control, which is not found.index; says; and for a row the column of the cell saying the words), and found.pressedOn says "the words" for words in a RichTextLabel pressed where they are drawn, or "the middle of the label" with a note saying why they could not be placed; on a rich label link_pressed says whether the press reached a link, with link_meta giving the meta the label reports clicked, an empty one included. A control scrolled into view first is scrolled each container out from it a frame after the one inside, and scrolled_into_view says whether a scroll value changed. A button in toggle mode, a CheckBox or CheckButton included, answers pressed with before and after, its button_pressed either side of the click: the same value twice is a click the game undid, and after is null when the click freed it. A refusal for a pointer turned off names where mouse_behavior_recursive was set. On a 3D node landed says the interface did not swallow the press: no control under the pointer stopped it, a control letting the pointer pass included, and every viewport on the way out from the room had the pointer over what shows the room. choose: what was chosen, read before the press, which is heard rather than assumed; what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick); and what the button in front of the menu shows now when it is still there. A menu is opened rather than clicked because its items are drawn rather than built, and the item is looked for as the menu stands once open, since a game can fill a menu as it opens. action, key, mouse_click and joypad_button: the event as it was sent, with whole saying whether the press was let go of again and pressed whether it is left held down (true only after pressed true), for a key the keycode, physical_keycode and unicode it carried, and for a controller button its number, its name and the device. mouse_motion: the position and the relative movement sent. mouse_drag: from, to, motions sent and relative_total. joypad_motion: the axis by number and name, the value and the device. key, mouse_click, click, joypad_button and joypad_motion answer actions, the InputMap actions the event also presses; for a motion, those its direction reaches past their deadzone, and none for one inside it. mouse_click, mouse_motion and mouse_drag answer hovered, the control under the pointer once the events are handled, or null over none; the other events are aimed at nothing. mouse_click with a press, and mouse_drag, answer press_went_to, the path of what the press went to (a control, or a window embedded over the point), read before it, or null when nothing there takes the pointer; given nodePath naming a Control, landed says whether that is the control or inside it. A click that did not land on a control whose mouse_filter is ignore, as a Label's is, says so under why_not_landed, and one on a control with something drawn over it names that, a window stacked above the one the control is in among them. An action, a key, a joypad_button or a joypad_motion answers focused with the path of the Control holding the keyboard focus when it went in, since that Control gets it before the game's _unhandled_input: Space or Enter on a focused button presses the button, and a stick or dpad pressing ui_down moves a focused list's selection, or the focus, and never reaches the game's handler. Where the press moved the focus, focused_after names the Control holding it once the event was handled, or null when nothing does. A mouse_click, mouse_motion or mouse_drag given nodePath answers it under path, its points in the pixels of the window they went to, and that window under window when it is not the game's own. One with a point outside the viewport it went to says so under outside_viewport, with the viewport's rectangle, since no control there receives it.`,
18080
+ answer: `click: what was under the pointer, and what became of the control: in_tree, removed or freed. A button answers pressed_signal, whether its pressed signal fired during the click. Named by says, found says which match it was, its index being the index argument that presses the same match again, found.item names the tab, list entry, tree row or menu bar title pressed (kind; index, the item's own number in its control, which is not found.index; says; and for a row the column of the cell saying the words), and found.pressedOn says "the words" for words in a RichTextLabel pressed where they are drawn, or "the middle of the label" with a note saying why they could not be placed; on a rich label link_pressed says whether the press reached a link, with link_meta giving the meta the label reports clicked, an empty one included. A control scrolled into view first is scrolled each container out from it a frame after the one inside, and scrolled_into_view says whether a scroll value changed. A button in toggle mode, a CheckBox or CheckButton included, answers pressed with before and after, its button_pressed either side of the click: the same value twice is a click the game undid, and after is null when the click freed it. A refusal for a pointer turned off names where mouse_behavior_recursive was set. On a 3D node landed says the interface did not swallow the press: no control under the pointer stopped it, a control letting the pointer pass included, and every viewport on the way out from the room had the pointer over what shows the room. choose: what was chosen, read before the press, which is heard rather than assumed; what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick); and what the button in front of the menu shows now when it is still there. A menu is opened rather than clicked because its items are drawn rather than built, and the item is looked for as the menu stands once open, since a game can fill a menu as it opens. action, key, mouse_click and joypad_button: the event as it was sent, with whole saying whether the press was let go of again and pressed whether it is left held down (true only after pressed true), for a key the keycode, physical_keycode and unicode it carried, and for a controller button its number, its name and the device. mouse_motion: the position and the relative movement sent. mouse_drag: from, to, motions sent and relative_total. joypad_motion: the axis by number and name, the value and the device. key, mouse_click, click, joypad_button and joypad_motion answer actions, the InputMap actions the event also presses; for a motion, those its direction reaches past their deadzone, and none for one inside it. mouse_click, mouse_motion and mouse_drag answer hovered, the control under the pointer once the events are handled, or null over none; the other events are aimed at nothing. mouse_click with a press, and mouse_drag, answer press_went_to, the path of what the press went to (a control, or a window embedded over the point), read before it, or null when nothing there takes the pointer; given nodePath naming a Control, landed says whether that is the control or inside it. A click that did not land on a control whose mouse_filter is ignore, as a Label's is, says so under why_not_landed, and one on a control with something drawn over it names that, a window stacked above the one the control is in among them. An action, a key, a joypad_button or a joypad_motion answers focused with the path of the Control holding the keyboard focus when it went in, since that Control gets it before the game's _unhandled_input: Space or Enter on a focused button presses the button, and a stick or dpad pressing ui_down moves a focused list's selection, or the focus, and never reaches the game's handler. Where the press moved the focus, focused_after names the Control holding it once the event was handled, or null when nothing does. A key that a focused LineEdit or TextEdit took as typing names that field under typed_into, with what it holds under holds, since a field being edited keeps the key from the game's _unhandled_input. editing_ended true, on a key or on text, is a LineEdit that stopped being edited, as Enter does by submitting it: it keeps the focus, and nothing typed into it lands until it is clicked again. text with the focus on a control that is no LineEdit or TextEdit answers holds null and a note saying so, since a focused button takes a space or Enter as a press. A mouse_click, mouse_motion or mouse_drag given nodePath answers it under path, its points in the pixels of the window they went to, and that window under window when it is not the game's own. One with a point outside the viewport it went to says so under outside_viewport, with the viewport's rectangle, since no control there receives it.`,
18081
18081
  parameters: {
18082
18082
  projectPath: RUNNING_PROJECT_PATH,
18083
18083
  pid: RUNNING_PID,
@@ -51932,8 +51932,18 @@ class GodotServer {
51932
51932
  if (!contained.ok) {
51933
51933
  return contained.response;
51934
51934
  }
51935
+ const unminted = scriptsWithoutUid(project.value.path);
51935
51936
  const outcome = await this.reimport(project.value.path, readNonEmptyString(contained.value, "resourcePath"), args["force"] === true, readPositiveNumber(args, "timeoutMs") ?? REIMPORT_TIMEOUT_MS);
51936
- return outcome.ok ? this.jsonTextResponse(outcome.payload) : outcome.response;
51937
+ if (!outcome.ok) {
51938
+ return outcome.response;
51939
+ }
51940
+ const stillUnminted = scriptsWithoutUid(project.value.path);
51941
+ const uids = withAddonPathsCounted(unminted.filter((script) => !stillUnminted.includes(script)).map((script) => `res://${script}`));
51942
+ return this.jsonTextResponse({
51943
+ ...outcome.payload,
51944
+ ...uids.own.length === 0 ? {} : { uidsWritten: uids.own },
51945
+ ...Object.keys(uids.inAddons).length === 0 ? {} : { uidsWrittenInAddons: uids.inAddons }
51946
+ });
51937
51947
  }
51938
51948
  editorHasOpen(projectPath) {
51939
51949
  const open = this.godotBridge.getStatus().projectPath;
@@ -296,6 +296,7 @@ static func _misfit(source: Node, signal_name: String, callable: Callable, flags
296
296
  if flags & Object.CONNECT_APPEND_SOURCE_OBJECT:
297
297
  passed.append({"name": "source", "type": TYPE_OBJECT, "class_name": source.get_class()})
298
298
  passed = passed.slice(0, maxi(passed.size() - callable.get_unbound_arguments_count(), 0))
299
+ var from_signal: int = passed.size()
299
300
  for bound: Variant in callable.get_bound_arguments():
300
301
  var bound_class: String = ""
301
302
  if bound is Object:
@@ -310,7 +311,25 @@ static func _misfit(source: Node, signal_name: String, callable: Callable, flags
310
311
  return "%s passes %s, and %s needs %d%s" % [signal_name, _count(passed.size()), method, needed, FAILS]
311
312
  if passed.size() > parameters.size():
312
313
  var most: String = "none" if parameters.is_empty() else "at most %d" % parameters.size()
313
- return "%s passes %s, and %s takes %s%s" % [signal_name, _count(passed.size()), method, most, FAILS]
314
+ # Said with the misfit, since unbinds is what a connection to a method wanting less is for, and
315
+ # only when the extra arguments are the signal's: a bound one is not dropped by unbinding.
316
+ var extra: int = passed.size() - parameters.size()
317
+ var remedy: String = (
318
+ (
319
+ " Connected with unbinds %d, the last %s the signal passes %s left out."
320
+ % [
321
+ extra + callable.get_unbound_arguments_count(),
322
+ "argument" if extra == 1 else "%d arguments" % extra,
323
+ "is" if extra == 1 else "are",
324
+ ]
325
+ )
326
+ if extra <= from_signal
327
+ else ""
328
+ )
329
+ return (
330
+ "%s passes %s, and %s takes %s%s%s"
331
+ % [signal_name, _count(passed.size()), method, most, FAILS, remedy]
332
+ )
314
333
  for at: int in passed.size():
315
334
  var given: Dictionary = passed[at]
316
335
  var parameter: Dictionary = parameters[at]
@@ -16,6 +16,8 @@ const GdhItems = preload("runtime_items.gd")
16
16
  const GdhScreen = preload("runtime_screen.gd")
17
17
  const GdhTargets = preload("runtime_targets.gd")
18
18
  const GdhMouse = preload("runtime_mouse.gd")
19
+ const GdhTyping = preload("runtime_typing.gd")
20
+ const GdhSays = preload("runtime_says.gd")
19
21
 
20
22
  ## Set, to the desktop's name, for a process gdharness started on a desktop of its own and for
21
23
  ## everything that process starts. Kept in step with `DESKTOP_VARIABLE` in src/desktop.ts.
@@ -276,6 +278,8 @@ func inject_key(params: Dictionary) -> Dictionary:
276
278
  event.unicode = GdhValues.glyph_of(event.keycode, event.shift_pressed)
277
279
 
278
280
  var focused: String = GdhValues.focus_path(_host)
281
+ var field: Control = GdhTyping.focus_owner(_host.get_tree().root)
282
+ var before: Dictionary = GdhTyping.field_before(field)
279
283
  Input.parse_input_event(event)
280
284
  var lasted: Variant = null
281
285
  if whole:
@@ -300,7 +304,7 @@ func inject_key(params: Dictionary) -> Dictionary:
300
304
  "pressed": held and not whole,
301
305
  "whole": whole,
302
306
  }
303
- return GdhValues.focus_noted(answer, focused, _host)
307
+ return GdhTyping.field_noted(GdhValues.focus_noted(answer, focused, _host), field, before)
304
308
 
305
309
 
306
310
  ## Clicks a mouse button at a position, and lets go of it again unless asked to hold it.
@@ -601,7 +605,7 @@ func click(params: Dictionary) -> Dictionary:
601
605
  item_scrolled = placed_item["scrolled"]
602
606
  var rich: RichTextLabel = control as RichTextLabel
603
607
  if found != null and rich != null and named_point == null:
604
- var placed: Dictionary = GdhTargets.point_on_words(rich, wanted)
608
+ var placed: Dictionary = GdhSays.point_on_words(rich, wanted)
605
609
  var found_here: Dictionary = found
606
610
  if placed.has("point"):
607
611
  aim = placed["point"]
@@ -170,3 +170,57 @@ static func widened(wanted: String) -> String:
170
170
  ## an n, so the two characters mean the break to everybody who writes them.
171
171
  static func as_said(wanted: String) -> String:
172
172
  return spaced(wanted.replace("\\n", "\n"))
173
+
174
+
175
+ ## Where [param label] draws the words [param wanted] matched in it, in the label's own space, when
176
+ ## they are part of its text: {"point"} on their middle, {"unplaced"} saying why there is none, or
177
+ ## {} when the words are the whole text, whose middle is the label's.
178
+ ##
179
+ ## A line of a dispatch names two people, each a link to their page, and a press on the label's
180
+ ## middle landed on the second name when the first was asked for, so the game opened the wrong page.
181
+ ## Godot answers which line a character is on, where that line is and how wide it is drawn, and
182
+ ## nothing narrower, so the place along the line is the font's measure of the text before the
183
+ ## words. A line in another font or holding an image measures off from what is drawn, which a wide
184
+ ## line with nothing between the words and its start hardly does.
185
+ static func point_on_words(label: RichTextLabel, wanted: String) -> Dictionary:
186
+ var text: String = spaced(GdhWords.said_by(label))
187
+ var start: int = -1
188
+ var length: int = 0
189
+ for words: String in alternatives(wanted):
190
+ if not is_plain(words):
191
+ continue
192
+ var at: int = text.findn(words)
193
+ if at >= 0 and (start < 0 or at < start):
194
+ start = at
195
+ length = words.length()
196
+ if start < 0:
197
+ return {"unplaced": "the words were matched as a pattern, which names no one place in the text"}
198
+ if text.strip_edges().length() == length:
199
+ return {}
200
+ var line: int = label.get_character_line(start)
201
+ var shown: Vector2i = label.get_line_range(line)
202
+ if line < 0 or shown.x > start:
203
+ return {"unplaced": "the label could not say which line the words are on"}
204
+ var on_line: int = mini(length, shown.y - start) if shown.y > start else length
205
+ var font: Font = label.get_theme_font("normal_font")
206
+ var font_size: int = label.get_theme_font_size("normal_font_size")
207
+ var before: float = _width(font, font_size, text.substr(shown.x, start - shown.x))
208
+ var across: float = _width(font, font_size, text.substr(start, on_line))
209
+ var frame: StyleBox = label.get_theme_stylebox("normal")
210
+ var left: float = frame.get_margin(SIDE_LEFT)
211
+ var room: float = label.size.x - left - frame.get_margin(SIDE_RIGHT)
212
+ var spare: float = maxf(room - label.get_line_width(line), 0.0)
213
+ var aligned: float = 0.0
214
+ if label.horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER:
215
+ aligned = spare * 0.5
216
+ elif label.horizontal_alignment == HORIZONTAL_ALIGNMENT_RIGHT:
217
+ aligned = spare
218
+ var scrolled: float = label.get_v_scroll_bar().value if label.scroll_active else 0.0
219
+ var x: float = clampf(left + aligned + before + across * 0.5, left, left + room)
220
+ var top: float = frame.get_margin(SIDE_TOP) + label.get_line_offset(line) - scrolled
221
+ return {"point": Vector2(x, top + label.get_line_height(line) * 0.5)}
222
+
223
+
224
+ ## How wide [param font] at [param font_size] draws [param words] on one line.
225
+ static func _width(font: Font, font_size: int, words: String) -> float:
226
+ return font.get_string_size(words, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x
@@ -12,6 +12,8 @@ const GdhWords = preload("runtime_words.gd")
12
12
 
13
13
  ## How many of the controls saying the same words a refusal names, with the index for each.
14
14
  const LISTED_CANDIDATES: int = 10
15
+ ## How many hidden controls saying the words a refusal names.
16
+ const NAMED_HIDDEN: int = 3
15
17
 
16
18
  ## Node.ProcessMode as its names, for a refusal to say which one is in force.
17
19
  const PROCESS_MODES: Dictionary[int, String] = {
@@ -63,7 +65,7 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
63
65
  var exact: Array[bool] = []
64
66
  # Whether the words are only inside longer words there, "out" in "Scout".
65
67
  var inside: Array[bool] = []
66
- var hidden: int = 0
68
+ var hidden: Array[Node] = []
67
69
  # Controls saying exactly the words that a click cannot reach, kept to say what a match on screen
68
70
  # that only says them as part of more stands in for.
69
71
  var exact_out_of_reach: Array[Node] = []
@@ -77,7 +79,7 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
77
79
  var node: Node = pending.pop_front()
78
80
  if node is Control and GdhSays.says(node, wanted):
79
81
  if not GdhQueries.shown(node):
80
- hidden += 1
82
+ hidden.append(node)
81
83
  if _says_exactly(GdhWords.said_by(node), wanted):
82
84
  exact_out_of_reach.append(node)
83
85
  out_of_reach_as.append("hidden")
@@ -451,10 +453,21 @@ static func _offered_note(from: Node, drawn: Array[Node], areas: Array[Rect2]) -
451
453
  return "" if words.is_empty() else "; the buttons on screen say %s" % words
452
454
 
453
455
 
454
- static func _hidden_note(hidden: int) -> String:
455
- if hidden == 0:
456
+ ## What to add to a refusal that found nothing on screen, when words matched hidden controls: how
457
+ ## many, and the first few by path with what hides each. A count alone left a caller with nothing to
458
+ ## show, since the control saying the words is rarely the node a game hides.
459
+ static func _hidden_note(hidden: Array[Node]) -> String:
460
+ if hidden.is_empty():
456
461
  return ""
457
- return "; %d hidden control%s" % [hidden, " says it" if hidden == 1 else "s say it"]
462
+ var named: Array[String] = []
463
+ for node: Node in hidden.slice(0, NAMED_HIDDEN):
464
+ named.append("%s%s" % [node.get_path(), why_hidden(node)])
465
+ if hidden.size() == 1:
466
+ return "; 1 hidden control says it, %s" % named[0]
467
+ return (
468
+ "; %d hidden controls say it, among them %s and %s"
469
+ % [hidden.size(), ", ".join(named.slice(0, -1)), named[-1]]
470
+ )
458
471
 
459
472
 
460
473
  ## What to add to a refusal that found nothing on screen, when words matched controls a click cannot
@@ -870,60 +883,6 @@ static func _clipped_away(
870
883
  return false
871
884
 
872
885
 
873
- ## Where [param label] draws the words [param wanted] matched in it, in the label's own space, when
874
- ## they are part of its text: {"point"} on their middle, {"unplaced"} saying why there is none, or
875
- ## {} when the words are the whole text, whose middle is the label's.
876
- ##
877
- ## A line of a dispatch names two people, each a link to their page, and a press on the label's
878
- ## middle landed on the second name when the first was asked for, so the game opened the wrong page.
879
- ## Godot answers which line a character is on, where that line is and how wide it is drawn, and
880
- ## nothing narrower, so the place along the line is the font's measure of the text before the
881
- ## words. A line in another font or holding an image measures off from what is drawn, which a wide
882
- ## line with nothing between the words and its start hardly does.
883
- static func point_on_words(label: RichTextLabel, wanted: String) -> Dictionary:
884
- var text: String = GdhSays.spaced(GdhWords.said_by(label))
885
- var start: int = -1
886
- var length: int = 0
887
- for words: String in GdhSays.alternatives(wanted):
888
- if not GdhSays.is_plain(words):
889
- continue
890
- var at: int = text.findn(words)
891
- if at >= 0 and (start < 0 or at < start):
892
- start = at
893
- length = words.length()
894
- if start < 0:
895
- return {"unplaced": "the words were matched as a pattern, which names no one place in the text"}
896
- if text.strip_edges().length() == length:
897
- return {}
898
- var line: int = label.get_character_line(start)
899
- var shown: Vector2i = label.get_line_range(line)
900
- if line < 0 or shown.x > start:
901
- return {"unplaced": "the label could not say which line the words are on"}
902
- var on_line: int = mini(length, shown.y - start) if shown.y > start else length
903
- var font: Font = label.get_theme_font("normal_font")
904
- var font_size: int = label.get_theme_font_size("normal_font_size")
905
- var before: float = _width(font, font_size, text.substr(shown.x, start - shown.x))
906
- var across: float = _width(font, font_size, text.substr(start, on_line))
907
- var frame: StyleBox = label.get_theme_stylebox("normal")
908
- var left: float = frame.get_margin(SIDE_LEFT)
909
- var room: float = label.size.x - left - frame.get_margin(SIDE_RIGHT)
910
- var spare: float = maxf(room - label.get_line_width(line), 0.0)
911
- var aligned: float = 0.0
912
- if label.horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER:
913
- aligned = spare * 0.5
914
- elif label.horizontal_alignment == HORIZONTAL_ALIGNMENT_RIGHT:
915
- aligned = spare
916
- var scrolled: float = label.get_v_scroll_bar().value if label.scroll_active else 0.0
917
- var x: float = clampf(left + aligned + before + across * 0.5, left, left + room)
918
- var top: float = frame.get_margin(SIDE_TOP) + label.get_line_offset(line) - scrolled
919
- return {"point": Vector2(x, top + label.get_line_height(line) * 0.5)}
920
-
921
-
922
- ## How wide [param font] at [param font_size] draws [param words] on one line.
923
- static func _width(font: Font, font_size: int, words: String) -> float:
924
- return font.get_string_size(words, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x
925
-
926
-
927
886
  ## The canvas layer [param control] is drawn on, 0 for the viewport's own canvas.
928
887
  static func _layer_of(control: Control) -> int:
929
888
  var layer: CanvasLayer = control.get_canvas_layer_node()
@@ -59,6 +59,7 @@ func inject_text(params: Dictionary) -> Dictionary:
59
59
  if focused != null:
60
60
  into = str(focused.get_path())
61
61
 
62
+ var before: Dictionary = field_before(focused)
62
63
  var replaced: bool = over and _select_everything(focused)
63
64
  # Emptying a field is a real thing to ask for and the one shape of filling one in that types no
64
65
  # characters. The selection is standing, so the key a player presses over one is what clears it.
@@ -70,7 +71,7 @@ func inject_text(params: Dictionary) -> Dictionary:
70
71
  var holds: Variant = null
71
72
  if is_instance_valid(focused):
72
73
  holds = _what_it_holds(focused)
73
- return {
74
+ var answer: Dictionary = {
74
75
  "type": "input_injected",
75
76
  "input_type": "text",
76
77
  "text": text,
@@ -79,6 +80,61 @@ func inject_text(params: Dictionary) -> Dictionary:
79
80
  "replaced": replaced,
80
81
  "holds": holds,
81
82
  }
83
+ if ended_editing(focused, before):
84
+ answer["editing_ended"] = true
85
+ # A control drawing its own field takes characters in its gui_input, so typing at one is not
86
+ # refused; but a focused button answered that the text went into it, which read as typed.
87
+ if into != null and before.is_empty():
88
+ answer["note"] = (
89
+ (
90
+ "%s has the focus and is no LineEdit or TextEdit, so each character reached it as a key"
91
+ + " press and went on to the game's input unless it kept it, and a focused button takes a"
92
+ + " space or Enter as a press of it. Click a field first to type into one."
93
+ )
94
+ % into
95
+ )
96
+ return answer
97
+
98
+
99
+ ## What [param focused] says and whether it is being edited, read before keys go in, for
100
+ ## [method field_noted] and [method ended_editing] to compare with; empty where the focus is no
101
+ ## field. A [TextEdit] has no editing state of its own and counts as always edited.
102
+ static func field_before(focused: Control) -> Dictionary:
103
+ var field: LineEdit = focused as LineEdit
104
+ if field != null:
105
+ return {"text": field.text, "editing": field.is_editing()}
106
+ var box: TextEdit = focused as TextEdit
107
+ if box != null:
108
+ return {"text": box.text, "editing": true}
109
+ return {}
110
+
111
+
112
+ ## Whether [param focused], read as [param before], stopped being edited, as a [LineEdit] does when
113
+ ## it is submitted: it keeps the focus and drops every key after, so a caller reading only the focus
114
+ ## typed next into nothing.
115
+ static func ended_editing(focused: Control, before: Dictionary) -> bool:
116
+ var was: bool = before.get("editing", false)
117
+ if not was or not is_instance_valid(focused):
118
+ return false
119
+ var field: LineEdit = focused as LineEdit
120
+ return field != null and not field.is_editing()
121
+
122
+
123
+ ## [param said] with what a key did to [param focused], the field read before it as [param before]:
124
+ ## typed_into and holds where its text changed, and editing_ended where it stopped being edited. A
125
+ ## field being edited takes a printable key as typing and keeps it from the game's _unhandled_input,
126
+ ## so Space into a name field pressed no jump, and an answer naming only the focus did not say why.
127
+ static func field_noted(said: Dictionary, focused: Control, before: Dictionary) -> Dictionary:
128
+ if before.is_empty() or not is_instance_valid(focused):
129
+ return said
130
+ var field: LineEdit = focused as LineEdit
131
+ var now: String = field.text if field != null else (focused as TextEdit).text
132
+ if now != before["text"]:
133
+ said["typed_into"] = str(focused.get_path())
134
+ said["holds"] = _what_it_holds(focused)
135
+ if ended_editing(focused, before):
136
+ said["editing_ended"] = true
137
+ return said
82
138
 
83
139
 
84
140
  ## The control keys pushed into [param viewport] go to: its focus, or the focus of the window
@@ -82,6 +82,8 @@ func get_dependencies(params: Dictionary) -> Dictionary:
82
82
  var full_path: String = resource_path
83
83
  if not full_path.begins_with("res://"):
84
84
  full_path = "res://" + full_path
85
+ if DirAccess.dir_exists_absolute(full_path):
86
+ return _log.failure(_folder_refusal(full_path))
85
87
  if not FileAccess.file_exists(full_path):
86
88
  return _log.failure(
87
89
  _files.with_nearest_files("Resource file does not exist: " + full_path, full_path)
@@ -193,6 +195,11 @@ func _loaded_and_missing(script: String) -> Array[String]:
193
195
  named[path] = true
194
196
  var missing: Array[String] = []
195
197
  for path: String in loaded:
198
+ # Left as written by the resolution, which is a uid the project does not know.
199
+ if path.begins_with("uid://"):
200
+ if not named.has(path) and not missing.has(path):
201
+ missing.append(path)
202
+ continue
196
203
  if not path.begins_with("res://") or path.begins_with("res://.") or path.get_extension().is_empty():
197
204
  continue
198
205
  if not named.has(path) and not FileAccess.file_exists(path) and not missing.has(path):
@@ -217,6 +224,15 @@ static func _named_by_settings() -> Array[String]:
217
224
  return named
218
225
 
219
226
 
227
+ ## The refusal for [param path], a folder: a walk follows one file, and a folder was refused as a
228
+ ## file that does not exist, about a folder the caller could see.
229
+ static func _folder_refusal(path: String) -> String:
230
+ return (
231
+ "%s is a folder, and project_dependencies follows one file: name a file in it."
232
+ % path.trim_suffix("/")
233
+ )
234
+
235
+
220
236
  ## One line for [param entry], one of [method missing_references].
221
237
  static func missing_line(entry: Dictionary) -> String:
222
238
  var referring: Array = entry.get("referenced_by", [])
@@ -235,6 +251,8 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
235
251
 
236
252
  if not resource_path.begins_with("res://"):
237
253
  resource_path = "res://" + resource_path
254
+ if DirAccess.dir_exists_absolute(resource_path):
255
+ return _log.failure(_folder_refusal(resource_path))
238
256
  # A file that is gone is searched for all the same, since what still refers to it is what a
239
257
  # caller asks about one, and the forward walk of a scene had just named it missing. Refused only
240
258
  # when nothing refers to it either, which is a path written wrong.
@@ -331,6 +349,11 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
331
349
  kind = _path_reference_kind(line, file_path)
332
350
  if not loaded.is_empty() and written_path != loaded:
333
351
  extra["stale_path"] = written_path
352
+ var unknown_uid: String = (
353
+ _unknown_uid_of(line) if trimmed.begins_with("[ext_resource") else ""
354
+ )
355
+ if not unknown_uid.is_empty():
356
+ extra["unknown_uid"] = unknown_uid
334
357
  elif by_its_autoload:
335
358
  kind = "autoload"
336
359
  else:
@@ -570,6 +593,7 @@ func _analyze_resource(path: String, current_depth: int, walk: GdhDependencyWalk
570
593
  elif path.get_extension() in SHADER_KINDS:
571
594
  content = _shader_code(content)
572
595
  var moved: Dictionary = _moved_by_uid(content)
596
+ var unknown: Dictionary = _unknown_uids(content, path)
573
597
 
574
598
  for pattern: String in REFERENCE_PATTERNS:
575
599
  var regex: RegEx = GdhPatterns.compiled(pattern)
@@ -577,6 +601,12 @@ func _analyze_resource(path: String, current_depth: int, walk: GdhDependencyWalk
577
601
  var written: String = _resolved(_referenced_path(m.get_string()), path)
578
602
  var dep_path: String = str(moved.get(written, written))
579
603
 
604
+ # A uid the project does not know names no file, and loading it fails: passed over as
605
+ # no res:// path, a script's load of one went unsaid while the game printed
606
+ # "Unrecognized UID" at that line.
607
+ if dep_path.begins_with("uid://"):
608
+ _depend(deps, path, dep_path, {}, current_depth, walk)
609
+ continue
580
610
  if not dep_path.begins_with("res://"):
581
611
  continue
582
612
  # A literal naming a directory, or the start of a path a script builds as it runs
@@ -590,14 +620,10 @@ func _analyze_resource(path: String, current_depth: int, walk: GdhDependencyWalk
590
620
  if not walk.include_built_in and dep_path.begins_with("res://."):
591
621
  continue
592
622
 
593
- _depend(
594
- deps,
595
- path,
596
- dep_path,
597
- {"stale_path": written} if dep_path != written else {},
598
- current_depth,
599
- walk
600
- )
623
+ var named_by: Dictionary = {"stale_path": written} if dep_path != written else {}
624
+ if unknown.has(dep_path):
625
+ named_by["unknown_uid"] = unknown[dep_path]
626
+ _depend(deps, path, dep_path, named_by, current_depth, walk)
601
627
 
602
628
  # A script uses another by its class_name as surely as by a preload, and only the reverse walk
603
629
  # looked for that: asked what a script depends on, the answer left out the script it typed a
@@ -633,7 +659,11 @@ func _depend(
633
659
  for existing: Dictionary in deps:
634
660
  if existing.get("path", "") == dep_path:
635
661
  return
636
- var dep_info: Dictionary = {"path": dep_path, "exists": FileAccess.file_exists(dep_path)}
662
+ # A uid still written as one is one the project does not know, and asked about, the engine prints
663
+ # an error for it.
664
+ var dep_info: Dictionary = {
665
+ "path": dep_path, "exists": not dep_path.begins_with("uid://") and FileAccess.file_exists(dep_path)
666
+ }
637
667
  dep_info.merge(named_by)
638
668
  # Marked where the reference closing the cycle is. Marked inside the file reached again, the tree
639
669
  # answered that file as a dependency of itself.
@@ -743,6 +773,25 @@ static func _moved_by_uid(content: String) -> Dictionary:
743
773
  return moved
744
774
 
745
775
 
776
+ # The ext_resource paths in [param content], read from [param from], whose uid names no file the
777
+ # project has, each with that uid. The engine warns that the uid is invalid and loads the path, and
778
+ # the walk followed the path saying nothing, so an export was the first to mention it.
779
+ static func _unknown_uids(content: String, from: String) -> Dictionary:
780
+ var unknown: Dictionary = {}
781
+ for line: RegExMatch in GdhPatterns.compiled("\\[ext_resource[^\\]]*\\]").search_all(content):
782
+ var uid: String = _unknown_uid_of(line.get_string())
783
+ var at: RegExMatch = GdhPatterns.compiled('path="([^"]+)"').search(line.get_string())
784
+ if at != null and not uid.is_empty():
785
+ unknown[_resolved(at.get_string(1), from)] = uid
786
+ return unknown
787
+
788
+
789
+ # The uid an ext_resource [param entry] carries when it names no file the project has, or empty.
790
+ static func _unknown_uid_of(entry: String) -> String:
791
+ var uid: RegExMatch = GdhPatterns.compiled('uid="(uid://[^"]+)"').search(entry)
792
+ return "" if uid == null or not _loaded_by_uid(entry).is_empty() else uid.get_string(1)
793
+
794
+
746
795
  # The file an ext_resource [param entry] is loaded from by its uid, or empty when it carries none
747
796
  # the project knows, and the engine falls back to the path.
748
797
  static func _loaded_by_uid(entry: String) -> String:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.3.37",
3
+ "version": "1.3.38",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",