Files
feuerwerk/src/ui/widgets/Menu.ts
T
adminandClaude Opus 5.5 0e8585665a
game-maker / gate (push) Successful in 2m47s
game-maker / suite (push) Successful in 8m41s
Load the 500 weight and draw missing glyphs as SVG icons (#214)
The theme asks for Source Sans 3 at weight 500, but fonts.ts only
loaded 400, 600 and 700. The browser synthesised the 500 text. The
issue comment also asked for the symbols the font lacks to become SVG
icons. A scan of src/ against the font's cmap found 20 such characters
in 30 places.

- fonts.ts loads source-sans-3-latin-500-normal.woff2 as well.
- Menus draw their ticks and the "current" dot as icons. The dot is a
  new MenuItem.current field and no longer part of the label text.
- Inspector action buttons (reset, apply, fit width/height) are
  icons. The menu arrow behind them is the drawn Dropdown chevron.
- Kind marks come from a table in markIcons.ts. Picture kinds use an
  icon, # and Aa stay text because the font has them.
- Scene marks in the asset panel, sound play/stop and the transition
  arrows are icons too.
- Apply is an icon although the font has the arrow. It sits next to
  the reset icon and would look different as a glyph.
- A new Icon widget draws on redraw like Button, so Node tests need no
  DOMParser.
- Tests find icons through the SVG id. The new hook
  __gm.locateIcons(id) and the harness helpers findIcon/clickIcon
  replace locate() on the old characters.

Findings:
- Weight 700 is loaded but the theme never uses it. Left as is.
- The old scene-mark check in module 35 accepted any play glyph on
  screen. It now checks the mark on the start scene's row.
- Module 44 needs the textures from module 37 and fails without it.

Tests:
- ./check.sh: 145 files, 2187 tests, exit 0 (before 144 / 2143).
- New tests/ui/fonts.test.ts: every theme weight is loaded, and every
  character in src/ exists in every shipped face. The cmap reader
  matches fontTools (231 characters).
- Icon tests cover the 19 new files. Each kind mark is either an
  icon or text, never both.
- e2e 1-16,22,30,34,35,43,44: 586 green, 0 FAIL, then 44 stopped
  for lack of module 37. e2e 1-16,37,44,47,48: 618 green.
  e2e 1-14 on the final build: 454 green.
- Two new e2e checks: loaded weights in 14, a reset icon pixel in 04.
- Counter-probes all fail as expected: 500 removed, a play glyph put
  back, the struct icon removed (one unit test each). In the browser,
  500 removed and reset icon hidden fail exactly the two new checks.
- Editor-suite check sum 1312 (1310 measured by CI at 056a622, +2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 08:13:22 +02:00

212 lines
7.6 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 { VStack } from '../layout/Stack';
import { UI_ICON_SVG } from '../icons';
import { searchRank } from '../search';
import { Theme } from '../Theme';
import { ui } from '../UIContext';
import type { Widget } from '../Widget';
import { Button } from './Button';
import { Label } from './Label';
import { Popup } from './Popup';
import { ScrollContainer } from './ScrollContainer';
import { Separator } from './Separator';
import { TextField } from './TextField';
export interface MenuItem {
label: string;
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
/** SVG file in front of the label (see `ButtonOptions.icon`). */
icon?: string;
/** Draws a tick box in front of the label and asks again after every pick. */
checked?: () => boolean;
/** Marks the entry that is picked now with a dot – for a menu that chooses one of several. */
current?: boolean;
/** Picking this does not close the menu – for a set you tick several entries in. */
keepOpen?: true;
}
/** A hairline between two groups of entries; it is not selectable and matches no search. */
export interface MenuSeparator {
separator: true;
}
export type MenuEntry = MenuItem | MenuSeparator;
export interface MenuOptions {
/** Adds a search box above the entries that filters as you type; Enter picks the first match. */
searchable?: boolean;
}
interface Row {
widget: Widget;
/** Null for a separator: it is decoration between groups, not an entry. */
button: Button | null;
label: string;
/** The tick box as it stands – asked again whenever a ticked entry was picked. */
ticked?: () => boolean;
}
/** A vertical list of clickable actions. Picking one closes the menu before it runs. */
export class Menu extends Popup {
private readonly rows: Row[] = [];
private readonly list = new VStack({ gap: 2 });
private readonly scroll = new ScrollContainer();
private readonly empty = new Label('No match', { style: 'muted', padding: Theme.padding });
private readonly search: TextField | null;
constructor(items: MenuEntry[], options: MenuOptions = {}) {
super({ padding: 4, gap: 4 });
this.minWidth = 160;
this.search = options.searchable
? new TextField('', {
placeholder: 'Search…',
onInput: () => this.filter(),
// Enter only. NOT onChange: that also fires when the field loses focus because an
// entry was clicked, which would then run the first match instead of the clicked one.
onSubmit: () => this.pickFirstMatch(),
})
: null;
if (this.search) this.content.add(this.search);
for (const item of items) {
if ('separator' in item) {
this.rows.push({ widget: new Separator(), button: null, label: '' });
continue;
}
const { checked } = item;
const button = new Button(item.label, {
variant: item.danger ? 'danger' : 'ghost',
align: 'left',
icon: checked ? tickIcon(checked()) : item.current ? UI_ICON_SVG.current : item.icon,
});
button.setEnabled(!item.disabled);
button.onClick = item.keepOpen
? () => {
item.onSelect();
this.retick();
}
: this.closeThen(item.onSelect);
this.rows.push({ widget: button, button, label: item.label.toLowerCase(), ticked: checked });
}
this.scroll.setContent(this.list);
this.content.add(this.scroll);
this.filter();
}
/** Re-reads every tick box: one entry may switch off another, so all of them are asked. */
private retick(): void {
for (const row of this.rows) if (row.button && row.ticked) row.button.setIcon(tickIcon(row.ticked()));
}
override open(globalX: number, globalY: number): void {
super.open(globalX, globalY);
setCurrent(this);
// open() blurs whatever had focus, so this has to come after it.
if (this.search) ui().focus.focus(this.search);
}
override close(): void {
if (current === this) current = null;
super.close();
}
/** The entries as they stand, in the order they are drawn; `null` is a separator (test hook). */
rowsDrawn(): (string | null)[] {
return this.list.children
.filter((child) => child !== this.empty && child.visible)
.map((child) => (child instanceof Button ? child.text : null));
}
/** Per entry of `rowsDrawn`, whether it can be picked; `null` for a separator (test hook). */
enabledDrawn(): (boolean | null)[] {
return this.list.children
.filter((child) => child !== this.empty && child.visible)
.map((child) => (child instanceof Button ? child.enabled : null));
}
/** Per entry of `rowsDrawn`: its tick box, `null` for an entry without one or a separator (test hook). */
ticksDrawn(): (boolean | null)[] {
const byButton = new Map(this.rows.map((row) => [row.button, row.ticked]));
return this.list.children
.filter((child) => child !== this.empty && child.visible)
.map((child) => {
const ticked = child instanceof Button ? byButton.get(child) : undefined;
return ticked ? ticked() : null;
});
}
/** Per entry of `rowsDrawn`, whether it shows an icon; `null` for a separator (test hook). */
iconsDrawn(): (boolean | null)[] {
return this.list.children
.filter((child) => child !== this.empty && child.visible)
.map((child) => (child instanceof Button ? child.hasIcon : null));
}
private filter(): void {
const query = this.search?.value.trim().toLowerCase() ?? '';
const shown = query ? this.ranked(query) : groupsOf(this.rows);
// Searching reorders, so the rows are re-hung rather than only hidden. removeChildren detaches
// them; the widgets themselves live in `this.rows` and survive every filter.
this.list.removeChildren();
for (const row of shown) this.list.addChild(row.widget);
this.empty.visible = shown.length === 0;
this.list.addChild(this.empty);
const size = this.list.measure();
const room = this.roomFor(this.scroll);
const scrolls = size.h > room;
this.scroll.minWidth = size.w + (scrolls ? Theme.scrollbarWidth : 0);
this.scroll.minHeight = Math.min(size.h, room);
this.scroll.scrollTo(0);
this.refreshLayout();
// Both are needed: a resize re-lays out only on an actual size change, and two filters can
// leave the list exactly as tall while showing different entries.
this.scroll.relayoutContent();
this.list.relayout();
}
/** Best answers first; a query flattens the groups, so separators drop out with them. */
private ranked(query: string): Row[] {
return this.rows
.map((row, index) => ({ row, index, rank: row.button ? searchRank(query, [row.label]) : -1 }))
.filter((entry) => entry.rank >= 0)
.sort((a, b) => a.rank - b.rank || a.index - b.index)
.map((entry) => entry.row);
}
private pickFirstMatch(): void {
const first = this.list.children.find(
(child): child is Button => child instanceof Button && child.visible && child.enabled,
);
first?.onClick?.();
}
}
function tickIcon(ticked: boolean): string {
return ticked ? UI_ICON_SVG.ticked : UI_ICON_SVG.unticked;
}
let current: Menu | null = null;
function setCurrent(menu: Menu): void {
current = menu;
}
/** The menu standing open, for the test hook. */
export function currentMenu(): Menu | null {
return current;
}
/** Drops separators that would open, close or double up the list – a line needs two sides. */
function groupsOf(rows: Row[]): Row[] {
const out: Row[] = [];
for (const row of rows) {
if (row.button) out.push(row);
else if (out.length > 0 && out[out.length - 1]!.button) out.push(row);
}
if (out.length > 0 && !out[out.length - 1]!.button) out.pop();
return out;
}