screen_app/tray/toggle.rs
1//! Tray-popover visibility state machine (M-TRAY.0 / AUT-249).
2//!
3//! Pure-Rust state machine — no Tauri types, no async, no I/O — so the
4//! transition logic is verifiable on every OS (including Windows
5//! where Tauri 2's `mock_builder` won't link at test-time per
6//! CLAUDE.md "Tauri 2 specifics").
7//!
8//! M-TRAY.3 will rename this to `MainWindowVisibility` when the
9//! tray-popover window is reshaped into the full app `main` window.
10//! The transition contract — toggle → show-or-hide — carries over
11//! unchanged.
12
13/// Whether the tray-popover window is currently shown to the user.
14///
15/// The state lives in `tauri::Manager`-managed storage; this enum is
16/// the source of truth for the click handler. `Default` is `Hidden`
17/// (matching `tauri.conf.json`'s `visible: false`).
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub enum TrayPopoverState {
20 /// Popover is hidden; next click shows it.
21 #[default]
22 Hidden,
23 /// Popover is visible; next click hides it.
24 Visible,
25}
26
27/// The action the click handler should perform after observing a
28/// tray click.
29///
30/// Returning an action enum (rather than mutating Tauri windows
31/// directly from inside the state machine) keeps this module free of
32/// Tauri types — the caller in `main.rs` does the actual `show()` /
33/// `hide()` call.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum Action {
36 /// Caller should `window.show()` + `window.set_focus()`.
37 Show,
38 /// Caller should `window.hide()`.
39 Hide,
40}
41
42impl TrayPopoverState {
43 /// Advance the state machine by one click; return the action the
44 /// caller must perform.
45 pub fn on_click(&mut self) -> Action {
46 match *self {
47 Self::Hidden => {
48 *self = Self::Visible;
49 Action::Show
50 }
51 Self::Visible => {
52 *self = Self::Hidden;
53 Action::Hide
54 }
55 }
56 }
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62
63 #[test]
64 fn default_is_hidden() {
65 assert_eq!(TrayPopoverState::default(), TrayPopoverState::Hidden);
66 }
67
68 #[test]
69 fn hidden_click_yields_show_and_becomes_visible() {
70 let mut s = TrayPopoverState::Hidden;
71 let action = s.on_click();
72 assert_eq!(action, Action::Show);
73 assert_eq!(s, TrayPopoverState::Visible);
74 }
75
76 #[test]
77 fn visible_click_yields_hide_and_becomes_hidden() {
78 let mut s = TrayPopoverState::Visible;
79 let action = s.on_click();
80 assert_eq!(action, Action::Hide);
81 assert_eq!(s, TrayPopoverState::Hidden);
82 }
83
84 #[test]
85 fn ten_alternating_clicks_round_trip() {
86 // Acceptance criterion from AUT-249: "Toggle is stable across
87 // at least 10 alternating clicks (no zombie windows, no
88 // double-spawn)." The state machine half of that asserts that
89 // the parity of click count drives state.
90 let mut s = TrayPopoverState::Hidden;
91 for i in 0..10 {
92 let action = s.on_click();
93 if i % 2 == 0 {
94 assert_eq!(action, Action::Show);
95 assert_eq!(s, TrayPopoverState::Visible);
96 } else {
97 assert_eq!(action, Action::Hide);
98 assert_eq!(s, TrayPopoverState::Hidden);
99 }
100 }
101 // After 10 (even) clicks the state should be back to Hidden.
102 assert_eq!(s, TrayPopoverState::Hidden);
103 }
104}