storebound/apps/desktop/src-tauri/tests/menu_panel.rs
CRT 5236f002f1 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.
2026-10-24 13:44:08 +08:00

203 lines
9.9 KiB
Rust

//! 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}");
}
}