Another project
1
fork

Configure Feed

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

feat(ui): dialog and modal widgets

Lewis: May this revision serve well! <lu5a@proton.me>

+648
+648
crates/bone-ui/src/widgets/dialog.rs
··· 1 + use crate::focus::FocusScopeKind; 2 + use crate::frame::FrameCtx; 3 + use crate::input::NamedKey; 4 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 + use crate::strings::StringKey; 6 + use crate::theme::{Border, Color, Step12, StrokeWidth}; 7 + use crate::widget_id::{WidgetId, WidgetKey}; 8 + 9 + use super::button::{Button, ButtonState, ButtonVariant, show_button}; 10 + use super::keys::{TakeKey, take_key}; 11 + use super::paint::{LabelText, WidgetPaint}; 12 + 13 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 14 + pub enum BackdropStyle { 15 + Scrim, 16 + None, 17 + } 18 + 19 + #[derive(Copy, Clone, Debug, PartialEq)] 20 + pub struct Modal { 21 + pub id: WidgetId, 22 + pub viewport: LayoutRect, 23 + pub size: LayoutSize, 24 + pub backdrop: BackdropStyle, 25 + } 26 + 27 + impl Modal { 28 + #[must_use] 29 + pub const fn new(id: WidgetId, viewport: LayoutRect, size: LayoutSize) -> Self { 30 + Self { 31 + id, 32 + viewport, 33 + size, 34 + backdrop: BackdropStyle::Scrim, 35 + } 36 + } 37 + 38 + #[must_use] 39 + pub const fn backdrop(self, backdrop: BackdropStyle) -> Self { 40 + Self { backdrop, ..self } 41 + } 42 + } 43 + 44 + #[derive(Clone, Debug, PartialEq)] 45 + pub struct ModalResponse { 46 + pub body_rect: LayoutRect, 47 + pub dismissed: bool, 48 + pub paint: Vec<WidgetPaint>, 49 + } 50 + 51 + #[must_use] 52 + pub fn show_modal<F, R>(ctx: &mut FrameCtx<'_>, modal: Modal, body: F) -> (ModalResponse, R) 53 + where 54 + F: FnOnce(&mut FrameCtx<'_>, LayoutRect, &mut Vec<WidgetPaint>) -> R, 55 + { 56 + ctx.focus.push_scope(FocusScopeKind::Modal); 57 + let mut paint = Vec::new(); 58 + if matches!(modal.backdrop, BackdropStyle::Scrim) { 59 + paint.push(WidgetPaint::Surface { 60 + rect: modal.viewport, 61 + fill: Color::TRANSPARENT.with_alpha(0.45), 62 + border: None, 63 + radius: ctx.theme.radius.none, 64 + elevation: None, 65 + }); 66 + } 67 + let body_rect = center_rect(modal.viewport, modal.size); 68 + paint.push(WidgetPaint::Surface { 69 + rect: body_rect, 70 + fill: ctx.theme.colors.surface(crate::theme::SurfaceLevel::L1), 71 + border: Some(Border { 72 + width: StrokeWidth::HAIRLINE, 73 + color: ctx.theme.colors.neutral.step(Step12::BORDER), 74 + }), 75 + radius: ctx.theme.radius.md, 76 + elevation: Some(ctx.theme.elevation.level1), 77 + }); 78 + let dismissed = take_key(ctx.input, &[TakeKey::named(NamedKey::Escape)]).is_some(); 79 + let extras = body(ctx, body_rect, &mut paint); 80 + ctx.focus.pop_scope(); 81 + ( 82 + ModalResponse { 83 + body_rect, 84 + dismissed, 85 + paint, 86 + }, 87 + extras, 88 + ) 89 + } 90 + 91 + fn center_rect(viewport: LayoutRect, size: LayoutSize) -> LayoutRect { 92 + let cx = viewport.origin.x.value() + viewport.size.width.value() / 2.0; 93 + let cy = viewport.origin.y.value() + viewport.size.height.value() / 2.0; 94 + LayoutRect::new( 95 + LayoutPos::new( 96 + LayoutPx::new(cx - size.width.value() / 2.0), 97 + LayoutPx::new(cy - size.height.value() / 2.0), 98 + ), 99 + size, 100 + ) 101 + } 102 + 103 + #[derive(Copy, Clone, Debug, PartialEq)] 104 + pub struct DialogButton { 105 + pub id: WidgetId, 106 + pub label: StringKey, 107 + pub variant: ButtonVariant, 108 + pub disabled: bool, 109 + } 110 + 111 + impl DialogButton { 112 + #[must_use] 113 + pub const fn primary(id: WidgetId, label: StringKey) -> Self { 114 + Self { 115 + id, 116 + label, 117 + variant: ButtonVariant::Primary, 118 + disabled: false, 119 + } 120 + } 121 + 122 + #[must_use] 123 + pub const fn secondary(id: WidgetId, label: StringKey) -> Self { 124 + Self { 125 + id, 126 + label, 127 + variant: ButtonVariant::Secondary, 128 + disabled: false, 129 + } 130 + } 131 + 132 + #[must_use] 133 + pub const fn destructive(id: WidgetId, label: StringKey) -> Self { 134 + Self { 135 + id, 136 + label, 137 + variant: ButtonVariant::Destructive, 138 + disabled: false, 139 + } 140 + } 141 + } 142 + 143 + #[derive(Copy, Clone, Debug, PartialEq)] 144 + pub struct Dialog<'a> { 145 + pub id: WidgetId, 146 + pub viewport: LayoutRect, 147 + pub size: LayoutSize, 148 + pub title: StringKey, 149 + pub buttons: &'a [DialogButton], 150 + } 151 + 152 + impl<'a> Dialog<'a> { 153 + #[must_use] 154 + pub const fn new( 155 + id: WidgetId, 156 + viewport: LayoutRect, 157 + size: LayoutSize, 158 + title: StringKey, 159 + buttons: &'a [DialogButton], 160 + ) -> Self { 161 + Self { 162 + id, 163 + viewport, 164 + size, 165 + title, 166 + buttons, 167 + } 168 + } 169 + } 170 + 171 + #[derive(Clone, Debug, PartialEq)] 172 + pub struct DialogResponse { 173 + pub body_rect: LayoutRect, 174 + pub activated: Option<WidgetId>, 175 + pub dismissed: bool, 176 + pub paint: Vec<WidgetPaint>, 177 + } 178 + 179 + const DIALOG_TITLE_HEIGHT: f32 = 44.0; 180 + const DIALOG_BUTTON_HEIGHT: f32 = 32.0; 181 + const DIALOG_BUTTON_GAP: f32 = 8.0; 182 + const DIALOG_BUTTON_WIDTH: f32 = 96.0; 183 + const DIALOG_PADDING: f32 = 16.0; 184 + 185 + #[must_use] 186 + pub fn show_dialog<F, R>(ctx: &mut FrameCtx<'_>, dialog: Dialog<'_>, body: F) -> (DialogResponse, R) 187 + where 188 + F: FnOnce(&mut FrameCtx<'_>, LayoutRect, &mut Vec<WidgetPaint>) -> R, 189 + { 190 + let surface = center_rect(dialog.viewport, dialog.size); 191 + let title_rect = title_rect_of(surface); 192 + let body_rect = body_rect_of(surface); 193 + let strip_rect = button_strip_rect_of(surface); 194 + let buttons = dialog.buttons; 195 + let title = dialog.title; 196 + let modal = Modal::new(dialog.id, dialog.viewport, dialog.size); 197 + let (modal_response, (activated, extras)) = show_modal(ctx, modal, |ctx, _surface, paint| { 198 + paint.push(WidgetPaint::Label { 199 + rect: title_label_rect(title_rect), 200 + text: LabelText::Key(title), 201 + color: ctx.theme.colors.text_primary(), 202 + role: ctx.theme.typography.heading, 203 + }); 204 + paint.push(WidgetPaint::Surface { 205 + rect: divider_rect(title_rect), 206 + fill: ctx.theme.colors.neutral.step(Step12::SUBTLE_BORDER), 207 + border: None, 208 + radius: ctx.theme.radius.none, 209 + elevation: None, 210 + }); 211 + let extras = body(ctx, body_rect, paint); 212 + let activated = render_button_strip(ctx, buttons, strip_rect, paint); 213 + seed_dialog_focus(ctx, buttons); 214 + (activated, extras) 215 + }); 216 + ( 217 + DialogResponse { 218 + body_rect, 219 + activated, 220 + dismissed: modal_response.dismissed, 221 + paint: modal_response.paint, 222 + }, 223 + extras, 224 + ) 225 + } 226 + 227 + fn title_rect_of(surface: LayoutRect) -> LayoutRect { 228 + LayoutRect::new( 229 + surface.origin, 230 + LayoutSize::new(surface.size.width, LayoutPx::new(DIALOG_TITLE_HEIGHT)), 231 + ) 232 + } 233 + 234 + fn title_label_rect(title: LayoutRect) -> LayoutRect { 235 + LayoutRect::new( 236 + LayoutPos::new( 237 + LayoutPx::new(title.origin.x.value() + DIALOG_PADDING), 238 + title.origin.y, 239 + ), 240 + LayoutSize::new( 241 + LayoutPx::saturating_nonneg(title.size.width.value() - 2.0 * DIALOG_PADDING), 242 + title.size.height, 243 + ), 244 + ) 245 + } 246 + 247 + fn body_rect_of(surface: LayoutRect) -> LayoutRect { 248 + LayoutRect::new( 249 + LayoutPos::new( 250 + surface.origin.x, 251 + LayoutPx::new(surface.origin.y.value() + DIALOG_TITLE_HEIGHT), 252 + ), 253 + LayoutSize::new( 254 + surface.size.width, 255 + LayoutPx::saturating_nonneg( 256 + surface.size.height.value() 257 + - DIALOG_TITLE_HEIGHT 258 + - DIALOG_BUTTON_HEIGHT 259 + - 2.0 * DIALOG_PADDING, 260 + ), 261 + ), 262 + ) 263 + } 264 + 265 + fn button_strip_rect_of(surface: LayoutRect) -> LayoutRect { 266 + LayoutRect::new( 267 + LayoutPos::new( 268 + surface.origin.x, 269 + LayoutPx::new( 270 + surface.origin.y.value() + surface.size.height.value() 271 + - DIALOG_BUTTON_HEIGHT 272 + - DIALOG_PADDING, 273 + ), 274 + ), 275 + LayoutSize::new(surface.size.width, LayoutPx::new(DIALOG_BUTTON_HEIGHT)), 276 + ) 277 + } 278 + 279 + fn divider_rect(title: LayoutRect) -> LayoutRect { 280 + LayoutRect::new( 281 + LayoutPos::new( 282 + title.origin.x, 283 + LayoutPx::new(title.origin.y.value() + title.size.height.value() - 1.0), 284 + ), 285 + LayoutSize::new(title.size.width, LayoutPx::new(1.0)), 286 + ) 287 + } 288 + 289 + fn seed_dialog_focus(ctx: &mut FrameCtx<'_>, buttons: &[DialogButton]) { 290 + let scope = ctx.focus.current_scope(); 291 + let in_scope = ctx.focus.focused().is_some_and(|f| { 292 + ctx.focus 293 + .tab_stops() 294 + .iter() 295 + .any(|(id, s)| *s == scope && *id == f) 296 + }); 297 + if in_scope { 298 + return; 299 + } 300 + if let Some(target) = buttons.iter().find(|b| !b.disabled).map(|b| b.id) { 301 + ctx.focus.request_focus(target); 302 + } 303 + } 304 + 305 + fn render_button_strip( 306 + ctx: &mut FrameCtx<'_>, 307 + buttons: &[DialogButton], 308 + strip_rect: LayoutRect, 309 + paint: &mut Vec<WidgetPaint>, 310 + ) -> Option<WidgetId> { 311 + if buttons.is_empty() { 312 + return None; 313 + } 314 + let count = buttons.len(); 315 + #[allow( 316 + clippy::cast_precision_loss, 317 + reason = "dialog button counts fit in f32 mantissa" 318 + )] 319 + let total_width = count as f32 * (DIALOG_BUTTON_WIDTH + DIALOG_BUTTON_GAP) - DIALOG_BUTTON_GAP; 320 + let strip_x = 321 + strip_rect.origin.x.value() + strip_rect.size.width.value() - total_width - DIALOG_PADDING; 322 + let mut activated: Option<WidgetId> = None; 323 + buttons.iter().enumerate().for_each(|(idx, button)| { 324 + #[allow( 325 + clippy::cast_precision_loss, 326 + reason = "dialog button index fits f32 mantissa" 327 + )] 328 + let i = idx as f32; 329 + let rect = LayoutRect::new( 330 + LayoutPos::new( 331 + LayoutPx::new(strip_x + i * (DIALOG_BUTTON_WIDTH + DIALOG_BUTTON_GAP)), 332 + strip_rect.origin.y, 333 + ), 334 + LayoutSize::new( 335 + LayoutPx::new(DIALOG_BUTTON_WIDTH), 336 + LayoutPx::new(DIALOG_BUTTON_HEIGHT), 337 + ), 338 + ); 339 + let state = if button.disabled { 340 + ButtonState::Disabled 341 + } else { 342 + ButtonState::Idle 343 + }; 344 + let response = show_button( 345 + ctx, 346 + Button::new(button.id, rect, button.label, button.variant).with_state(state), 347 + ); 348 + paint.extend(response.paint); 349 + if response.activated && activated.is_none() { 350 + activated = Some(button.id); 351 + } 352 + }); 353 + activated 354 + } 355 + 356 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 357 + pub enum ConfirmationOutcome { 358 + Confirm, 359 + Cancel, 360 + } 361 + 362 + #[derive(Copy, Clone, Debug, PartialEq)] 363 + pub struct ConfirmationDialog { 364 + pub id: WidgetId, 365 + pub viewport: LayoutRect, 366 + pub size: LayoutSize, 367 + pub title: StringKey, 368 + pub message: StringKey, 369 + pub confirm_label: StringKey, 370 + pub cancel_label: StringKey, 371 + pub destructive: bool, 372 + } 373 + 374 + #[derive(Clone, Debug, PartialEq)] 375 + pub struct ConfirmationResponse { 376 + pub outcome: Option<ConfirmationOutcome>, 377 + pub paint: Vec<WidgetPaint>, 378 + } 379 + 380 + #[must_use] 381 + pub fn show_confirmation( 382 + ctx: &mut FrameCtx<'_>, 383 + dialog: ConfirmationDialog, 384 + ) -> ConfirmationResponse { 385 + let confirm_id = dialog.id.child(WidgetKey::new("confirm")); 386 + let cancel_id = dialog.id.child(WidgetKey::new("cancel")); 387 + let confirm = if dialog.destructive { 388 + DialogButton::destructive(confirm_id, dialog.confirm_label) 389 + } else { 390 + DialogButton::primary(confirm_id, dialog.confirm_label) 391 + }; 392 + let cancel = DialogButton::secondary(cancel_id, dialog.cancel_label); 393 + let buttons = [cancel, confirm]; 394 + let message = dialog.message; 395 + let (response, ()) = show_dialog( 396 + ctx, 397 + Dialog::new( 398 + dialog.id, 399 + dialog.viewport, 400 + dialog.size, 401 + dialog.title, 402 + &buttons, 403 + ), 404 + |ctx, body_rect, paint| { 405 + paint.push(WidgetPaint::Label { 406 + rect: body_rect, 407 + text: LabelText::Key(message), 408 + color: ctx.theme.colors.text_primary(), 409 + role: ctx.theme.typography.body, 410 + }); 411 + }, 412 + ); 413 + let outcome = match (response.dismissed, response.activated) { 414 + (true, _) => Some(ConfirmationOutcome::Cancel), 415 + (_, Some(id)) if id == confirm_id => Some(ConfirmationOutcome::Confirm), 416 + (_, Some(id)) if id == cancel_id => Some(ConfirmationOutcome::Cancel), 417 + _ => None, 418 + }; 419 + ConfirmationResponse { 420 + outcome, 421 + paint: response.paint, 422 + } 423 + } 424 + 425 + #[cfg(test)] 426 + mod tests { 427 + use std::sync::Arc; 428 + 429 + use super::{ 430 + ConfirmationDialog, ConfirmationOutcome, Dialog, DialogButton, Modal, show_confirmation, 431 + show_dialog, show_modal, 432 + }; 433 + use crate::focus::FocusManager; 434 + use crate::frame::FrameCtx; 435 + use crate::hit_test::{HitFrame, HitState, resolve}; 436 + use crate::hotkey::HotkeyTable; 437 + use crate::input::{ 438 + FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 439 + PointerButtonMask, PointerSample, 440 + }; 441 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 442 + use crate::strings::{StringKey, StringTable}; 443 + use crate::theme::Theme; 444 + use crate::widget_id::{WidgetId, WidgetKey}; 445 + 446 + fn modal_id() -> WidgetId { 447 + WidgetId::ROOT.child(WidgetKey::new("modal")) 448 + } 449 + 450 + fn viewport() -> LayoutRect { 451 + LayoutRect::new( 452 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 453 + LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(600.0)), 454 + ) 455 + } 456 + 457 + fn run_dialog( 458 + focus: &mut FocusManager, 459 + snap: &mut InputSnapshot, 460 + prev: &HitState, 461 + buttons: &[DialogButton], 462 + ) -> (super::DialogResponse, HitState) { 463 + let theme = Arc::new(Theme::light()); 464 + let table = HotkeyTable::new(); 465 + let mut hits = HitFrame::new(); 466 + let response = { 467 + let mut ctx = FrameCtx::new( 468 + theme, 469 + snap, 470 + focus, 471 + &table, 472 + StringTable::empty(), 473 + &mut hits, 474 + prev, 475 + ); 476 + let (response, ()) = show_dialog( 477 + &mut ctx, 478 + Dialog::new( 479 + modal_id(), 480 + viewport(), 481 + LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(240.0)), 482 + StringKey::new("dialog.title"), 483 + buttons, 484 + ), 485 + |_ctx, _body_rect, _paint| {}, 486 + ); 487 + response 488 + }; 489 + let next = resolve(prev, &hits, snap, focus.focused()); 490 + (response, next) 491 + } 492 + 493 + #[test] 494 + fn escape_dismisses_modal() { 495 + let theme = Arc::new(Theme::light()); 496 + let table = HotkeyTable::new(); 497 + let mut focus = FocusManager::new(); 498 + let mut hits = HitFrame::new(); 499 + let prev = HitState::new(); 500 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 501 + snap.keys_pressed.push(KeyEvent::new( 502 + KeyCode::Named(NamedKey::Escape), 503 + ModifierMask::NONE, 504 + )); 505 + let response = { 506 + let mut ctx = FrameCtx::new( 507 + theme, 508 + &mut snap, 509 + &mut focus, 510 + &table, 511 + StringTable::empty(), 512 + &mut hits, 513 + &prev, 514 + ); 515 + let (response, ()) = show_modal( 516 + &mut ctx, 517 + Modal::new( 518 + modal_id(), 519 + viewport(), 520 + LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)), 521 + ), 522 + |_ctx, _body_rect, _paint| {}, 523 + ); 524 + response 525 + }; 526 + assert!(response.dismissed); 527 + } 528 + 529 + #[test] 530 + fn dialog_centers_body_rect() { 531 + let buttons: Vec<DialogButton> = Vec::new(); 532 + let mut focus = FocusManager::new(); 533 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 534 + let prev = HitState::new(); 535 + let (response, _) = run_dialog(&mut focus, &mut snap, &prev, &buttons); 536 + let cx = response.body_rect.origin.x.value() + response.body_rect.size.width.value() / 2.0; 537 + assert!((cx - 400.0).abs() <= 1.0); 538 + } 539 + 540 + #[test] 541 + fn show_modal_pops_scope_so_later_widgets_are_not_trapped() { 542 + let theme = Arc::new(Theme::light()); 543 + let table = HotkeyTable::new(); 544 + let mut focus = FocusManager::new(); 545 + let mut hits = HitFrame::new(); 546 + let prev = HitState::new(); 547 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 548 + { 549 + let mut ctx = FrameCtx::new( 550 + theme, 551 + &mut snap, 552 + &mut focus, 553 + &table, 554 + StringTable::empty(), 555 + &mut hits, 556 + &prev, 557 + ); 558 + let (_response, ()) = show_modal( 559 + &mut ctx, 560 + Modal::new( 561 + modal_id(), 562 + viewport(), 563 + LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)), 564 + ), 565 + |_ctx, _body_rect, _paint| {}, 566 + ); 567 + assert_eq!( 568 + ctx.focus.current_scope(), 569 + crate::focus::FocusScopeId::ROOT, 570 + "modal scope must be popped before show_modal returns", 571 + ); 572 + } 573 + } 574 + 575 + #[test] 576 + fn click_confirm_button_emits_confirm_outcome() { 577 + let mut focus = FocusManager::new(); 578 + let mut prev = HitState::new(); 579 + let dialog_size = LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(240.0)); 580 + let dialog_x = (800.0 - 400.0) / 2.0; 581 + let dialog_y = (600.0 - 240.0) / 2.0; 582 + let confirm_x = dialog_x + 400.0 - 16.0 - 96.0 / 2.0; 583 + let confirm_y = dialog_y + 240.0 - 16.0 - 32.0 / 2.0; 584 + let click_pos = LayoutPos::new(LayoutPx::new(confirm_x), LayoutPx::new(confirm_y)); 585 + let theme = Arc::new(Theme::light()); 586 + let table = HotkeyTable::new(); 587 + let mut last: Option<super::ConfirmationResponse> = None; 588 + [ 589 + press_at(click_pos), 590 + release_at(click_pos), 591 + idle_at(click_pos), 592 + ] 593 + .into_iter() 594 + .for_each(|mut snap| { 595 + let mut hits = HitFrame::new(); 596 + let response = { 597 + let mut ctx = FrameCtx::new( 598 + theme.clone(), 599 + &mut snap, 600 + &mut focus, 601 + &table, 602 + StringTable::empty(), 603 + &mut hits, 604 + &prev, 605 + ); 606 + show_confirmation( 607 + &mut ctx, 608 + ConfirmationDialog { 609 + id: modal_id(), 610 + viewport: viewport(), 611 + size: dialog_size, 612 + title: StringKey::new("dialog.title"), 613 + message: StringKey::new("dialog.message"), 614 + confirm_label: StringKey::new("dialog.ok"), 615 + cancel_label: StringKey::new("dialog.cancel"), 616 + destructive: false, 617 + }, 618 + ) 619 + }; 620 + last = Some(response); 621 + prev = resolve(&prev, &hits, &snap, focus.focused()); 622 + }); 623 + let Some(response) = last else { 624 + panic!("response missing") 625 + }; 626 + assert_eq!(response.outcome, Some(ConfirmationOutcome::Confirm)); 627 + } 628 + 629 + fn press_at(pos: LayoutPos) -> InputSnapshot { 630 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 631 + s.pointer = Some(PointerSample::new(pos)); 632 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 633 + s 634 + } 635 + 636 + fn release_at(pos: LayoutPos) -> InputSnapshot { 637 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 638 + s.pointer = Some(PointerSample::new(pos)); 639 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 640 + s 641 + } 642 + 643 + fn idle_at(pos: LayoutPos) -> InputSnapshot { 644 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 645 + s.pointer = Some(PointerSample::new(pos)); 646 + s 647 + } 648 + }