214 lines
10 KiB
Rust
214 lines
10 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 tray_root_sits_just_above_the_icon_never_over_the_taskbar() {
|
|
// icon in a bottom taskbar (the icon is below the work area): bottom edge exactly GAP above the icon's top
|
|
let icon = Anchor::Tray { x: 1800.0, y: 1044.0, w: 32.0, h: 32.0 };
|
|
let (_, y) = place_menu(icon, (260.0, 300.0), WORK);
|
|
assert_eq!(y + 300.0, 1040.0 - GAP);
|
|
// even when the reported work area wrongly includes the taskbar, the panel stays above the icon
|
|
let (_, y) = place_menu(icon, (260.0, 300.0), (0.0, 0.0, 1920.0, 1080.0));
|
|
assert!(y + 300.0 <= 1044.0 - 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}");
|
|
}
|
|
}
|