Skip to main content

cosmic/widget/toaster/
mod.rs

1// Copyright 2024 wiiznokes
2// SPDX-License-Identifier: MPL-2.0
3
4//! A widget that displays toasts.
5
6use std::collections::VecDeque;
7use std::rc::Rc;
8
9use crate::widget::{Column, container};
10use iced::Task;
11use iced_core::Element;
12use slotmap::{SlotMap, new_key_type};
13use widget::Toaster;
14
15use super::{button, column, icon, row, text};
16
17mod widget;
18
19/// Create a new Toaster widget.
20pub fn toaster<'a, Message: Clone + 'static>(
21    toasts: &'a Toasts<Message>,
22    content: impl Into<Element<'a, Message, crate::Theme, iced::Renderer>>,
23) -> Element<'a, Message, crate::Theme, iced::Renderer> {
24    let theme = crate::theme::active();
25    let cosmic_theme::Spacing {
26        space_xxxs,
27        space_xxs,
28        space_s,
29        space_m,
30        ..
31    } = theme.cosmic().spacing;
32
33    let make_toast = move |(id, toast): (ToastId, &'a Toast<Message>)| {
34        let row = row::with_capacity(2)
35            .push(text(&toast.message))
36            .push(
37                row::with_capacity(2)
38                    .push_maybe(toast.action.as_ref().map(|action| {
39                        button::text(&action.description).on_press((action.message)(id))
40                    }))
41                    .push(
42                        button::icon(icon::from_name("window-close-symbolic"))
43                            .on_press((toasts.on_close)(id)),
44                    )
45                    .align_y(iced::Alignment::Center)
46                    .spacing(space_xxs),
47            )
48            .align_y(iced::Alignment::Center)
49            .spacing(space_s);
50
51        container(row)
52            .padding([space_xxs, space_s, space_xxs, space_m])
53            .class(crate::style::Container::Tooltip)
54    };
55
56    let col = toasts
57        .queue
58        .iter()
59        .filter_map(|id| Some((*id, toasts.toasts.get(*id)?)))
60        .rev()
61        .map(make_toast)
62        .fold(column::with_capacity(toasts.toasts.len()), Column::push)
63        .spacing(space_xxxs);
64
65    Toaster::new(col.into(), content.into(), toasts.toasts.is_empty()).into()
66}
67
68/// Duration for the [`Toast`]
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)]
70pub enum Duration {
71    #[default]
72    Short,
73    Long,
74    Custom(std::time::Duration),
75}
76
77impl Duration {
78    #[cfg(feature = "tokio")]
79    fn duration(&self) -> std::time::Duration {
80        match self {
81            Duration::Short => std::time::Duration::from_millis(5000),
82            Duration::Long => std::time::Duration::from_millis(15000),
83            Duration::Custom(duration) => *duration,
84        }
85    }
86}
87
88impl From<std::time::Duration> for Duration {
89    fn from(value: std::time::Duration) -> Self {
90        Self::Custom(value)
91    }
92}
93
94/// Action that can be triggered by the user.
95///
96/// Example: `undo`
97#[derive(Clone)]
98pub struct Action<Message> {
99    pub description: String,
100    pub message: Rc<dyn Fn(ToastId) -> Message>,
101}
102
103impl<Message> std::fmt::Debug for Action<Message> {
104    #[cold]
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.debug_struct("Action")
107            .field("description", &self.description)
108            .finish()
109    }
110}
111
112/// Represent the data used to display a [`Toast`]
113#[derive(Debug, Clone)]
114pub struct Toast<Message> {
115    message: String,
116    action: Option<Action<Message>>,
117    duration: Duration,
118}
119
120impl<Message> Toast<Message> {
121    /// Construct a new [`Toast`] with the provided message.
122    pub fn new(message: impl Into<String>) -> Self {
123        Self {
124            message: message.into(),
125            action: None,
126            duration: Duration::default(),
127        }
128    }
129
130    /// Set the [`Action`] of this [`Toast`]
131    #[must_use]
132    pub fn action(
133        mut self,
134        description: String,
135        message: impl Fn(ToastId) -> Message + 'static,
136    ) -> Self {
137        self.action.replace(Action {
138            description,
139            message: Rc::new(message),
140        });
141        self
142    }
143
144    /// Set the [`Duration`] of this [`Toast`]
145    #[must_use]
146    pub fn duration(mut self, duration: impl Into<Duration>) -> Self {
147        self.duration = duration.into();
148        self
149    }
150}
151
152new_key_type! { pub struct ToastId; }
153
154#[derive(Debug, Clone)]
155pub struct Toasts<Message> {
156    toasts: SlotMap<ToastId, Toast<Message>>,
157    queue: VecDeque<ToastId>,
158    on_close: fn(ToastId) -> Message,
159    limit: usize,
160}
161
162impl<Message: Clone + Send + 'static> Toasts<Message> {
163    pub fn new(on_close: fn(ToastId) -> Message) -> Self {
164        let limit = 5;
165        Self {
166            toasts: SlotMap::with_capacity_and_key(limit),
167            queue: VecDeque::new(),
168            on_close,
169            limit,
170        }
171    }
172
173    /// Add a new [`Toast`]
174    pub fn push(&mut self, toast: Toast<Message>) -> Task<Message> {
175        while self.toasts.len() >= self.limit {
176            self.toasts.remove(
177                self.queue
178                    .pop_front()
179                    .expect("Queue must contain all toast ids"),
180            );
181        }
182
183        #[cfg(feature = "tokio")]
184        let duration = toast.duration.duration();
185
186        let id = self.toasts.insert(toast);
187        self.queue.push_back(id);
188
189        #[cfg(feature = "tokio")]
190        {
191            let on_close = self.on_close;
192            crate::task::future(async move {
193                tokio::time::sleep(duration).await;
194                on_close(id)
195            })
196        }
197        #[cfg(not(feature = "tokio"))]
198        {
199            Task::none()
200        }
201    }
202
203    /// Remove a [`Toast`]
204    pub fn remove(&mut self, id: ToastId) {
205        self.toasts.remove(id);
206        if let Some(pos) = self.queue.iter().position(|key| *key == id) {
207            self.queue.remove(pos);
208        }
209    }
210}