Split the debug hook into one file per topic (#215)

debugHook.ts had grown to 1097 lines of unrelated test hooks. They
now live in seven files under src/editor/debug/, grouped by topic.
debugHook.ts only assembles them and is down to 86 lines.

- The shape of window.__gm is unchanged for tools/. Measured in the
  browser before and after: 120 top-level keys and 313 leaves with
  their arity, byte-identical JSON.
- The Text import is explicit in the helpers. Without it the name
  fell back to the DOM type and instanceof would never have matched.

Findings:
- The comment for vectorArt sat above pixelArt. It is back in place.
- An orphaned duplicate comment on assetRow is gone.

Tests:
- ./check.sh: 145 files, 2192 tests, CHECK EXIT 0 (unchanged)
- e2e 1-16,37,39-43,47,48: ALL PASSED, 734 checks
- The shape probe turns red against a deliberately misnested hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 10:20:06 +02:00
1 parent b830f9c747
commit ed29b2f3dc
9 files changed
+1114 -1031

No files matched your search

+234
View File
@@ -0,0 +1,234 @@
import type { AssetType } from '../../core/model/AssetTypes';
import { Button, Panel, Widget, activeDrag, currentMenu, ui } from '../../ui';
import { Theme } from '../../ui/Theme';
import { currentFontPicker } from '../font/FontPicker';
import { currentSoundPicker } from '../sound/SoundPicker';
import { rectOf, textProbe } from '../textProbe';
import { currentImagePicker } from '../texture/ImagePicker';
import { Color, type ColorSource, type Container, Text } from 'pixi.js';
import { centreOf, type HookContext } from './hookHelpers';
/** Widgets found by their visible text, the panels, menus, pickers and dialogs around the tabs. */
export function uiHooks({ app, state, editor }: HookContext) {
const { hierarchy, assets, inspector } = editor;
const { eachText, locate, locateAll, locateIcons } = textProbe(app);
/** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */
const textColor = (text: string): string | null => {
let found: string | null = null;
const walk = (node: Container): void => {
if (found !== null || !node.visible) return;
if (node instanceof Text && node.text === text) {
found = new Color(node.style.fill as ColorSource).toHex();
return;
}
for (const child of node.children) walk(child);
};
walk(app.stage);
return found;
};
/**
* Centre of the control of an inspector row, addressed by its label. A row aligns its label to a
* *minimum* width, so a label wider than the column pushes its control right – which is why a
* fixed offset from the label's own text is the wrong way to click one.
*/
const fieldOf = (label: string, last = false): { x: number; y: number; left: number } | null => {
let found: Text | null = null;
const walk = (node: Container): void => {
if ((found !== null && !last) || !node.visible) return;
if (node instanceof Text && node.text === label) {
found = node;
return;
}
for (const child of node.children) walk(child);
};
walk(app.stage);
// TypeScript does not track the assignment inside `walk`, hence the cast.
let owner: Container | null = (found as Text | null)?.parent ?? null;
while (owner && !(owner instanceof Widget)) owner = owner.parent;
const row = owner?.parent;
if (!row || !owner) return null;
const control = row.children[row.children.indexOf(owner) + 1];
if (!(control instanceof Widget)) return null;
return { ...centreOf(control), left: control.getGlobalPosition().x };
};
/**
* The visible text of the overlay layer, in drawing order – everything an open popup shows.
* Menus hide filtered-out entries, so this is also what a search box currently leaves, and a
* list taller than its popup only reports the rows currently scrolled into it.
*/
const overlayTexts = (): string[] => {
const out: string[] = [];
eachText(ui().overlay, (node) => {
if (node.text) out.push(node.text);
});
return out;
};
/**
* Visible texts drawn wider than the widget that owns them. The owner's box is its declared
* `w`/`h`, not `getBounds()`: a Pixi container's bounds are the union of its children –
* including the overflowing text – so every overflow would measure as zero.
*/
const textOverflows = (all = false): { text: string; owner: string; over: number }[] => {
const out: { text: string; owner: string; over: number }[] = [];
const walk = (node: Container, owner: Widget | null, clipped: boolean): void => {
if (!node.visible) return;
const isWidget = node instanceof Widget;
const box = isWidget ? node : owner;
// A mask further up does not excuse a label spilling out of its own control, so the
// question restarts at every widget. `Boolean`, not `!== null`: Pixi's mask getter
// returns `undefined` when nothing masks.
const masked = (isWidget ? false : clipped) || Boolean(node.mask);
if (node instanceof Text) {
if (!box || !node.text || (masked && !all)) return;
const text = node.getBounds();
const origin = box.getGlobalPosition();
const over = Math.max(text.x + text.width - (origin.x + box.w), origin.x - text.x);
if (over > 1 || all) {
out.push({ text: node.text, owner: box.constructor.name, over: Math.round(over) });
}
return;
}
for (const child of node.children) walk(child, box, masked);
};
walk(app.stage, null, false);
return out;
};
/**
* The counterpart of `textOverflows`: buttons whose label was shortened away to nothing, which
* leaves a control that says nothing at all. An empty `Text` is invisible to the overflow walk.
*/
const labelsDropped = (): { text: string; owner: string; w: number }[] => {
const out: { text: string; owner: string; w: number }[] = [];
const walk = (node: Container): void => {
if (!node.visible) return;
if (node instanceof Button && node.text && !node.shownText) {
out.push({ text: node.text, owner: node.constructor.name, w: node.w });
}
for (const child of node.children) walk(child);
};
walk(app.stage);
return out;
};
/** What the last visible button labelled `text` draws – shorter when it was truncated. */
const shownLabel = (text: string): string | null => {
let shown: string | null = null;
const walk = (node: Container): void => {
if (!node.visible) return;
if (node instanceof Button && node.text === text) shown = node.shownText;
for (const child of node.children) walk(child);
};
walk(app.stage);
return shown;
};
/** Screen rectangle of the open popup (the backdrop is not a widget), or null when none is. */
const overlayRect = (): { x: number; y: number; w: number; h: number } | null => {
const popups = ui().overlay.children.filter((node): node is Widget => node instanceof Widget);
const popup = popups[popups.length - 1];
if (!popup) return null;
const p = popup.getGlobalPosition();
return { x: p.x, y: p.y, w: popup.w, h: popup.h };
};
return {
locate,
locateAll,
/** Pictures on screen drawn from the SVG file with this id (#214), e.g. `reset`, `kind-struct`. */
locateIcons,
/** The first such picture (or the topmost with `last`), or `null`. */
locateIcon: (name: string, last = false) => {
const hits = locateIcons(name);
return (last ? hits.at(-1) : hits[0]) ?? null;
},
inspectorField: fieldOf,
/** The inspector panel on screen – what tells whether a row of it can be clicked at all. */
inspectorRect: () => rectOf(inspector),
/** Where the focused widget sits on screen, or `null` – for checks on its focus border. */
focusedRect: () => {
const widget = ui().focus.focused;
return widget ? rectOf(widget) : null;
},
/** What a panel dragged shut keeps: its header. Follows the theme, so checks need not. */
panelHeader: Panel.HEADER_HEIGHT,
/** A theme colour as a hex string, so a check names the colour instead of its current value. */
themeColor: (name: keyof typeof Theme.colors) => `#${Theme.colors[name].toString(16).padStart(6, '0')}`,
inspectorInfo: () => inspector.debugInfo(),
textColor,
overlayTexts,
overlayRect,
/** Entries of the open menu in drawn order, `null` for a separator line (#43). */
menuRows: () => currentMenu()?.rowsDrawn() ?? null,
/** Per entry of `menuRows`, whether an icon stands in front of it. */
menuIcons: () => currentMenu()?.iconsDrawn() ?? null,
/** Per entry of `menuRows`, its tick box: `true`/`false`, `null` for an entry without one. */
menuTicks: () => currentMenu()?.ticksDrawn() ?? null,
/** Per entry of `menuRows`, whether it can be picked (a greyed-out one cannot). */
menuEnabled: () => currentMenu()?.enabledDrawn() ?? null,
textOverflows,
labelsDropped,
shownLabel,
tabRow: () => editor.tabRow(),
/** The open "Change password" dialog (#182): its message and whether it is waiting, or null. */
passwordDialog: () => editor.accountDialog()?.info() ?? null,
/** The project's collision layers (#152), and the names the open dialog holds (null when shut). */
collisionLayers: () => ({
project: [...state.project.collisionLayers],
dialog: editor.projectSettingsDialog()?.layerNames() ?? null,
}),
/** The "Project settings" dialog (#183): name and start-scene switch as its fields stand. */
projectSettings: () => editor.projectSettingsDialog()?.values() ?? null,
/** Where to click the start-scene switch inside that dialog. */
projectSettingsPlayFromAt: () => {
const dialog = editor.projectSettingsDialog();
return dialog ? centreOf(dialog.playFromControl) : null;
},
/** The "Open project" dialog (#183): the projects it lists and the row picked. */
projectPicker: () => editor.projectPickerDialog()?.info() ?? null,
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
/** The left column: asset panel on top, scene objects below, and the divider between them. */
leftColumn: () => ({
assets: rectOf(editor.assets),
objects: rectOf(editor.hierarchy),
splitter: centreOf(editor.columnSplitter),
}),
/** The panel-to-view drag in flight, if any – a drop that never started looks like a refused one. */
dragPayload: () => activeDrag(),
/** An asset's row, its group unfolded first as a user would; `assetRowShown` leaves it folded. */
assetRow: (id: string) => assets.rowGlobal(id, true),
assetRowShown: (id: string) => assets.rowGlobal(id),
assetRowColor: (id: string) => assets.rowColor(id),
/** The asset panel's groups in order; a heading's row is `assetRow('group:<type>')`. */
assetGroups: () => assets.groups(),
assetGroupIcon: (type: AssetType) => assets.groupIcon(type),
/** The panel's own rectangle – what tells a row in view from one scrolled past its edge. */
assetsRect: () => rectOf(assets),
/** The console panel: its lines as kept, its drawn height, and the divider above it. */
console: {
lines: () => editor.console.debugInfo().lines,
height: () => editor.console.h,
clear: () => editor.console.clear(),
scroll: () => editor.console.scrollInfo(),
centre: () => centreOf(editor.console),
splitterAt: () => centreOf(editor.consoleSplitter),
},
/** The open image picker: what it offers, what is picked, and where a tile sits. */
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
imagePickerTile: (file: string) => currentImagePicker()?.tileGlobal(file) ?? null,
/** The same for the font picker, whose rows are the preview. */
fontPicker: () => currentFontPicker()?.debugInfo() ?? null,
fontPickerRow: (file: string) => currentFontPicker()?.rowGlobal(file) ?? null,
/** And for the sound picker, whose rows carry a ▶ of their own. */
soundPicker: () => currentSoundPicker()?.debugInfo() ?? null,
soundPickerRow: (file: string) => currentSoundPicker()?.rowGlobal(file) ?? null,
soundPickerPlay: (file: string) => currentSoundPicker()?.playGlobal(file) ?? null,
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
hierarchyOrder: () => hierarchy.rowOrder(),
hierarchyChevron: (id: string) => hierarchy.chevronPosition(id),
/** Centre of a hierarchy row, clear of the chevron – for clicks that must hit the row itself. */
hierarchyRow: (id: string) => hierarchy.rowPosition(id),
hierarchyRowColor: (id: string) => hierarchy.rowColor(id),
/** Centre of the type icon at the right edge of a hierarchy row. */
hierarchyIcon: (id: string) => hierarchy.iconPosition(id),
hierarchyExpandAll: () => hierarchy.expandAll(),
hierarchyCollapseAll: () => hierarchy.collapseAll(),
};
}