Skip to main content

coding_agent_search/ui/components/
toast.rs

1//! Toast notification component for transient user feedback.
2//!
3//! Provides non-blocking notifications that auto-dismiss after a configurable duration.
4//! Supports coalescing of similar messages to prevent notification spam.
5
6use ftui::core::geometry::Rect;
7use ftui::render::cell::PackedRgba;
8use std::collections::VecDeque;
9use std::time::{Duration, Instant};
10
11use super::theme::ThemePalette;
12
13/// Type of toast notification, determines styling and icon
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ToastType {
16    /// Informational message
17    Info,
18    /// Success/completion message
19    Success,
20    /// Warning that doesn't block operation
21    Warning,
22    /// Error that needs attention
23    Error,
24}
25
26impl ToastType {
27    /// Get the icon/prefix for this toast type
28    pub fn icon(self) -> &'static str {
29        match self {
30            Self::Info => "i",
31            Self::Success => "*",
32            Self::Warning => "!",
33            Self::Error => "x",
34        }
35    }
36
37    /// Get the color for this toast type as a PackedRgba.
38    pub fn color(self, palette: &ThemePalette) -> PackedRgba {
39        match self {
40            Self::Info => palette.accent,
41            Self::Success => palette.user,
42            Self::Warning => palette.system,
43            Self::Error => PackedRgba::rgb(247, 118, 142),
44        }
45    }
46
47    /// Get default duration for this toast type
48    pub fn default_duration(self) -> Duration {
49        match self {
50            Self::Info => Duration::from_secs(3),
51            Self::Success => Duration::from_secs(2),
52            Self::Warning => Duration::from_secs(4),
53            Self::Error => Duration::from_secs(6), // Errors stay longer
54        }
55    }
56}
57
58/// Position where toasts appear on screen
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum ToastPosition {
61    /// Top-right corner (default)
62    #[default]
63    TopRight,
64    /// Top-left corner
65    TopLeft,
66    /// Bottom-right corner
67    BottomRight,
68    /// Bottom-left corner
69    BottomLeft,
70    /// Top-center
71    TopCenter,
72    /// Bottom-center
73    BottomCenter,
74}
75
76/// A single toast notification
77#[derive(Debug, Clone)]
78pub struct Toast {
79    /// Unique identifier for coalescing
80    pub id: String,
81    /// The message to display
82    pub message: String,
83    /// Type of toast (determines styling)
84    pub toast_type: ToastType,
85    /// When the toast was created
86    pub created_at: Instant,
87    /// How long until auto-dismiss
88    pub duration: Duration,
89    /// Number of coalesced messages (for "x5" badge)
90    pub count: usize,
91}
92
93impl Toast {
94    /// Create a new toast with default duration
95    pub fn new(message: impl Into<String>, toast_type: ToastType) -> Self {
96        let message = message.into();
97        let id = format!("{:?}:{}", toast_type, message);
98        Self {
99            id,
100            message,
101            toast_type,
102            created_at: Instant::now(),
103            duration: toast_type.default_duration(),
104            count: 1,
105        }
106    }
107
108    /// Create a toast with custom duration
109    pub fn with_duration(mut self, duration: Duration) -> Self {
110        self.duration = duration;
111        self
112    }
113
114    /// Create a toast with custom ID (for coalescing control)
115    pub fn with_id(mut self, id: impl Into<String>) -> Self {
116        self.id = id.into();
117        self
118    }
119
120    /// Check if this toast has expired
121    pub fn is_expired(&self) -> bool {
122        self.created_at.elapsed() >= self.duration
123    }
124
125    /// Get remaining time as a fraction (0.0 = expired, 1.0 = just created)
126    pub fn remaining_fraction(&self) -> f32 {
127        let total = self.duration.as_secs_f32();
128        if total <= 0.0 {
129            return 0.0; // Treat zero/negative duration as immediately expired
130        }
131        let elapsed = self.created_at.elapsed().as_secs_f32();
132        (1.0 - elapsed / total).clamp(0.0, 1.0)
133    }
134
135    /// Convenience constructors
136    pub fn info(message: impl Into<String>) -> Self {
137        Self::new(message, ToastType::Info)
138    }
139
140    pub fn success(message: impl Into<String>) -> Self {
141        Self::new(message, ToastType::Success)
142    }
143
144    pub fn warning(message: impl Into<String>) -> Self {
145        Self::new(message, ToastType::Warning)
146    }
147
148    pub fn error(message: impl Into<String>) -> Self {
149        Self::new(message, ToastType::Error)
150    }
151}
152
153/// Manages a collection of toast notifications
154#[derive(Debug)]
155pub struct ToastManager {
156    /// Active toasts (newest first for top-down rendering)
157    toasts: VecDeque<Toast>,
158    /// Maximum number of visible toasts
159    max_visible: usize,
160    /// Position on screen
161    position: ToastPosition,
162    /// Whether to coalesce similar toasts
163    coalesce: bool,
164}
165
166impl Default for ToastManager {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172impl ToastManager {
173    /// Create a new toast manager with defaults
174    pub fn new() -> Self {
175        Self {
176            toasts: VecDeque::new(),
177            max_visible: 5,
178            position: ToastPosition::TopRight,
179            coalesce: true,
180        }
181    }
182
183    /// Set maximum visible toasts
184    pub fn with_max_visible(mut self, max: usize) -> Self {
185        self.max_visible = max;
186        self
187    }
188
189    /// Set toast position
190    pub fn with_position(mut self, position: ToastPosition) -> Self {
191        self.position = position;
192        self
193    }
194
195    /// Enable/disable coalescing
196    pub fn with_coalesce(mut self, coalesce: bool) -> Self {
197        self.coalesce = coalesce;
198        self
199    }
200
201    /// Add a new toast
202    pub fn push(&mut self, toast: Toast) {
203        // Try to coalesce with existing toast
204        if self.coalesce
205            && let Some(existing) = self.toasts.iter_mut().find(|t| t.id == toast.id)
206        {
207            existing.count = existing.count.saturating_add(1);
208            existing.created_at = Instant::now(); // Reset timer
209            return;
210        }
211
212        // Add new toast at front
213        self.toasts.push_front(toast);
214
215        // Trim excess
216        let retention_limit = self.max_visible.saturating_mul(2);
217        while self.toasts.len() > retention_limit {
218            self.toasts.pop_back();
219        }
220    }
221
222    /// Remove expired toasts
223    pub fn tick(&mut self) {
224        self.toasts.retain(|t| !t.is_expired());
225    }
226
227    /// Clear all toasts
228    pub fn clear(&mut self) {
229        self.toasts.clear();
230    }
231
232    /// Dismiss the oldest toast
233    pub fn dismiss_oldest(&mut self) {
234        self.toasts.pop_back();
235    }
236
237    /// Dismiss all toasts of a specific type
238    pub fn dismiss_type(&mut self, toast_type: ToastType) {
239        self.toasts.retain(|t| t.toast_type != toast_type);
240    }
241
242    /// Get visible toasts (limited by `max_visible`)
243    pub fn visible(&self) -> impl Iterator<Item = &Toast> {
244        self.toasts.iter().take(self.max_visible)
245    }
246
247    /// Check if there are any active toasts
248    pub fn is_empty(&self) -> bool {
249        self.toasts.is_empty()
250    }
251
252    /// Get count of active toasts
253    pub fn len(&self) -> usize {
254        self.toasts.len()
255    }
256
257    /// Get the position setting
258    pub fn position(&self) -> ToastPosition {
259        self.position
260    }
261
262    /// Calculate the render area for toasts given the full terminal area
263    pub fn render_area(&self, full_area: Rect) -> Rect {
264        const HORIZONTAL_MARGIN: u16 = 2;
265        const TOAST_ROW_HEIGHT: usize = 3;
266        const VERTICAL_MARGIN: u16 = 1;
267
268        let toast_width = 40.min(full_area.width.saturating_sub(4));
269        let visible_count = self.visible().count();
270        let max_height = full_area.height.saturating_sub(VERTICAL_MARGIN * 2);
271        let toast_height = visible_count
272            .saturating_mul(TOAST_ROW_HEIGHT)
273            .min(usize::from(max_height))
274            .try_into()
275            .unwrap_or(max_height);
276
277        if toast_width == 0 || toast_height == 0 {
278            return Rect::new(full_area.x, full_area.y, 0, 0);
279        }
280
281        let x = match self.position {
282            ToastPosition::TopLeft | ToastPosition::BottomLeft => {
283                full_area.x.saturating_add(HORIZONTAL_MARGIN)
284            }
285            ToastPosition::TopRight | ToastPosition::BottomRight => full_area
286                .right()
287                .saturating_sub(toast_width.saturating_add(HORIZONTAL_MARGIN)),
288            ToastPosition::TopCenter | ToastPosition::BottomCenter => full_area
289                .x
290                .saturating_add(full_area.width.saturating_sub(toast_width) / 2),
291        };
292
293        let y = match self.position {
294            ToastPosition::TopLeft | ToastPosition::TopRight | ToastPosition::TopCenter => {
295                full_area.y.saturating_add(VERTICAL_MARGIN)
296            }
297            ToastPosition::BottomLeft
298            | ToastPosition::BottomRight
299            | ToastPosition::BottomCenter => full_area
300                .bottom()
301                .saturating_sub(toast_height.saturating_add(VERTICAL_MARGIN)),
302        };
303
304        Rect::new(x, y, toast_width, toast_height)
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::{Toast, ToastManager, ToastPosition, ToastType};
311    use ftui::core::geometry::Rect;
312    use std::collections::VecDeque;
313    use std::time::Duration;
314
315    #[test]
316    fn test_toast_creation() {
317        let toast = Toast::info("Test message");
318        assert_eq!(toast.message, "Test message");
319        assert_eq!(toast.toast_type, ToastType::Info);
320        assert_eq!(toast.count, 1);
321    }
322
323    #[test]
324    fn test_toast_type_defaults() {
325        assert_eq!(ToastType::Info.default_duration(), Duration::from_secs(3));
326        assert_eq!(ToastType::Error.default_duration(), Duration::from_secs(6));
327    }
328
329    #[test]
330    fn test_toast_manager_push() {
331        let mut manager = ToastManager::new();
332        manager.push(Toast::info("First"));
333        manager.push(Toast::success("Second"));
334        assert_eq!(manager.len(), 2);
335    }
336
337    #[test]
338    fn test_toast_coalescing() {
339        let mut manager = ToastManager::new().with_coalesce(true);
340        manager.push(Toast::info("Same message"));
341        manager.push(Toast::info("Same message"));
342        manager.push(Toast::info("Same message"));
343
344        assert_eq!(manager.len(), 1);
345        assert_eq!(manager.visible().next().unwrap().count, 3);
346    }
347
348    #[test]
349    fn test_toast_coalesced_count_saturates() {
350        let mut manager = ToastManager::new().with_coalesce(true);
351        manager.push(Toast::info("Same message"));
352        manager.toasts.front_mut().unwrap().count = usize::MAX;
353
354        manager.push(Toast::info("Same message"));
355
356        assert_eq!(manager.len(), 1);
357        assert_eq!(manager.visible().next().unwrap().count, usize::MAX);
358    }
359
360    #[test]
361    fn test_toast_coalescing_disabled() {
362        let mut manager = ToastManager::new().with_coalesce(false);
363        manager.push(Toast::info("Same message"));
364        manager.push(Toast::info("Same message"));
365
366        assert_eq!(manager.len(), 2);
367    }
368
369    #[test]
370    fn test_toast_position() {
371        let manager = ToastManager::new().with_position(ToastPosition::BottomLeft);
372        assert_eq!(manager.position(), ToastPosition::BottomLeft);
373    }
374
375    #[test]
376    fn test_toast_retention_limit_saturates() {
377        let mut manager = ToastManager::new()
378            .with_max_visible(usize::MAX)
379            .with_coalesce(false);
380
381        manager.push(Toast::info("First"));
382        manager.push(Toast::info("Second"));
383
384        assert_eq!(manager.len(), 2);
385    }
386
387    #[test]
388    fn test_render_area_respects_full_area_origin() {
389        let mut manager = ToastManager::new().with_position(ToastPosition::TopLeft);
390        manager.push(Toast::info("Origin-aware"));
391
392        let area = manager.render_area(Rect::new(10, 5, 80, 20));
393
394        assert_eq!(area.x, 12);
395        assert_eq!(area.y, 6);
396        assert_eq!(area.width, 40);
397        assert_eq!(area.height, 3);
398    }
399
400    #[test]
401    fn test_render_area_caps_large_visible_count_without_truncation() {
402        let manager = ToastManager {
403            toasts: (0..21_846)
404                .map(|idx| Toast::info(format!("Toast {idx}")))
405                .collect::<VecDeque<_>>(),
406            max_visible: usize::MAX,
407            position: ToastPosition::TopRight,
408            coalesce: false,
409        };
410
411        let area = manager.render_area(Rect::new(0, 0, 80, u16::MAX));
412
413        assert_eq!(area.height, u16::MAX - 2);
414    }
415
416    #[test]
417    fn test_dismiss_type() {
418        let mut manager = ToastManager::new();
419        manager.push(Toast::info("Info 1"));
420        manager.push(Toast::error("Error 1"));
421        manager.push(Toast::info("Info 2"));
422
423        manager.dismiss_type(ToastType::Info);
424        assert_eq!(manager.len(), 1);
425        assert_eq!(
426            manager.visible().next().unwrap().toast_type,
427            ToastType::Error
428        );
429    }
430}