Files
feuerwerk/src/editor/debug/uiHooks.ts
T
adminandClaude Opus 5.5 3f12a77aea Preview a sprite at the end of its inspector, placed or as an asset
A sprite's inspector listed its rows but never showed what they add up
to. The picture, its frame, nine-slice, tint and shader could only be
judged in the scene, often far from the object being edited.

- SpritePreview draws the sprite through syncSpriteMesh, the function
  the scene draws it with. Slices, padding, tint and shader come out
  the same without a second path. syncSpriteMesh now takes only the
  three fields of a DrawItem it reads.
- The subject is resolved by previewPicture from the Shader tab, so a
  placed sprite shows its own overrides.
- previewFit is the rule for size and place: whole device pixels per
  unit from 1 on, at most 8, and the corner on a whole device pixel.
  Pixel art stays sharp. A sprite larger than the group shrinks freely.
- A shader with a Time node keeps the preview redrawing once a frame
  while it is on screen. A folded group or a hidden panel stops it.
- New test hook __gm.inspectorSpritePreview().

Finding: the preview redrew on a model change in a microtask at first.
A drawn picture whose revision moved on is retried once, in a
microtask queued by the scene's synchronous redraw. That retry ran
before the preview had let go, and the old pixel art sheet stayed
waiting (e2e 48l-iii read waiting [1,1,1]). The preview now redraws
at once on a model change, as the scene does.

Tests:
- unit: 238 files, 3592 tests (+5 in preview-fit.test.ts), and the
  group list of a placed sprite in sections.test.ts ends in PREVIEW
- e2e 48: ALL PASSED, 47 checks (+2: 48l-i asset preview with
  the frame, 48l-iii placed preview with its shader values)
- counter-checks: without whole-pixel zoom 2 unit tests fail; with
  the microtask redraw 48l-iii fails with waiting [1,1,1]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:27:35 +02:00

430 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AssetType } from '../../core/model/AssetTypes';
import {
Button,
Grip,
Label,
Panel,
SectionHeader,
TextField,
Widget,
activeDrag,
currentMenu,
tooltips,
ui,
} from '../../ui';
import { Theme } from '../../ui/Theme';
import { currentFontPicker } from '../font/FontPicker';
import { generalSettings, keepGeneralSettings } from '../generalSettings';
import { currentGroupDialog } from '../panels/GroupNameDialog';
import { currentSoundPicker } from '../sound/SoundPicker';
import { rectOf, textProbe } from '../textProbe';
import { SpritePreview } from '../inspector/SpritePreview';
import { currentImagePicker } from '../texture/ImagePicker';
import type { UsersPane } from '../users/UsersPane';
import { Color, type ColorSource, type Container, Text } from 'pixi.js';
import { centreOf, type HookContext } from './hookHelpers';
function hex(color: number): string {
return `#${color.toString(16).padStart(6, '0')}`;
}
/** 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 => {
const control = controlOf(label, last);
return control && { ...centreOf(control), left: control.getGlobalPosition().x };
};
/** The control right of the row label `label`, as `fieldOf` finds it. */
const controlOf = (label: string, last: boolean): Widget | null => besideOf(label, last, 1);
/** The widget `step` places along from the row label `label`: +1 its control, -1 its mark. */
const besideOf = (label: string, last: boolean, step: number): Widget | 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) + step];
if (!(control instanceof Widget)) return null;
// Taller than the window since its groups have bands (#290): the row is scrolled into view.
inspector.reveal(control);
return control;
};
/**
* 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, (text) => {
if (text) out.push(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,
/** A sprite's preview in the inspector, scrolled into view: where its picture is drawn, on screen. */
inspectorSpritePreview: () => {
let preview: SpritePreview | null = null;
const walk = (node: Container): void => {
if (node instanceof SpritePreview) preview = node;
else for (const child of node.children) walk(child);
};
walk(inspector);
const found = preview as SpritePreview | null;
if (!found) return null;
inspector.reveal(found);
const at = found.getGlobalPosition();
const { x, y, w, h } = found.drawn;
return { x: at.x + x, y: at.y + y, w, h, animates: found.animates };
},
/** The mark in front of the row named `label` – what a variable, a field or a port is dragged by (#310). */
inspectorMark: (label: string, last = false) => {
const mark = besideOf(label, last, -1);
return mark && { ...centreOf(mark), dots: mark instanceof Grip ? mark.dotsShown : 'none' };
},
/** The eyedropper beside the object field of a row (#276): where to press it, and whether it is lit. */
inspectorEyedropper: (label: string, last = false) => {
const button = controlOf(label, last)?.children.at(-1);
return button instanceof Button ? { ...centreOf(button), lit: button.toggled } : null;
},
/**
* The label of an inspector row as a link (#221): its centre, colour and whether it is underlined
* and takes presses, and a text field right of it (value, read-only). Searched in the inspector
* only – the tab row says "Shader" too.
*/
inspectorLink: (text: string) => {
let found: Label | null = null;
const walk = (node: Container): void => {
if (found || !node.visible) return;
if (node instanceof Label && node.text === text) found = node;
else for (const child of node.children) walk(child);
};
walk(inspector);
const label = found as Label | null;
if (!label) return null;
inspector.reveal(label);
const shown = label.children.find((child): child is Text => child instanceof Text);
const row = label.parent;
const next = row?.children[row.children.indexOf(label) + 1];
return {
field: next instanceof TextField ? { value: next.value, readOnly: next.readOnly } : null,
...centreOf(label),
color: shown ? new Color(shown.style.fill as ColorSource).toHex() : null,
underlined: label.underlined,
pressable: label.eventMode === 'static',
};
},
/**
* A button of the inspector by its text, scrolled into view first: the rows of a selection can
* be taller than the window (#291), and a button below the fold is on no screen pixel to find.
*/
inspectorButton: (text: string) => {
let found: Button | null = null;
const walk = (node: Container): void => {
if (found || !node.visible) return;
if (node instanceof Button && node.text === text) found = node;
else for (const child of node.children) walk(child);
};
walk(inspector);
const button = found as Button | null;
if (!button) return null;
inspector.reveal(button);
return { ...centreOf(button), enabled: button.enabled };
},
/**
* The band of a group of rows (#290), by its title: where to click it clear of its controls, its
* rectangle and whether the group is folded. The first one showing, or the last with `last`.
*/
section: (title: string, last = false) => {
const found: SectionHeader[] = [];
const walk = (node: Container): void => {
if (!node.visible) return;
if (node instanceof SectionHeader && node.title === title) found.push(node);
for (const child of node.children) walk(child);
};
walk(app.stage);
const header = last ? found.at(-1) : found[0];
if (!header) return null;
const rect = rectOf(header);
return { x: rect.x + 60, y: rect.y + rect.h / 2, rect, collapsed: header.sectionCollapsed };
},
/** 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) => hex(Theme.colors[name]),
inspectorInfo: () => inspector.debugInfo(),
textColor,
overlayTexts,
overlayRect,
/** The tooltip on screen (#209): its text, its box and the box of the control it explains; null without one. */
tooltip: () => tooltips.shown,
/** 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,
/** Per entry of `menuRows`, `{ text, fill }` as drawn: label colour and background (`null` = none). */
menuLooks: () =>
currentMenu()
?.looksDrawn()
.map((look) => look && { text: hex(look.text), fill: look.fill === null ? null : hex(look.fill) }) ??
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 user management (#179); `pane()` is null for an account that is no administrator.
* `buttonAt` takes an action (`rename`, `role`, `password`, `delete`) or `open:<project id>`.
*/
users: {
pane: () =>
editor.usersPane ? { visible: editor.tab === 'management', ...editor.usersPane.debugInfo() } : null,
buttonAt: (username: string, action: Parameters<UsersPane['buttonOf']>[1]) => {
const button = editor.usersPane?.buttonOf(username, action);
return button
? { ...centreOf(button), right: rectOf(button).x + button.w, enabled: button.enabled }
: null;
},
/** The list on screen, for checks that its rows fit into it. */
rect: () => (editor.usersPane ? rectOf(editor.usersPane) : null),
/** The open form: its title, the message under it, whether it waits, its role (null without a picker). */
dialog: () => editor.usersPane?.currentDialog()?.info() ?? null,
/** Reads the list again and answers once it is drawn – `json()` does not wait for a promise, `evaluate()` does. */
reload: () => editor.usersPane?.reload(),
},
/** 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 same for the render layers (#302). */
renderLayers: () => ({
project: [...state.project.renderLayers],
dialog: editor.projectSettingsDialog()?.renderLayerNames() ?? null,
}),
/** The "Project settings" dialog (#183): name, start-scene switch and symbol scale 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;
},
/** Where to click the While playing switch inside that dialog (#265). */
projectSettingsWhilePlayingAt: () => {
const dialog = editor.projectSettingsDialog();
return dialog ? centreOf(dialog.whilePlayingControl) : null;
},
/** Where to click the Game link zoom switch inside that dialog (#279). */
projectSettingsGameLinkZoomAt: () => {
const dialog = editor.projectSettingsDialog();
return dialog ? centreOf(dialog.gameLinkZoomControl) : null;
},
/** Where to click the symbol scale field inside that dialog. */
projectSettingsSymbolScaleAt: () => {
const dialog = editor.projectSettingsDialog();
return dialog ? centreOf(dialog.symbolScaleControl) : null;
},
/** The "General settings" dialog (#309) as its field stands, null while shut. */
generalSettingsDialog: () => editor.generalSettingsDialog()?.values() ?? null,
/** Where to click the zoom field inside that dialog. */
generalSettingsZoomAt: () => {
const dialog = editor.generalSettingsDialog();
return dialog ? centreOf(dialog.zoomControl) : null;
},
/**
* The zoom transition in ms as kept in this browser; with `ms` it is set first. The harness sets
* 0 on every load, so a check reading the zoom after the wheel finds the step and not the way.
*/
zoomTransition: (ms?: number) => {
if (ms !== undefined) keepGeneralSettings({ zoomTransitionMs: ms });
return generalSettings().zoomTransitionMs;
},
/** The "Open project" dialog (#183): the projects it lists and the row picked. */
projectPicker: () => editor.projectPickerDialog()?.info() ?? null,
/** The "Snapshots" dialog (#231): its rows, newest first, the row picked and its name field. */
snapshots: () => {
const dialog = editor.snapshotsDialog();
return dialog ? { ...dialog.info(), nameAt: centreOf(dialog.nameControl) } : null;
},
/** The "Release" dialog (#266): the link, the state line and the warnings. */
release: () => editor.releaseDialog()?.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),
/** The asset row lighting up after a ping (#221) and how brightly, or null once it has faded. */
assetPing: () => assets.pingState(),
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,
imagePickerRect: () => {
const picker = currentImagePicker();
return picker ? rectOf(picker.listView) : 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),
/** Centre of the hint of a hierarchy row – a group's member count. */
hierarchyHint: (id: string) => hierarchy.hintPosition(id),
hierarchyExpandAll: () => hierarchy.expandAll(),
hierarchyCollapseAll: () => hierarchy.collapseAll(),
/** Whether the dialog naming a hierarchy group is open (#241). */
groupDialogOpen: () => currentGroupDialog() !== null,
};
}