#![forbid(unsafe_code)]
use web_time::{Duration, Instant};
use crate::{Widget, clear_text_area};
use ftui_core::geometry::Rect;
use ftui_render::cell::Cell;
use ftui_render::frame::Frame;
use ftui_style::Style;
use ftui_text::display_width;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ToastId(pub u64);
impl ToastId {
pub fn new(id: u64) -> Self {
Self(id)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToastPosition {
TopLeft,
TopCenter,
#[default]
TopRight,
BottomLeft,
BottomCenter,
BottomRight,
}
impl ToastPosition {
pub fn calculate_position(
self,
terminal_width: u16,
terminal_height: u16,
toast_width: u16,
toast_height: u16,
margin: u16,
) -> (u16, u16) {
let x = match self {
Self::TopLeft | Self::BottomLeft => margin,
Self::TopCenter | Self::BottomCenter => terminal_width.saturating_sub(toast_width) / 2,
Self::TopRight | Self::BottomRight => terminal_width
.saturating_sub(toast_width)
.saturating_sub(margin),
};
let y = match self {
Self::TopLeft | Self::TopCenter | Self::TopRight => margin,
Self::BottomLeft | Self::BottomCenter | Self::BottomRight => terminal_height
.saturating_sub(toast_height)
.saturating_sub(margin),
};
(x, y)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToastIcon {
Success,
Error,
Warning,
#[default]
Info,
Custom(char),
}
impl ToastIcon {
pub fn as_char(self) -> char {
match self {
Self::Success => '\u{2713}', Self::Error => '\u{2717}', Self::Warning => '!',
Self::Info => 'i',
Self::Custom(c) => c,
}
}
pub fn as_ascii(self) -> char {
match self {
Self::Success => '+',
Self::Error => 'x',
Self::Warning => '!',
Self::Info => 'i',
Self::Custom(c) if c.is_ascii() => c,
Self::Custom(_) => '*',
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToastStyle {
Success,
Error,
Warning,
#[default]
Info,
Neutral,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToastAnimationPhase {
Entering,
#[default]
Visible,
Exiting,
Hidden,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToastEntranceAnimation {
SlideFromTop,
#[default]
SlideFromRight,
SlideFromBottom,
SlideFromLeft,
FadeIn,
None,
}
impl ToastEntranceAnimation {
fn offset_from_dimension(value: u16) -> i16 {
i16::try_from(value).unwrap_or(i16::MAX)
}
pub fn initial_offset(self, toast_width: u16, toast_height: u16) -> (i16, i16) {
let width_offset = Self::offset_from_dimension(toast_width);
let height_offset = Self::offset_from_dimension(toast_height);
match self {
Self::SlideFromTop => (0, -height_offset),
Self::SlideFromRight => (width_offset, 0),
Self::SlideFromBottom => (0, height_offset),
Self::SlideFromLeft => (-width_offset, 0),
Self::FadeIn | Self::None => (0, 0),
}
}
pub fn offset_at_progress(
self,
progress: f64,
toast_width: u16,
toast_height: u16,
) -> (i16, i16) {
let (dx, dy) = self.initial_offset(toast_width, toast_height);
let inv_progress = 1.0 - progress.clamp(0.0, 1.0);
(
(dx as f64 * inv_progress).round() as i16,
(dy as f64 * inv_progress).round() as i16,
)
}
pub fn affects_position(self) -> bool {
!matches!(self, Self::FadeIn | Self::None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToastExitAnimation {
#[default]
FadeOut,
SlideOut,
SlideToTop,
SlideToRight,
SlideToBottom,
SlideToLeft,
None,
}
impl ToastExitAnimation {
pub fn final_offset(
self,
toast_width: u16,
toast_height: u16,
entrance: ToastEntranceAnimation,
) -> (i16, i16) {
let width_offset = ToastEntranceAnimation::offset_from_dimension(toast_width);
let height_offset = ToastEntranceAnimation::offset_from_dimension(toast_height);
match self {
Self::SlideOut => {
let (dx, dy) = entrance.initial_offset(toast_width, toast_height);
(-dx, -dy)
}
Self::SlideToTop => (0, -height_offset),
Self::SlideToRight => (width_offset, 0),
Self::SlideToBottom => (0, height_offset),
Self::SlideToLeft => (-width_offset, 0),
Self::FadeOut | Self::None => (0, 0),
}
}
pub fn offset_at_progress(
self,
progress: f64,
toast_width: u16,
toast_height: u16,
entrance: ToastEntranceAnimation,
) -> (i16, i16) {
let (dx, dy) = self.final_offset(toast_width, toast_height, entrance);
let p = progress.clamp(0.0, 1.0);
(
(dx as f64 * p).round() as i16,
(dy as f64 * p).round() as i16,
)
}
pub fn affects_position(self) -> bool {
!matches!(self, Self::FadeOut | Self::None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum ToastEasing {
Linear,
#[default]
EaseOut,
EaseIn,
EaseInOut,
Bounce,
}
impl ToastEasing {
pub fn apply(self, t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
match self {
Self::Linear => t,
Self::EaseOut => {
let inv = 1.0 - t;
1.0 - inv * inv * inv
}
Self::EaseIn => t * t * t,
Self::EaseInOut => {
if t < 0.5 {
4.0 * t * t * t
} else {
let inv = -2.0 * t + 2.0;
1.0 - inv * inv * inv / 2.0
}
}
Self::Bounce => {
let n1 = 7.5625;
let d1 = 2.75;
let mut t = t;
if t < 1.0 / d1 {
n1 * t * t
} else if t < 2.0 / d1 {
t -= 1.5 / d1;
n1 * t * t + 0.75
} else if t < 2.5 / d1 {
t -= 2.25 / d1;
n1 * t * t + 0.9375
} else {
t -= 2.625 / d1;
n1 * t * t + 0.984375
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct ToastAnimationConfig {
pub entrance: ToastEntranceAnimation,
pub exit: ToastExitAnimation,
pub entrance_duration: Duration,
pub exit_duration: Duration,
pub entrance_easing: ToastEasing,
pub exit_easing: ToastEasing,
pub respect_reduced_motion: bool,
}
impl Default for ToastAnimationConfig {
fn default() -> Self {
Self {
entrance: ToastEntranceAnimation::default(),
exit: ToastExitAnimation::default(),
entrance_duration: Duration::from_millis(200),
exit_duration: Duration::from_millis(150),
entrance_easing: ToastEasing::EaseOut,
exit_easing: ToastEasing::EaseIn,
respect_reduced_motion: true,
}
}
}
impl ToastAnimationConfig {
pub fn none() -> Self {
Self {
entrance: ToastEntranceAnimation::None,
exit: ToastExitAnimation::None,
entrance_duration: Duration::ZERO,
exit_duration: Duration::ZERO,
..Default::default()
}
}
pub fn is_disabled(&self) -> bool {
matches!(self.entrance, ToastEntranceAnimation::None)
&& matches!(self.exit, ToastExitAnimation::None)
}
}
#[derive(Debug, Clone)]
pub struct ToastAnimationState {
pub phase: ToastAnimationPhase,
pub phase_started: Instant,
pub reduced_motion: bool,
}
impl Default for ToastAnimationState {
fn default() -> Self {
Self {
phase: ToastAnimationPhase::Entering,
phase_started: Instant::now(),
reduced_motion: false,
}
}
}
impl ToastAnimationState {
pub fn new() -> Self {
Self::default()
}
pub fn with_reduced_motion() -> Self {
Self {
phase: ToastAnimationPhase::Visible,
phase_started: Instant::now(),
reduced_motion: true,
}
}
pub fn progress(&self, phase_duration: Duration) -> f64 {
if phase_duration.is_zero() {
return 1.0;
}
let elapsed = self.phase_started.elapsed();
(elapsed.as_secs_f64() / phase_duration.as_secs_f64()).min(1.0)
}
pub fn transition_to(&mut self, phase: ToastAnimationPhase) {
self.phase = phase;
self.phase_started = Instant::now();
}
pub fn start_exit(&mut self) {
if self.reduced_motion {
self.transition_to(ToastAnimationPhase::Hidden);
} else {
self.transition_to(ToastAnimationPhase::Exiting);
}
}
pub fn is_complete(&self) -> bool {
self.phase == ToastAnimationPhase::Hidden
}
pub fn tick(&mut self, config: &ToastAnimationConfig) -> bool {
let prev_phase = self.phase;
match self.phase {
ToastAnimationPhase::Entering => {
let duration = if self.reduced_motion {
Duration::ZERO
} else {
config.entrance_duration
};
if self.progress(duration) >= 1.0 {
self.transition_to(ToastAnimationPhase::Visible);
}
}
ToastAnimationPhase::Exiting => {
let duration = if self.reduced_motion {
Duration::ZERO
} else {
config.exit_duration
};
if self.progress(duration) >= 1.0 {
self.transition_to(ToastAnimationPhase::Hidden);
}
}
ToastAnimationPhase::Visible | ToastAnimationPhase::Hidden => {}
}
self.phase != prev_phase
}
pub fn current_offset(
&self,
config: &ToastAnimationConfig,
toast_width: u16,
toast_height: u16,
) -> (i16, i16) {
if self.reduced_motion {
return (0, 0);
}
match self.phase {
ToastAnimationPhase::Entering => {
let raw_progress = self.progress(config.entrance_duration);
let eased_progress = config.entrance_easing.apply(raw_progress);
config
.entrance
.offset_at_progress(eased_progress, toast_width, toast_height)
}
ToastAnimationPhase::Exiting => {
let raw_progress = self.progress(config.exit_duration);
let eased_progress = config.exit_easing.apply(raw_progress);
config.exit.offset_at_progress(
eased_progress,
toast_width,
toast_height,
config.entrance,
)
}
ToastAnimationPhase::Visible => (0, 0),
ToastAnimationPhase::Hidden => (0, 0),
}
}
pub fn current_opacity(&self, config: &ToastAnimationConfig) -> f64 {
if self.reduced_motion {
return if self.phase == ToastAnimationPhase::Hidden {
0.0
} else {
1.0
};
}
match self.phase {
ToastAnimationPhase::Entering => {
if matches!(config.entrance, ToastEntranceAnimation::FadeIn) {
let raw_progress = self.progress(config.entrance_duration);
config.entrance_easing.apply(raw_progress)
} else {
1.0
}
}
ToastAnimationPhase::Exiting => {
if matches!(config.exit, ToastExitAnimation::FadeOut) {
let raw_progress = self.progress(config.exit_duration);
1.0 - config.exit_easing.apply(raw_progress)
} else {
1.0
}
}
ToastAnimationPhase::Visible => 1.0,
ToastAnimationPhase::Hidden => 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct ToastConfig {
pub position: ToastPosition,
pub duration: Option<Duration>,
pub duration_explicit: bool,
pub style_variant: ToastStyle,
pub max_width: u16,
pub margin: u16,
pub dismissable: bool,
pub animation: ToastAnimationConfig,
}
impl Default for ToastConfig {
fn default() -> Self {
Self {
position: ToastPosition::default(),
duration: Some(Duration::from_secs(5)),
duration_explicit: false,
style_variant: ToastStyle::default(),
max_width: 50,
margin: 1,
dismissable: true,
animation: ToastAnimationConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyEvent {
Esc,
Tab,
Enter,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToastAction {
pub label: String,
pub id: String,
}
impl ToastAction {
pub fn new(label: impl Into<String>, id: impl Into<String>) -> Self {
let label = label.into();
let id = id.into();
debug_assert!(
!label.trim().is_empty(),
"ToastAction label must not be empty"
);
debug_assert!(!id.trim().is_empty(), "ToastAction id must not be empty");
Self { label, id }
}
pub fn display_width(&self) -> usize {
display_width(self.label.as_str()) + 2 }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToastEvent {
None,
Dismissed,
Action(String),
FocusChanged,
}
#[derive(Debug, Clone)]
pub struct ToastContent {
pub message: String,
pub icon: Option<ToastIcon>,
pub title: Option<String>,
}
impl ToastContent {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
icon: None,
title: None,
}
}
#[must_use]
pub fn with_icon(mut self, icon: ToastIcon) -> Self {
self.icon = Some(icon);
self
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
}
#[derive(Debug, Clone)]
pub struct ToastState {
pub created_at: Instant,
pub dismissed: bool,
pub animation: ToastAnimationState,
pub focused_action: Option<usize>,
pub timer_paused: bool,
pub pause_started: Option<Instant>,
pub total_paused: Duration,
}
impl Default for ToastState {
fn default() -> Self {
Self {
created_at: Instant::now(),
dismissed: false,
animation: ToastAnimationState::default(),
focused_action: None,
timer_paused: false,
pause_started: None,
total_paused: Duration::ZERO,
}
}
}
impl ToastState {
pub fn with_reduced_motion() -> Self {
Self {
created_at: Instant::now(),
dismissed: false,
animation: ToastAnimationState::with_reduced_motion(),
focused_action: None,
timer_paused: false,
pause_started: None,
total_paused: Duration::ZERO,
}
}
}
#[derive(Debug, Clone)]
pub struct Toast {
pub id: ToastId,
pub content: ToastContent,
pub config: ToastConfig,
pub state: ToastState,
pub actions: Vec<ToastAction>,
style: Style,
icon_style: Style,
title_style: Style,
action_style: Style,
action_focus_style: Style,
}
impl Toast {
pub fn new(message: impl Into<String>) -> Self {
static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let id = ToastId::new(NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
Self {
id,
content: ToastContent::new(message),
config: ToastConfig::default(),
state: ToastState::default(),
actions: Vec::new(),
style: Style::default(),
icon_style: Style::default(),
title_style: Style::default(),
action_style: Style::default(),
action_focus_style: Style::default(),
}
}
pub fn with_id(id: ToastId, message: impl Into<String>) -> Self {
Self {
id,
content: ToastContent::new(message),
config: ToastConfig::default(),
state: ToastState::default(),
actions: Vec::new(),
style: Style::default(),
icon_style: Style::default(),
title_style: Style::default(),
action_style: Style::default(),
action_focus_style: Style::default(),
}
}
#[must_use]
pub fn icon(mut self, icon: ToastIcon) -> Self {
self.content.icon = Some(icon);
self
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.content.title = Some(title.into());
self
}
#[must_use]
pub fn position(mut self, position: ToastPosition) -> Self {
self.config.position = position;
self
}
#[must_use]
pub fn duration(mut self, duration: Duration) -> Self {
self.config.duration = Some(duration);
self.config.duration_explicit = true;
self
}
#[must_use]
pub fn persistent(mut self) -> Self {
self.config.duration = None;
self.config.duration_explicit = true;
self
}
#[must_use]
pub fn style_variant(mut self, variant: ToastStyle) -> Self {
self.config.style_variant = variant;
self
}
#[must_use]
pub fn max_width(mut self, width: u16) -> Self {
self.config.max_width = width;
self
}
#[must_use]
pub fn margin(mut self, margin: u16) -> Self {
self.config.margin = margin;
self
}
#[must_use]
pub fn dismissable(mut self, dismissable: bool) -> Self {
self.config.dismissable = dismissable;
self
}
#[must_use]
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn with_icon_style(mut self, style: Style) -> Self {
self.icon_style = style;
self
}
#[must_use]
pub fn with_title_style(mut self, style: Style) -> Self {
self.title_style = style;
self
}
#[must_use]
pub fn entrance_animation(mut self, animation: ToastEntranceAnimation) -> Self {
self.config.animation.entrance = animation;
self
}
#[must_use]
pub fn exit_animation(mut self, animation: ToastExitAnimation) -> Self {
self.config.animation.exit = animation;
self
}
#[must_use]
pub fn entrance_duration(mut self, duration: Duration) -> Self {
self.config.animation.entrance_duration = duration;
self
}
#[must_use]
pub fn exit_duration(mut self, duration: Duration) -> Self {
self.config.animation.exit_duration = duration;
self
}
#[must_use]
pub fn entrance_easing(mut self, easing: ToastEasing) -> Self {
self.config.animation.entrance_easing = easing;
self
}
#[must_use]
pub fn exit_easing(mut self, easing: ToastEasing) -> Self {
self.config.animation.exit_easing = easing;
self
}
#[must_use]
pub fn action(mut self, action: ToastAction) -> Self {
self.actions.push(action);
self
}
#[must_use]
pub fn actions(mut self, actions: Vec<ToastAction>) -> Self {
self.actions = actions;
self
}
#[must_use]
pub fn with_action_style(mut self, style: Style) -> Self {
self.action_style = style;
self
}
#[must_use]
pub fn with_action_focus_style(mut self, style: Style) -> Self {
self.action_focus_style = style;
self
}
#[must_use]
pub fn no_animation(mut self) -> Self {
self.config.animation = ToastAnimationConfig::none();
self.state.animation = ToastAnimationState {
phase: ToastAnimationPhase::Visible,
phase_started: Instant::now(),
reduced_motion: true,
};
self
}
#[must_use]
pub fn reduced_motion(mut self, enabled: bool) -> Self {
self.config.animation.respect_reduced_motion = enabled;
if enabled {
self.state.animation = ToastAnimationState::with_reduced_motion();
}
self
}
pub fn is_expired(&self) -> bool {
if let Some(duration) = self.config.duration {
let wall_elapsed = self.state.created_at.elapsed();
let effective_elapsed = wall_elapsed.saturating_sub(self.paused_duration());
effective_elapsed >= duration
} else {
false
}
}
#[inline]
pub fn is_visible(&self) -> bool {
self.state.animation.phase != ToastAnimationPhase::Hidden
}
pub fn is_animating(&self) -> bool {
matches!(
self.state.animation.phase,
ToastAnimationPhase::Entering | ToastAnimationPhase::Exiting
)
}
pub fn dismiss(&mut self) {
if !self.state.dismissed {
self.state.dismissed = true;
self.state.animation.start_exit();
}
}
pub fn dismiss_immediately(&mut self) {
self.state.dismissed = true;
self.state
.animation
.transition_to(ToastAnimationPhase::Hidden);
}
pub fn tick_animation(&mut self) -> bool {
self.state.animation.tick(&self.config.animation)
}
pub fn animation_phase(&self) -> ToastAnimationPhase {
self.state.animation.phase
}
pub fn animation_offset(&self) -> (i16, i16) {
let (width, height) = self.calculate_dimensions();
self.state
.animation
.current_offset(&self.config.animation, width, height)
}
pub fn animation_opacity(&self) -> f64 {
self.state.animation.current_opacity(&self.config.animation)
}
#[must_use = "use the remaining time (if any) for scheduling"]
pub fn remaining_time(&self) -> Option<Duration> {
self.config.duration.map(|d| {
let wall_elapsed = self.state.created_at.elapsed();
let effective_elapsed = wall_elapsed.saturating_sub(self.paused_duration());
d.saturating_sub(effective_elapsed)
})
}
pub fn handle_key(&mut self, key: KeyEvent) -> ToastEvent {
if !self.is_visible() || self.state.dismissed {
return ToastEvent::None;
}
match key {
KeyEvent::Esc => {
if self.has_focus() {
self.clear_focus();
ToastEvent::None
} else if self.config.dismissable {
self.dismiss();
ToastEvent::Dismissed
} else {
ToastEvent::None
}
}
KeyEvent::Tab => {
if self.actions.is_empty() {
return ToastEvent::None;
}
let next = match self.state.focused_action {
None => 0,
Some(i) => (i + 1) % self.actions.len(),
};
self.state.focused_action = Some(next);
self.pause_timer();
ToastEvent::FocusChanged
}
KeyEvent::Enter => {
if let Some(idx) = self.state.focused_action
&& let Some(action) = self.actions.get(idx)
{
let id = action.id.clone();
self.dismiss();
return ToastEvent::Action(id);
}
ToastEvent::None
}
_ => ToastEvent::None,
}
}
pub fn pause_timer(&mut self) {
if !self.state.timer_paused {
self.state.timer_paused = true;
self.state.pause_started = Some(Instant::now());
}
}
pub fn resume_timer(&mut self) {
if self.state.timer_paused {
if let Some(pause_start) = self.state.pause_started.take() {
self.state.total_paused = self
.state
.total_paused
.saturating_add(pause_start.elapsed());
}
self.state.timer_paused = false;
}
}
pub fn clear_focus(&mut self) {
self.state.focused_action = None;
self.resume_timer();
}
pub fn has_focus(&self) -> bool {
self.state.focused_action.is_some()
}
#[must_use = "use the focused action (if any)"]
pub fn focused_action(&self) -> Option<&ToastAction> {
self.state
.focused_action
.and_then(|idx| self.actions.get(idx))
}
fn paused_duration(&self) -> Duration {
let mut paused = self.state.total_paused;
if self.state.timer_paused
&& let Some(pause_start) = self.state.pause_started
{
paused = paused.saturating_add(pause_start.elapsed());
}
paused
}
pub fn calculate_dimensions(&self) -> (u16, u16) {
let max_width = self.config.max_width as usize;
let icon_width = self
.content
.icon
.map(|icon| {
let mut buf = [0u8; 4];
let s = icon.as_char().encode_utf8(&mut buf);
display_width(s) + 1
})
.unwrap_or(0); let message_width = display_width(self.content.message.as_str());
let title_width = self
.content
.title
.as_ref()
.map(|t| display_width(t.as_str()))
.unwrap_or(0);
let mut content_width = (icon_width + message_width).max(title_width);
if !self.actions.is_empty() {
let actions_width: usize = self
.actions
.iter()
.map(|a| a.display_width())
.sum::<usize>()
+ self.actions.len().saturating_sub(1); content_width = content_width.max(actions_width);
}
let total_width = content_width.saturating_add(4).min(max_width);
let has_title = self.content.title.is_some();
let has_actions = !self.actions.is_empty();
let height = 3 + u16::from(has_title) + u16::from(has_actions);
(total_width as u16, height)
}
}
impl Widget for Toast {
fn render(&self, area: Rect, frame: &mut Frame) {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!(
"widget_render",
widget = "Toast",
x = area.x,
y = area.y,
w = area.width,
h = area.height
)
.entered();
if area.is_empty() {
return;
}
let (content_width, content_height) = self.calculate_dimensions();
let width = area.width.min(content_width);
let height = area.height.min(content_height);
if width < 3 || height < 3 {
return; }
let render_area = Rect::new(area.x, area.y, width, height);
if !self.is_visible() {
clear_text_area(frame, render_area, Style::default());
return;
}
let deg = frame.buffer.degradation;
if !deg.render_content() {
return;
}
let base_style = if deg.apply_styling() {
self.style
} else {
Style::default()
};
clear_text_area(frame, render_area, base_style);
let use_unicode = deg.use_unicode_borders();
let (tl, tr, bl, br, h, v) = if use_unicode {
(
'\u{250C}', '\u{2510}', '\u{2514}', '\u{2518}', '\u{2500}', '\u{2502}',
)
} else {
('+', '+', '+', '+', '-', '|')
};
let mut cell = Cell::from_char(tl);
if deg.apply_styling() {
crate::apply_style(&mut cell, self.style);
}
frame.buffer.set_fast(render_area.x, render_area.y, cell);
for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
let mut cell = Cell::from_char(h);
if deg.apply_styling() {
crate::apply_style(&mut cell, self.style);
}
frame.buffer.set_fast(x, render_area.y, cell);
}
let mut cell_tr = Cell::from_char(tr);
if deg.apply_styling() {
crate::apply_style(&mut cell_tr, self.style);
}
frame.buffer.set_fast(
render_area.right().saturating_sub(1),
render_area.y,
cell_tr,
);
let bottom_y = render_area.bottom().saturating_sub(1);
let mut cell_bl = Cell::from_char(bl);
if deg.apply_styling() {
crate::apply_style(&mut cell_bl, self.style);
}
frame.buffer.set_fast(render_area.x, bottom_y, cell_bl);
for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
let mut cell = Cell::from_char(h);
if deg.apply_styling() {
crate::apply_style(&mut cell, self.style);
}
frame.buffer.set_fast(x, bottom_y, cell);
}
let mut cell_br = Cell::from_char(br);
if deg.apply_styling() {
crate::apply_style(&mut cell_br, self.style);
}
frame
.buffer
.set_fast(render_area.right().saturating_sub(1), bottom_y, cell_br);
for y in (render_area.y + 1)..bottom_y {
let mut cell_l = Cell::from_char(v);
if deg.apply_styling() {
crate::apply_style(&mut cell_l, self.style);
}
frame.buffer.set_fast(render_area.x, y, cell_l);
let mut cell_r = Cell::from_char(v);
if deg.apply_styling() {
crate::apply_style(&mut cell_r, self.style);
}
frame
.buffer
.set_fast(render_area.right().saturating_sub(1), y, cell_r);
}
let content_x = render_area.x + 1; let content_width = width.saturating_sub(2); let mut content_y = render_area.y + 1;
if let Some(ref title) = self.content.title {
let title_style = if deg.apply_styling() {
self.title_style.merge(&self.style)
} else {
Style::default()
};
let title_style = if deg.apply_styling() {
title_style
} else {
Style::default()
};
crate::draw_text_span(
frame,
content_x,
content_y,
title,
title_style,
content_x + content_width,
);
content_y += 1;
}
let mut msg_x = content_x;
if let Some(icon) = self.content.icon {
let icon_char = if use_unicode {
icon.as_char()
} else {
icon.as_ascii()
};
let icon_style = if deg.apply_styling() {
self.icon_style.merge(&self.style)
} else {
Style::default()
};
let icon_str = icon_char.to_string();
msg_x = crate::draw_text_span(
frame,
msg_x,
content_y,
&icon_str,
icon_style,
content_x + content_width,
);
msg_x = crate::draw_text_span(
frame,
msg_x,
content_y,
" ",
Style::default(),
content_x + content_width,
);
}
let msg_style = if deg.apply_styling() {
self.style
} else {
Style::default()
};
crate::draw_text_span(
frame,
msg_x,
content_y,
&self.content.message,
msg_style,
content_x + content_width,
);
if !self.actions.is_empty() {
content_y += 1;
let mut btn_x = content_x;
for (idx, action) in self.actions.iter().enumerate() {
let is_focused = self.state.focused_action == Some(idx);
let btn_style = if is_focused && deg.apply_styling() {
self.action_focus_style.merge(&self.style)
} else if deg.apply_styling() {
self.action_style.merge(&self.style)
} else {
Style::default()
};
let max_x = content_x + content_width;
let label = format!("[{}]", action.label);
btn_x = crate::draw_text_span(frame, btn_x, content_y, &label, btn_style, max_x);
if idx + 1 < self.actions.len() {
btn_x = crate::draw_text_span(
frame,
btn_x,
content_y,
" ",
Style::default(),
max_x,
);
}
}
}
}
fn is_essential(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::budget::DegradationLevel;
use ftui_render::grapheme_pool::GraphemePool;
fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
frame
.buffer
.get(x, y)
.copied()
.expect("test cell should exist")
}
fn line_text(frame: &Frame, y: u16, width: u16) -> String {
(0..width)
.map(|x| {
frame
.buffer
.get(x, y)
.and_then(|cell| cell.content.as_char())
.unwrap_or(' ')
})
.collect()
}
fn focused_action_id(toast: &Toast) -> &str {
toast
.focused_action()
.expect("focused action should exist")
.id
.as_str()
}
fn unwrap_remaining(remaining: Option<Duration>) -> Duration {
remaining.expect("remaining duration should exist")
}
#[test]
fn test_toast_new() {
let toast = Toast::new("Hello");
assert_eq!(toast.content.message, "Hello");
assert!(toast.content.icon.is_none());
assert!(toast.content.title.is_none());
assert!(!toast.config.duration_explicit);
assert!(toast.is_visible());
}
#[test]
fn test_toast_builder() {
let toast = Toast::new("Test message")
.icon(ToastIcon::Success)
.title("Success")
.position(ToastPosition::BottomRight)
.duration(Duration::from_secs(10))
.max_width(60);
assert_eq!(toast.content.message, "Test message");
assert_eq!(toast.content.icon, Some(ToastIcon::Success));
assert_eq!(toast.content.title, Some("Success".to_string()));
assert_eq!(toast.config.position, ToastPosition::BottomRight);
assert_eq!(toast.config.duration, Some(Duration::from_secs(10)));
assert!(toast.config.duration_explicit);
assert_eq!(toast.config.max_width, 60);
}
#[test]
fn test_toast_persistent() {
let toast = Toast::new("Persistent").persistent();
assert!(toast.config.duration.is_none());
assert!(toast.config.duration_explicit);
assert!(!toast.is_expired());
}
#[test]
fn test_toast_dismiss() {
let mut toast = Toast::new("Dismissable").no_animation();
assert!(toast.is_visible());
toast.dismiss();
assert!(!toast.is_visible());
assert!(toast.state.dismissed);
}
#[test]
fn test_toast_position_calculate() {
let terminal_width = 80;
let terminal_height = 24;
let toast_width = 30;
let toast_height = 3;
let margin = 1;
let (x, y) = ToastPosition::TopLeft.calculate_position(
terminal_width,
terminal_height,
toast_width,
toast_height,
margin,
);
assert_eq!(x, 1);
assert_eq!(y, 1);
let (x, y) = ToastPosition::TopRight.calculate_position(
terminal_width,
terminal_height,
toast_width,
toast_height,
margin,
);
assert_eq!(x, 80 - 30 - 1); assert_eq!(y, 1);
let (x, y) = ToastPosition::BottomRight.calculate_position(
terminal_width,
terminal_height,
toast_width,
toast_height,
margin,
);
assert_eq!(x, 49);
assert_eq!(y, 24 - 3 - 1);
let (x, y) = ToastPosition::TopCenter.calculate_position(
terminal_width,
terminal_height,
toast_width,
toast_height,
margin,
);
assert_eq!(x, (80 - 30) / 2); assert_eq!(y, 1);
}
#[test]
fn test_toast_icon_chars() {
assert_eq!(ToastIcon::Success.as_char(), '\u{2713}');
assert_eq!(ToastIcon::Error.as_char(), '\u{2717}');
assert_eq!(ToastIcon::Warning.as_char(), '!');
assert_eq!(ToastIcon::Info.as_char(), 'i');
assert_eq!(ToastIcon::Custom('*').as_char(), '*');
assert_eq!(ToastIcon::Success.as_ascii(), '+');
assert_eq!(ToastIcon::Error.as_ascii(), 'x');
}
#[test]
fn test_toast_dimensions() {
let toast = Toast::new("Short");
let (w, h) = toast.calculate_dimensions();
assert_eq!(w, 9);
assert_eq!(h, 3);
let toast_with_title = Toast::new("Message").title("Title");
let (_w, h) = toast_with_title.calculate_dimensions();
assert_eq!(h, 4); }
#[test]
fn test_toast_dimensions_with_icon() {
let toast = Toast::new("Message").icon(ToastIcon::Success);
let (w, _h) = toast.calculate_dimensions();
let mut buf = [0u8; 4];
let icon = ToastIcon::Success.as_char().encode_utf8(&mut buf);
let expected = display_width(icon) + 1 + display_width("Message") + 4;
assert_eq!(w, expected as u16);
}
#[test]
fn test_toast_dimensions_max_width() {
let toast = Toast::new("This is a very long message that exceeds max width").max_width(20);
let (w, _h) = toast.calculate_dimensions();
assert!(w <= 20);
}
#[test]
fn test_toast_render_basic() {
let toast = Toast::new("Hello");
let area = Rect::new(0, 0, 15, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(15, 5, &mut pool);
toast.render(area, &mut frame);
assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('\u{250C}')); assert!(frame.buffer.get(1, 1).is_some()); }
#[test]
fn test_toast_render_with_icon() {
let toast = Toast::new("OK").icon(ToastIcon::Success);
let area = Rect::new(0, 0, 10, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
toast.render(area, &mut frame);
let icon_cell = cell_at(&frame, 1, 1);
let ok = if let Some(ch) = icon_cell.content.as_char() {
ch == '\u{2713}'
} else if let Some(id) = icon_cell.content.grapheme_id() {
frame.pool.get(id) == Some("\u{2713}")
} else {
false
};
assert!(ok, "expected toast icon cell to contain ✓");
}
#[test]
fn test_toast_render_with_title() {
let toast = Toast::new("Body").title("Head");
let area = Rect::new(0, 0, 15, 6);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(15, 6, &mut pool);
toast.render(area, &mut frame);
let title_cell = cell_at(&frame, 1, 1);
assert_eq!(title_cell.content.as_char(), Some('H'));
}
#[test]
fn test_toast_render_zero_area() {
let toast = Toast::new("Test");
let area = Rect::new(0, 0, 0, 0);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
toast.render(area, &mut frame); }
#[test]
fn test_toast_render_small_area() {
let toast = Toast::new("Test");
let area = Rect::new(0, 0, 2, 2);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(2, 2, &mut pool);
toast.render(area, &mut frame); }
#[test]
fn test_toast_not_visible_when_dismissed_clears_previous_render_area() {
let mut toast = Toast::new("Test").no_animation();
let area = Rect::new(0, 0, 20, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 5, &mut pool);
let (toast_width, toast_height) = toast.calculate_dimensions();
toast.render(area, &mut frame);
toast.dismiss();
toast.render(area, &mut frame);
for y in 0..toast_height.min(area.height) {
for x in 0..toast_width.min(area.width) {
assert_eq!(cell_at(&frame, x, y).content.as_char(), Some(' '));
}
}
}
#[test]
fn test_toast_is_not_essential() {
let toast = Toast::new("Test");
assert!(!toast.is_essential());
}
#[test]
fn test_toast_simple_borders_use_ascii() {
let toast = Toast::new("Hello");
let area = Rect::new(0, 0, 15, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(15, 5, &mut pool);
frame.buffer.degradation = DegradationLevel::SimpleBorders;
toast.render(area, &mut frame);
assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('+'));
assert_eq!(cell_at(&frame, 1, 0).content.as_char(), Some('-'));
assert_eq!(cell_at(&frame, 0, 1).content.as_char(), Some('|'));
}
#[test]
fn test_toast_skeleton_is_noop() {
let toast = Toast::new("Hello").style_variant(ToastStyle::Success);
let area = Rect::new(0, 0, 15, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(15, 5, &mut pool);
let mut expected_pool = GraphemePool::new();
let expected = Frame::new(15, 5, &mut expected_pool);
frame.buffer.degradation = DegradationLevel::Skeleton;
toast.render(area, &mut frame);
for y in 0..5 {
for x in 0..15 {
assert_eq!(frame.buffer.get(x, y), expected.buffer.get(x, y));
}
}
}
#[test]
fn test_toast_render_shorter_message_clears_stale_suffix() {
let area = Rect::new(0, 0, 20, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 5, &mut pool);
Toast::new("Long message text")
.max_width(18)
.no_animation()
.render(area, &mut frame);
Toast::new("Hi")
.max_width(18)
.no_animation()
.render(area, &mut frame);
assert_eq!(line_text(&frame, 1, 6), "│Hi │");
}
#[test]
fn test_toast_no_styling_shorter_title_and_message_clear_stale_text() {
let area = Rect::new(0, 0, 18, 6);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(18, 6, &mut pool);
Toast::new("Long body")
.title("LongTitle")
.max_width(16)
.no_animation()
.render(area, &mut frame);
frame.buffer.degradation = DegradationLevel::NoStyling;
Toast::new("Ok")
.title("Hi")
.max_width(16)
.no_animation()
.render(area, &mut frame);
assert_eq!(line_text(&frame, 1, 6), "|Hi |");
assert_eq!(line_text(&frame, 2, 6), "|Ok |");
}
#[test]
fn test_toast_id_uniqueness() {
let toast1 = Toast::new("A");
let toast2 = Toast::new("B");
assert_ne!(toast1.id, toast2.id);
}
#[test]
fn test_toast_style_variants() {
let success = Toast::new("OK").style_variant(ToastStyle::Success);
let error = Toast::new("Fail").style_variant(ToastStyle::Error);
let warning = Toast::new("Warn").style_variant(ToastStyle::Warning);
let info = Toast::new("Info").style_variant(ToastStyle::Info);
let neutral = Toast::new("Neutral").style_variant(ToastStyle::Neutral);
assert_eq!(success.config.style_variant, ToastStyle::Success);
assert_eq!(error.config.style_variant, ToastStyle::Error);
assert_eq!(warning.config.style_variant, ToastStyle::Warning);
assert_eq!(info.config.style_variant, ToastStyle::Info);
assert_eq!(neutral.config.style_variant, ToastStyle::Neutral);
}
#[test]
fn test_toast_content_builder() {
let content = ToastContent::new("Message")
.with_icon(ToastIcon::Warning)
.with_title("Alert");
assert_eq!(content.message, "Message");
assert_eq!(content.icon, Some(ToastIcon::Warning));
assert_eq!(content.title, Some("Alert".to_string()));
}
#[test]
fn test_animation_phase_default() {
let toast = Toast::new("Test");
assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Entering);
}
#[test]
fn test_animation_phase_reduced_motion() {
let toast = Toast::new("Test").reduced_motion(true);
assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
assert!(toast.state.animation.reduced_motion);
}
#[test]
fn test_animation_no_animation() {
let toast = Toast::new("Test").no_animation();
assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
assert!(toast.config.animation.is_disabled());
}
#[test]
fn test_entrance_animation_builder() {
let toast = Toast::new("Test")
.entrance_animation(ToastEntranceAnimation::SlideFromTop)
.entrance_duration(Duration::from_millis(300))
.entrance_easing(ToastEasing::Bounce);
assert_eq!(
toast.config.animation.entrance,
ToastEntranceAnimation::SlideFromTop
);
assert_eq!(
toast.config.animation.entrance_duration,
Duration::from_millis(300)
);
assert_eq!(toast.config.animation.entrance_easing, ToastEasing::Bounce);
}
#[test]
fn test_exit_animation_builder() {
let toast = Toast::new("Test")
.exit_animation(ToastExitAnimation::SlideOut)
.exit_duration(Duration::from_millis(100))
.exit_easing(ToastEasing::EaseInOut);
assert_eq!(toast.config.animation.exit, ToastExitAnimation::SlideOut);
assert_eq!(
toast.config.animation.exit_duration,
Duration::from_millis(100)
);
assert_eq!(toast.config.animation.exit_easing, ToastEasing::EaseInOut);
}
#[test]
fn test_entrance_animation_offsets() {
let width = 30u16;
let height = 5u16;
let (dx, dy) = ToastEntranceAnimation::SlideFromTop.initial_offset(width, height);
assert_eq!(dx, 0);
assert_eq!(dy, -(height as i16));
let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(0.0, width, height);
assert_eq!(dx, 0);
assert_eq!(dy, -(height as i16));
let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(1.0, width, height);
assert_eq!(dx, 0);
assert_eq!(dy, 0);
let (dx, dy) = ToastEntranceAnimation::SlideFromRight.initial_offset(width, height);
assert_eq!(dx, width as i16);
assert_eq!(dy, 0);
}
#[test]
fn test_exit_animation_offsets() {
let width = 30u16;
let height = 5u16;
let entrance = ToastEntranceAnimation::SlideFromRight;
let (dx, dy) = ToastExitAnimation::SlideOut.final_offset(width, height, entrance);
assert_eq!(dx, -(width as i16)); assert_eq!(dy, 0);
let (dx, dy) =
ToastExitAnimation::SlideOut.offset_at_progress(0.0, width, height, entrance);
assert_eq!(dx, 0);
assert_eq!(dy, 0);
let (dx, dy) =
ToastExitAnimation::SlideOut.offset_at_progress(1.0, width, height, entrance);
assert_eq!(dx, -(width as i16));
assert_eq!(dy, 0);
}
#[test]
fn test_easing_apply() {
assert!((ToastEasing::Linear.apply(0.5) - 0.5).abs() < 0.001);
assert!(ToastEasing::EaseOut.apply(0.5) > 0.5);
assert!(ToastEasing::EaseIn.apply(0.5) < 0.5);
for easing in [
ToastEasing::Linear,
ToastEasing::EaseIn,
ToastEasing::EaseOut,
ToastEasing::EaseInOut,
ToastEasing::Bounce,
] {
assert!((easing.apply(0.0) - 0.0).abs() < 0.001, "{:?} at 0", easing);
assert!((easing.apply(1.0) - 1.0).abs() < 0.001, "{:?} at 1", easing);
}
}
#[test]
fn test_animation_state_progress() {
let state = ToastAnimationState::new();
let progress = state.progress(Duration::from_millis(200));
assert!(
progress < 0.1,
"Progress should be small immediately after creation"
);
}
#[test]
fn test_animation_state_zero_duration() {
let state = ToastAnimationState::new();
let progress = state.progress(Duration::ZERO);
assert_eq!(progress, 1.0);
}
#[test]
fn test_dismiss_starts_exit_animation() {
let mut toast = Toast::new("Test").no_animation();
toast.state.animation.phase = ToastAnimationPhase::Visible;
toast.state.animation.reduced_motion = false;
toast.dismiss();
assert!(toast.state.dismissed);
assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Exiting);
}
#[test]
fn test_dismiss_immediately() {
let mut toast = Toast::new("Test");
toast.dismiss_immediately();
assert!(toast.state.dismissed);
assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Hidden);
assert!(!toast.is_visible());
}
#[test]
fn test_is_animating() {
let toast = Toast::new("Test");
assert!(toast.is_animating());
let toast_visible = Toast::new("Test").no_animation();
assert!(!toast_visible.is_animating()); }
#[test]
fn test_animation_opacity_fade_in() {
let config = ToastAnimationConfig {
entrance: ToastEntranceAnimation::FadeIn,
exit: ToastExitAnimation::FadeOut,
entrance_duration: Duration::from_millis(200),
exit_duration: Duration::from_millis(150),
entrance_easing: ToastEasing::Linear,
exit_easing: ToastEasing::Linear,
respect_reduced_motion: false,
};
let mut state = ToastAnimationState::new();
let opacity = state.current_opacity(&config);
assert!(opacity < 0.1, "Should be low opacity at start");
state.phase = ToastAnimationPhase::Visible;
let opacity = state.current_opacity(&config);
assert!((opacity - 1.0).abs() < 0.001);
}
#[test]
fn test_animation_config_default() {
let config = ToastAnimationConfig::default();
assert_eq!(config.entrance, ToastEntranceAnimation::SlideFromRight);
assert_eq!(config.exit, ToastExitAnimation::FadeOut);
assert_eq!(config.entrance_duration, Duration::from_millis(200));
assert_eq!(config.exit_duration, Duration::from_millis(150));
assert!(config.respect_reduced_motion);
}
#[test]
fn test_animation_affects_position() {
assert!(ToastEntranceAnimation::SlideFromTop.affects_position());
assert!(ToastEntranceAnimation::SlideFromRight.affects_position());
assert!(!ToastEntranceAnimation::FadeIn.affects_position());
assert!(!ToastEntranceAnimation::None.affects_position());
assert!(ToastExitAnimation::SlideOut.affects_position());
assert!(ToastExitAnimation::SlideToLeft.affects_position());
assert!(!ToastExitAnimation::FadeOut.affects_position());
assert!(!ToastExitAnimation::None.affects_position());
}
#[test]
fn test_toast_animation_offset() {
let toast = Toast::new("Test").entrance_animation(ToastEntranceAnimation::SlideFromRight);
let (dx, dy) = toast.animation_offset();
assert!(dx > 0, "Should have positive x offset at start");
assert_eq!(dy, 0);
}
#[test]
fn action_builder_single() {
let toast = Toast::new("msg").action(ToastAction::new("Retry", "retry"));
assert_eq!(toast.actions.len(), 1);
assert_eq!(toast.actions[0].label, "Retry");
assert_eq!(toast.actions[0].id, "retry");
}
#[test]
fn action_builder_multiple() {
let toast = Toast::new("msg")
.action(ToastAction::new("Ack", "ack"))
.action(ToastAction::new("Snooze", "snooze"));
assert_eq!(toast.actions.len(), 2);
}
#[test]
fn action_builder_vec() {
let actions = vec![
ToastAction::new("A", "a"),
ToastAction::new("B", "b"),
ToastAction::new("C", "c"),
];
let toast = Toast::new("msg").actions(actions);
assert_eq!(toast.actions.len(), 3);
}
#[test]
fn action_display_width() {
let a = ToastAction::new("OK", "ok");
assert_eq!(a.display_width(), 4);
}
#[test]
fn handle_key_esc_dismisses() {
let mut toast = Toast::new("msg").no_animation();
let result = toast.handle_key(KeyEvent::Esc);
assert_eq!(result, ToastEvent::Dismissed);
}
#[test]
fn handle_key_esc_clears_focus_first() {
let mut toast = Toast::new("msg")
.action(ToastAction::new("A", "a"))
.no_animation();
toast.handle_key(KeyEvent::Tab);
assert!(toast.has_focus());
let result = toast.handle_key(KeyEvent::Esc);
assert_eq!(result, ToastEvent::None);
assert!(!toast.has_focus());
}
#[test]
fn handle_key_tab_cycles_focus() {
let mut toast = Toast::new("msg")
.action(ToastAction::new("A", "a"))
.action(ToastAction::new("B", "b"))
.no_animation();
let r1 = toast.handle_key(KeyEvent::Tab);
assert_eq!(r1, ToastEvent::FocusChanged);
assert_eq!(toast.state.focused_action, Some(0));
let r2 = toast.handle_key(KeyEvent::Tab);
assert_eq!(r2, ToastEvent::FocusChanged);
assert_eq!(toast.state.focused_action, Some(1));
let r3 = toast.handle_key(KeyEvent::Tab);
assert_eq!(r3, ToastEvent::FocusChanged);
assert_eq!(toast.state.focused_action, Some(0));
}
#[test]
fn handle_key_tab_no_actions_is_noop() {
let mut toast = Toast::new("msg").no_animation();
let result = toast.handle_key(KeyEvent::Tab);
assert_eq!(result, ToastEvent::None);
}
#[test]
fn handle_key_enter_invokes_action() {
let mut toast = Toast::new("msg")
.action(ToastAction::new("Retry", "retry"))
.no_animation();
toast.handle_key(KeyEvent::Tab); let result = toast.handle_key(KeyEvent::Enter);
assert_eq!(result, ToastEvent::Action("retry".into()));
}
#[test]
fn handle_key_enter_no_focus_is_noop() {
let mut toast = Toast::new("msg")
.action(ToastAction::new("A", "a"))
.no_animation();
let result = toast.handle_key(KeyEvent::Enter);
assert_eq!(result, ToastEvent::None);
}
#[test]
fn handle_key_other_is_noop() {
let mut toast = Toast::new("msg").no_animation();
let result = toast.handle_key(KeyEvent::Other);
assert_eq!(result, ToastEvent::None);
}
#[test]
fn handle_key_dismissed_toast_is_noop() {
let mut toast = Toast::new("msg").no_animation();
toast.state.dismissed = true;
let result = toast.handle_key(KeyEvent::Esc);
assert_eq!(result, ToastEvent::None);
}
#[test]
fn pause_timer_sets_flag() {
let mut toast = Toast::new("msg").no_animation();
toast.pause_timer();
assert!(toast.state.timer_paused);
assert!(toast.state.pause_started.is_some());
}
#[test]
fn resume_timer_accumulates_paused() {
let mut toast = Toast::new("msg").no_animation();
toast.pause_timer();
std::thread::sleep(Duration::from_millis(10));
toast.resume_timer();
assert!(!toast.state.timer_paused);
assert!(toast.state.total_paused >= Duration::from_millis(5));
}
#[test]
fn pause_resume_idempotent() {
let mut toast = Toast::new("msg").no_animation();
toast.pause_timer();
toast.pause_timer();
assert!(toast.state.timer_paused);
toast.resume_timer();
toast.resume_timer();
assert!(!toast.state.timer_paused);
}
#[test]
fn resume_timer_saturates_paused_duration() {
let mut toast = Toast::new("msg").no_animation();
toast.state.total_paused = Duration::MAX;
toast.pause_timer();
std::thread::sleep(Duration::from_millis(1));
toast.resume_timer();
assert_eq!(toast.state.total_paused, Duration::MAX);
}
#[test]
fn active_pause_queries_saturate_paused_duration() {
let mut toast = Toast::new("msg")
.duration(Duration::from_secs(1))
.no_animation();
toast.state.total_paused = Duration::MAX;
toast.pause_timer();
std::thread::sleep(Duration::from_millis(1));
assert!(!toast.is_expired());
assert_eq!(toast.remaining_time(), Some(Duration::from_secs(1)));
}
#[test]
fn clear_focus_resumes_timer() {
let mut toast = Toast::new("msg")
.action(ToastAction::new("A", "a"))
.no_animation();
toast.handle_key(KeyEvent::Tab);
assert!(toast.state.timer_paused);
toast.clear_focus();
assert!(!toast.has_focus());
assert!(!toast.state.timer_paused);
}
#[test]
fn focused_action_returns_correct() {
let mut toast = Toast::new("msg")
.action(ToastAction::new("X", "x"))
.action(ToastAction::new("Y", "y"))
.no_animation();
assert!(toast.focused_action().is_none());
toast.handle_key(KeyEvent::Tab);
assert_eq!(focused_action_id(&toast), "x");
toast.handle_key(KeyEvent::Tab);
assert_eq!(focused_action_id(&toast), "y");
}
#[test]
fn is_expired_accounts_for_pause() {
let mut toast = Toast::new("msg")
.duration(Duration::from_millis(50))
.no_animation();
toast.pause_timer();
std::thread::sleep(Duration::from_millis(60));
assert!(
!toast.is_expired(),
"Should not expire while timer is paused"
);
toast.resume_timer();
assert!(
!toast.is_expired(),
"Should not expire immediately after resume because paused time was subtracted"
);
}
#[test]
fn dimensions_include_actions_row() {
let toast = Toast::new("Hi")
.action(ToastAction::new("OK", "ok"))
.no_animation();
let (_, h) = toast.calculate_dimensions();
assert_eq!(h, 4);
}
#[test]
fn dimensions_with_title_and_actions() {
let toast = Toast::new("Hi")
.title("Title")
.action(ToastAction::new("OK", "ok"))
.no_animation();
let (_, h) = toast.calculate_dimensions();
assert_eq!(h, 5);
}
#[test]
fn dimensions_width_accounts_for_actions() {
let toast = Toast::new("Hi")
.action(ToastAction::new("LongButtonLabel", "lb"))
.no_animation();
let (w, _) = toast.calculate_dimensions();
assert!(w >= 20);
}
#[test]
fn render_with_actions_does_not_panic() {
let toast = Toast::new("Test")
.action(ToastAction::new("OK", "ok"))
.action(ToastAction::new("Cancel", "cancel"))
.no_animation();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(60, 20, &mut pool);
let area = Rect::new(0, 0, 40, 10);
toast.render(area, &mut frame);
}
#[test]
fn render_focused_action_does_not_panic() {
let mut toast = Toast::new("Test")
.action(ToastAction::new("OK", "ok"))
.no_animation();
toast.handle_key(KeyEvent::Tab);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(60, 20, &mut pool);
let area = Rect::new(0, 0, 40, 10);
toast.render(area, &mut frame);
}
#[test]
fn render_actions_tiny_area_does_not_panic() {
let toast = Toast::new("X")
.action(ToastAction::new("A", "a"))
.no_animation();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 3, &mut pool);
let area = Rect::new(0, 0, 5, 3);
toast.render(area, &mut frame);
}
#[test]
fn toast_action_styles() {
let style = Style::new().bold();
let focus_style = Style::new().italic();
let toast = Toast::new("msg")
.action(ToastAction::new("A", "a"))
.with_action_style(style)
.with_action_focus_style(focus_style);
assert_eq!(toast.action_style, style);
assert_eq!(toast.action_focus_style, focus_style);
}
#[test]
fn persistent_toast_not_expired_with_actions() {
let toast = Toast::new("msg")
.persistent()
.action(ToastAction::new("Dismiss", "dismiss"))
.no_animation();
std::thread::sleep(Duration::from_millis(10));
assert!(!toast.is_expired());
}
#[test]
fn action_invoke_second_button() {
let mut toast = Toast::new("msg")
.action(ToastAction::new("A", "a"))
.action(ToastAction::new("B", "b"))
.no_animation();
toast.handle_key(KeyEvent::Tab); toast.handle_key(KeyEvent::Tab); let result = toast.handle_key(KeyEvent::Enter);
assert_eq!(result, ToastEvent::Action("b".into()));
}
#[test]
fn remaining_time_with_pause() {
let toast = Toast::new("msg")
.duration(Duration::from_secs(10))
.no_animation();
let remaining = toast.remaining_time();
assert!(remaining.is_some());
let r = unwrap_remaining(remaining);
assert!(r > Duration::from_secs(9));
}
#[test]
fn position_bottom_left() {
let (x, y) = ToastPosition::BottomLeft.calculate_position(80, 24, 20, 3, 1);
assert_eq!(x, 1);
assert_eq!(y, 24 - 3 - 1); }
#[test]
fn position_bottom_center() {
let (x, y) = ToastPosition::BottomCenter.calculate_position(80, 24, 20, 3, 1);
assert_eq!(x, (80 - 20) / 2); assert_eq!(y, 24 - 3 - 1); }
#[test]
fn position_toast_wider_than_terminal_saturates() {
let (x, y) = ToastPosition::TopRight.calculate_position(20, 10, 30, 3, 1);
assert_eq!(x, 0); assert_eq!(y, 1);
}
#[test]
fn position_zero_margin() {
let (x, y) = ToastPosition::TopLeft.calculate_position(80, 24, 20, 3, 0);
assert_eq!(x, 0);
assert_eq!(y, 0);
let (x, y) = ToastPosition::BottomRight.calculate_position(80, 24, 20, 3, 0);
assert_eq!(x, 60);
assert_eq!(y, 21);
}
#[test]
fn position_toast_taller_than_terminal_saturates() {
let (_, y) = ToastPosition::BottomLeft.calculate_position(80, 3, 20, 10, 1);
assert_eq!(y, 0); }
#[test]
fn icon_custom_non_ascii_falls_back_to_star() {
let icon = ToastIcon::Custom('\u{1F525}'); assert_eq!(icon.as_char(), '\u{1F525}');
assert_eq!(icon.as_ascii(), '*');
}
#[test]
fn icon_custom_ascii_preserved() {
let icon = ToastIcon::Custom('#');
assert_eq!(icon.as_char(), '#');
assert_eq!(icon.as_ascii(), '#');
}
#[test]
fn icon_warning_ascii_same() {
assert_eq!(ToastIcon::Warning.as_ascii(), '!');
assert_eq!(ToastIcon::Info.as_ascii(), 'i');
}
#[test]
fn toast_position_default_is_top_right() {
assert_eq!(ToastPosition::default(), ToastPosition::TopRight);
}
#[test]
fn toast_icon_default_is_info() {
assert_eq!(ToastIcon::default(), ToastIcon::Info);
}
#[test]
fn toast_style_default_is_info() {
assert_eq!(ToastStyle::default(), ToastStyle::Info);
}
#[test]
fn toast_animation_phase_default_is_visible() {
assert_eq!(ToastAnimationPhase::default(), ToastAnimationPhase::Visible);
}
#[test]
fn toast_entrance_animation_default_is_slide_from_right() {
assert_eq!(
ToastEntranceAnimation::default(),
ToastEntranceAnimation::SlideFromRight
);
}
#[test]
fn toast_exit_animation_default_is_fade_out() {
assert_eq!(ToastExitAnimation::default(), ToastExitAnimation::FadeOut);
}
#[test]
fn toast_easing_default_is_ease_out() {
assert_eq!(ToastEasing::default(), ToastEasing::EaseOut);
}
#[test]
fn entrance_slide_from_bottom_offset() {
let (dx, dy) = ToastEntranceAnimation::SlideFromBottom.initial_offset(20, 5);
assert_eq!(dx, 0);
assert_eq!(dy, 5); }
#[test]
fn entrance_slide_from_left_offset() {
let (dx, dy) = ToastEntranceAnimation::SlideFromLeft.initial_offset(20, 5);
assert_eq!(dx, -20);
assert_eq!(dy, 0);
}
#[test]
fn entrance_fade_in_no_offset() {
let (dx, dy) = ToastEntranceAnimation::FadeIn.initial_offset(20, 5);
assert_eq!(dx, 0);
assert_eq!(dy, 0);
}
#[test]
fn entrance_none_no_offset() {
let (dx, dy) = ToastEntranceAnimation::None.initial_offset(20, 5);
assert_eq!(dx, 0);
assert_eq!(dy, 0);
}
#[test]
fn entrance_offset_progress_clamped() {
let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(-0.5, 20, 5);
assert_eq!(dx, 0);
assert_eq!(dy, -5);
let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(2.0, 20, 5);
assert_eq!(dx, 0);
assert_eq!(dy, 0); }
#[test]
fn entrance_offset_at_half_progress() {
let (dx, dy) = ToastEntranceAnimation::SlideFromRight.offset_at_progress(0.5, 20, 5);
assert_eq!(dx, 10); assert_eq!(dy, 0);
}
#[test]
fn entrance_offsets_saturate_large_dimensions() {
assert_eq!(
ToastEntranceAnimation::SlideFromRight.initial_offset(u16::MAX, u16::MAX),
(i16::MAX, 0)
);
assert_eq!(
ToastEntranceAnimation::SlideFromLeft.initial_offset(u16::MAX, u16::MAX),
(-i16::MAX, 0)
);
assert_eq!(
ToastEntranceAnimation::SlideFromTop.initial_offset(u16::MAX, u16::MAX),
(0, -i16::MAX)
);
}
#[test]
fn exit_slide_to_top_offset() {
let entrance = ToastEntranceAnimation::SlideFromRight;
let (dx, dy) = ToastExitAnimation::SlideToTop.final_offset(20, 5, entrance);
assert_eq!(dx, 0);
assert_eq!(dy, -5);
}
#[test]
fn exit_slide_to_right_offset() {
let entrance = ToastEntranceAnimation::SlideFromRight;
let (dx, dy) = ToastExitAnimation::SlideToRight.final_offset(20, 5, entrance);
assert_eq!(dx, 20);
assert_eq!(dy, 0);
}
#[test]
fn exit_slide_to_bottom_offset() {
let entrance = ToastEntranceAnimation::SlideFromRight;
let (dx, dy) = ToastExitAnimation::SlideToBottom.final_offset(20, 5, entrance);
assert_eq!(dx, 0);
assert_eq!(dy, 5);
}
#[test]
fn exit_slide_to_left_offset() {
let entrance = ToastEntranceAnimation::SlideFromRight;
let (dx, dy) = ToastExitAnimation::SlideToLeft.final_offset(20, 5, entrance);
assert_eq!(dx, -20);
assert_eq!(dy, 0);
}
#[test]
fn exit_fade_out_no_offset() {
let entrance = ToastEntranceAnimation::SlideFromRight;
let (dx, dy) = ToastExitAnimation::FadeOut.final_offset(20, 5, entrance);
assert_eq!(dx, 0);
assert_eq!(dy, 0);
}
#[test]
fn exit_none_no_offset() {
let entrance = ToastEntranceAnimation::SlideFromRight;
let (dx, dy) = ToastExitAnimation::None.final_offset(20, 5, entrance);
assert_eq!(dx, 0);
assert_eq!(dy, 0);
}
#[test]
fn exit_offset_progress_clamped() {
let entrance = ToastEntranceAnimation::SlideFromRight;
let (dx, dy) = ToastExitAnimation::SlideToTop.offset_at_progress(-1.0, 20, 5, entrance);
assert_eq!((dx, dy), (0, 0));
let (dx, dy) = ToastExitAnimation::SlideToTop.offset_at_progress(5.0, 20, 5, entrance);
assert_eq!((dx, dy), (0, -5)); }
#[test]
fn exit_offsets_saturate_large_dimensions() {
let entrance = ToastEntranceAnimation::SlideFromRight;
assert_eq!(
ToastExitAnimation::SlideToRight.final_offset(u16::MAX, u16::MAX, entrance),
(i16::MAX, 0)
);
assert_eq!(
ToastExitAnimation::SlideToBottom.final_offset(u16::MAX, u16::MAX, entrance),
(0, i16::MAX)
);
assert_eq!(
ToastExitAnimation::SlideOut.final_offset(u16::MAX, u16::MAX, entrance),
(-i16::MAX, 0)
);
}
#[test]
fn easing_clamped_below_zero() {
for easing in [
ToastEasing::Linear,
ToastEasing::EaseIn,
ToastEasing::EaseOut,
ToastEasing::EaseInOut,
ToastEasing::Bounce,
] {
let result = easing.apply(-0.5);
assert!(
(result - 0.0).abs() < 0.001,
"{easing:?} at -0.5 should clamp to 0"
);
}
}
#[test]
fn easing_clamped_above_one() {
for easing in [
ToastEasing::Linear,
ToastEasing::EaseIn,
ToastEasing::EaseOut,
ToastEasing::EaseInOut,
ToastEasing::Bounce,
] {
let result = easing.apply(1.5);
assert!(
(result - 1.0).abs() < 0.001,
"{easing:?} at 1.5 should clamp to 1"
);
}
}
#[test]
fn easing_ease_in_out_first_half() {
let result = ToastEasing::EaseInOut.apply(0.25);
assert!(
result < 0.25,
"EaseInOut at 0.25 should be < 0.25 (accelerating)"
);
}
#[test]
fn easing_ease_in_out_second_half() {
let result = ToastEasing::EaseInOut.apply(0.75);
assert!(
result > 0.75,
"EaseInOut at 0.75 should be > 0.75 (decelerating)"
);
}
#[test]
fn easing_bounce_monotonic_at_key_points() {
let d1 = 2.75;
let t1 = 0.2 / d1; let t2 = 1.5 / d1; let t3 = 2.3 / d1; let t4 = 2.7 / d1;
let v1 = ToastEasing::Bounce.apply(t1);
let v2 = ToastEasing::Bounce.apply(t2);
let v3 = ToastEasing::Bounce.apply(t3);
let v4 = ToastEasing::Bounce.apply(t4);
assert!((0.0..=1.0).contains(&v1), "branch 1: {v1}");
assert!((0.0..=1.0).contains(&v2), "branch 2: {v2}");
assert!((0.0..=1.0).contains(&v3), "branch 3: {v3}");
assert!((0.0..=1.0).contains(&v4), "branch 4: {v4}");
}
#[test]
fn animation_state_tick_entering_to_visible() {
let config = ToastAnimationConfig {
entrance_duration: Duration::ZERO, ..ToastAnimationConfig::default()
};
let mut state = ToastAnimationState::new();
assert_eq!(state.phase, ToastAnimationPhase::Entering);
let changed = state.tick(&config);
assert!(changed, "Phase should change from Entering to Visible");
assert_eq!(state.phase, ToastAnimationPhase::Visible);
}
#[test]
fn animation_state_tick_exiting_to_hidden() {
let config = ToastAnimationConfig {
exit_duration: Duration::ZERO,
..ToastAnimationConfig::default()
};
let mut state = ToastAnimationState::new();
state.transition_to(ToastAnimationPhase::Exiting);
let changed = state.tick(&config);
assert!(changed, "Phase should change from Exiting to Hidden");
assert_eq!(state.phase, ToastAnimationPhase::Hidden);
}
#[test]
fn animation_state_tick_visible_no_change() {
let config = ToastAnimationConfig::default();
let mut state = ToastAnimationState::new();
state.transition_to(ToastAnimationPhase::Visible);
let changed = state.tick(&config);
assert!(!changed, "Visible phase should not auto-transition");
assert_eq!(state.phase, ToastAnimationPhase::Visible);
}
#[test]
fn animation_state_tick_hidden_no_change() {
let config = ToastAnimationConfig::default();
let mut state = ToastAnimationState::new();
state.transition_to(ToastAnimationPhase::Hidden);
let changed = state.tick(&config);
assert!(!changed);
assert_eq!(state.phase, ToastAnimationPhase::Hidden);
}
#[test]
fn animation_state_start_exit_reduced_motion_goes_to_hidden() {
let mut state = ToastAnimationState::with_reduced_motion();
assert_eq!(state.phase, ToastAnimationPhase::Visible);
state.start_exit();
assert_eq!(state.phase, ToastAnimationPhase::Hidden);
}
#[test]
fn animation_state_is_complete() {
let mut state = ToastAnimationState::new();
assert!(!state.is_complete());
state.transition_to(ToastAnimationPhase::Hidden);
assert!(state.is_complete());
}
#[test]
fn animation_offset_visible_is_zero() {
let config = ToastAnimationConfig::default();
let mut state = ToastAnimationState::new();
state.phase = ToastAnimationPhase::Visible;
let (dx, dy) = state.current_offset(&config, 20, 5);
assert_eq!((dx, dy), (0, 0));
}
#[test]
fn animation_offset_hidden_is_zero() {
let config = ToastAnimationConfig::default();
let mut state = ToastAnimationState::new();
state.phase = ToastAnimationPhase::Hidden;
let (dx, dy) = state.current_offset(&config, 20, 5);
assert_eq!((dx, dy), (0, 0));
}
#[test]
fn animation_offset_reduced_motion_always_zero() {
let config = ToastAnimationConfig::default();
let state = ToastAnimationState::with_reduced_motion();
let (dx, dy) = state.current_offset(&config, 20, 5);
assert_eq!((dx, dy), (0, 0));
}
#[test]
fn animation_opacity_visible_is_one() {
let config = ToastAnimationConfig::default();
let mut state = ToastAnimationState::new();
state.phase = ToastAnimationPhase::Visible;
assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
}
#[test]
fn animation_opacity_hidden_is_zero() {
let config = ToastAnimationConfig::default();
let mut state = ToastAnimationState::new();
state.phase = ToastAnimationPhase::Hidden;
assert!((state.current_opacity(&config) - 0.0).abs() < 0.001);
}
#[test]
fn animation_opacity_reduced_motion_visible_is_one() {
let config = ToastAnimationConfig::default();
let mut state = ToastAnimationState::with_reduced_motion();
state.phase = ToastAnimationPhase::Visible;
assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
}
#[test]
fn animation_opacity_reduced_motion_hidden_is_zero() {
let config = ToastAnimationConfig::default();
let mut state = ToastAnimationState::with_reduced_motion();
state.phase = ToastAnimationPhase::Hidden;
assert!((state.current_opacity(&config) - 0.0).abs() < 0.001);
}
#[test]
fn animation_opacity_exiting_non_fade_is_one() {
let config = ToastAnimationConfig {
exit: ToastExitAnimation::SlideOut,
..ToastAnimationConfig::default()
};
let mut state = ToastAnimationState::new();
state.phase = ToastAnimationPhase::Exiting;
assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
}
#[test]
fn animation_opacity_entering_non_fade_is_one() {
let config = ToastAnimationConfig {
entrance: ToastEntranceAnimation::SlideFromTop,
..ToastAnimationConfig::default()
};
let mut state = ToastAnimationState::new();
state.phase = ToastAnimationPhase::Entering;
assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
}
#[test]
fn toast_with_id() {
let toast = Toast::with_id(ToastId::new(42), "Custom ID");
assert_eq!(toast.id, ToastId::new(42));
assert_eq!(toast.content.message, "Custom ID");
}
#[test]
fn toast_tick_animation_returns_true_on_phase_change() {
let mut toast = Toast::new("Test").entrance_duration(Duration::ZERO);
assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Entering);
let changed = toast.tick_animation();
assert!(changed);
assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
}
#[test]
fn toast_tick_animation_returns_false_when_stable() {
let mut toast = Toast::new("Test").no_animation();
assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
let changed = toast.tick_animation();
assert!(!changed);
}
#[test]
fn toast_animation_phase_accessor() {
let toast = Toast::new("Test").no_animation();
assert_eq!(toast.animation_phase(), ToastAnimationPhase::Visible);
}
#[test]
fn toast_animation_opacity_accessor() {
let toast = Toast::new("Test").no_animation();
assert!((toast.animation_opacity() - 1.0).abs() < 0.001);
}
#[test]
fn toast_remaining_time_persistent_is_none() {
let toast = Toast::new("msg").persistent().no_animation();
assert!(toast.remaining_time().is_none());
}
#[test]
fn toast_dismiss_twice_idempotent() {
let mut toast = Toast::new("msg").no_animation();
toast.state.animation.reduced_motion = false;
toast.dismiss();
assert!(toast.state.dismissed);
let phase_after_first = toast.state.animation.phase;
toast.dismiss(); assert_eq!(toast.state.animation.phase, phase_after_first);
}
#[test]
fn toast_non_dismissable_esc_noop() {
let mut toast = Toast::new("msg").dismissable(false).no_animation();
let result = toast.handle_key(KeyEvent::Esc);
assert_eq!(result, ToastEvent::None);
assert!(toast.is_visible());
}
#[test]
fn toast_margin_builder() {
let toast = Toast::new("msg").margin(5);
assert_eq!(toast.config.margin, 5);
}
#[test]
fn toast_with_icon_style_builder() {
let style = Style::new().italic();
let toast = Toast::new("msg").with_icon_style(style);
assert_eq!(toast.icon_style, style);
}
#[test]
fn toast_with_title_style_builder() {
let style = Style::new().bold();
let toast = Toast::new("msg").with_title_style(style);
assert_eq!(toast.title_style, style);
}
#[test]
fn toast_config_default_values() {
let config = ToastConfig::default();
assert_eq!(config.position, ToastPosition::TopRight);
assert_eq!(config.duration, Some(Duration::from_secs(5)));
assert!(!config.duration_explicit);
assert_eq!(config.style_variant, ToastStyle::Info);
assert_eq!(config.max_width, 50);
assert_eq!(config.margin, 1);
assert!(config.dismissable);
}
#[test]
fn animation_config_none_fields() {
let config = ToastAnimationConfig::none();
assert_eq!(config.entrance, ToastEntranceAnimation::None);
assert_eq!(config.exit, ToastExitAnimation::None);
assert_eq!(config.entrance_duration, Duration::ZERO);
assert_eq!(config.exit_duration, Duration::ZERO);
assert!(config.is_disabled());
}
#[test]
fn animation_config_is_disabled_false_for_default() {
let config = ToastAnimationConfig::default();
assert!(!config.is_disabled());
}
#[test]
fn toast_id_hash_consistent() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(ToastId::new(1));
set.insert(ToastId::new(2));
set.insert(ToastId::new(1)); assert_eq!(set.len(), 2);
}
#[test]
fn toast_id_debug() {
let id = ToastId::new(42);
let dbg = format!("{:?}", id);
assert!(dbg.contains("42"), "Debug: {dbg}");
}
#[test]
fn toast_event_debug_clone() {
let event = ToastEvent::Action("test".into());
let dbg = format!("{:?}", event);
assert!(dbg.contains("Action"), "Debug: {dbg}");
let cloned = event.clone();
assert_eq!(cloned, ToastEvent::Action("test".into()));
}
#[test]
fn key_event_traits() {
let key = KeyEvent::Tab;
let copy = key; assert_eq!(key, copy);
let dbg = format!("{:?}", key);
assert!(dbg.contains("Tab"), "Debug: {dbg}");
}
#[test]
fn animation_tick_entering_reduced_motion_transitions_immediately() {
let config = ToastAnimationConfig::default();
let mut state = ToastAnimationState {
phase: ToastAnimationPhase::Entering,
phase_started: Instant::now(),
reduced_motion: true,
};
let changed = state.tick(&config);
assert!(changed);
assert_eq!(state.phase, ToastAnimationPhase::Visible);
}
#[test]
fn animation_tick_exiting_reduced_motion_transitions_immediately() {
let config = ToastAnimationConfig::default();
let mut state = ToastAnimationState {
phase: ToastAnimationPhase::Exiting,
phase_started: Instant::now(),
reduced_motion: true,
};
let changed = state.tick(&config);
assert!(changed);
assert_eq!(state.phase, ToastAnimationPhase::Hidden);
}
}