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.
122 lines
5.5 KiB
Rust
122 lines
5.5 KiB
Rust
//! The tray's pure parts: menu model, tooltip, click actions, notification targets, the attention dot, settings.
|
|
use storebound_desktop::tray::*;
|
|
use storebound_desktop::workspace::{check_settings, valid_hhmm, Settings};
|
|
|
|
fn summary() -> TraySummary {
|
|
TraySummary {
|
|
tooltip: "storebound · 2 in review · 1 needs you".into(),
|
|
attention: 1,
|
|
muted: false,
|
|
apps: vec![
|
|
TrayApp { key: "k1".into(), name: "First".into(), state: "App Store: In review · Play: Live".into() },
|
|
TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() },
|
|
],
|
|
labels: TrayLabels::default(),
|
|
}
|
|
}
|
|
|
|
fn ids(m: &[Entry]) -> Vec<String> {
|
|
m.iter().filter_map(|e| if let Entry::Item { id, .. } = e { Some(id.clone()) } else { None }).collect()
|
|
}
|
|
|
|
#[test]
|
|
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") };
|
|
assert_eq!(label, "Apps");
|
|
assert_eq!(items.len(), 2);
|
|
let Entry::Item { id, label, enabled } = &items[0] else { panic!() };
|
|
assert_eq!((id.as_str(), *enabled), ("tray-app:k1", true));
|
|
assert!(label.contains("First") && label.contains("In review"), "{label}");
|
|
let Entry::Item { label, .. } = &items[1] else { panic!() };
|
|
assert_eq!(label, "Second", "an app without a state shows its name only");
|
|
}
|
|
|
|
#[test]
|
|
fn mute_entry_follows_the_state_and_empty_apps_say_so() {
|
|
let mut s = summary();
|
|
s.muted = true;
|
|
s.apps.clear();
|
|
let m = menu_model(&s);
|
|
let Entry::Item { label, .. } = m.iter().find(|e| matches!(e, Entry::Item { id, .. } if id == ID_MUTE)).unwrap() else { panic!() };
|
|
assert_eq!(label, "Unmute notifications");
|
|
let Entry::Sub { items, .. } = &m[3] else { panic!() };
|
|
assert!(matches!(&items[0], Entry::Item { enabled: false, label, .. } if label == "No apps yet"));
|
|
}
|
|
|
|
#[test]
|
|
fn tooltip_is_one_short_line() {
|
|
assert_eq!(tooltip_text(&summary()), "storebound · 2 in review · 1 needs you");
|
|
let mut s = summary();
|
|
s.tooltip = "a\nb\t c".into();
|
|
assert_eq!(tooltip_text(&s), "a b c");
|
|
s.tooltip = "x".repeat(500);
|
|
assert_eq!(tooltip_text(&s).chars().count(), MAX_TIP);
|
|
s.tooltip = " ".into();
|
|
assert_eq!(tooltip_text(&s), "storebound");
|
|
}
|
|
|
|
#[test]
|
|
fn clicks_map_to_actions() {
|
|
assert_eq!(action_of(ID_OPEN), Some(Action::Open));
|
|
assert_eq!(action_of(ID_QUIT), Some(Action::Quit));
|
|
assert_eq!(action_of(ID_READ), Some(Action::Event("read")));
|
|
assert_eq!(action_of(ID_MUTE), Some(Action::Event("mute-toggle")));
|
|
assert_eq!(action_of("tray-app:k1"), Some(Action::Route(r#"{"appId":"k1","screen":"app","tab":"releases"}"#.into())));
|
|
assert_eq!(action_of("tray-app:"), None);
|
|
assert_eq!(action_of("other"), None);
|
|
let Some(Action::Route(r)) = action_of(ID_UPDATE) else { panic!() };
|
|
assert!(valid_route(&r) && r.contains("updates"));
|
|
}
|
|
|
|
#[test]
|
|
fn only_known_small_routes_are_remembered() {
|
|
assert!(valid_route(r#"{"screen":"app","appId":"k1","tab":"releases"}"#));
|
|
assert!(valid_route(r#"{"screen":"portfolio"}"#));
|
|
assert!(!valid_route(r#"{"screen":"evil"}"#));
|
|
assert!(!valid_route(r#"{"screen":"app","appId":5}"#));
|
|
assert!(!valid_route("[]"));
|
|
assert!(!valid_route("not json"));
|
|
assert!(!valid_route(&format!(r#"{{"screen":"app","appId":"{}"}}"#, "a".repeat(500))));
|
|
// handed out once
|
|
set_pending(r#"{"screen":"portfolio"}"#);
|
|
assert_eq!(take_pending().as_deref(), Some(r#"{"screen":"portfolio"}"#));
|
|
assert_eq!(take_pending(), None);
|
|
set_pending(r#"{"screen":"evil"}"#);
|
|
assert_eq!(take_pending(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn attention_dot_changes_only_the_corner() {
|
|
let (w, h) = (32u32, 32u32);
|
|
let base: Vec<u8> = std::iter::repeat_n([10u8, 20, 30, 255], (w * h) as usize).flatten().collect();
|
|
let out = with_attention_dot(&base, w, h);
|
|
assert_eq!(out.len(), base.len());
|
|
let at = |v: &[u8], x: u32, y: u32| -> [u8; 4] { let i = ((y * w + x) * 4) as usize; [v[i], v[i + 1], v[i + 2], v[i + 3]] };
|
|
assert_eq!(at(&out, 2, 30), [10, 20, 30, 255], "the bottom left stays");
|
|
let dot = at(&out, 25, 6);
|
|
assert!(dot[0] > 200 && dot[1] < 90 && dot[2] < 90, "red in the middle of the dot: {dot:?}");
|
|
// transparent icon: the dot is opaque, the rest stays clear
|
|
let clear = vec![0u8; (w * h * 4) as usize];
|
|
let out = with_attention_dot(&clear, w, h);
|
|
assert_eq!(at(&out, 2, 30)[3], 0);
|
|
assert_eq!(at(&out, 25, 6)[3], 255);
|
|
// a wrong buffer size is returned unchanged
|
|
assert_eq!(with_attention_dot(&[1, 2, 3], w, h), vec![1, 2, 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn quiet_hours_settings_are_checked_and_default_off() {
|
|
let d = Settings::default();
|
|
assert!(!d.notify.quiet_hours.enabled);
|
|
assert_eq!((d.notify.quiet_hours.from.as_str(), d.notify.quiet_hours.to.as_str()), ("23:00", "08:00"));
|
|
assert!(d.notify.rejected && d.notify.approved && d.notify.live);
|
|
assert!(valid_hhmm("00:00") && valid_hhmm("23:59"));
|
|
assert!(!valid_hhmm("24:00") && !valid_hhmm("7:00") && !valid_hhmm("12:60") && !valid_hhmm("ab:cd"));
|
|
let mut s = d.clone();
|
|
s.notify.quiet_hours.from = "25:00".into();
|
|
assert!(check_settings(&s).is_err());
|
|
let old: Settings = serde_json::from_value(serde_json::json!({ "notify": { "crash": false } })).unwrap();
|
|
assert!(!old.notify.crash && old.notify.rejected && !old.notify.quiet_hours.enabled, "an older file gets the new defaults");
|
|
}
|