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
|
|
@ -5,7 +5,7 @@ include!("src/commands_list.rs");
|
|||
fn main() {
|
||||
tauri_build::try_build(
|
||||
tauri_build::Attributes::new()
|
||||
.app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS].concat().into_boxed_slice()))),
|
||||
.app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS, MENU_COMMANDS].concat().into_boxed_slice()))),
|
||||
)
|
||||
.expect("tauri build script failed");
|
||||
}
|
||||
|
|
|
|||
21
apps/desktop/src-tauri/capabilities/menu.json
Normal file
21
apps/desktop/src-tauri/capabilities/menu.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "menu-window",
|
||||
"description": "The tray menu panels (menu-1, menu-2...: small frameless windows the app opens on a right click on the tray icon, one per open or pinned submenu) with the app's own bundled tray-menu page only. They may listen to app events (the live menu tree) and call the menu commands: read their panel, report their size, open, pin and close panels, pick a row, which runs the same tray action as the native menu, and start a drag of their own window (a pinned panel's header). They never read the stores or reach the sidecar; every command re-checks the window label and page origin in Rust.",
|
||||
"windows": ["menu-*"],
|
||||
"local": true,
|
||||
"permissions": [
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"core:window:allow-start-dragging",
|
||||
"allow-menu-state",
|
||||
"allow-menu-ready",
|
||||
"allow-menu-open-sub",
|
||||
"allow-menu-pin-sub",
|
||||
"allow-menu-close-subs",
|
||||
"allow-menu-close",
|
||||
"allow-menu-dismiss",
|
||||
"allow-menu-pin",
|
||||
"allow-menu-select"
|
||||
]
|
||||
}
|
||||
|
|
@ -49,6 +49,11 @@ fn guard_card(w: &WebviewWindow) -> Res<()> {
|
|||
policy::require_card(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
|
||||
}
|
||||
|
||||
fn guard_menu(w: &WebviewWindow) -> Res<()> {
|
||||
let url = w.url().map_err(|e| Fail::host(e.to_string()))?;
|
||||
policy::require_menu(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
|
||||
}
|
||||
|
||||
fn saved(s: &AppState) -> Res<std::sync::MutexGuard<'_, Saved>> {
|
||||
s.saved.lock().map_err(|_| Fail::host("state poisoned"))
|
||||
}
|
||||
|
|
@ -1665,3 +1670,79 @@ pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res<Vec<Strin
|
|||
let rec = ai_fix::runs().lock().ok().and_then(|mut m| m.remove(&run_id)).ok_or_else(|| Fail::host("no such AI fix run"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || ai_fix::undo(&rec.0, &rec.1)).await.map_err(|e| Fail::host(e.to_string()))?.map_err(Fail::host)
|
||||
}
|
||||
|
||||
// ---- the tray menu panels (menu_panel.rs; only `menu-*` windows) ----
|
||||
|
||||
/// A panel: what it shows (the whole tree and its path into it).
|
||||
#[tauri::command]
|
||||
pub fn menu_state(window: WebviewWindow) -> Res<Option<crate::menu_panel::PanelInfo>> {
|
||||
guard_menu(&window)?;
|
||||
Ok(crate::menu_panel::panel_state(&window))
|
||||
}
|
||||
|
||||
/// A panel measured itself (CSS px): sized, placed inside the work area and shown.
|
||||
#[tauri::command]
|
||||
pub fn menu_ready(window: WebviewWindow, width: f64, height: f64) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::ready(&window, width, height);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens a submenu beside this panel at the row whose top is `top` (CSS px).
|
||||
#[tauri::command]
|
||||
pub fn menu_open_sub(window: WebviewWindow, key: String, top: f64, focus: bool) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::open_sub(&window, &key, top, focus, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ctrl / middle click on a submenu row: opens it as its own pinned panel.
|
||||
#[tauri::command]
|
||||
pub fn menu_pin_sub(window: WebviewWindow, key: String, top: f64) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::open_sub(&window, &key, top, false, true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The pointer moved to a plain row: this panel's submenus close.
|
||||
#[tauri::command]
|
||||
pub fn menu_close_subs(window: WebviewWindow) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::close_subs(&window);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Closes this panel (Left / Esc in a submenu, X on a pinned one).
|
||||
#[tauri::command]
|
||||
pub fn menu_close(window: WebviewWindow, refocus_parent: bool) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::close(&window, refocus_parent);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Esc on the root menu: every transient panel closes.
|
||||
#[tauri::command]
|
||||
pub fn menu_dismiss(window: WebviewWindow) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::dismiss(window.app_handle());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pins or unpins this panel.
|
||||
#[tauri::command]
|
||||
pub fn menu_pin(window: WebviewWindow, pinned: bool) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::pin(&window, pinned);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A pick: the same action as the native tray menu (an id tray.rs knows; anything else is refused).
|
||||
#[tauri::command]
|
||||
pub fn menu_select(window: WebviewWindow, id: String) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
if id.len() > 300 || crate::tray::action_of(&id).is_none() {
|
||||
return Err(Fail::host("unknown menu item"));
|
||||
}
|
||||
crate::menu_panel::select(&window, id);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,3 +56,6 @@ pub const COMMANDS: &[&str] = &[
|
|||
|
||||
/// The tray card window's commands (capabilities/card.json grants them to the `card` window only).
|
||||
pub const CARD_COMMANDS: &[&str] = &["card_get", "card_action"];
|
||||
|
||||
/// The tray menu panels' commands (capabilities/menu.json grants them to the `menu-*` windows only).
|
||||
pub const MENU_COMMANDS: &[&str] = &["menu_state", "menu_ready", "menu_open_sub", "menu_pin_sub", "menu_close_subs", "menu_close", "menu_dismiss", "menu_pin", "menu_select"];
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub mod commands;
|
|||
pub mod confirm;
|
||||
pub mod downloads;
|
||||
pub mod logs;
|
||||
pub mod menu_panel;
|
||||
pub mod policy;
|
||||
pub mod profiles;
|
||||
pub mod registry;
|
||||
|
|
@ -43,17 +44,53 @@ fn started_hidden() -> bool {
|
|||
std::env::args().any(|a| a == "--hidden")
|
||||
}
|
||||
|
||||
/// Tray icon: a click shows the window; the menu (tray.rs) opens the window, reads the stores, lists the apps with their
|
||||
/// state, mutes notifications, checks for updates, opens Settings and quits (the only real quit on Windows and Linux,
|
||||
/// where closing the window keeps the app running in the tray). The window refines tooltip, menu and icon after every
|
||||
/// read (`tray_update`).
|
||||
/// Tray icon: a left click shows the tray card (card.rs), a right click the app's own menu panels (menu_panel.rs, with
|
||||
/// the tree from tray.rs: open the window, read the stores, the apps with their state, mute, updates, Settings and Quit,
|
||||
/// the only real quit on Windows and Linux, where closing the window keeps the app running in the tray). The window
|
||||
/// refines tooltip, menu and icon after every read (`tray_update`). Linux trays send no clicks: the native menu,
|
||||
/// rendered from the same tree, stays on the icon there (and anywhere the panels fail).
|
||||
fn tray(app: &tauri::App) -> tauri::Result<()> {
|
||||
let menu = tray::initial_menu(app.handle())?;
|
||||
let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").menu(&menu).show_menu_on_left_click(false);
|
||||
let handle = app.handle();
|
||||
let panels = handle.state::<menu_panel::MenuPanels>();
|
||||
if let Ok(mut n) = panels.nodes.lock() {
|
||||
*n = tray::initial_nodes();
|
||||
}
|
||||
let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").show_menu_on_left_click(false);
|
||||
if cfg!(target_os = "linux") {
|
||||
panels.native.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
b = b.menu(&tray::native_menu(handle, &tray::initial_nodes())?);
|
||||
}
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
b = b.icon(icon.clone());
|
||||
}
|
||||
b.on_menu_event(|app, e| match tray::action_of(e.id().as_ref()) {
|
||||
b.on_menu_event(|app, e| dispatch_menu(app, e.id().as_ref()))
|
||||
.on_tray_icon_event(|t, e| {
|
||||
let TrayIconEvent::Click { button, button_state: MouseButtonState::Up, rect, position, .. } = e else { return };
|
||||
let app = t.app_handle();
|
||||
match button {
|
||||
// right click: the menu panels at the click point
|
||||
MouseButton::Right => menu_panel::open_root(app, position.x, position.y),
|
||||
// left click: the tray card next to the icon
|
||||
MouseButton::Left => {
|
||||
if app.get_webview_window(card::LABEL).is_none() {
|
||||
show_main(app);
|
||||
return;
|
||||
}
|
||||
let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0);
|
||||
let p = rect.position.to_physical::<f64>(scale);
|
||||
let s = rect.size.to_physical::<f64>(scale);
|
||||
card::toggle(app, card::Rect { x: p.x, y: p.y, w: s.width, h: s.height });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A tray menu pick (a menu panel row or the native fallback menu): tray.rs maps the id to what it does.
|
||||
pub fn dispatch_menu<R: Runtime>(app: &AppHandle<R>, id: &str) {
|
||||
match tray::action_of(id) {
|
||||
Some(tray::Action::Open) => show_main(app),
|
||||
Some(tray::Action::Quit) => app.exit(0),
|
||||
Some(tray::Action::Event(word)) => {
|
||||
|
|
@ -64,23 +101,7 @@ fn tray(app: &tauri::App) -> tauri::Result<()> {
|
|||
let _ = app.emit_to(policy::MAIN, "tray-route", route);
|
||||
}
|
||||
None => {}
|
||||
})
|
||||
.on_tray_icon_event(|t, e| {
|
||||
// left click: the tray card next to the icon (right click keeps the native menu)
|
||||
if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, rect, .. } = e {
|
||||
let app = t.app_handle();
|
||||
if app.get_webview_window(card::LABEL).is_none() {
|
||||
show_main(app);
|
||||
return;
|
||||
}
|
||||
let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0);
|
||||
let p = rect.position.to_physical::<f64>(scale);
|
||||
let s = rect.size.to_physical::<f64>(scale);
|
||||
card::toggle(app, card::Rect { x: p.x, y: p.y, w: s.width, h: s.height });
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Silent self-update: at start (after a minute) and every 30 minutes, a signed update from storebound.dev is
|
||||
|
|
@ -282,7 +303,7 @@ pub fn run() {
|
|||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| show_main(app)))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
// never restore "visible": a --hidden start would otherwise pop the window up
|
||||
.plugin(tauri_plugin_window_state::Builder::new().with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build())
|
||||
.plugin(tauri_plugin_window_state::Builder::new().with_filter(|label| !label.starts_with(menu_panel::LABEL_PREFIX)).with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build())
|
||||
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec!["--hidden"])))
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
|
|
@ -337,7 +358,10 @@ pub fn run() {
|
|||
Err(e) => logs::host("warn", &format!("autostart not set: {e}")),
|
||||
}
|
||||
app.manage(state);
|
||||
app.manage(menu_panel::MenuPanels::default());
|
||||
tray(app)?;
|
||||
// pinned tray menu panels of the last session come back
|
||||
menu_panel::restore(app.handle());
|
||||
if let Err(e) = card::create(app.handle()) {
|
||||
logs::host("warn", &format!("tray card not created: {e}"));
|
||||
}
|
||||
|
|
@ -438,6 +462,15 @@ pub fn run() {
|
|||
commands::ai_fix_undo,
|
||||
commands::card_get,
|
||||
commands::card_action,
|
||||
commands::menu_state,
|
||||
commands::menu_ready,
|
||||
commands::menu_open_sub,
|
||||
commands::menu_pin_sub,
|
||||
commands::menu_close_subs,
|
||||
commands::menu_close,
|
||||
commands::menu_dismiss,
|
||||
commands::menu_pin,
|
||||
commands::menu_select,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("storebound desktop failed to start");
|
||||
|
|
|
|||
744
apps/desktop/src-tauri/src/menu_panel.rs
Normal file
744
apps/desktop/src-tauri/src/menu_panel.rs
Normal file
|
|
@ -0,0 +1,744 @@
|
|||
//! The tray's right-click menu, drawn by the app itself instead of the OS menu (tray-menu.html, src/traymenu/).
|
||||
//!
|
||||
//! - One tree: tray.rs builds the menu as `Entry`s, turned here into `Node`s. Every submenu has a stable key (its label
|
||||
//! without the live part), so an open or pinned panel finds its submenu again after the values change.
|
||||
//! - One window per panel: the root menu and each submenu are their own frameless, always-on-top `menu-N` windows. The
|
||||
//! page measures itself and calls `menu_ready`; Rust sizes, places (inside the monitor's work area) and shows it.
|
||||
//! - The panel book (`Book`, pure, tests/menu_panel.rs) records which panels are open, which are pinned and whose child
|
||||
//! each one is. Transient panels close together (focus left all of them, Esc, a pick); pinned ones stay, can be
|
||||
//! dragged, step aside so they never cover each other and come back after a restart (`menuPins` in state.json).
|
||||
//! - Live: every `tray_update` pushes the tree to the open panels (`menu-model`), which update in place. Nothing polls.
|
||||
//! - Idle: a closed panel is destroyed, never hidden.
|
||||
//! - Fallback: when a panel window cannot be built or never reports ready, the native tray menu (rendered from the same
|
||||
//! tree) comes back. Linux always uses it (its tray sends no click events).
|
||||
//!
|
||||
//! The top half is pure and covered by tests/menu_panel.rs.
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::tray::Entry;
|
||||
|
||||
pub const LABEL_PREFIX: &str = "menu-";
|
||||
pub const PAGE: &str = "tray-menu.html";
|
||||
pub const MODEL_EVENT: &str = "menu-model";
|
||||
pub const PANEL_EVENT: &str = "menu-panel";
|
||||
/// Gap to the work area's edges (physical px at scale 1).
|
||||
pub const GAP: f64 = 8.0;
|
||||
/// The estimated size of a panel before it measured itself (logical px).
|
||||
pub const EST: (f64, f64) = (280.0, 220.0);
|
||||
|
||||
// ---------------------------------------------------------------- model
|
||||
|
||||
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Node {
|
||||
Item { id: String, label: String, enabled: bool },
|
||||
Sep,
|
||||
Sub { key: String, label: String, items: Vec<Node> },
|
||||
}
|
||||
|
||||
/// A submenu's stable key: its label without status glyphs and without the live part after ':' / '·' / '('
|
||||
/// ("Apps (3)" -> "apps", "Apps · 2 need you" -> "apps"). Pinned panels are found again by these keys.
|
||||
pub fn sub_key(label: &str) -> String {
|
||||
let head = label.split([':', '\u{00B7}', '(']).next().unwrap_or("");
|
||||
let mut out = String::new();
|
||||
for c in head.chars() {
|
||||
if c.is_alphanumeric() {
|
||||
out.extend(c.to_lowercase());
|
||||
} else if (c == ' ' || c == '-' || c == '_') && !out.is_empty() && !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let out = out.trim_end_matches('-').to_string();
|
||||
if out.is_empty() { "sub".into() } else { out }
|
||||
}
|
||||
|
||||
/// Sibling submenus with the same key get "-2", "-3"...
|
||||
pub fn unique_keys(mut nodes: Vec<Node>) -> Vec<Node> {
|
||||
let mut seen: BTreeMap<String, u32> = BTreeMap::new();
|
||||
for n in nodes.iter_mut() {
|
||||
if let Node::Sub { key, .. } = n {
|
||||
let count = seen.entry(key.clone()).or_insert(0);
|
||||
*count += 1;
|
||||
if *count > 1 {
|
||||
*key = format!("{key}-{count}");
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes
|
||||
}
|
||||
|
||||
/// The tray menu entries as panel nodes. A submenu keeps the fixed key tray.rs gives it (so a translated label never
|
||||
/// changes it), else the key comes from its label.
|
||||
pub fn from_entries(entries: &[Entry]) -> Vec<Node> {
|
||||
let nodes = entries
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Entry::Item { id, label, enabled } => Node::Item { id: id.clone(), label: label.clone(), enabled: *enabled },
|
||||
Entry::Separator => Node::Sep,
|
||||
Entry::Sub { key, label, items } => Node::Sub { key: if key.is_empty() { sub_key(label) } else { sub_key(key) }, label: label.clone(), items: from_entries(items) },
|
||||
})
|
||||
.collect();
|
||||
unique_keys(nodes)
|
||||
}
|
||||
|
||||
/// The items of the submenu at `path` (empty path: the root) and its label.
|
||||
pub fn find<'a>(nodes: &'a [Node], path: &[String]) -> Option<(&'a [Node], Option<&'a str>)> {
|
||||
let mut current = nodes;
|
||||
let mut label = None;
|
||||
for key in path {
|
||||
let (items, l) = current.iter().find_map(|n| match n {
|
||||
Node::Sub { key: k, label: l, items } if k == key => Some((items.as_slice(), l.as_str())),
|
||||
_ => None,
|
||||
})?;
|
||||
current = items;
|
||||
label = Some(l);
|
||||
}
|
||||
Some((current, label))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- placement
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Anchor {
|
||||
/// The tray click point (physical).
|
||||
Point { x: f64, y: f64 },
|
||||
/// Beside a parent panel: its left and right edge and the row's top (physical).
|
||||
Beside { left: f64, right: f64, top: f64 },
|
||||
/// A remembered top-left (pinned panels).
|
||||
At { x: f64, y: f64 },
|
||||
}
|
||||
|
||||
/// x, y, width, height (physical px).
|
||||
pub type Rect = (f64, f64, f64, f64);
|
||||
|
||||
/// Keeps a panel of size w x h fully inside the work area (with `GAP`).
|
||||
pub fn clamp_into(x: f64, y: f64, w: f64, h: f64, work: Rect) -> (f64, f64) {
|
||||
let (wx, wy, ww, wh) = work;
|
||||
(x.clamp(wx + GAP, (wx + ww - w - GAP).max(wx + GAP)), y.clamp(wy + GAP, (wy + wh - h - GAP).max(wy + GAP)))
|
||||
}
|
||||
|
||||
/// Where a panel of `size` goes for `anchor`, inside the work area: the root opens away from the taskbar / menu bar the
|
||||
/// click is near, a submenu beside its parent (flipped left at the screen edge), a pinned panel at its spot.
|
||||
pub fn place_menu(anchor: Anchor, size: (f64, f64), work: Rect) -> (f64, f64) {
|
||||
let (w, h) = size;
|
||||
let (wx, wy, ww, wh) = work;
|
||||
let (x, y) = match anchor {
|
||||
Anchor::Point { x, y } => (if x + w > wx + ww { x - w } else { x }, if y + h > wy + wh { y - h } else { y }),
|
||||
Anchor::Beside { left, right, top } => (if right + w <= wx + ww - GAP { right - 2.0 } else { left - w + 2.0 }, top - 5.0),
|
||||
Anchor::At { x, y } => (x, y),
|
||||
};
|
||||
clamp_into(x, y, w, h, work)
|
||||
}
|
||||
|
||||
pub fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
a.0 < b.0 + b.2 && b.0 < a.0 + a.2 && a.1 < b.1 + b.3 && b.1 < a.1 + a.3
|
||||
}
|
||||
|
||||
/// A spot for a newly pinned panel that covers no other pinned panel: below whatever it overlaps, then the next column.
|
||||
/// Gives up after a while (a screen full of pins) and returns the clamped wish.
|
||||
pub fn free_spot(want: Rect, others: &[Rect], work: Rect) -> (f64, f64) {
|
||||
let (mut x, mut y, w, h) = want;
|
||||
for _ in 0..64 {
|
||||
let Some(hit) = others.iter().find(|o| overlaps((x, y, w, h), **o)) else {
|
||||
return clamp_into(x, y, w, h, work);
|
||||
};
|
||||
y = hit.1 + hit.3 + GAP;
|
||||
if y + h > work.1 + work.3 {
|
||||
y = work.1 + GAP;
|
||||
x += w + GAP;
|
||||
if x + w > work.0 + work.2 {
|
||||
x = work.0 + GAP;
|
||||
}
|
||||
}
|
||||
}
|
||||
clamp_into(want.0, want.1, w, h, work)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- panel book
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Panel {
|
||||
pub path: Vec<String>,
|
||||
pub pinned: bool,
|
||||
pub parent: Option<String>,
|
||||
pub anchor: Anchor,
|
||||
/// Measured and shown once.
|
||||
pub shown: bool,
|
||||
/// Physical top-left once shown (kept for pins).
|
||||
pub pos: Option<(f64, f64)>,
|
||||
/// Opened from the keyboard: takes focus and starts on its first row.
|
||||
pub focus: bool,
|
||||
}
|
||||
|
||||
/// A pinned panel as state.json keeps it (`menuPins`).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PinRecord {
|
||||
pub path: Vec<String>,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
/// Which panels are open, which are pinned, who is whose child.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Book {
|
||||
pub panels: BTreeMap<String, Panel>,
|
||||
next: u32,
|
||||
}
|
||||
|
||||
impl Book {
|
||||
fn add(&mut self, path: Vec<String>, pinned: bool, parent: Option<String>, anchor: Anchor) -> String {
|
||||
self.next += 1;
|
||||
let label = format!("{LABEL_PREFIX}{}", self.next);
|
||||
self.panels.insert(label.clone(), Panel { path, pinned, parent, anchor, shown: false, pos: None, focus: false });
|
||||
label
|
||||
}
|
||||
|
||||
fn remove_all(&mut self, labels: &[String]) {
|
||||
for l in labels {
|
||||
self.panels.remove(l);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transient(&self) -> Vec<String> {
|
||||
self.panels.iter().filter(|(_, p)| !p.pinned).map(|(l, _)| l.clone()).collect()
|
||||
}
|
||||
|
||||
/// Transient panels below `label` (children first).
|
||||
pub fn descendants(&self, label: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut frontier = vec![label.to_string()];
|
||||
while let Some(l) = frontier.pop() {
|
||||
for (child, p) in &self.panels {
|
||||
if p.parent.as_deref() == Some(l.as_str()) && !p.pinned && !out.contains(child) {
|
||||
out.push(child.clone());
|
||||
frontier.push(child.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A fresh root menu: every transient panel closes, pinned ones stay. Returns the new label and the closed ones.
|
||||
pub fn open_root(&mut self, anchor: Anchor) -> (String, Vec<String>) {
|
||||
let closes = self.transient();
|
||||
self.remove_all(&closes);
|
||||
(self.add(vec![], false, None, anchor), closes)
|
||||
}
|
||||
|
||||
/// Opens submenu `key` of `parent`; its other transient children close. None when that submenu already is the open
|
||||
/// child, or the parent is gone. `pinned`: torn off as its own pinned panel (one pinned copy per submenu).
|
||||
pub fn open_sub(&mut self, parent: &str, key: &str, anchor: Anchor, pinned: bool) -> Option<(String, Vec<String>)> {
|
||||
let mut path = self.panels.get(parent)?.path.clone();
|
||||
path.push(key.to_string());
|
||||
if !pinned && self.panels.values().any(|p| p.parent.as_deref() == Some(parent) && !p.pinned && p.path == path) {
|
||||
return None;
|
||||
}
|
||||
let mut closes = self.descendants(parent);
|
||||
self.remove_all(&closes);
|
||||
if pinned {
|
||||
let old: Vec<String> = self.panels.iter().filter(|(_, p)| p.pinned && p.path == path).map(|(l, _)| l.clone()).collect();
|
||||
for l in old {
|
||||
closes.extend(self.close(&l));
|
||||
}
|
||||
return Some((self.add(path, true, None, anchor), closes));
|
||||
}
|
||||
Some((self.add(path, false, Some(parent.to_string()), anchor), closes))
|
||||
}
|
||||
|
||||
/// Closes `label` and its transient children.
|
||||
pub fn close(&mut self, label: &str) -> Vec<String> {
|
||||
if !self.panels.contains_key(label) {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = self.descendants(label);
|
||||
out.push(label.to_string());
|
||||
self.remove_all(&out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Every transient panel closes (Esc on the root, a pick, focus left the menus).
|
||||
pub fn dismiss(&mut self) -> Vec<String> {
|
||||
let t = self.transient();
|
||||
self.remove_all(&t);
|
||||
t
|
||||
}
|
||||
|
||||
/// Pin or unpin. Pinning detaches the panel from its chain: the transient panels that are not its own children
|
||||
/// close (returned). A second pinned copy of the same submenu closes too.
|
||||
pub fn pin(&mut self, label: &str, on: bool) -> Vec<String> {
|
||||
let Some(p) = self.panels.get(label) else { return vec![] };
|
||||
let path = p.path.clone();
|
||||
if !on {
|
||||
if let Some(p) = self.panels.get_mut(label) {
|
||||
p.pinned = false;
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
let mut closes: Vec<String> = self.panels.iter().filter(|(l, p)| p.pinned && p.path == path && l.as_str() != label).map(|(l, _)| l.clone()).collect();
|
||||
if let Some(p) = self.panels.get_mut(label) {
|
||||
p.pinned = true;
|
||||
p.parent = None;
|
||||
}
|
||||
let mine = self.descendants(label);
|
||||
closes.extend(self.transient().into_iter().filter(|l| l != label && !mine.contains(l)));
|
||||
self.remove_all(&closes);
|
||||
closes
|
||||
}
|
||||
|
||||
/// The tree changed: transient panels whose submenu is gone close (with their children). Pinned ones stay and show
|
||||
/// that their submenu is gone until it comes back (an app that is read again) or they are closed.
|
||||
pub fn prune(&mut self, nodes: &[Node]) -> Vec<String> {
|
||||
let gone: Vec<String> = self.panels.iter().filter(|(_, p)| !p.pinned && find(nodes, &p.path).is_none()).map(|(l, _)| l.clone()).collect();
|
||||
let mut out = Vec::new();
|
||||
for l in gone {
|
||||
if self.panels.contains_key(&l) {
|
||||
out.extend(self.close(&l));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The pinned panels to save (only placed ones; in label order, so the order is stable).
|
||||
pub fn pins(&self) -> Vec<PinRecord> {
|
||||
self.panels
|
||||
.values()
|
||||
.filter(|p| p.pinned)
|
||||
.filter_map(|p| p.pos.map(|(x, y)| PinRecord { path: p.path.clone(), x: x.round() as i32, y: y.round() as i32 }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Recreates pinned panels from a saved list (duplicates and more than `MAX_PINS` dropped).
|
||||
pub fn restore(&mut self, pins: &[PinRecord]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for r in pins.iter().take(MAX_PINS) {
|
||||
if self.panels.values().any(|p| p.pinned && p.path == r.path) || r.path.len() > 8 {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = (r.x as f64, r.y as f64);
|
||||
let l = self.add(r.path.clone(), true, None, Anchor::At { x, y });
|
||||
if let Some(p) = self.panels.get_mut(&l) {
|
||||
p.pos = Some((x, y));
|
||||
}
|
||||
out.push(l);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// At most this many pinned panels are restored after a restart.
|
||||
pub const MAX_PINS: usize = 12;
|
||||
|
||||
/// A panel label the app made (`menu-` and a number), for the window / command checks.
|
||||
pub fn is_menu_label(label: &str) -> bool {
|
||||
label.strip_prefix(LABEL_PREFIX).is_some_and(|n| !n.is_empty() && n.len() <= 9 && n.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- runtime
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PanelInfo {
|
||||
pub label: String,
|
||||
pub path: Vec<String>,
|
||||
pub pinned: bool,
|
||||
pub has_parent: bool,
|
||||
pub focus_first: bool,
|
||||
pub nodes: Vec<Node>,
|
||||
}
|
||||
|
||||
/// The panels' shared state (managed by the app).
|
||||
#[derive(Default)]
|
||||
pub struct MenuPanels {
|
||||
pub book: Mutex<Book>,
|
||||
pub nodes: Mutex<Vec<Node>>,
|
||||
/// The native menu took over (a panel failed, or Linux).
|
||||
pub native: AtomicBool,
|
||||
/// Bumped on every move of a pinned panel; the save runs once the moves stop.
|
||||
moves: AtomicU64,
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
mod runtime {
|
||||
use super::*;
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||
|
||||
fn state(app: &AppHandle) -> tauri::State<'_, MenuPanels> {
|
||||
app.state::<MenuPanels>()
|
||||
}
|
||||
|
||||
fn book<R>(app: &AppHandle, f: impl FnOnce(&mut Book) -> R) -> R {
|
||||
let s = state(app);
|
||||
let mut b = s.book.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f(&mut b)
|
||||
}
|
||||
|
||||
fn nodes(app: &AppHandle) -> Vec<Node> {
|
||||
state(app).nodes.lock().map(|n| n.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn native_fallback(app: &AppHandle) -> bool {
|
||||
state(app).native.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Puts the native menu (from the current tree) on the tray: the fallback, and Linux.
|
||||
pub fn set_native(app: &AppHandle) {
|
||||
let Some(tray) = app.tray_by_id("main") else { return };
|
||||
match crate::tray::native_menu(app, &nodes(app)) {
|
||||
Ok(m) => {
|
||||
let _ = tray.set_menu(Some(m));
|
||||
}
|
||||
Err(e) => crate::logs::host("warn", &format!("native tray menu not built: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the pinned panels into state.json (`menuPins`).
|
||||
fn save_pins(app: &AppHandle) {
|
||||
let pins = book(app, |b| b.pins());
|
||||
let Some(s) = app.try_state::<crate::commands::AppState>() else { return };
|
||||
let Ok(mut st) = s.saved.lock() else { return };
|
||||
if st.menu_pins == pins {
|
||||
return;
|
||||
}
|
||||
st.menu_pins = pins;
|
||||
if let Err(e) = crate::store::save(&s.data_dir, &st) {
|
||||
crate::logs::host("warn", &format!("menu pins not saved: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the latest tree and pushes it to every open panel (they update in place). Panels of a submenu that is gone
|
||||
/// close when transient. Cheap when nothing changed or no panel is open.
|
||||
pub fn set_nodes(app: &AppHandle, next: Vec<Node>) {
|
||||
let changed = {
|
||||
let s = state(app);
|
||||
let mut g = s.nodes.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let changed = *g != next;
|
||||
*g = next.clone();
|
||||
changed
|
||||
};
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
let (closes, open) = book(app, |b| (b.prune(&next), b.panels.keys().cloned().collect::<Vec<_>>()));
|
||||
destroy(app, &closes);
|
||||
for l in open {
|
||||
let _ = app.emit_to(l.as_str(), MODEL_EVENT, &next);
|
||||
}
|
||||
if native_fallback(app) {
|
||||
set_native(app);
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy(app: &AppHandle, labels: &[String]) {
|
||||
for l in labels {
|
||||
if let Some(w) = app.get_webview_window(l) {
|
||||
let _ = w.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_window(app: &AppHandle, label: &str) -> tauri::Result<WebviewWindow> {
|
||||
let b = WebviewWindowBuilder::new(app, label, WebviewUrl::App(PAGE.into()))
|
||||
.title("storebound")
|
||||
.inner_size(EST.0, EST.1)
|
||||
.decorations(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(false)
|
||||
.shadow(true)
|
||||
.visible(false)
|
||||
.focused(false);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let b = b.transparent(true);
|
||||
let window = b.build()?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = window.set_visible_on_all_workspaces(true);
|
||||
let handle = app.clone();
|
||||
let me = label.to_string();
|
||||
window.on_window_event(move |event| match event {
|
||||
tauri::WindowEvent::Focused(false) => {
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(160));
|
||||
close_transient_unless_focused(&handle);
|
||||
});
|
||||
}
|
||||
tauri::WindowEvent::Moved(pos) => {
|
||||
let pinned = book(&handle, |b| match b.panels.get_mut(&me) {
|
||||
Some(p) if p.shown => {
|
||||
p.pos = Some((pos.x as f64, pos.y as f64));
|
||||
p.pinned
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if pinned {
|
||||
// a drag sends many moves: save once they stop
|
||||
let n = state(&handle).moves.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
if state(&handle).moves.load(Ordering::Relaxed) == n {
|
||||
save_pins(&handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
/// Focus left a menu window: every transient panel closes unless one of them has focus now.
|
||||
fn close_transient_unless_focused(app: &AppHandle) {
|
||||
let transient = book(app, |b| b.transient());
|
||||
if transient.iter().any(|l| app.get_webview_window(l).and_then(|w| w.is_focused().ok()).unwrap_or(false)) {
|
||||
return;
|
||||
}
|
||||
book(app, |b| {
|
||||
for l in &transient {
|
||||
b.panels.remove(l);
|
||||
}
|
||||
});
|
||||
destroy(app, &transient);
|
||||
}
|
||||
|
||||
/// The panels cannot be shown: the native menu goes on the tray and opens once now.
|
||||
fn native_popup(app: &AppHandle) {
|
||||
if !state(app).native.swap(true, Ordering::Relaxed) {
|
||||
crate::logs::host("warn", "tray menu panels unavailable: the native menu is used");
|
||||
}
|
||||
set_native(app);
|
||||
let handle = app.clone();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
if let (Some(w), Ok(menu)) = (handle.get_webview_window(crate::policy::MAIN), crate::tray::native_menu(&handle, &nodes(&handle))) {
|
||||
let _ = w.popup_menu(&menu);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A right click on the tray icon: a fresh root menu at the click point (physical px).
|
||||
pub fn open_root(app: &AppHandle, x: f64, y: f64) {
|
||||
if native_fallback(app) {
|
||||
return; // the native menu is on the tray and opens by itself
|
||||
}
|
||||
let (label, closes) = book(app, |b| b.open_root(Anchor::Point { x, y }));
|
||||
destroy(app, &closes);
|
||||
let handle = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = build_window(&handle, &label) {
|
||||
crate::logs::host("warn", &format!("tray menu panel not built: {e}"));
|
||||
book(&handle, |b| b.panels.remove(&label));
|
||||
native_popup(&handle);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2500));
|
||||
if book(&handle, |b| b.panels.get(&label).map(|p| p.shown)) == Some(false) {
|
||||
crate::logs::host("warn", "tray menu panel never reported ready");
|
||||
let closes = book(&handle, |b| b.close(&label));
|
||||
destroy(&handle, &closes);
|
||||
native_popup(&handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Brings back the pinned panels of the last session (state.json `menuPins`).
|
||||
pub fn restore(app: &AppHandle) {
|
||||
let pins = app.try_state::<crate::commands::AppState>().and_then(|s| s.saved.lock().ok().map(|st| st.menu_pins.clone())).unwrap_or_default();
|
||||
if pins.is_empty() || native_fallback(app) {
|
||||
return;
|
||||
}
|
||||
let labels = book(app, |b| b.restore(&pins));
|
||||
let handle = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
for l in labels {
|
||||
if build_window(&handle, &l).is_err() {
|
||||
book(&handle, |b| b.panels.remove(&l));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn work_area(app: &AppHandle, x: f64, y: f64) -> Option<Rect> {
|
||||
let m = app.monitor_from_point(x, y).ok().flatten().or_else(|| app.primary_monitor().ok().flatten())?;
|
||||
let w = m.work_area();
|
||||
Some((w.position.x as f64, w.position.y as f64, w.size.width as f64, w.size.height as f64))
|
||||
}
|
||||
|
||||
fn anchor_point(a: Anchor) -> (f64, f64) {
|
||||
match a {
|
||||
Anchor::Point { x, y } | Anchor::At { x, y } => (x, y),
|
||||
Anchor::Beside { right, top, .. } => (right, top),
|
||||
}
|
||||
}
|
||||
|
||||
fn outer_rect(w: &WebviewWindow) -> Option<Rect> {
|
||||
let p = w.outer_position().ok()?;
|
||||
let s = w.outer_size().ok()?;
|
||||
Some((p.x as f64, p.y as f64, s.width as f64, s.height as f64))
|
||||
}
|
||||
|
||||
fn pinned_rects(app: &AppHandle, skip: &str) -> Vec<Rect> {
|
||||
let labels: Vec<String> = book(app, |b| b.panels.iter().filter(|(l, p)| p.pinned && l.as_str() != skip).map(|(l, _)| l.clone()).collect());
|
||||
labels.iter().filter_map(|l| app.get_webview_window(l).and_then(|w| outer_rect(&w))).collect()
|
||||
}
|
||||
|
||||
// ---- what the panel page calls (commands.rs checks the window first) ----
|
||||
|
||||
pub fn panel_state(window: &WebviewWindow) -> Option<PanelInfo> {
|
||||
let app = window.app_handle();
|
||||
let p = book(app, |b| b.panels.get(window.label()).cloned())?;
|
||||
Some(PanelInfo { label: window.label().into(), path: p.path, pinned: p.pinned, has_parent: p.parent.is_some(), focus_first: p.focus, nodes: nodes(app) })
|
||||
}
|
||||
|
||||
/// The panel measured itself (CSS px): size it, place it inside the work area, show it the first time.
|
||||
pub fn ready(window: &WebviewWindow, width: f64, height: f64) {
|
||||
let app = window.app_handle().clone();
|
||||
let Some(panel) = book(&app, |b| b.panels.get(window.label()).cloned()) else { return };
|
||||
let scale = window.scale_factor().unwrap_or(1.0);
|
||||
let size = ((width.clamp(120.0, 600.0) * scale).ceil(), (height.clamp(40.0, 900.0) * scale).ceil());
|
||||
let (ax, ay) = panel.pos.unwrap_or_else(|| anchor_point(panel.anchor));
|
||||
let Some(work) = work_area(&app, ax, ay) else { return };
|
||||
let (x, y) = if panel.shown {
|
||||
let (x, y, _, _) = outer_rect(window).unwrap_or((ax, ay, 0.0, 0.0));
|
||||
clamp_into(x, y, size.0, size.1, work)
|
||||
} else if let Some((x, y)) = panel.pos {
|
||||
clamp_into(x, y, size.0, size.1, work)
|
||||
} else {
|
||||
place_menu(panel.anchor, size, work)
|
||||
};
|
||||
let _ = window.set_size(PhysicalSize::new(size.0 as u32, size.1 as u32));
|
||||
let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32));
|
||||
if !panel.shown {
|
||||
book(&app, |b| {
|
||||
if let Some(p) = b.panels.get_mut(window.label()) {
|
||||
p.shown = true;
|
||||
p.pos = Some((x, y));
|
||||
}
|
||||
});
|
||||
let _ = window.show();
|
||||
if panel.focus || (panel.parent.is_none() && !panel.pinned) {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
if panel.pinned {
|
||||
save_pins(&app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens submenu `key` beside this panel at the row whose top is `top` (CSS px). `pinned`: torn off (Ctrl / middle
|
||||
/// click), placed so it covers no other pinned panel.
|
||||
pub fn open_sub(window: &WebviewWindow, key: &str, top: f64, focus: bool, pinned: bool) {
|
||||
let app = window.app_handle().clone();
|
||||
let Some((wx, wy, ww, _)) = outer_rect(window) else { return };
|
||||
let scale = window.scale_factor().unwrap_or(1.0);
|
||||
let anchor = Anchor::Beside { left: wx, right: wx + ww, top: wy + top.max(0.0) * scale };
|
||||
let Some((label, closes)) = book(&app, |b| b.open_sub(window.label(), key, anchor, pinned)) else { return };
|
||||
destroy(&app, &closes);
|
||||
if pinned {
|
||||
let others = pinned_rects(&app, &label);
|
||||
if let Some(work) = work_area(&app, wx, wy) {
|
||||
let est = (EST.0 * scale, EST.1 * scale);
|
||||
let (x, y) = place_menu(anchor, est, work);
|
||||
let spot = free_spot((x, y, est.0, est.1), &others, work);
|
||||
book(&app, |b| {
|
||||
if let Some(p) = b.panels.get_mut(&label) {
|
||||
p.pos = Some(spot);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
book(&app, |b| {
|
||||
if let Some(p) = b.panels.get_mut(&label) {
|
||||
p.focus = focus;
|
||||
}
|
||||
});
|
||||
let handle = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
if build_window(&handle, &label).is_err() {
|
||||
book(&handle, |b| b.panels.remove(&label));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The pointer moved to a plain row: this panel's transient submenus close.
|
||||
pub fn close_subs(window: &WebviewWindow) {
|
||||
let app = window.app_handle().clone();
|
||||
let closes = book(&app, |b| {
|
||||
let d = b.descendants(window.label());
|
||||
for l in &d {
|
||||
b.panels.remove(l);
|
||||
}
|
||||
d
|
||||
});
|
||||
destroy(&app, &closes);
|
||||
}
|
||||
|
||||
/// Closes this panel (Left / Esc in a submenu, X on a pinned one).
|
||||
pub fn close(window: &WebviewWindow, refocus_parent: bool) {
|
||||
let app = window.app_handle().clone();
|
||||
let (closes, parent, was_pinned) = book(&app, |b| {
|
||||
let p = b.panels.get(window.label()).cloned();
|
||||
(b.close(window.label()), p.as_ref().and_then(|p| p.parent.clone()), p.is_some_and(|p| p.pinned))
|
||||
});
|
||||
if refocus_parent {
|
||||
if let Some(w) = parent.and_then(|p| app.get_webview_window(&p)) {
|
||||
let _ = w.set_focus();
|
||||
let _ = app.emit_to(w.label(), PANEL_EVENT, "focus");
|
||||
}
|
||||
}
|
||||
destroy(&app, &closes);
|
||||
if was_pinned {
|
||||
save_pins(&app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Esc on the root menu: every transient panel closes.
|
||||
pub fn dismiss(app: &AppHandle) {
|
||||
let closes = book(app, |b| b.dismiss());
|
||||
destroy(app, &closes);
|
||||
}
|
||||
|
||||
/// Pin or unpin this panel. Pinning steps it aside from the other pinned panels.
|
||||
pub fn pin(window: &WebviewWindow, on: bool) {
|
||||
let app = window.app_handle().clone();
|
||||
let label = window.label().to_string();
|
||||
let closes = book(&app, |b| b.pin(&label, on));
|
||||
destroy(&app, &closes);
|
||||
if on {
|
||||
let others = pinned_rects(&app, &label);
|
||||
if let Some(me) = outer_rect(window) {
|
||||
if let Some(work) = work_area(&app, me.0, me.1) {
|
||||
let (x, y) = free_spot(me, &others, work);
|
||||
if (x, y) != (me.0, me.1) {
|
||||
let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32));
|
||||
}
|
||||
book(&app, |b| {
|
||||
if let Some(p) = b.panels.get_mut(&label) {
|
||||
p.pos = Some((x, y));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = app.emit_to(label.as_str(), PANEL_EVENT, if on { "pinned" } else { "unpinned" });
|
||||
save_pins(&app);
|
||||
}
|
||||
|
||||
/// A pick: the same action as the native menu. Transient panels close; pinned ones stay and update in place.
|
||||
pub fn select(window: &WebviewWindow, id: String) {
|
||||
let app = window.app_handle().clone();
|
||||
let pinned = book(&app, |b| b.panels.get(window.label()).map(|p| p.pinned).unwrap_or(false));
|
||||
if !pinned {
|
||||
dismiss(&app);
|
||||
}
|
||||
let handle = app.clone();
|
||||
let _ = app.run_on_main_thread(move || crate::dispatch_menu(&handle, &id));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
pub use runtime::*;
|
||||
|
|
@ -56,6 +56,17 @@ pub fn require_card(label: &str, url: &Url, dev: bool) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The tray menu panels (`menu-1`, `menu-2`... menu_panel.rs): they may call only the menu commands.
|
||||
pub fn require_menu(label: &str, url: &Url, dev: bool) -> Result<(), String> {
|
||||
if !crate::menu_panel::is_menu_label(label) {
|
||||
return Err(format!("not allowed from window '{label}'"));
|
||||
}
|
||||
if !is_local_origin(url, dev) {
|
||||
return Err("not allowed from this page".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Method kinds the sidecar announces in its `session.hello` descriptors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
|
|
|
|||
|
|
@ -45,11 +45,14 @@ pub struct State {
|
|||
pub overrides: Overrides,
|
||||
#[serde(default)]
|
||||
pub settings: Settings,
|
||||
/// Pinned tray menu panels and where they sit (menu_panel.rs); restored at start.
|
||||
#[serde(default, rename = "menuPins", skip_serializing_if = "Vec::is_empty")]
|
||||
pub menu_pins: Vec<crate::menu_panel::PinRecord>,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> State {
|
||||
State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default() }
|
||||
State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default(), menu_pins: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +124,7 @@ pub fn migrate(mut v: Value) -> (State, u32, Vec<String>) {
|
|||
studios: list(&o, "studios", &mut notes),
|
||||
overrides: one(&o, "overrides", &mut notes),
|
||||
settings: one(&o, "settings", &mut notes),
|
||||
menu_pins: list(&o, "menuPins", &mut notes),
|
||||
};
|
||||
// two entries with one id (hand-edited or merged files): the first one wins
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! The tray: tooltip, attention icon, context menu and the "where to go" memory of the last notification.
|
||||
//!
|
||||
//! The window pushes a small summary after every store read (`tray_update`); this module turns it into a menu model
|
||||
//! (pure, tested in tests/tray.rs), rebuilds the menu, sets the tooltip and swaps the icon for one with a red dot while
|
||||
//! (pure, tested in tests/tray.rs), pushes it to the menu panels (menu_panel.rs), sets the tooltip and swaps the icon for one with a red dot while
|
||||
//! something needs the owner. Menu clicks come back as events to the window: `tray-route` (a route as JSON) and
|
||||
//! `tray-action` (`read`, `mute-toggle`).
|
||||
//!
|
||||
|
|
@ -14,7 +14,9 @@ use std::time::{Duration, Instant};
|
|||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tauri::image::Image;
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
use crate::menu_panel::{from_entries, Node};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
pub const ID_OPEN: &str = "tray-open";
|
||||
|
|
@ -88,7 +90,8 @@ pub struct TraySummary {
|
|||
pub enum Entry {
|
||||
Item { id: String, label: String, enabled: bool },
|
||||
Separator,
|
||||
Sub { label: String, items: Vec<Entry> },
|
||||
/// `key`: the submenu's fixed key (menu_panel::sub_key), the same in every language.
|
||||
Sub { key: String, label: String, items: Vec<Entry> },
|
||||
}
|
||||
|
||||
fn clean(s: &str, max: usize) -> String {
|
||||
|
|
@ -132,7 +135,7 @@ pub fn menu_model(s: &TraySummary) -> Vec<Entry> {
|
|||
item(ID_OPEN, &l.open),
|
||||
item(ID_READ, &l.read_now),
|
||||
Entry::Separator,
|
||||
Entry::Sub { label: clean(&l.apps, 40), items: apps },
|
||||
Entry::Sub { key: "apps".into(), label: clean(&l.apps, 40), items: apps },
|
||||
Entry::Separator,
|
||||
item(ID_MUTE, if s.muted { &l.unmute } else { &l.mute }),
|
||||
item(ID_UPDATE, &l.update),
|
||||
|
|
@ -233,40 +236,45 @@ pub fn take_pending() -> Option<String> {
|
|||
|
||||
static LAST: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
fn build_menu<R: Runtime>(app: &AppHandle<R>, model: &[Entry]) -> tauri::Result<Menu<R>> {
|
||||
let menu = Menu::new(app)?;
|
||||
for e in model {
|
||||
match e {
|
||||
Entry::Item { id, label, enabled } => menu.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?,
|
||||
Entry::Separator => menu.append(&PredefinedMenuItem::separator(app)?)?,
|
||||
Entry::Sub { label, items } => {
|
||||
let sub = Submenu::new(app, label, true)?;
|
||||
for it in items {
|
||||
if let Entry::Item { id, label, enabled } = it {
|
||||
sub.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?;
|
||||
}
|
||||
}
|
||||
menu.append(&sub)?;
|
||||
/// The native menu items for a panel tree (the fallback, and Linux), nested to any depth.
|
||||
fn native_items<R: Runtime>(app: &AppHandle<R>, nodes: &[Node]) -> tauri::Result<Vec<Box<dyn IsMenuItem<R>>>> {
|
||||
let mut out: Vec<Box<dyn IsMenuItem<R>>> = Vec::new();
|
||||
for n in nodes {
|
||||
match n {
|
||||
Node::Item { id, label, enabled } => out.push(Box::new(MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)),
|
||||
Node::Sep => out.push(Box::new(PredefinedMenuItem::separator(app)?)),
|
||||
Node::Sub { label, items, .. } => {
|
||||
let children = native_items(app, items)?;
|
||||
let refs: Vec<&dyn IsMenuItem<R>> = children.iter().map(|b| b.as_ref()).collect();
|
||||
out.push(Box::new(Submenu::with_items(app, label, true, &refs)?));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(menu)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The menu shown before the window has sent anything (English words).
|
||||
pub fn initial_menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||
build_menu(app, &menu_model(&TraySummary::default()))
|
||||
/// The native tray menu, rendered from the same tree as the panels.
|
||||
pub fn native_menu<R: Runtime>(app: &AppHandle<R>, nodes: &[Node]) -> tauri::Result<Menu<R>> {
|
||||
let items = native_items(app, nodes)?;
|
||||
let refs: Vec<&dyn IsMenuItem<R>> = items.iter().map(|b| b.as_ref()).collect();
|
||||
Menu::with_items(app, &refs)
|
||||
}
|
||||
|
||||
/// Applies a summary: tooltip, menu and icon of the tray with id `main`. Skips the work when nothing changed.
|
||||
pub fn apply<R: Runtime>(app: &AppHandle<R>, s: &TraySummary) -> tauri::Result<()> {
|
||||
/// The tree before the window has sent anything (English words).
|
||||
pub fn initial_nodes() -> Vec<Node> {
|
||||
from_entries(&menu_model(&TraySummary::default()))
|
||||
}
|
||||
|
||||
/// Applies a summary: tooltip and icon of the tray with id `main`, and the menu tree, which goes live to the open menu
|
||||
/// panels (and to the native menu when that is in use). Skips the work when nothing changed.
|
||||
pub fn apply(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> {
|
||||
let Some(tray) = app.tray_by_id("main") else { return Ok(()) };
|
||||
let key = format!("{s:?}");
|
||||
if LAST.lock().ok().is_some_and(|l| l.as_deref() == Some(key.as_str())) {
|
||||
return Ok(());
|
||||
}
|
||||
tray.set_tooltip(Some(tooltip_text(s)))?;
|
||||
tray.set_menu(Some(build_menu(app, &menu_model(s))?))?;
|
||||
crate::menu_panel::set_nodes(app, from_entries(&menu_model(s)));
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
let img = if s.attention > 0 { Image::new_owned(with_attention_dot(icon.rgba(), icon.width(), icon.height()), icon.width(), icon.height()) } else { icon.clone() };
|
||||
tray.set_icon(Some(img))?;
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
"form-action": "'none'"
|
||||
},
|
||||
"freezePrototype": true,
|
||||
"capabilities": ["main-window", "card-window"]
|
||||
"capabilities": ["main-window", "card-window", "menu-window"]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
|
|
|||
203
apps/desktop/src-tauri/tests/menu_panel.rs
Normal file
203
apps/desktop/src-tauri/tests/menu_panel.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
//! The tray menu panels' pure half: the tree and its stable keys, live updates, placement inside the work area, the
|
||||
//! panel book (transient vs pinned, parents, restore after a restart).
|
||||
use storebound_desktop::menu_panel::*;
|
||||
use storebound_desktop::store::State;
|
||||
use storebound_desktop::tray::{menu_model, Entry, TrayApp, TrayLabels, TraySummary};
|
||||
|
||||
const WORK: Rect = (0.0, 0.0, 1920.0, 1040.0);
|
||||
const AT: Anchor = Anchor::At { x: 0.0, y: 0.0 };
|
||||
|
||||
fn summary(state: &str, apps_label: &str) -> TraySummary {
|
||||
TraySummary {
|
||||
apps: vec![TrayApp { key: "k1".into(), name: "First".into(), state: state.into() }, TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() }],
|
||||
labels: TrayLabels { apps: apps_label.into(), ..TrayLabels::default() },
|
||||
..TraySummary::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn tree() -> Vec<Node> {
|
||||
from_entries(&[
|
||||
Entry::Item { id: "open".into(), label: "Open".into(), enabled: true },
|
||||
Entry::Separator,
|
||||
Entry::Sub { key: String::new(), label: "Fleet".into(), items: vec![Entry::Sub { key: String::new(), label: "Recently lowered".into(), items: vec![] }] },
|
||||
Entry::Sub { key: String::new(), label: "Profile: Work".into(), items: vec![] },
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_ignore_live_parts_and_stay_unique() {
|
||||
assert_eq!(sub_key("Profile: Work"), "profile");
|
||||
assert_eq!(sub_key("\u{25CF} First \u{00B7} ok"), "first");
|
||||
assert_eq!(sub_key("Queue (1 running, 2 queued)"), "queue");
|
||||
assert_eq!(sub_key("\u{2716}"), "sub");
|
||||
let nodes = unique_keys(vec![Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }, Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }]);
|
||||
assert!(matches!(&nodes[1], Node::Sub { key, .. } if key == "a-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_walks_keys() {
|
||||
let t = tree();
|
||||
let (items, label) = find(&t, &["fleet".into(), "recently-lowered".into()]).unwrap();
|
||||
assert_eq!((items.len(), label), (0, Some("Recently lowered")));
|
||||
assert!(find(&t, &["nope".into()]).is_none());
|
||||
assert_eq!(find(&t, &[]).unwrap().0.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tray_tree_keeps_its_apps_key_in_every_language_and_value() {
|
||||
let en = from_entries(&menu_model(&summary("App Store: In review", "Apps")));
|
||||
let tr = from_entries(&menu_model(&summary("App Store: Live", "Uygulamalar")));
|
||||
let key = |n: &[Node]| n.iter().find_map(|n| if let Node::Sub { key, .. } = n { Some(key.clone()) } else { None }).unwrap();
|
||||
assert_eq!(key(&en), "apps");
|
||||
assert_eq!(key(&tr), "apps");
|
||||
// a live value changed: the tree differs (so it is pushed), the open Apps panel still resolves
|
||||
assert_ne!(en, tr);
|
||||
let (items, _) = find(&tr, &["apps".into()]).unwrap();
|
||||
assert!(matches!(&items[0], Node::Item { id, label, .. } if id == "tray-app:k1" && label.contains("Live")));
|
||||
// the same summary twice: the same tree (nothing is pushed)
|
||||
assert_eq!(from_entries(&menu_model(&summary("x", "Apps"))), from_entries(&menu_model(&summary("x", "Apps"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_serializes_as_the_panel_page_reads_it() {
|
||||
let v = serde_json::to_value(tree()).unwrap();
|
||||
assert_eq!(v[0], serde_json::json!({ "kind": "item", "id": "open", "label": "Open", "enabled": true }));
|
||||
assert_eq!(v[1], serde_json::json!({ "kind": "sep" }));
|
||||
assert_eq!(v[2]["kind"], "sub");
|
||||
assert_eq!(v[2]["key"], "fleet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_opens_away_from_the_taskbar_and_stays_inside() {
|
||||
// bottom taskbar, icon at the right: above and left of the click
|
||||
let (x, y) = place_menu(Anchor::Point { x: 1900.0, y: 1060.0 }, (300.0, 400.0), WORK);
|
||||
assert!(x + 300.0 <= 1920.0 - GAP && y + 400.0 <= 1040.0 - GAP && x >= 0.0 && y >= 0.0);
|
||||
// macOS menu bar at the top: below it
|
||||
let (_, y) = place_menu(Anchor::Point { x: 1800.0, y: 0.0 }, (300.0, 400.0), (0.0, 25.0, 1920.0, 1055.0));
|
||||
assert!(y >= 25.0 + GAP);
|
||||
// a second monitor left of the primary (negative x)
|
||||
let (x, _) = place_menu(Anchor::Point { x: -10.0, y: 500.0 }, (300.0, 400.0), (-1920.0, 0.0, 1920.0, 1080.0));
|
||||
assert!(x + 300.0 <= -GAP + 0.001 && x >= -1920.0);
|
||||
// a panel taller than the work area: pinned to the top edge, never above it
|
||||
let (_, y) = place_menu(Anchor::Point { x: 100.0, y: 100.0 }, (300.0, 2000.0), WORK);
|
||||
assert_eq!(y, GAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submenus_open_beside_and_flip_left_at_the_edge() {
|
||||
let (x, _) = place_menu(Anchor::Beside { left: 1600.0, right: 1880.0, top: 100.0 }, (280.0, 200.0), WORK);
|
||||
assert!(x + 280.0 <= 1602.0);
|
||||
let (x, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 100.0 }, (280.0, 200.0), WORK);
|
||||
assert!((378.0..=380.0).contains(&x) && (90.0..=100.0).contains(&y));
|
||||
// a row near the bottom: the submenu moves up into the work area
|
||||
let (_, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 1000.0 }, (280.0, 300.0), WORK);
|
||||
assert!(y + 300.0 <= 1040.0 - GAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_panels_step_aside_and_stay_inside() {
|
||||
let others = [(100.0, 100.0, 280.0, 300.0), (100.0, 408.0, 280.0, 300.0)];
|
||||
let (x, y) = free_spot((120.0, 120.0, 280.0, 300.0), &others, WORK);
|
||||
assert!(others.iter().all(|o| !overlaps((x, y, 280.0, 300.0), *o)));
|
||||
assert!(x >= 0.0 && y >= 0.0 && x + 280.0 <= 1920.0 && y + 300.0 <= 1040.0);
|
||||
// no room left below: the next column
|
||||
let col = [(8.0, 8.0, 280.0, 1000.0)];
|
||||
let (x, _) = free_spot((8.0, 8.0, 280.0, 300.0), &col, WORK);
|
||||
assert!(x >= 288.0);
|
||||
// nothing in the way: where it was
|
||||
assert_eq!(free_spot((500.0, 500.0, 280.0, 300.0), &others, WORK), (500.0, 500.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_menus_open_and_pinned_ones_survive_a_new_menu() {
|
||||
let mut b = Book::default();
|
||||
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap();
|
||||
assert!(b.open_sub(&root, "fleet", AT, false).is_none(), "the same submenu again is a no-op");
|
||||
let (deep, _) = b.open_sub(&fleet, "recently-lowered", AT, false).unwrap();
|
||||
assert_eq!(b.descendants(&root).len(), 2);
|
||||
// pinning the Fleet panel detaches it; the root closes, its own child stays its own
|
||||
let closes = b.pin(&fleet, true);
|
||||
assert_eq!(closes, vec![root.clone()]);
|
||||
assert!(b.panels[&fleet].pinned && b.panels[&fleet].parent.is_none());
|
||||
assert_eq!(b.descendants(&fleet), vec![deep.clone()]);
|
||||
let (root2, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
assert_eq!(closes, vec![deep]);
|
||||
assert!(b.panels.contains_key(&fleet));
|
||||
assert_eq!(b.transient(), vec![root2.clone()]);
|
||||
// a pick / Esc: transient panels go, the pinned one stays
|
||||
assert_eq!(b.dismiss(), vec![root2]);
|
||||
assert_eq!(b.panels.len(), 1);
|
||||
// unpinned, it behaves like a menu again and goes with the next one
|
||||
b.pin(&fleet, false);
|
||||
let (_, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
assert!(closes.contains(&fleet));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_torn_off_submenu_keeps_one_pinned_copy() {
|
||||
let mut b = Book::default();
|
||||
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
let (p1, _) = b.open_sub(&root, "fleet", AT, true).unwrap();
|
||||
let (p2, closes) = b.open_sub(&root, "fleet", AT, true).unwrap();
|
||||
assert!(closes.contains(&p1) && b.panels[&p2].pinned && !b.panels.contains_key(&p1));
|
||||
assert_eq!(b.panels[&p2].path, vec!["fleet".to_string()]);
|
||||
// the root stays open (a tear-off is not a pick)
|
||||
assert!(b.panels.contains_key(&root));
|
||||
// closing a panel that is already gone does nothing
|
||||
assert!(b.close("menu-99").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_update_closes_nothing_that_still_exists() {
|
||||
let mut b = Book::default();
|
||||
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
let (profile, _) = b.open_sub(&root, "profile", AT, true).unwrap();
|
||||
let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap();
|
||||
assert!(b.prune(&tree()).is_empty());
|
||||
// the Fleet submenu is gone from the tree: its transient panel closes, the pinned Profile panel stays even when its
|
||||
// submenu goes too (it shows that it is gone)
|
||||
let smaller = from_entries(&[Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }]);
|
||||
assert_eq!(b.prune(&smaller), vec![fleet]);
|
||||
assert!(b.panels.contains_key(&root) && b.panels.contains_key(&profile));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pins_are_saved_in_the_state_and_restored_after_a_restart() {
|
||||
let mut b = Book::default();
|
||||
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
let (p, _) = b.open_sub(&root, "apps", AT, true).unwrap();
|
||||
// not placed yet: nothing to save
|
||||
assert!(b.pins().is_empty());
|
||||
b.panels.get_mut(&p).unwrap().pos = Some((40.4, 50.6));
|
||||
let pins = b.pins();
|
||||
assert_eq!(pins, vec![PinRecord { path: vec!["apps".into()], x: 40, y: 51 }]);
|
||||
// through state.json
|
||||
let st = State { menu_pins: pins.clone(), ..State::default() };
|
||||
let text = serde_json::to_string(&st).unwrap();
|
||||
assert!(text.contains("\"menuPins\""));
|
||||
let back: State = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.menu_pins, pins);
|
||||
// an empty list is not written at all (older files stay as they were)
|
||||
assert!(!serde_json::to_string(&State::default()).unwrap().contains("menuPins"));
|
||||
let mut fresh = Book::default();
|
||||
let mut saved = back.menu_pins.clone();
|
||||
saved.push(saved[0].clone()); // a duplicate is dropped
|
||||
let labels = fresh.restore(&saved);
|
||||
assert_eq!(labels.len(), 1);
|
||||
let r = &fresh.panels[&labels[0]];
|
||||
assert!(r.pinned && r.path == vec!["apps".to_string()] && r.pos == Some((40.0, 51.0)) && r.parent.is_none());
|
||||
// a restored pin survives the next menu
|
||||
let (_, closes) = fresh.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
assert!(closes.is_empty());
|
||||
let many: Vec<PinRecord> = (0..40).map(|i| PinRecord { path: vec![format!("s{i}")], x: 0, y: 0 }).collect();
|
||||
assert_eq!(Book::default().restore(&many).len(), MAX_PINS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_labels_are_menu_and_a_number() {
|
||||
assert!(is_menu_label("menu-1") && is_menu_label("menu-204"));
|
||||
for bad in ["menu-", "menu-a", "card", "main", "menu-1-2", "xmenu-1"] {
|
||||
assert!(!is_menu_label(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use serde_json::{json, Value};
|
||||
use storebound_desktop::policy::*;
|
||||
use storebound_desktop::{CARD_COMMANDS, COMMANDS};
|
||||
use storebound_desktop::{CARD_COMMANDS, COMMANDS, MENU_COMMANDS};
|
||||
use url::Url;
|
||||
|
||||
fn root() -> PathBuf {
|
||||
|
|
@ -26,7 +26,7 @@ fn one_capability_for_the_main_window_with_exactly_the_app_commands() {
|
|||
let dir = root().join("capabilities");
|
||||
let mut files: Vec<PathBuf> = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
files.sort();
|
||||
assert_eq!(files.len(), 2, "main and card capability files expected, found {files:?}");
|
||||
assert_eq!(files.len(), 3, "main, card and menu capability files expected, found {files:?}");
|
||||
let cap = read_json(&dir.join("main.json"));
|
||||
assert_eq!(cap["identifier"], "main-window");
|
||||
assert_eq!(cap["windows"], json!(["main"]));
|
||||
|
|
@ -71,6 +71,45 @@ fn the_card_window_gets_only_the_card_commands() {
|
|||
assert!(require_main("card", &u("tauri://localhost/card.html"), false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_panels_get_only_the_menu_commands() {
|
||||
let cap = read_json(&root().join("capabilities/menu.json"));
|
||||
assert_eq!(cap["identifier"], "menu-window");
|
||||
assert_eq!(cap["windows"], json!(["menu-*"]));
|
||||
assert!(cap.get("webviews").is_none() && cap.get("remote").is_none());
|
||||
assert_ne!(cap["local"], json!(false));
|
||||
let got: BTreeSet<String> = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
|
||||
let mut want: BTreeSet<String> = MENU_COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect();
|
||||
want.insert("core:event:allow-listen".into());
|
||||
want.insert("core:event:allow-unlisten".into());
|
||||
// a pinned panel is dragged by its header (data-tauri-drag-region): only its own window
|
||||
want.insert("core:window:allow-start-dragging".into());
|
||||
assert_eq!(got, want);
|
||||
// the menu commands never leak into the main or card capability
|
||||
for other in ["main.json", "card.json"] {
|
||||
let cap = read_json(&root().join("capabilities").join(other));
|
||||
let perms = cap["permissions"].to_string();
|
||||
for c in MENU_COMMANDS {
|
||||
assert!(!perms.contains(&format!("\"allow-{}\"", c.replace('_', "-"))), "{c} granted in {other}");
|
||||
}
|
||||
}
|
||||
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
|
||||
for c in MENU_COMMANDS {
|
||||
let at = cmds.find(&format!("pub fn {c}(")).unwrap_or_else(|| panic!("{c} not in commands.rs"));
|
||||
let body = &cmds[at..];
|
||||
let open = body.find('{').unwrap();
|
||||
assert!(body[open + 1..].trim_start().starts_with("guard_menu(&window)?;"), "{c} must start with guard_menu(&window)?");
|
||||
}
|
||||
assert!(require_menu("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_ok());
|
||||
assert!(require_menu("menu-12", &u("http://tauri.localhost/tray-menu.html"), false).is_ok());
|
||||
for bad in ["main", "card", "menu-", "menu-x", "menu-1a", "menus-1"] {
|
||||
assert!(require_menu(bad, &u("tauri://localhost/tray-menu.html"), false).is_err(), "{bad}");
|
||||
}
|
||||
assert!(require_menu("menu-1", &u("https://example.com/tray-menu.html"), false).is_err());
|
||||
assert!(require_main("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err());
|
||||
assert!(require_card("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_registered_command_is_in_the_list_and_nothing_else() {
|
||||
let lib = fs::read_to_string(root().join("src/lib.rs")).unwrap();
|
||||
|
|
@ -81,7 +120,7 @@ fn every_registered_command_is_in_the_list_and_nothing_else() {
|
|||
.map(|s| s.trim().trim_start_matches("commands::").to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).chain(MENU_COMMANDS).map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
// every command checks the caller first
|
||||
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
|
||||
for c in COMMANDS {
|
||||
|
|
@ -109,7 +148,7 @@ fn csp_is_strict_and_the_config_has_no_backdoors() {
|
|||
assert!(!v.contains("unsafe-eval") && !v.contains("unsafe-inline") && !v.contains('*'), "{k}: {v}");
|
||||
assert!(!v.contains("https:") && !v.contains("http://") || v == "ipc: http://ipc.localhost", "{k}: no remote sources: {v}");
|
||||
}
|
||||
assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window"]));
|
||||
assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window", "menu-window"]));
|
||||
assert_eq!(conf["app"]["security"]["freezePrototype"], json!(true));
|
||||
assert!(conf["app"].get("withGlobalTauri").is_none() || conf["app"]["withGlobalTauri"] == json!(false));
|
||||
let windows = conf["app"]["windows"].as_array().unwrap();
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ fn ids(m: &[Entry]) -> Vec<String> {
|
|||
fn menu_has_every_entry_in_order() {
|
||||
let m = menu_model(&summary());
|
||||
assert_eq!(ids(&m), vec![ID_OPEN, ID_READ, ID_MUTE, ID_UPDATE, ID_SETTINGS, ID_QUIT]);
|
||||
let Entry::Sub { label, items } = &m[3] else { panic!("the Apps submenu comes after the first separator") };
|
||||
let Entry::Sub { label, items, .. } = &m[3] else { panic!("the Apps submenu comes after the first separator") };
|
||||
assert_eq!(label, "Apps");
|
||||
assert_eq!(items.len(), 2);
|
||||
let Entry::Item { id, label, enabled } = &items[0] else { panic!() };
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const menu = useContextMenu();
|
||||
const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => (
|
||||
<div role="listitem" key={a.key}>
|
||||
<button {...menu(() => appMenu(a, d, nav, { showIgnored }))} className={`nav nav-app has-line${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
|
||||
<button {...menu(() => appMenu(a, d, nav, { showIgnored }), `app:${a.key}`)} className={`nav nav-app has-line${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
|
||||
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' })}
|
||||
title={`${a.name} · ${st.text}`}>
|
||||
<span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{(st.tone === 'bad' || st.tone === 'warn') && <span className={`ds-sdot ${st.tone}`} role="img" aria-label={st.tone === 'bad' ? t('pf.blocker') : t('pf.warning')} />}</span>
|
||||
|
|
|
|||
|
|
@ -229,6 +229,19 @@
|
|||
.ds-menu-k { max-width: 150px; overflow: hidden; text-overflow: ellipsis; margin-left: 18px; color: var(--faint); font-size: var(--ds-t-micro); letter-spacing: .02em; font-variant-numeric: tabular-nums; }
|
||||
.ds-menu-sub { width: 12px; height: 12px; color: var(--faint); margin-left: 10px; }
|
||||
.ds-menu-sep { height: 1px; margin: 5px 6px; background: var(--ds-stroke); }
|
||||
/* menu header: the pin button (a normal menu or submenu), a pinned panel's title, unpin, close and drag handle */
|
||||
.ds-menu-head { display: flex; align-items: center; gap: 2px; height: 24px; padding: 0 0 0 9px; margin-bottom: 1px; }
|
||||
.ds-menu-head.pinned { cursor: grab; touch-action: none; }
|
||||
.ds-menu-head.pinned:active { cursor: grabbing; }
|
||||
.ds-menu-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(--faint); }
|
||||
.ds-menu-btn { flex: none; width: 22px; height: 22px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px;
|
||||
background: transparent; color: var(--faint); cursor: pointer; }
|
||||
.ds-menu-btn svg { width: 13px; height: 13px; }
|
||||
.ds-menu-btn:hover, .ds-menu-btn:focus-visible { background: var(--ds-inset); color: var(--ink); outline: none; }
|
||||
.ds-menu-btn.on { color: var(--accent); }
|
||||
.ds-menu.pinned { border-color: color-mix(in srgb, var(--accent) 45%, var(--ds-stroke-strong)); transition: opacity 120ms var(--ease); }
|
||||
.ds-menu-gone { max-width: 240px; padding: 6px 10px 8px; color: var(--muted); font-size: var(--ds-t-micro); white-space: normal; }
|
||||
@media (prefers-reduced-motion: reduce) { .ds-menu { transition: none; transform: none; } }
|
||||
:root[data-motion="reduce"] .ds-menu { transition: none; transform: none; }
|
||||
/* headings focused by code (dialog / wizard step titles) for screen readers: no ring */
|
||||
|
|
|
|||
|
|
@ -2059,6 +2059,12 @@ export const en = {
|
|||
|
||||
// context menus (ui/ContextMenu.tsx)
|
||||
'cm.cut': "Cut",
|
||||
'cm.pin': "Pin: keeps this menu open",
|
||||
'cm.unpin': "Unpin",
|
||||
'cm.closePanel': "Close",
|
||||
'cm.gone': "Gone: what this menu was for is not on screen now.",
|
||||
'cm.menu': "Menu",
|
||||
'cm.tearOff': "Ctrl+click or middle-click: pin this submenu",
|
||||
'cm.copy': "Copy",
|
||||
'cm.paste': "Paste",
|
||||
'cm.selectAll': "Select all",
|
||||
|
|
|
|||
|
|
@ -2061,6 +2061,12 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
|
||||
// context menus (ui/ContextMenu.tsx)
|
||||
'cm.cut': "Kes",
|
||||
'cm.pin': "Sabitle: menü açık kalır",
|
||||
'cm.unpin': "Sabitlemeyi kaldır",
|
||||
'cm.closePanel': "Kapat",
|
||||
'cm.gone': "Yok: bu menünün ait olduğu şey şu an ekranda değil.",
|
||||
'cm.menu': "Menü",
|
||||
'cm.tearOff': "Ctrl+tık veya orta tık: bu alt menüyü sabitle",
|
||||
'cm.copy': "Kopyala",
|
||||
'cm.paste': "Yapıştır",
|
||||
'cm.selectAll': "Tümünü seç",
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ function AppControls({ app }: { app: UnifiedApp }) {
|
|||
</select></label>
|
||||
)}
|
||||
<span className="grow" />
|
||||
<button className="btn btn-sm btn-ghost" {...more(() => appMenu(app, d, nav, { showIgnored }))} aria-label={t('cm.more')} title={t('cm.more')}><Icon name="more" />{app.ignored || app.ignoredStores.length ? t('nav.ignored') : null}</button>
|
||||
<button className="btn btn-sm btn-ghost" {...more(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)} aria-label={t('cm.more')} title={t('cm.more')}><Icon name="more" />{app.ignored || app.ignoredStores.length ? t('nav.ignored') : null}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -130,7 +130,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
|||
return (
|
||||
<div className="page ds-app">
|
||||
<Hero name={name} icon={d.iconFor(app)} projectId={app.project && !app.project.missing ? app.project.id : null}
|
||||
{...cm(() => appMenu(app, d, nav, { showIgnored }))}
|
||||
{...cm(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}
|
||||
badges={<>
|
||||
{e?.summary?.profile && app.project && <Chip tone="outline">{t(`profile.${e.summary.profile}` as 'profile.app')}</Chip>}
|
||||
{!app.project && <StoreOnlyTag />}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function Ignored() {
|
|||
{apps.map(a => {
|
||||
const s = d.studios.find(x => x.id === a.studio) ?? null;
|
||||
return (
|
||||
<div className="li" key={a.key} {...cm(() => appMenu(a, d, nav, { showIgnored: true }))}>
|
||||
<div className="li" key={a.key} {...cm(() => appMenu(a, d, nav, { showIgnored: true }), `app:${a.key}`)}>
|
||||
<AppIcon name={a.name} src={d.iconFor(a)} />
|
||||
<div className="li-main">
|
||||
<span className="li-title row" style={{ gap: 8 }}>{a.name}{!a.project && <StoreOnlyTag />}</span>
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null;
|
|||
const menu = useContextMenu();
|
||||
const [showIgnored] = useShowIgnored();
|
||||
return (
|
||||
<article className="pf-card" style={style} aria-label={name} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
|
||||
<article className="pf-card" style={style} aria-label={name} {...menu(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}>
|
||||
<div className={`pf-shots${shots.length ? '' : ' empty'}`} aria-hidden="true">
|
||||
{shots.length ? shots.map((s, i) => <img key={i} src={s} alt="" draggable={false} loading="lazy" />) : <>
|
||||
<AppIcon name={app.name} src={icon} className="pf-hero-icon" />
|
||||
|
|
@ -373,7 +373,7 @@ export function Portfolio() {
|
|||
const ios = !!(app.ios || e?.app?.platforms.ios), android = !!(app.android || e?.app?.platforms.android);
|
||||
const sub = app.ios?.app.bundleId ?? e?.app?.platforms.ios?.bundleId ?? app.android?.app.id ?? e?.app?.platforms.android?.packageName ?? '';
|
||||
return (
|
||||
<tr key={app.key} className="clickable" onClick={() => open(app)} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
|
||||
<tr key={app.key} className="clickable" onClick={() => open(app)} {...menu(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}>
|
||||
<td>
|
||||
<div className="cell-app">
|
||||
<AppIcon name={app.name} src={d.iconFor(app)} />
|
||||
|
|
@ -456,7 +456,7 @@ export function Portfolio() {
|
|||
<button className="link" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)}>{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>
|
||||
{showIgnored && ignoredApps.map(a => (
|
||||
<span key={a.key} className="row muted" style={{ gap: 4 }}>
|
||||
<button className="link" onClick={() => open(a)} {...menu(() => appMenu(a, d, nav, { showIgnored }))}>{a.name}</button>
|
||||
<button className="link" onClick={() => open(a)} {...menu(() => appMenu(a, d, nav, { showIgnored }), `app:${a.key}`)}>{a.name}</button>
|
||||
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => d.updateOverrides(o => withIgnored(o, a.ignoreKeys, false))} aria-label={t('ignored.restore')} title={t('ignored.restore')}><Icon name="eye" /></button>
|
||||
</span>
|
||||
))}
|
||||
|
|
|
|||
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; }
|
||||
|
|
@ -1,55 +1,94 @@
|
|||
// Context menus (design system): the browser's own menu never shows. ContextMenuProvider (main.tsx) prevents every
|
||||
// native `contextmenu`, opens a small Cut / Copy / Paste / Select all menu on text fields and a Copy menu on selected
|
||||
// text, and turns Shift+F10 / the Menu key into a context menu on the focused element. Screens attach menus with
|
||||
// `useContextMenu()`: `<div {...menu(() => [...entries])}>`. Keyboard: arrows, Home / End, Enter / Space, Esc,
|
||||
// ArrowRight / ArrowLeft for submenus, type-ahead. Logic (placement, moves, type-ahead) in menu-logic.ts.
|
||||
import { createContext, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, useCallback, useContext, useEffect, useLayoutEffect, useId, useRef, useState } from 'react';
|
||||
// `useContextMenu()`: `<div {...menu(() => [...entries], 'app:' + key)}>`. Keyboard: arrows, Home / End, Enter / Space,
|
||||
// Esc, ArrowRight / ArrowLeft for submenus, type-ahead. Logic (placement, moves, type-ahead) in menu-logic.ts.
|
||||
//
|
||||
// Several menus at once (the same behaviour as the tray menu panels): a menu's pin button keeps it open as a floating
|
||||
// panel; the pin button of a submenu, or Ctrl / Cmd / middle click on a submenu row, tears that submenu off as its own
|
||||
// pinned panel. Pinned panels survive the next menu, can be dragged by their header, step aside so they never sit on
|
||||
// each other, and update in place: they re-read their entries from the newest builder the screen registered (live
|
||||
// sources, menu-stack.ts), so a read that changes a value never closes them. The panel stack is pure (menu-stack.ts).
|
||||
import { createContext, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useContext, useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { t } from '../i18n';
|
||||
import { copyText } from '../lib/crash';
|
||||
import { Icon } from './icons';
|
||||
import { MOD } from './kit';
|
||||
import { enabled, first, isEditable, isItem, last, type MenuEntry, type MenuInput, type MenuItem, move, placeAt, placeBeside, tidy, typeahead } from './menu-logic';
|
||||
import { clampPos, closeMenu, closeUnpinned, LiveMenus, moveMenu, openMenu, pinMenu, type Pos, resolvePath, type StackEntry, tearOff, unpinMenu } from './menu-stack';
|
||||
|
||||
export type { MenuEntry, MenuInput, MenuItem } from './menu-logic';
|
||||
|
||||
interface Open { x: number; y: number; items: MenuEntry[]; from: Element | null; keyboard: boolean }
|
||||
type OpenFn = (x: number, y: number, input: MenuInput, opts?: { from?: Element | null; keyboard?: boolean }) => void;
|
||||
interface OpenOpts { from?: Element | null; keyboard?: boolean; source?: string | null }
|
||||
type OpenFn = (x: number, y: number, input: MenuInput, opts?: OpenOpts) => void;
|
||||
const Ctx = createContext<OpenFn | null>(null);
|
||||
|
||||
/** Every screen's menu builders, newest first: what pinned panels re-read. */
|
||||
const live = new LiveMenus();
|
||||
|
||||
/** Marks a native event a menu handled, so the global fallback leaves it alone. */
|
||||
const HANDLED = '__sbMenu';
|
||||
/** Marks the contextmenu event Shift+F10 / the Menu key sent (the menu starts on its first item). */
|
||||
const BY_KEY = '__sbKey';
|
||||
|
||||
/** Props that attach a menu to an element: `<button {...menu(() => entries)}>`. Entries are built when it opens. */
|
||||
export function useContextMenu(): (build: () => MenuInput) => { onContextMenu: (e: ReactMouseEvent) => void } {
|
||||
/** One live source key per menu call of a component (`id` when given, else its call order in the render); the keys a
|
||||
* render no longer makes are dropped after it commits, all of them when the component unmounts. */
|
||||
function useLiveKeys(): (id?: string) => string {
|
||||
const hid = useId();
|
||||
const seen = useRef(new Set<string>());
|
||||
const n = useRef(0);
|
||||
seen.current = new Set();
|
||||
n.current = 0;
|
||||
useEffect(() => { live.retain(hid, seen.current); });
|
||||
useEffect(() => () => live.retain(hid), [hid]);
|
||||
return useCallback((id?: string) => {
|
||||
const k = `${hid}#${id ?? n.current++}`;
|
||||
seen.current.add(k);
|
||||
return k;
|
||||
}, [hid]);
|
||||
}
|
||||
|
||||
/** Props that attach a menu to an element: `<button {...menu(() => entries, id)}>`. Entries are built when it opens
|
||||
* (and again for a pinned copy whenever the element re-renders). `id`: what the menu is about, unique in the component
|
||||
* (an app key), so a pinned panel follows that thing even when rows reorder. */
|
||||
export function useContextMenu(): (build: () => MenuInput, id?: string) => { onContextMenu: (e: ReactMouseEvent) => void } {
|
||||
const open = useContext(Ctx);
|
||||
return useCallback((build: () => MenuInput) => ({
|
||||
onContextMenu: (e: ReactMouseEvent) => {
|
||||
if ((e.nativeEvent as unknown as Record<string, unknown>)[HANDLED]) return;
|
||||
// a text field or a selection inside keeps the edit menu
|
||||
const target = e.target as HTMLElement;
|
||||
if (isEditable(target as HTMLInputElement) || (window.getSelection()?.toString() ?? '').trim()) return;
|
||||
e.preventDefault();
|
||||
(e.nativeEvent as unknown as Record<string, unknown>)[HANDLED] = true;
|
||||
if (!open) return;
|
||||
open(e.clientX, e.clientY, build(), { from: e.currentTarget as Element, keyboard: !!(e.nativeEvent as unknown as Record<string, unknown>)[BY_KEY] });
|
||||
},
|
||||
}), [open]);
|
||||
const key = useLiveKeys();
|
||||
return useCallback((build: () => MenuInput, id?: string) => {
|
||||
const source = key(id);
|
||||
live.set(source, build);
|
||||
return {
|
||||
onContextMenu: (e: ReactMouseEvent) => {
|
||||
if ((e.nativeEvent as unknown as Record<string, unknown>)[HANDLED]) return;
|
||||
// a text field or a selection inside keeps the edit menu
|
||||
const target = e.target as HTMLElement;
|
||||
if (isEditable(target as HTMLInputElement) || (window.getSelection()?.toString() ?? '').trim()) return;
|
||||
e.preventDefault();
|
||||
(e.nativeEvent as unknown as Record<string, unknown>)[HANDLED] = true;
|
||||
if (!open) return;
|
||||
open(e.clientX, e.clientY, build(), { from: e.currentTarget as Element, keyboard: !!(e.nativeEvent as unknown as Record<string, unknown>)[BY_KEY], source });
|
||||
},
|
||||
};
|
||||
}, [open, key]);
|
||||
}
|
||||
|
||||
/** Props for a "..." overflow button that opens the same menu under itself (click, Enter or Space). */
|
||||
export function useMenuButton(): (build: () => MenuInput) => { onClick: (e: ReactMouseEvent) => void; 'aria-haspopup': 'menu' } {
|
||||
export function useMenuButton(): (build: () => MenuInput, id?: string) => { onClick: (e: ReactMouseEvent) => void; 'aria-haspopup': 'menu' } {
|
||||
const open = useContext(Ctx);
|
||||
return useCallback((build: () => MenuInput) => ({
|
||||
'aria-haspopup': 'menu' as const,
|
||||
onClick: (e: ReactMouseEvent) => {
|
||||
if (!open) return;
|
||||
const r = (e.currentTarget as Element).getBoundingClientRect();
|
||||
open(Math.round(r.left), Math.round(r.bottom + 4), build(), { from: e.currentTarget as Element, keyboard: e.detail === 0 });
|
||||
},
|
||||
}), [open]);
|
||||
const key = useLiveKeys();
|
||||
return useCallback((build: () => MenuInput, id?: string) => {
|
||||
const source = key(id);
|
||||
live.set(source, build);
|
||||
return {
|
||||
'aria-haspopup': 'menu' as const,
|
||||
onClick: (e: ReactMouseEvent) => {
|
||||
if (!open) return;
|
||||
const r = (e.currentTarget as Element).getBoundingClientRect();
|
||||
open(Math.round(r.left), Math.round(r.bottom + 4), build(), { from: e.currentTarget as Element, keyboard: e.detail === 0, source });
|
||||
},
|
||||
};
|
||||
}, [open, key]);
|
||||
}
|
||||
|
||||
/** Copies and says so in a toast-less way (the menu closes; failures are silent like the browser's own Copy). */
|
||||
|
|
@ -75,19 +114,48 @@ function editMenu(el: HTMLInputElement | HTMLTextAreaElement | HTMLElement): Men
|
|||
]);
|
||||
}
|
||||
|
||||
/** What a panel was opened with (kept beside the pure stack entry). */
|
||||
interface Extra { items: MenuEntry[]; from: Element | null; keyboard: boolean }
|
||||
|
||||
export function ContextMenuProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<Open | null>(null);
|
||||
const [stack, setStack] = useState<StackEntry[]>([]);
|
||||
const stackRef = useRef(stack);
|
||||
stackRef.current = stack;
|
||||
const extras = useRef(new Map<string, Extra>());
|
||||
const seq = useRef(0);
|
||||
const nextUid = () => `m${++seq.current}`;
|
||||
|
||||
const open = useCallback<OpenFn>((x, y, input, opts) => {
|
||||
const items = tidy(input);
|
||||
if (!items.length) return;
|
||||
setState({ x, y, items, from: opts?.from ?? (document.activeElement as Element | null), keyboard: !!opts?.keyboard });
|
||||
const uid = nextUid();
|
||||
extras.current.set(uid, { items, from: opts?.from ?? (document.activeElement as Element | null), keyboard: !!opts?.keyboard });
|
||||
setStack(s => openMenu(s, { uid, source: opts?.source ?? null, path: [], pinned: false, x, y }));
|
||||
}, []);
|
||||
const close = useCallback((restore: boolean) => {
|
||||
setState(s => {
|
||||
if (restore && s?.from instanceof HTMLElement && s.from.isConnected) s.from.focus({ preventScroll: true });
|
||||
return null;
|
||||
});
|
||||
/** Every unpinned menu closes; `restore` puts the focus back where the last one was opened. */
|
||||
const closeTransient = useCallback((restore: boolean) => {
|
||||
const last = [...stackRef.current].reverse().find(e => !e.pinned);
|
||||
if (!last) return;
|
||||
const from = extras.current.get(last.uid)?.from;
|
||||
if (restore && from instanceof HTMLElement && from.isConnected) from.focus({ preventScroll: true });
|
||||
setStack(s => closeUnpinned(s));
|
||||
}, []);
|
||||
const pin = useCallback((uid: string, pos: Pos) => setStack(s => pinMenu(s, uid, pos)), []);
|
||||
/** A submenu at `path` of panel `uid` becomes its own pinned panel at `pos`. */
|
||||
const tear = useCallback((uid: string, path: string[], pos: Pos, closeOthers: boolean) => {
|
||||
const parent = stackRef.current.find(e => e.uid === uid);
|
||||
const extra = extras.current.get(uid);
|
||||
if (!parent || !extra) return;
|
||||
const id = nextUid();
|
||||
extras.current.set(id, { items: extra.items, from: null, keyboard: false });
|
||||
setStack(s => tearOff(closeOthers ? closeUnpinned(s) : s, { uid: id, source: parent.source, path: [...parent.path, ...path], pinned: true, x: pos.x, y: pos.y }, pos));
|
||||
}, []);
|
||||
|
||||
// what a closed panel was opened with is not kept
|
||||
useEffect(() => {
|
||||
const ids = new Set(stack.map(e => e.uid));
|
||||
for (const k of [...extras.current.keys()]) if (!ids.has(k)) extras.current.delete(k);
|
||||
}, [stack]);
|
||||
|
||||
useEffect(() => {
|
||||
// the fallback for everything no screen menu handled: never the browser's menu
|
||||
|
|
@ -118,15 +186,30 @@ export function ContextMenuProvider({ children }: { children: ReactNode }) {
|
|||
return () => { document.removeEventListener('contextmenu', onMenu); document.removeEventListener('keydown', onKey, true); };
|
||||
}, [open]);
|
||||
|
||||
const hasTransient = stack.some(e => !e.pinned);
|
||||
return (
|
||||
<Ctx.Provider value={open}>
|
||||
{children}
|
||||
{state && createPortal(<MenuRoot key={`${state.x},${state.y}`} state={state} close={close} />, document.body)}
|
||||
{stack.length > 0 && createPortal(<>
|
||||
{hasTransient && <OutsideCloser close={closeTransient} />}
|
||||
{stack.map(e => {
|
||||
const extra = extras.current.get(e.uid);
|
||||
if (!extra) return null;
|
||||
return e.pinned
|
||||
? <PinnedPanel key={e.uid} entry={e} extra={extra} closeTransient={closeTransient}
|
||||
onClose={() => setStack(s => closeMenu(s, e.uid))} onUnpin={() => setStack(s => unpinMenu(s, e.uid))}
|
||||
onMove={p => setStack(s => moveMenu(s, e.uid, p))} onTear={(path, p) => tear(e.uid, path, p, false)} />
|
||||
: <MenuList key={e.uid} items={extra.items} at={{ x: e.x, y: e.y }} close={closeTransient} root keyboard={extra.keyboard} path={[]}
|
||||
onPin={r => pin(e.uid, { x: r.left, y: r.top })} onTear={(path, p, others) => tear(e.uid, path, p, others)} />;
|
||||
})}
|
||||
</>, document.body)}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuRoot({ state, close }: { state: Open; close: (restore: boolean) => void }) {
|
||||
/** While a normal menu is open: a click outside every menu, a scroll outside, blur or resize closes it (pinned panels
|
||||
* stay). */
|
||||
function OutsideCloser({ close }: { close: (restore: boolean) => void }) {
|
||||
useEffect(() => {
|
||||
const outside = (e: Event) => { if (!(e.target as Element | null)?.closest?.('.ds-menu')) close(false); };
|
||||
const scroll = (e: Event) => { if (!(e.target instanceof Element && e.target.closest('.ds-menu'))) close(false); };
|
||||
|
|
@ -142,12 +225,43 @@ function MenuRoot({ state, close }: { state: Open; close: (restore: boolean) =>
|
|||
window.removeEventListener('resize', blur);
|
||||
};
|
||||
}, [close]);
|
||||
return <MenuList items={state.items} at={{ x: state.x, y: state.y }} close={close} root keyboard={state.keyboard} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
type Anchor = { x: number; y: number } | { left: number; right: number; top: number };
|
||||
/** A pinned panel: its entries re-read from the live source on every change of the screen that registered it. */
|
||||
function PinnedPanel({ entry, extra, closeTransient, onClose, onUnpin, onMove, onTear }: {
|
||||
entry: StackEntry; extra: Extra; closeTransient: (restore: boolean) => void; onClose: () => void; onUnpin: () => void;
|
||||
onMove: (p: Pos) => void; onTear: (path: string[], p: Pos) => void;
|
||||
}) {
|
||||
const [, bump] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => (entry.source ? live.watch(entry.source, bump) : undefined), [entry.source]);
|
||||
const root = entry.source ? live.get(entry.source) ?? null : extra.items;
|
||||
const items = root ? resolvePath(root, entry.path) : null;
|
||||
const title = entry.path.length ? entry.path[entry.path.length - 1]! : t('cm.menu');
|
||||
return (
|
||||
<MenuList items={items ?? []} gone={!items} at={{ fx: entry.x, fy: entry.y }} close={() => {}} root path={[]}
|
||||
pinned={{ title, onClose, onUnpin, onMove }} done={() => closeTransient(false)} onTear={(path, p) => onTear(path, p)} />
|
||||
);
|
||||
}
|
||||
|
||||
function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEntry[]; at: Anchor; close: (restore: boolean) => void; root?: boolean; keyboard?: boolean; onBack?: () => void }) {
|
||||
type Anchor = { x: number; y: number } | { left: number; right: number; top: number } | { fx: number; fy: number };
|
||||
|
||||
interface PinnedHead { title: string; onClose: () => void; onUnpin: () => void; onMove: (p: Pos) => void }
|
||||
|
||||
function MenuList({ items, at, close, root, keyboard, onBack, path, onPin, onTear, pinned, gone, done }: {
|
||||
items: MenuEntry[]; at: Anchor; close: (restore: boolean) => void; root?: boolean; keyboard?: boolean; onBack?: () => void;
|
||||
/** Submenu labels from the panel's root to this list. */
|
||||
path: string[];
|
||||
/** The pin button of a normal (root) menu. */
|
||||
onPin?: (rect: DOMRect) => void;
|
||||
/** A submenu torn off: its path (from the panel root), where, and whether the normal menus close. */
|
||||
onTear?: (path: string[], pos: Pos, closeOthers: boolean) => void;
|
||||
/** This is a pinned panel's root list: header with title, unpin, close, drag. */
|
||||
pinned?: PinnedHead;
|
||||
gone?: boolean;
|
||||
/** After a pick in a pinned panel (normal menus close; the panel stays). */
|
||||
done?: () => void;
|
||||
}) {
|
||||
const uid = useId();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
||||
|
|
@ -155,19 +269,33 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
|
|||
const [sub, setSub] = useState<{ index: number; anchor: Anchor; focus: boolean } | null>(null);
|
||||
const typed = useRef({ q: '', at: 0 });
|
||||
const hoverTimer = useRef<number | undefined>(undefined);
|
||||
const shown = useRef(false);
|
||||
const drag = useRef<{ dx: number; dy: number } | null>(null);
|
||||
const fixed = 'fx' in at ? at : null;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const box = { w: el.offsetWidth, h: el.offsetHeight }, view = { w: window.innerWidth, h: window.innerHeight };
|
||||
setPos('x' in at ? placeAt(at.x, at.y, box, view) : placeBeside(at, box, view));
|
||||
}, [at]);
|
||||
if ('fx' in at) { const p = clampPos({ x: at.fx, y: at.fy }, box, view); setPos({ left: p.x, top: p.y }); }
|
||||
else setPos('x' in at ? placeAt(at.x, at.y, box, view) : placeBeside(at, box, view));
|
||||
// a pinned panel follows its position (drag) and stays inside the window when it shrinks
|
||||
}, ['fx' in at ? at.fx : 'x' in at ? at.x : at.left, 'fx' in at ? at.fy : 'x' in at ? at.y : at.top, items.length]);
|
||||
useEffect(() => {
|
||||
if (!pos) return;
|
||||
if (!fixed) return;
|
||||
const onResize = () => { const el = ref.current; if (el) { const p = clampPos({ x: fixed.fx, y: fixed.fy }, { w: el.offsetWidth, h: el.offsetHeight }, { w: window.innerWidth, h: window.innerHeight }); setPos({ left: p.x, top: p.y }); } };
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [fixed?.fx, fixed?.fy]);
|
||||
useEffect(() => {
|
||||
if (!pos || shown.current) return;
|
||||
shown.current = true;
|
||||
// a pinned panel never takes the focus by itself (it updates while the owner works elsewhere)
|
||||
if (pinned) return;
|
||||
ref.current?.focus({ preventScroll: true });
|
||||
// a submenu, or a menu opened from the keyboard, starts on its first item; a mouse-opened menu waits for a key
|
||||
if (!root || keyboard) setActive(first(items));
|
||||
}, [pos, root, keyboard, items]);
|
||||
}, [pos, root, keyboard, items, pinned]);
|
||||
useEffect(() => () => window.clearTimeout(hoverTimer.current), []);
|
||||
|
||||
const itemEl = (i: number) => ref.current?.querySelector<HTMLElement>(`[data-i="${i}"]`) ?? null;
|
||||
|
|
@ -177,11 +305,22 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
|
|||
const r = el.getBoundingClientRect();
|
||||
setSub({ index: i, anchor: { left: r.left, right: r.right, top: r.top }, focus });
|
||||
};
|
||||
const choose = (i: number) => {
|
||||
const tearAt = (i: number) => {
|
||||
const e = items[i];
|
||||
const el = itemEl(i);
|
||||
if (!isItem(e) || !e.submenu || !el || !onTear) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
onTear([...path, e.label], { x: Math.round(r.right + 6), y: Math.round(r.top - 30) }, false);
|
||||
};
|
||||
const choose = (i: number, ev?: { button: number; ctrlKey: boolean; metaKey: boolean }) => {
|
||||
const e = items[i];
|
||||
if (!enabled(e)) return;
|
||||
if (e.submenu) { openSub(i, true); return; }
|
||||
close(true);
|
||||
if (e.submenu) {
|
||||
if (ev && (ev.button === 1 || ev.ctrlKey || ev.metaKey)) { tearAt(i); return; }
|
||||
openSub(i, true);
|
||||
return;
|
||||
}
|
||||
if (done) { setSub(null); done(); } else close(true);
|
||||
// after the menu is gone, so the action sees the restored focus (paste, select all)
|
||||
window.setTimeout(() => e.onSelect?.(), 0);
|
||||
};
|
||||
|
|
@ -197,8 +336,8 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
|
|||
else if (k === 'Enter' || k === ' ') { stop(); if (active >= 0) choose(active); }
|
||||
else if (k === 'ArrowRight') { stop(); const e = items[active]; if (enabled(e) && e.submenu) openSub(active, true); }
|
||||
else if (k === 'ArrowLeft') { stop(); onBack?.(); }
|
||||
else if (k === 'Escape') { stop(); if (onBack) onBack(); else close(true); }
|
||||
else if (k === 'Tab') { stop(); close(true); }
|
||||
else if (k === 'Escape') { stop(); if (onBack) onBack(); else if (pinned) ref.current?.blur(); else close(true); }
|
||||
else if (k === 'Tab') { stop(); if (!pinned) close(true); }
|
||||
else if (k.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
|
||||
stop();
|
||||
const now = Date.now();
|
||||
|
|
@ -211,12 +350,44 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
|
|||
|
||||
useEffect(() => { if (active >= 0) itemEl(active)?.scrollIntoView({ block: 'nearest' }); }, [active]);
|
||||
|
||||
// a pinned panel is dragged by its header
|
||||
const onHeadDown = (e: ReactPointerEvent) => {
|
||||
if (!pinned || e.button !== 0 || (e.target as Element).closest('button') || !pos) return;
|
||||
drag.current = { dx: e.clientX - pos.left, dy: e.clientY - pos.top };
|
||||
(e.currentTarget as Element).setPointerCapture(e.pointerId);
|
||||
};
|
||||
const onHeadMove = (e: ReactPointerEvent) => {
|
||||
if (!drag.current || !pinned) return;
|
||||
pinned.onMove({ x: Math.round(e.clientX - drag.current.dx), y: Math.round(e.clientY - drag.current.dy) });
|
||||
};
|
||||
const onHeadUp = () => { drag.current = null; };
|
||||
|
||||
// the header: a pinned panel's title, unpin and close; a normal menu's (or submenu's) pin button
|
||||
const pinHere = () => {
|
||||
if (pinned) return pinned.onUnpin();
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
if (path.length) onTear?.(path, { x: Math.round(r.left), y: Math.round(r.top) }, true);
|
||||
else onPin?.(r);
|
||||
};
|
||||
const canPin = !!pinned || (path.length ? !!onTear : !!onPin);
|
||||
const head = canPin && (
|
||||
<div className={`ds-menu-head${pinned ? ' pinned' : ''}`} onPointerDown={onHeadDown} onPointerMove={onHeadMove} onPointerUp={onHeadUp} onPointerCancel={onHeadUp}>
|
||||
<span className="ds-menu-title">{pinned ? pinned.title : path.length ? path[path.length - 1] : ''}</span>
|
||||
<button type="button" className={`ds-menu-btn${pinned ? ' on' : ''}`} title={pinned ? t('cm.unpin') : t('cm.pin')} aria-label={pinned ? t('cm.unpin') : t('cm.pin')}
|
||||
aria-pressed={!!pinned} onClick={pinHere}><Icon name={pinned ? 'pinOff' : 'pin'} /></button>
|
||||
{pinned && <button type="button" className="ds-menu-btn" title={t('cm.closePanel')} aria-label={t('cm.closePanel')} onClick={pinned.onClose}><Icon name="x" /></button>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} className={`ds-menu${pos ? ' in' : ''}`} role="menu" tabIndex={-1} onKeyDown={onKey}
|
||||
<div ref={ref} className={`ds-menu${pos ? ' in' : ''}${pinned ? ' pinned' : ''}`} role="menu" tabIndex={-1} onKeyDown={onKey}
|
||||
style={pos ? { left: pos.left, top: pos.top } : { left: -9999, top: -9999 }}
|
||||
aria-activedescendant={active >= 0 ? `${uid}-${active}` : undefined}
|
||||
aria-label={pinned?.title} aria-activedescendant={active >= 0 ? `${uid}-${active}` : undefined}
|
||||
onContextMenu={e => e.preventDefault()}>
|
||||
{head}
|
||||
{gone && <div className="ds-menu-gone">{t('cm.gone')}</div>}
|
||||
{items.map((e, i) => {
|
||||
if (!isItem(e)) return <div key={`s${i}`} className="ds-menu-sep" role="separator" />;
|
||||
const dis = !!e.disabled;
|
||||
|
|
@ -225,7 +396,7 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
|
|||
<div key={i} id={`${uid}-${i}`} data-i={i} role={checkable ? 'menuitemcheckbox' : 'menuitem'}
|
||||
aria-checked={checkable ? e.checked : undefined} aria-disabled={dis || undefined}
|
||||
aria-haspopup={e.submenu ? 'menu' : undefined} aria-expanded={e.submenu ? sub?.index === i : undefined}
|
||||
title={typeof e.disabled === 'string' ? e.disabled : undefined}
|
||||
title={typeof e.disabled === 'string' ? e.disabled : e.submenu && onTear ? t('cm.tearOff') : undefined}
|
||||
className={`ds-menu-item${i === active ? ' active' : ''}${e.danger ? ' danger' : ''}${dis ? ' disabled' : ''}`}
|
||||
onPointerMove={() => {
|
||||
if (active !== i) setActive(dis ? -1 : i);
|
||||
|
|
@ -233,7 +404,9 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
|
|||
if (!dis && e.submenu) { if (sub?.index !== i) hoverTimer.current = window.setTimeout(() => openSub(i, false), 120); }
|
||||
else if (sub) hoverTimer.current = window.setTimeout(() => setSub(null), 200);
|
||||
}}
|
||||
onClick={() => choose(i)}>
|
||||
onMouseDown={ev => { if (ev.button === 1) ev.preventDefault(); }}
|
||||
onAuxClick={ev => { if (ev.button === 1) choose(i, ev); }}
|
||||
onClick={ev => choose(i, ev)}>
|
||||
<span className="ds-menu-ic">{checkable ? (e.checked ? <Icon name="check" /> : null) : e.icon ? <Icon name={e.icon} /> : null}</span>
|
||||
<span className="ds-menu-l">{e.label}</span>
|
||||
{e.shortcut && <span className="ds-menu-k">{e.shortcut}</span>}
|
||||
|
|
@ -243,7 +416,8 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
|
|||
})}
|
||||
</div>
|
||||
{sub && isItem(items[sub.index]) && (items[sub.index] as MenuItem).submenu && (
|
||||
<MenuList key={sub.index} items={(items[sub.index] as MenuItem).submenu as MenuEntry[]} at={sub.anchor} close={close}
|
||||
<MenuList key={sub.index} items={(items[sub.index] as MenuItem).submenu as MenuEntry[]} at={sub.anchor} close={close} done={done}
|
||||
path={[...path, (items[sub.index] as MenuItem).label]} onTear={onTear}
|
||||
onBack={() => { setSub(null); ref.current?.focus({ preventScroll: true }); }} />
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ import { PauseIcon } from '@phosphor-icons/react/dist/csr/Pause';
|
|||
import { PencilSimpleIcon } from '@phosphor-icons/react/dist/csr/PencilSimple';
|
||||
import { PlayIcon } from '@phosphor-icons/react/dist/csr/Play';
|
||||
import { PlusIcon } from '@phosphor-icons/react/dist/csr/Plus';
|
||||
import { PushPinIcon } from '@phosphor-icons/react/dist/csr/PushPin';
|
||||
import { PushPinSlashIcon } from '@phosphor-icons/react/dist/csr/PushPinSlash';
|
||||
import { PulseIcon } from '@phosphor-icons/react/dist/csr/Pulse';
|
||||
import { QuestionIcon } from '@phosphor-icons/react/dist/csr/Question';
|
||||
import { RocketLaunchIcon } from '@phosphor-icons/react/dist/csr/RocketLaunch';
|
||||
|
|
@ -89,7 +91,7 @@ const ICONS = {
|
|||
more: DotsThreeIcon, gauge: GaugeIcon, eye: EyeIcon, eyeOff: EyeSlashIcon, link: LinkIcon, unlink: LinkBreakIcon, bell: BellIcon, filter: FunnelIcon, play: PlayIcon,
|
||||
send: PaperPlaneRightIcon, edit: PencilSimpleIcon, upload: UploadSimpleIcon, sliders: SlidersHorizontalIcon,
|
||||
rotate: ArrowCounterClockwiseIcon, cloud: CloudIcon, coins: CoinsIcon, chart: TrendUpIcon, flask: FlaskIcon, grid: GridFourIcon,
|
||||
clipboard: ClipboardTextIcon, share: ExportIcon, lock: LockIcon, question: QuestionIcon,
|
||||
clipboard: ClipboardTextIcon, share: ExportIcon, lock: LockIcon, question: QuestionIcon, pin: PushPinIcon, pinOff: PushPinSlashIcon,
|
||||
} satisfies Record<string, PhosphorIcon>;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
|
|
|
|||
140
apps/desktop/src/ui/menu-stack.ts
Normal file
140
apps/desktop/src/ui/menu-stack.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// The in-app context menus' panel stack without React or the DOM (tested in tests/menu-stack.test.ts): several menus
|
||||
// open at once, a submenu chain per menu, pinned panels that survive the next menu, step aside so they never sit on
|
||||
// each other and stay inside the window; plus the live sources pinned panels re-read their entries from.
|
||||
import type { MenuEntry, MenuInput, MenuItem } from './menu-logic';
|
||||
import { tidy } from './menu-logic';
|
||||
|
||||
export interface Pos { x: number; y: number }
|
||||
|
||||
/** One open panel: a root menu, or a submenu torn off (pinned) at `path` (submenu labels from the root). */
|
||||
export interface StackEntry {
|
||||
uid: string;
|
||||
/** The live source of its entries (`liveKey`), or null for a menu built once (the edit menu). */
|
||||
source: string | null;
|
||||
path: readonly string[];
|
||||
pinned: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Pinned panels step this far (CSS px) until no other pinned panel sits there. */
|
||||
export const OFFSET_STEP = 24;
|
||||
|
||||
const same = (a: Pick<StackEntry, 'source' | 'path'>, b: Pick<StackEntry, 'source' | 'path'>) =>
|
||||
a.source !== null && a.source === b.source && a.path.length === b.path.length && a.path.every((p, i) => p === b.path[i]);
|
||||
|
||||
/** Opening a menu closes only the unpinned ones. */
|
||||
export function openMenu(list: readonly StackEntry[], entry: StackEntry): StackEntry[] {
|
||||
return [...list.filter(e => e.pinned), entry];
|
||||
}
|
||||
|
||||
/** Outside click, Esc, a pick, scroll, blur: every unpinned panel closes (the same list when there is none). */
|
||||
export function closeUnpinned(list: readonly StackEntry[]): StackEntry[] {
|
||||
return list.some(e => !e.pinned) ? list.filter(e => e.pinned) : (list as StackEntry[]);
|
||||
}
|
||||
|
||||
export function closeMenu(list: readonly StackEntry[], uid: string): StackEntry[] {
|
||||
return list.some(e => e.uid === uid) ? list.filter(e => e.uid !== uid) : (list as StackEntry[]);
|
||||
}
|
||||
|
||||
/** A free spot: steps down-right until no taken spot is within a step of it. */
|
||||
export function autoOffset(taken: readonly Pos[], pos: Pos, step = OFFSET_STEP): Pos {
|
||||
let next = { ...pos };
|
||||
for (let k = 0; k < 200 && taken.some(t => Math.abs(t.x - next.x) < step && Math.abs(t.y - next.y) < step); k++) next = { x: next.x + step, y: next.y + step };
|
||||
return next;
|
||||
}
|
||||
|
||||
const taken = (list: readonly StackEntry[], skip?: string): Pos[] => list.filter(e => e.pinned && e.uid !== skip).map(e => ({ x: e.x, y: e.y }));
|
||||
|
||||
/** Pins an open panel where it is (moved off other pinned panels). A pinned copy of the same panel is replaced. */
|
||||
export function pinMenu(list: readonly StackEntry[], uid: string, pos: Pos): StackEntry[] {
|
||||
const entry = list.find(e => e.uid === uid);
|
||||
if (!entry || entry.pinned) return list as StackEntry[];
|
||||
const rest = list.filter(e => !(e.pinned && same(e, entry)));
|
||||
const at = autoOffset(taken(rest, uid), pos);
|
||||
return rest.map(e => (e.uid === uid ? { ...e, pinned: true, x: at.x, y: at.y } : e));
|
||||
}
|
||||
|
||||
/** A submenu torn off its menu (pin button on a submenu, Ctrl / middle click on its row): a new pinned panel. The menu
|
||||
* it came from stays open. */
|
||||
export function tearOff(list: readonly StackEntry[], entry: StackEntry, pos: Pos): StackEntry[] {
|
||||
const rest = list.filter(e => !(e.pinned && same(e, entry)));
|
||||
const at = autoOffset(taken(rest), pos);
|
||||
return [...rest, { ...entry, pinned: true, x: at.x, y: at.y }];
|
||||
}
|
||||
|
||||
/** Unpin: a root menu becomes a normal menu again (it closes with the next one); a torn-off submenu closes. */
|
||||
export function unpinMenu(list: readonly StackEntry[], uid: string): StackEntry[] {
|
||||
const entry = list.find(e => e.uid === uid);
|
||||
if (!entry?.pinned) return list as StackEntry[];
|
||||
if (entry.path.length > 0) return closeMenu(list, uid);
|
||||
return list.map(e => (e.uid === uid ? { ...e, pinned: false } : e));
|
||||
}
|
||||
|
||||
export function moveMenu(list: readonly StackEntry[], uid: string, pos: Pos): StackEntry[] {
|
||||
return list.map(e => (e.uid === uid ? { ...e, x: pos.x, y: pos.y } : e));
|
||||
}
|
||||
|
||||
/** Keeps a panel of `size` inside `box` (the window), `margin` from its edges. */
|
||||
export function clampPos(pos: Pos, size: { w: number; h: number }, box: { w: number; h: number }, margin = 8): Pos {
|
||||
return { x: Math.max(margin, Math.min(pos.x, box.w - size.w - margin)), y: Math.max(margin, Math.min(pos.y, box.h - size.h - margin)) };
|
||||
}
|
||||
|
||||
/** The entries at `path` (submenu labels) inside a menu, or null when that submenu is gone. */
|
||||
export function resolvePath(items: readonly MenuEntry[], path: readonly string[]): MenuEntry[] | null {
|
||||
let cur = items as MenuEntry[];
|
||||
for (const label of path) {
|
||||
const next = cur.find((e): e is MenuItem => e !== 'sep' && e.label === label && !!e.submenu);
|
||||
if (!next) return null;
|
||||
cur = next.submenu as MenuEntry[];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
// ---- live sources ----
|
||||
|
||||
/**
|
||||
* The menus' live sources: every `useContextMenu` / `useMenuButton` call registers the builder it got on each render,
|
||||
* so a pinned panel re-reads its entries from the newest builder (fresh data, fresh actions) and updates in place. A
|
||||
* source that is gone (its row unmounted) gives null: the panel says so instead of closing.
|
||||
*/
|
||||
export class LiveMenus {
|
||||
private builders = new Map<string, () => MenuInput>();
|
||||
private subs = new Map<string, Set<() => void>>();
|
||||
private pending = new Set<string>();
|
||||
constructor(private schedule: (fn: () => void) => void = fn => queueMicrotask(fn)) {}
|
||||
|
||||
/** Registers (or replaces) the builder of `key`; its watchers hear about it once per batch. */
|
||||
set(key: string, build: () => MenuInput): void {
|
||||
this.builders.set(key, build);
|
||||
this.ping(key);
|
||||
}
|
||||
delete(key: string): void {
|
||||
if (this.builders.delete(key)) this.ping(key);
|
||||
}
|
||||
/** Keeps only the keys of one hook (`prefix#...`) that its last render registered; `keep` empty drops them all. */
|
||||
retain(prefix: string, keep: ReadonlySet<string> = new Set()): void {
|
||||
for (const k of [...this.builders.keys()]) if (k.startsWith(`${prefix}#`) && !keep.has(k)) this.delete(k);
|
||||
}
|
||||
/** The entries of `key` now (tidied), or null when it is gone. */
|
||||
get(key: string): MenuEntry[] | null {
|
||||
const b = this.builders.get(key);
|
||||
if (!b) return null;
|
||||
try { return tidy(b()); } catch { return null; }
|
||||
}
|
||||
has(key: string): boolean { return this.builders.has(key); }
|
||||
watch(key: string, fn: () => void): () => void {
|
||||
let s = this.subs.get(key);
|
||||
if (!s) { s = new Set(); this.subs.set(key, s); }
|
||||
s.add(fn);
|
||||
return () => { s.delete(fn); if (!s.size) this.subs.delete(key); };
|
||||
}
|
||||
private ping(key: string): void {
|
||||
if (!this.subs.has(key) || this.pending.has(key)) return;
|
||||
this.pending.add(key);
|
||||
this.schedule(() => {
|
||||
this.pending.delete(key);
|
||||
for (const fn of [...(this.subs.get(key) ?? [])]) fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
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}"`);
|
||||
});
|
||||
});
|
||||
14
apps/desktop/tray-menu.html
Normal file
14
apps/desktop/tray-menu.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>storebound</title>
|
||||
</head>
|
||||
<body class="tm-page">
|
||||
<div id="menu"></div>
|
||||
<script type="module" src="/src/traymenu/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -16,7 +16,8 @@ export default defineConfig({
|
|||
// no inline scripts or styles: the CSP is script-src 'self', style-src 'self'
|
||||
assetsInlineLimit: 0,
|
||||
cssCodeSplit: false,
|
||||
// two pages: the main window and the tray card (pre-created hidden by src-tauri/src/card.rs)
|
||||
rollupOptions: { input: { main: 'index.html', card: 'card.html' } },
|
||||
// three pages: the main window, the tray card (pre-created hidden by src-tauri/src/card.rs) and the tray menu panels
|
||||
// (one window per panel, src-tauri/src/menu_panel.rs)
|
||||
rollupOptions: { input: { main: 'index.html', card: 'card.html', menu: 'tray-menu.html' } },
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue