Skip to main content

frus_widgets/
alert.rs

1//! [`Alert`]: a **persistent** (contextual) message box, as opposed to the transient
2//! [`crate::SnackBar`].
3//!
4//! It was called `AlertDialog` until milestone 424, and it never was one: it has no
5//! actions, no barrier and no message type, and it sits in the flow rather than over it.
6//! The reference's `AlertDialog` is [`crate::AlertDialog`] now, and this kept the shape it
7//! always had under a name that describes it.
8
9use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
10use frus_layout::Style;
11
12use crate::interaction::Status;
13use crate::theme::Theme;
14use crate::widget::Widget;
15
16const PAD: f32 = 12.0;
17const ACCENT: f32 = 4.0;
18const ICON_W: f32 = 26.0;
19/// The icon glyph's size. **Geometry, not type**: it sits in a column of a fixed `ICON_W`.
20const ICON_SIZE: f32 = 16.0;
21
22/// The title's style: what the caller said, else what the theme says, else `titleMedium`.
23///
24/// The reference's *dialog* titles in `headlineSmall`, and this is not that dialog —
25/// [`crate::AlertDialog`] is, since milestone 424. This has the shape of the reference's
26/// banner: an accent bar, an icon and a tinted background, no actions and no barrier. The
27/// banner has no title at all, so the heading role at this scale is the nearest thing the
28/// reference says — a 24 px heading inside a 12 px box would not be.
29///
30/// **Resolved once**: the same number measures the box and draws the glyphs. Resolving is
31/// where the reader's font setting is applied, so measuring at the bare constant and
32/// painting through a style is a layout that disagrees with itself.
33fn title_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
34    over.or(theme.and_then(|t| t.widgets.alert.title_text_style))
35        .unwrap_or_else(|| crate::theme::type_scale(theme).title_medium)
36        .resolved()
37}
38
39/// The message's style — the reference's banner *and* its dialog both say `bodyMedium`.
40/// See [`title_style`].
41fn body_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
42    over.or(theme.and_then(|t| t.widgets.alert.content_text_style))
43        .unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
44        .resolved()
45}
46
47/// The nature of an alert box.
48#[derive(Copy, Clone, Debug, PartialEq, Eq)]
49pub enum AlertKind {
50    /// Neutral information.
51    Info,
52    /// Something went well.
53    Success,
54    /// Something that deserves attention, short of an error.
55    Warning,
56    /// Something went wrong.
57    Error,
58}
59
60/// A message box.
61pub struct Alert {
62    title: Option<String>,
63    text: String,
64    kind: AlertKind,
65    title_text_style: Option<TextStyle>,
66    content_text_style: Option<TextStyle>,
67}
68
69impl Alert {
70    /// Creates an informational box.
71    pub fn new(text: impl Into<String>) -> Self {
72        Self {
73            title: None,
74            text: text.into(),
75            kind: AlertKind::Info,
76            title_text_style: None,
77            content_text_style: None,
78        }
79    }
80
81    /// The heading's type, over the theme's and the reference's.
82    #[must_use]
83    pub fn title_text_style(mut self, style: TextStyle) -> Self {
84        self.title_text_style = Some(style);
85        self
86    }
87
88    /// The message's type, over the theme's and the reference's.
89    #[must_use]
90    pub fn content_text_style(mut self, style: TextStyle) -> Self {
91        self.content_text_style = Some(style);
92        self
93    }
94
95    /// The two styles this box is measured and drawn with.
96    fn styles(&self, theme: Option<&Theme>) -> (ResolvedTextStyle, ResolvedTextStyle) {
97        (
98            title_style(self.title_text_style, theme),
99            body_style(self.content_text_style, theme),
100        )
101    }
102
103    /// Adds a title.
104    pub fn title(mut self, title: impl Into<String>) -> Self {
105        self.title = Some(title.into());
106        self
107    }
108
109    /// The success variant.
110    pub fn success(mut self) -> Self {
111        self.kind = AlertKind::Success;
112        self
113    }
114
115    /// Variante avertissement.
116    pub fn warning(mut self) -> Self {
117        self.kind = AlertKind::Warning;
118        self
119    }
120
121    /// Variante erreur.
122    pub fn error(mut self) -> Self {
123        self.kind = AlertKind::Error;
124        self
125    }
126
127    fn accent(&self, theme: &Theme) -> Color {
128        match self.kind {
129            AlertKind::Info => theme.primary,
130            AlertKind::Success => Color::rgb8(70, 190, 120),
131            AlertKind::Warning => Color::rgb8(230, 170, 40),
132            AlertKind::Error => Color::rgb8(210, 96, 96),
133        }
134    }
135
136    fn icon(&self) -> &'static str {
137        match self.kind {
138            AlertKind::Info => "i",
139            AlertKind::Success => "✓",
140            AlertKind::Warning => "!",
141            AlertKind::Error => "×",
142        }
143    }
144}
145
146impl<Msg> Widget<Msg> for Alert {
147    fn style(&self) -> Style {
148        // A paragraph: free dimensions, the size comes from `measure()` — the
149        // message wraps to the width the parent offers.
150        Style::default()
151    }
152
153    fn measure(&self, theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
154        let text = self.text.clone();
155        let title = self.title.clone();
156        let (title_s, body_s) = self.styles(Some(theme));
157        Some(Box::new(move |max_width, _| {
158            let chrome = ACCENT + ICON_W + PAD; // bar + icon + right margin
159            let text_avail = max_width.map(|w| (w - chrome).max(40.0));
160            let body = frus_text::measure_wrapped_resolved(&text, &body_s, text_avail);
161            let title_h = title
162                .as_ref()
163                .map(|_| title_s.line_height() + 4.0)
164                .unwrap_or(0.0);
165            let title_w = title
166                .as_ref()
167                .map(|t| frus_text::measure_resolved(t, &title_s).width)
168                .unwrap_or(0.0);
169            let natural_w = chrome + body.width.max(title_w);
170            frus_core::Size::new(
171                max_width.map_or(natural_w, |w| w.min(natural_w)).ceil(),
172                (PAD * 2.0 + title_h + body.height).ceil(),
173            )
174        }))
175    }
176
177    fn measure_key(&self, theme: &Theme) -> Option<u64> {
178        use std::hash::{Hash, Hasher};
179        let mut hasher = std::collections::hash_map::DefaultHasher::new();
180        self.text.hash(&mut hasher);
181        self.title.hash(&mut hasher);
182        // The **resolved** styles, because the theme now has a say in the measurement and
183        // a key that ignores half its inputs is a stale layout waiting for a theme swap.
184        let (title_s, body_s) = self.styles(Some(theme));
185        for style in [title_s, body_s] {
186            style.size.to_bits().hash(&mut hasher);
187            style.weight.to_u16().hash(&mut hasher);
188            style.italic.hash(&mut hasher);
189            style.height.map(f32::to_bits).hash(&mut hasher);
190            style.family.hash(&mut hasher);
191        }
192        Some(hasher.finish())
193    }
194
195    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
196        &[]
197    }
198
199    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
200        let o = status.opacity;
201        let accent = self.accent(theme);
202        // Tinted background + a discreet border.
203        scene.draw_rect(
204            bounds,
205            accent.fade(0.12 * o),
206            theme.radius,
207            1.0,
208            accent.fade(0.4 * o),
209        );
210        // Accent bar on the left.
211        scene.draw_rect(
212            Rect::new(bounds.x, bounds.y, ACCENT, bounds.height),
213            accent.fade(o),
214            0.0,
215            0.0,
216            Color::TRANSPARENT,
217        );
218        // The glyph standing in for an icon. `exact`, not resolved: it sits in a column
219        // of a fixed `ICON_W`, and a reader who doubles the type would push it out.
220        scene.text(
221            Point::new(bounds.x + ACCENT + 7.0, bounds.y + PAD),
222            self.icon().to_string(),
223            &ResolvedTextStyle::exact(ICON_SIZE),
224            accent.fade(o),
225        );
226        let text_x = bounds.x + ACCENT + ICON_W;
227        // The message wraps to the width actually available (the laid-out one),
228        // to stay consistent with `measure()`.
229        let wrap_w = (bounds.width - (ACCENT + ICON_W + PAD)).max(40.0);
230        let (title_s, body_s) = self.styles(Some(theme));
231        match &self.title {
232            Some(title) => {
233                scene.text(
234                    Point::new(text_x, bounds.y + PAD),
235                    title.clone(),
236                    &title_s,
237                    theme.on_surface.fade(o),
238                );
239                scene.text_wrapped(
240                    Point::new(text_x, bounds.y + PAD + title_s.line_height() + 4.0),
241                    self.text.clone(),
242                    &body_s,
243                    theme.muted.fade(o),
244                    wrap_w,
245                );
246            }
247            None => {
248                scene.text_wrapped(
249                    Point::new(text_x, bounds.y + PAD),
250                    self.text.clone(),
251                    &body_s,
252                    theme.on_surface.fade(o),
253                    wrap_w,
254                );
255            }
256        }
257    }
258
259    fn on_click(&self) -> Option<Msg> {
260        None
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use frus_core::Primitive;
268
269    #[test]
270    fn paints_accent_and_text() {
271        let alert = Alert::new("Attention !").title("Alerte").warning();
272        let mut scene = Scene::new();
273        Widget::<()>::paint(
274            &alert,
275            Rect::new(0.0, 0.0, 240.0, 60.0),
276            Status::default(),
277            &Theme::default(),
278            &mut scene,
279        );
280        let warn = Color::rgb8(230, 170, 40);
281        // The accent bar (the variant's solid color) is present.
282        assert!(scene
283            .primitives()
284            .iter()
285            .any(|p| matches!(p, Primitive::Rect { color, .. } if *color == warn)));
286        // Title + text are painted.
287        assert!(scene
288            .primitives()
289            .iter()
290            .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Alerte")));
291        assert!(scene
292            .primitives()
293            .iter()
294            .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Attention !")));
295    }
296
297    /// The message **wraps** to the offered width: narrower → taller (and never
298    /// wider than the offer) — no more box overflowing its parent.
299    #[test]
300    fn message_wraps_to_the_offered_width() {
301        let alert = Alert::new("Press Enter to add a task; swipe from the left edge to go back.")
302            .title("Tip");
303        let theme = Theme::default();
304        let measure = Widget::<()>::measure(&alert, &theme).expect("a measure closure");
305        let free = measure(None, None);
306        let narrow = measure(Some(280.0), None);
307        assert!(
308            narrow.width <= 280.0,
309            "clamped to the offer ({})",
310            narrow.width
311        );
312        assert!(narrow.height > free.height, "wrapped → taller");
313        // The measure key follows the content (the relayout cache).
314        let other = Alert::new("short");
315        assert_ne!(
316            Widget::<()>::measure_key(&alert, &theme),
317            Widget::<()>::measure_key(&other, &theme)
318        );
319    }
320}