Another project
1
fork

Configure Feed

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

feat(ui): radio group widget

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

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