1use 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
63pub 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 pub fn add_button(mut self, text: &str, event: Event) -> Self {
113 self.buttons.push((text.to_string(), event));
114 self
115 }
116
117 #[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
199pub struct Dialog {
237 id: Id,
238 rect: Rectangle,
239 children: Vec<Box<dyn View>>,
240 view_id: ViewId,
241 button_count: usize,
242 button_width: i32,
246}
247
248impl Dialog {
249 pub fn builder(view_id: ViewId, title: String) -> DialogBuilder {
273 DialogBuilder::new(view_id, title)
274 }
275
276 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}