Skip to main content

zest_widget/widget/
message_box.rs

1//! Modal dialog: a dimmed full-screen scrim with a centered card holding a
2//! title, body text, and a row of action buttons.
3//!
4//! Immediate-mode: the host owns visibility. Render a `MessageBox` only
5//! while the dialog should be shown (e.g. `if self.show_dialog { ... }`),
6//! and remove it from the tree to dismiss. Each button carries the message
7//! the host wants when it is tapped — typically a "dismiss" message that
8//! flips the host's visibility flag.
9//!
10//! ## Structure
11//!
12//! Built entirely from a [`Stack`](crate::widget::stack::Stack):
13//!
14//! 1. **Scrim** (bottom layer) — a full-bleed rect filled with the darkest
15//!    neutral (`palette.neutral_10`) so the content reads as dimmed behind
16//!    the modal (the renderer has no alpha). The scrim also
17//!    catches every touch that misses the card, so the widgets behind the
18//!    modal stay inert. A tap on the scrim optionally emits
19//!    [`on_dismiss`](MessageBox::on_dismiss).
20//! 2. **Card** (top layer) — a centered [`Column`] of the title, body, and a
21//!    [`Row`] of [`Button`]s,
22//!    painted over a panel rect. Pushed last so it draws on top of and is
23//!    touched before the scrim.
24//!
25//! The card is composed lazily on the first lifecycle call so the chainable
26//! builders only record configuration — the per-button messages and the
27//! optional dismiss message are stored until the stack is assembled.
28
29use super::{Widget, button::Button, column::Column, element::Element, row::Row, text::Text};
30use alloc::{string::String, vec::Vec};
31use core::marker::PhantomData;
32use embedded_graphics::{pixelcolor::PixelColor, prelude::*, primitives::Rectangle};
33use zest_core::{Constraints, Horizontal, Length, RenderError, Renderer, TouchPhase, Vertical};
34use zest_theme::Theme;
35
36/// Default card width in pixels.
37const CARD_W: u32 = 260;
38/// Inner padding of the card in pixels.
39const CARD_PAD: u32 = 12;
40/// Height of the action-button row in pixels.
41const BUTTON_H: u32 = 36;
42
43/// A modal dialog over a dimmed scrim. The host owns visibility; the card
44/// holds a title, body, and a row of action buttons, each emitting a
45/// host-supplied message.
46pub struct MessageBox<'a, C: PixelColor, M: Clone> {
47    title: String,
48    body: String,
49    buttons: Vec<(String, M)>,
50    on_dismiss: Option<M>,
51    width: Length,
52    height: Length,
53    /// Composed stack (scrim + card), built on first lifecycle call.
54    stack: Option<Element<'a, C, M>>,
55}
56
57impl<'a, C: PixelColor + 'a, M: Clone + 'a> MessageBox<'a, C, M> {
58    /// New empty modal. Fills its parent region (the scrim covers the
59    /// screen). Configure with the builders, then it composes itself into a
60    /// [`Stack`](crate::Stack).
61    pub fn new() -> Self {
62        Self {
63            title: String::new(),
64            body: String::new(),
65            buttons: Vec::new(),
66            on_dismiss: None,
67            width: Length::Fill,
68            height: Length::Fill,
69            stack: None,
70        }
71    }
72
73    /// The card title (drawn in the heading font).
74    #[must_use]
75    pub fn title(mut self, title: impl Into<String>) -> Self {
76        self.title = title.into();
77        self
78    }
79
80    /// The card body text.
81    #[must_use]
82    pub fn body(mut self, body: impl Into<String>) -> Self {
83        self.body = body.into();
84        self
85    }
86
87    /// Append an action button. Repeatable — buttons are laid out
88    /// left-to-right in a row at the bottom of the card. The given message
89    /// is emitted when that button is tapped.
90    #[must_use]
91    pub fn button(mut self, label: impl Into<String>, message: M) -> Self {
92        self.buttons.push((label.into(), message));
93        self
94    }
95
96    /// Message emitted when the scrim (the area outside the card)
97    /// is tapped. Without it, taps outside the card are swallowed but emit
98    /// nothing (the modal stays open until a button is pressed).
99    #[must_use]
100    pub fn on_dismiss(mut self, message: M) -> Self {
101        self.on_dismiss = Some(message);
102        self
103    }
104
105    /// Width sizing intent of the whole modal region (default
106    /// [`Length::Fill`]).
107    #[must_use]
108    pub fn width(mut self, width: impl Into<Length>) -> Self {
109        self.width = width.into();
110        self
111    }
112
113    /// Height sizing intent of the whole modal region (default
114    /// [`Length::Fill`]).
115    #[must_use]
116    pub fn height(mut self, height: impl Into<Length>) -> Self {
117        self.height = height.into();
118        self
119    }
120
121    /// Compose the scrim and card into a [`Stack`](crate::Stack), draining
122    /// the recorded config so each per-button message is owned once.
123    fn build(&mut self) -> Element<'a, C, M> {
124        // --- card body: title + body + button row -----------------------
125        let mut card = Column::new()
126            .spacing(8)
127            .width(Length::Fill)
128            .height(Length::Shrink);
129
130        card = card.push(
131            Text::new(self.title.clone())
132                .align_x(Horizontal::Center)
133                .height(Length::Shrink),
134        );
135        card = card.push(
136            Text::new(self.body.clone())
137                .align_x(Horizontal::Center)
138                .height(Length::Shrink),
139        );
140
141        let mut button_row = Row::new()
142            .spacing(8)
143            .width(Length::Fill)
144            .height(Length::Fixed(BUTTON_H));
145        for (label, msg) in core::mem::take(&mut self.buttons) {
146            button_row = button_row.push(Button::new(label).on_press(msg));
147        }
148        card = card.push(button_row);
149
150        // The panel behind the card content: a padded rect with a filled
151        // background so the text/buttons read against the scrim. Sizes to a
152        // fixed width and to its content height.
153        let panel = MessageCard {
154            rect: Rectangle::zero(),
155            inner: Element::new(card),
156            card_width: Length::Fixed(CARD_W),
157            card_height: Length::Shrink,
158            _color: PhantomData,
159        };
160
161        // --- assemble: scrim (bottom) + centered card (top) -------------
162        let scrim = Scrim {
163            rect: Rectangle::zero(),
164            on_dismiss: self.on_dismiss.take(),
165            _color: PhantomData,
166        };
167
168        let stack = super::stack::Stack::new()
169            .width(self.width)
170            .height(self.height)
171            .push_aligned(scrim, Horizontal::Left, Vertical::Top)
172            .push_aligned(panel, Horizontal::Center, Vertical::Center);
173
174        Element::new(stack)
175    }
176
177    fn ensure_built(&mut self) {
178        if self.stack.is_none() {
179            self.stack = Some(self.build());
180        }
181    }
182}
183
184impl<'a, C: PixelColor + 'a, M: Clone + 'a> Default for MessageBox<'a, C, M> {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190impl<'a, C: PixelColor + 'a, M: Clone + 'a> Widget<C, M> for MessageBox<'a, C, M> {
191    fn measure(&mut self, constraints: Constraints) -> Size {
192        self.ensure_built();
193        self.stack
194            .as_mut()
195            .map_or(Size::zero(), |s| s.measure(constraints))
196    }
197
198    fn preferred_size(&self) -> (Length, Length) {
199        (self.width, self.height)
200    }
201
202    fn arrange(&mut self, rect: Rectangle) {
203        self.ensure_built();
204        if let Some(stack) = self.stack.as_mut() {
205            stack.arrange(rect);
206        }
207    }
208
209    fn rect(&self) -> Rectangle {
210        self.stack.as_ref().map_or(Rectangle::zero(), |s| s.rect())
211    }
212
213    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
214        self.stack
215            .as_mut()
216            .and_then(|s| s.handle_touch(point, phase))
217    }
218
219    fn mark_pressed(&mut self, point: Point) {
220        if let Some(stack) = self.stack.as_mut() {
221            stack.mark_pressed(point);
222        }
223    }
224
225    fn draw<'t>(
226        &self,
227        renderer: &mut dyn Renderer<C>,
228        theme: &Theme<'t, C>,
229    ) -> Result<(), RenderError> {
230        if let Some(stack) = &self.stack {
231            stack.draw(renderer, theme)?;
232        }
233        Ok(())
234    }
235}
236
237// ---- internal: the dimming scrim -------------------------------------------
238
239/// Full-bleed dimming layer. Paints a solid tint over the whole region and
240/// catches every touch that reaches it (i.e. anything that missed the card),
241/// optionally emitting a dismiss message.
242struct Scrim<C: PixelColor, M: Clone> {
243    rect: Rectangle,
244    on_dismiss: Option<M>,
245    _color: PhantomData<C>,
246}
247
248impl<C: PixelColor, M: Clone> Scrim<C, M> {
249    fn hit_test(&self, point: Point) -> bool {
250        let tl = self.rect.top_left;
251        let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
252        point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
253    }
254}
255
256impl<C: PixelColor, M: Clone> Widget<C, M> for Scrim<C, M> {
257    fn measure(&mut self, constraints: Constraints) -> Size {
258        constraints.max
259    }
260
261    fn preferred_size(&self) -> (Length, Length) {
262        (Length::Fill, Length::Fill)
263    }
264
265    fn arrange(&mut self, rect: Rectangle) {
266        self.rect = rect;
267    }
268
269    fn rect(&self) -> Rectangle {
270        self.rect
271    }
272
273    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
274        // Swallow all touches over the modal region; only emit on release.
275        if !self.hit_test(point) {
276            return None;
277        }
278        match phase {
279            TouchPhase::Up => self.on_dismiss.clone(),
280            _ => None,
281        }
282    }
283
284    fn draw<'t>(
285        &self,
286        renderer: &mut dyn Renderer<C>,
287        theme: &Theme<'t, C>,
288    ) -> Result<(), RenderError> {
289        // Opaque dimming tint — the renderer has no alpha.
290        renderer.fill_rect(self.rect, theme.palette.neutral_10)?;
291        Ok(())
292    }
293}
294
295// ---- internal: the card panel ----------------------------------------------
296
297/// The card panel: a filled, bordered rect with padding around its inner
298/// content (the title/body/button column). Sizes to its content height.
299struct MessageCard<'a, C: PixelColor, M: Clone> {
300    rect: Rectangle,
301    inner: Element<'a, C, M>,
302    card_width: Length,
303    card_height: Length,
304    _color: PhantomData<C>,
305}
306
307impl<'a, C: PixelColor + 'a, M: Clone + 'a> Widget<C, M> for MessageCard<'a, C, M> {
308    fn measure(&mut self, constraints: Constraints) -> Size {
309        let pad2 = CARD_PAD * 2;
310        let inner_c = constraints.shrink(pad2, pad2);
311        let inner = self.inner.measure(inner_c);
312        let w = self.card_width.resolve(CARD_W, constraints.max.width);
313        let h = self
314            .card_height
315            .resolve(inner.height + pad2, constraints.max.height);
316        constraints.clamp(Size::new(w, h))
317    }
318
319    fn preferred_size(&self) -> (Length, Length) {
320        (self.card_width, self.card_height)
321    }
322
323    fn arrange(&mut self, rect: Rectangle) {
324        self.rect = rect;
325        let pad = CARD_PAD as i32;
326        let inner_rect = Rectangle::new(
327            rect.top_left + Point::new(pad, pad),
328            Size::new(
329                rect.size.width.saturating_sub(CARD_PAD * 2),
330                rect.size.height.saturating_sub(CARD_PAD * 2),
331            ),
332        );
333        self.inner.arrange(inner_rect);
334    }
335
336    fn rect(&self) -> Rectangle {
337        self.rect
338    }
339
340    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
341        self.inner.handle_touch(point, phase)
342    }
343
344    fn mark_pressed(&mut self, point: Point) {
345        self.inner.mark_pressed(point);
346    }
347
348    fn draw<'t>(
349        &self,
350        renderer: &mut dyn Renderer<C>,
351        theme: &Theme<'t, C>,
352    ) -> Result<(), RenderError> {
353        renderer.fill_rect(self.rect, theme.primary.base)?;
354        renderer.stroke_rect(self.rect, theme.background.divider)?;
355        self.inner.draw(renderer, theme)?;
356        Ok(())
357    }
358}