Another project
1use crate::a11y::{AccessNode, Role};
2use crate::focus::FocusScopeKind;
3use crate::frame::FrameCtx;
4use crate::input::NamedKey;
5use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
6use crate::strings::StringKey;
7use crate::theme::{Border, Color, Step12, StrokeWidth};
8use crate::widget_id::{WidgetId, WidgetKey};
9
10use super::button::{Button, ButtonState, ButtonVariant, show_button};
11use super::keys::{TakeKey, take_key};
12use super::paint::{LabelText, WidgetPaint};
13
14#[derive(Copy, Clone, Debug, PartialEq)]
15pub struct Modal {
16 pub id: WidgetId,
17 pub viewport: LayoutRect,
18 pub size: LayoutSize,
19 pub label: StringKey,
20}
21
22impl Modal {
23 #[must_use]
24 pub const fn new(
25 id: WidgetId,
26 viewport: LayoutRect,
27 size: LayoutSize,
28 label: StringKey,
29 ) -> Self {
30 Self {
31 id,
32 viewport,
33 size,
34 label,
35 }
36 }
37}
38
39#[derive(Clone, Debug, PartialEq)]
40pub struct ModalResponse {
41 pub body_rect: LayoutRect,
42 pub dismissed: bool,
43 pub paint: Vec<WidgetPaint>,
44}
45
46#[must_use]
47pub fn show_modal<F, R>(ctx: &mut FrameCtx<'_>, modal: Modal, body: F) -> (ModalResponse, R)
48where
49 F: FnOnce(&mut FrameCtx<'_>, LayoutRect, &mut Vec<WidgetPaint>) -> R,
50{
51 ctx.focus.push_scope(FocusScopeKind::Modal);
52 let mut paint = vec![WidgetPaint::Surface {
53 rect: modal.viewport,
54 fill: Color::TRANSPARENT.with_alpha(0.45),
55 border: None,
56 radius: ctx.theme().radius.none,
57 elevation: None,
58 }];
59 let scrim_id = modal.id.child(WidgetKey::new("scrim"));
60 ctx.block_pointer(scrim_id, modal.viewport);
61 let body_rect = center_rect(modal.viewport, modal.size);
62 paint.push(WidgetPaint::Surface {
63 rect: body_rect,
64 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1),
65 border: Some(Border {
66 width: StrokeWidth::HAIRLINE,
67 color: ctx.theme().colors.neutral.step(Step12::BORDER),
68 }),
69 radius: ctx.theme().radius.md,
70 elevation: Some(ctx.theme().elevation.level1),
71 });
72 ctx.a11y.push(
73 modal.id,
74 body_rect,
75 AccessNode::new(Role::Dialog).with_label(modal.label),
76 );
77 let dismissed = take_key(ctx.input, &[TakeKey::named(NamedKey::Escape)]).is_some();
78 let extras = body(ctx, body_rect, &mut paint);
79 ctx.focus.pop_scope();
80 (
81 ModalResponse {
82 body_rect,
83 dismissed,
84 paint,
85 },
86 extras,
87 )
88}
89
90fn center_rect(viewport: LayoutRect, size: LayoutSize) -> LayoutRect {
91 let cx = viewport.origin.x.value() + viewport.size.width.value() / 2.0;
92 let cy = viewport.origin.y.value() + viewport.size.height.value() / 2.0;
93 LayoutRect::new(
94 LayoutPos::new(
95 LayoutPx::new(cx - size.width.value() / 2.0),
96 LayoutPx::new(cy - size.height.value() / 2.0),
97 ),
98 size,
99 )
100}
101
102#[derive(Copy, Clone, Debug, PartialEq)]
103pub struct DialogButton {
104 pub id: WidgetId,
105 pub label: StringKey,
106 pub variant: ButtonVariant,
107 pub disabled: bool,
108}
109
110impl DialogButton {
111 #[must_use]
112 pub const fn primary(id: WidgetId, label: StringKey) -> Self {
113 Self {
114 id,
115 label,
116 variant: ButtonVariant::Primary,
117 disabled: false,
118 }
119 }
120
121 #[must_use]
122 pub const fn secondary(id: WidgetId, label: StringKey) -> Self {
123 Self {
124 id,
125 label,
126 variant: ButtonVariant::Secondary,
127 disabled: false,
128 }
129 }
130
131 #[must_use]
132 pub const fn destructive(id: WidgetId, label: StringKey) -> Self {
133 Self {
134 id,
135 label,
136 variant: ButtonVariant::Destructive,
137 disabled: false,
138 }
139 }
140}
141
142#[derive(Copy, Clone, Debug, PartialEq)]
143pub struct Dialog<'a> {
144 pub id: WidgetId,
145 pub viewport: LayoutRect,
146 pub size: LayoutSize,
147 pub title: StringKey,
148 pub buttons: &'a [DialogButton],
149}
150
151impl<'a> Dialog<'a> {
152 #[must_use]
153 pub const fn new(
154 id: WidgetId,
155 viewport: LayoutRect,
156 size: LayoutSize,
157 title: StringKey,
158 buttons: &'a [DialogButton],
159 ) -> Self {
160 Self {
161 id,
162 viewport,
163 size,
164 title,
165 buttons,
166 }
167 }
168}
169
170#[derive(Clone, Debug, PartialEq)]
171pub struct DialogResponse {
172 pub body_rect: LayoutRect,
173 pub activated: Option<WidgetId>,
174 pub dismissed: bool,
175 pub paint: Vec<WidgetPaint>,
176}
177
178const DIALOG_TITLE_HEIGHT: f32 = 32.0;
179const DIALOG_BUTTON_HEIGHT: f32 = 23.0;
180const DIALOG_BUTTON_GAP: f32 = 8.0;
181const DIALOG_BUTTON_WIDTH: f32 = 88.0;
182const DIALOG_PADDING: f32 = 12.0;
183
184#[must_use]
185pub fn show_dialog<F, R>(ctx: &mut FrameCtx<'_>, dialog: Dialog<'_>, body: F) -> (DialogResponse, R)
186where
187 F: FnOnce(&mut FrameCtx<'_>, LayoutRect, &mut Vec<WidgetPaint>) -> R,
188{
189 let surface = center_rect(dialog.viewport, dialog.size);
190 let title_rect = title_rect_of(surface);
191 let body_rect = body_rect_of(surface);
192 let strip_rect = button_strip_rect_of(surface);
193 let buttons = dialog.buttons;
194 let title = dialog.title;
195 let modal = Modal::new(dialog.id, dialog.viewport, dialog.size, dialog.title);
196 let (modal_response, (activated, extras)) = show_modal(ctx, modal, |ctx, _surface, paint| {
197 paint.push(WidgetPaint::Label {
198 rect: title_label_rect(title_rect),
199 text: LabelText::Key(title),
200 color: ctx.theme().colors.text_primary(),
201 role: ctx.theme().typography.heading,
202 });
203 paint.push(WidgetPaint::Surface {
204 rect: divider_rect(title_rect),
205 fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
206 border: None,
207 radius: ctx.theme().radius.none,
208 elevation: None,
209 });
210 let extras = body(ctx, body_rect, paint);
211 let activated = render_button_strip(ctx, buttons, strip_rect, paint);
212 seed_dialog_focus(ctx, buttons);
213 (activated, extras)
214 });
215 (
216 DialogResponse {
217 body_rect,
218 activated,
219 dismissed: modal_response.dismissed,
220 paint: modal_response.paint,
221 },
222 extras,
223 )
224}
225
226fn title_rect_of(surface: LayoutRect) -> LayoutRect {
227 LayoutRect::new(
228 surface.origin,
229 LayoutSize::new(surface.size.width, LayoutPx::new(DIALOG_TITLE_HEIGHT)),
230 )
231}
232
233fn title_label_rect(title: LayoutRect) -> LayoutRect {
234 LayoutRect::new(
235 LayoutPos::new(
236 LayoutPx::new(title.origin.x.value() + DIALOG_PADDING),
237 title.origin.y,
238 ),
239 LayoutSize::new(
240 LayoutPx::saturating_nonneg(title.size.width.value() - 2.0 * DIALOG_PADDING),
241 title.size.height,
242 ),
243 )
244}
245
246fn body_rect_of(surface: LayoutRect) -> LayoutRect {
247 LayoutRect::new(
248 LayoutPos::new(
249 surface.origin.x,
250 LayoutPx::new(surface.origin.y.value() + DIALOG_TITLE_HEIGHT),
251 ),
252 LayoutSize::new(
253 surface.size.width,
254 LayoutPx::saturating_nonneg(
255 surface.size.height.value()
256 - DIALOG_TITLE_HEIGHT
257 - DIALOG_BUTTON_HEIGHT
258 - 2.0 * DIALOG_PADDING,
259 ),
260 ),
261 )
262}
263
264fn button_strip_rect_of(surface: LayoutRect) -> LayoutRect {
265 LayoutRect::new(
266 LayoutPos::new(
267 surface.origin.x,
268 LayoutPx::new(
269 surface.origin.y.value() + surface.size.height.value()
270 - DIALOG_BUTTON_HEIGHT
271 - DIALOG_PADDING,
272 ),
273 ),
274 LayoutSize::new(surface.size.width, LayoutPx::new(DIALOG_BUTTON_HEIGHT)),
275 )
276}
277
278fn divider_rect(title: LayoutRect) -> LayoutRect {
279 LayoutRect::new(
280 LayoutPos::new(
281 title.origin.x,
282 LayoutPx::new(title.origin.y.value() + title.size.height.value() - 1.0),
283 ),
284 LayoutSize::new(title.size.width, LayoutPx::new(1.0)),
285 )
286}
287
288fn seed_dialog_focus(ctx: &mut FrameCtx<'_>, buttons: &[DialogButton]) {
289 let scope = ctx.focus.current_scope();
290 let in_scope = ctx.focus.focused().is_some_and(|f| {
291 ctx.focus
292 .tab_stops()
293 .iter()
294 .any(|(id, s)| *s == scope && *id == f)
295 });
296 if in_scope {
297 return;
298 }
299 if let Some(target) = buttons.iter().find(|b| !b.disabled).map(|b| b.id) {
300 ctx.focus.request_focus(target);
301 }
302}
303
304fn render_button_strip(
305 ctx: &mut FrameCtx<'_>,
306 buttons: &[DialogButton],
307 strip_rect: LayoutRect,
308 paint: &mut Vec<WidgetPaint>,
309) -> Option<WidgetId> {
310 if buttons.is_empty() {
311 return None;
312 }
313 let count = buttons.len();
314 #[allow(
315 clippy::cast_precision_loss,
316 reason = "dialog button counts fit in f32 mantissa"
317 )]
318 let total_width = count as f32 * (DIALOG_BUTTON_WIDTH + DIALOG_BUTTON_GAP) - DIALOG_BUTTON_GAP;
319 let strip_x =
320 strip_rect.origin.x.value() + strip_rect.size.width.value() - total_width - DIALOG_PADDING;
321 let mut activated: Option<WidgetId> = None;
322 buttons.iter().enumerate().for_each(|(idx, button)| {
323 #[allow(
324 clippy::cast_precision_loss,
325 reason = "dialog button index fits f32 mantissa"
326 )]
327 let i = idx as f32;
328 let rect = LayoutRect::new(
329 LayoutPos::new(
330 LayoutPx::new(strip_x + i * (DIALOG_BUTTON_WIDTH + DIALOG_BUTTON_GAP)),
331 strip_rect.origin.y,
332 ),
333 LayoutSize::new(
334 LayoutPx::new(DIALOG_BUTTON_WIDTH),
335 LayoutPx::new(DIALOG_BUTTON_HEIGHT),
336 ),
337 );
338 let state = if button.disabled {
339 ButtonState::Disabled
340 } else {
341 ButtonState::Idle
342 };
343 let response = show_button(
344 ctx,
345 Button::new(button.id, rect, button.label, button.variant).with_state(state),
346 );
347 paint.extend(response.paint);
348 if response.activated && activated.is_none() {
349 activated = Some(button.id);
350 }
351 });
352 activated
353}
354
355#[derive(Copy, Clone, Debug, PartialEq, Eq)]
356pub enum ConfirmationOutcome {
357 Confirm,
358 Cancel,
359}
360
361#[derive(Copy, Clone, Debug, PartialEq)]
362pub struct ConfirmationDialog {
363 pub id: WidgetId,
364 pub viewport: LayoutRect,
365 pub size: LayoutSize,
366 pub title: StringKey,
367 pub message: StringKey,
368 pub confirm_label: StringKey,
369 pub cancel_label: StringKey,
370 pub destructive: bool,
371}
372
373#[derive(Clone, Debug, PartialEq)]
374pub struct ConfirmationResponse {
375 pub outcome: Option<ConfirmationOutcome>,
376 pub paint: Vec<WidgetPaint>,
377}
378
379#[must_use]
380pub fn show_confirmation(
381 ctx: &mut FrameCtx<'_>,
382 dialog: ConfirmationDialog,
383) -> ConfirmationResponse {
384 let confirm_id = dialog.id.child(WidgetKey::new("confirm"));
385 let cancel_id = dialog.id.child(WidgetKey::new("cancel"));
386 let confirm = if dialog.destructive {
387 DialogButton::destructive(confirm_id, dialog.confirm_label)
388 } else {
389 DialogButton::primary(confirm_id, dialog.confirm_label)
390 };
391 let cancel = DialogButton::secondary(cancel_id, dialog.cancel_label);
392 let buttons = [cancel, confirm];
393 let message = dialog.message;
394 let (response, ()) = show_dialog(
395 ctx,
396 Dialog::new(
397 dialog.id,
398 dialog.viewport,
399 dialog.size,
400 dialog.title,
401 &buttons,
402 ),
403 |ctx, body_rect, paint| {
404 paint.push(WidgetPaint::Label {
405 rect: body_rect,
406 text: LabelText::Key(message),
407 color: ctx.theme().colors.text_primary(),
408 role: ctx.theme().typography.body,
409 });
410 },
411 );
412 let outcome = match (response.dismissed, response.activated) {
413 (true, _) => Some(ConfirmationOutcome::Cancel),
414 (_, Some(id)) if id == confirm_id => Some(ConfirmationOutcome::Confirm),
415 (_, Some(id)) if id == cancel_id => Some(ConfirmationOutcome::Cancel),
416 _ => None,
417 };
418 ConfirmationResponse {
419 outcome,
420 paint: response.paint,
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use std::sync::Arc;
427
428 use super::{
429 ConfirmationDialog, ConfirmationOutcome, Dialog, DialogButton, Modal, show_confirmation,
430 show_dialog, show_modal,
431 };
432 use crate::focus::FocusManager;
433 use crate::frame::FrameCtx;
434 use crate::hit_test::{HitFrame, HitState, resolve};
435 use crate::hotkey::HotkeyTable;
436 use crate::input::{
437 FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton,
438 PointerButtonMask, PointerSample,
439 };
440 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
441 use crate::strings::{StringKey, StringTable};
442 use crate::theme::Theme;
443 use crate::widget_id::{WidgetId, WidgetKey};
444
445 fn modal_id() -> WidgetId {
446 WidgetId::ROOT.child(WidgetKey::new("modal"))
447 }
448
449 fn viewport() -> LayoutRect {
450 LayoutRect::new(
451 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
452 LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(600.0)),
453 )
454 }
455
456 fn run_dialog(
457 focus: &mut FocusManager,
458 snap: &mut InputSnapshot,
459 prev: &HitState,
460 buttons: &[DialogButton],
461 ) -> (super::DialogResponse, HitState) {
462 let theme = Arc::new(Theme::light());
463 let table = HotkeyTable::new();
464 let mut hits = HitFrame::new();
465 let response = {
466 let mut shaper = bone_text::Shaper::new();
467 let mut a11y = crate::a11y::AccessTreeBuilder::new();
468 let mut ctx = FrameCtx::new(
469 theme,
470 snap,
471 focus,
472 &table,
473 StringTable::empty(),
474 &mut hits,
475 prev,
476 &mut a11y,
477 &mut shaper,
478 );
479 let (response, ()) = show_dialog(
480 &mut ctx,
481 Dialog::new(
482 modal_id(),
483 viewport(),
484 LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(240.0)),
485 StringKey::new("dialog.title"),
486 buttons,
487 ),
488 |_ctx, _body_rect, _paint| {},
489 );
490 response
491 };
492 let next = resolve(prev, &hits, snap, focus.focused());
493 (response, next)
494 }
495
496 #[test]
497 fn escape_dismisses_modal() {
498 let theme = Arc::new(Theme::light());
499 let table = HotkeyTable::new();
500 let mut focus = FocusManager::new();
501 let mut hits = HitFrame::new();
502 let prev = HitState::new();
503 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
504 snap.keys_pressed.push(KeyEvent::new(
505 KeyCode::Named(NamedKey::Escape),
506 ModifierMask::NONE,
507 ));
508 let response = {
509 let mut shaper = bone_text::Shaper::new();
510 let mut a11y = crate::a11y::AccessTreeBuilder::new();
511 let mut ctx = FrameCtx::new(
512 theme,
513 &mut snap,
514 &mut focus,
515 &table,
516 StringTable::empty(),
517 &mut hits,
518 &prev,
519 &mut a11y,
520 &mut shaper,
521 );
522 let (response, ()) = show_modal(
523 &mut ctx,
524 Modal::new(
525 modal_id(),
526 viewport(),
527 LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)),
528 StringKey::new("test.modal"),
529 ),
530 |_ctx, _body_rect, _paint| {},
531 );
532 response
533 };
534 assert!(response.dismissed);
535 }
536
537 #[test]
538 fn dialog_centers_body_rect() {
539 let buttons: Vec<DialogButton> = Vec::new();
540 let mut focus = FocusManager::new();
541 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
542 let prev = HitState::new();
543 let (response, _) = run_dialog(&mut focus, &mut snap, &prev, &buttons);
544 let cx = response.body_rect.origin.x.value() + response.body_rect.size.width.value() / 2.0;
545 assert!((cx - 400.0).abs() <= 1.0);
546 }
547
548 #[test]
549 fn modal_scrim_blocks_pointer_hits_to_chrome_below() {
550 use crate::frame::InteractDeclaration;
551 use crate::hit_test::Sense;
552
553 let theme = Arc::new(Theme::light());
554 let table = HotkeyTable::new();
555 let mut focus = FocusManager::new();
556 let mut hits = HitFrame::new();
557 let prev = HitState::new();
558 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
559 let chrome_id = WidgetId::ROOT.child(WidgetKey::new("chrome.button"));
560 let chrome_rect = LayoutRect::new(
561 LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0)),
562 LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(24.0)),
563 );
564 snap.pointer = Some(PointerSample::new(LayoutPos::new(
565 LayoutPx::new(20.0),
566 LayoutPx::new(20.0),
567 )));
568 snap.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
569 snap.buttons_released = PointerButtonMask::just(PointerButton::Primary);
570 {
571 let mut shaper = bone_text::Shaper::new();
572 let mut a11y = crate::a11y::AccessTreeBuilder::new();
573 let mut ctx = FrameCtx::new(
574 theme,
575 &mut snap,
576 &mut focus,
577 &table,
578 StringTable::empty(),
579 &mut hits,
580 &prev,
581 &mut a11y,
582 &mut shaper,
583 );
584 ctx.interact(InteractDeclaration::new(
585 chrome_id,
586 chrome_rect,
587 Sense::INTERACTIVE,
588 ));
589 let (_response, ()) = show_modal(
590 &mut ctx,
591 Modal::new(
592 modal_id(),
593 viewport(),
594 LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)),
595 StringKey::new("test.modal"),
596 ),
597 |_ctx, _body_rect, _paint| {},
598 );
599 }
600 let resolved = resolve(&prev, &hits, &snap, focus.focused());
601 assert!(
602 !resolved.interaction(chrome_id).click(),
603 "modal scrim must absorb pointer clicks aimed at chrome below the modal",
604 );
605 }
606
607 #[test]
608 fn show_modal_pops_scope_so_later_widgets_are_not_trapped() {
609 let theme = Arc::new(Theme::light());
610 let table = HotkeyTable::new();
611 let mut focus = FocusManager::new();
612 let mut hits = HitFrame::new();
613 let prev = HitState::new();
614 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
615 {
616 let mut shaper = bone_text::Shaper::new();
617 let mut a11y = crate::a11y::AccessTreeBuilder::new();
618 let mut ctx = FrameCtx::new(
619 theme,
620 &mut snap,
621 &mut focus,
622 &table,
623 StringTable::empty(),
624 &mut hits,
625 &prev,
626 &mut a11y,
627 &mut shaper,
628 );
629 let (_response, ()) = show_modal(
630 &mut ctx,
631 Modal::new(
632 modal_id(),
633 viewport(),
634 LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)),
635 StringKey::new("test.modal"),
636 ),
637 |_ctx, _body_rect, _paint| {},
638 );
639 assert_eq!(
640 ctx.focus.current_scope(),
641 crate::focus::FocusScopeId::ROOT,
642 "modal scope must be popped before show_modal returns",
643 );
644 }
645 }
646
647 #[test]
648 fn enter_on_focused_button_emits_activation() {
649 let confirm_id = modal_id().child(WidgetKey::new("confirm"));
650 let cancel_id = modal_id().child(WidgetKey::new("cancel"));
651 let buttons = [
652 DialogButton::primary(confirm_id, StringKey::new("dialog.ok")),
653 DialogButton::secondary(cancel_id, StringKey::new("dialog.cancel")),
654 ];
655 let mut focus = FocusManager::new();
656 let prev = HitState::new();
657 let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
658 focus.request_focus(confirm_id);
659 let _ = run_dialog(&mut focus, &mut warm, &prev, &buttons);
660 assert_eq!(focus.focused(), Some(confirm_id));
661
662 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
663 snap.keys_pressed.push(KeyEvent::new(
664 KeyCode::Named(NamedKey::Enter),
665 ModifierMask::NONE,
666 ));
667 let (response, _) = run_dialog(&mut focus, &mut snap, &prev, &buttons);
668 assert_eq!(response.activated, Some(confirm_id));
669 }
670
671 fn run_confirmation(
672 focus: &mut FocusManager,
673 snap: &mut InputSnapshot,
674 prev: &HitState,
675 destructive: bool,
676 ) -> (super::ConfirmationResponse, HitState) {
677 let theme = Arc::new(Theme::light());
678 let table = HotkeyTable::new();
679 let mut hits = HitFrame::new();
680 let response = {
681 let mut shaper = bone_text::Shaper::new();
682 let mut a11y = crate::a11y::AccessTreeBuilder::new();
683 let mut ctx = FrameCtx::new(
684 theme,
685 snap,
686 focus,
687 &table,
688 StringTable::empty(),
689 &mut hits,
690 prev,
691 &mut a11y,
692 &mut shaper,
693 );
694 show_confirmation(
695 &mut ctx,
696 ConfirmationDialog {
697 id: modal_id(),
698 viewport: viewport(),
699 size: LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(240.0)),
700 title: StringKey::new("dialog.title"),
701 message: StringKey::new("dialog.message"),
702 confirm_label: StringKey::new("dialog.ok"),
703 cancel_label: StringKey::new("dialog.cancel"),
704 destructive,
705 },
706 )
707 };
708 let next = resolve(prev, &hits, snap, focus.focused());
709 (response, next)
710 }
711
712 #[test]
713 fn enter_on_seeded_cancel_button_emits_cancel_outcome() {
714 let cancel_id = modal_id().child(WidgetKey::new("cancel"));
715 let mut focus = FocusManager::new();
716 let prev = HitState::new();
717 let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
718 let _ = run_confirmation(&mut focus, &mut warm, &prev, false);
719 assert_eq!(
720 focus.focused(),
721 Some(cancel_id),
722 "confirmation seeds focus on cancel button (buttons[0])",
723 );
724
725 let mut enter = InputSnapshot::idle(FrameInstant::ZERO);
726 enter.keys_pressed.push(KeyEvent::new(
727 KeyCode::Named(NamedKey::Enter),
728 ModifierMask::NONE,
729 ));
730 let (response, _) = run_confirmation(&mut focus, &mut enter, &prev, false);
731 assert_eq!(response.outcome, Some(ConfirmationOutcome::Cancel));
732 }
733
734 #[test]
735 fn escape_on_open_confirmation_emits_cancel_outcome() {
736 let mut focus = FocusManager::new();
737 let prev = HitState::new();
738 let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
739 let _ = run_confirmation(&mut focus, &mut warm, &prev, false);
740
741 let mut esc = InputSnapshot::idle(FrameInstant::ZERO);
742 esc.keys_pressed.push(KeyEvent::new(
743 KeyCode::Named(NamedKey::Escape),
744 ModifierMask::NONE,
745 ));
746 let (response, _) = run_confirmation(&mut focus, &mut esc, &prev, false);
747 assert_eq!(response.outcome, Some(ConfirmationOutcome::Cancel));
748 }
749
750 #[test]
751 fn click_confirm_button_emits_confirm_outcome() {
752 let mut focus = FocusManager::new();
753 let mut prev = HitState::new();
754 let dialog_size = LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(240.0));
755 let dialog_x = (800.0 - 400.0) / 2.0;
756 let dialog_y = (600.0 - 240.0) / 2.0;
757 let confirm_x = dialog_x + 400.0 - super::DIALOG_PADDING - super::DIALOG_BUTTON_WIDTH / 2.0;
758 let confirm_y =
759 dialog_y + 240.0 - super::DIALOG_PADDING - super::DIALOG_BUTTON_HEIGHT / 2.0;
760 let click_pos = LayoutPos::new(LayoutPx::new(confirm_x), LayoutPx::new(confirm_y));
761 let theme = Arc::new(Theme::light());
762 let table = HotkeyTable::new();
763 let mut last: Option<super::ConfirmationResponse> = None;
764 [
765 press_at(click_pos),
766 release_at(click_pos),
767 idle_at(click_pos),
768 ]
769 .into_iter()
770 .for_each(|mut snap| {
771 let mut hits = HitFrame::new();
772 let response = {
773 let mut shaper = bone_text::Shaper::new();
774 let mut a11y = crate::a11y::AccessTreeBuilder::new();
775 let mut ctx = FrameCtx::new(
776 theme.clone(),
777 &mut snap,
778 &mut focus,
779 &table,
780 StringTable::empty(),
781 &mut hits,
782 &prev,
783 &mut a11y,
784 &mut shaper,
785 );
786 show_confirmation(
787 &mut ctx,
788 ConfirmationDialog {
789 id: modal_id(),
790 viewport: viewport(),
791 size: dialog_size,
792 title: StringKey::new("dialog.title"),
793 message: StringKey::new("dialog.message"),
794 confirm_label: StringKey::new("dialog.ok"),
795 cancel_label: StringKey::new("dialog.cancel"),
796 destructive: false,
797 },
798 )
799 };
800 last = Some(response);
801 prev = resolve(&prev, &hits, &snap, focus.focused());
802 });
803 let Some(response) = last else {
804 panic!("response missing")
805 };
806 assert_eq!(response.outcome, Some(ConfirmationOutcome::Confirm));
807 }
808
809 fn press_at(pos: LayoutPos) -> InputSnapshot {
810 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
811 s.pointer = Some(PointerSample::new(pos));
812 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
813 s
814 }
815
816 fn release_at(pos: LayoutPos) -> InputSnapshot {
817 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
818 s.pointer = Some(PointerSample::new(pos));
819 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
820 s
821 }
822
823 fn idle_at(pos: LayoutPos) -> InputSnapshot {
824 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
825 s.pointer = Some(PointerSample::new(pos));
826 s
827 }
828}