Pick a new asset from category submenus in the + menu
The + menu was one long list in two groups, placeable types and library ones. That split is no longer wanted. The menu now lists the categories of the asset panel, and each opens its types beside it, so a new asset is filed where the panel shows it. - Menu learns submenus: an entry with submenu instead of onSelect opens a second Menu right of the first, or left of it where the canvas ends. Its first entry lines up with the row that opened it. - A submenu opens on hover or click. Resting on another entry swaps or closes it after 150 ms, so a pointer cutting across the rows on its way into a submenu does not lose it. - The row of an open submenu stays lit, and a triangle pointing right marks every such row. - A submenu has no backdrop and no Escape of its own. Popup takes a backdrop option for that. The first menu's backdrop and Escape close the whole stack, and picking an entry does the same. - assetMenuCategories replaces assetMenuGroups and takes categories and their order from ASSET_GROUPS. The types are sorted by label. - New test hooks __gm.menuSubmenus() and __gm.addMenuCategory(). pickAssetType opens the category first, so the modules that pick a type through it need no change of their own. Modules 04, 10, 47, 48, 53 and 54 open the category themselves where they read the menu. Tests: - Unit asset-menu: 4 green - e2e 04: ALL PASSED, 63 checks. New: categories in panel order, each type once and sorted, submenu placement, hover swaps the submenu, picking closes menu and submenu - Gate: CHECK EXIT 0, 261 files, 4040 tests - The other modules that use the + menu did not run here Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5e09ba9a50
commit
a848cf8469
16 files changed
+320
-131
No files matched your search
@@ -76,6 +76,7 @@ export {
|
||||
type MenuItem,
|
||||
type MenuOptions,
|
||||
type MenuSeparator,
|
||||
type MenuSubmenu,
|
||||
} from './widgets/Menu';
|
||||
export { Separator } from './widgets/Separator';
|
||||
export { searchRank } from './search';
|
||||
|
||||
+163
-6
@@ -1,10 +1,12 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
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 type { Size, Widget } from '../Widget';
|
||||
import { Button, type ButtonOptions } from './Button';
|
||||
import { CHEVRON_SLOT } from './Dropdown';
|
||||
import { Label } from './Label';
|
||||
import { Popup } from './Popup';
|
||||
import { ScrollContainer } from './ScrollContainer';
|
||||
@@ -31,13 +33,36 @@ export interface MenuSeparator {
|
||||
separator: true;
|
||||
}
|
||||
|
||||
export type MenuEntry = MenuItem | MenuSeparator;
|
||||
/**
|
||||
* An entry that opens more entries beside the menu, on hover or on a click. A search matches it by
|
||||
* its own label only, not by what it holds.
|
||||
*/
|
||||
export interface MenuSubmenu {
|
||||
label: string;
|
||||
submenu: MenuEntry[];
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export type MenuEntry = MenuItem | MenuSeparator | MenuSubmenu;
|
||||
|
||||
export interface MenuOptions {
|
||||
/** Adds a search box above the entries that filters as you type; Enter picks the first match. */
|
||||
searchable?: boolean;
|
||||
/** The menu this one is a submenu of; set by `Menu` itself when a submenu entry opens. */
|
||||
under?: Menu;
|
||||
}
|
||||
|
||||
/** The padding of a menu around its entries; a submenu opens this far above its row to line up. */
|
||||
const PADDING = 4;
|
||||
/** How far a submenu reaches back over the menu it hangs off, so the two read as one. */
|
||||
const SUBMENU_OVERLAP = 2;
|
||||
/**
|
||||
* How long the pointer has to rest on another entry before an open submenu swaps or closes. A
|
||||
* pointer on its way into a submenu cuts across the rows below its own, and they must not take it.
|
||||
*/
|
||||
const SUBMENU_DELAY_MS = 150;
|
||||
|
||||
interface Row {
|
||||
widget: Widget;
|
||||
/** Null for a separator: it is decoration between groups, not an entry. */
|
||||
@@ -54,9 +79,14 @@ export class Menu extends Popup {
|
||||
private readonly scroll = new ScrollContainer();
|
||||
private readonly empty = new Label('No match', { style: 'muted', padding: Theme.padding });
|
||||
private readonly search: TextField | null;
|
||||
private readonly under: Menu | null;
|
||||
/** The submenu standing open beside this menu, and the entry it belongs to. */
|
||||
private child: { menu: Menu; row: SubmenuRow } | null = null;
|
||||
private hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(items: MenuEntry[], options: MenuOptions = {}) {
|
||||
super({ padding: 4, gap: 4 });
|
||||
super({ padding: PADDING, gap: 4, backdrop: !options.under });
|
||||
this.under = options.under ?? null;
|
||||
this.minWidth = 160;
|
||||
this.search = options.searchable
|
||||
? new TextField('', {
|
||||
@@ -74,6 +104,16 @@ export class Menu extends Popup {
|
||||
this.rows.push({ widget: new Separator(), button: null, label: '' });
|
||||
continue;
|
||||
}
|
||||
if ('submenu' in item) {
|
||||
const row = new SubmenuRow(item.label, { variant: 'ghost', align: 'left', icon: item.icon });
|
||||
row.setEnabled(!item.disabled);
|
||||
const open = () => this.openSubmenu(row, item.submenu);
|
||||
row.onClick = open;
|
||||
row.on('pointerover', () => (this.child ? this.later(open) : open()));
|
||||
row.on('pointerout', () => this.cancelHover());
|
||||
this.rows.push({ widget: row, button: row, label: item.label.toLowerCase() });
|
||||
continue;
|
||||
}
|
||||
const { checked } = item;
|
||||
// A destructive entry says so in red text only: a filled red row shouted louder than the
|
||||
// menu around it and read like the one under the pointer.
|
||||
@@ -89,7 +129,12 @@ export class Menu extends Popup {
|
||||
item.onSelect();
|
||||
this.retick();
|
||||
}
|
||||
: this.closeThen(item.onSelect);
|
||||
: this.pickThen(item.onSelect);
|
||||
// Resting on a plain entry puts an open submenu away.
|
||||
button.on('pointerover', () => {
|
||||
if (this.child) this.later(() => this.closeSubmenu());
|
||||
});
|
||||
button.on('pointerout', () => this.cancelHover());
|
||||
this.rows.push({ widget: button, button, label: item.label.toLowerCase(), ticked: checked });
|
||||
}
|
||||
this.scroll.setContent(this.list);
|
||||
@@ -97,6 +142,59 @@ export class Menu extends Popup {
|
||||
this.filter();
|
||||
}
|
||||
|
||||
/** Closes the whole stack of menus, the first one included, before the action runs. */
|
||||
private pickThen(action: () => void): () => void {
|
||||
return () => {
|
||||
this.root().close();
|
||||
action();
|
||||
};
|
||||
}
|
||||
|
||||
/** The first menu of a stack of submenus; a menu on its own is its own root. */
|
||||
private root(): Menu {
|
||||
return this.under ? this.under.root() : this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens `entries` beside `row`, the first entry level with it: right of this menu, or left of it
|
||||
* where the canvas ends. Another submenu standing open closes first.
|
||||
*/
|
||||
private openSubmenu(row: SubmenuRow, entries: MenuEntry[]): void {
|
||||
this.cancelHover();
|
||||
if (this.child?.row === row || !this.isOpen) return;
|
||||
this.closeSubmenu();
|
||||
const menu = new Menu(entries, { under: this });
|
||||
const self = this.getGlobalPosition();
|
||||
const at = row.getGlobalPosition();
|
||||
const { w } = menu.measure();
|
||||
const right = self.x + this.w - SUBMENU_OVERLAP;
|
||||
const x = right + w <= ui().app.screen.width ? right : self.x - w + SUBMENU_OVERLAP;
|
||||
menu.onClose = () => {
|
||||
row.setHeld(false);
|
||||
if (this.child?.menu === menu) this.child = null;
|
||||
};
|
||||
this.child = { menu, row };
|
||||
row.setHeld(true);
|
||||
menu.open(x, at.y - PADDING);
|
||||
}
|
||||
|
||||
private closeSubmenu(): void {
|
||||
this.child?.menu.close();
|
||||
}
|
||||
|
||||
private later(action: () => void): void {
|
||||
this.cancelHover();
|
||||
this.hoverTimer = setTimeout(() => {
|
||||
this.hoverTimer = null;
|
||||
if (this.isOpen) action();
|
||||
}, SUBMENU_DELAY_MS);
|
||||
}
|
||||
|
||||
private cancelHover(): void {
|
||||
if (this.hoverTimer !== null) clearTimeout(this.hoverTimer);
|
||||
this.hoverTimer = null;
|
||||
}
|
||||
|
||||
/** 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()));
|
||||
@@ -109,8 +207,11 @@ export class Menu extends Popup {
|
||||
if (this.search) ui().focus.focus(this.search);
|
||||
}
|
||||
|
||||
/** Closes a submenu standing open first; the menu it hangs off becomes the current one again. */
|
||||
override close(): void {
|
||||
if (current === this) current = null;
|
||||
this.cancelHover();
|
||||
this.closeSubmenu();
|
||||
if (current === this) current = this.under?.isOpen ? this.under : null;
|
||||
super.close();
|
||||
}
|
||||
|
||||
@@ -153,6 +254,13 @@ export class Menu extends Popup {
|
||||
.map((child) => (child instanceof Button ? child.hasIcon : null));
|
||||
}
|
||||
|
||||
/** Per entry of `rowsDrawn`, whether it opens a submenu; `null` for a separator (test hook). */
|
||||
submenusDrawn(): (boolean | null)[] {
|
||||
return this.list.children
|
||||
.filter((child) => child !== this.empty && child.visible)
|
||||
.map((child) => (child instanceof Button ? child.opensMenu : null));
|
||||
}
|
||||
|
||||
private filter(): void {
|
||||
const query = this.search?.value.trim().toLowerCase() ?? '';
|
||||
const shown = query ? this.ranked(query) : groupsOf(this.rows);
|
||||
@@ -194,6 +302,55 @@ export class Menu extends Popup {
|
||||
}
|
||||
}
|
||||
|
||||
/** An entry that opens a submenu: a triangle pointing right at its end says so. */
|
||||
class SubmenuRow extends Button {
|
||||
private readonly arrow = new Graphics();
|
||||
/** On while its submenu stands open, so the row stays lit as the pointer moves over into it. */
|
||||
private held = false;
|
||||
|
||||
constructor(label: string, options: ButtonOptions) {
|
||||
super(label, options);
|
||||
this.addChild(this.arrow);
|
||||
}
|
||||
|
||||
override get opensMenu(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
setHeld(held: boolean): void {
|
||||
if (held === this.held) return;
|
||||
this.held = held;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
const size = super.measure();
|
||||
return { w: size.w + CHEVRON_SLOT, h: size.h };
|
||||
}
|
||||
|
||||
protected override trailingRoom(): number {
|
||||
return CHEVRON_SLOT;
|
||||
}
|
||||
|
||||
protected override drawBackground(): void {
|
||||
super.drawBackground();
|
||||
if (this.held && !this.hovered)
|
||||
this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(Theme.colors.controlHover);
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
const x = this.w - CHEVRON_SLOT / 2 - Theme.padding / 2;
|
||||
const y = this.h / 2;
|
||||
this.arrow
|
||||
.clear()
|
||||
.moveTo(x - 2, y - 4)
|
||||
.lineTo(x + 3, y)
|
||||
.lineTo(x - 2, y + 4)
|
||||
.fill(Theme.colors.textMuted);
|
||||
}
|
||||
}
|
||||
|
||||
function tickIcon(ticked: boolean): string {
|
||||
return ticked ? UI_ICON_SVG.ticked : UI_ICON_SVG.unticked;
|
||||
}
|
||||
|
||||
+20
-9
@@ -11,6 +11,11 @@ export interface PopupOptions {
|
||||
gap?: number;
|
||||
/** Modal popups dim the screen and ignore click-away; only Escape or an action closes them. */
|
||||
modal?: boolean;
|
||||
/**
|
||||
* False for a popup that hangs off another one, a submenu: a backdrop of its own would cover the
|
||||
* popup below it, and that one's backdrop and Escape close both anyway.
|
||||
*/
|
||||
backdrop?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +54,7 @@ export class Popup extends Widget {
|
||||
readonly content: VStack;
|
||||
onClose?: () => void;
|
||||
private readonly modal: boolean;
|
||||
private readonly hasBackdrop: boolean;
|
||||
/** Where `open()` was asked to put the top-left corner, before clamping to the screen. */
|
||||
private openX = 0;
|
||||
private openY = 0;
|
||||
@@ -56,6 +62,7 @@ export class Popup extends Widget {
|
||||
constructor(options: PopupOptions = {}) {
|
||||
super();
|
||||
this.modal = options.modal ?? false;
|
||||
this.hasBackdrop = options.backdrop ?? true;
|
||||
this.content = new VStack({ padding: options.padding ?? Theme.padding, gap: options.gap ?? Theme.gap });
|
||||
this.addChild(this.bg, this.content);
|
||||
this.backdrop.eventMode = 'static';
|
||||
@@ -66,7 +73,7 @@ export class Popup extends Widget {
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
|
||||
}
|
||||
|
||||
private get isOpen(): boolean {
|
||||
get isOpen(): boolean {
|
||||
return this.parent !== null;
|
||||
}
|
||||
|
||||
@@ -74,17 +81,20 @@ export class Popup extends Widget {
|
||||
open(globalX: number, globalY: number): void {
|
||||
const { overlay, app, focus } = ui();
|
||||
const screen = app.screen;
|
||||
this.backdrop
|
||||
.clear()
|
||||
.rect(0, 0, screen.width, screen.height)
|
||||
.fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 });
|
||||
this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height);
|
||||
overlay.addChild(this.backdrop, this);
|
||||
if (this.hasBackdrop) {
|
||||
this.backdrop
|
||||
.clear()
|
||||
.rect(0, 0, screen.width, screen.height)
|
||||
.fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 });
|
||||
this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height);
|
||||
overlay.addChild(this.backdrop);
|
||||
window.addEventListener('keydown', this.onEscape, { capture: true });
|
||||
}
|
||||
overlay.addChild(this);
|
||||
this.openX = globalX;
|
||||
this.openY = globalY;
|
||||
this.place();
|
||||
focus.blur();
|
||||
window.addEventListener('keydown', this.onEscape, { capture: true });
|
||||
}
|
||||
|
||||
/** Re-measures an open popup after its content changed size. No-op while closed. */
|
||||
@@ -118,7 +128,8 @@ export class Popup extends Widget {
|
||||
close(): void {
|
||||
if (!this.isOpen) return;
|
||||
window.removeEventListener('keydown', this.onEscape, { capture: true });
|
||||
ui().overlay.removeChild(this.backdrop, this);
|
||||
if (this.backdrop.parent) ui().overlay.removeChild(this.backdrop);
|
||||
ui().overlay.removeChild(this);
|
||||
// A field inside the popup would keep the keyboard bridge's DOM focus after the popup is gone,
|
||||
// swallowing every later Delete or Ctrl+Z. Blurring here mirrors the blur in `open()`.
|
||||
ui().focus.blur();
|
||||
|
||||
Reference in new issue
Block a user