1use 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
50const HEADER_HEIGHT_PX: i32 = 32;
52const FOOTER_HEIGHT_PX: i32 = 40;
54
55pub struct MessageBox {
69 bounds: Rect,
70 title: String,
71 text: String,
72 buttons: ButtonMatrix,
74 active_button: ButtonId,
76 closed: bool,
78 pub style: Style,
80 pub text_color: Color,
82 pub button_text_color: Color,
84 font: WidgetFont,
87}
88
89impl MessageBox {
90 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 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 pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
122 self.font.set(font);
123 }
124
125 pub fn title(&self) -> &str {
129 &self.title
130 }
131
132 pub fn set_title(&mut self, title: &str) {
134 self.title = String::from(title);
135 }
136
137 pub fn text(&self) -> &str {
139 &self.text
140 }
141
142 pub fn set_text(&mut self, text: &str) {
144 self.text = String::from(text);
145 }
146
147 pub fn active_button(&self) -> ButtonId {
152 self.active_button
153 }
154
155 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 pub fn button_text(&self, id: ButtonId) -> Option<&str> {
166 self.buttons.button_text(id)
167 }
168
169 pub fn button_count(&self) -> usize {
171 self.buttons.button_count()
172 }
173
174 pub fn close(&mut self) -> ButtonId {
182 self.closed = true;
183 self.active_button
184 }
185
186 pub fn is_closed(&self) -> bool {
188 self.closed
189 }
190
191 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 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 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 draw_widget_bg(renderer, self.bounds, &self.style);
266
267 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 let content = self.content_rect();
281 if !self.text.is_empty() {
282 self.draw_wrapped_text(renderer, &self.text, content);
283 }
284
285 self.buttons.draw(renderer);
287 }
288
289 fn handle_event(&mut self, event: &Event) -> bool {
290 if self.buttons.handle_event(event) {
292 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#[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 #[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 #[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 #[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 #[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 #[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 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}