1#![forbid(unsafe_code)]
2
3use std::collections::{HashMap, VecDeque};
31use std::hash::{Hash, Hasher};
32use std::time::{Duration, Instant};
33
34use ftui_core::geometry::Rect;
35use ftui_render::frame::Frame;
36
37use crate::Widget;
38use crate::toast::{Toast, ToastId, ToastPosition};
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
45pub enum NotificationPriority {
46 Low = 0,
48 #[default]
50 Normal = 1,
51 High = 2,
53 Urgent = 3,
55}
56
57#[derive(Debug, Clone)]
59pub struct QueueConfig {
60 pub max_visible: usize,
62 pub max_queued: usize,
64 pub default_duration: Duration,
66 pub position: ToastPosition,
68 pub stagger_offset: u16,
70 pub dedup_window_ms: u64,
72}
73
74impl Default for QueueConfig {
75 fn default() -> Self {
76 Self {
77 max_visible: 3,
78 max_queued: 10,
79 default_duration: Duration::from_secs(5),
80 position: ToastPosition::TopRight,
81 stagger_offset: 1,
82 dedup_window_ms: 1000,
83 }
84 }
85}
86
87impl QueueConfig {
88 pub fn new() -> Self {
90 Self::default()
91 }
92
93 pub fn max_visible(mut self, max: usize) -> Self {
95 self.max_visible = max;
96 self
97 }
98
99 pub fn max_queued(mut self, max: usize) -> Self {
101 self.max_queued = max;
102 self
103 }
104
105 pub fn default_duration(mut self, duration: Duration) -> Self {
107 self.default_duration = duration;
108 self
109 }
110
111 pub fn position(mut self, position: ToastPosition) -> Self {
113 self.position = position;
114 self
115 }
116
117 pub fn stagger_offset(mut self, offset: u16) -> Self {
119 self.stagger_offset = offset;
120 self
121 }
122
123 pub fn dedup_window_ms(mut self, ms: u64) -> Self {
125 self.dedup_window_ms = ms;
126 self
127 }
128}
129
130#[derive(Debug)]
132struct QueuedNotification {
133 toast: Toast,
134 priority: NotificationPriority,
135 #[allow(dead_code)]
137 created_at: Instant,
138 content_hash: u64,
139}
140
141impl QueuedNotification {
142 fn new(toast: Toast, priority: NotificationPriority) -> Self {
143 let content_hash = Self::compute_hash(&toast);
144 Self {
145 toast,
146 priority,
147 created_at: Instant::now(),
148 content_hash,
149 }
150 }
151
152 fn compute_hash(toast: &Toast) -> u64 {
153 use std::collections::hash_map::DefaultHasher;
154 let mut hasher = DefaultHasher::new();
155 toast.content.message.hash(&mut hasher);
156 if let Some(ref title) = toast.content.title {
157 title.hash(&mut hasher);
158 }
159 hasher.finish()
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum QueueAction {
166 Show(ToastId),
168 Hide(ToastId),
170 Reposition(ToastId),
172}
173
174#[derive(Debug, Clone, Default)]
176pub struct QueueStats {
177 pub total_pushed: u64,
179 pub overflow_count: u64,
181 pub dedup_count: u64,
183 pub user_dismissed: u64,
185 pub auto_expired: u64,
187}
188
189#[derive(Debug)]
195pub struct NotificationQueue {
196 queue: VecDeque<QueuedNotification>,
198 visible: Vec<Toast>,
200 config: QueueConfig,
202 dedup_window: Duration,
204 recent_hashes: HashMap<u64, Instant>,
206 stats: QueueStats,
208}
209
210pub struct NotificationStack<'a> {
215 queue: &'a NotificationQueue,
216 margin: u16,
217}
218
219impl<'a> NotificationStack<'a> {
220 pub fn new(queue: &'a NotificationQueue) -> Self {
222 Self { queue, margin: 1 }
223 }
224
225 pub fn margin(mut self, margin: u16) -> Self {
227 self.margin = margin;
228 self
229 }
230}
231
232impl Widget for NotificationStack<'_> {
233 fn render(&self, area: Rect, frame: &mut Frame) {
234 if area.is_empty() || self.queue.visible().is_empty() {
235 return;
236 }
237
238 let positions = self
239 .queue
240 .calculate_positions(area.width, area.height, self.margin);
241
242 for (toast, (_, rel_x, rel_y)) in self.queue.visible().iter().zip(positions.iter()) {
243 let (toast_width, toast_height) = toast.calculate_dimensions();
244 let x = area.x.saturating_add(*rel_x);
245 let y = area.y.saturating_add(*rel_y);
246 let toast_area = Rect::new(x, y, toast_width, toast_height);
247 let render_area = toast_area.intersection(&area);
248 if !render_area.is_empty() {
249 toast.render(render_area, frame);
250 }
251 }
252 }
253}
254
255impl NotificationQueue {
256 pub fn new(config: QueueConfig) -> Self {
258 let dedup_window = Duration::from_millis(config.dedup_window_ms);
259 Self {
260 queue: VecDeque::new(),
261 visible: Vec::new(),
262 config,
263 dedup_window,
264 recent_hashes: HashMap::new(),
265 stats: QueueStats::default(),
266 }
267 }
268
269 pub fn with_defaults() -> Self {
271 Self::new(QueueConfig::default())
272 }
273
274 pub fn push(&mut self, toast: Toast, priority: NotificationPriority) -> bool {
279 self.stats.total_pushed += 1;
280
281 let queued = QueuedNotification::new(toast, priority);
282
283 if !self.dedup_check(queued.content_hash) {
285 self.stats.dedup_count += 1;
286 return false;
287 }
288
289 if self.queue.len() >= self.config.max_queued {
291 self.stats.overflow_count += 1;
292 if let Some(idx) = self.find_lowest_priority_index() {
294 if self.queue[idx].priority < priority {
295 self.queue.remove(idx);
296 } else {
297 return false; }
299 } else {
300 return false;
301 }
302 }
303
304 if priority == NotificationPriority::Urgent {
306 self.queue.push_front(queued);
308 } else {
309 let insert_idx = self
311 .queue
312 .iter()
313 .position(|q| q.priority < priority)
314 .unwrap_or(self.queue.len());
315 self.queue.insert(insert_idx, queued);
316 }
317
318 true
319 }
320
321 pub fn notify(&mut self, toast: Toast) -> bool {
323 self.push(toast, NotificationPriority::Normal)
324 }
325
326 pub fn urgent(&mut self, toast: Toast) -> bool {
328 self.push(toast, NotificationPriority::Urgent)
329 }
330
331 pub fn dismiss(&mut self, id: ToastId) {
333 if let Some(idx) = self.visible.iter().position(|t| t.id == id) {
335 self.visible[idx].dismiss();
336 self.stats.user_dismissed += 1;
337 }
338
339 if let Some(idx) = self.queue.iter().position(|q| q.toast.id == id) {
341 self.queue.remove(idx);
342 self.stats.user_dismissed += 1;
343 }
344 }
345
346 pub fn dismiss_all(&mut self) {
348 for toast in &mut self.visible {
349 toast.dismiss();
350 }
351 self.stats.user_dismissed += self.queue.len() as u64;
352 self.queue.clear();
353 }
354
355 pub fn tick(&mut self, _delta: Duration) -> Vec<QueueAction> {
360 let mut actions = Vec::new();
361
362 let now = Instant::now();
364 self.recent_hashes
365 .retain(|_, t| now.duration_since(*t) < self.dedup_window);
366
367 let mut i = 0;
369 while i < self.visible.len() {
370 if !self.visible[i].is_visible() {
371 let id = self.visible[i].id;
372 self.visible.remove(i);
373 self.stats.auto_expired += 1;
374 actions.push(QueueAction::Hide(id));
375 } else {
376 i += 1;
377 }
378 }
379
380 while self.visible.len() < self.config.max_visible {
382 if let Some(queued) = self.queue.pop_front() {
383 let id = queued.toast.id;
384 self.visible.push(queued.toast);
385 actions.push(QueueAction::Show(id));
386 } else {
387 break;
388 }
389 }
390
391 actions
392 }
393
394 pub fn visible(&self) -> &[Toast] {
396 &self.visible
397 }
398
399 pub fn visible_mut(&mut self) -> &mut [Toast] {
401 &mut self.visible
402 }
403
404 pub fn pending_count(&self) -> usize {
406 self.queue.len()
407 }
408
409 pub fn visible_count(&self) -> usize {
411 self.visible.len()
412 }
413
414 pub fn total_count(&self) -> usize {
416 self.visible.len() + self.queue.len()
417 }
418
419 pub fn is_empty(&self) -> bool {
421 self.visible.is_empty() && self.queue.is_empty()
422 }
423
424 pub fn stats(&self) -> &QueueStats {
426 &self.stats
427 }
428
429 pub fn config(&self) -> &QueueConfig {
431 &self.config
432 }
433
434 pub fn calculate_positions(
438 &self,
439 terminal_width: u16,
440 terminal_height: u16,
441 margin: u16,
442 ) -> Vec<(ToastId, u16, u16)> {
443 let mut positions = Vec::with_capacity(self.visible.len());
444 let is_top = matches!(
445 self.config.position,
446 ToastPosition::TopLeft | ToastPosition::TopCenter | ToastPosition::TopRight
447 );
448
449 let mut y_offset: u16 = 0;
450
451 for toast in &self.visible {
452 let (toast_width, toast_height) = toast.calculate_dimensions();
453 let (base_x, base_y) = self.config.position.calculate_position(
454 terminal_width,
455 terminal_height,
456 toast_width,
457 toast_height,
458 margin,
459 );
460
461 let y = if is_top {
462 base_y.saturating_add(y_offset)
463 } else {
464 base_y.saturating_sub(y_offset)
465 };
466
467 positions.push((toast.id, base_x, y));
468 y_offset = y_offset
469 .saturating_add(toast_height)
470 .saturating_add(self.config.stagger_offset);
471 }
472
473 positions
474 }
475
476 fn dedup_check(&mut self, hash: u64) -> bool {
480 let now = Instant::now();
481
482 self.recent_hashes
484 .retain(|_, t| now.duration_since(*t) < self.dedup_window);
485
486 if self.recent_hashes.contains_key(&hash) {
488 return false;
489 }
490
491 self.recent_hashes.insert(hash, now);
492 true
493 }
494
495 fn find_lowest_priority_index(&self) -> Option<usize> {
497 self.queue
498 .iter()
499 .enumerate()
500 .min_by_key(|(_, q)| q.priority)
501 .map(|(i, _)| i)
502 }
503}
504
505impl Default for NotificationQueue {
506 fn default() -> Self {
507 Self::with_defaults()
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use ftui_render::frame::Frame;
515 use ftui_render::grapheme_pool::GraphemePool;
516
517 fn make_toast(msg: &str) -> Toast {
518 Toast::with_id(ToastId::new(0), msg).persistent() }
520
521 #[test]
522 fn test_queue_new() {
523 let queue = NotificationQueue::with_defaults();
524 assert!(queue.is_empty());
525 assert_eq!(queue.visible_count(), 0);
526 assert_eq!(queue.pending_count(), 0);
527 }
528
529 #[test]
530 fn test_queue_push_and_tick() {
531 let mut queue = NotificationQueue::with_defaults();
532
533 queue.push(make_toast("Hello"), NotificationPriority::Normal);
534 assert_eq!(queue.pending_count(), 1);
535 assert_eq!(queue.visible_count(), 0);
536
537 let actions = queue.tick(Duration::from_millis(16));
539 assert_eq!(queue.pending_count(), 0);
540 assert_eq!(queue.visible_count(), 1);
541 assert_eq!(actions.len(), 1);
542 assert!(matches!(actions[0], QueueAction::Show(_)));
543 }
544
545 #[test]
546 fn test_queue_fifo() {
547 let config = QueueConfig::default().max_visible(1);
548 let mut queue = NotificationQueue::new(config);
549
550 queue.push(make_toast("First"), NotificationPriority::Normal);
551 queue.push(make_toast("Second"), NotificationPriority::Normal);
552 queue.push(make_toast("Third"), NotificationPriority::Normal);
553
554 queue.tick(Duration::from_millis(16));
555 assert_eq!(queue.visible()[0].content.message, "First");
556
557 queue.visible_mut()[0].dismiss();
559 queue.tick(Duration::from_millis(16));
560 assert_eq!(queue.visible()[0].content.message, "Second");
561 }
562
563 #[test]
564 fn test_queue_max_visible() {
565 let config = QueueConfig::default().max_visible(2);
566 let mut queue = NotificationQueue::new(config);
567
568 queue.push(make_toast("A"), NotificationPriority::Normal);
569 queue.push(make_toast("B"), NotificationPriority::Normal);
570 queue.push(make_toast("C"), NotificationPriority::Normal);
571
572 queue.tick(Duration::from_millis(16));
573
574 assert_eq!(queue.visible_count(), 2);
575 assert_eq!(queue.pending_count(), 1);
576 }
577
578 #[test]
579 fn test_queue_priority_urgent() {
580 let config = QueueConfig::default().max_visible(1);
581 let mut queue = NotificationQueue::new(config);
582
583 queue.push(make_toast("Normal1"), NotificationPriority::Normal);
584 queue.push(make_toast("Normal2"), NotificationPriority::Normal);
585 queue.push(make_toast("Urgent"), NotificationPriority::Urgent);
586
587 queue.tick(Duration::from_millis(16));
588 assert_eq!(queue.visible()[0].content.message, "Urgent");
590 }
591
592 #[test]
593 fn test_queue_priority_ordering() {
594 let config = QueueConfig::default().max_visible(0); let mut queue = NotificationQueue::new(config);
596
597 queue.push(make_toast("Low"), NotificationPriority::Low);
598 queue.push(make_toast("Normal"), NotificationPriority::Normal);
599 queue.push(make_toast("High"), NotificationPriority::High);
600
601 let messages: Vec<_> = queue
603 .queue
604 .iter()
605 .map(|q| q.toast.content.message.as_str())
606 .collect();
607 assert_eq!(messages, vec!["High", "Normal", "Low"]);
608 }
609
610 #[test]
611 fn test_queue_dedup() {
612 let config = QueueConfig::default().dedup_window_ms(1000);
613 let mut queue = NotificationQueue::new(config);
614
615 assert!(queue.push(make_toast("Same message"), NotificationPriority::Normal));
616 assert!(!queue.push(make_toast("Same message"), NotificationPriority::Normal));
617
618 assert_eq!(queue.stats().dedup_count, 1);
619 }
620
621 #[test]
622 fn test_queue_overflow() {
623 let config = QueueConfig::default().max_queued(2);
624 let mut queue = NotificationQueue::new(config);
625
626 assert!(queue.push(make_toast("A"), NotificationPriority::Normal));
627 assert!(queue.push(make_toast("B"), NotificationPriority::Normal));
628 assert!(!queue.push(make_toast("C"), NotificationPriority::Normal));
630
631 assert_eq!(queue.stats().overflow_count, 1);
632 }
633
634 #[test]
635 fn test_queue_overflow_drops_lower_priority() {
636 let config = QueueConfig::default().max_queued(2);
637 let mut queue = NotificationQueue::new(config);
638
639 assert!(queue.push(make_toast("Low1"), NotificationPriority::Low));
640 assert!(queue.push(make_toast("Low2"), NotificationPriority::Low));
641 assert!(queue.push(make_toast("High"), NotificationPriority::High));
643
644 assert_eq!(queue.pending_count(), 2);
645 let messages: Vec<_> = queue
646 .queue
647 .iter()
648 .map(|q| q.toast.content.message.as_str())
649 .collect();
650 assert!(messages.contains(&"High"));
651 }
652
653 #[test]
654 fn test_queue_dismiss() {
655 let mut queue = NotificationQueue::with_defaults();
656
657 queue.push(make_toast("Test"), NotificationPriority::Normal);
658 queue.tick(Duration::from_millis(16));
659
660 let id = queue.visible()[0].id;
661 queue.dismiss(id);
662 queue.tick(Duration::from_millis(16));
663
664 assert_eq!(queue.visible_count(), 0);
665 assert_eq!(queue.stats().user_dismissed, 1);
666 }
667
668 #[test]
669 fn test_queue_dismiss_all() {
670 let mut queue = NotificationQueue::with_defaults();
671
672 queue.push(make_toast("A"), NotificationPriority::Normal);
673 queue.push(make_toast("B"), NotificationPriority::Normal);
674 queue.tick(Duration::from_millis(16));
675
676 queue.dismiss_all();
677 queue.tick(Duration::from_millis(16));
678
679 assert!(queue.is_empty());
680 }
681
682 #[test]
683 fn test_queue_calculate_positions_top() {
684 let config = QueueConfig::default().position(ToastPosition::TopRight);
685 let mut queue = NotificationQueue::new(config);
686
687 queue.push(make_toast("A"), NotificationPriority::Normal);
688 queue.push(make_toast("B"), NotificationPriority::Normal);
689 queue.tick(Duration::from_millis(16));
690
691 let positions = queue.calculate_positions(80, 24, 1);
692 assert_eq!(positions.len(), 2);
693
694 assert!(positions[0].2 < positions[1].2);
696 }
697
698 #[test]
699 fn test_queue_calculate_positions_bottom() {
700 let config = QueueConfig::default().position(ToastPosition::BottomRight);
701 let mut queue = NotificationQueue::new(config);
702
703 queue.push(make_toast("A"), NotificationPriority::Normal);
704 queue.push(make_toast("B"), NotificationPriority::Normal);
705 queue.tick(Duration::from_millis(16));
706
707 let positions = queue.calculate_positions(80, 24, 1);
708 assert_eq!(positions.len(), 2);
709
710 assert!(positions[0].2 > positions[1].2);
712 }
713
714 #[test]
715 fn test_queue_notify_helper() {
716 let mut queue = NotificationQueue::with_defaults();
717 assert!(queue.notify(make_toast("Normal")));
718 queue.tick(Duration::from_millis(16));
719 assert_eq!(queue.visible_count(), 1);
720 }
721
722 #[test]
723 fn test_queue_urgent_helper() {
724 let config = QueueConfig::default().max_visible(1);
725 let mut queue = NotificationQueue::new(config);
726
727 queue.notify(make_toast("Normal"));
728 queue.urgent(make_toast("Urgent"));
729 queue.tick(Duration::from_millis(16));
730
731 assert_eq!(queue.visible()[0].content.message, "Urgent");
732 }
733
734 #[test]
735 fn test_queue_stats() {
736 let mut queue = NotificationQueue::with_defaults();
737
738 queue.push(make_toast("A"), NotificationPriority::Normal);
739 queue.push(make_toast("A"), NotificationPriority::Normal); queue.tick(Duration::from_millis(16));
741
742 assert_eq!(queue.stats().total_pushed, 2);
743 assert_eq!(queue.stats().dedup_count, 1);
744 }
745
746 #[test]
747 fn test_queue_config_builder() {
748 let config = QueueConfig::new()
749 .max_visible(5)
750 .max_queued(20)
751 .default_duration(Duration::from_secs(10))
752 .position(ToastPosition::BottomLeft)
753 .stagger_offset(2)
754 .dedup_window_ms(500);
755
756 assert_eq!(config.max_visible, 5);
757 assert_eq!(config.max_queued, 20);
758 assert_eq!(config.default_duration, Duration::from_secs(10));
759 assert_eq!(config.position, ToastPosition::BottomLeft);
760 assert_eq!(config.stagger_offset, 2);
761 assert_eq!(config.dedup_window_ms, 500);
762 }
763
764 #[test]
765 fn test_queue_total_count() {
766 let config = QueueConfig::default().max_visible(1);
767 let mut queue = NotificationQueue::new(config);
768
769 queue.push(make_toast("A"), NotificationPriority::Normal);
770 queue.push(make_toast("B"), NotificationPriority::Normal);
771 queue.tick(Duration::from_millis(16));
772
773 assert_eq!(queue.total_count(), 2);
774 assert_eq!(queue.visible_count(), 1);
775 assert_eq!(queue.pending_count(), 1);
776 }
777
778 #[test]
779 fn notification_stack_renders_visible_toast() {
780 let mut queue = NotificationQueue::with_defaults();
781 queue.push(make_toast("Hello"), NotificationPriority::Normal);
782 queue.tick(Duration::from_millis(16));
783
784 let mut pool = GraphemePool::new();
785 let mut frame = Frame::new(40, 10, &mut pool);
786 let area = Rect::new(0, 0, 40, 10);
787
788 NotificationStack::new(&queue)
789 .margin(0)
790 .render(area, &mut frame);
791
792 let (_, x, y) = queue.calculate_positions(40, 10, 0)[0];
793 let cell = frame.buffer.get(x, y).expect("cell should exist");
794 assert!(!cell.is_empty(), "stack should render toast content");
795 }
796}