import { ASSET_TYPE_LABELS, 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 { assetMenuCategories } from '../panels/assetMenu'; 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`, whether it opens a submenu. With one open, `menuRows` is that submenu. */ menuSubmenus: () => currentMenu()?.submenusDrawn() ?? 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:`. */ users: { pane: () => editor.usersPane ? { visible: editor.tab === 'management', ...editor.usersPane.debugInfo() } : null, buttonAt: (username: string, action: Parameters[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; }, /** Where to click the master's volume field and its sound FX switch inside that dialog (#325). */ projectSettingsMasterAt: () => { const dialog = editor.projectSettingsDialog(); return dialog && { volume: centreOf(dialog.masterVolumeControl), fx: centreOf(dialog.masterFxControl) }; }, /** 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:')`. */ assetGroups: () => assets.groups(), /** The categories above the groups; a heading's row is `assetRow('category:')`. */ assetCategories: () => assets.categories(), /** The `+` menu entry whose submenu holds the type with this menu label, or null. */ addMenuCategory: (label: string) => assetMenuCategories().find(({ types }) => types.some((type) => ASSET_TYPE_LABELS[type] === label)) ?.category ?? null, 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, }; }