A search that ignores what a thing is called is no search, so the name wins (#43)
game-maker / suite (push) Failing after 10m23s

Typing "variable" into the node palette landed on `Run behavior`, because the
filter weighed label, type and description alike and then sorted by category.
`searchRank` in `src/ui/search.ts` now ranks a match: exact label or type, then
prefix, then label, then type, and only last a hit that lives in the
description alone. Category and alphabet order within a rank. The searchable
`Menu` uses the same function, so searching means the same thing everywhere.

The `+` menu grew to fifteen entries in declaration order. It is built in two
alphabetical groups now -- the types that go into a scene, then the ones that
stay in the library -- with a separator between them, the split `isPlaceableType`
already draws. `ASSET_TYPES` itself stays untouched: it is also the
serialisation and check order. A search flattens the groups and drops the lines.

Popups were capped at fixed heights (320 menu, 260 palette, 340 pickers) while
the canvas had room to spare. `Popup.roomFor()` measures its own chrome and
takes the window height minus a tenth at each end. Placement clamps
asymmetrically: the bottom margin is enforced, the top is only a budget, so a
menu anchored near the top stays at its button instead of being pushed off it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-15 10:21:20 +02:00
1 parent 5671a38044
commit 3b039384c4
20 files changed
+570 -55

No files matched your search

+82 -16
View File
@@ -1,10 +1,13 @@
import { VStack } from '../layout/Stack';
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 {
@@ -14,22 +17,34 @@ export interface MenuItem {
disabled?: boolean;
}
/** 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;
}
const MAX_LIST_HEIGHT = 320;
interface Row {
widget: Widget;
/** Null for a separator: it is decoration between groups, not an entry. */
button: Button | null;
label: string;
}
/** A vertical list of clickable actions. Picking one closes the menu before it runs. */
export class Menu extends Popup {
private readonly entries: { button: Button; haystack: string }[] = [];
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: MenuItem[], options: MenuOptions = {}) {
constructor(items: MenuEntry[], options: MenuOptions = {}) {
super({ padding: 4, gap: 4 });
this.minWidth = 160;
this.search = options.searchable
@@ -44,16 +59,18 @@ export class Menu extends Popup {
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 button = new Button(item.label, {
variant: item.danger ? 'danger' : 'ghost',
align: 'left',
});
button.setEnabled(!item.disabled);
button.onClick = this.closeThen(item.onSelect);
this.list.add(button);
this.entries.push({ button, haystack: item.label.toLowerCase() });
this.rows.push({ widget: button, button, label: item.label.toLowerCase() });
}
this.list.add(this.empty);
this.scroll.setContent(this.list);
this.content.add(this.scroll);
this.filter();
@@ -61,23 +78,39 @@ export class Menu extends Popup {
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));
}
private filter(): void {
const query = this.search?.value.trim().toLowerCase() ?? '';
let matches = 0;
for (const entry of this.entries) {
entry.button.visible = entry.haystack.includes(query);
if (entry.button.visible) matches++;
}
this.empty.visible = matches === 0;
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 scrolls = size.h > MAX_LIST_HEIGHT;
const room = this.roomFor(this.scroll);
const scrolls = size.h > room;
this.scroll.minWidth = size.w + (scrolls ? Theme.scrollbarWidth + 4 : 0);
this.scroll.minHeight = Math.min(size.h, MAX_LIST_HEIGHT);
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
@@ -86,8 +119,41 @@ export class Menu extends Popup {
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.entries.find((entry) => entry.button.visible && entry.button.enabled);
first?.button.onClick?.();
const first = this.list.children.find(
(child): child is Button => child instanceof Button && child.visible && child.enabled,
);
first?.onClick?.();
}
}
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;
}