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);
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);
if position.is_bottom() {
for toast in toasts.into_iter().rev() {
stack_col = stack_col.push(render_single_toast(toast));
}
} else {
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 => (Color::from_rgb8(0xD9, 0x77, 0x06), 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]);
}
}