Skip to main content

purple_ssh/app/
status_state.rs

1use std::collections::VecDeque;
2use std::time::Instant;
3
4/// Status/toast-owned state grouped off the `App` god-struct. Contains the
5/// footer status message, the active toast and the toast queue. Pure state
6/// container plus the routing helpers that only touch these three fields.
7/// `tick_status` stays on `App` because it must read `syncing_providers` to
8/// suppress expiry during in-flight provider syncs.
9#[derive(Default)]
10pub struct StatusCenter {
11    pub status: Option<StatusMessage>,
12    pub toast: Option<StatusMessage>,
13    pub toast_queue: VecDeque<StatusMessage>,
14}
15
16impl StatusCenter {
17    pub fn set_status(&mut self, text: impl Into<String>, is_error: bool) {
18        let class = if is_error {
19            MessageClass::Error
20        } else {
21            MessageClass::Success
22        };
23        // Errors are sticky by default so the user cannot miss them.
24        let sticky = matches!(class, MessageClass::Error);
25        let msg = StatusMessage {
26            text: text.into(),
27            class,
28            tick_count: 0,
29            sticky,
30            created_at: std::time::Instant::now(),
31        };
32        if msg.is_toast() {
33            self.push_toast(msg);
34        } else {
35            log::debug!("footer <- {:?}: {}", msg.class, msg.text);
36            self.status = Some(msg);
37        }
38    }
39
40    /// Push a toast message. Success replaces any active toast immediately
41    /// (last-write-wins). Warning and Error promote over an active Success
42    /// toast and queue only behind another Warning or Error, so a user-
43    /// initiated guard ("Demo mode. X disabled.") is never starved by a
44    /// background hint that held the slot. The queue is capped at
45    /// `TOAST_QUEUE_MAX` to bound memory.
46    pub(crate) fn push_toast(&mut self, msg: StatusMessage) {
47        log::debug!("toast <- {:?}: {}", msg.class, msg.text);
48        if msg.class == MessageClass::Success {
49            self.toast = Some(msg);
50            self.toast_queue.clear();
51            return;
52        }
53        // Warning + Error path.
54        let active_blocks = self
55            .toast
56            .as_ref()
57            .is_some_and(|t| t.class != MessageClass::Success);
58        if active_blocks {
59            if self.toast_queue.len() >= crate::ui::design::TOAST_QUEUE_MAX {
60                if let Some(dropped) = self.toast_queue.front() {
61                    log::debug!("toast queue full, dropping: {}", dropped.text);
62                }
63                self.toast_queue.pop_front();
64            }
65            self.toast_queue.push_back(msg);
66        } else {
67            if let Some(ref dropped) = self.toast {
68                log::debug!(
69                    "toast promoted: replacing Success '{}' with {:?}",
70                    dropped.text,
71                    msg.class
72                );
73            }
74            self.toast = Some(msg);
75        }
76    }
77
78    /// Set an Info-class status message that displays in the footer only.
79    pub fn set_info_status(&mut self, text: impl Into<String>) {
80        let text = text.into();
81        log::debug!("footer <- Info: {}", text);
82        self.status = Some(StatusMessage {
83            text,
84            class: MessageClass::Info,
85            tick_count: 0,
86            sticky: false,
87            created_at: std::time::Instant::now(),
88        });
89    }
90
91    /// Like `notify` but skips the write when a sticky message is active.
92    /// Use for background/timer events (ping expiry, sync ticks) that must
93    /// not clobber an in-progress or critical sticky message.
94    /// Routes to Info (footer) by default, Error toast if is_error.
95    pub fn set_background_status(&mut self, text: impl Into<String>, is_error: bool) {
96        if is_error {
97            let msg = StatusMessage {
98                text: text.into(),
99                class: MessageClass::Error,
100                tick_count: 0,
101                sticky: true,
102                created_at: std::time::Instant::now(),
103            };
104            self.push_toast(msg);
105            return;
106        }
107        let text = text.into();
108        if self.status.as_ref().is_some_and(|s| s.sticky) {
109            log::debug!(
110                "[purple] background status suppressed (sticky active, dropped: {})",
111                text
112            );
113            return;
114        }
115        log::debug!("footer <- Info: {}", text);
116        self.status = Some(StatusMessage {
117            text,
118            class: MessageClass::Info,
119            tick_count: 0,
120            sticky: false,
121            created_at: std::time::Instant::now(),
122        });
123    }
124
125    /// Sticky messages always go to the footer (`self.status`), even when the
126    /// class is Error. The `sticky` flag overrides the normal toast routing
127    /// because sticky messages (Vault SSH signing summaries, progress spinners)
128    /// must remain visible in the footer until explicitly replaced.
129    pub fn set_sticky_status(&mut self, text: impl Into<String>, is_error: bool) {
130        let text = text.into();
131        let class = if is_error {
132            MessageClass::Error
133        } else {
134            MessageClass::Progress
135        };
136        log::debug!("footer <- sticky {:?}: {}", class, text);
137        self.status = Some(StatusMessage {
138            text,
139            class,
140            tick_count: 0,
141            sticky: true,
142            created_at: std::time::Instant::now(),
143        });
144    }
145
146    /// Clear the footer status unconditionally. Use when a new user action
147    /// makes the prior status stale (e.g. starting a ping run that will
148    /// post its own progress, or entering search mode). The sticky-aware
149    /// variant is `clear_sticky_status`.
150    pub(crate) fn clear_status(&mut self) {
151        if let Some(s) = &self.status {
152            log::debug!("footer <- clear: {}", s.text);
153        }
154        self.status = None;
155    }
156
157    /// Drop the sticky footer status (set by `set_sticky_status` /
158    /// `notify_progress`). Long-running operations call this when they
159    /// finish so the "Pushing X..." line does not linger after the
160    /// transient success/partial toast lands on top of it.
161    pub fn clear_sticky_status(&mut self) {
162        if let Some(s) = &self.status {
163            if s.sticky {
164                log::debug!("footer <- clear sticky: {}", s.text);
165                self.status = None;
166            }
167        }
168    }
169
170    /// Tick the toast message timer. Uses wall-clock time via `created_at`
171    /// so expiry is independent of the tick rate. Called every tick; the
172    /// actual check is `created_at.elapsed() > timeout_ms()`.
173    pub fn tick_toast(&mut self) {
174        if let Some(ref toast) = self.toast {
175            if toast.sticky {
176                return;
177            }
178            let timeout_ms = toast.timeout_ms();
179            if timeout_ms != u64::MAX && toast.created_at.elapsed().as_millis() as u64 > timeout_ms
180            {
181                log::debug!("toast expired: {}", toast.text);
182                self.toast = self.toast_queue.pop_front();
183            }
184        }
185    }
186}
187
188/// Classification of status messages for routing to toast overlay vs footer.
189///
190/// Five levels: Success / Info / Warning / Error / Progress. Severity rises
191/// from Info to Error. Toast vs footer routing follows attention-urgency:
192/// Success, Warning and Error draw the eye via toast; Info and Progress
193/// sit in the footer for passive consumption.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum MessageClass {
196    /// User action succeeded (copy, sort, delete). Toast, length-proportional timeout.
197    /// Color: green `\u{2713}`.
198    Success,
199    /// Background event (sync complete, config reload). Footer, length-proportional timeout.
200    /// Color: muted.
201    Info,
202    /// Caution or degraded state (stale hosts, deprecated config,
203    /// validation failure, empty-state notice). Toast, length-proportional
204    /// timeout (longer than Success). Auto-expires.
205    /// Color: yellow `\u{26A0}`.
206    Warning,
207    /// Error condition requiring acknowledgement. Toast, **sticky by default**
208    /// so the user cannot miss it. Cleared by next user action.
209    /// Color: red `\u{2716}`.
210    Error,
211    /// Long-running operation with spinner. Footer, sticky.
212    /// Color: muted with spinner.
213    Progress,
214}
215
216/// Status message displayed as toast overlay or in the footer.
217#[derive(Debug, Clone)]
218pub struct StatusMessage {
219    pub text: String,
220    pub class: MessageClass,
221    /// Retained for backward compatibility with tests that inspect it.
222    /// Expiry logic uses `created_at` (wall-clock) instead.
223    #[allow(dead_code)]
224    pub tick_count: u32,
225    /// When true the message never auto-expires and `notify_background`
226    /// will not overwrite it. Cleared by `notify` or `notify_progress`.
227    pub sticky: bool,
228    /// Wall-clock instant when the message was created. Used by the drain
229    /// bar renderer for smooth (frame-rate-independent) animation instead
230    /// of the discrete `tick_count`.
231    pub created_at: Instant,
232}
233
234impl StatusMessage {
235    /// Backward compat: is this an error- or warning-class message?
236    pub fn is_error(&self) -> bool {
237        matches!(self.class, MessageClass::Error | MessageClass::Warning)
238    }
239
240    /// Timeout in milliseconds for this message class.
241    ///
242    /// Length-proportional: shorter messages clear faster, longer messages
243    /// stay visible longer to give the user time to read. The minimum keeps
244    /// 1-word messages on screen long enough to register; the per-word
245    /// component scales with reading time. Errors and Progress are sticky
246    /// (return `u64::MAX`).
247    ///
248    /// All timing is in wall-clock milliseconds, independent of the tick
249    /// rate. Both `tick_toast` (expiry) and `render_toast` (drain bar)
250    /// compare `created_at.elapsed()` against this value.
251    pub fn timeout_ms(&self) -> u64 {
252        let words = self
253            .text
254            .split_whitespace()
255            .count()
256            .min(crate::ui::design::WORD_CAP) as u64;
257        let proportional = words.saturating_mul(crate::ui::design::MS_PER_WORD);
258        let min_ms = match self.class {
259            MessageClass::Success | MessageClass::Info => crate::ui::design::TIMEOUT_MIN_MS,
260            MessageClass::Warning => crate::ui::design::TIMEOUT_MIN_WARNING_MS,
261            MessageClass::Error | MessageClass::Progress => return u64::MAX,
262        };
263        min_ms.max(proportional)
264    }
265
266    /// Should this message render as a toast overlay?
267    pub fn is_toast(&self) -> bool {
268        matches!(
269            self.class,
270            MessageClass::Success | MessageClass::Warning | MessageClass::Error
271        )
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    fn msg(text: &str, class: MessageClass, sticky: bool) -> StatusMessage {
280        StatusMessage {
281            text: text.to_string(),
282            class,
283            tick_count: 0,
284            sticky,
285            created_at: std::time::Instant::now(),
286        }
287    }
288
289    #[test]
290    fn default_is_quiet() {
291        let s = StatusCenter::default();
292        assert!(s.status.is_none());
293        assert!(s.toast.is_none());
294        assert!(s.toast_queue.is_empty());
295    }
296
297    #[test]
298    fn test_set_status_info_populates_status_field() {
299        let mut s = StatusCenter::default();
300        // Info class is routed to the footer, not a toast.
301        s.set_info_status("hello");
302        assert!(s.status.is_some());
303        assert_eq!(s.status.as_ref().unwrap().text, "hello");
304        assert!(s.toast.is_none());
305    }
306
307    #[test]
308    fn test_set_status_error_is_routed_to_sticky_toast() {
309        let mut s = StatusCenter::default();
310        s.set_status("boom", true);
311        // Errors are toasts and sticky, so they live in `toast`.
312        assert!(s.toast.is_some());
313        let toast = s.toast.as_ref().unwrap();
314        assert_eq!(toast.class, MessageClass::Error);
315        assert!(toast.sticky);
316    }
317
318    #[test]
319    fn test_set_sticky_status_writes_footer_and_marks_sticky() {
320        let mut s = StatusCenter::default();
321        s.set_sticky_status("signing cert", false);
322        let footer = s.status.as_ref().expect("footer status set");
323        assert_eq!(footer.text, "signing cert");
324        assert_eq!(footer.class, MessageClass::Progress);
325        assert!(
326            footer.sticky,
327            "sticky progress message must stay until replaced"
328        );
329        // Sticky messages never go to the toast slot.
330        assert!(s.toast.is_none());
331    }
332
333    #[test]
334    fn tick_toast_advances_queue_once_active_expires() {
335        let mut s = StatusCenter::default();
336        // First warning occupies the active toast slot.
337        s.push_toast(msg("first", MessageClass::Warning, false));
338        // Second warning queues because the slot is taken.
339        s.push_toast(msg("second", MessageClass::Warning, false));
340        assert_eq!(s.toast.as_ref().unwrap().text, "first");
341        assert_eq!(s.toast_queue.len(), 1);
342
343        // Force the active toast into the expired state by rewinding
344        // created_at past its wall-clock timeout.
345        let expired_at = std::time::Instant::now()
346            .checked_sub(std::time::Duration::from_secs(60))
347            .expect("instant subtraction");
348        if let Some(active) = s.toast.as_mut() {
349            active.created_at = expired_at;
350        }
351        s.tick_toast();
352        // Queue drains into the active slot.
353        assert_eq!(s.toast.as_ref().unwrap().text, "second");
354        assert!(s.toast_queue.is_empty());
355    }
356
357    #[test]
358    fn tick_toast_does_not_expire_sticky_toast() {
359        let mut s = StatusCenter::default();
360        s.push_toast(msg("stay", MessageClass::Error, true));
361        let expired_at = std::time::Instant::now()
362            .checked_sub(std::time::Duration::from_secs(3600))
363            .expect("instant subtraction");
364        if let Some(active) = s.toast.as_mut() {
365            active.created_at = expired_at;
366        }
367        s.tick_toast();
368        assert!(s.toast.is_some(), "sticky toast must not expire");
369    }
370
371    #[test]
372    fn clear_status_drops_active_footer_status() {
373        let mut s = StatusCenter::default();
374        s.set_info_status("syncing aws");
375        assert!(s.status.is_some());
376        s.clear_status();
377        assert!(s.status.is_none());
378    }
379
380    #[test]
381    fn clear_status_also_drops_sticky_footer_status() {
382        // Sticky is intentional: callers that want sticky-aware semantics
383        // use `clear_sticky_status`. This variant is the heavy hammer.
384        let mut s = StatusCenter::default();
385        s.set_sticky_status("signing cert", false);
386        assert!(s.status.as_ref().is_some_and(|m| m.sticky));
387        s.clear_status();
388        assert!(s.status.is_none());
389    }
390
391    #[test]
392    fn clear_status_on_empty_is_noop() {
393        let mut s = StatusCenter::default();
394        s.clear_status();
395        assert!(s.status.is_none());
396        assert!(s.toast.is_none());
397    }
398
399    #[test]
400    fn clear_status_does_not_touch_active_toast() {
401        let mut s = StatusCenter::default();
402        s.set_info_status("info");
403        s.push_toast(msg("warn", MessageClass::Warning, false));
404        s.clear_status();
405        assert!(s.status.is_none(), "footer cleared");
406        assert!(s.toast.is_some(), "toast slot untouched");
407    }
408}