cadmus_core/view/
dialog.rs

1//! A modal dialog view that displays a message and custom buttons.
2//!
3//! The dialog component provides a flexible way to display modal dialogs with a title
4//! message and multiple custom buttons. Dialogs are centered on the display and render
5//! with a bordered white background.
6//!
7//! # Building a Dialog
8//!
9//! Use the [`Dialog::builder`] method to create a dialog with a fluent API:
10//!
11//! ```no_run
12//! use cadmus_core::view::dialog::Dialog;
13//! use cadmus_core::view::{Event, ViewId};
14//!
15//! # let mut context = unsafe { std::mem::zeroed() };
16//! let dialog = Dialog::builder(ViewId::BookMenu, "Confirm deletion?".to_string())
17//!     .add_button("Cancel", Event::Close(ViewId::BookMenu))
18//!     .add_button("Delete", Event::Close(ViewId::BookMenu))
19//!     .build(&mut context);
20//! ```
21//!
22//! # Behavior
23//!
24//! - **Multi-line messages**: The title supports multi-line text via newline characters
25//! - **Dynamic layout**: Buttons are evenly distributed horizontally regardless of count
26//! - **Button events**: When a button is tapped, it sends the event configured for that button.
27//!   To close the dialog, you can either make the button event an [`Event::Close`] or handle
28//!   the event in your view logic to remove the dialog from the view hierarchy.
29//! - **Outside tap**: Tapping outside the dialog area automatically sends an [`Event::Close`]
30//!
31//! # Example: Adding to a View
32//!
33//! ```no_run
34//! use cadmus_core::view::dialog::Dialog;
35//! use cadmus_core::view::{Event, ViewId, View};
36//!
37//! # let mut context = unsafe { std::mem::zeroed() };
38//! # let mut view_children: Vec<Box<dyn View>> = Vec::new();
39//! let dialog = Dialog::builder(ViewId::BookMenu, "Save changes?".to_string())
40//!     .add_button("Discard", Event::Close(ViewId::BookMenu))
41//!     .add_button("Save", Event::Close(ViewId::BookMenu))
42//!     .build(&mut context);
43//!
44//! // Add the dialog to your view hierarchy
45//! view_children.push(Box::new(dialog) as Box<dyn View>);
46//! ```
47//!
48//! [`Event`]: super::Event
49
50use super::button::Button;
51use super::label::Label;
52use super::{Align, Bus, Event, Hub, Id, RenderQueue, View, ViewId, ID_FEEDER};
53use super::{BORDER_RADIUS_MEDIUM, THICKNESS_LARGE};
54use crate::color::{BLACK, WHITE};
55use crate::context::Context;
56use crate::device::CURRENT_DEVICE;
57use crate::font::{font_from_style, Fonts, NORMAL_STYLE};
58use crate::framebuffer::Framebuffer;
59use crate::geom::{BorderSpec, CornerSpec, Rectangle};
60use crate::gesture::GestureEvent;
61use crate::unit::scale_by_dpi;
62
63/// Builder for constructing a [`Dialog`] with custom buttons and message.
64///
65/// Use [`Dialog::builder`] to create a new builder, then chain calls to
66/// [`add_button`](DialogBuilder::add_button) to define the buttons, and finally
67/// call [`build`](DialogBuilder::build) to create the dialog.
68///
69/// # Example
70///
71/// ```no_run
72/// use cadmus_core::view::dialog::Dialog;
73/// use cadmus_core::view::{Event, ViewId};
74///
75/// // Note: In actual use, `context` is provided by the application.
76/// // Dialog::builder requires a properly initialized Context with
77/// // Display and Fonts, so we show the API pattern here.
78/// # let mut context = unsafe { std::mem::zeroed() };
79/// let dialog = Dialog::builder(ViewId::AboutDialog, "Are you sure?".to_string())
80///     .add_button("Cancel", Event::Close(ViewId::AboutDialog))
81///     .add_button("Confirm", Event::Validate)
82///     .build(&mut context);
83/// ```
84pub struct DialogBuilder {
85    view_id: ViewId,
86    title: String,
87    buttons: Vec<(String, Event)>,
88}
89
90impl DialogBuilder {
91    fn new(view_id: ViewId, title: String) -> Self {
92        DialogBuilder {
93            view_id,
94            title,
95            buttons: Vec::new(),
96        }
97    }
98
99    /// Add a button to the dialog.
100    ///
101    /// Buttons are displayed from left to right in the order they are added.
102    /// Each button sends a specific event when tapped.
103    ///
104    /// # Arguments
105    ///
106    /// * `text` - The label text displayed on the button
107    /// * `event` - The event sent when the button is tapped
108    ///
109    /// # Returns
110    ///
111    /// Returns `self` to allow method chaining.
112    pub fn add_button(mut self, text: &str, event: Event) -> Self {
113        self.buttons.push((text.to_string(), event));
114        self
115    }
116
117    /// Build the dialog with the configured title and buttons.
118    ///
119    /// Calculates the dialog layout, creates label and button views, and
120    /// centers the dialog on the display.
121    ///
122    /// # Arguments
123    ///
124    /// * `context` - The rendering context, used for font metrics and display dimensions
125    ///
126    /// # Returns
127    ///
128    /// A new [`Dialog`] instance ready to be displayed.
129    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, context), fields(view_id = ?self.view_id, title = ?self.title)))]
130    pub fn build(self, context: &mut Context) -> Dialog {
131        let id = ID_FEEDER.next();
132        let dpi = CURRENT_DEVICE.dpi;
133        let (width, height) = context.display.dims;
134
135        let font = font_from_style(&mut context.fonts, &NORMAL_STYLE, dpi);
136        let x_height = font.x_heights.0 as i32;
137        let padding = font.em() as i32;
138
139        let min_message_width = width as i32 / 2;
140        let max_message_width = width as i32 - 3 * padding;
141        let max_button_width = width as i32 / 5;
142        let button_height = 4 * x_height;
143
144        let text_lines: Vec<&str> = self.title.lines().collect();
145        let line_count = text_lines.len().max(1);
146        let line_height = font.line_height();
147
148        let mut max_line_width = min_message_width;
149        for line in &text_lines {
150            let plan = font.plan(line, Some(max_message_width), None);
151            max_line_width = max_line_width.max(plan.width);
152        }
153
154        let label_height = line_count as i32 * line_height;
155        let message_width = max_line_width.max(min_message_width) + 3 * padding;
156
157        let button_count = self.buttons.len().max(1);
158        let mut max_button_text_width = 0;
159        for (text, _) in &self.buttons {
160            let plan = font.plan(text, Some(max_button_width), None);
161            max_button_text_width = max_button_text_width.max(plan.width);
162        }
163        let button_width = max_button_text_width + padding;
164
165        let required_button_area_width =
166            button_count as i32 * button_width + (button_count as i32 + 1) * padding;
167        let dialog_width = message_width.max(required_button_area_width);
168        let dialog_height = label_height + button_height + 3 * padding;
169
170        let dx = (width as i32 - dialog_width) / 2;
171        let dy = (height as i32 - dialog_height) / 2;
172        let rect = rect![dx, dy, dx + dialog_width, dy + dialog_height];
173
174        let mut children: Vec<Box<dyn View>> = Vec::new();
175        for line in &text_lines {
176            let label = Label::new(Rectangle::default(), line.to_string(), Align::Center);
177            children.push(Box::new(label) as Box<dyn View>);
178        }
179        for (text, event) in &self.buttons {
180            let button = Button::new(Rectangle::default(), event.clone(), text.clone());
181            children.push(Box::new(button) as Box<dyn View>);
182        }
183
184        let mut dialog = Dialog {
185            id,
186            rect,
187            children,
188            view_id: self.view_id,
189            button_count,
190            button_width,
191        };
192
193        dialog.layout_children(&mut context.fonts);
194
195        dialog
196    }
197}
198
199/// A modal dialog view that displays a message and allows users to select from custom buttons.
200///
201/// The dialog is centered on the display and renders a bordered rectangle containing:
202/// - A title message (can be multi-line)
203/// - Buttons evenly distributed horizontally
204///
205/// # Closing a Dialog
206///
207/// The dialog sends an [`Event::Close`] when the user taps outside the dialog area.
208/// To close the dialog from a button tap, configure the button with a [`Event::Close`] event.
209/// Other button events are propagated without closing the dialog. Which means you must handle the
210/// closing of the dialog.
211///
212/// # Lifecycle
213///
214/// Create a dialog using the builder pattern via [`Dialog::builder`], which handles
215/// automatic layout calculation based on the display dimensions and text content.
216///
217/// # Example
218///
219/// ```no_run
220/// use cadmus_core::view::dialog::Dialog;
221/// use cadmus_core::view::{Event, ViewId, View};
222///
223/// # let mut context = unsafe { std::mem::zeroed() };
224/// let mut view_children: Vec<Box<dyn View>> = Vec::new();
225///
226/// // Note: In actual use, `context` is provided by the application.
227/// // Dialog::builder requires a properly initialized Context with
228/// // Display and Fonts, so we show the API pattern here.
229/// let dialog = Dialog::builder(ViewId::BookMenu, "Delete this file?".to_string())
230///     .add_button("No", Event::Close(ViewId::BookMenu))
231///     .add_button("Yes", Event::Close(ViewId::BookMenu))
232///     .build(&mut context);
233///
234/// view_children.push(Box::new(dialog) as Box<dyn View>);
235/// ```
236pub struct Dialog {
237    id: Id,
238    rect: Rectangle,
239    children: Vec<Box<dyn View>>,
240    view_id: ViewId,
241    button_count: usize,
242    /// Content-based button width computed once during [`DialogBuilder::build`]
243    /// from the widest button text. Reused by [`layout_children`](Dialog::layout_children)
244    /// on every resize so buttons keep their text-proportional sizing.
245    button_width: i32,
246}
247
248impl Dialog {
249    /// Create a builder for a new dialog.
250    ///
251    /// # Arguments
252    ///
253    /// * `view_id` - Unique identifier for the dialog
254    /// * `title` - The message text to display (supports multi-line text)
255    ///
256    /// # Returns
257    ///
258    /// A [`DialogBuilder`] that can be configured with buttons via [`add_button`](DialogBuilder::add_button).
259    ///
260    /// # Example
261    ///
262    /// ```no_run
263    /// use cadmus_core::view::dialog::Dialog;
264    /// use cadmus_core::view::{Event, ViewId};
265    ///
266    /// # let mut context = unsafe { std::mem::zeroed() };
267    /// let _dialog = Dialog::builder(ViewId::BookMenu, "Are you sure?".to_string())
268    ///     .add_button("Cancel", Event::Close(ViewId::BookMenu))
269    ///     .add_button("OK", Event::Validate)
270    ///     .build(&mut context);
271    /// ```
272    pub fn builder(view_id: ViewId, title: String) -> DialogBuilder {
273        DialogBuilder::new(view_id, title)
274    }
275
276    /// Position all child views within the current dialog rect.
277    ///
278    /// Labels are stacked vertically with one `padding` inset from each edge.
279    /// Buttons use a content-based width ([`button_width`](Dialog::button_width))
280    /// and are centered horizontally with even spacing.
281    ///
282    /// Both [`DialogBuilder::build`] and [`Dialog::resize`] delegate to this
283    /// method so the layout algorithm is defined in a single place.
284    fn layout_children(&mut self, fonts: &mut Fonts) {
285        let dpi = CURRENT_DEVICE.dpi;
286        let font = font_from_style(fonts, &NORMAL_STYLE, dpi);
287        let x_height = font.x_heights.0 as i32;
288        let padding = font.em() as i32;
289        let line_height = font.line_height();
290        let button_height = 4 * x_height;
291
292        let label_count = self.children.len() - self.button_count;
293
294        for i in 0..label_count {
295            let y_offset = self.rect.min.y + padding + (i as i32 * line_height);
296            *self.children[i].rect_mut() = rect![
297                self.rect.min.x + padding,
298                y_offset,
299                self.rect.max.x - padding,
300                y_offset + line_height
301            ];
302        }
303
304        let button_area_width = self.rect.width() as i32 - 2 * padding;
305        let button_spacing = (button_area_width - self.button_count as i32 * self.button_width)
306            / (self.button_count as i32 + 1);
307
308        for idx in 0..self.button_count {
309            let x_offset = self.rect.min.x
310                + padding
311                + (idx as i32 + 1) * button_spacing
312                + idx as i32 * self.button_width;
313            *self.children[label_count + idx].rect_mut() = rect![
314                x_offset,
315                self.rect.max.y - button_height - padding,
316                x_offset + self.button_width,
317                self.rect.max.y - padding
318            ];
319        }
320    }
321}
322
323impl View for Dialog {
324    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, hub, _bus, _rq, _context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))]
325    fn handle_event(
326        &mut self,
327        evt: &Event,
328        hub: &Hub,
329        _bus: &mut Bus,
330        _rq: &mut RenderQueue,
331        _context: &mut Context,
332    ) -> bool {
333        match *evt {
334            Event::Gesture(GestureEvent::Tap(center)) if !self.rect.includes(center) => {
335                hub.send(Event::Close(self.view_id)).ok();
336                true
337            }
338            Event::Gesture(..) => true,
339            _ => false,
340        }
341    }
342
343    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, fb, _fonts, _rect), fields(rect = ?_rect)))]
344    fn render(&self, fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {
345        let dpi = CURRENT_DEVICE.dpi;
346
347        let border_radius = scale_by_dpi(BORDER_RADIUS_MEDIUM, dpi) as i32;
348        let border_thickness = scale_by_dpi(THICKNESS_LARGE, dpi) as u16;
349
350        fb.draw_rounded_rectangle_with_border(
351            &self.rect,
352            &CornerSpec::Uniform(border_radius),
353            &BorderSpec {
354                thickness: border_thickness,
355                color: BLACK,
356            },
357            &WHITE,
358        );
359    }
360
361    fn resize(&mut self, _rect: Rectangle, hub: &Hub, rq: &mut RenderQueue, context: &mut Context) {
362        let (width, height) = context.display.dims;
363        let dialog_width = self.rect.width() as i32;
364        let dialog_height = self.rect.height() as i32;
365
366        let dx = (width as i32 - dialog_width) / 2;
367        let dy = (height as i32 - dialog_height) / 2;
368        self.rect = rect![dx, dy, dx + dialog_width, dy + dialog_height];
369
370        self.layout_children(&mut context.fonts);
371
372        for child in &mut self.children {
373            let rect = *child.rect();
374            child.resize(rect, hub, rq, context);
375        }
376    }
377
378    fn is_background(&self) -> bool {
379        true
380    }
381
382    fn rect(&self) -> &Rectangle {
383        &self.rect
384    }
385
386    fn rect_mut(&mut self) -> &mut Rectangle {
387        &mut self.rect
388    }
389
390    fn children(&self) -> &Vec<Box<dyn View>> {
391        &self.children
392    }
393
394    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
395        &mut self.children
396    }
397
398    fn id(&self) -> Id {
399        self.id
400    }
401
402    fn view_id(&self) -> Option<ViewId> {
403        Some(self.view_id)
404    }
405}
406
407#[cfg(test)]
408impl Dialog {
409    fn rect_for_test(&self) -> &Rectangle {
410        &self.rect
411    }
412
413    fn button_count_for_test(&self) -> usize {
414        self.button_count
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::context::test_helpers::create_test_context;
422
423    #[test]
424    fn dialog_width_should_not_be_static() {
425        let mut context = create_test_context();
426
427        let dialog = Dialog::builder(ViewId::BookMenu, "Where to check for updates?".to_string())
428            .add_button("Stable Release", Event::Close(ViewId::BookMenu))
429            .add_button("Main Branch", Event::Close(ViewId::BookMenu))
430            .add_button("PR Build", Event::Close(ViewId::BookMenu))
431            .build(&mut context);
432
433        let dialog2 = Dialog::builder(ViewId::BookMenu, "Where to check for updates?".to_string())
434            .add_button("Stable Release", Event::Close(ViewId::BookMenu))
435            .build(&mut context);
436
437        let dialog1_rect = dialog.rect_for_test();
438        let dialog1_width = dialog1_rect.width() as i32;
439        let dialog2_rect = dialog2.rect_for_test();
440        let dialog2_width = dialog2_rect.width() as i32;
441
442        assert!(
443            dialog1_width > dialog2_width,
444            "Expected triple button dialog to be wider than single button: {}--{}",
445            dialog1_width,
446            dialog2_width
447        );
448    }
449    #[test]
450    fn dialog_width_with_three_buttons_should_expand() {
451        let mut context = create_test_context();
452
453        let dialog = Dialog::builder(ViewId::BookMenu, "Where to check for updates?".to_string())
454            .add_button("Stable Release", Event::Close(ViewId::BookMenu))
455            .add_button("Main Branch", Event::Close(ViewId::BookMenu))
456            .add_button("PR Build", Event::Close(ViewId::BookMenu))
457            .build(&mut context);
458
459        let dialog_rect = dialog.rect_for_test();
460        let dialog_width = dialog_rect.width() as i32;
461
462        assert!(
463            dialog_width > 0,
464            "Dialog width should be positive, got {}",
465            dialog_width
466        );
467
468        assert_eq!(
469            dialog.button_count_for_test(),
470            3,
471            "Dialog should have 3 buttons"
472        );
473    }
474
475    #[test]
476    fn dialog_width_single_button_should_be_valid() {
477        let mut context = create_test_context();
478
479        let dialog = Dialog::builder(ViewId::BookMenu, "Confirm deletion?".to_string())
480            .add_button("Cancel", Event::Close(ViewId::BookMenu))
481            .build(&mut context);
482
483        let dialog_rect = dialog.rect_for_test();
484        let dialog_width = dialog_rect.width() as i32;
485
486        assert!(
487            dialog_width > 0,
488            "Dialog width should be positive, got {}",
489            dialog_width
490        );
491
492        assert_eq!(
493            dialog.button_count_for_test(),
494            1,
495            "Dialog should have 1 button"
496        );
497    }
498
499    #[test]
500    fn dialog_should_center_on_display() {
501        if std::env::var("TEST_ROOT_DIR").is_err() {
502            return;
503        }
504
505        let mut context = create_test_context();
506
507        let dialog = Dialog::builder(ViewId::BookMenu, "Test message".to_string())
508            .add_button("OK", Event::Close(ViewId::BookMenu))
509            .build(&mut context);
510
511        let rect = dialog.rect_for_test();
512        let dialog_width = rect.width();
513        let dialog_height = rect.height();
514        let dialog_x = rect.min.x as u32;
515        let dialog_y = rect.min.y as u32;
516
517        let expected_x = (context.display.dims.0 - dialog_width) / 2;
518        let expected_y = (context.display.dims.1 - dialog_height) / 2;
519
520        assert_eq!(
521            dialog_x, expected_x,
522            "Dialog X position should be centered: got {}, expected {}",
523            dialog_x, expected_x
524        );
525        assert_eq!(
526            dialog_y, expected_y,
527            "Dialog Y position should be centered: got {}, expected {}",
528            dialog_y, expected_y
529        );
530    }
531}