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>
430 lines
21 KiB
TypeScript
430 lines
21 KiB
TypeScript
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,
|
||
};
|
||
}
|