Skip to main content

fractal/utils/
toast.rs

1//! Macros and methods to display toasts in the interface.
2
3use std::collections::HashMap;
4
5use adw::prelude::*;
6
7use super::freplace;
8use crate::{
9    Window,
10    components::{LabelWithWidgets, Pill, ToastableDialog},
11    prelude::*,
12};
13
14/// Show a toast with the given message on an ancestor of `widget`.
15///
16/// The simplest way to use this macro is for displaying a simple message. It
17/// can be anything that implements `AsRef<str>`.
18///
19/// ```no_run
20/// use gettextts::gettext;
21///
22/// use crate::toast;
23///
24/// # let widget = unimplemented!();
25/// toast!(widget, gettext("Something happened"));
26/// ```
27///
28/// This macro also supports replacing named variables with their value. It
29/// supports both the `var` and the `var = expr` syntax. The variable value must
30/// implement `ToString`.
31///
32/// ```no_run
33/// use gettextts::gettext;
34///
35/// use crate::toast;
36///
37/// # let widget = unimplemented!();
38/// # let error_nb = 0;
39/// toast!(
40///     widget,
41///     gettext("Error number {n}: {msg}"),
42///     n = error_nb.to_string(),
43///     msg,
44/// );
45/// ```
46///
47/// To add [`Pill`]s to the toast, you can precede a type that implements
48/// [`PillSource`] with `@`.
49///
50/// ```no_run
51/// use gettextts::gettext;
52/// use crate::toast;
53/// use crate::session::{Room, User};
54///
55/// # let session = unimplemented!();
56/// # let room_id = unimplemented!();
57/// # let user_id = unimplemented!();
58/// let room = Room::new(session, room_id);
59/// let member = Member::new(room, user_id);
60///
61/// toast!(
62///     widget,
63///     gettext("Could not contact {user} in {room}"),
64///     @user = member,
65///     @room,
66/// );
67/// ```
68///
69/// For this macro to work, the widget must have one of these ancestors that can
70/// show toasts:
71///
72/// - `ToastableDialog`
73/// - `AdwPreferencesDialog`
74/// - `AdwPreferencesWindow`
75/// - `Window`
76///
77/// [`PillSource`]: crate::components::PillSource
78#[macro_export]
79macro_rules! toast {
80    // Without vars, with or without a trailing comma.
81    ($widget:expr, $message:expr $(,)?) => {
82        {
83            $crate::utils::toast::add_toast(
84                $widget.upcast_ref(),
85                adw::Toast::new($message.as_ref())
86            );
87        }
88    };
89    // With vars.
90    ($widget:expr, $message:expr, $($tail:tt)+) => {
91        {
92            let (string_vars, pill_vars) = $crate::_toast_accum!([], [], $($tail)+);
93            $crate::utils::toast::add_toast_with_vars(
94                $widget.upcast_ref(),
95                $message.as_ref(),
96                &string_vars,
97                &pill_vars.into()
98            );
99        }
100    };
101}
102
103/// Macro to accumulate the variables passed to `toast!`.
104///
105/// Returns a `([(&str, String)],[(&str, Pill)])` tuple. The items in the first
106/// array are `(var_name, var_value)` tuples, and the ones in the second array
107/// are `(var_name, pill)` tuples.
108#[doc(hidden)]
109#[macro_export]
110macro_rules! _toast_accum {
111    // `var = val` syntax, without anything after.
112    ([$($string_vars:tt)*], [$($pill_vars:tt)*], $var:ident = $val:expr) => {
113        $crate::_toast_accum!([$($string_vars)*], [$($pill_vars)*], $var = $val,)
114    };
115    // `var = val` syntax, with a trailing comma or other vars after.
116    ([$($string_vars:tt)*], [$($pill_vars:tt)*], $var:ident = $val:expr, $($tail:tt)*) => {
117        $crate::_toast_accum!([$($string_vars)* (stringify!($var), $val.to_string()),], [$($pill_vars)*], $($tail)*)
118    };
119    // `var` syntax, with or without a trailing comma and other vars after.
120    ([$($string_vars:tt)*], [$($pill_vars:tt)*], $var:ident $($tail:tt)*) => {
121        $crate::_toast_accum!([$($string_vars)* (stringify!($var), $var.to_string()),], [$($pill_vars)*] $($tail)*)
122    };
123    // `@var = val` syntax, without anything after.
124    ([$($string_vars:tt)*], [$($pill_vars:tt)*], @$var:ident = $val:expr) => {
125        $crate::_toast_accum!([$($string_vars)*], [$($pill_vars)*], @$var = $val,)
126    };
127    // `@var = val` syntax, with a trailing comma or other vars after.
128    ([$($string_vars:tt)*], [$($pill_vars:tt)*], @$var:ident = $val:expr, $($tail:tt)*) => {
129        {
130            use $crate::components::PillSourceExt;
131            // We do not need to watch safety settings for pills, rooms will be watched
132            // automatically.
133            let pill: $crate::components::Pill = $val.to_pill($crate::components::AvatarImageSafetySetting::None, None);
134            $crate::_toast_accum!([$($string_vars)*], [$($pill_vars)* (stringify!($var), pill),], $($tail)*)
135        }
136    };
137    // `@var` syntax, with or without a trailing comma and other vars after.
138    ([$($string_vars:tt)*], [$($pill_vars:tt)*], @$var:ident $($tail:tt)*) => {
139        {
140            use $crate::components::PillSourceExt;
141            // We do not need to watch safety settings for pills, rooms will be watched
142            // automatically.
143            let pill: $crate::components::Pill = $var.to_pill($crate::components::AvatarImageSafetySetting::None, None);
144            $crate::_toast_accum!([$($string_vars)*], [$($pill_vars)* (stringify!($var), pill),] $($tail)*)
145        }
146    };
147    // No more vars, with or without trailing comma.
148    ([$($string_vars:tt)*], [$($pill_vars:tt)*] $(,)?) => { ([$($string_vars)*], [$($pill_vars)*]) };
149}
150
151/// Add the given `AdwToast` to the ancestor of the given widget.
152///
153/// The widget must have one of these ancestors that can show toasts:
154///
155/// - `ToastableDialog`
156/// - `AdwPreferencesDialog`
157/// - `AdwPreferencesWindow`
158/// - `Window`
159pub(crate) fn add_toast(widget: &gtk::Widget, toast: adw::Toast) {
160    if let Some(dialog) = widget
161        .ancestor(ToastableDialog::static_type())
162        .and_downcast::<ToastableDialog>()
163    {
164        dialog.add_toast(toast);
165    } else if let Some(dialog) = widget
166        .ancestor(adw::PreferencesDialog::static_type())
167        .and_downcast::<adw::PreferencesDialog>()
168    {
169        dialog.add_toast(toast);
170    } else if let Some(root) = widget.root() {
171        // FIXME: AdwPreferencesWindow is deprecated but RoomDetails uses it.
172        #[allow(deprecated)]
173        if let Some(window) = root.downcast_ref::<adw::PreferencesWindow>() {
174            use adw::prelude::PreferencesWindowExt;
175            window.add_toast(toast);
176        } else if let Some(window) = root.downcast_ref::<Window>() {
177            window.add_toast(toast);
178        } else {
179            panic!("Trying to display a toast when the parent doesn't support it");
180        }
181    }
182}
183
184/// Add a toast with the given message and variables to the ancestor of the
185/// given widget.
186///
187/// The widget must have one of these ancestors that can show toasts:
188///
189/// - `ToastableDialog`
190/// - `AdwPreferencesDialog`
191/// - `AdwPreferencesWindow`
192/// - `Window`
193pub(crate) fn add_toast_with_vars(
194    widget: &gtk::Widget,
195    message: &str,
196    string_vars: &[(&str, String)],
197    pill_vars: &HashMap<&str, Pill>,
198) {
199    let string_dict: Vec<_> = string_vars
200        .iter()
201        .map(|(key, val)| (*key, val.as_ref()))
202        .collect();
203    let message = freplace(message, &string_dict);
204
205    let toast = if pill_vars.is_empty() {
206        adw::Toast::new(&message)
207    } else {
208        let mut swapped_label = String::new();
209        let mut widgets = Vec::with_capacity(pill_vars.len());
210        let mut last_end = 0;
211
212        // Find the locations of the pills in the message.
213        let mut matches = pill_vars
214            .keys()
215            .flat_map(|key| {
216                message
217                    .match_indices(&format!("{{{key}}}"))
218                    .map(|(start, _)| (start, *key))
219                    .collect::<Vec<_>>()
220            })
221            .collect::<Vec<_>>();
222        // Sort the locations, so we can insert the pills in the right order.
223        matches.sort_unstable();
224
225        for (start, key) in matches {
226            swapped_label.push_str(&message[last_end..start]);
227            swapped_label.push_str(LabelWithWidgets::PLACEHOLDER);
228            last_end = start + key.len() + 2;
229            widgets.push(
230                pill_vars
231                    .get(key)
232                    .expect("match key should be in map")
233                    .clone(),
234            );
235        }
236        swapped_label.push_str(&message[last_end..message.len()]);
237
238        let widget = LabelWithWidgets::new();
239        widget.set_valign(gtk::Align::Center);
240        widget.set_label_and_widgets(swapped_label, widgets);
241
242        adw::Toast::builder().custom_title(&widget).build()
243    };
244
245    add_toast(widget, toast);
246}