From 4449b29cc9204774806010743e0d49fc930b5b96 Mon Sep 17 00:00:00 2001 From: wiiznokes <78230769+wiiznokes@users.noreply.github.com> Date: Tue, 2 Jul 2024 17:55:38 +0200 Subject: [PATCH] feat(widget): add toast widget --- src/widget/mod.rs | 2 + src/widget/toaster/mod.rs | 179 ++++++++++++++++++++++ src/widget/toaster/widget.rs | 277 +++++++++++++++++++++++++++++++++++ 3 files changed, 458 insertions(+) create mode 100644 src/widget/toaster/mod.rs create mode 100644 src/widget/toaster/widget.rs diff --git a/src/widget/mod.rs b/src/widget/mod.rs index a1b6cb51..7f36f0c4 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -349,3 +349,5 @@ pub mod tooltip { pub mod warning; #[doc(inline)] pub use warning::*; + +pub mod toaster; \ No newline at end of file diff --git a/src/widget/toaster/mod.rs b/src/widget/toaster/mod.rs new file mode 100644 index 00000000..b61038fe --- /dev/null +++ b/src/widget/toaster/mod.rs @@ -0,0 +1,179 @@ +// Copyright 2024 wiiznokes +// SPDX-License-Identifier: MPL-2.0 + +//! A widget that displays toasts. + +use std::collections::VecDeque; +use std::time::Duration; + +use crate::app::Command; +use crate::widget::container; +use crate::widget::Column; +use iced_core::Element; +use widget::Toaster; + +use crate::ext::CollectionWidget; + +use super::column; +use super::{button, row, text}; + +mod widget; + +/// Create a new Toaster widget. +pub fn toaster<'a, Message>( + toasts: &'a Toasts, + content: impl Into>, +) -> Element<'a, Message, crate::Theme, iced::Renderer> +where + Message: From + Clone + 'static, +{ + let make_toast = |toast: &'a Toast| { + let row = row() + .push(text(&toast.message)) + .push_maybe(toast.action.as_ref().map(|action| { + button::standard(&action.description).on_press(action.message.clone()) + })) + .push(button::standard("close").on_press(ToastMessage(toast.id).into())) + .align_items(iced::Alignment::Center); + + container(row) + .padding(crate::theme::active().cosmic().space_xs()) + .style(crate::style::Container::Card) + }; + + let col = toasts + .toasts + .iter() + .rev() + .map(make_toast) + .fold(column::with_capacity(toasts.toasts.len()), Column::push) + .spacing(crate::theme::active().cosmic().space_xxxs()); + + Toaster::new(col.into(), content.into(), toasts.toasts.is_empty()).into() +} + +/// Duration for the [`Toast`] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)] +pub enum ToastDuration { + #[default] + Short, + Long, + Custom(Duration), +} + +impl ToastDuration { + fn duration(&self) -> Duration { + match self { + ToastDuration::Short => Duration::from_millis(2000), + ToastDuration::Long => Duration::from_millis(3500), + ToastDuration::Custom(duration) => *duration, + } + } +} + +impl From for ToastDuration { + fn from(value: Duration) -> Self { + Self::Custom(value) + } +} + +/// Action that can be triggered by the user. +/// +/// Example: `undo` +#[derive(Debug, Clone)] +pub struct ToastAction { + pub description: String, + pub message: Message, +} + +/// Represent the data used to display a [`Toast`] +#[derive(Debug, Clone)] +pub struct Toast { + message: String, + action: Option>, + duration: ToastDuration, + id: u32, +} + +impl Toast { + /// Construct a new [`Toast`] with the provided message. + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + action: None, + duration: ToastDuration::default(), + id: 0, + } + } + + /// Set the [`ToastAction`] of this [`Toast`] + #[must_use] + pub fn action(mut self, action: ToastAction) -> Self { + self.action.replace(action); + self + } + + /// Set the [`ToastDuration`] of this [`Toast`] + #[must_use] + pub fn duration(mut self, duration: impl Into) -> Self { + self.duration = duration.into(); + self + } +} + +#[derive(Debug, Clone)] +pub struct Toasts { + id_count: u32, + toasts: VecDeque>, + limit: usize, +} + +// need custom impl to not require Message: Clone +impl Default for Toasts { + fn default() -> Self { + Self { + id_count: 0, + toasts: VecDeque::new(), + limit: 5, + } + } +} + +#[derive(Debug, Clone)] +pub struct ToastMessage(u32); + +impl Toasts { + /// Add a new [`Toast`] + pub fn push( + &mut self, + mut toast: Toast, + ) -> Command> + where + Message: From, + { + while self.toasts.len() >= self.limit { + self.toasts.pop_front(); + } + + toast.id = self.id_count; + self.id_count += 1; + + let message = ToastMessage(toast.id); + let duration = toast.duration.duration(); + + self.toasts.push_back(toast); + + crate::command::future(async move { + tokio::time::sleep(duration).await; + crate::app::Message::App(Message::from(message)) + }) + } + + /// Handle the [`ToastMessage`] + pub fn handle_message(&mut self, message: &ToastMessage) { + self.toasts + .iter() + .position(|e| e.id == message.0) + .map(|index| self.toasts.remove(index)); + } +} diff --git a/src/widget/toaster/widget.rs b/src/widget/toaster/widget.rs new file mode 100644 index 00000000..c1137732 --- /dev/null +++ b/src/widget/toaster/widget.rs @@ -0,0 +1,277 @@ +// Copyright 2024 wiiznokes +// SPDX-License-Identifier: MPL-2.0 + +use iced::{Limits, Size}; +use iced_core::layout::Node; + +use iced_core::event::{self, Event}; +use iced_core::layout; +use iced_core::mouse; +use iced_core::overlay; +use iced_core::renderer::{self}; +use iced_core::widget::tree::Tree; +use iced_core::widget::Operation; +use iced_core::Element; +use iced_core::Overlay; +use iced_core::{Clipboard, Layout, Length, Point, Rectangle, Shell, Vector, Widget}; +use iced_renderer::core::widget::OperationOutputWrapper; + +pub struct Toaster<'a, Message, Theme, Renderer> { + toasts: Element<'a, Message, Theme, Renderer>, + content: Element<'a, Message, Theme, Renderer>, + is_empty: bool, +} + +impl<'a, Message, Theme, Renderer> Toaster<'a, Message, Theme, Renderer> { + pub fn new( + toasts: Element<'a, Message, Theme, Renderer>, + content: Element<'a, Message, Theme, Renderer>, + is_empty: bool, + ) -> Self { + Self { + toasts, + content, + is_empty, + } + } +} + +impl<'a, Message, Theme, Renderer> Widget + for Toaster<'a, Message, Theme, Renderer> +where + Renderer: iced_core::Renderer, +{ + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget() + .layout(&mut tree.children[0], renderer, limits) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content), Tree::new(&self.toasts)] + } + + fn diff(&mut self, tree: &mut Tree) { + tree.diff_children(&mut [&mut self.content, &mut self.toasts]); + } + + fn operate<'b>( + &'b self, + state: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation>, + ) { + self.content + .as_widget() + .operate(&mut state.children[0], layout, renderer, operation); + } + + fn on_event( + &mut self, + state: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + self.content.as_widget_mut().on_event( + &mut state.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) + } + + fn mouse_interaction( + &self, + state: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &state.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn overlay<'b>( + &'b mut self, + state: &'b mut Tree, + layout: Layout<'_>, + _renderer: &Renderer, + ) -> Option> { + if self.is_empty { + None + } else { + let bounds = layout.bounds(); + + Some(overlay::Element::new( + bounds.position(), + Box::new(ToasterOverlay::new( + &mut state.children[1], + &mut self.toasts, + )), + )) + } + } +} + +struct ToasterOverlay<'a, 'b, Message, Theme = iced::Theme, Renderer = iced::Renderer> { + state: &'b mut Tree, + element: &'b mut Element<'a, Message, Theme, Renderer>, +} + +impl<'a, 'b, Message, Theme, Renderer> ToasterOverlay<'a, 'b, Message, Theme, Renderer> +where + Renderer: renderer::Renderer, +{ + fn new(state: &'b mut Tree, element: &'b mut Element<'a, Message, Theme, Renderer>) -> Self { + Self { state, element } + } +} + +impl<'a, 'b, Message, Theme, Renderer> Overlay + for ToasterOverlay<'a, 'b, Message, Theme, Renderer> +where + Renderer: renderer::Renderer, +{ + fn layout( + &mut self, + renderer: &Renderer, + bounds: Size, + position: Point, + _translation: Vector, + ) -> Node { + let limits = Limits::new(Size::ZERO, bounds); + + let mut node = self + .element + .as_widget() + .layout(self.state, renderer, &limits); + + let offset = 15.; + + let position = Point::new( + (bounds.width / 2.) - (node.size().width / 2.), + bounds.height - (node.size().height + offset), + ); + + node.move_to_mut(position); + + node + } + + fn draw( + &self, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + let bounds = layout.bounds(); + self.element + .as_widget() + .draw(self.state, renderer, theme, style, layout, cursor, &bounds); + } + + fn on_event( + &mut self, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell, + ) -> event::Status { + self.element.as_widget_mut().on_event( + self.state, + event, + layout, + cursor, + renderer, + clipboard, + shell, + &layout.bounds(), + ) + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.element + .as_widget() + .mouse_interaction(self.state, layout, cursor, viewport, renderer) + } + + fn overlay<'c>( + &'c mut self, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option> { + self.element + .as_widget_mut() + .overlay(self.state, layout, renderer) + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Renderer: renderer::Renderer + 'a, + Theme: 'a, + Message: 'a, +{ + fn from( + toaster: Toaster<'a, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(toaster) + } +}