Skip to main content

embedded_gui/widgets/
dialog.rs

1//! Actionable & Confirmation Dialog Widgets
2//!
3//! Provides modal dialogs and alert prompt cards with:
4//! - Icon glyph header (Info, Warning, Error, Success, Question)
5//! - Multi-line title & message body text
6//! - Interactive action buttons with focus cursor and callback action IDs
7//! - Automated contrast styling and rounded border frames
8
9use core::fmt::Debug;
10use embedded_graphics_core::{
11    draw_target::DrawTarget,
12    pixelcolor::{Rgb565, WebColors},
13};
14use heapless::{String, Vec};
15
16use crate::{
17    geometry::Rect,
18    render::{Compositor, RenderCtx},
19    style::Border,
20};
21
22/// Errors produced during dialog operations.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum DialogError {
25    /// Render target draw error.
26    RenderError,
27    /// Capacity exceeded for action buttons.
28    CapacityExceeded,
29}
30
31/// Dialog icon types.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
33pub enum DialogType {
34    /// Informational dialog with cyan info icon.
35    #[default]
36    Info,
37    /// Warning dialog with amber exclamation icon.
38    Warning,
39    /// Critical error dialog with red cross icon.
40    Error,
41    /// Success dialog with green checkmark icon.
42    Success,
43    /// Question / confirmation prompt with gold question icon.
44    Question,
45}
46
47/// Action button descriptor in an actionable dialog.
48#[derive(Clone, Debug)]
49pub struct DialogAction {
50    /// Text label for the action button.
51    pub label: String<16>,
52    /// Unique action identifier passed when triggered.
53    pub action_id: u16,
54    /// Is this a destructive / warning action (rendered in red).
55    pub is_destructive: bool,
56}
57
58impl DialogAction {
59    /// Creates a standard action button.
60    pub fn new(label: &str, action_id: u16) -> Self {
61        let mut text = String::new();
62        let _ = text.push_str(label);
63        Self {
64            label: text,
65            action_id,
66            is_destructive: false,
67        }
68    }
69
70    /// Creates a destructive action button.
71    pub fn destructive(label: &str, action_id: u16) -> Self {
72        let mut text = String::new();
73        let _ = text.push_str(label);
74        Self {
75            label: text,
76            action_id,
77            is_destructive: true,
78        }
79    }
80}
81
82/// Self-contained actionable modal dialog.
83#[derive(Clone, Debug)]
84pub struct ActionableDialogWidget<const MAX_ACTIONS: usize = 3> {
85    /// Dialog type icon and theme.
86    pub dialog_type: DialogType,
87    /// Dialog title.
88    pub title: String<24>,
89    /// Dialog message body.
90    pub message: String<64>,
91    /// Action buttons collection.
92    pub actions: Vec<DialogAction, MAX_ACTIONS>,
93    /// Index of currently selected/focused action.
94    pub selected_action: usize,
95    /// Background card color.
96    pub background_color: Rgb565,
97    /// Border stroke color.
98    pub border_color: Rgb565,
99}
100
101impl<const MAX_ACTIONS: usize> ActionableDialogWidget<MAX_ACTIONS> {
102    /// Creates a new actionable dialog.
103    pub fn new(title: &str, message: &str, dialog_type: DialogType) -> Self {
104        let mut title_str = String::new();
105        let _ = title_str.push_str(title);
106
107        let mut msg_str = String::new();
108        let _ = msg_str.push_str(message);
109
110        let border_color = match dialog_type {
111            DialogType::Info => Rgb565::CSS_CYAN,
112            DialogType::Warning => Rgb565::CSS_ORANGE,
113            DialogType::Error => Rgb565::CSS_RED,
114            DialogType::Success => Rgb565::CSS_GREEN,
115            DialogType::Question => Rgb565::CSS_GOLD,
116        };
117
118        Self {
119            dialog_type,
120            title: title_str,
121            message: msg_str,
122            actions: Vec::new(),
123            selected_action: 0,
124            background_color: Rgb565::new(4, 8, 14),
125            border_color,
126        }
127    }
128
129    /// Adds an action button to the dialog.
130    pub fn add_action(&mut self, action: DialogAction) -> Result<(), DialogError> {
131        self.actions
132            .push(action)
133            .map_err(|_| DialogError::CapacityExceeded)
134    }
135
136    /// Selects the next action button to the right.
137    pub fn select_next(&mut self) {
138        if !self.actions.is_empty() {
139            self.selected_action = (self.selected_action + 1) % self.actions.len();
140        }
141    }
142
143    /// Selects the previous action button to the left.
144    pub fn select_prev(&mut self) {
145        if !self.actions.is_empty() {
146            self.selected_action = if self.selected_action == 0 {
147                self.actions.len() - 1
148            } else {
149                self.selected_action - 1
150            };
151        }
152    }
153
154    /// Gets the action ID of the currently selected button.
155    pub fn current_action_id(&self) -> Option<u16> {
156        self.actions.get(self.selected_action).map(|a| a.action_id)
157    }
158
159    /// Renders the actionable dialog.
160    pub fn render<D, C>(
161        &self,
162        ctx: &mut RenderCtx<'_, D, C>,
163        bounds: Rect,
164    ) -> Result<(), DialogError>
165    where
166        D: DrawTarget<Color = Rgb565>,
167        C: Compositor<D>,
168    {
169        if bounds.is_empty() {
170            return Ok(());
171        }
172
173        // 1. Draw Container Box
174        ctx.fill_rounded_rect(bounds, 6, self.background_color)
175            .map_err(|_| DialogError::RenderError)?;
176        ctx.stroke_rounded_rect(bounds, 6, Border::one(self.border_color))
177            .map_err(|_| DialogError::RenderError)?;
178
179        // 2. Icon Badge & Title Header
180        let header_y = bounds.y + 10;
181        let (icon_symbol, icon_color) = match self.dialog_type {
182            DialogType::Info => ("(i)", Rgb565::CSS_CYAN),
183            DialogType::Warning => ("(!)", Rgb565::CSS_ORANGE),
184            DialogType::Error => ("(X)", Rgb565::CSS_RED),
185            DialogType::Success => ("(V)", Rgb565::CSS_GREEN),
186            DialogType::Question => ("(?)", Rgb565::CSS_GOLD),
187        };
188
189        ctx.draw_text(bounds.x + 12, header_y, icon_symbol, icon_color)
190            .map_err(|_| DialogError::RenderError)?;
191        ctx.draw_text(bounds.x + 32, header_y, &self.title, Rgb565::CSS_WHITE)
192            .map_err(|_| DialogError::RenderError)?;
193
194        // 3. Message Body Text
195        ctx.draw_text(
196            bounds.x + 12,
197            header_y + 18,
198            &self.message,
199            Rgb565::new(20, 40, 30),
200        )
201        .map_err(|_| DialogError::RenderError)?;
202
203        // 4. Action Buttons along bottom
204        if !self.actions.is_empty() {
205            let num_acts = self.actions.len() as i32;
206            let spacing = 6i32;
207            let total_spacing = spacing * (num_acts - 1);
208            let btn_w = ((bounds.w as i32 - 24 - total_spacing) / num_acts).max(36) as u32;
209            let btn_h = 20u32;
210            let btn_y = bounds.bottom() - 10 - btn_h as i32;
211            let start_x = bounds.x + 12;
212
213            for (i, action) in self.actions.iter().enumerate() {
214                let is_selected = i == self.selected_action;
215                let bx = start_x + (i as i32 * (btn_w as i32 + spacing));
216                let btn_rect = Rect::new(bx, btn_y, btn_w, btn_h);
217
218                let bg = if is_selected {
219                    if action.is_destructive {
220                        Rgb565::new(30, 4, 4)
221                    } else {
222                        Rgb565::new(0, 35, 45)
223                    }
224                } else {
225                    Rgb565::new(6, 12, 18)
226                };
227
228                let border = if is_selected {
229                    if action.is_destructive {
230                        Rgb565::CSS_RED
231                    } else {
232                        Rgb565::CSS_CYAN
233                    }
234                } else {
235                    Rgb565::new(10, 20, 30)
236                };
237
238                ctx.fill_rounded_rect(btn_rect, 3, bg)
239                    .map_err(|_| DialogError::RenderError)?;
240                ctx.stroke_rounded_rect(btn_rect, 3, Border::one(border))
241                    .map_err(|_| DialogError::RenderError)?;
242
243                let text_x = btn_rect.x + (btn_rect.w as i32 - (action.label.len() as i32 * 4)) / 2;
244                let text_y = btn_rect.y + 6;
245                let text_color = if is_selected {
246                    Rgb565::CSS_WHITE
247                } else {
248                    Rgb565::new(15, 30, 25)
249                };
250
251                ctx.draw_text(text_x, text_y, &action.label, text_color)
252                    .map_err(|_| DialogError::RenderError)?;
253            }
254        }
255
256        Ok(())
257    }
258}
259
260/// Standard 2-button confirmation dialog.
261#[derive(Clone, Debug)]
262pub struct ConfirmationDialogWidget {
263    /// Inner actionable dialog instance.
264    pub dialog: ActionableDialogWidget<2>,
265}
266
267impl ConfirmationDialogWidget {
268    /// Creates a confirmation prompt with Confirm and Cancel buttons.
269    pub fn new(title: &str, message: &str, confirm_id: u16, cancel_id: u16) -> Self {
270        let mut dialog = ActionableDialogWidget::new(title, message, DialogType::Question);
271        let _ = dialog.add_action(DialogAction::new("CANCEL", cancel_id));
272        let _ = dialog.add_action(DialogAction::new("CONFIRM", confirm_id));
273        dialog.selected_action = 1; // Default to confirm
274        Self { dialog }
275    }
276
277    /// Renders the confirmation dialog.
278    pub fn render<D, C>(
279        &self,
280        ctx: &mut RenderCtx<'_, D, C>,
281        bounds: Rect,
282    ) -> Result<(), DialogError>
283    where
284        D: DrawTarget<Color = Rgb565>,
285        C: Compositor<D>,
286    {
287        self.dialog.render(ctx, bounds)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::framebuffer::Framebuffer;
295
296    #[test]
297    fn test_dialog_actions_and_render() {
298        let screen = Rect::new(0, 0, 240, 240);
299        let mut fb = Framebuffer::<{ 240 * 240 }>::new(240, 240);
300        let mut ctx = RenderCtx::new(&mut fb, screen);
301
302        let mut dialog = ActionableDialogWidget::<3>::new(
303            "DELETE ENTRY?",
304            "This action cannot be undone.",
305            DialogType::Warning,
306        );
307        assert!(dialog.add_action(DialogAction::new("CANCEL", 1)).is_ok());
308        assert!(
309            dialog
310                .add_action(DialogAction::destructive("DELETE", 2))
311                .is_ok()
312        );
313
314        assert_eq!(dialog.current_action_id(), Some(1));
315        dialog.select_next();
316        assert_eq!(dialog.current_action_id(), Some(2));
317
318        let res = dialog.render(&mut ctx, Rect::new(10, 60, 220, 100));
319        assert!(res.is_ok());
320    }
321
322    #[test]
323    fn test_confirmation_dialog() {
324        let confirm = ConfirmationDialogWidget::new("SYNC DATA", "Upload 12 pending logs?", 10, 20);
325        assert_eq!(confirm.dialog.actions.len(), 2);
326    }
327}