Files
feuerwerk/src/editor/debug/uiHooks.ts
T
adminandClaude Opus 5.5 393caadc9d
game-maker / gate (push) Successful in 2m39s
game-maker / suite (push) Successful in 13m10s
Head the groups of the inspector with bands that fold (#290)
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>
2026-10-01 22:08:28 +02:00

360 lines
17 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,
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,
};
}