Skip to main content

denise_ui/widgets/
alert.rs

1//! A coloured banner with a message.
2
3use alloc::string::{String, ToString};
4
5use denise::Pen;
6use denise::{Point, Radius, Role};
7use denise_text::{TextEngine, TextStyle};
8
9use crate::widget::{MeasureCtx, Measured, Offer, PaintCtx, Widget};
10use crate::widgets::describe::{
11    Describe, DynDescribe, Group, Mismatch, Property, PropertyKind, ROLES, Value,
12};
13use crate::widgets::style::interactive_pair;
14
15/// An inline banner: `Info`, `Success`, `Warning` or `Error`, with a message.
16///
17/// Not interactive, not focusable, not a tab stop. It sits *in* the layout, in
18/// the place the thing it is about would be.
19///
20/// ```
21/// # use denise_ui::Alert;
22/// # use denise::theme::Role;
23/// Alert::new(Role::Success, "Lagret").with_icon('✓');
24/// Alert::new(Role::Error, "Kunne ikke lagre: disken er full");
25/// ```
26///
27/// # This is a banner, not a dialog
28///
29/// Worth being explicit, because the word covers both. A *dialog* — something
30/// that takes over, dims what is behind it and demands an answer — is
31/// [`Ui::push_scene`](crate::Ui::push_scene), which already exists and already
32/// dims and captures input. This is the strip of colour that reports something
33/// happened.
34///
35/// Neither one opens a window. Denise is a single [`Surface`](denise::Surface):
36/// on `denise-drm` there is no window system to open one in, and `denise-win32`,
37/// `denise-macos` and `denise-activex` are *embedded* — the host owns the window
38/// and Denise owns one rectangle inside it, so a control that spawned a
39/// top-level window would escape its host's modality and outlive the dialog that
40/// owns it.
41///
42/// An application that wants a native message box on a desktop build should call
43/// the platform for one. It knows which build it is; the toolkit would have to
44/// guess. That is the same conclusion the backend choice reached.
45///
46/// # Sizing
47///
48/// [`preferred_height`](Alert::preferred_height) reports what the message needs
49/// once wrapped to a width — the query convention every sizable widget here
50/// uses, called by the application and never by the tree.
51#[derive(Clone, Debug)]
52pub struct Alert {
53    text: String,
54    icon: Option<char>,
55    role: Role,
56    style: TextStyle,
57}
58
59impl Alert {
60    /// A banner in `role` carrying `text`.
61    ///
62    /// `text` may contain `\n`, and is wrapped to the width it is given.
63    pub fn new(role: Role, text: impl Into<String>) -> Self {
64        Self {
65            text: text.into(),
66            icon: None,
67            role,
68            style: TextStyle::built_in(16),
69        }
70    }
71
72    /// Puts a character in front of the message.
73    ///
74    /// A `char`, not an icon set. There is no icon story in this toolkit and
75    /// inventing one inside a banner would be the wrong place to start; whether
76    /// `✓` or `⚠` actually draws depends on the font in use, and the built-in
77    /// bitmap font has Latin and `æøå` and nothing else. `!` and `i` always work.
78    pub fn with_icon(mut self, icon: char) -> Self {
79        self.icon = Some(icon);
80        self
81    }
82
83    /// Sets the message's font and size.
84    pub fn with_style(mut self, style: TextStyle) -> Self {
85        self.style = style;
86        self
87    }
88
89    /// The current message.
90    #[inline]
91    pub fn text(&self) -> &str {
92        &self.text
93    }
94
95    /// Replaces the message.
96    pub fn set_text(&mut self, text: impl Into<String>) {
97        self.text = text.into();
98    }
99
100    /// Replaces the message, reporting whether it actually changed.
101    pub fn update(&mut self, text: &str) -> bool {
102        let changed = self.text != text;
103        if changed {
104            self.text = text.to_string();
105        }
106        changed
107    }
108
109    /// Replaces the role.
110    pub fn set_role(&mut self, role: Role) {
111        self.role = role;
112    }
113
114    /// The current role.
115    #[inline]
116    pub const fn role(&self) -> Role {
117        self.role
118    }
119
120    /// Replaces the leading character, or removes it.
121    pub fn set_icon(&mut self, icon: Option<char>) {
122        self.icon = icon;
123    }
124
125    /// Height this banner needs for its message wrapped to `width`.
126    pub fn preferred_height(&self, engine: &mut TextEngine, width: i32) -> i32 {
127        let inset = padding(self.style.size_px);
128        // Computed first: `wrapped_height` takes the engine mutably too.
129        let available = self.text_width(engine, width);
130        engine.wrapped_height(self.style, &self.text, available) + inset * 2
131    }
132
133    /// Space the message has after the padding and any icon.
134    fn text_width(&self, engine: &mut TextEngine, width: i32) -> i32 {
135        let inset = padding(self.style.size_px);
136        let icon = self.icon_width(engine);
137        (width - inset * 2 - icon).max(1)
138    }
139
140    /// Width the icon and its gap occupy, or zero when there is none.
141    fn icon_width(&self, engine: &mut TextEngine) -> i32 {
142        let Some(icon) = self.icon else {
143            return 0;
144        };
145        let mut buffer = [0u8; 4];
146        let glyph = icon.encode_utf8(&mut buffer);
147        engine.measure_line(self.style, glyph) + padding(self.style.size_px)
148    }
149}
150
151/// Space between the message and the edge, on one side.
152#[inline]
153const fn padding(size_px: u16) -> i32 {
154    let half = size_px as i32 / 2;
155    if half < 4 { 4 } else { half }
156}
157
158impl<M: 'static> Widget<M> for Alert {
159    fn describe(&self) -> Option<&dyn DynDescribe> {
160        Some(self)
161    }
162
163    fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
164        Some(self)
165    }
166    fn measure(&self, ctx: &mut MeasureCtx<'_>, offered: Offer) -> Measured {
167        // Height for a width, and no answer without one: wrapped text has no
168        // height until it knows what it wraps to. A banner is as wide as you
169        // make it, so there is no width to offer back.
170        Measured {
171            width: None,
172            height: offered.width.map(|w| self.preferred_height(ctx.text, w)),
173        }
174    }
175
176    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
177        let bounds = ctx.bounds;
178        if bounds.is_empty() {
179            return;
180        }
181        // Both colours from one pairing, which is the whole reason this is a
182        // widget rather than a `Panel` and a `Label`: a caller assembling it by
183        // hand reaches for `BaseContent` and gets warning-coloured text nobody
184        // can read on a warning-coloured background.
185        let (fill, content) = interactive_pair(ctx.theme, self.role, ctx.state);
186        canvas.fill_rounded_rect(bounds, ctx.theme.radius(Radius::Box), fill);
187
188        let inset = padding(self.style.size_px);
189        let line_height = ctx.text.line_height(self.style);
190        let mut x = bounds.x + inset;
191
192        if let Some(icon) = self.icon {
193            let mut buffer = [0u8; 4];
194            let glyph = icon.encode_utf8(&mut buffer);
195            let width = ctx.text.measure_line(self.style, glyph);
196            ctx.text.draw(
197                canvas,
198                self.style,
199                Point::new(x, bounds.y + inset),
200                glyph,
201                content,
202            );
203            x += width + inset;
204        }
205
206        let available = (bounds.right() - inset - x).max(1);
207        // Collected because `wrap` borrows the engine and drawing needs it again.
208        // The lines borrow `self.text`, so only the slice headers are copied.
209        let lines: alloc::vec::Vec<&str> = ctx.text.wrap(self.style, &self.text, available);
210        for (index, line) in lines.iter().enumerate() {
211            let y = bounds.y + inset + index as i32 * line_height;
212            if y >= bounds.bottom() {
213                // More message than banner. Clipped rather than drawn over
214                // whatever is below, which the tree would do for us anyway —
215                // stopping here just saves the glyph work.
216                break;
217            }
218            ctx.text
219                .draw(canvas, self.style, Point::new(x, y), line, content);
220        }
221    }
222}
223
224impl Describe for Alert {
225    const KIND: &'static str = "alert";
226    const DOC: &'static str =
227        "A coloured banner saying something happened, in the place it happened.";
228    const GROUP: Group = Group::Display;
229    const ICON: &'static denise::icon::Icon = &super::icons::ALERT;
230
231    const PROPERTIES: &'static [Property] = &[
232        Property::new("text", PropertyKind::Text, "The message."),
233        Property::new(
234            "role",
235            PropertyKind::Enum(ROLES),
236            "The status this banner reports; an alert with no status is a label.",
237        ),
238        Property::new(
239            "icon",
240            PropertyKind::Text,
241            "A single character drawn before the text.",
242        ),
243        Property::new(
244            "size",
245            PropertyKind::Int { min: 6, max: 96 },
246            "Text size in logical pixels.",
247        )
248        .in_pixels(),
249    ];
250
251    fn get(&self, name: &str) -> Option<Value> {
252        Some(match name {
253            "text" => Value::text(self.text.as_str()),
254            "role" => Value::role(self.role),
255            // A banner without an icon has nothing to report, which is what
256            // keeps `icon` out of a file that never set one.
257            "icon" => Value::Text(self.icon?.to_string()),
258            "size" => Value::Int(i32::from(self.style.size_px)),
259            _ => return None,
260        })
261    }
262
263    fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
264        match name {
265            "text" => self.text = value.as_text()?,
266            "role" => self.role = value.as_role()?,
267            // The field holds one character, so a longer string keeps its
268            // first: the property is described as a single character and a
269            // banner is not the place to reject a form over a stray one. An
270            // empty string removes the icon, which is the only way a file has
271            // of saying so.
272            "icon" => self.icon = value.as_text()?.chars().next(),
273            "size" => self.style.size_px = value.as_size()?,
274            _ => return Err(Mismatch::Unknown),
275        }
276        Ok(())
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use denise::Theme;
284
285    use crate::widget::VisualState;
286
287    fn engine() -> TextEngine {
288        TextEngine::new()
289    }
290
291    /// The message wraps, so a long one is taller than a short one at the same
292    /// width. Without this the banner is a one-liner with a scrollbar it does
293    /// not have.
294    #[test]
295    fn a_longer_message_needs_a_taller_banner_at_the_same_width() {
296        let mut engine = engine();
297        let short = Alert::new(Role::Info, "Lagret").preferred_height(&mut engine, 200);
298        let long = Alert::new(
299            Role::Error,
300            "Kunne ikke lagre fordi disken er full og det er ingen plass igjen",
301        )
302        .preferred_height(&mut engine, 200);
303        assert!(long > short, "{long} is not taller than {short}");
304    }
305
306    /// And a wider banner needs fewer lines for the same message.
307    #[test]
308    fn a_wider_banner_needs_less_height_for_the_same_message() {
309        let mut engine = engine();
310        let alert = Alert::new(Role::Warning, "en to tre fire fem seks sju atte ni ti");
311        let narrow = alert.preferred_height(&mut engine, 120);
312        let wide = alert.preferred_height(&mut engine, 600);
313        assert!(narrow > wide, "narrow {narrow} should exceed wide {wide}");
314    }
315
316    /// An icon takes space from the message, so the same text in the same width
317    /// needs at least as much height with one as without.
318    #[test]
319    fn an_icon_takes_its_space_from_the_message() {
320        let mut engine = engine();
321        let text = "en to tre fire fem seks sju atte";
322        let bare = Alert::new(Role::Info, text).preferred_height(&mut engine, 160);
323        let iconed = Alert::new(Role::Info, text)
324            .with_icon('!')
325            .preferred_height(&mut engine, 160);
326        assert!(
327            iconed >= bare,
328            "an icon should not make the banner shorter: {iconed} < {bare}"
329        );
330        assert!(Alert::new(Role::Info, text).icon_width(&mut engine) == 0);
331        assert!(
332            Alert::new(Role::Info, text)
333                .with_icon('!')
334                .icon_width(&mut engine)
335                > 0
336        );
337    }
338
339    /// A width too small to hold anything must still leave the message a column
340    /// to wrap into, rather than a zero or negative one.
341    #[test]
342    fn an_absurdly_narrow_banner_still_leaves_a_column_for_the_text() {
343        let mut engine = engine();
344        for width in [-100, 0, 1, 5, 20] {
345            let alert = Alert::new(Role::Error, "feil").with_icon('!');
346            assert!(
347                alert.text_width(&mut engine, width) >= 1,
348                "width {width} left no room at all"
349            );
350            assert!(alert.preferred_height(&mut engine, width) > 0);
351        }
352    }
353
354    /// An empty message is still a banner with a line's worth of height, not a
355    /// zero-height sliver.
356    #[test]
357    fn an_empty_message_still_has_height() {
358        let mut engine = engine();
359        let height = Alert::new(Role::Info, "").preferred_height(&mut engine, 200);
360        assert!(height > 0);
361    }
362
363    /// A message written every cycle should repaint only when it changes.
364    #[test]
365    fn writing_the_same_message_reports_no_change() {
366        let mut alert = Alert::new(Role::Info, "Lagret");
367        assert!(!alert.update("Lagret"));
368        assert!(alert.update("Lagret kl. 12:01"));
369    }
370
371    /// The whole reason this is a widget rather than a `Panel` plus a `Label`:
372    /// every role's text has to stay readable on its own background, in every
373    /// theme. A caller assembling it by hand reaches for `BaseContent`.
374    #[test]
375    fn every_role_keeps_its_message_readable_in_every_theme() {
376        use denise::theme::{AA_LARGE, contrast_x100};
377
378        for theme in Theme::BUILT_IN {
379            for role in [Role::Info, Role::Success, Role::Warning, Role::Error] {
380                for state in [VisualState::NONE, VisualState::DISABLED] {
381                    let (fill, content) = interactive_pair(&theme, role, state);
382                    let ratio = contrast_x100(fill, content);
383                    assert!(
384                        ratio >= AA_LARGE,
385                        "{} {role:?} {state:?}: message on banner is {ratio}, floor \
386                         is {AA_LARGE}",
387                        theme.name
388                    );
389                }
390            }
391        }
392    }
393
394    /// A multi-byte icon must not be sliced when it is encoded for measurement.
395    #[test]
396    fn a_multi_byte_icon_survives_being_measured() {
397        let mut engine = engine();
398        for icon in ['!', 'æ', '✓', '⚠'] {
399            let alert = Alert::new(Role::Info, "melding").with_icon(icon);
400            assert!(
401                alert.icon_width(&mut engine) > 0,
402                "{icon} measured as nothing"
403            );
404        }
405    }
406}