Skip to main content

rlvgl_widgets/
message_box.rs

1// SPDX-License-Identifier: MIT
2//! LVGL-parity message-box widget (LPAR-14 §5.H).
3//!
4//! [`MessageBox`] presents a modal-style dialog with:
5//!
6//! * a **header** area containing the title text,
7//! * a **content** area with the body message (greedy-wrapped), and
8//! * a **footer** area with a [`ButtonMatrix`] populated from the caller's
9//!   button labels.
10//!
11//! # Layout
12//!
13//! `set_bounds` divides the total height into three horizontal bands:
14//!
15//! ```text
16//! ┌──────────────────────┐  ← header top
17//! │  Title               │  } HEADER_HEIGHT_PX
18//! ├──────────────────────┤
19//! │  Body message        │  } remaining height − FOOTER_HEIGHT_PX
20//! │  (wrapped)           │
21//! ├──────────────────────┤
22//! │  [ OK ]  [ Cancel ]  │  } FOOTER_HEIGHT_PX
23//! └──────────────────────┘
24//! ```
25//!
26//! # Close semantics
27//!
28//! `close()` records that the dialog should be dismissed and returns the
29//! currently active button ID.  It does **not** remove the widget from a tree
30//! or manipulate z-order (LPAR-14 §5.H v1 scope); the application is
31//! responsible for detaching it.
32//!
33//! # Coexistence
34//!
35//! This widget is **not** a rename of `ui::Modal` or `ui::Alert`.  It is the
36//! LVGL `lv_msgbox` parity widget, distinct from the simpler `ui`-layer
37//! dialog primitives.
38
39use alloc::string::String;
40use alloc::vec::Vec;
41use rlvgl_core::draw::draw_widget_bg;
42use rlvgl_core::event::Event;
43use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr, wrap_greedy_ltr};
44use rlvgl_core::renderer::{ClipRenderer, Renderer};
45use rlvgl_core::style::Style;
46use rlvgl_core::widget::{Color, Rect, Widget};
47
48use crate::button_matrix::{BUTTON_NONE, ButtonId, ButtonMatrix};
49
50/// Pixel height reserved for the header (title) band.
51const HEADER_HEIGHT_PX: i32 = 32;
52/// Pixel height reserved for the footer (button) band.
53const FOOTER_HEIGHT_PX: i32 = 40;
54
55/// LVGL-parity message-box dialog (LPAR-14 §5.H).
56///
57/// Combines a title header, a wrapped body message, and a button row into a
58/// single widget.  The button row is implemented as an internal
59/// [`ButtonMatrix`].
60///
61/// # Button IDs
62///
63/// Buttons are assigned sequential [`ButtonId`] values in the order supplied
64/// to [`new`](Self::new).  Use those IDs with [`active_button`] and
65/// [`activate_button`].
66///
67/// [`active_button`]: Self::active_button
68pub struct MessageBox {
69    bounds: Rect,
70    title: String,
71    text: String,
72    /// Internal button-row.
73    buttons: ButtonMatrix,
74    /// The button that was most recently activated (or `BUTTON_NONE`).
75    active_button: ButtonId,
76    /// Whether `close()` has been called.
77    closed: bool,
78    /// Style for the outer container.
79    pub style: Style,
80    /// Text colour for the title and body.
81    pub text_color: Color,
82    /// Text colour for the button labels (forwarded to the inner matrix).
83    pub button_text_color: Color,
84    /// Font assignment for this widget (FONT-00 §5); resolves to `FONT_6X10`
85    /// when unset.
86    font: WidgetFont,
87}
88
89impl MessageBox {
90    /// Create a [`MessageBox`] with the given `bounds`, `title`, body `text`,
91    /// and button `labels`.
92    ///
93    /// `buttons` is a slice of label strings.  An empty slice creates a
94    /// message-box with no footer buttons.
95    pub fn new(bounds: Rect, title: &str, text: &str, buttons: &[&str]) -> Self {
96        let footer_rect = Self::footer_rect_for(bounds);
97        let mut bm = ButtonMatrix::new(footer_rect);
98
99        // Build a flat one-row map from the button labels.
100        let map: Vec<&str> = buttons.to_vec();
101        if !map.is_empty() {
102            bm.set_map(&map);
103        }
104
105        Self {
106            bounds,
107            title: String::from(title),
108            text: String::from(text),
109            buttons: bm,
110            active_button: BUTTON_NONE,
111            closed: false,
112            style: Style::default(),
113            text_color: Color(0, 0, 0, 255),
114            button_text_color: Color(50, 50, 50, 255),
115            font: WidgetFont::new(),
116        }
117    }
118
119    /// Assign the font used to render this widget (FONT-00 §5); resolves to
120    /// `FONT_6X10` when unset.
121    pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
122        self.font.set(font);
123    }
124
125    // ── Title / text ──────────────────────────────────────────────────────────
126
127    /// Return the current title string.
128    pub fn title(&self) -> &str {
129        &self.title
130    }
131
132    /// Replace the title string.
133    pub fn set_title(&mut self, title: &str) {
134        self.title = String::from(title);
135    }
136
137    /// Return the current body text.
138    pub fn text(&self) -> &str {
139        &self.text
140    }
141
142    /// Replace the body text.
143    pub fn set_text(&mut self, text: &str) {
144        self.text = String::from(text);
145    }
146
147    // ── Button state ──────────────────────────────────────────────────────────
148
149    /// Return the most-recently activated button, or [`BUTTON_NONE`] if no
150    /// button has been activated yet.
151    pub fn active_button(&self) -> ButtonId {
152        self.active_button
153    }
154
155    /// Programmatically activate button `id`, recording it as the active
156    /// button.  Equivalent to the user pressing a button.
157    pub fn activate_button(&mut self, id: ButtonId) {
158        let count = self.buttons.button_count();
159        if id != BUTTON_NONE && (id.0 as usize) < count {
160            self.active_button = id;
161        }
162    }
163
164    /// Return the label of button `id`, or `None` if `id` is out of range.
165    pub fn button_text(&self, id: ButtonId) -> Option<&str> {
166        self.buttons.button_text(id)
167    }
168
169    /// Return the total number of buttons in the footer row.
170    pub fn button_count(&self) -> usize {
171        self.buttons.button_count()
172    }
173
174    // ── Close ─────────────────────────────────────────────────────────────────
175
176    /// Signal that the dialog should be dismissed.
177    ///
178    /// Records `closed = true` and returns the currently
179    /// [`active_button`](Self::active_button).  The caller must detach or hide
180    /// the widget; no z-order or tree manipulation is performed.
181    pub fn close(&mut self) -> ButtonId {
182        self.closed = true;
183        self.active_button
184    }
185
186    /// Return `true` after [`close`](Self::close) has been called.
187    pub fn is_closed(&self) -> bool {
188        self.closed
189    }
190
191    // ── Layout helpers ────────────────────────────────────────────────────────
192
193    /// Compute the header rect from the outer bounds.
194    fn header_rect(&self) -> Rect {
195        Rect {
196            x: self.bounds.x,
197            y: self.bounds.y,
198            width: self.bounds.width,
199            height: HEADER_HEIGHT_PX.min(self.bounds.height),
200        }
201    }
202
203    /// Compute the content rect from the outer bounds.
204    fn content_rect(&self) -> Rect {
205        let top = self.bounds.y + HEADER_HEIGHT_PX;
206        let bottom = self.bounds.y + self.bounds.height - FOOTER_HEIGHT_PX;
207        Rect {
208            x: self.bounds.x,
209            y: top,
210            width: self.bounds.width,
211            height: (bottom - top).max(0),
212        }
213    }
214
215    fn footer_rect_for(bounds: Rect) -> Rect {
216        let top = bounds.y + bounds.height - FOOTER_HEIGHT_PX;
217        Rect {
218            x: bounds.x,
219            y: top.max(bounds.y),
220            width: bounds.width,
221            height: FOOTER_HEIGHT_PX.min(bounds.height),
222        }
223    }
224
225    // ── Rendering helpers ─────────────────────────────────────────────────────
226
227    /// Draw `text` inside `clip_rect`, wrapping to fit the width.
228    fn draw_wrapped_text(&self, renderer: &mut dyn Renderer, text: &str, clip: Rect) {
229        let font = self.font.resolve();
230        let lm = font.line_metrics();
231        let line_h = lm.line_height as i32;
232        let wrapped = wrap_greedy_ltr(font, text, clip.width, 0, 0);
233        let mut clip_r = ClipRenderer::new(renderer, clip);
234        for (i, wl) in wrapped.lines.iter().enumerate() {
235            let baseline = clip.y + i as i32 * line_h + lm.ascent as i32;
236            if baseline > clip.y + clip.height {
237                break;
238            }
239            let segment = &text[wl.start..wl.end];
240            if segment.is_empty() {
241                continue;
242            }
243            let shaped = shape_text_ltr(font, segment, (clip.x, baseline), 0);
244            clip_r.draw_text_shaped(&shaped, (0, 0), self.text_color);
245        }
246    }
247}
248
249impl Widget for MessageBox {
250    fn bounds(&self) -> Rect {
251        self.bounds
252    }
253
254    fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
255        Some(&mut self.font)
256    }
257
258    fn set_bounds(&mut self, new_bounds: Rect) {
259        self.bounds = new_bounds;
260        self.buttons.set_bounds(Self::footer_rect_for(new_bounds));
261    }
262
263    fn draw(&self, renderer: &mut dyn Renderer) {
264        // Outer background.
265        draw_widget_bg(renderer, self.bounds, &self.style);
266
267        // Header — title text.
268        let header = self.header_rect();
269        let font = self.font.resolve();
270        let lm = font.line_metrics();
271        let title_baseline =
272            header.y + lm.ascent as i32 + (HEADER_HEIGHT_PX - lm.line_height as i32) / 2;
273        if !self.title.is_empty() {
274            let shaped = shape_text_ltr(font, &self.title, (header.x, title_baseline), 0);
275            let mut clip_r = ClipRenderer::new(renderer, header);
276            clip_r.draw_text_shaped(&shaped, (0, 0), self.text_color);
277        }
278
279        // Content — wrapped body text.
280        let content = self.content_rect();
281        if !self.text.is_empty() {
282            self.draw_wrapped_text(renderer, &self.text, content);
283        }
284
285        // Footer — button matrix.
286        self.buttons.draw(renderer);
287    }
288
289    fn handle_event(&mut self, event: &Event) -> bool {
290        // Forward pointer/key events to the button matrix.
291        if self.buttons.handle_event(event) {
292            // If a button was activated inside the matrix, record it.
293            let pressed = self.buttons.selected_button();
294            if pressed != BUTTON_NONE {
295                self.active_button = pressed;
296            }
297            return true;
298        }
299        false
300    }
301}
302
303// ---------------------------------------------------------------------------
304// Tests
305// ---------------------------------------------------------------------------
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use rlvgl_core::widget::Rect;
311
312    const BOUNDS: Rect = Rect {
313        x: 0,
314        y: 0,
315        width: 300,
316        height: 200,
317    };
318
319    // ── Constructor ───────────────────────────────────────────────────────────
320
321    #[test]
322    fn new_sets_title_and_text() {
323        let mb = MessageBox::new(BOUNDS, "Alert", "Something happened", &["OK", "Cancel"]);
324        assert_eq!(mb.title(), "Alert");
325        assert_eq!(mb.text(), "Something happened");
326    }
327
328    #[test]
329    fn button_count_matches_slice() {
330        let mb = MessageBox::new(BOUNDS, "T", "M", &["Yes", "No", "Maybe"]);
331        assert_eq!(mb.button_count(), 3);
332    }
333
334    #[test]
335    fn button_labels_are_accessible() {
336        let mb = MessageBox::new(BOUNDS, "T", "M", &["OK", "Cancel"]);
337        assert_eq!(mb.button_text(ButtonId(0)), Some("OK"));
338        assert_eq!(mb.button_text(ButtonId(1)), Some("Cancel"));
339        assert_eq!(mb.button_text(ButtonId(2)), None);
340    }
341
342    #[test]
343    fn no_buttons_is_valid() {
344        let mb = MessageBox::new(BOUNDS, "Info", "No action needed", &[]);
345        assert_eq!(mb.button_count(), 0);
346    }
347
348    // ── Title / text mutation ─────────────────────────────────────────────────
349
350    #[test]
351    fn set_title_updates() {
352        let mut mb = MessageBox::new(BOUNDS, "Old", "msg", &["OK"]);
353        mb.set_title("New");
354        assert_eq!(mb.title(), "New");
355    }
356
357    #[test]
358    fn set_text_updates() {
359        let mut mb = MessageBox::new(BOUNDS, "T", "old msg", &["OK"]);
360        mb.set_text("new msg");
361        assert_eq!(mb.text(), "new msg");
362    }
363
364    // ── Active button ─────────────────────────────────────────────────────────
365
366    #[test]
367    fn active_button_initially_none() {
368        let mb = MessageBox::new(BOUNDS, "T", "M", &["OK"]);
369        assert_eq!(mb.active_button(), BUTTON_NONE);
370    }
371
372    #[test]
373    fn activate_button_records_id() {
374        let mut mb = MessageBox::new(BOUNDS, "T", "M", &["OK", "Cancel"]);
375        mb.activate_button(ButtonId(1));
376        assert_eq!(mb.active_button(), ButtonId(1));
377    }
378
379    #[test]
380    fn activate_out_of_range_is_a_noop() {
381        let mut mb = MessageBox::new(BOUNDS, "T", "M", &["OK"]);
382        mb.activate_button(ButtonId(99));
383        assert_eq!(mb.active_button(), BUTTON_NONE);
384    }
385
386    // ── Close ─────────────────────────────────────────────────────────────────
387
388    #[test]
389    fn close_returns_active_button_and_sets_closed() {
390        let mut mb = MessageBox::new(BOUNDS, "T", "M", &["OK", "Cancel"]);
391        mb.activate_button(ButtonId(0));
392        let result = mb.close();
393        assert_eq!(result, ButtonId(0));
394        assert!(mb.is_closed());
395    }
396
397    #[test]
398    fn close_without_selection_returns_none() {
399        let mut mb = MessageBox::new(BOUNDS, "T", "M", &["OK"]);
400        let result = mb.close();
401        assert_eq!(result, BUTTON_NONE);
402        assert!(mb.is_closed());
403    }
404
405    // ── Layout ────────────────────────────────────────────────────────────────
406
407    #[test]
408    fn set_bounds_updates_footer_rect() {
409        let mut mb = MessageBox::new(BOUNDS, "T", "M", &["OK"]);
410        let new_bounds = Rect {
411            x: 10,
412            y: 20,
413            width: 400,
414            height: 250,
415        };
416        mb.set_bounds(new_bounds);
417        assert_eq!(mb.bounds(), new_bounds);
418        // Footer should now sit at the bottom of the new bounds.
419        let footer = MessageBox::footer_rect_for(new_bounds);
420        let expected_footer_top = new_bounds.y + new_bounds.height - FOOTER_HEIGHT_PX;
421        assert_eq!(footer.y, expected_footer_top);
422        assert_eq!(footer.width, new_bounds.width);
423    }
424
425    #[test]
426    fn layout_bands_are_contiguous() {
427        let mb = MessageBox::new(BOUNDS, "T", "M", &["OK"]);
428        let header = mb.header_rect();
429        let content = mb.content_rect();
430        let footer = MessageBox::footer_rect_for(mb.bounds);
431
432        assert_eq!(header.y, BOUNDS.y);
433        assert_eq!(header.y + header.height, content.y);
434        assert_eq!(content.y + content.height, footer.y);
435        assert_eq!(footer.y + footer.height, BOUNDS.y + BOUNDS.height);
436    }
437}