coding_agent_search/ui/components/
toast.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ToastType {
16 Info,
18 Success,
20 Warning,
22 Error,
24}
25
26impl ToastType {
27 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 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 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), }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum ToastPosition {
61 #[default]
63 TopRight,
64 TopLeft,
66 BottomRight,
68 BottomLeft,
70 TopCenter,
72 BottomCenter,
74}
75
76#[derive(Debug, Clone)]
78pub struct Toast {
79 pub id: String,
81 pub message: String,
83 pub toast_type: ToastType,
85 pub created_at: Instant,
87 pub duration: Duration,
89 pub count: usize,
91}
92
93impl Toast {
94 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 pub fn with_duration(mut self, duration: Duration) -> Self {
110 self.duration = duration;
111 self
112 }
113
114 pub fn with_id(mut self, id: impl Into<String>) -> Self {
116 self.id = id.into();
117 self
118 }
119
120 pub fn is_expired(&self) -> bool {
122 self.created_at.elapsed() >= self.duration
123 }
124
125 pub fn remaining_fraction(&self) -> f32 {
127 let total = self.duration.as_secs_f32();
128 if total <= 0.0 {
129 return 0.0; }
131 let elapsed = self.created_at.elapsed().as_secs_f32();
132 (1.0 - elapsed / total).clamp(0.0, 1.0)
133 }
134
135 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#[derive(Debug)]
155pub struct ToastManager {
156 toasts: VecDeque<Toast>,
158 max_visible: usize,
160 position: ToastPosition,
162 coalesce: bool,
164}
165
166impl Default for ToastManager {
167 fn default() -> Self {
168 Self::new()
169 }
170}
171
172impl ToastManager {
173 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 pub fn with_max_visible(mut self, max: usize) -> Self {
185 self.max_visible = max;
186 self
187 }
188
189 pub fn with_position(mut self, position: ToastPosition) -> Self {
191 self.position = position;
192 self
193 }
194
195 pub fn with_coalesce(mut self, coalesce: bool) -> Self {
197 self.coalesce = coalesce;
198 self
199 }
200
201 pub fn push(&mut self, toast: Toast) {
203 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(); return;
210 }
211
212 self.toasts.push_front(toast);
214
215 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 pub fn tick(&mut self) {
224 self.toasts.retain(|t| !t.is_expired());
225 }
226
227 pub fn clear(&mut self) {
229 self.toasts.clear();
230 }
231
232 pub fn dismiss_oldest(&mut self) {
234 self.toasts.pop_back();
235 }
236
237 pub fn dismiss_type(&mut self, toast_type: ToastType) {
239 self.toasts.retain(|t| t.toast_type != toast_type);
240 }
241
242 pub fn visible(&self) -> impl Iterator<Item = &Toast> {
244 self.toasts.iter().take(self.max_visible)
245 }
246
247 pub fn is_empty(&self) -> bool {
249 self.toasts.is_empty()
250 }
251
252 pub fn len(&self) -> usize {
254 self.toasts.len()
255 }
256
257 pub fn position(&self) -> ToastPosition {
259 self.position
260 }
261
262 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}