A search that ignores what a thing is called is no search, so the name wins (#43)
game-maker / suite (push) Failing after 10m23s
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:
1 parent
5671a38044
commit
3b039384c4
20 files changed
+570
-55
No files matched your search
+82
-16
@@ -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;
|
||||
}
|
||||
+45
-1
@@ -13,6 +13,35 @@ export interface PopupOptions {
|
||||
modal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Left free around a popup, so it never touches the edge of the canvas. A share of the canvas
|
||||
* rather than a constant: the same gap that frames a popup on a laptop looks like a hairline on a
|
||||
* large screen, and the height cap is measured against the canvas anyway.
|
||||
*/
|
||||
const SCREEN_MARGIN = 0.1;
|
||||
|
||||
/** The gap the layout reserves, in pixels on the current canvas. */
|
||||
export function screenMargin(screenHeight: number): number {
|
||||
return Math.round(screenHeight * SCREEN_MARGIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a popup of `height` ends up when it was asked to open at `wanted`. The margin has to be
|
||||
* enforced here, not only in `roomFor`: a popup anchored low is *shifted* up to fit, and shifting
|
||||
* it against the raw canvas height parks it on the bottom edge – which is why the gap used to be
|
||||
* visible above and missing below.
|
||||
*
|
||||
* Downwards only, though. Above the popup the margin is what the height cap spends, not a distance
|
||||
* to keep: a menu hanging off a button near the top of the screen belongs under its button, and
|
||||
* pushing it down to the tenth would tear it away from what opened it. So the top edge of the
|
||||
* canvas is the only limit upwards, and a popup taller than the canvas starts there instead of
|
||||
* being centred out of view.
|
||||
*/
|
||||
export function clampPopupY(wanted: number, height: number, screenHeight: number): number {
|
||||
const margin = screenMargin(screenHeight);
|
||||
return clamp(wanted, 0, Math.max(0, screenHeight - margin - height));
|
||||
}
|
||||
|
||||
/** A floating box on the overlay layer with a click-away backdrop. */
|
||||
export class Popup extends Widget {
|
||||
private readonly backdrop = new Graphics();
|
||||
@@ -68,7 +97,7 @@ export class Popup extends Widget {
|
||||
const size = this.measure();
|
||||
this.resize(size.w, size.h);
|
||||
const x = clamp(this.openX, 0, Math.max(0, screen.width - size.w));
|
||||
const y = clamp(this.openY, 0, Math.max(0, screen.height - size.h));
|
||||
const y = clampPopupY(this.openY, size.h, screen.height);
|
||||
this.position.set(Math.round(x), Math.round(y));
|
||||
}
|
||||
|
||||
@@ -96,6 +125,21 @@ export class Popup extends Widget {
|
||||
this.onClose?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* How tall the scrolling part of this popup may become: the canvas, minus a margin, minus the
|
||||
* chrome around it. The chrome is *measured* – everything the content holds besides `scroll` –
|
||||
* rather than repeated as a constant, so a search field or a row of buttons cannot be forgotten.
|
||||
*
|
||||
* A fixed cap would either waste a tall screen or overflow a short one; the canvas is the only
|
||||
* number that knows which of the two we are on.
|
||||
*/
|
||||
protected roomFor(scroll: Widget, minimum = Theme.rowHeight * 3): number {
|
||||
const chrome = this.content.measure().h - scroll.measure().h;
|
||||
const screenHeight = ui().app.screen.height;
|
||||
const room = screenHeight - screenMargin(screenHeight) * 2 - chrome;
|
||||
return Math.max(minimum, room);
|
||||
}
|
||||
|
||||
/** Closes first, so `action` runs with the overlay already cleared. */
|
||||
protected closeThen(action?: () => void): () => void {
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { Theme } from '../Theme';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
/** A hairline between two groups of entries. Takes no pointer events and answers no search. */
|
||||
export class Separator extends Widget {
|
||||
private readonly line = new Graphics();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.line);
|
||||
this.minHeight = 5;
|
||||
this.eventMode = 'none';
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
this.line
|
||||
.clear()
|
||||
.rect(Theme.padding, Math.floor(this.h / 2), Math.max(0, this.w - Theme.padding * 2), 1)
|
||||
.fill({ color: Theme.colors.border });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user