1#![forbid(unsafe_code)]
2
3use ahash::AHashMap;
31use std::collections::VecDeque;
32use std::hash::{Hash, Hasher};
33use web_time::{Duration, Instant};
34
35use ftui_core::geometry::Rect;
36use ftui_render::frame::Frame;
37
38use crate::Widget;
39use crate::toast::{Toast, ToastId, ToastPosition};
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
46pub enum NotificationPriority {
47 Low = 0,
49 #[default]
51 Normal = 1,
52 High = 2,
54 Urgent = 3,
56}
57
58#[derive(Debug, Clone)]
60pub struct QueueConfig {
61 pub max_visible: usize,
63 pub max_queued: usize,
65 pub default_duration: Duration,
67 pub position: ToastPosition,
69 pub stagger_offset: u16,
71 pub dedup_window_ms: u64,
73}
74
75impl Default for QueueConfig {
76 fn default() -> Self {
77 Self {
78 max_visible: 3,
79 max_queued: 10,
80 default_duration: Duration::from_secs(5),
81 position: ToastPosition::TopRight,
82 stagger_offset: 1,
83 dedup_window_ms: 1000,
84 }
85 }
86}
87
88impl QueueConfig {
89 pub fn new() -> Self {
91 Self::default()
92 }
93
94 #[must_use]
96 pub fn max_visible(mut self, max: usize) -> Self {
97 self.max_visible = max;
98 self
99 }
100
101 #[must_use]
103 pub fn max_queued(mut self, max: usize) -> Self {
104 self.max_queued = max;
105 self
106 }
107
108 #[must_use]
110 pub fn default_duration(mut self, duration: Duration) -> Self {
111 self.default_duration = duration;
112 self
113 }
114
115 #[must_use]
117 pub fn position(mut self, position: ToastPosition) -> Self {
118 self.position = position;
119 self
120 }
121
122 #[must_use]
124 pub fn stagger_offset(mut self, offset: u16) -> Self {
125 self.stagger_offset = offset;
126 self
127 }
128
129 #[must_use]
131 pub fn dedup_window_ms(mut self, ms: u64) -> Self {
132 self.dedup_window_ms = ms;
133 self
134 }
135}
136
137#[derive(Debug)]
139struct QueuedNotification {
140 toast: Toast,
141 priority: NotificationPriority,
142 #[allow(dead_code)]
144 created_at: Instant,
145 content_hash: u64,
146}
147
148impl QueuedNotification {
149 fn new(toast: Toast, priority: NotificationPriority) -> Self {
150 let content_hash = Self::compute_hash(&toast);
151 Self {
152 toast,
153 priority,
154 created_at: Instant::now(),
155 content_hash,
156 }
157 }
158
159 fn compute_hash(toast: &Toast) -> u64 {
160 use std::collections::hash_map::DefaultHasher;
161 let mut hasher = DefaultHasher::new();
162 toast.content.message.hash(&mut hasher);
163 if let Some(ref title) = toast.content.title {
164 title.hash(&mut hasher);
165 }
166 hasher.finish()
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum QueueAction {
173 Show(ToastId),
175 Hide(ToastId),
177 Reposition(ToastId),
179}
180
181#[derive(Debug, Clone, Default)]
183pub struct QueueStats {
184 pub total_pushed: u64,
186 pub overflow_count: u64,
188 pub dedup_count: u64,
190 pub user_dismissed: u64,
192 pub auto_expired: u64,
194}
195
196#[derive(Debug)]
202pub struct NotificationQueue {
203 queue: VecDeque<QueuedNotification>,
205 visible: Vec<Toast>,
207 config: QueueConfig,
209 dedup_window: Duration,
211 recent_hashes: AHashMap<u64, Instant>,
213 stats: QueueStats,
215}
216
217pub struct NotificationStack<'a> {
222 queue: &'a NotificationQueue,
223 margin: u16,
224}
225
226impl<'a> NotificationStack<'a> {
227 pub fn new(queue: &'a NotificationQueue) -> Self {
229 Self { queue, margin: 1 }
230 }
231
232 #[must_use]
234 pub fn margin(mut self, margin: u16) -> Self {
235 self.margin = margin;
236 self
237 }
238}
239
240impl Widget for NotificationStack<'_> {
241 fn render(&self, area: Rect, frame: &mut Frame) {
242 if area.is_empty() || self.queue.visible().is_empty() {
243 return;
244 }
245
246 let positions = self
247 .queue
248 .calculate_positions(area.width, area.height, self.margin);
249
250 for (toast, (_, rel_x, rel_y)) in self.queue.visible().iter().zip(positions.iter()) {
251 let (toast_width, toast_height) = toast.calculate_dimensions();
252 let x = area.x.saturating_add(*rel_x);
253 let y = area.y.saturating_add(*rel_y);
254 let toast_area = Rect::new(x, y, toast_width, toast_height);
255 let render_area = toast_area.intersection(&area);
256 if !render_area.is_empty() {
257 toast.render(render_area, frame);
258 }
259 }
260 }
261}
262
263impl NotificationQueue {
264 pub fn new(config: QueueConfig) -> Self {
266 let dedup_window = Duration::from_millis(config.dedup_window_ms);
267 Self {
268 queue: VecDeque::new(),
269 visible: Vec::new(),
270 config,
271 dedup_window,
272 recent_hashes: AHashMap::new(),
273 stats: QueueStats::default(),
274 }
275 }
276
277 pub fn with_defaults() -> Self {
279 Self::new(QueueConfig::default())
280 }
281
282 pub fn push(&mut self, toast: Toast, priority: NotificationPriority) -> bool {
287 self.stats.total_pushed += 1;
288
289 let queued = QueuedNotification::new(toast, priority);
290
291 if !self.dedup_check(queued.content_hash) {
293 self.stats.dedup_count += 1;
294 return false;
295 }
296
297 if self.queue.len() >= self.config.max_queued {
299 self.stats.overflow_count += 1;
300 if let Some(idx) = self.find_lowest_priority_index() {
302 if self.queue[idx].priority < priority {
303 self.queue.remove(idx);
304 } else {
305 return false; }
307 } else {
308 return false;
309 }
310 }
311
312 if priority == NotificationPriority::Urgent {
314 self.queue.push_front(queued);
316 } else {
317 let insert_idx = self
319 .queue
320 .iter()
321 .position(|q| q.priority < priority)
322 .unwrap_or(self.queue.len());
323 self.queue.insert(insert_idx, queued);
324 }
325
326 true
327 }
328
329 pub fn notify(&mut self, toast: Toast) -> bool {
331 self.push(toast, NotificationPriority::Normal)
332 }
333
334 pub fn urgent(&mut self, toast: Toast) -> bool {
336 self.push(toast, NotificationPriority::Urgent)
337 }
338
339 pub fn dismiss(&mut self, id: ToastId) {
341 if let Some(idx) = self.visible.iter().position(|t| t.id == id) {
343 self.visible[idx].dismiss();
344 self.stats.user_dismissed += 1;
345 }
346
347 if let Some(idx) = self.queue.iter().position(|q| q.toast.id == id) {
349 self.queue.remove(idx);
350 self.stats.user_dismissed += 1;
351 }
352 }
353
354 pub fn dismiss_all(&mut self) {
356 for toast in &mut self.visible {
357 toast.dismiss();
358 }
359 self.stats.user_dismissed += self.queue.len() as u64;
360 self.queue.clear();
361 }
362
363 pub fn tick(&mut self, _delta: Duration) -> Vec<QueueAction> {
368 let mut actions = Vec::new();
369
370 let now = Instant::now();
372 self.recent_hashes
373 .retain(|_, t| now.duration_since(*t) < self.dedup_window);
374
375 let mut i = 0;
377 while i < self.visible.len() {
378 if !self.visible[i].is_visible() {
379 let id = self.visible[i].id;
380 self.visible.remove(i);
381 self.stats.auto_expired += 1;
382 actions.push(QueueAction::Hide(id));
383 } else {
384 i += 1;
385 }
386 }
387
388 while self.visible.len() < self.config.max_visible {
390 if let Some(queued) = self.queue.pop_front() {
391 let id = queued.toast.id;
392 self.visible.push(queued.toast);
393 actions.push(QueueAction::Show(id));
394 } else {
395 break;
396 }
397 }
398
399 actions
400 }
401
402 pub fn visible(&self) -> &[Toast] {
404 &self.visible
405 }
406
407 pub fn visible_mut(&mut self) -> &mut [Toast] {
409 &mut self.visible
410 }
411
412 pub fn pending_count(&self) -> usize {
414 self.queue.len()
415 }
416
417 pub fn visible_count(&self) -> usize {
419 self.visible.len()
420 }
421
422 pub fn total_count(&self) -> usize {
424 self.visible.len() + self.queue.len()
425 }
426
427 #[inline]
429 pub fn is_empty(&self) -> bool {
430 self.visible.is_empty() && self.queue.is_empty()
431 }
432
433 pub fn stats(&self) -> &QueueStats {
435 &self.stats
436 }
437
438 pub fn config(&self) -> &QueueConfig {
440 &self.config
441 }
442
443 pub fn calculate_positions(
447 &self,
448 terminal_width: u16,
449 terminal_height: u16,
450 margin: u16,
451 ) -> Vec<(ToastId, u16, u16)> {
452 let mut positions = Vec::with_capacity(self.visible.len());
453 let is_top = matches!(
454 self.config.position,
455 ToastPosition::TopLeft | ToastPosition::TopCenter | ToastPosition::TopRight
456 );
457
458 let mut y_offset: u16 = 0;
459
460 for toast in &self.visible {
461 let (toast_width, toast_height) = toast.calculate_dimensions();
462 let (base_x, base_y) = self.config.position.calculate_position(
463 terminal_width,
464 terminal_height,
465 toast_width,
466 toast_height,
467 margin,
468 );
469
470 let y = if is_top {
471 base_y.saturating_add(y_offset)
472 } else {
473 base_y.saturating_sub(y_offset)
474 };
475
476 positions.push((toast.id, base_x, y));
477 y_offset = y_offset
478 .saturating_add(toast_height)
479 .saturating_add(self.config.stagger_offset);
480 }
481
482 positions
483 }
484
485 fn dedup_check(&mut self, hash: u64) -> bool {
489 let now = Instant::now();
490
491 self.recent_hashes
493 .retain(|_, t| now.duration_since(*t) < self.dedup_window);
494
495 if self.recent_hashes.contains_key(&hash) {
497 return false;
498 }
499
500 self.recent_hashes.insert(hash, now);
501 true
502 }
503
504 fn find_lowest_priority_index(&self) -> Option<usize> {
506 self.queue
507 .iter()
508 .enumerate()
509 .min_by_key(|(_, q)| q.priority)
510 .map(|(i, _)| i)
511 }
512}
513
514impl Default for NotificationQueue {
515 fn default() -> Self {
516 Self::with_defaults()
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use ftui_render::frame::Frame;
524 use ftui_render::grapheme_pool::GraphemePool;
525
526 fn make_toast(msg: &str) -> Toast {
527 Toast::with_id(ToastId::new(0), msg).persistent() }
529
530 #[test]
531 fn test_queue_new() {
532 let queue = NotificationQueue::with_defaults();
533 assert!(queue.is_empty());
534 assert_eq!(queue.visible_count(), 0);
535 assert_eq!(queue.pending_count(), 0);
536 }
537
538 #[test]
539 fn test_queue_push_and_tick() {
540 let mut queue = NotificationQueue::with_defaults();
541
542 queue.push(make_toast("Hello"), NotificationPriority::Normal);
543 assert_eq!(queue.pending_count(), 1);
544 assert_eq!(queue.visible_count(), 0);
545
546 let actions = queue.tick(Duration::from_millis(16));
548 assert_eq!(queue.pending_count(), 0);
549 assert_eq!(queue.visible_count(), 1);
550 assert_eq!(actions.len(), 1);
551 assert!(matches!(actions[0], QueueAction::Show(_)));
552 }
553
554 #[test]
555 fn test_queue_fifo() {
556 let config = QueueConfig::default().max_visible(1);
557 let mut queue = NotificationQueue::new(config);
558
559 queue.push(make_toast("First"), NotificationPriority::Normal);
560 queue.push(make_toast("Second"), NotificationPriority::Normal);
561 queue.push(make_toast("Third"), NotificationPriority::Normal);
562
563 queue.tick(Duration::from_millis(16));
564 assert_eq!(queue.visible()[0].content.message, "First");
565
566 queue.visible_mut()[0].dismiss();
568 queue.tick(Duration::from_millis(16));
569 assert_eq!(queue.visible()[0].content.message, "Second");
570 }
571
572 #[test]
573 fn test_queue_max_visible() {
574 let config = QueueConfig::default().max_visible(2);
575 let mut queue = NotificationQueue::new(config);
576
577 queue.push(make_toast("A"), NotificationPriority::Normal);
578 queue.push(make_toast("B"), NotificationPriority::Normal);
579 queue.push(make_toast("C"), NotificationPriority::Normal);
580
581 queue.tick(Duration::from_millis(16));
582
583 assert_eq!(queue.visible_count(), 2);
584 assert_eq!(queue.pending_count(), 1);
585 }
586
587 #[test]
588 fn test_queue_priority_urgent() {
589 let config = QueueConfig::default().max_visible(1);
590 let mut queue = NotificationQueue::new(config);
591
592 queue.push(make_toast("Normal1"), NotificationPriority::Normal);
593 queue.push(make_toast("Normal2"), NotificationPriority::Normal);
594 queue.push(make_toast("Urgent"), NotificationPriority::Urgent);
595
596 queue.tick(Duration::from_millis(16));
597 assert_eq!(queue.visible()[0].content.message, "Urgent");
599 }
600
601 #[test]
602 fn test_queue_priority_ordering() {
603 let config = QueueConfig::default().max_visible(0); let mut queue = NotificationQueue::new(config);
605
606 queue.push(make_toast("Low"), NotificationPriority::Low);
607 queue.push(make_toast("Normal"), NotificationPriority::Normal);
608 queue.push(make_toast("High"), NotificationPriority::High);
609
610 let messages: Vec<_> = queue
612 .queue
613 .iter()
614 .map(|q| q.toast.content.message.as_str())
615 .collect();
616 assert_eq!(messages, vec!["High", "Normal", "Low"]);
617 }
618
619 #[test]
620 fn test_queue_dedup() {
621 let config = QueueConfig::default().dedup_window_ms(1000);
622 let mut queue = NotificationQueue::new(config);
623
624 assert!(queue.push(make_toast("Same message"), NotificationPriority::Normal));
625 assert!(!queue.push(make_toast("Same message"), NotificationPriority::Normal));
626
627 assert_eq!(queue.stats().dedup_count, 1);
628 }
629
630 #[test]
631 fn test_queue_overflow() {
632 let config = QueueConfig::default().max_queued(2);
633 let mut queue = NotificationQueue::new(config);
634
635 assert!(queue.push(make_toast("A"), NotificationPriority::Normal));
636 assert!(queue.push(make_toast("B"), NotificationPriority::Normal));
637 assert!(!queue.push(make_toast("C"), NotificationPriority::Normal));
639
640 assert_eq!(queue.stats().overflow_count, 1);
641 }
642
643 #[test]
644 fn test_queue_overflow_drops_lower_priority() {
645 let config = QueueConfig::default().max_queued(2);
646 let mut queue = NotificationQueue::new(config);
647
648 assert!(queue.push(make_toast("Low1"), NotificationPriority::Low));
649 assert!(queue.push(make_toast("Low2"), NotificationPriority::Low));
650 assert!(queue.push(make_toast("High"), NotificationPriority::High));
652
653 assert_eq!(queue.pending_count(), 2);
654 let messages: Vec<_> = queue
655 .queue
656 .iter()
657 .map(|q| q.toast.content.message.as_str())
658 .collect();
659 assert!(messages.contains(&"High"));
660 }
661
662 #[test]
663 fn test_queue_dismiss() {
664 let mut queue = NotificationQueue::with_defaults();
665
666 queue.push(make_toast("Test"), NotificationPriority::Normal);
667 queue.tick(Duration::from_millis(16));
668
669 let id = queue.visible()[0].id;
670 queue.dismiss(id);
671 queue.tick(Duration::from_millis(16));
672
673 assert_eq!(queue.visible_count(), 0);
674 assert_eq!(queue.stats().user_dismissed, 1);
675 }
676
677 #[test]
678 fn test_queue_dismiss_all() {
679 let mut queue = NotificationQueue::with_defaults();
680
681 queue.push(make_toast("A"), NotificationPriority::Normal);
682 queue.push(make_toast("B"), NotificationPriority::Normal);
683 queue.tick(Duration::from_millis(16));
684
685 queue.dismiss_all();
686 queue.tick(Duration::from_millis(16));
687
688 assert!(queue.is_empty());
689 }
690
691 #[test]
692 fn test_queue_calculate_positions_top() {
693 let config = QueueConfig::default().position(ToastPosition::TopRight);
694 let mut queue = NotificationQueue::new(config);
695
696 queue.push(make_toast("A"), NotificationPriority::Normal);
697 queue.push(make_toast("B"), NotificationPriority::Normal);
698 queue.tick(Duration::from_millis(16));
699
700 let positions = queue.calculate_positions(80, 24, 1);
701 assert_eq!(positions.len(), 2);
702
703 assert!(positions[0].2 < positions[1].2);
705 }
706
707 #[test]
708 fn test_queue_calculate_positions_bottom() {
709 let config = QueueConfig::default().position(ToastPosition::BottomRight);
710 let mut queue = NotificationQueue::new(config);
711
712 queue.push(make_toast("A"), NotificationPriority::Normal);
713 queue.push(make_toast("B"), NotificationPriority::Normal);
714 queue.tick(Duration::from_millis(16));
715
716 let positions = queue.calculate_positions(80, 24, 1);
717 assert_eq!(positions.len(), 2);
718
719 assert!(positions[0].2 > positions[1].2);
721 }
722
723 #[test]
724 fn test_queue_notify_helper() {
725 let mut queue = NotificationQueue::with_defaults();
726 assert!(queue.notify(make_toast("Normal")));
727 queue.tick(Duration::from_millis(16));
728 assert_eq!(queue.visible_count(), 1);
729 }
730
731 #[test]
732 fn test_queue_urgent_helper() {
733 let config = QueueConfig::default().max_visible(1);
734 let mut queue = NotificationQueue::new(config);
735
736 queue.notify(make_toast("Normal"));
737 queue.urgent(make_toast("Urgent"));
738 queue.tick(Duration::from_millis(16));
739
740 assert_eq!(queue.visible()[0].content.message, "Urgent");
741 }
742
743 #[test]
744 fn test_queue_stats() {
745 let mut queue = NotificationQueue::with_defaults();
746
747 queue.push(make_toast("A"), NotificationPriority::Normal);
748 queue.push(make_toast("A"), NotificationPriority::Normal); queue.tick(Duration::from_millis(16));
750
751 assert_eq!(queue.stats().total_pushed, 2);
752 assert_eq!(queue.stats().dedup_count, 1);
753 }
754
755 #[test]
756 fn test_queue_config_builder() {
757 let config = QueueConfig::new()
758 .max_visible(5)
759 .max_queued(20)
760 .default_duration(Duration::from_secs(10))
761 .position(ToastPosition::BottomLeft)
762 .stagger_offset(2)
763 .dedup_window_ms(500);
764
765 assert_eq!(config.max_visible, 5);
766 assert_eq!(config.max_queued, 20);
767 assert_eq!(config.default_duration, Duration::from_secs(10));
768 assert_eq!(config.position, ToastPosition::BottomLeft);
769 assert_eq!(config.stagger_offset, 2);
770 assert_eq!(config.dedup_window_ms, 500);
771 }
772
773 #[test]
774 fn test_queue_total_count() {
775 let config = QueueConfig::default().max_visible(1);
776 let mut queue = NotificationQueue::new(config);
777
778 queue.push(make_toast("A"), NotificationPriority::Normal);
779 queue.push(make_toast("B"), NotificationPriority::Normal);
780 queue.tick(Duration::from_millis(16));
781
782 assert_eq!(queue.total_count(), 2);
783 assert_eq!(queue.visible_count(), 1);
784 assert_eq!(queue.pending_count(), 1);
785 }
786
787 #[test]
788 fn queue_config_default_values() {
789 let config = QueueConfig::default();
790 assert_eq!(config.max_visible, 3);
791 assert_eq!(config.max_queued, 10);
792 assert_eq!(config.default_duration, Duration::from_secs(5));
793 assert_eq!(config.position, ToastPosition::TopRight);
794 assert_eq!(config.stagger_offset, 1);
795 assert_eq!(config.dedup_window_ms, 1000);
796 }
797
798 #[test]
799 fn notification_priority_default_is_normal() {
800 assert_eq!(
801 NotificationPriority::default(),
802 NotificationPriority::Normal
803 );
804 }
805
806 #[test]
807 fn notification_priority_ordering() {
808 assert!(NotificationPriority::Low < NotificationPriority::Normal);
809 assert!(NotificationPriority::Normal < NotificationPriority::High);
810 assert!(NotificationPriority::High < NotificationPriority::Urgent);
811 }
812
813 #[test]
814 fn queue_default_trait_delegates_to_with_defaults() {
815 let queue = NotificationQueue::default();
816 assert!(queue.is_empty());
817 assert_eq!(queue.config().max_visible, 3);
818 }
819
820 #[test]
821 fn is_empty_false_when_pending() {
822 let mut queue = NotificationQueue::with_defaults();
823 queue.push(make_toast("X"), NotificationPriority::Normal);
824 assert!(!queue.is_empty());
825 }
826
827 #[test]
828 fn is_empty_false_when_visible() {
829 let mut queue = NotificationQueue::with_defaults();
830 queue.push(make_toast("X"), NotificationPriority::Normal);
831 queue.tick(Duration::from_millis(16));
832 assert!(!queue.is_empty());
833 }
834
835 #[test]
836 fn visible_mut_allows_modification() {
837 let mut queue = NotificationQueue::with_defaults();
838 queue.push(make_toast("Original"), NotificationPriority::Normal);
839 queue.tick(Duration::from_millis(16));
840
841 queue.visible_mut()[0].dismiss();
843 queue.tick(Duration::from_millis(16));
844 assert_eq!(queue.visible_count(), 0);
845 }
846
847 #[test]
848 fn config_accessor_returns_config() {
849 let config = QueueConfig::default().max_visible(7).stagger_offset(3);
850 let queue = NotificationQueue::new(config);
851 assert_eq!(queue.config().max_visible, 7);
852 assert_eq!(queue.config().stagger_offset, 3);
853 }
854
855 #[test]
856 fn dismiss_all_clears_queue_and_visible() {
857 let config = QueueConfig::default().max_visible(1);
858 let mut queue = NotificationQueue::new(config);
859
860 queue.push(make_toast("A"), NotificationPriority::Normal);
861 queue.push(make_toast("B"), NotificationPriority::Normal);
862 queue.tick(Duration::from_millis(16));
863
864 assert_eq!(queue.visible_count(), 1);
866 assert_eq!(queue.pending_count(), 1);
867
868 queue.dismiss_all();
869 assert_eq!(queue.stats().user_dismissed, 1);
872 assert_eq!(queue.pending_count(), 0);
873
874 queue.tick(Duration::from_millis(16));
876 assert!(queue.is_empty());
877 }
878
879 #[test]
880 fn queue_action_equality() {
881 let id = ToastId::new(42);
882 assert_eq!(QueueAction::Show(id), QueueAction::Show(id));
883 assert_eq!(QueueAction::Hide(id), QueueAction::Hide(id));
884 assert_eq!(QueueAction::Reposition(id), QueueAction::Reposition(id));
885 assert_ne!(QueueAction::Show(id), QueueAction::Hide(id));
886 }
887
888 #[test]
889 fn queue_stats_default_all_zero() {
890 let stats = QueueStats::default();
891 assert_eq!(stats.total_pushed, 0);
892 assert_eq!(stats.overflow_count, 0);
893 assert_eq!(stats.dedup_count, 0);
894 assert_eq!(stats.user_dismissed, 0);
895 assert_eq!(stats.auto_expired, 0);
896 }
897
898 #[test]
899 fn calculate_positions_empty_returns_empty() {
900 let queue = NotificationQueue::with_defaults();
901 let positions = queue.calculate_positions(80, 24, 1);
902 assert!(positions.is_empty());
903 }
904
905 #[test]
906 fn notification_stack_empty_area_renders_nothing() {
907 let mut queue = NotificationQueue::with_defaults();
908 queue.push(make_toast("Hello"), NotificationPriority::Normal);
909 queue.tick(Duration::from_millis(16));
910
911 let mut pool = GraphemePool::new();
912 let mut frame = Frame::new(40, 10, &mut pool);
913 let empty_area = Rect::new(0, 0, 0, 0);
914
915 NotificationStack::new(&queue).render(empty_area, &mut frame);
917 }
918
919 #[test]
920 fn notification_stack_margin_builder() {
921 let queue = NotificationQueue::with_defaults();
922 let stack = NotificationStack::new(&queue).margin(5);
923 assert_eq!(stack.margin, 5);
924 }
925
926 #[test]
927 fn notification_stack_renders_visible_toast() {
928 let mut queue = NotificationQueue::with_defaults();
929 queue.push(make_toast("Hello"), NotificationPriority::Normal);
930 queue.tick(Duration::from_millis(16));
931
932 let mut pool = GraphemePool::new();
933 let mut frame = Frame::new(40, 10, &mut pool);
934 let area = Rect::new(0, 0, 40, 10);
935
936 NotificationStack::new(&queue)
937 .margin(0)
938 .render(area, &mut frame);
939
940 let (_, x, y) = queue.calculate_positions(40, 10, 0)[0];
941 let cell = frame.buffer.get(x, y).expect("cell should exist");
942 assert!(!cell.is_empty(), "stack should render toast content");
943 }
944}