screen_app/tray/bubble_toggle.rs
1//! Webcam-bubble visibility state machine (M-BUBBLE.0 / AUT-273).
2//!
3//! Mirrors the [`super::toggle::TrayPopoverState`] shape — a pure-Rust
4//! `Hidden ↔ Visible` state machine that returns an action enum so the
5//! caller in `commands.rs` is responsible for the Tauri `show()` /
6//! `hide()` calls. Splitting state from I/O keeps the transition logic
7//! cross-OS-testable without a Tauri runtime (CLAUDE.md "Tauri 2
8//! `mock_builder` aborts at list-time on Windows").
9
10/// Whether the `webcam-bubble` window is currently shown to the user.
11///
12/// The state lives in `tauri::Manager`-managed storage; this enum is
13/// the source of truth for the "Show webcam bubble" toggle button in
14/// the Recorder surface. `Default` is `Hidden` (matching
15/// `tauri.conf.json`'s `visible: false`).
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub enum BubbleVisibility {
18 /// Bubble window is hidden; next toggle shows it.
19 #[default]
20 Hidden,
21 /// Bubble window is visible; next toggle hides it.
22 Visible,
23}
24
25/// The action the toggle handler should perform after observing a
26/// user click on "Show webcam bubble." Returning an enum (rather than
27/// mutating Tauri windows directly here) keeps this module free of
28/// Tauri types so the unit tests don't need a runtime.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum BubbleAction {
31 /// Caller should `window.show()` (and not `set_focus()` — the
32 /// bubble is meant to float as a peripheral, not steal focus from
33 /// the `AppShell`).
34 Show,
35 /// Caller should `window.hide()`.
36 Hide,
37}
38
39impl BubbleVisibility {
40 /// Advance the state machine by one click; return the action the
41 /// caller must perform.
42 pub fn on_click(&mut self) -> BubbleAction {
43 match *self {
44 Self::Hidden => {
45 *self = Self::Visible;
46 BubbleAction::Show
47 }
48 Self::Visible => {
49 *self = Self::Hidden;
50 BubbleAction::Hide
51 }
52 }
53 }
54
55 /// Align the state to `visible`, returning the action the caller
56 /// should perform — or `None` when already in the requested state.
57 ///
58 /// Distinct from [`Self::on_click`] (which always flips). Used by
59 /// callers that own their own source of truth for the desired
60 /// visibility (e.g. the recorder's `camera_enabled` signal) and
61 /// need lockstep alignment without depending on the state
62 /// machine's prior position. ISS-05.
63 pub fn set(&mut self, visible: bool) -> Option<BubbleAction> {
64 match (*self, visible) {
65 (Self::Hidden, true) => {
66 *self = Self::Visible;
67 Some(BubbleAction::Show)
68 }
69 (Self::Visible, false) => {
70 *self = Self::Hidden;
71 Some(BubbleAction::Hide)
72 }
73 (Self::Hidden, false) | (Self::Visible, true) => None,
74 }
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 #[test]
83 fn default_is_hidden() {
84 assert_eq!(BubbleVisibility::default(), BubbleVisibility::Hidden);
85 }
86
87 #[test]
88 fn hidden_click_yields_show_and_becomes_visible() {
89 let mut s = BubbleVisibility::Hidden;
90 let action = s.on_click();
91 assert_eq!(action, BubbleAction::Show);
92 assert_eq!(s, BubbleVisibility::Visible);
93 }
94
95 #[test]
96 fn visible_click_yields_hide_and_becomes_hidden() {
97 let mut s = BubbleVisibility::Visible;
98 let action = s.on_click();
99 assert_eq!(action, BubbleAction::Hide);
100 assert_eq!(s, BubbleVisibility::Hidden);
101 }
102
103 #[test]
104 fn ten_alternating_clicks_round_trip() {
105 let mut s = BubbleVisibility::Hidden;
106 for i in 0..10 {
107 let action = s.on_click();
108 if i % 2 == 0 {
109 assert_eq!(action, BubbleAction::Show);
110 assert_eq!(s, BubbleVisibility::Visible);
111 } else {
112 assert_eq!(action, BubbleAction::Hide);
113 assert_eq!(s, BubbleVisibility::Hidden);
114 }
115 }
116 assert_eq!(s, BubbleVisibility::Hidden);
117 }
118
119 #[test]
120 fn set_true_from_hidden_yields_show() {
121 let mut s = BubbleVisibility::Hidden;
122 assert_eq!(s.set(true), Some(BubbleAction::Show));
123 assert_eq!(s, BubbleVisibility::Visible);
124 }
125
126 #[test]
127 fn set_false_from_visible_yields_hide() {
128 let mut s = BubbleVisibility::Visible;
129 assert_eq!(s.set(false), Some(BubbleAction::Hide));
130 assert_eq!(s, BubbleVisibility::Hidden);
131 }
132
133 #[test]
134 fn set_to_current_state_is_a_noop() {
135 let mut hidden = BubbleVisibility::Hidden;
136 assert_eq!(hidden.set(false), None);
137 let mut visible = BubbleVisibility::Visible;
138 assert_eq!(visible.set(true), None);
139 }
140}