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