#![forbid(unsafe_code)]
use ahash::AHashMap;
use std::collections::VecDeque;
use std::hash::{Hash, Hasher};
use web_time::{Duration, Instant};
use ftui_core::geometry::Rect;
use ftui_render::frame::Frame;
use crate::Widget;
use crate::toast::{Toast, ToastId, ToastPosition};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum NotificationPriority {
Low = 0,
#[default]
Normal = 1,
High = 2,
Urgent = 3,
}
#[derive(Debug, Clone)]
pub struct QueueConfig {
pub max_visible: usize,
pub max_queued: usize,
pub default_duration: Duration,
pub position: ToastPosition,
pub stagger_offset: u16,
pub dedup_window_ms: u64,
}
impl Default for QueueConfig {
fn default() -> Self {
Self {
max_visible: 3,
max_queued: 10,
default_duration: Duration::from_secs(5),
position: ToastPosition::TopRight,
stagger_offset: 1,
dedup_window_ms: 1000,
}
}
}
impl QueueConfig {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn max_visible(mut self, max: usize) -> Self {
self.max_visible = max;
self
}
#[must_use]
pub fn max_queued(mut self, max: usize) -> Self {
self.max_queued = max;
self
}
#[must_use]
pub fn default_duration(mut self, duration: Duration) -> Self {
self.default_duration = duration;
self
}
#[must_use]
pub fn position(mut self, position: ToastPosition) -> Self {
self.position = position;
self
}
#[must_use]
pub fn stagger_offset(mut self, offset: u16) -> Self {
self.stagger_offset = offset;
self
}
#[must_use]
pub fn dedup_window_ms(mut self, ms: u64) -> Self {
self.dedup_window_ms = ms;
self
}
}
#[derive(Debug)]
struct QueuedNotification {
toast: Toast,
priority: NotificationPriority,
#[allow(dead_code)]
created_at: Instant,
content_hash: u64,
}
impl QueuedNotification {
fn new(toast: Toast, priority: NotificationPriority) -> Self {
let content_hash = Self::compute_hash(&toast);
Self {
toast,
priority,
created_at: Instant::now(),
content_hash,
}
}
fn compute_hash(toast: &Toast) -> u64 {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
toast.content.message.hash(&mut hasher);
if let Some(ref title) = toast.content.title {
title.hash(&mut hasher);
}
hasher.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueueAction {
Show(ToastId),
Hide(ToastId),
Reposition(ToastId),
}
#[derive(Debug, Clone, Default)]
pub struct QueueStats {
pub total_pushed: u64,
pub overflow_count: u64,
pub dedup_count: u64,
pub user_dismissed: u64,
pub auto_expired: u64,
}
#[derive(Debug)]
pub struct NotificationQueue {
queue: VecDeque<QueuedNotification>,
visible: Vec<Toast>,
config: QueueConfig,
dedup_window: Duration,
recent_hashes: AHashMap<u64, Instant>,
stats: QueueStats,
}
pub struct NotificationStack<'a> {
queue: &'a NotificationQueue,
margin: u16,
}
impl<'a> NotificationStack<'a> {
pub fn new(queue: &'a NotificationQueue) -> Self {
Self { queue, margin: 1 }
}
#[must_use]
pub fn margin(mut self, margin: u16) -> Self {
self.margin = margin;
self
}
}
impl Widget for NotificationStack<'_> {
fn render(&self, area: Rect, frame: &mut Frame) {
if area.is_empty() || self.queue.visible().is_empty() {
return;
}
let positions = self
.queue
.calculate_positions(area.width, area.height, self.margin);
for (toast, (_, rel_x, rel_y)) in self.queue.visible().iter().zip(positions.iter()) {
let (toast_width, toast_height) = toast.calculate_dimensions();
let x = area.x.saturating_add(*rel_x);
let y = area.y.saturating_add(*rel_y);
let toast_area = Rect::new(x, y, toast_width, toast_height);
let render_area = toast_area.intersection(&area);
if !render_area.is_empty() {
toast.render(render_area, frame);
}
}
}
}
impl NotificationQueue {
pub fn new(config: QueueConfig) -> Self {
let dedup_window = Duration::from_millis(config.dedup_window_ms);
Self {
queue: VecDeque::new(),
visible: Vec::new(),
config,
dedup_window,
recent_hashes: AHashMap::new(),
stats: QueueStats::default(),
}
}
pub fn with_defaults() -> Self {
Self::new(QueueConfig::default())
}
pub fn push(&mut self, toast: Toast, priority: NotificationPriority) -> bool {
self.stats.total_pushed += 1;
let queued = QueuedNotification::new(self.apply_default_duration(toast), priority);
if !self.dedup_check(queued.content_hash) {
self.stats.dedup_count += 1;
return false;
}
if self.queue.len() >= self.config.max_queued {
self.stats.overflow_count += 1;
if let Some(idx) = self.find_lowest_priority_index() {
if self.queue[idx].priority < priority {
self.queue.remove(idx);
} else {
return false; }
} else {
return false;
}
}
if priority == NotificationPriority::Urgent {
self.queue.push_front(queued);
} else {
let insert_idx = self
.queue
.iter()
.position(|q| q.priority < priority)
.unwrap_or(self.queue.len());
self.queue.insert(insert_idx, queued);
}
true
}
pub fn notify(&mut self, toast: Toast) -> bool {
self.push(toast, NotificationPriority::Normal)
}
pub fn urgent(&mut self, toast: Toast) -> bool {
self.push(toast, NotificationPriority::Urgent)
}
pub fn dismiss(&mut self, id: ToastId) {
if let Some(idx) = self.visible.iter().position(|t| t.id == id)
&& !self.visible[idx].state.dismissed
{
self.visible[idx].dismiss();
self.stats.user_dismissed += 1;
}
if let Some(idx) = self.queue.iter().position(|q| q.toast.id == id) {
self.queue.remove(idx);
self.stats.user_dismissed += 1;
}
}
pub fn dismiss_all(&mut self) {
let mut dismissed_visible = 0u64;
for toast in &mut self.visible {
if !toast.state.dismissed {
toast.dismiss();
dismissed_visible += 1;
}
}
self.stats.user_dismissed += dismissed_visible + self.queue.len() as u64;
self.queue.clear();
}
pub fn tick(&mut self, _delta: Duration) -> Vec<QueueAction> {
let mut actions = Vec::new();
let now = Instant::now();
self.recent_hashes
.retain(|_, t| now.saturating_duration_since(*t) < self.dedup_window);
let mut i = 0;
while i < self.visible.len() {
let toast = &mut self.visible[i];
if !toast.state.dismissed && toast.is_expired() {
toast.dismiss();
self.stats.auto_expired += 1;
}
toast.tick_animation();
if !self.visible[i].is_visible() {
let id = self.visible[i].id;
self.visible.remove(i);
actions.push(QueueAction::Hide(id));
} else {
i += 1;
}
}
while self.visible.len() < self.config.max_visible {
if let Some(queued) = self.queue.pop_front() {
let id = queued.toast.id;
self.visible.push(queued.toast);
actions.push(QueueAction::Show(id));
} else {
break;
}
}
actions
}
pub fn visible(&self) -> &[Toast] {
&self.visible
}
pub fn visible_mut(&mut self) -> &mut [Toast] {
&mut self.visible
}
pub fn pending_count(&self) -> usize {
self.queue.len()
}
pub fn visible_count(&self) -> usize {
self.visible.len()
}
pub fn total_count(&self) -> usize {
self.visible.len() + self.queue.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.visible.is_empty() && self.queue.is_empty()
}
pub fn stats(&self) -> &QueueStats {
&self.stats
}
pub fn config(&self) -> &QueueConfig {
&self.config
}
pub fn calculate_positions(
&self,
terminal_width: u16,
terminal_height: u16,
margin: u16,
) -> Vec<(ToastId, u16, u16)> {
let mut positions = Vec::with_capacity(self.visible.len());
let is_top = matches!(
self.config.position,
ToastPosition::TopLeft | ToastPosition::TopCenter | ToastPosition::TopRight
);
let mut y_offset: u16 = 0;
for toast in &self.visible {
let (toast_width, toast_height) = toast.calculate_dimensions();
let (base_x, base_y) = self.config.position.calculate_position(
terminal_width,
terminal_height,
toast_width,
toast_height,
margin,
);
let y = if is_top {
base_y.saturating_add(y_offset)
} else {
base_y.saturating_sub(y_offset)
};
positions.push((toast.id, base_x, y));
y_offset = y_offset
.saturating_add(toast_height)
.saturating_add(self.config.stagger_offset);
}
positions
}
fn dedup_check(&mut self, hash: u64) -> bool {
let now = Instant::now();
self.recent_hashes
.retain(|_, t| now.saturating_duration_since(*t) < self.dedup_window);
if self.recent_hashes.contains_key(&hash) {
return false;
}
self.recent_hashes.insert(hash, now);
true
}
fn find_lowest_priority_index(&self) -> Option<usize> {
self.queue
.iter()
.enumerate()
.min_by_key(|(_, q)| q.priority)
.map(|(i, _)| i)
}
fn apply_default_duration(&self, mut toast: Toast) -> Toast {
if !toast.config.duration_explicit {
toast.config.duration = Some(self.config.default_duration);
toast.config.duration_explicit = true;
}
toast
}
}
impl Default for NotificationQueue {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
use web_time::Duration;
fn make_toast(msg: &str) -> Toast {
Toast::with_id(ToastId::new(0), msg)
.persistent()
.no_animation() }
fn make_ephemeral_toast(msg: &str) -> Toast {
Toast::new(msg).no_animation()
}
#[test]
fn test_queue_new() {
let queue = NotificationQueue::with_defaults();
assert!(queue.is_empty());
assert_eq!(queue.visible_count(), 0);
assert_eq!(queue.pending_count(), 0);
}
#[test]
fn test_queue_push_and_tick() {
let mut queue = NotificationQueue::with_defaults();
queue.push(make_toast("Hello"), NotificationPriority::Normal);
assert_eq!(queue.pending_count(), 1);
assert_eq!(queue.visible_count(), 0);
let actions = queue.tick(Duration::from_millis(16));
assert_eq!(queue.pending_count(), 0);
assert_eq!(queue.visible_count(), 1);
assert_eq!(actions.len(), 1);
assert!(matches!(actions[0], QueueAction::Show(_)));
}
#[test]
fn test_queue_fifo() {
let config = QueueConfig::default().max_visible(1);
let mut queue = NotificationQueue::new(config);
queue.push(make_toast("First"), NotificationPriority::Normal);
queue.push(make_toast("Second"), NotificationPriority::Normal);
queue.push(make_toast("Third"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
assert_eq!(queue.visible()[0].content.message, "First");
queue.visible_mut()[0].dismiss();
queue.tick(Duration::from_millis(16));
assert_eq!(queue.visible()[0].content.message, "Second");
}
#[test]
fn test_queue_max_visible() {
let config = QueueConfig::default().max_visible(2);
let mut queue = NotificationQueue::new(config);
queue.push(make_toast("A"), NotificationPriority::Normal);
queue.push(make_toast("B"), NotificationPriority::Normal);
queue.push(make_toast("C"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
assert_eq!(queue.visible_count(), 2);
assert_eq!(queue.pending_count(), 1);
}
#[test]
fn test_queue_priority_urgent() {
let config = QueueConfig::default().max_visible(1);
let mut queue = NotificationQueue::new(config);
queue.push(make_toast("Normal1"), NotificationPriority::Normal);
queue.push(make_toast("Normal2"), NotificationPriority::Normal);
queue.push(make_toast("Urgent"), NotificationPriority::Urgent);
queue.tick(Duration::from_millis(16));
assert_eq!(queue.visible()[0].content.message, "Urgent");
}
#[test]
fn test_queue_priority_ordering() {
let config = QueueConfig::default().max_visible(0); let mut queue = NotificationQueue::new(config);
queue.push(make_toast("Low"), NotificationPriority::Low);
queue.push(make_toast("Normal"), NotificationPriority::Normal);
queue.push(make_toast("High"), NotificationPriority::High);
let messages: Vec<_> = queue
.queue
.iter()
.map(|q| q.toast.content.message.as_str())
.collect();
assert_eq!(messages, vec!["High", "Normal", "Low"]);
}
#[test]
fn test_queue_dedup() {
let config = QueueConfig::default().dedup_window_ms(1000);
let mut queue = NotificationQueue::new(config);
assert!(queue.push(make_toast("Same message"), NotificationPriority::Normal));
assert!(!queue.push(make_toast("Same message"), NotificationPriority::Normal));
assert_eq!(queue.stats().dedup_count, 1);
}
#[test]
fn test_queue_overflow() {
let config = QueueConfig::default().max_queued(2);
let mut queue = NotificationQueue::new(config);
assert!(queue.push(make_toast("A"), NotificationPriority::Normal));
assert!(queue.push(make_toast("B"), NotificationPriority::Normal));
assert!(!queue.push(make_toast("C"), NotificationPriority::Normal));
assert_eq!(queue.stats().overflow_count, 1);
}
#[test]
fn test_queue_overflow_drops_lower_priority() {
let config = QueueConfig::default().max_queued(2);
let mut queue = NotificationQueue::new(config);
assert!(queue.push(make_toast("Low1"), NotificationPriority::Low));
assert!(queue.push(make_toast("Low2"), NotificationPriority::Low));
assert!(queue.push(make_toast("High"), NotificationPriority::High));
assert_eq!(queue.pending_count(), 2);
let messages: Vec<_> = queue
.queue
.iter()
.map(|q| q.toast.content.message.as_str())
.collect();
assert!(messages.contains(&"High"));
}
#[test]
fn test_queue_dismiss() {
let mut queue = NotificationQueue::with_defaults();
queue.push(make_toast("Test"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
let id = queue.visible()[0].id;
queue.dismiss(id);
queue.tick(Duration::from_millis(16));
assert_eq!(queue.visible_count(), 0);
assert_eq!(queue.stats().user_dismissed, 1);
}
#[test]
fn test_queue_dismiss_all() {
let mut queue = NotificationQueue::with_defaults();
queue.push(make_toast("A"), NotificationPriority::Normal);
queue.push(make_toast("B"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
queue.dismiss_all();
queue.tick(Duration::from_millis(16));
assert!(queue.is_empty());
assert_eq!(queue.stats().user_dismissed, 2);
}
#[test]
fn test_queue_calculate_positions_top() {
let config = QueueConfig::default().position(ToastPosition::TopRight);
let mut queue = NotificationQueue::new(config);
queue.push(make_toast("A"), NotificationPriority::Normal);
queue.push(make_toast("B"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
let positions = queue.calculate_positions(80, 24, 1);
assert_eq!(positions.len(), 2);
assert!(positions[0].2 < positions[1].2);
}
#[test]
fn test_queue_calculate_positions_bottom() {
let config = QueueConfig::default().position(ToastPosition::BottomRight);
let mut queue = NotificationQueue::new(config);
queue.push(make_toast("A"), NotificationPriority::Normal);
queue.push(make_toast("B"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
let positions = queue.calculate_positions(80, 24, 1);
assert_eq!(positions.len(), 2);
assert!(positions[0].2 > positions[1].2);
}
#[test]
fn test_queue_notify_helper() {
let mut queue = NotificationQueue::with_defaults();
assert!(queue.notify(make_toast("Normal")));
queue.tick(Duration::from_millis(16));
assert_eq!(queue.visible_count(), 1);
}
#[test]
fn test_queue_urgent_helper() {
let config = QueueConfig::default().max_visible(1);
let mut queue = NotificationQueue::new(config);
queue.notify(make_toast("Normal"));
queue.urgent(make_toast("Urgent"));
queue.tick(Duration::from_millis(16));
assert_eq!(queue.visible()[0].content.message, "Urgent");
}
#[test]
fn test_queue_stats() {
let mut queue = NotificationQueue::with_defaults();
queue.push(make_toast("A"), NotificationPriority::Normal);
queue.push(make_toast("A"), NotificationPriority::Normal); queue.tick(Duration::from_millis(16));
assert_eq!(queue.stats().total_pushed, 2);
assert_eq!(queue.stats().dedup_count, 1);
}
#[test]
fn test_queue_config_builder() {
let config = QueueConfig::new()
.max_visible(5)
.max_queued(20)
.default_duration(Duration::from_secs(10))
.position(ToastPosition::BottomLeft)
.stagger_offset(2)
.dedup_window_ms(500);
assert_eq!(config.max_visible, 5);
assert_eq!(config.max_queued, 20);
assert_eq!(config.default_duration, Duration::from_secs(10));
assert_eq!(config.position, ToastPosition::BottomLeft);
assert_eq!(config.stagger_offset, 2);
assert_eq!(config.dedup_window_ms, 500);
}
#[test]
fn test_queue_total_count() {
let config = QueueConfig::default().max_visible(1);
let mut queue = NotificationQueue::new(config);
queue.push(make_toast("A"), NotificationPriority::Normal);
queue.push(make_toast("B"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
assert_eq!(queue.total_count(), 2);
assert_eq!(queue.visible_count(), 1);
assert_eq!(queue.pending_count(), 1);
}
#[test]
fn queue_config_default_values() {
let config = QueueConfig::default();
assert_eq!(config.max_visible, 3);
assert_eq!(config.max_queued, 10);
assert_eq!(config.default_duration, Duration::from_secs(5));
assert_eq!(config.position, ToastPosition::TopRight);
assert_eq!(config.stagger_offset, 1);
assert_eq!(config.dedup_window_ms, 1000);
}
#[test]
fn notification_priority_default_is_normal() {
assert_eq!(
NotificationPriority::default(),
NotificationPriority::Normal
);
}
#[test]
fn notification_priority_ordering() {
assert!(NotificationPriority::Low < NotificationPriority::Normal);
assert!(NotificationPriority::Normal < NotificationPriority::High);
assert!(NotificationPriority::High < NotificationPriority::Urgent);
}
#[test]
fn queue_default_trait_delegates_to_with_defaults() {
let queue = NotificationQueue::default();
assert!(queue.is_empty());
assert_eq!(queue.config().max_visible, 3);
}
#[test]
fn is_empty_false_when_pending() {
let mut queue = NotificationQueue::with_defaults();
queue.push(make_toast("X"), NotificationPriority::Normal);
assert!(!queue.is_empty());
}
#[test]
fn is_empty_false_when_visible() {
let mut queue = NotificationQueue::with_defaults();
queue.push(make_toast("X"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
assert!(!queue.is_empty());
}
#[test]
fn visible_mut_allows_modification() {
let mut queue = NotificationQueue::with_defaults();
queue.push(make_toast("Original"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
queue.visible_mut()[0].dismiss();
queue.tick(Duration::from_millis(16));
assert_eq!(queue.visible_count(), 0);
}
#[test]
fn config_accessor_returns_config() {
let config = QueueConfig::default().max_visible(7).stagger_offset(3);
let queue = NotificationQueue::new(config);
assert_eq!(queue.config().max_visible, 7);
assert_eq!(queue.config().stagger_offset, 3);
}
#[test]
fn dismiss_all_clears_queue_and_visible() {
let config = QueueConfig::default().max_visible(1);
let mut queue = NotificationQueue::new(config);
queue.push(make_toast("A"), NotificationPriority::Normal);
queue.push(make_toast("B"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
assert_eq!(queue.visible_count(), 1);
assert_eq!(queue.pending_count(), 1);
queue.dismiss_all();
assert_eq!(queue.stats().user_dismissed, 2);
assert_eq!(queue.pending_count(), 0);
queue.tick(Duration::from_millis(16));
assert!(queue.is_empty());
}
#[test]
fn dismiss_does_not_double_count_already_dismissed_visible_toast() {
let mut queue = NotificationQueue::with_defaults();
queue.push(make_toast("A"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
let id = queue.visible()[0].id;
queue.dismiss(id);
queue.dismiss(id);
assert_eq!(queue.stats().user_dismissed, 1);
}
#[test]
fn queue_applies_config_default_duration_to_default_toasts() {
let config = QueueConfig::default().default_duration(Duration::from_secs(12));
let mut queue = NotificationQueue::new(config);
queue.push(make_ephemeral_toast("A"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
assert_eq!(
queue.visible()[0].config.duration,
Some(Duration::from_secs(12))
);
}
#[test]
fn queue_preserves_persistent_toasts_when_applying_default_duration() {
let config = QueueConfig::default().default_duration(Duration::from_secs(12));
let mut queue = NotificationQueue::new(config);
queue.push(make_toast("A"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
assert_eq!(queue.visible()[0].config.duration, None);
}
#[test]
fn queue_preserves_explicit_custom_duration() {
let config = QueueConfig::default().default_duration(Duration::from_secs(12));
let mut queue = NotificationQueue::new(config);
queue.push(
Toast::new("A")
.duration(Duration::from_secs(2))
.no_animation(),
NotificationPriority::Normal,
);
queue.tick(Duration::from_millis(16));
assert_eq!(
queue.visible()[0].config.duration,
Some(Duration::from_secs(2))
);
}
#[test]
fn queue_preserves_explicit_duration_even_when_equal_to_toast_default() {
let config = QueueConfig::default().default_duration(Duration::from_secs(12));
let mut queue = NotificationQueue::new(config);
queue.push(
Toast::new("A")
.duration(Duration::from_secs(5))
.no_animation(),
NotificationPriority::Normal,
);
queue.tick(Duration::from_millis(16));
assert_eq!(
queue.visible()[0].config.duration,
Some(Duration::from_secs(5))
);
}
#[test]
fn queue_action_equality() {
let id = ToastId::new(42);
assert_eq!(QueueAction::Show(id), QueueAction::Show(id));
assert_eq!(QueueAction::Hide(id), QueueAction::Hide(id));
assert_eq!(QueueAction::Reposition(id), QueueAction::Reposition(id));
assert_ne!(QueueAction::Show(id), QueueAction::Hide(id));
}
#[test]
fn queue_stats_default_all_zero() {
let stats = QueueStats::default();
assert_eq!(stats.total_pushed, 0);
assert_eq!(stats.overflow_count, 0);
assert_eq!(stats.dedup_count, 0);
assert_eq!(stats.user_dismissed, 0);
assert_eq!(stats.auto_expired, 0);
}
#[test]
fn calculate_positions_empty_returns_empty() {
let queue = NotificationQueue::with_defaults();
let positions = queue.calculate_positions(80, 24, 1);
assert!(positions.is_empty());
}
#[test]
fn notification_stack_empty_area_renders_nothing() {
let mut queue = NotificationQueue::with_defaults();
queue.push(make_toast("Hello"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 10, &mut pool);
let empty_area = Rect::new(0, 0, 0, 0);
NotificationStack::new(&queue).render(empty_area, &mut frame);
}
#[test]
fn notification_stack_margin_builder() {
let queue = NotificationQueue::with_defaults();
let stack = NotificationStack::new(&queue).margin(5);
assert_eq!(stack.margin, 5);
}
#[test]
fn notification_stack_renders_visible_toast() {
let mut queue = NotificationQueue::with_defaults();
queue.push(make_toast("Hello"), NotificationPriority::Normal);
queue.tick(Duration::from_millis(16));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 10, &mut pool);
let area = Rect::new(0, 0, 40, 10);
NotificationStack::new(&queue)
.margin(0)
.render(area, &mut frame);
let (_, x, y) = queue.calculate_positions(40, 10, 0)[0];
let cell = frame.buffer.get(x, y).expect("cell should exist");
assert!(!cell.is_empty(), "stack should render toast content");
}
}