Another project
0

Configure Feed

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

at main 20 kB View raw
1use std::sync::Arc; 2 3use bone_text::Shaper; 4 5use crate::a11y::{AccessNode, AccessTreeBuilder, Role}; 6use crate::focus::FocusManager; 7use crate::hit_test::{HitFrame, HitItem, HitState, Interaction, Sense, ZLayer}; 8use crate::hotkey::{ActionId, HotkeyScopes, HotkeyTable, KeyChord}; 9use crate::input::{InputSnapshot, KeyEvent}; 10use crate::layout::{LayoutDirection, LayoutRect}; 11use crate::strings::{Locale, StringTable}; 12use crate::theme::Theme; 13use crate::widget_id::WidgetId; 14 15#[derive(Clone, Debug, PartialEq)] 16pub struct InteractDeclaration { 17 pub id: WidgetId, 18 pub rect: LayoutRect, 19 pub sense: Sense, 20 pub z: ZLayer, 21 pub disabled: bool, 22 pub focusable: bool, 23 pub active: bool, 24 pub a11y: Option<AccessNode>, 25} 26 27impl InteractDeclaration { 28 #[must_use] 29 pub const fn new(id: WidgetId, rect: LayoutRect, sense: Sense) -> Self { 30 Self { 31 id, 32 rect, 33 sense, 34 z: ZLayer::BASE, 35 disabled: false, 36 focusable: false, 37 active: false, 38 a11y: None, 39 } 40 } 41 42 #[must_use] 43 pub fn at_z(self, z: ZLayer) -> Self { 44 Self { z, ..self } 45 } 46 47 #[must_use] 48 pub fn disabled(self, disabled: bool) -> Self { 49 Self { disabled, ..self } 50 } 51 52 #[must_use] 53 pub fn focusable(self, focusable: bool) -> Self { 54 Self { focusable, ..self } 55 } 56 57 #[must_use] 58 pub fn active(self, active: bool) -> Self { 59 Self { active, ..self } 60 } 61 62 #[must_use] 63 pub fn a11y(self, node: AccessNode) -> Self { 64 Self { 65 a11y: Some(node), 66 ..self 67 } 68 } 69} 70 71pub struct FrameCtx<'a> { 72 theme: Arc<Theme>, 73 pub input: &'a mut InputSnapshot, 74 pub focus: &'a mut FocusManager, 75 pub hotkeys: &'a HotkeyTable, 76 pub strings: &'a StringTable, 77 pub hits: &'a mut HitFrame, 78 pub previous: &'a HitState, 79 pub a11y: &'a mut AccessTreeBuilder, 80 pub shaper: &'a mut Shaper, 81} 82 83impl<'a> FrameCtx<'a> { 84 #[must_use] 85 #[allow( 86 clippy::too_many_arguments, 87 reason = "FrameCtx threads every per-frame subsystem; bundling them obscures lifetimes" 88 )] 89 pub fn new( 90 theme: Arc<Theme>, 91 input: &'a mut InputSnapshot, 92 focus: &'a mut FocusManager, 93 hotkeys: &'a HotkeyTable, 94 strings: &'a StringTable, 95 hits: &'a mut HitFrame, 96 previous: &'a HitState, 97 a11y: &'a mut AccessTreeBuilder, 98 shaper: &'a mut Shaper, 99 ) -> Self { 100 focus.begin_frame(); 101 focus.observe_input(input); 102 a11y.begin_frame(); 103 Self { 104 theme, 105 input, 106 focus, 107 hotkeys, 108 strings, 109 hits, 110 previous, 111 a11y, 112 shaper, 113 } 114 } 115 116 #[must_use] 117 pub fn theme(&self) -> &Theme { 118 &self.theme 119 } 120 121 #[must_use] 122 pub fn locale(&self) -> Locale { 123 self.strings.locale() 124 } 125 126 #[must_use] 127 pub fn direction(&self) -> LayoutDirection { 128 self.strings.direction() 129 } 130 131 #[must_use] 132 pub fn is_focused(&self, id: WidgetId) -> bool { 133 self.focus.focused() == Some(id) 134 } 135 136 pub fn request_focus(&mut self, id: WidgetId) { 137 self.focus.request_focus(id); 138 } 139 140 pub fn interact(&mut self, declaration: InteractDeclaration) -> Interaction { 141 if !declaration.disabled { 142 self.focus.register_focusable(declaration.id); 143 if declaration.focusable { 144 self.focus.register_tab_stop(declaration.id); 145 } 146 if declaration 147 .a11y 148 .as_ref() 149 .is_some_and(|node| node.role == Role::TextInput) 150 { 151 self.focus.register_text_input(declaration.id); 152 } 153 } 154 self.hits.push(HitItem { 155 id: declaration.id, 156 rect: declaration.rect, 157 sense: declaration.sense, 158 z: declaration.z, 159 disabled: declaration.disabled, 160 active: declaration.active, 161 }); 162 if let Some(node) = declaration.a11y { 163 self.a11y.push(declaration.id, declaration.rect, node); 164 } 165 let interaction = self.previous.interaction(declaration.id); 166 if interaction.click() && declaration.focusable && !declaration.disabled { 167 self.focus.request_focus(declaration.id); 168 } 169 interaction 170 } 171 172 pub fn block_pointer(&mut self, id: WidgetId, rect: LayoutRect) { 173 self.hits.push(HitItem { 174 id, 175 rect, 176 sense: Sense::INTERACTIVE, 177 z: ZLayer::BASE, 178 disabled: false, 179 active: false, 180 }); 181 } 182 183 pub fn theme_scope<R>( 184 &mut self, 185 modify: impl FnOnce(&Theme) -> Theme, 186 body: impl FnOnce(&mut FrameCtx<'a>) -> R, 187 ) -> R { 188 let outer = Arc::clone(&self.theme); 189 self.theme = Arc::new(modify(&self.theme)); 190 let result = body(self); 191 self.theme = outer; 192 result 193 } 194 195 pub fn dispatch_hotkeys(&mut self, scopes: &HotkeyScopes) -> Vec<ActionId> { 196 let table = self.hotkeys; 197 let pending = std::mem::take(&mut self.input.keys_pressed); 198 let (matched, remaining) = pending.into_iter().fold( 199 (Vec::new(), Vec::new()), 200 |(mut matched, mut remaining), event: KeyEvent| { 201 match table.dispatch(KeyChord::from(event), scopes) { 202 Some(action) => matched.push(action), 203 None => remaining.push(event), 204 } 205 (matched, remaining) 206 }, 207 ); 208 self.input.keys_pressed = remaining; 209 matched 210 } 211} 212 213impl Drop for FrameCtx<'_> { 214 fn drop(&mut self) { 215 self.focus.end_frame(); 216 } 217} 218 219#[cfg(test)] 220mod tests { 221 use core::num::NonZeroU32; 222 use core::time::Duration; 223 use std::sync::Arc; 224 225 use super::{FrameCtx, InteractDeclaration}; 226 use crate::a11y::AccessTreeBuilder; 227 use crate::focus::FocusManager; 228 use crate::hit_test::{HitFrame, HitState, Sense, resolve}; 229 use crate::hotkey::{ 230 ActionId, HotkeyBinding, HotkeyScope, HotkeyScopes, HotkeyTable, KeyChord, 231 }; 232 use crate::input::{ 233 FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, PointerButton, 234 PointerButtonMask, PointerSample, 235 }; 236 use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 237 use crate::strings::{Locale, StringTable}; 238 use crate::theme::{Theme, ThemeMode}; 239 use crate::widget_id::{WidgetId, WidgetKey}; 240 241 fn global_scope() -> HotkeyScopes { 242 HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]) 243 } 244 245 fn rect() -> LayoutRect { 246 LayoutRect::new( 247 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 248 LayoutSize::new(LayoutPx::new(50.0), LayoutPx::new(50.0)), 249 ) 250 } 251 252 fn id(name: &'static str) -> WidgetId { 253 WidgetId::ROOT.child(WidgetKey::new(name)) 254 } 255 256 fn action(n: u32) -> ActionId { 257 let Some(nz) = NonZeroU32::new(n) else { 258 panic!("test action id must be non-zero"); 259 }; 260 ActionId::new(nz) 261 } 262 263 #[test] 264 fn interact_registers_tab_stop_when_focusable() { 265 let theme = Arc::new(Theme::light()); 266 let mut focus = FocusManager::new(); 267 let hotkeys = HotkeyTable::new(); 268 let mut hits = HitFrame::new(); 269 let prev = HitState::new(); 270 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 271 let mut shaper = bone_text::Shaper::new(); 272 let mut a11y = AccessTreeBuilder::new(); 273 { 274 let mut frame = FrameCtx::new( 275 theme, 276 &mut input, 277 &mut focus, 278 &hotkeys, 279 StringTable::empty(), 280 &mut hits, 281 &prev, 282 &mut a11y, 283 &mut shaper, 284 ); 285 let _ = frame.interact( 286 InteractDeclaration::new(id("button"), rect(), Sense::INTERACTIVE).focusable(true), 287 ); 288 } 289 assert_eq!(focus.tab_stops().len(), 1); 290 assert!(hits.items().iter().any(|item| item.id == id("button"))); 291 } 292 293 #[test] 294 fn dispatch_hotkeys_consumes_pressed_chord() { 295 let chord = KeyChord::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL); 296 let Ok(table) = HotkeyTable::try_from_bindings(vec![HotkeyBinding::new( 297 chord, 298 HotkeyScope::Global, 299 action(42), 300 )]) else { 301 panic!("registration must succeed"); 302 }; 303 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 304 input.keys_pressed.push(KeyEvent::new( 305 KeyCode::Char(KeyChar::from_char('s')), 306 ModifierMask::CTRL, 307 )); 308 let mut focus = FocusManager::new(); 309 let mut hits = HitFrame::new(); 310 let prev = HitState::new(); 311 let mut shaper = bone_text::Shaper::new(); 312 let mut a11y = AccessTreeBuilder::new(); 313 let actions = { 314 let mut frame = FrameCtx::new( 315 Arc::new(Theme::light()), 316 &mut input, 317 &mut focus, 318 &table, 319 StringTable::empty(), 320 &mut hits, 321 &prev, 322 &mut a11y, 323 &mut shaper, 324 ); 325 frame.dispatch_hotkeys(&global_scope()) 326 }; 327 assert_eq!(actions, vec![action(42)]); 328 assert!(input.keys_pressed.is_empty()); 329 } 330 331 #[test] 332 fn dispatch_hotkeys_leaves_unmatched_keys() { 333 let table = HotkeyTable::new(); 334 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 335 let event = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL); 336 input.keys_pressed.push(event); 337 let mut focus = FocusManager::new(); 338 let mut hits = HitFrame::new(); 339 let prev = HitState::new(); 340 let mut shaper = bone_text::Shaper::new(); 341 let mut a11y = AccessTreeBuilder::new(); 342 let actions = { 343 let mut frame = FrameCtx::new( 344 Arc::new(Theme::light()), 345 &mut input, 346 &mut focus, 347 &table, 348 StringTable::empty(), 349 &mut hits, 350 &prev, 351 &mut a11y, 352 &mut shaper, 353 ); 354 frame.dispatch_hotkeys(&global_scope()) 355 }; 356 assert!(actions.is_empty()); 357 assert_eq!(input.keys_pressed, vec![event]); 358 } 359 360 #[test] 361 fn end_to_end_press_release_routes_through_resolve() { 362 let theme = Arc::new(Theme::light()); 363 let mut focus = FocusManager::new(); 364 let hotkeys = HotkeyTable::new(); 365 let mut hits = HitFrame::new(); 366 let mut state = HitState::new(); 367 let mut press = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(0))); 368 press.pointer = Some(PointerSample::new(LayoutPos::new( 369 LayoutPx::new(10.0), 370 LayoutPx::new(10.0), 371 ))); 372 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 373 374 let mut shaper = bone_text::Shaper::new(); 375 let mut a11y = AccessTreeBuilder::new(); 376 { 377 let mut frame = FrameCtx::new( 378 theme.clone(), 379 &mut press, 380 &mut focus, 381 &hotkeys, 382 StringTable::empty(), 383 &mut hits, 384 &state, 385 &mut a11y, 386 &mut shaper, 387 ); 388 let _ = frame.interact( 389 InteractDeclaration::new(id("btn"), rect(), Sense::INTERACTIVE).focusable(true), 390 ); 391 } 392 state = resolve(&state, &hits, &press, focus.focused()); 393 assert!(state.interaction(id("btn")).pressed()); 394 395 hits.clear(); 396 let mut release = 397 InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(40))); 398 release.pointer = Some(PointerSample::new(LayoutPos::new( 399 LayoutPx::new(15.0), 400 LayoutPx::new(15.0), 401 ))); 402 release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 403 404 { 405 let mut frame = FrameCtx::new( 406 theme.clone(), 407 &mut release, 408 &mut focus, 409 &hotkeys, 410 StringTable::empty(), 411 &mut hits, 412 &state, 413 &mut a11y, 414 &mut shaper, 415 ); 416 let _ = frame.interact( 417 InteractDeclaration::new(id("btn"), rect(), Sense::INTERACTIVE).focusable(true), 418 ); 419 } 420 state = resolve(&state, &hits, &release, focus.focused()); 421 assert!(state.interaction(id("btn")).click()); 422 423 hits.clear(); 424 let mut idle = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(80))); 425 { 426 let mut frame = FrameCtx::new( 427 theme, 428 &mut idle, 429 &mut focus, 430 &hotkeys, 431 StringTable::empty(), 432 &mut hits, 433 &state, 434 &mut a11y, 435 &mut shaper, 436 ); 437 let _ = frame.interact( 438 InteractDeclaration::new(id("btn"), rect(), Sense::INTERACTIVE).focusable(true), 439 ); 440 } 441 assert_eq!( 442 focus.focused(), 443 Some(id("btn")), 444 "click on focusable widget auto-focuses next frame", 445 ); 446 } 447 448 #[test] 449 fn theme_scope_swaps_theme_for_body_and_restores_after() { 450 let mut focus = FocusManager::new(); 451 let hotkeys = HotkeyTable::new(); 452 let mut hits = HitFrame::new(); 453 let prev = HitState::new(); 454 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 455 let mut shaper = bone_text::Shaper::new(); 456 let mut a11y = AccessTreeBuilder::new(); 457 let mut frame = FrameCtx::new( 458 Arc::new(Theme::light()), 459 &mut input, 460 &mut focus, 461 &hotkeys, 462 StringTable::empty(), 463 &mut hits, 464 &prev, 465 &mut a11y, 466 &mut shaper, 467 ); 468 assert_eq!(frame.theme().mode, ThemeMode::Light); 469 let inner_mode = frame.theme_scope(|_| Theme::dark(), |frame| frame.theme().mode); 470 assert_eq!(inner_mode, ThemeMode::Dark); 471 assert_eq!(frame.theme().mode, ThemeMode::Light); 472 } 473 474 #[test] 475 fn nested_theme_scopes_unwind_lifo() { 476 let mut focus = FocusManager::new(); 477 let hotkeys = HotkeyTable::new(); 478 let mut hits = HitFrame::new(); 479 let prev = HitState::new(); 480 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 481 let mut shaper = bone_text::Shaper::new(); 482 let mut a11y = AccessTreeBuilder::new(); 483 let mut frame = FrameCtx::new( 484 Arc::new(Theme::light()), 485 &mut input, 486 &mut focus, 487 &hotkeys, 488 StringTable::empty(), 489 &mut hits, 490 &prev, 491 &mut a11y, 492 &mut shaper, 493 ); 494 let modes = frame.theme_scope( 495 |_| Theme::dark(), 496 |frame| { 497 let outer_mode = frame.theme().mode; 498 let inner_mode = frame.theme_scope(|_| Theme::light(), |frame| frame.theme().mode); 499 let after_inner = frame.theme().mode; 500 (outer_mode, inner_mode, after_inner) 501 }, 502 ); 503 assert_eq!(modes, (ThemeMode::Dark, ThemeMode::Light, ThemeMode::Dark)); 504 assert_eq!(frame.theme().mode, ThemeMode::Light); 505 } 506 507 #[test] 508 fn theme_scope_observes_modified_accent_via_clone() { 509 let mut focus = FocusManager::new(); 510 let hotkeys = HotkeyTable::new(); 511 let mut hits = HitFrame::new(); 512 let prev = HitState::new(); 513 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 514 let mut shaper = bone_text::Shaper::new(); 515 let mut a11y = AccessTreeBuilder::new(); 516 let mut frame = FrameCtx::new( 517 Arc::new(Theme::light()), 518 &mut input, 519 &mut focus, 520 &hotkeys, 521 StringTable::empty(), 522 &mut hits, 523 &prev, 524 &mut a11y, 525 &mut shaper, 526 ); 527 let outer_accent = frame.theme().colors.accent_solid(); 528 let outer_ring = frame.theme().colors.focus_ring(); 529 let (inner_accent, inner_ring) = frame.theme_scope( 530 |t| { 531 let mut next = t.clone(); 532 next.colors.accent = t.colors.danger; 533 next 534 }, 535 |frame| { 536 ( 537 frame.theme().colors.accent_solid(), 538 frame.theme().colors.focus_ring(), 539 ) 540 }, 541 ); 542 assert_ne!(inner_accent, outer_accent); 543 assert_eq!(inner_ring, inner_accent); 544 assert_eq!(frame.theme().colors.accent_solid(), outer_accent); 545 assert_eq!(frame.theme().colors.focus_ring(), outer_ring); 546 } 547 548 #[test] 549 fn hot_swap_between_frames_rebinds_token_reads() { 550 let mut focus = FocusManager::new(); 551 let hotkeys = HotkeyTable::new(); 552 let strings = StringTable::empty(); 553 let prev = HitState::new(); 554 let read_tokens = |theme: Arc<Theme>, 555 focus: &mut FocusManager, 556 hits: &mut HitFrame, 557 input: &mut InputSnapshot, 558 a11y: &mut AccessTreeBuilder, 559 shaper: &mut bone_text::Shaper| { 560 let frame = FrameCtx::new( 561 theme, input, focus, &hotkeys, strings, hits, &prev, a11y, shaper, 562 ); 563 ( 564 frame.theme().mode, 565 frame.theme().colors.text_primary(), 566 frame.theme().colors.surface(crate::theme::SurfaceLevel::L0), 567 ) 568 }; 569 570 let mut hits_a = HitFrame::new(); 571 let mut input_a = InputSnapshot::idle(FrameInstant::ZERO); 572 let mut shaper_a = bone_text::Shaper::new(); 573 let mut a11y_a = AccessTreeBuilder::new(); 574 let (mode_a, text_a, surface_a) = read_tokens( 575 Arc::new(Theme::light()), 576 &mut focus, 577 &mut hits_a, 578 &mut input_a, 579 &mut a11y_a, 580 &mut shaper_a, 581 ); 582 583 let mut hits_b = HitFrame::new(); 584 let mut input_b = InputSnapshot::idle(FrameInstant::ZERO); 585 let mut shaper_b = bone_text::Shaper::new(); 586 let mut a11y_b = AccessTreeBuilder::new(); 587 let (mode_b, text_b, surface_b) = read_tokens( 588 Arc::new(Theme::dark()), 589 &mut focus, 590 &mut hits_b, 591 &mut input_b, 592 &mut a11y_b, 593 &mut shaper_b, 594 ); 595 596 assert_eq!(mode_a, ThemeMode::Light); 597 assert_eq!(mode_b, ThemeMode::Dark); 598 assert_ne!(text_a, text_b); 599 assert_ne!(surface_a, surface_b); 600 assert!(surface_a.relative_luminance() > surface_b.relative_luminance()); 601 assert!(text_a.relative_luminance() < text_b.relative_luminance()); 602 } 603 604 #[test] 605 fn locale_and_direction_follow_string_table() { 606 let theme = Arc::new(Theme::light()); 607 let mut focus = FocusManager::new(); 608 let hotkeys = HotkeyTable::new(); 609 let mut hits = HitFrame::new(); 610 let prev = HitState::new(); 611 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 612 let strings = StringTable::for_locale(Locale::ArXb); 613 let mut shaper = bone_text::Shaper::new(); 614 let mut a11y = AccessTreeBuilder::new(); 615 let frame = FrameCtx::new( 616 theme, 617 &mut input, 618 &mut focus, 619 &hotkeys, 620 &strings, 621 &mut hits, 622 &prev, 623 &mut a11y, 624 &mut shaper, 625 ); 626 assert_eq!(frame.locale(), Locale::ArXb); 627 assert_eq!(frame.direction(), LayoutDirection::Rtl); 628 } 629}