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
|
|
@ -3,10 +3,11 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Desktop tray: the right-click menu is drawn by the app (the design system's menu look) instead of the OS menu. Each submenu opens as its own small panel; the pin button, Ctrl+click or a middle click keeps a panel open (pinned): it can be dragged, steps aside so it never covers another pinned panel, and comes back after a restart. Values (the apps and their store state, mute) update in place after every store read without closing anything. The native menu stays as a fallback (and on Linux), built from the same menu tree. The left-click tray card is unchanged.
|
||||
- Desktop right-click menus in the window work the same way: a menu's pin button keeps it open as a floating panel, a submenu can be torn off (its pin button, Ctrl+click or a middle click), pinned panels survive the next menu, can be dragged, and update in place when the data behind them changes ("Gone" when the thing they were for left the screen).
|
||||
- `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas.
|
||||
- `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them).
|
||||
- `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size.
|
||||
||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs)
|
||||
|
||||
- `apple signing ensure` signs every target of the Xcode project, not only the app: it reads the pbxproj targets (app, app extensions such as a share extension or widget, watch app, App Clip: Release PRODUCT_BUNDLE_IDENTIFIER and entitlements), registers each extension's bundle id (`POST /v1/bundleIds`, platform from the profile type), enables the capabilities its entitlements need (read back) and, with manual signing, creates its App Store profile; `--dry` lists every target. `apple.signing.extensions` (`bundleId`, `name`, `capabilities`, `appGroups`) replaces the detected extensions when detection is wrong; `status` shows a target table.
|
||||
- App Groups: the API cannot assign a group to a bundle id, so `signing status` / `ensure` and (manual signing) `apple build` decode the profile and compare its `com.apple.security.application-groups` with the target's entitlements; a gap names the developer portal link (`.../identifiers/bundleId/edit/<id>`) and the steps to tick the group. `signing ensure --refresh-profile` makes the profile again afterwards; `apple build --allow-app-group-gap` overrides.
|
||||
|
|
|
|||
|
|
@ -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' } },
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -490,6 +490,25 @@ The CLI and the app are one product: one version number, one set of store accoun
|
|||
starts silently in the tray, without a window. Closing the window keeps it running in the tray; the tray icon
|
||||
opens it, the tray menu's Quit (macOS: Cmd+Q) quits. Turn the logon entry off in the OS (Windows: Settings > Apps >
|
||||
Startup). Window state never restores "visible", so a `--hidden` start stays hidden.
|
||||
- **Tray menu panels** (`src-tauri/src/menu_panel.rs`, `src/traymenu/`, page `tray-menu.html`): a right click on the
|
||||
tray icon opens the app's own menu instead of the OS menu. tray.rs builds one tree (`menu_model`, submenus with a
|
||||
stable key such as `apps`, the same in every language); every panel (the root and each submenu) is its own
|
||||
frameless, always-on-top `menu-N` window that measures itself and calls `menu_ready`, and Rust places it inside the
|
||||
monitor's work area (the root away from the taskbar or menu bar, a submenu beside its parent, flipped at the edge).
|
||||
The panel book (pure, `tests/menu_panel.rs`) keeps transient and pinned panels apart: a new menu, Esc, a pick or
|
||||
focus leaving every menu closes the transient ones; pinned ones (pin button, Ctrl+click, middle click) stay, can be
|
||||
dragged by the header, step aside from other pins and are saved in state.json (`menuPins`) and restored at start.
|
||||
`tray_update` pushes the tree to the open panels (`menu-model`), which update in place; nothing polls, and a closed
|
||||
panel is destroyed. Only `menu-*` windows get the `menu_*` commands (`capabilities/menu.json`, `guard_menu`). If a
|
||||
panel cannot be built or never reports ready, the native menu (rendered from the same tree) goes on the tray and
|
||||
pops once; Linux always uses it (its tray sends no clicks). The left-click card stays as it was. Look at the panels
|
||||
without Tauri: `bunx vite` in apps/desktop and open `/tray-menu.html` (a dev-only demo on fixture data; `?still=1`
|
||||
stops the ticking values).
|
||||
- **Pinned context menus in the window** (`src/ui/ContextMenu.tsx`, pure stack in `src/ui/menu-stack.ts`): every menu
|
||||
has a pin button; a submenu's pin button, Ctrl / Cmd + click or a middle click on a submenu row tears it off. Pinned
|
||||
panels survive the next menu, are dragged by the header and re-read their entries from the newest builder the screen
|
||||
registered (`useContextMenu()` / `useMenuButton()` register on every render; pass an id such as `app:<key>` when rows
|
||||
can reorder), so a store read updates them in place; when the thing is gone they say so instead of closing.
|
||||
- **`storebound desktop` install / update**: before the installer runs it stops the running app (only processes
|
||||
started from the exact install path: `%LOCALAPPDATA%\storebound\storebound-desktop.exe`,
|
||||
`storebound.app/Contents/MacOS/storebound-desktop`, the AppImage; a graceful close, then by PID after 10 s). After the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue