The groups of the inspector had a small grey label as their title, in the same grid as a property row, so with many rows one group hardly stood apart from the next. Now every group has a band as its head, from edge to edge of the panel, and a click on it folds the group. - A band runs across the padding of its column in the header colour, with the title and a mark that says whether the group is open. The titles stay as they are, as answered in the issue. - A click on a band folds its group: the rows go, the band stays and the next band moves up against it. A control in the band (Open, + Variable, + Property) does its own job and folds nothing. - A placed object has a group SWITCHES under its name, asset and parent: Enabled, Keep on scene load and the switches of a behavior. An asset has SWITCHES and PROPERTIES, and a group without rows is left out. - The same bands head the tilemap panel, the Variables tab, the side of the Pixel art tab and the shape list of vector art. A list with a title (Layers, Frames, Shapes) folds with a click on its header, clear of its plus. - ui/sections.ts holds the rules as pure functions, SectionStack is the column and SectionHeader the band. Every title of the inspector goes through addTitle or sectionHeader in inspector/host.ts. Decided here: - A fold is kept for good, in localStorage of this browser. The issue asked whether for the session or for good and the answer left it open. - A fold is remembered by title, per panel. PROPERTIES folded once is folded on every object, asset and node. A number in a title does not count. - A behavior asset has all its rows under SWITCHES. A Variables asset and a state machine get a group SCOPE. - A greyed out control in a band takes the click too. Without that the click fell through to the band and folded the group. - The Curve tab has no column of this kind and is unchanged. Found on the way: - The inspector of a sprite is taller than the 800 pixel window of the checks now. The hooks that hand out a row scroll it into view first, and module 28 scrolls before it looks for a row by its text. Tests: - Gate: 202 files, 3008 tests (2982 + 26), exit 0 - e2e: no complete run for this state. Modules 28 to 61 passed alone (749 checks). A full run was cut off in the accounts module at 1492 passes without a failure, the modules after it and the smokes are missing. The sum of 1639 (1635 + 4) is reckoned, CI measures it. - 26 unit tests in tests/editor/sections.test.ts - Four e2e checks with a real mouse, three in module 02 and one in module 48 - 25 unit counter-probes all fail tests, run on stein2 - Screenshots of the bands looked at before the last build, none after it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
360 lines
17 KiB
TypeScript
360 lines
17 KiB
TypeScript
import type { AssetType } from '../../core/model/AssetTypes';
|
||
import {
|
||
Button,
|
||
Label,
|
||
Panel,
|
||
SectionHeader,
|
||
TextField,
|
||
Widget,
|
||
activeDrag,
|
||
currentMenu,
|
||
ui,
|
||
} from '../../ui';
|
||
import { Theme } from '../../ui/Theme';
|
||
import { currentFontPicker } from '../font/FontPicker';
|
||
import { currentGroupDialog } from '../panels/GroupNameDialog';
|
||
import { currentSoundPicker } from '../sound/SoundPicker';
|
||
import { rectOf, textProbe } from '../textProbe';
|
||
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 => {
|
||
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;
|
||
// 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,
|
||
/** 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',
|
||
};
|
||
},
|
||
/**
|
||
* 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,
|
||
/** 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 "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 symbol scale field inside that dialog. */
|
||
projectSettingsSymbolScaleAt: () => {
|
||
const dialog = editor.projectSettingsDialog();
|
||
return dialog ? centreOf(dialog.symbolScaleControl) : null;
|
||
},
|
||
/** 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,
|
||
};
|
||
}
|