desktop: tray menu panels and pinnable context menus
The tray's right-click menu is drawn by the app: one tree from tray.rs (stable submenu keys), one frameless always-on-top menu-N window per panel rendered by tray-menu.html with the design system, placed inside the monitor's work area. A pure panel book keeps transient and pinned panels apart; pinned panels (pin button, Ctrl+click, middle click) are draggable, step aside from each other and are saved in state.json (menuPins) and restored at start. tray_update pushes the tree to the open panels, which update in place; closed panels are destroyed. The native menu, rendered from the same tree, stays as the fallback and on Linux. Only menu-* windows get the menu_* commands (menu.json). In-app context menus get the same behaviour through a pure menu stack (ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels survive the next menu, are draggable and re-read their entries from live sources so data changes update them in place. Also drops a stray diff3 marker line from CHANGELOG.md.
This commit is contained in:
parent
445ab1adbc
commit
5236f002f1
35 changed files with 2308 additions and 123 deletions
150
apps/desktop/tests/menu-stack.test.ts
Normal file
150
apps/desktop/tests/menu-stack.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// The in-app context menus' panel stack (ui/menu-stack.ts): several menus open, pinned panels that survive the next
|
||||
// menu and never sit on each other, tear-offs, live sources that a pinned panel re-reads, and the wiring in
|
||||
// ContextMenu.tsx that keeps it that way.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { MenuEntry, MenuInput } from '../src/ui/menu-logic';
|
||||
import { autoOffset, clampPos, closeMenu, closeUnpinned, LiveMenus, moveMenu, OFFSET_STEP, openMenu, pinMenu, resolvePath, type StackEntry, tearOff, unpinMenu } from '../src/ui/menu-stack';
|
||||
|
||||
const e = (uid: string, extra: Partial<StackEntry> = {}): StackEntry => ({ uid, source: `src-${uid}`, path: [], pinned: false, x: 10, y: 10, ...extra });
|
||||
const uids = (l: readonly StackEntry[]) => l.map(x => `${x.uid}${x.pinned ? '*' : ''}`);
|
||||
|
||||
describe('menu stack', () => {
|
||||
test('opening a menu closes only the unpinned ones', () => {
|
||||
let l = openMenu([], e('a'));
|
||||
l = pinMenu(l, 'a', { x: 100, y: 100 });
|
||||
l = openMenu(l, e('b'));
|
||||
l = openMenu(l, e('c'));
|
||||
expect(uids(l)).toEqual(['a*', 'c']);
|
||||
l = closeUnpinned(l);
|
||||
expect(uids(l)).toEqual(['a*']);
|
||||
expect(closeUnpinned(l)).toBe(l); // nothing to close: the same list (no re-render)
|
||||
});
|
||||
|
||||
test('a pinned panel steps aside from other pinned panels', () => {
|
||||
let l = [e('a', { pinned: true, x: 100, y: 100 }), e('b')];
|
||||
l = pinMenu(l, 'b', { x: 104, y: 98 });
|
||||
const b = l.find(x => x.uid === 'b')!;
|
||||
expect(b.pinned).toBe(true);
|
||||
expect(Math.abs(b.x - 100) >= OFFSET_STEP || Math.abs(b.y - 100) >= OFFSET_STEP).toBe(true);
|
||||
expect(autoOffset([{ x: 0, y: 0 }, { x: 24, y: 24 }], { x: 0, y: 0 })).toEqual({ x: 48, y: 48 });
|
||||
expect(autoOffset([], { x: 5, y: 5 })).toEqual({ x: 5, y: 5 });
|
||||
});
|
||||
|
||||
test('pinning the same panel again replaces the old pinned copy', () => {
|
||||
let l = [e('a', { pinned: true, source: 's', path: ['Copy'] }), e('b', { source: 's', path: ['Copy'] })];
|
||||
l = pinMenu(l, 'b', { x: 300, y: 300 });
|
||||
expect(uids(l)).toEqual(['b*']);
|
||||
// a menu without a live source (the edit menu) is never "the same" as another
|
||||
l = [e('a', { pinned: true, source: null }), e('b', { source: null })];
|
||||
expect(uids(pinMenu(l, 'b', { x: 300, y: 300 }))).toEqual(['a*', 'b*']);
|
||||
});
|
||||
|
||||
test('a torn-off submenu is its own pinned panel and the menu it came from stays open', () => {
|
||||
let l = openMenu([], e('a'));
|
||||
l = tearOff(l, e('t', { path: ['Open tab'] }), { x: 50, y: 50 });
|
||||
expect(uids(l)).toEqual(['a', 't*']);
|
||||
// the same submenu torn off again: one copy
|
||||
l = tearOff(l, e('t2', { path: ['Open tab'], source: 'src-t' }), { x: 50, y: 50 });
|
||||
expect(uids(l)).toEqual(['a', 't2*']);
|
||||
});
|
||||
|
||||
test('unpin: a root menu becomes a normal menu again, a torn-off submenu closes', () => {
|
||||
let l = [e('a', { pinned: true }), e('t', { pinned: true, path: ['Copy'] })];
|
||||
l = unpinMenu(l, 'a');
|
||||
expect(uids(l)).toEqual(['a', 't*']);
|
||||
l = unpinMenu(l, 't');
|
||||
expect(uids(l)).toEqual(['a']);
|
||||
expect(uids(closeMenu(l, 'a'))).toEqual([]);
|
||||
expect(closeMenu(l, 'zz')).toBe(l);
|
||||
});
|
||||
|
||||
test('a moved panel keeps the move; positions stay inside the window', () => {
|
||||
const l = moveMenu([e('a', { pinned: true })], 'a', { x: 400, y: 20 });
|
||||
expect(l[0]).toMatchObject({ x: 400, y: 20 });
|
||||
expect(clampPos({ x: 1900, y: -40 }, { w: 240, h: 300 }, { w: 1280, h: 800 })).toEqual({ x: 1280 - 240 - 8, y: 8 });
|
||||
expect(clampPos({ x: 100, y: 100 }, { w: 240, h: 300 }, { w: 1280, h: 800 })).toEqual({ x: 100, y: 100 });
|
||||
});
|
||||
|
||||
test('resolvePath walks submenu labels; a gone submenu gives null', () => {
|
||||
const items: MenuEntry[] = [{ label: 'Open' }, 'sep', { label: 'Copy', submenu: [{ label: 'Name' }, { label: 'More', submenu: [{ label: 'Deep' }] }] }];
|
||||
expect(resolvePath(items, [])).toBe(items);
|
||||
expect(resolvePath(items, ['Copy', 'More'])!.map(x => (x === 'sep' ? '-' : x.label))).toEqual(['Deep']);
|
||||
expect(resolvePath(items, ['Open'])).toBeNull(); // not a submenu
|
||||
expect(resolvePath(items, ['Gone'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('live sources', () => {
|
||||
const sync = () => {
|
||||
const queue: (() => void)[] = [];
|
||||
return { live: new LiveMenus(fn => queue.push(fn)), flush: () => { while (queue.length) queue.shift()!(); } };
|
||||
};
|
||||
|
||||
test('a pinned panel hears once per batch and re-reads the newest builder', () => {
|
||||
const { live, flush } = sync();
|
||||
let value = 1;
|
||||
const build = (): MenuInput => [{ label: `Rollout ${value}%` }, false];
|
||||
live.set('h#0', build);
|
||||
let heard = 0;
|
||||
const off = live.watch('h#0', () => heard++);
|
||||
value = 20;
|
||||
live.set('h#0', build);
|
||||
live.set('h#0', build);
|
||||
expect(heard).toBe(0);
|
||||
flush();
|
||||
expect(heard).toBe(1);
|
||||
expect(live.get('h#0')!.map(x => (x === 'sep' ? '-' : x.label))).toEqual(['Rollout 20%']);
|
||||
off();
|
||||
live.set('h#0', build);
|
||||
flush();
|
||||
expect(heard).toBe(1);
|
||||
});
|
||||
|
||||
test('keys a render no longer makes are dropped and their panels hear it (gone, not closed)', () => {
|
||||
const { live, flush } = sync();
|
||||
live.set('h#0', () => [{ label: 'A' }]);
|
||||
live.set('h#app:k1', () => [{ label: 'B' }]);
|
||||
live.set('other#0', () => [{ label: 'C' }]);
|
||||
let heard = 0;
|
||||
live.watch('h#app:k1', () => heard++);
|
||||
live.retain('h', new Set(['h#0']));
|
||||
flush();
|
||||
expect(heard).toBe(1);
|
||||
expect(live.get('h#app:k1')).toBeNull();
|
||||
expect(live.has('h#0') && live.has('other#0')).toBe(true);
|
||||
live.retain('h');
|
||||
expect(live.has('h#0')).toBe(false);
|
||||
expect(live.has('other#0')).toBe(true);
|
||||
});
|
||||
|
||||
test('a builder that throws reads as gone', () => {
|
||||
const { live } = sync();
|
||||
live.set('x#0', () => { throw new Error('boom'); });
|
||||
expect(live.get('x#0')).toBeNull();
|
||||
expect(live.get('missing')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wiring', () => {
|
||||
const s = readFileSync(join(import.meta.dir, '../src/ui/ContextMenu.tsx'), 'utf8');
|
||||
test('menus register live sources and pinned panels re-read them', () => {
|
||||
expect(s).toMatch(/live\.set\(source, build\)/);
|
||||
expect(s).toMatch(/live\.watch\(entry\.source, bump\)/);
|
||||
expect(s).toMatch(/resolvePath\(root, entry\.path\)/);
|
||||
});
|
||||
test('an outside click closes only the normal menus; a pick in a pinned panel keeps it', () => {
|
||||
expect(s).toMatch(/closeUnpinned\(s\)/);
|
||||
expect(s).toMatch(/done=\{\(\) => closeTransient\(false\)\}/);
|
||||
expect(s).toMatch(/ev\.button === 1 \|\| ev\.ctrlKey \|\| ev\.metaKey/);
|
||||
});
|
||||
test('the app menus carry the app key, so a pinned panel follows the app when rows reorder', () => {
|
||||
for (const f of ['src/App.tsx', 'src/screens/AppView.tsx', 'src/screens/Ignored.tsx', 'src/screens/Portfolio.tsx']) {
|
||||
const src = readFileSync(join(import.meta.dir, '..', f), 'utf8');
|
||||
const lines = src.split('\n').filter(l => l.includes('=> appMenu('));
|
||||
expect(lines.length, f).toBeGreaterThan(0);
|
||||
for (const l of lines) expect(l, f).toMatch(/`app:\$\{/);
|
||||
}
|
||||
});
|
||||
});
|
||||
85
apps/desktop/tests/tray-menu.test.ts
Normal file
85
apps/desktop/tests/tray-menu.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// The tray menu panels' page (src/traymenu/): the tree from Rust, paths by stable keys, the row split, filtering,
|
||||
// keyboard moves, a live update that keeps the highlight, and the page's wiring (its own bundle, dev demo only in dev).
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { filterNodes, findPath, isTearOff, keepActive, type MenuNode, nextIndex, rows, split } from '../src/traymenu/model';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const src = (f: string) => readFileSync(join(ROOT, f), 'utf8');
|
||||
const item = (id: string, label: string, enabled = true): MenuNode => ({ kind: 'item', id, label, enabled });
|
||||
const tree = (state: string): MenuNode[] => [
|
||||
item('tray-open', 'Open storebound'),
|
||||
{ kind: 'sep' },
|
||||
{ kind: 'sub', key: 'apps', label: 'Apps', items: [item('tray-app:k1', `First · ${state}`), item('tray-app:k2', 'Second')] },
|
||||
item('tray-none', 'No apps yet', false),
|
||||
item('tray-quit', 'Quit storebound'),
|
||||
];
|
||||
|
||||
describe('tray menu model', () => {
|
||||
test('a row splits into its text and the live value after the middle dot', () => {
|
||||
expect(split('First · App Store: In review · Play: Live')).toEqual({ text: 'First', value: 'App Store: In review · Play: Live' });
|
||||
expect(split('Settings')).toEqual({ text: 'Settings' });
|
||||
expect(split('Odd · ')).toEqual({ text: 'Odd' });
|
||||
});
|
||||
test('paths follow submenu keys; a gone submenu gives null', () => {
|
||||
expect(findPath(tree('x'), [])!.label).toBeNull();
|
||||
expect(findPath(tree('x'), ['apps'])!.items).toHaveLength(2);
|
||||
expect(findPath(tree('x'), ['apps'])!.label).toBe('Apps');
|
||||
expect(findPath(tree('x'), ['gone'])).toBeNull();
|
||||
});
|
||||
test('typing filters rows by every word, separators drop out', () => {
|
||||
const r = filterNodes(tree('x'), 'stor quit');
|
||||
expect(r.map(n => (n.kind === 'sep' ? '-' : n.label))).toEqual(['Quit storebound']);
|
||||
expect(filterNodes(tree('x'), ' ')).toHaveLength(5);
|
||||
});
|
||||
test('keyboard moves skip disabled rows and wrap', () => {
|
||||
const en = rows(tree('x')).map(n => n.kind === 'sub' || n.enabled);
|
||||
expect(en).toEqual([true, true, false, true]);
|
||||
expect(nextIndex(1, 1, en)).toBe(3);
|
||||
expect(nextIndex(3, 1, en)).toBe(0);
|
||||
expect(nextIndex(-1, 1, en)).toBe(0);
|
||||
expect(nextIndex(-1, -1, en)).toBe(3);
|
||||
expect(nextIndex(0, 1, [])).toBe(-1);
|
||||
});
|
||||
test('a live update keeps the highlight on the same row', () => {
|
||||
const before = rows(findPath(tree('In review'), ['apps'])!.items);
|
||||
const after = rows(findPath([...tree('Live')], ['apps'])!.items).slice().reverse();
|
||||
expect(keepActive(before, after, 0)).toBe(1); // First moved to index 1
|
||||
expect(keepActive(before, after.slice(0, 1), 0)).toBe(0); // gone: the same place, clamped
|
||||
expect(keepActive(before, after, -1)).toBe(-1);
|
||||
expect(keepActive(before, [], 0)).toBe(-1);
|
||||
});
|
||||
test('Ctrl / Cmd + click or a middle click tears a submenu off', () => {
|
||||
expect(isTearOff({ button: 1, ctrlKey: false, metaKey: false })).toBe(true);
|
||||
expect(isTearOff({ button: 0, ctrlKey: true, metaKey: false })).toBe(true);
|
||||
expect(isTearOff({ button: 0, ctrlKey: false, metaKey: true })).toBe(true);
|
||||
expect(isTearOff({ button: 0, ctrlKey: false, metaKey: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tray menu page', () => {
|
||||
test('its own page in the build, with the demo only behind import.meta.env.DEV', () => {
|
||||
expect(src('vite.config.ts')).toMatch(/menu: 'tray-menu\.html'/);
|
||||
expect(src('tray-menu.html')).toMatch(/src="\/src\/traymenu\/main\.tsx"/);
|
||||
const main = src('src/traymenu/main.tsx');
|
||||
expect(main).toMatch(/!inTauri\(\) && import\.meta\.env\.DEV/);
|
||||
expect(main).toMatch(/await import\('\.\/demo'\)/);
|
||||
expect(main).not.toMatch(/^import .*\.\/demo/m);
|
||||
});
|
||||
test('the page never polls: values come from menu-model pushes', () => {
|
||||
const main = src('src/traymenu/main.tsx');
|
||||
expect(src('src/traymenu/menu-api.ts')).toMatch(/sub<MenuNode\[\]>\('menu-model'/);
|
||||
expect(main).toMatch(/onModel\(setNodes\)/);
|
||||
expect(main).not.toMatch(/setInterval/);
|
||||
expect(src('src/traymenu/TrayMenu.tsx')).not.toMatch(/setInterval|requestAnimationFrame/);
|
||||
});
|
||||
test('every command the page calls is a menu command Rust grants to menu-* windows', () => {
|
||||
const api = src('src/traymenu/menu-api.ts');
|
||||
const called = [...api.matchAll(/call\('(\w+)'/g), ...api.matchAll(/invoke<[^>]*>\('(\w+)'/g)].map(m => m[1]);
|
||||
const list = src('src-tauri/src/commands_list.rs');
|
||||
const menu = list.slice(list.indexOf('MENU_COMMANDS'));
|
||||
expect(called.length).toBeGreaterThan(5);
|
||||
for (const c of called) expect(menu, c).toContain(`"${c}"`);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue