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; }