Skip to main content

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}