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:
1 parent
b830f9c747
commit
ed29b2f3dc
9 files changed
+1114
-1031
No files matched your search
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user