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
166
apps/desktop/src/traymenu/TrayMenu.tsx
Normal file
166
apps/desktop/src/traymenu/TrayMenu.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// One tray menu panel (tray-menu.html): the root menu or one submenu, drawn with the design system's menu look. The
|
||||
// window around it belongs to Rust (menu_panel.rs); the panel only measures itself and reports what the owner does.
|
||||
import { AppWindowIcon } from '@phosphor-icons/react/dist/csr/AppWindow';
|
||||
import { ArrowsClockwiseIcon } from '@phosphor-icons/react/dist/csr/ArrowsClockwise';
|
||||
import { BellSlashIcon } from '@phosphor-icons/react/dist/csr/BellSlash';
|
||||
import { CaretRightIcon } from '@phosphor-icons/react/dist/csr/CaretRight';
|
||||
import { DeviceMobileIcon } from '@phosphor-icons/react/dist/csr/DeviceMobile';
|
||||
import { DownloadSimpleIcon } from '@phosphor-icons/react/dist/csr/DownloadSimple';
|
||||
import { GearSixIcon } from '@phosphor-icons/react/dist/csr/GearSix';
|
||||
import { PowerIcon } from '@phosphor-icons/react/dist/csr/Power';
|
||||
import { PushPinIcon } from '@phosphor-icons/react/dist/csr/PushPin';
|
||||
import { PushPinSlashIcon } from '@phosphor-icons/react/dist/csr/PushPinSlash';
|
||||
import { SquaresFourIcon } from '@phosphor-icons/react/dist/csr/SquaresFour';
|
||||
import { XIcon } from '@phosphor-icons/react/dist/csr/X';
|
||||
import { type KeyboardEvent, type MouseEvent, type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { filterNodes, findPath, isEnabled, isTearOff, keepActive, type MenuNode, nextIndex, type Row, rows, split } from './model';
|
||||
|
||||
export interface MenuBridge {
|
||||
ready(width: number, height: number): void;
|
||||
openSub(key: string, top: number, focus: boolean): void;
|
||||
pinSub(key: string, top: number): void;
|
||||
closeSubs(): void;
|
||||
close(refocusParent: boolean): void;
|
||||
dismiss(): void;
|
||||
pin(on: boolean): void;
|
||||
select(id: string): void;
|
||||
}
|
||||
|
||||
const WORDS = {
|
||||
en: { title: 'storebound', pin: 'Pin (stays open)', unpin: 'Unpin', close: 'Close', gone: 'This menu is gone for now.', filter: 'Filter', none: 'Nothing matches', tearOff: 'Ctrl+click or middle-click: pin' },
|
||||
tr: { title: 'storebound', pin: 'Sabitle (açık kalır)', unpin: 'Sabitlemeyi kaldır', close: 'Kapat', gone: 'Bu menü şu an yok.', filter: 'Süz', none: 'Eşleşen yok', tearOff: 'Ctrl+tık veya orta tık: sabitle' },
|
||||
} satisfies Record<'en' | 'tr', Record<string, string>>;
|
||||
export type MenuLang = keyof typeof WORDS;
|
||||
|
||||
const ICON: Record<string, ReactNode> = {
|
||||
'tray-open': <AppWindowIcon />, 'tray-read': <ArrowsClockwiseIcon />, 'tray-mute': <BellSlashIcon />,
|
||||
'tray-update': <DownloadSimpleIcon />, 'tray-settings': <GearSixIcon />, 'tray-quit': <PowerIcon />,
|
||||
};
|
||||
const iconOf = (n: Row): ReactNode => (n.kind === 'sub' ? <SquaresFourIcon /> : ICON[n.id] ?? (n.id.startsWith('tray-app:') ? <DeviceMobileIcon /> : null));
|
||||
|
||||
export function TrayMenu({ nodes, path, pinned, hasParent, focusFirst, bridge, lang = 'en', openKey }: {
|
||||
nodes: MenuNode[]; path: string[]; pinned: boolean; hasParent: boolean; focusFirst?: boolean; bridge: MenuBridge; lang?: MenuLang;
|
||||
/** The submenu shown as open (the dev demo; in the app Rust knows). */
|
||||
openKey?: string;
|
||||
}) {
|
||||
const w = WORDS[lang];
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [active, setActive] = useState(-1);
|
||||
const [open, setOpen] = useState<string | null>(openKey ?? null);
|
||||
const hover = useRef<number | undefined>(undefined);
|
||||
const found = useMemo(() => findPath(nodes, path), [nodes, path]);
|
||||
const items = useMemo(() => (found ? filterNodes(found.items, query) : []), [found, query]);
|
||||
const list = useMemo(() => rows(items), [items]);
|
||||
const prev = useRef<Row[]>(list);
|
||||
|
||||
// a live update keeps the highlight on the same row
|
||||
useEffect(() => {
|
||||
setActive(a => keepActive(prev.current, list, a));
|
||||
prev.current = list;
|
||||
}, [list]);
|
||||
useEffect(() => {
|
||||
if (focusFirst) setActive(nextIndex(-1, 1, list.map(isEnabled)));
|
||||
ref.current?.focus({ preventScroll: true });
|
||||
// only on the first render
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(hover.current), []);
|
||||
|
||||
// the window takes the panel's size (Rust places it inside the work area)
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let last = '';
|
||||
const report = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const size = `${Math.ceil(r.width)}x${Math.ceil(r.height)}`;
|
||||
if (size !== last) { last = size; bridge.ready(Math.ceil(r.width), Math.ceil(r.height)); }
|
||||
};
|
||||
report();
|
||||
const ro = new ResizeObserver(report);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [bridge]);
|
||||
|
||||
const rowTop = (i: number) => ref.current?.querySelector<HTMLElement>(`[data-i="${i}"]`)?.getBoundingClientRect().top ?? 0;
|
||||
const openSub = (i: number, focus: boolean) => {
|
||||
const n = list[i];
|
||||
if (n?.kind !== 'sub') return;
|
||||
setOpen(n.key);
|
||||
bridge.openSub(n.key, rowTop(i), focus);
|
||||
};
|
||||
const pick = (i: number, e?: { button: number; ctrlKey: boolean; metaKey: boolean }) => {
|
||||
const n = list[i];
|
||||
if (!n || !isEnabled(n)) return;
|
||||
if (n.kind === 'sub') {
|
||||
if (e && isTearOff(e)) { bridge.pinSub(n.key, rowTop(i)); return; }
|
||||
openSub(i, !e);
|
||||
return;
|
||||
}
|
||||
bridge.select(n.id);
|
||||
};
|
||||
const back = () => (hasParent ? bridge.close(true) : pinned ? undefined : bridge.dismiss());
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const k = e.key;
|
||||
const en = list.map(isEnabled);
|
||||
const stop = () => { e.preventDefault(); e.stopPropagation(); };
|
||||
if (k === 'ArrowDown') { stop(); setActive(a => nextIndex(a, 1, en)); }
|
||||
else if (k === 'ArrowUp') { stop(); setActive(a => nextIndex(a, -1, en)); }
|
||||
else if (k === 'Home' || k === 'PageUp') { stop(); setActive(nextIndex(-1, 1, en)); }
|
||||
else if (k === 'End' || k === 'PageDown') { stop(); setActive(nextIndex(-1, -1, en)); }
|
||||
else if (k === 'Enter' || k === ' ') { stop(); if (active >= 0) pick(active); }
|
||||
else if (k === 'ArrowRight') { stop(); if (list[active]?.kind === 'sub') openSub(active, true); }
|
||||
else if (k === 'ArrowLeft') { stop(); if (hasParent) bridge.close(true); }
|
||||
else if (k === 'Escape') { stop(); if (query) setQuery(''); else if (pinned) bridge.close(false); else back(); }
|
||||
else if (k === 'Backspace') { stop(); setQuery(q => q.slice(0, -1)); }
|
||||
else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { stop(); setQuery(q => q + k); setActive(0); }
|
||||
};
|
||||
|
||||
// a pinned panel whose submenu is gone keeps a name: its key
|
||||
const title = !found ? (path[path.length - 1] ?? w.title).replace(/-/g, ' ') : found.label == null ? w.title : split(found.label).text;
|
||||
const onRowMove = (i: number, n: Row) => {
|
||||
if (active !== i) setActive(isEnabled(n) ? i : -1);
|
||||
window.clearTimeout(hover.current);
|
||||
if (n.kind === 'sub') {
|
||||
if (open !== n.key) hover.current = window.setTimeout(() => openSub(i, false), 120);
|
||||
} else if (open) {
|
||||
hover.current = window.setTimeout(() => { setOpen(null); bridge.closeSubs(); }, 200);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`tm-panel${pinned ? ' pinned' : ''}`} role="menu" tabIndex={-1} onKeyDown={onKey} aria-label={title}
|
||||
onContextMenu={e => e.preventDefault()}>
|
||||
<div className="tm-head" data-tauri-drag-region={pinned ? '' : undefined}>
|
||||
<span className="tm-title" data-tauri-drag-region={pinned ? '' : undefined}>{query ? <><span className="tm-q">{query}</span><span className="tm-caret" /></> : title}</span>
|
||||
<button type="button" className={`tm-btn${pinned ? ' on' : ''}`} title={pinned ? w.unpin : w.pin} aria-label={pinned ? w.unpin : w.pin} aria-pressed={pinned}
|
||||
onClick={() => bridge.pin(!pinned)}>{pinned ? <PushPinSlashIcon /> : <PushPinIcon />}</button>
|
||||
{pinned && <button type="button" className="tm-btn" title={w.close} aria-label={w.close} onClick={() => bridge.close(false)}><XIcon /></button>}
|
||||
</div>
|
||||
{!found && <div className="tm-gone">{w.gone}</div>}
|
||||
{found && !list.length && <div className="tm-gone">{query ? w.none : '—'}</div>}
|
||||
{items.map((n, idx) => {
|
||||
if (n.kind === 'sep') return <div key={`s${idx}`} className="ds-menu-sep" role="separator" />;
|
||||
const i = list.indexOf(n);
|
||||
const { text, value } = split(n.label);
|
||||
const dis = !isEnabled(n);
|
||||
return (
|
||||
<div key={n.kind === 'sub' ? `sub:${n.key}` : n.id} data-i={i} role="menuitem" aria-disabled={dis || undefined}
|
||||
aria-haspopup={n.kind === 'sub' ? 'menu' : undefined} aria-expanded={n.kind === 'sub' ? open === n.key : undefined}
|
||||
title={n.kind === 'sub' ? w.tearOff : undefined}
|
||||
className={`ds-menu-item tm-row${i === active ? ' active' : ''}${dis ? ' disabled' : ''}${n.kind === 'sub' && open === n.key ? ' open' : ''}`}
|
||||
onPointerMove={() => onRowMove(i, n)}
|
||||
onMouseDown={(e: MouseEvent) => { if (e.button === 1) e.preventDefault(); }}
|
||||
onAuxClick={(e: MouseEvent) => { if (e.button === 1) pick(i, e); }}
|
||||
onClick={(e: MouseEvent) => pick(i, e)}>
|
||||
<span className="ds-menu-ic">{iconOf(n)}</span>
|
||||
<span className="ds-menu-l">{text}</span>
|
||||
{value && <span className="tm-value">{value}</span>}
|
||||
{n.kind === 'sub' && <CaretRightIcon className="ds-menu-sub" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
apps/desktop/src/traymenu/demo.tsx
Normal file
55
apps/desktop/src/traymenu/demo.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// Dev only (a plain browser, never in the release bundle): the tray menu panels on fixture data, as the tray would show
|
||||
// them: the root menu with Apps open beside it and a pinned Apps panel, whose values change every few seconds the way a
|
||||
// store read changes them (the panels update in place).
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { MenuNode } from './model';
|
||||
import { type MenuBridge, type MenuLang, TrayMenu } from './TrayMenu';
|
||||
|
||||
const STATES = [
|
||||
['App Store: In review · Play: Live', 'App Store: Ready for sale · Play: Live'],
|
||||
['Play: Rolling out 20%', 'Play: Rolling out 50%', 'Play: Rolling out 100%'],
|
||||
['App Store: Rejected', 'App Store: Rejected'],
|
||||
['App Store: Waiting for review', 'App Store: In review'],
|
||||
];
|
||||
const NAMES = ['Star Dash', 'Puzzle Garden', 'Pocket Chef', 'Night Runner'];
|
||||
|
||||
/** The tree tray.rs builds (menu_model), with fixture apps. */
|
||||
export function demoNodes(tick: number, lang: MenuLang): MenuNode[] {
|
||||
const tr = lang === 'tr';
|
||||
const item = (id: string, label: string, enabled = true): MenuNode => ({ kind: 'item', id, label, enabled });
|
||||
return [
|
||||
item('tray-open', tr ? 'storebound\'u aç' : 'Open storebound'),
|
||||
item('tray-read', tr ? 'Mağazaları şimdi oku' : 'Read the stores now'),
|
||||
{ kind: 'sep' },
|
||||
{ kind: 'sub', key: 'apps', label: tr ? 'Uygulamalar' : 'Apps', items: NAMES.map((n, i) => item(`tray-app:k${i}`, `${n} · ${STATES[i]![tick % STATES[i]!.length]}`)) },
|
||||
{ kind: 'sep' },
|
||||
item('tray-mute', tr ? 'Bildirimleri 1 saat sustur' : 'Mute notifications for 1 hour'),
|
||||
item('tray-update', tr ? 'Güncellemeleri denetle' : 'Check for updates'),
|
||||
item('tray-settings', tr ? 'Ayarlar' : 'Settings'),
|
||||
{ kind: 'sep' },
|
||||
item('tray-quit', tr ? 'storebound\'dan çık' : 'Quit storebound'),
|
||||
];
|
||||
}
|
||||
|
||||
const log = (what: string) => (...a: unknown[]) => console.info('tray menu', what, ...a);
|
||||
const demoBridge: MenuBridge = { ready: () => {}, openSub: log('openSub'), pinSub: log('pinSub'), closeSubs: () => {}, close: log('close'), dismiss: log('dismiss'), pin: log('pin'), select: log('select') };
|
||||
|
||||
export function Demo({ lang }: { lang: MenuLang }) {
|
||||
const [tick, setTick] = useState(0);
|
||||
const q = new URLSearchParams(location.search);
|
||||
const still = q.has('still');
|
||||
useEffect(() => {
|
||||
if (still) return;
|
||||
const t = window.setInterval(() => setTick(n => n + 1), 3000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [still]);
|
||||
const nodes = useMemo(() => demoNodes(tick, lang), [tick, lang]);
|
||||
return (
|
||||
<>
|
||||
<div className="tm-demo-at" style={{ left: 24, top: 160 }}><TrayMenu nodes={nodes} path={[]} pinned={false} hasParent={false} bridge={demoBridge} lang={lang} openKey="apps" /></div>
|
||||
<div className="tm-demo-at" style={{ left: 262, top: 262 }}><TrayMenu nodes={nodes} path={['apps']} pinned={false} hasParent focusFirst bridge={demoBridge} lang={lang} /></div>
|
||||
<div className="tm-demo-at" style={{ left: 690, top: 24 }}><TrayMenu nodes={nodes} path={['apps']} pinned hasParent={false} bridge={demoBridge} lang={lang} /></div>
|
||||
<div className="tm-demo-at" style={{ left: 690, top: 260 }}><TrayMenu nodes={nodes} path={['gone']} pinned hasParent={false} bridge={demoBridge} lang={lang} /></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
apps/desktop/src/traymenu/main.tsx
Normal file
70
apps/desktop/src/traymenu/main.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// The tray menu panel window's entry (tray-menu.html; one window per panel, made by menu_panel.rs). Theme and language
|
||||
// from the same prefs as the main window; the tree and every live update from Rust (`menu-model`). In a plain dev
|
||||
// browser it shows a demo with fixture data instead (demo.tsx, never in the release bundle).
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { applyPrefs, loadPrefs, resolveLang } from '../prefs';
|
||||
import { bridge, inTauri, menuState, onModel, onPanel } from './menu-api';
|
||||
import type { MenuNode, PanelInfo } from './model';
|
||||
import { type MenuLang, TrayMenu } from './TrayMenu';
|
||||
import '../styles.css';
|
||||
import '../design.css';
|
||||
import './traymenu.css';
|
||||
|
||||
const sys = () => (typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
|
||||
function prefs(): MenuLang {
|
||||
const p = loadPrefs();
|
||||
if (import.meta.env.DEV) {
|
||||
const q = new URLSearchParams(location.search);
|
||||
const th = q.get('theme'), lg = q.get('lang');
|
||||
if (th === 'light' || th === 'dark') p.theme = th;
|
||||
if (lg === 'en' || lg === 'tr') p.lang = lg;
|
||||
}
|
||||
applyPrefs(p, document.documentElement, sys());
|
||||
return resolveLang(p.lang, sys());
|
||||
}
|
||||
|
||||
/** One panel window: its state from Rust, the live tree, pin changes. */
|
||||
function Panel({ lang }: { lang: MenuLang }) {
|
||||
const [info, setInfo] = useState<PanelInfo | null>(null);
|
||||
const [nodes, setNodes] = useState<MenuNode[]>([]);
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const [focusTick, setFocusTick] = useState(0);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const offs = [
|
||||
onModel(setNodes),
|
||||
onPanel(word => {
|
||||
if (word === 'pinned') setPinned(true);
|
||||
else if (word === 'unpinned') setPinned(false);
|
||||
else if (word === 'focus') setFocusTick(n => n + 1);
|
||||
}),
|
||||
];
|
||||
menuState().then((s: PanelInfo | null) => {
|
||||
if (!live || !s) return;
|
||||
setInfo(s);
|
||||
setNodes(s.nodes);
|
||||
setPinned(s.pinned);
|
||||
}, () => {});
|
||||
return () => { live = false; for (const off of offs) off(); };
|
||||
}, []);
|
||||
useEffect(() => { if (focusTick) document.querySelector<HTMLElement>('.tm-panel')?.focus(); }, [focusTick]);
|
||||
if (!info) return null;
|
||||
return <TrayMenu nodes={nodes} path={info.path} pinned={pinned} hasParent={info.hasParent && !pinned} focusFirst={info.focusFirst} bridge={bridge} lang={lang} />;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
let lang = prefs();
|
||||
const root = createRoot(document.getElementById('menu')!);
|
||||
if (!inTauri() && import.meta.env.DEV) {
|
||||
const { Demo } = await import('./demo');
|
||||
document.body.classList.add('tm-demo');
|
||||
root.render(<Demo lang={lang} />);
|
||||
return;
|
||||
}
|
||||
const render = () => root.render(<Panel key={lang} lang={lang} />);
|
||||
render();
|
||||
// theme or language changed in the main window (same origin: shared localStorage)
|
||||
window.addEventListener('storage', () => { const l = prefs(); if (l !== lang) { lang = l; render(); } });
|
||||
})();
|
||||
33
apps/desktop/src/traymenu/menu-api.ts
Normal file
33
apps/desktop/src/traymenu/menu-api.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// The tray menu panels' host calls (the panel page's counterpart of src/api.ts, kept apart so the panel bundle stays
|
||||
// small). Only `menu-*` windows may call these (capabilities/menu.json; every command re-checks the window in Rust).
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { MenuNode, PanelInfo } from './model';
|
||||
import type { MenuBridge } from './TrayMenu';
|
||||
|
||||
export const inTauri = (): boolean => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
|
||||
const call = (cmd: string, args?: Record<string, unknown>) => { invoke(cmd, args).catch(() => {}); };
|
||||
|
||||
function sub<T>(ev: string, fn: (p: T) => void): () => void {
|
||||
const un = listen<T>(ev, e => fn(e.payload));
|
||||
return () => { un.then(f => f()).catch(() => {}); };
|
||||
}
|
||||
|
||||
/** This panel: its path into the tree, pinned or not, and the tree now. */
|
||||
export const menuState = (): Promise<PanelInfo | null> => invoke<PanelInfo | null>('menu_state').catch(() => null);
|
||||
/** Every change of the tree (a store read, mute, language): the panel updates in place. */
|
||||
export const onModel = (fn: (nodes: MenuNode[]) => void) => sub<MenuNode[]>('menu-model', fn);
|
||||
/** Rust about this panel: `pinned`, `unpinned`, `focus`. */
|
||||
export const onPanel = (fn: (word: string) => void) => sub<string>('menu-panel', fn);
|
||||
|
||||
export const bridge: MenuBridge = {
|
||||
ready: (width, height) => call('menu_ready', { width, height }),
|
||||
openSub: (key, top, focus) => call('menu_open_sub', { key, top, focus }),
|
||||
pinSub: (key, top) => call('menu_pin_sub', { key, top }),
|
||||
closeSubs: () => call('menu_close_subs'),
|
||||
close: refocusParent => call('menu_close', { refocusParent }),
|
||||
dismiss: () => call('menu_dismiss'),
|
||||
pin: pinned => call('menu_pin', { pinned }),
|
||||
select: id => call('menu_select', { id }),
|
||||
};
|
||||
75
apps/desktop/src/traymenu/model.ts
Normal file
75
apps/desktop/src/traymenu/model.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// The tray menu panels' pure half (tested in tests/tray-menu.test.ts): the tree Rust sends (menu_panel.rs `Node`),
|
||||
// walking it by stable submenu keys, splitting a row into its text and live value, type-to-filter, keyboard moves and
|
||||
// keeping the highlighted row on the same item when a live update arrives.
|
||||
|
||||
export type MenuNode =
|
||||
| { kind: 'item'; id: string; label: string; enabled: boolean }
|
||||
| { kind: 'sep' }
|
||||
| { kind: 'sub'; key: string; label: string; items: MenuNode[] };
|
||||
export type Row = Exclude<MenuNode, { kind: 'sep' }>;
|
||||
|
||||
/** What menu_state returns for one panel window. */
|
||||
export interface PanelInfo {
|
||||
label: string;
|
||||
path: string[];
|
||||
pinned: boolean;
|
||||
hasParent: boolean;
|
||||
focusFirst: boolean;
|
||||
nodes: MenuNode[];
|
||||
}
|
||||
|
||||
/** "First · App Store: In review" -> { text: 'First', value: 'App Store: In review' } (the value is right-aligned). */
|
||||
export function split(label: string): { text: string; value?: string } {
|
||||
const at = label.indexOf('·');
|
||||
if (at <= 0) return { text: label.trim() };
|
||||
const value = label.slice(at + 1).trim();
|
||||
return value ? { text: label.slice(0, at).trim(), value } : { text: label.slice(0, at).trim() };
|
||||
}
|
||||
|
||||
/** The items at `path` (submenu keys) and the submenu's label, or null when that submenu is gone. */
|
||||
export function findPath(nodes: readonly MenuNode[], path: readonly string[]): { items: MenuNode[]; label: string | null } | null {
|
||||
let items = nodes as MenuNode[];
|
||||
let label: string | null = null;
|
||||
for (const key of path) {
|
||||
const next = items.find((n): n is Extract<MenuNode, { kind: 'sub' }> => n.kind === 'sub' && n.key === key);
|
||||
if (!next) return null;
|
||||
items = next.items;
|
||||
label = next.label;
|
||||
}
|
||||
return { items, label };
|
||||
}
|
||||
|
||||
/** Type-to-filter: rows whose label holds every word; separators drop out while filtering. */
|
||||
export function filterNodes(items: readonly MenuNode[], query: string): MenuNode[] {
|
||||
const words = query.toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return items as MenuNode[];
|
||||
return items.filter(n => n.kind !== 'sep' && words.every(w => n.label.toLocaleLowerCase().includes(w)));
|
||||
}
|
||||
|
||||
export const rows = (items: readonly MenuNode[]): Row[] => items.filter((n): n is Row => n.kind !== 'sep');
|
||||
export const isEnabled = (n: Row): boolean => n.kind === 'sub' || n.enabled;
|
||||
export const rowId = (n: Row): string => (n.kind === 'sub' ? `sub:${n.key}` : n.id);
|
||||
|
||||
/** The next enabled row from `current` in `dir`, wrapping; -1 when none. `current` -1 starts before the first. */
|
||||
export function nextIndex(current: number, dir: 1 | -1, enabled: readonly boolean[]): number {
|
||||
const n = enabled.length;
|
||||
if (!n) return -1;
|
||||
const from = current < 0 ? (dir > 0 ? -1 : n) : current;
|
||||
for (let step = 1; step <= n; step += 1) {
|
||||
const i = (((from + dir * step) % n) + n) % n;
|
||||
if (enabled[i]) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** A live update must not move the highlight: the same id / key in the new rows, else the same place (clamped). */
|
||||
export function keepActive(before: readonly Row[], after: readonly Row[], active: number): number {
|
||||
if (active < 0 || !after.length) return -1;
|
||||
const was = before[active];
|
||||
if (!was) return Math.min(active, after.length - 1);
|
||||
const found = after.findIndex(n => rowId(n) === rowId(was));
|
||||
return found >= 0 ? found : Math.min(active, after.length - 1);
|
||||
}
|
||||
|
||||
/** Ctrl / Cmd + click or a middle click on a submenu row tears it off as a pinned panel. */
|
||||
export const isTearOff = (e: { button: number; ctrlKey: boolean; metaKey: boolean }): boolean => e.button === 1 || ((e.ctrlKey || e.metaKey) && e.button === 0);
|
||||
28
apps/desktop/src/traymenu/traymenu.css
Normal file
28
apps/desktop/src/traymenu/traymenu.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/* The tray menu panels (tray-menu.html): the design system's menu (.ds-menu-*) in its own small window. The window is
|
||||
transparent and exactly as large as the panel (menu_panel.rs sizes it from what the panel measures). */
|
||||
html:has(body.tm-page), body.tm-page { margin: 0; background: transparent; overflow: hidden; }
|
||||
body.tm-page { font: var(--ds-t-body)/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); }
|
||||
|
||||
.tm-panel { box-sizing: border-box; width: max-content; min-width: 230px; max-width: 380px; max-height: 860px; overflow: auto; padding: 4px 5px 5px;
|
||||
border-radius: 12px; background: var(--ds-raised); border: 1px solid var(--ds-stroke-strong);
|
||||
font-size: var(--ds-t-body); color: var(--ink); outline: none; user-select: none; cursor: default; }
|
||||
.tm-panel.pinned { border-color: color-mix(in srgb, var(--accent) 45%, var(--ds-stroke-strong)); }
|
||||
.tm-head { display: flex; align-items: center; gap: 2px; height: 28px; padding: 0 2px 0 9px; margin-bottom: 2px; }
|
||||
.tm-panel.pinned .tm-head { cursor: grab; }
|
||||
.tm-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--ds-t-micro);
|
||||
font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
|
||||
.tm-q { text-transform: none; letter-spacing: 0; color: var(--ink); font-weight: 500; }
|
||||
.tm-caret { display: inline-block; width: 1px; height: 11px; margin-left: 1px; vertical-align: -1px; background: var(--accent); }
|
||||
.tm-btn { flex: none; width: 24px; height: 24px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px;
|
||||
background: transparent; color: var(--faint); cursor: pointer; }
|
||||
.tm-btn svg { width: 14px; height: 14px; }
|
||||
.tm-btn:hover, .tm-btn:focus-visible { background: var(--ds-inset); color: var(--ink); outline: none; }
|
||||
.tm-btn.on { color: var(--accent); }
|
||||
.tm-row.open:not(.active) { background: var(--ds-inset); }
|
||||
.tm-value { max-width: 190px; overflow: hidden; text-overflow: ellipsis; margin-left: 18px; color: var(--muted);
|
||||
font-size: var(--ds-t-micro); font-variant-numeric: tabular-nums; }
|
||||
.tm-gone { padding: 8px 10px 10px; color: var(--muted); font-size: var(--ds-t-micro); }
|
||||
|
||||
/* the dev demo (a plain browser): several panels side by side on a desktop-like backdrop */
|
||||
body.tm-page.tm-demo { overflow: auto; min-height: 100vh; background: linear-gradient(135deg, #3b4a6b, #1d2436); }
|
||||
.tm-demo-at { position: absolute; }
|
||||
Loading…
Add table
Add a link
Reference in a new issue