Files
feuerwerk/src/editor/debug/uiHooks.ts
T
adminandClaude Opus 5.5 3a52f2290f
game-maker / gate (push) Successful in 3m36s
game-maker / suite (push) Failing after 14m30s
Mix sounds through buses and a master (#325)
Until now every sound went straight to the speakers. There was no
point where all of them came together, so nothing could turn the
music down against the effects, and a compressor or a shared echo
over everything had nowhere to sit. The bus technique of #346 was
there already, only the end of the chain was missing.

- Bus asset (Audio): Volume, Sound FX and Into. Into is empty for
  the master and never offers the bus itself nor a bus that already
  goes into it. The list is worked out when the menu opens, so a
  change made meanwhile counts. A loop that is in a project anyway
  sends the buses on it into the master.
- The master is no asset. Its volume and sound FX are two new rows
  under Master in Project settings, one undo step each. A project
  written before has 1 and none, which is how it sounded.
- Sound, instrument, pattern and song have a row Bus, empty for the
  master. Play sound, Play note, Play pattern and Play song have an
  input bus: From the asset, Master or a bus. Inside a song or a
  pattern only the bus of the song or pattern counts.
- Set bus volume (bus, volume, time, easing) and Bus volume. An
  empty bus is the master. The volume set holds for the whole run,
  across scenes, over that of the asset. GameRun resets it at start
  and end. Set sound FX parameters without a voice now reaches the
  sound FX of the bus picked in its new bus input.
- SoundLibrary: the master is the bus "mix", each bus "mix:<id>".
  Every bus, the playing masters of #346 included, ends there, and
  only the master is heard. setMix sets them up once a context
  exists, so an editor nobody listened to opens no audio device. The
  same mix again changes nothing, which lets the editor hand it over
  on every change of the project.
- The tabs play as the game does: Sound, Sound FX, Instrument,
  Pattern and Song tab through the bus of their asset and the master.
- Pause game does nothing to the buses (the user's answer 4b). A
  game that wants quiet in its pause menu sets a bus volume itself.
- Streaming long music files is #349, split off as asked.

Finding on the way: the Into menu first worked from a list made when
the inspector row was built. After another bus was sent into the
open one, e2e 34 saw it still offered. It is now asked on open.

The SoundLibrary tests expected voices to end at the destination.
They now end in the master, which adds its input, level and pan to
every wiring and two gains to every list. Those expectations are
updated, nothing else in them changed.

Tests:
- Unit buses (new): 15 green. Ten counter-checks, each red on its
  own: loop check, bus picked, reset at run start, bus of Set sound
  FX parameters, master volume in the settings, and in SoundLibrary
  default into the master, voices into the master, run volume over
  the mix, no context before a play, a playing on its bus.
- Unit sound-library: 90 green (5 new). Node catalog: 416 green
  (9 new cases).
- e2e 34 (melektaus-atx): ALL PASSED, 75 checks (3 new). The voice
  of the Sound tab was heard on mix:<bus>, that bus into the other,
  that one at 0.5 into the master. Master volume 0.6 via the dialog,
  undo back to 1.
- Gate (melektaus-atx): CHECK EXIT 0, 262 files, 4072 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:13:32 +02:00

444 lines
22 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 { 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:<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;
},
/** 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:<type>')`. */
assetGroups: () => assets.groups(),
/** The categories above the groups; a heading's row is `assetRow('category:<name>')`. */
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,
};
}