Another project
0

Configure Feed

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

at main 21 kB View raw
1use crate::a11y::{AccessNode, Role}; 2use crate::frame::{FrameCtx, InteractDeclaration}; 3use crate::hit_test::Sense; 4use crate::input::{KeyCode, NamedKey}; 5use crate::layout::LayoutRect; 6use crate::strings::StringKey; 7use crate::widget_id::WidgetId; 8 9use super::keys::{TakeKey, take_activation, take_key}; 10use super::paint::{GlyphMark, WidgetPaint}; 11use super::visuals::{Indicator, IndicatorMark, push_indicator}; 12 13#[derive(Copy, Clone, Debug, PartialEq, Eq)] 14pub enum RadioOrientation { 15 Vertical, 16 Horizontal, 17} 18 19#[derive(Clone, Debug, PartialEq)] 20pub struct RadioOption<T: Copy + PartialEq> { 21 pub id: WidgetId, 22 pub rect: LayoutRect, 23 pub label: StringKey, 24 pub value: T, 25} 26 27#[derive(Clone, Debug, PartialEq)] 28pub struct RadioGroup<T: Copy + PartialEq> { 29 pub options: Vec<RadioOption<T>>, 30 pub selected: T, 31 pub orientation: RadioOrientation, 32 pub disabled: bool, 33} 34 35impl<T: Copy + PartialEq> RadioGroup<T> { 36 #[must_use] 37 pub fn new(options: Vec<RadioOption<T>>, selected: T) -> Self { 38 Self { 39 options, 40 selected, 41 orientation: RadioOrientation::Vertical, 42 disabled: false, 43 } 44 } 45 46 #[must_use] 47 pub fn orientation(self, orientation: RadioOrientation) -> Self { 48 Self { 49 orientation, 50 ..self 51 } 52 } 53 54 #[must_use] 55 pub fn disabled(self, disabled: bool) -> Self { 56 Self { disabled, ..self } 57 } 58} 59 60#[derive(Clone, Debug, PartialEq)] 61pub struct RadioGroupResponse<T: Copy + PartialEq> { 62 pub selected: T, 63 pub changed: bool, 64 pub paint: Vec<WidgetPaint>, 65} 66 67#[derive(Copy, Clone, Debug, PartialEq, Eq)] 68enum NavAction { 69 Prev, 70 Next, 71 First, 72 Last, 73} 74 75#[must_use] 76pub fn show_radio_group<T: Copy + PartialEq>( 77 ctx: &mut FrameCtx<'_>, 78 group: RadioGroup<T>, 79) -> RadioGroupResponse<T> { 80 let RadioGroup { 81 options, 82 selected: initial_selected, 83 orientation, 84 disabled, 85 } = group; 86 if !disabled 87 && let Some(tab_stop) = options 88 .iter() 89 .find(|o| o.value == initial_selected) 90 .or_else(|| options.first()) 91 .map(|o| o.id) 92 { 93 ctx.focus.register_tab_stop(tab_stop); 94 } 95 let mut paint = Vec::new(); 96 let mut clicked: Option<T> = None; 97 options.iter().for_each(|option| { 98 let active = option.value == initial_selected; 99 let interaction = ctx.interact( 100 InteractDeclaration::new(option.id, option.rect, Sense::INTERACTIVE) 101 .focusable(false) 102 .disabled(disabled) 103 .active(active) 104 .a11y( 105 AccessNode::new(Role::RadioButton) 106 .with_label(option.label) 107 .with_disabled(disabled) 108 .with_selected(active), 109 ), 110 ); 111 if !disabled && interaction.click() { 112 clicked = Some(option.value); 113 ctx.focus.request_focus(option.id); 114 } 115 let live_focused = ctx.is_focused(option.id); 116 push_indicator( 117 ctx, 118 &mut paint, 119 Indicator { 120 rect: option.rect, 121 label: option.label, 122 mark: active.then_some(IndicatorMark::Glyph(GlyphMark::RadioDot)), 123 active, 124 disabled, 125 radius: ctx.theme().radius.pill, 126 }, 127 interaction, 128 live_focused, 129 ); 130 }); 131 let in_group_focus = ctx 132 .focus 133 .focused() 134 .is_some_and(|id| options.iter().any(|o| o.id == id)); 135 if !disabled && in_group_focus { 136 if let Some(action) = take_navigation(ctx, orientation) { 137 let len = options.len(); 138 let current = options 139 .iter() 140 .position(|o| Some(o.id) == ctx.focus.focused()) 141 .unwrap_or(0); 142 let next = navigate(action, current, len); 143 if next != current 144 && let Some(target) = options.get(next) 145 { 146 ctx.focus.request_focus(target.id); 147 } 148 } 149 if take_activation(ctx.input) 150 && let Some(focused_id) = ctx.focus.focused() 151 && let Some(option) = options.iter().find(|o| o.id == focused_id) 152 { 153 clicked = Some(option.value); 154 } 155 } 156 let mut selected = initial_selected; 157 let changed = if let Some(value) = clicked { 158 selected = value; 159 value != initial_selected 160 } else { 161 false 162 }; 163 RadioGroupResponse { 164 selected, 165 changed, 166 paint, 167 } 168} 169 170fn take_navigation(ctx: &mut FrameCtx<'_>, orientation: RadioOrientation) -> Option<NavAction> { 171 let (prev, next) = match orientation { 172 RadioOrientation::Vertical => (NamedKey::ArrowUp, NamedKey::ArrowDown), 173 RadioOrientation::Horizontal => (NamedKey::ArrowLeft, NamedKey::ArrowRight), 174 }; 175 let event = take_key( 176 ctx.input, 177 &[ 178 TakeKey::named(prev), 179 TakeKey::named(next), 180 TakeKey::named(NamedKey::Home), 181 TakeKey::named(NamedKey::End), 182 ], 183 )?; 184 Some(match event.code { 185 KeyCode::Named(key) if key == prev => NavAction::Prev, 186 KeyCode::Named(key) if key == next => NavAction::Next, 187 KeyCode::Named(NamedKey::Home) => NavAction::First, 188 KeyCode::Named(NamedKey::End) => NavAction::Last, 189 _ => unreachable!("take_key only returns the listed candidates"), 190 }) 191} 192 193fn navigate(action: NavAction, current: usize, len: usize) -> usize { 194 if len == 0 { 195 return 0; 196 } 197 match action { 198 NavAction::Next => (current + 1) % len, 199 NavAction::Prev => (current + len - 1) % len, 200 NavAction::First => 0, 201 NavAction::Last => len - 1, 202 } 203} 204 205#[cfg(test)] 206mod tests { 207 use std::sync::Arc; 208 209 use super::{RadioGroup, RadioOption, RadioOrientation, show_radio_group}; 210 use crate::focus::FocusManager; 211 use crate::frame::FrameCtx; 212 use crate::hit_test::{HitFrame, HitState, resolve}; 213 use crate::hotkey::HotkeyTable; 214 use crate::input::{ 215 FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 216 PointerButtonMask, PointerSample, 217 }; 218 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 219 use crate::strings::StringKey; 220 use crate::strings::StringTable; 221 use crate::theme::Theme; 222 use crate::widget_id::{WidgetId, WidgetKey}; 223 224 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 225 enum Pick { 226 A, 227 B, 228 C, 229 } 230 231 fn group_id() -> WidgetId { 232 WidgetId::ROOT.child(WidgetKey::new("group")) 233 } 234 235 fn opt_rect(index: f32) -> LayoutRect { 236 LayoutRect::new( 237 LayoutPos::new(LayoutPx::new(index * 80.0), LayoutPx::ZERO), 238 LayoutSize::new(LayoutPx::new(72.0), LayoutPx::new(28.0)), 239 ) 240 } 241 242 fn three_options() -> Vec<RadioOption<Pick>> { 243 vec![ 244 RadioOption { 245 id: group_id().child(WidgetKey::new("a")), 246 rect: opt_rect(0.0), 247 label: StringKey::new("opt.a"), 248 value: Pick::A, 249 }, 250 RadioOption { 251 id: group_id().child(WidgetKey::new("b")), 252 rect: opt_rect(1.0), 253 label: StringKey::new("opt.b"), 254 value: Pick::B, 255 }, 256 RadioOption { 257 id: group_id().child(WidgetKey::new("c")), 258 rect: opt_rect(2.0), 259 label: StringKey::new("opt.c"), 260 value: Pick::C, 261 }, 262 ] 263 } 264 265 fn at(option: usize) -> LayoutPos { 266 #[allow(clippy::cast_precision_loss, reason = "test option index < 16")] 267 let option_f32 = option as f32; 268 LayoutPos::new(LayoutPx::new(option_f32 * 80.0 + 10.0), LayoutPx::new(10.0)) 269 } 270 271 fn press_at(pos: LayoutPos) -> InputSnapshot { 272 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 273 s.pointer = Some(PointerSample::new(pos)); 274 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 275 s 276 } 277 278 fn release_at(pos: LayoutPos) -> InputSnapshot { 279 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 280 s.pointer = Some(PointerSample::new(pos)); 281 s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 282 s 283 } 284 285 fn idle_at(pos: LayoutPos) -> InputSnapshot { 286 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 287 s.pointer = Some(PointerSample::new(pos)); 288 s 289 } 290 291 fn run_pick(target: usize) -> Pick { 292 let theme = Arc::new(Theme::light()); 293 let table = HotkeyTable::new(); 294 let mut focus = FocusManager::new(); 295 let mut hits = HitFrame::new(); 296 let mut state = HitState::new(); 297 let mut selected = Pick::A; 298 [ 299 press_at(at(target)), 300 release_at(at(target)), 301 idle_at(at(target)), 302 ] 303 .into_iter() 304 .for_each(|mut input| { 305 hits.clear(); 306 let group = RadioGroup::new(three_options(), selected); 307 let response = { 308 let mut shaper = bone_text::Shaper::new(); 309 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 310 let mut ctx = FrameCtx::new( 311 theme.clone(), 312 &mut input, 313 &mut focus, 314 &table, 315 StringTable::empty(), 316 &mut hits, 317 &state, 318 &mut a11y, 319 &mut shaper, 320 ); 321 show_radio_group(&mut ctx, group) 322 }; 323 selected = response.selected; 324 state = resolve(&state, &hits, &input, focus.focused()); 325 }); 326 selected 327 } 328 329 #[test] 330 fn click_picks_target_option() { 331 assert_eq!(run_pick(1), Pick::B); 332 assert_eq!(run_pick(2), Pick::C); 333 } 334 335 fn run_with_focus_and_keys( 336 options: Vec<RadioOption<Pick>>, 337 selected: Pick, 338 orientation: RadioOrientation, 339 focus_target: WidgetId, 340 events: Vec<KeyEvent>, 341 ) -> (super::RadioGroupResponse<Pick>, FocusManager) { 342 let theme = Arc::new(Theme::light()); 343 let table = HotkeyTable::new(); 344 let mut focus = FocusManager::new(); 345 focus.register_focusable(focus_target); 346 focus.request_focus(focus_target); 347 focus.end_frame(); 348 let mut hits = HitFrame::new(); 349 let prev = HitState::new(); 350 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 351 input.keys_pressed = events; 352 let group = RadioGroup::new(options, selected).orientation(orientation); 353 let response = { 354 let mut shaper = bone_text::Shaper::new(); 355 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 356 let mut ctx = FrameCtx::new( 357 theme, 358 &mut input, 359 &mut focus, 360 &table, 361 StringTable::empty(), 362 &mut hits, 363 &prev, 364 &mut a11y, 365 &mut shaper, 366 ); 367 show_radio_group(&mut ctx, group) 368 }; 369 (response, focus) 370 } 371 372 #[test] 373 fn vertical_arrow_down_roves_focus_within_group() { 374 let options = three_options(); 375 let first_id = options[0].id; 376 let second_id = options[1].id; 377 let (response, focus) = run_with_focus_and_keys( 378 options, 379 Pick::A, 380 RadioOrientation::Vertical, 381 first_id, 382 vec![KeyEvent::new( 383 KeyCode::Named(NamedKey::ArrowDown), 384 ModifierMask::NONE, 385 )], 386 ); 387 assert!(!response.changed); 388 assert_eq!(focus.focused(), Some(second_id)); 389 } 390 391 #[test] 392 fn horizontal_arrow_right_roves_focus() { 393 let options = three_options(); 394 let first_id = options[0].id; 395 let second_id = options[1].id; 396 let (_, focus) = run_with_focus_and_keys( 397 options, 398 Pick::A, 399 RadioOrientation::Horizontal, 400 first_id, 401 vec![KeyEvent::new( 402 KeyCode::Named(NamedKey::ArrowRight), 403 ModifierMask::NONE, 404 )], 405 ); 406 assert_eq!(focus.focused(), Some(second_id)); 407 } 408 409 #[test] 410 fn vertical_ignores_horizontal_arrow() { 411 let options = three_options(); 412 let first_id = options[0].id; 413 let arrow = KeyEvent::new(KeyCode::Named(NamedKey::ArrowRight), ModifierMask::NONE); 414 let (_, focus) = run_with_focus_and_keys( 415 options, 416 Pick::A, 417 RadioOrientation::Vertical, 418 first_id, 419 vec![arrow], 420 ); 421 assert_eq!( 422 focus.focused(), 423 Some(first_id), 424 "vertical orientation ignores ArrowRight" 425 ); 426 } 427 428 #[test] 429 fn horizontal_ignores_vertical_arrow() { 430 let options = three_options(); 431 let first_id = options[0].id; 432 let (_, focus) = run_with_focus_and_keys( 433 options, 434 Pick::A, 435 RadioOrientation::Horizontal, 436 first_id, 437 vec![KeyEvent::new( 438 KeyCode::Named(NamedKey::ArrowDown), 439 ModifierMask::NONE, 440 )], 441 ); 442 assert_eq!(focus.focused(), Some(first_id)); 443 } 444 445 #[test] 446 fn home_jumps_to_first_option() { 447 let options = three_options(); 448 let third_id = options[2].id; 449 let first_id = options[0].id; 450 let (_, focus) = run_with_focus_and_keys( 451 options, 452 Pick::C, 453 RadioOrientation::Vertical, 454 third_id, 455 vec![KeyEvent::new( 456 KeyCode::Named(NamedKey::Home), 457 ModifierMask::NONE, 458 )], 459 ); 460 assert_eq!(focus.focused(), Some(first_id)); 461 } 462 463 #[test] 464 fn end_jumps_to_last_option() { 465 let options = three_options(); 466 let first_id = options[0].id; 467 let third_id = options[2].id; 468 let (_, focus) = run_with_focus_and_keys( 469 options, 470 Pick::A, 471 RadioOrientation::Vertical, 472 first_id, 473 vec![KeyEvent::new( 474 KeyCode::Named(NamedKey::End), 475 ModifierMask::NONE, 476 )], 477 ); 478 assert_eq!(focus.focused(), Some(third_id)); 479 } 480 481 #[test] 482 fn space_picks_focused_option() { 483 let options = three_options(); 484 let second_id = options[1].id; 485 let (response, _) = run_with_focus_and_keys( 486 options, 487 Pick::A, 488 RadioOrientation::Vertical, 489 second_id, 490 vec![KeyEvent::new( 491 KeyCode::Named(NamedKey::Space), 492 ModifierMask::NONE, 493 )], 494 ); 495 assert!(response.changed); 496 assert_eq!(response.selected, Pick::B); 497 } 498 499 #[test] 500 fn enter_picks_focused_option() { 501 let options = three_options(); 502 let second_id = options[1].id; 503 let (response, _) = run_with_focus_and_keys( 504 options, 505 Pick::A, 506 RadioOrientation::Vertical, 507 second_id, 508 vec![KeyEvent::new( 509 KeyCode::Named(NamedKey::Enter), 510 ModifierMask::NONE, 511 )], 512 ); 513 assert!(response.changed); 514 assert_eq!(response.selected, Pick::B); 515 } 516 517 #[test] 518 fn selected_outside_options_falls_back_to_first_tab_stop() { 519 let theme = Arc::new(Theme::light()); 520 let table = HotkeyTable::new(); 521 let mut focus = FocusManager::new(); 522 let mut hits = HitFrame::new(); 523 let state = HitState::new(); 524 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 525 let two = vec![three_options()[0].clone(), three_options()[1].clone()]; 526 let first_id = two[0].id; 527 let group = RadioGroup::new(two, Pick::C); 528 { 529 let mut shaper = bone_text::Shaper::new(); 530 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 531 let mut ctx = FrameCtx::new( 532 theme, 533 &mut input, 534 &mut focus, 535 &table, 536 StringTable::empty(), 537 &mut hits, 538 &state, 539 &mut a11y, 540 &mut shaper, 541 ); 542 let _ = show_radio_group(&mut ctx, group); 543 } 544 assert_eq!( 545 focus.tab_stops().iter().map(|(id, _)| *id).next(), 546 Some(first_id), 547 "selection not in options still leaves the group Tab-reachable", 548 ); 549 } 550 551 #[test] 552 fn selected_option_is_only_parent_tab_stop() { 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 state = HitState::new(); 558 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 559 let options = three_options(); 560 let group = RadioGroup::new(options.clone(), Pick::B); 561 { 562 let mut shaper = bone_text::Shaper::new(); 563 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 564 let mut ctx = FrameCtx::new( 565 theme, 566 &mut input, 567 &mut focus, 568 &table, 569 StringTable::empty(), 570 &mut hits, 571 &state, 572 &mut a11y, 573 &mut shaper, 574 ); 575 let _ = show_radio_group(&mut ctx, group); 576 } 577 let stop_ids: Vec<WidgetId> = focus.tab_stops().iter().map(|(id, _)| *id).collect(); 578 assert_eq!( 579 stop_ids, 580 vec![options[1].id], 581 "only selected option enters tab order" 582 ); 583 } 584 585 #[test] 586 fn keys_pass_through_when_focus_outside_group() { 587 let theme = Arc::new(Theme::light()); 588 let table = HotkeyTable::new(); 589 let mut focus = FocusManager::new(); 590 let mut hits = HitFrame::new(); 591 let state = HitState::new(); 592 let arrow = KeyEvent::new(KeyCode::Named(NamedKey::ArrowDown), ModifierMask::NONE); 593 let space = KeyEvent::new(KeyCode::Named(NamedKey::Space), ModifierMask::NONE); 594 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 595 input.keys_pressed = vec![arrow, space]; 596 let group = RadioGroup::new(three_options(), Pick::A); 597 { 598 let mut shaper = bone_text::Shaper::new(); 599 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 600 let mut ctx = FrameCtx::new( 601 theme, 602 &mut input, 603 &mut focus, 604 &table, 605 StringTable::empty(), 606 &mut hits, 607 &state, 608 &mut a11y, 609 &mut shaper, 610 ); 611 let _ = show_radio_group(&mut ctx, group); 612 } 613 assert_eq!( 614 input.keys_pressed, 615 vec![arrow, space], 616 "no in-group focus, keys preserved" 617 ); 618 } 619 620 #[test] 621 fn disabled_group_does_not_change_selection_via_pointer() { 622 let theme = Arc::new(Theme::light()); 623 let table = HotkeyTable::new(); 624 let mut focus = FocusManager::new(); 625 let mut hits = HitFrame::new(); 626 let mut state = HitState::new(); 627 let mut selected = Pick::A; 628 [press_at(at(1)), release_at(at(1)), idle_at(at(1))] 629 .into_iter() 630 .for_each(|mut input| { 631 hits.clear(); 632 let group = RadioGroup::new(three_options(), selected).disabled(true); 633 let response = { 634 let mut shaper = bone_text::Shaper::new(); 635 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 636 let mut ctx = FrameCtx::new( 637 theme.clone(), 638 &mut input, 639 &mut focus, 640 &table, 641 StringTable::empty(), 642 &mut hits, 643 &state, 644 &mut a11y, 645 &mut shaper, 646 ); 647 show_radio_group(&mut ctx, group) 648 }; 649 selected = response.selected; 650 state = resolve(&state, &hits, &input, focus.focused()); 651 }); 652 assert_eq!(selected, Pick::A); 653 } 654}