use std::time::{Duration, Instant};
use iced::{
Alignment::Center,
Background, Border, Color, Element, Length, Shadow, Subscription,
alignment::{Horizontal, Vertical},
widget::{button, column, container, row, text},
};
use snora_core::{LayoutDirection, Toast, ToastIntent, ToastLifetime, ToastPosition};
const TOAST_WIDTH: f32 = 340.0;
const SWEEP_INTERVAL: Duration = Duration::from_millis(500);
const WARNING_COLOR: Color = Color::from_rgb(0.851, 0.467, 0.024);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ToastRenderOrder {
Chronological,
ReverseChronological,
}
fn render_order_for(position: ToastPosition) -> ToastRenderOrder {
if position.is_top() {
ToastRenderOrder::ReverseChronological
} else {
ToastRenderOrder::Chronological
}
}
pub(crate) fn render_toasts<'a, Message>(
toasts: Vec<Toast<Message>>,
position: ToastPosition,
direction: LayoutDirection,
) -> Option<Element<'a, Message>>
where
Message: Clone + 'a,
{
if toasts.is_empty() {
return None;
}
let mut stack_col = column![].spacing(8);
match render_order_for(position) {
ToastRenderOrder::ReverseChronological => {
for toast in toasts.into_iter().rev() {
stack_col = stack_col.push(render_single_toast(toast));
}
}
ToastRenderOrder::Chronological => {
for toast in toasts {
stack_col = stack_col.push(render_single_toast(toast));
}
}
}
let horizontal_anchor = horizontal_align(position, direction);
let vertical_anchor = if position.is_top() {
Vertical::Top
} else {
Vertical::Bottom
};
Some(
container(stack_col)
.width(Length::Fill)
.height(Length::Fill)
.padding(24)
.align_x(horizontal_anchor)
.align_y(vertical_anchor)
.into(),
)
}
fn horizontal_align(position: ToastPosition, direction: LayoutDirection) -> Horizontal {
use ToastPosition::*;
match position {
TopCenter | BottomCenter => Horizontal::Center,
TopStart | BottomStart => match direction {
LayoutDirection::Ltr => Horizontal::Left,
LayoutDirection::Rtl => Horizontal::Right,
},
TopEnd | BottomEnd => match direction {
LayoutDirection::Ltr => Horizontal::Right,
LayoutDirection::Rtl => Horizontal::Left,
},
}
}
fn render_single_toast<'a, Message>(toast: Toast<Message>) -> Element<'a, Message>
where
Message: Clone + 'a,
{
let intent = toast.intent;
let text_col = column![
text(toast.title).size(16),
text(toast.message).size(14),
]
.spacing(4);
let close_btn = button(text("×").size(18))
.on_press(toast.on_dismiss)
.padding([0, 8])
.style(|_theme, status| close_button_style(status));
let body = row![container(text_col).width(Length::Fill), close_btn]
.align_y(Center)
.spacing(4);
container(body)
.width(Length::Fixed(TOAST_WIDTH))
.padding(12)
.style(move |theme| toast_style(theme, intent))
.into()
}
fn toast_style(theme: &iced::Theme, intent: ToastIntent) -> iced::widget::container::Style {
use iced::widget::container::Style;
let ep = theme.extended_palette();
let (background, text_color) = match intent {
ToastIntent::Debug => (ep.background.strong.color, ep.background.strong.text),
ToastIntent::Info => (ep.primary.base.color, ep.primary.base.text),
ToastIntent::Success => (ep.success.base.color, ep.success.base.text),
ToastIntent::Warning => (WARNING_COLOR, Color::WHITE),
ToastIntent::Error => (ep.danger.base.color, ep.danger.base.text),
};
Style {
background: Some(Background::Color(background)),
text_color: Some(text_color),
border: Border {
radius: 8.0.into(),
..Default::default()
},
shadow: Shadow::default(),
..Default::default()
}
}
fn close_button_style(status: button::Status) -> button::Style {
let alpha = match status {
button::Status::Hovered => 1.0,
_ => 0.75,
};
button::Style {
background: None,
text_color: Color {
a: alpha,
..Color::WHITE
},
border: Border::default(),
shadow: Shadow::default(),
snap: true,
}
}
pub fn subscription<Message, F>(
toasts: &[Toast<Message>],
tick_message: F,
) -> Subscription<Message>
where
Message: Clone + Send + 'static,
F: Fn() -> Message + Send + Sync + Clone + 'static,
{
let has_transient = toasts
.iter()
.any(|t| matches!(t.lifetime, ToastLifetime::Transient(_)));
if has_transient {
iced::time::every(SWEEP_INTERVAL).map(move |_| tick_message())
} else {
Subscription::none()
}
}
pub fn sweep_expired<Message: Clone>(toasts: &mut Vec<Toast<Message>>, now: Instant) {
toasts.retain(|t| !t.is_expired(now));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sweep_drops_only_expired_transient() {
let base = Instant::now();
let live_transient = Toast::new(1, ToastIntent::Info, "a", "b", ())
.with_lifetime(ToastLifetime::seconds(10))
.with_created_at(base);
let dead_transient = Toast::new(2, ToastIntent::Info, "a", "b", ())
.with_lifetime(ToastLifetime::millis(100))
.with_created_at(base);
let persistent = Toast::new(3, ToastIntent::Error, "a", "b", ())
.persistent()
.with_created_at(base);
let mut v = vec![live_transient, dead_transient, persistent];
sweep_expired(&mut v, base + Duration::from_secs(1));
let remaining_ids: Vec<u64> = v.iter().map(|t| t.id).collect();
assert_eq!(remaining_ids, vec![1, 3]);
}
#[test]
fn top_end_ltr_resolves_right() {
assert_eq!(
horizontal_align(ToastPosition::TopEnd, LayoutDirection::Ltr),
iced::alignment::Horizontal::Right,
);
}
#[test]
fn top_end_rtl_mirrors_to_left() {
assert_eq!(
horizontal_align(ToastPosition::TopEnd, LayoutDirection::Rtl),
iced::alignment::Horizontal::Left,
);
}
#[test]
fn top_start_ltr_resolves_left() {
assert_eq!(
horizontal_align(ToastPosition::TopStart, LayoutDirection::Ltr),
iced::alignment::Horizontal::Left,
);
}
#[test]
fn top_start_rtl_mirrors_to_right() {
assert_eq!(
horizontal_align(ToastPosition::TopStart, LayoutDirection::Rtl),
iced::alignment::Horizontal::Right,
);
}
#[test]
fn center_positions_unaffected_by_direction() {
for dir in [LayoutDirection::Ltr, LayoutDirection::Rtl] {
assert_eq!(
horizontal_align(ToastPosition::TopCenter, dir),
iced::alignment::Horizontal::Center,
"TopCenter must be unaffected by direction ({dir:?})",
);
assert_eq!(
horizontal_align(ToastPosition::BottomCenter, dir),
iced::alignment::Horizontal::Center,
"BottomCenter must be unaffected by direction ({dir:?})",
);
}
}
#[test]
fn top_positions_render_reverse_chronological() {
use ToastPosition::*;
for pos in [TopEnd, TopStart, TopCenter] {
assert_eq!(
render_order_for(pos),
ToastRenderOrder::ReverseChronological,
"{pos:?} should use reverse-chronological order",
);
}
}
#[test]
fn bottom_positions_render_chronological() {
use ToastPosition::*;
for pos in [BottomEnd, BottomStart, BottomCenter] {
assert_eq!(
render_order_for(pos),
ToastRenderOrder::Chronological,
"{pos:?} should use chronological order",
);
}
}
}