1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum DialogError {
25 RenderError,
27 CapacityExceeded,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
33pub enum DialogType {
34 #[default]
36 Info,
37 Warning,
39 Error,
41 Success,
43 Question,
45}
46
47#[derive(Clone, Debug)]
49pub struct DialogAction {
50 pub label: String<16>,
52 pub action_id: u16,
54 pub is_destructive: bool,
56}
57
58impl DialogAction {
59 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 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#[derive(Clone, Debug)]
84pub struct ActionableDialogWidget<const MAX_ACTIONS: usize = 3> {
85 pub dialog_type: DialogType,
87 pub title: String<24>,
89 pub message: String<64>,
91 pub actions: Vec<DialogAction, MAX_ACTIONS>,
93 pub selected_action: usize,
95 pub background_color: Rgb565,
97 pub border_color: Rgb565,
99}
100
101impl<const MAX_ACTIONS: usize> ActionableDialogWidget<MAX_ACTIONS> {
102 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 pub fn add_action(&mut self, action: DialogAction) -> Result<(), DialogError> {
131 self.actions
132 .push(action)
133 .map_err(|_| DialogError::CapacityExceeded)
134 }
135
136 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 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 pub fn current_action_id(&self) -> Option<u16> {
156 self.actions.get(self.selected_action).map(|a| a.action_id)
157 }
158
159 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 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 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 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 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#[derive(Clone, Debug)]
262pub struct ConfirmationDialogWidget {
263 pub dialog: ActionableDialogWidget<2>,
265}
266
267impl ConfirmationDialogWidget {
268 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; Self { dialog }
275 }
276
277 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}