Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 27 kB View raw
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}