Another project
1
fork

Configure Feed

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

feat(ui): a11y tree smoke test

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

+726 -13
+677
crates/bone-ui/tests/a11y_smoke.rs
··· 1 + use core::time::Duration; 2 + use std::collections::BTreeSet; 3 + use std::sync::Arc; 4 + 5 + use accesskit::NodeId; 6 + use uom::si::angle::degree; 7 + use uom::si::f64::{Angle, Length}; 8 + use uom::si::length::millimeter; 9 + 10 + use bone_ui::a11y::{root_node_id, widget_node_id}; 11 + use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 12 + use bone_ui::widgets::{ 13 + AlwaysValid, AngleEditor, BoolEditor, Button, ButtonVariant, Checkbox, CheckboxState, 14 + ConfirmationDialog, ContextMenu, Dialog, DialogButton, Dropdown, DropdownItem, DropdownState, 15 + FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerState, HotkeyCapture, 16 + HotkeyCaptureState, LengthEditor, ListItem, ListView, ListViewState, MemoryClipboard, Menu, 17 + MenuBar, MenuBarEntry, MenuBarState, MenuItem, MenuState, Modal, NumericInput, Panel, 18 + PanelState, PanelTitlebar, PropertyGrid, PropertyOption, PropertyRow, RadioGroup, RadioOption, 19 + Ribbon, RibbonGroup, RibbonIconSize, RibbonState, RibbonTab, SelectionEditor, Slider, 20 + SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Table, TableColumn, TableRow, 21 + TableState, Tabs, TabsOrientation, TextEditor, TextInput, TextInputState, Toast, ToastKind, 22 + ToastState, ToggleButton, Toolbar, ToolbarItem, Tooltip, TooltipPlacement, TooltipState, 23 + TreeNode, TreeView, TreeViewState, show_button, show_checkbox, show_confirmation, 24 + show_context_menu, show_dialog, show_dropdown, show_file_picker, show_hotkey_capture, 25 + show_list_view, show_menu, show_menu_bar, show_modal, show_panel, show_parsed_input, 26 + show_property_grid, show_radio_group, show_ribbon, show_slider, show_status_bar, show_table, 27 + show_tabs, show_text_input, show_toast, show_toggle_button, show_toolbar, show_tooltip, 28 + show_tree_view, 29 + }; 30 + use bone_ui::{ 31 + AccessTreeBuilder, FocusManager, FrameCtx, FrameInstant, HitFrame, HitState, HotkeyTable, 32 + InputSnapshot, StringKey, StringTable, Theme, WidgetId, WidgetKey, 33 + }; 34 + 35 + const LABEL: StringKey = StringKey::new("smoke.label"); 36 + 37 + fn id(name: &'static str) -> WidgetId { 38 + WidgetId::ROOT.child(WidgetKey::new(name)) 39 + } 40 + 41 + fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 42 + LayoutRect::new( 43 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 44 + LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), 45 + ) 46 + } 47 + 48 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 49 + enum Choice { 50 + A, 51 + B, 52 + } 53 + 54 + const FRAME_NOW: FrameInstant = FrameInstant::from_duration(Duration::from_secs(1)); 55 + 56 + struct GalleryState { 57 + panel: PanelState, 58 + dropdown: DropdownState, 59 + text_input: TextInputState, 60 + numeric_input: TextInputState, 61 + clipboard: MemoryClipboard, 62 + list: ListViewState, 63 + table: TableState, 64 + tree: TreeViewState, 65 + toolbar_overflow: bool, 66 + ribbon: RibbonState, 67 + menu: MenuState, 68 + context_menu: MenuState, 69 + menu_bar: MenuBarState, 70 + tooltip: TooltipState, 71 + toast: ToastState, 72 + file_picker: FilePickerState, 73 + property_text: TextEditor, 74 + property_bool: BoolEditor, 75 + property_select: SelectionEditor, 76 + property_length: LengthEditor, 77 + property_angle: AngleEditor, 78 + hotkey: HotkeyCaptureState, 79 + } 80 + 81 + impl GalleryState { 82 + fn new() -> Self { 83 + Self { 84 + panel: PanelState::open(), 85 + dropdown: DropdownState::closed(), 86 + text_input: TextInputState::from_text("Hello"), 87 + numeric_input: TextInputState::from_text("42"), 88 + clipboard: MemoryClipboard::default(), 89 + list: ListViewState::default(), 90 + table: TableState::default(), 91 + tree: TreeViewState::default(), 92 + toolbar_overflow: false, 93 + ribbon: RibbonState::default(), 94 + menu: MenuState::default(), 95 + context_menu: MenuState::default(), 96 + menu_bar: MenuBarState::default(), 97 + tooltip: TooltipState { 98 + hover_began: Some(FrameInstant::ZERO), 99 + ..TooltipState::default() 100 + }, 101 + toast: ToastState::fresh(), 102 + file_picker: FilePickerState::default(), 103 + property_text: TextEditor::new("name"), 104 + property_bool: BoolEditor::new(true), 105 + property_select: SelectionEditor::new( 106 + vec![ 107 + PropertyOption { 108 + label: StringKey::new("smoke.option_a"), 109 + }, 110 + PropertyOption { 111 + label: StringKey::new("smoke.option_b"), 112 + }, 113 + ], 114 + Some(0), 115 + ), 116 + property_length: LengthEditor::new(Length::new::<millimeter>(10.0)), 117 + property_angle: AngleEditor::new(Angle::new::<degree>(45.0)), 118 + hotkey: HotkeyCaptureState::default(), 119 + } 120 + } 121 + } 122 + 123 + fn render_gallery(focus: &mut FocusManager, a11y: &mut AccessTreeBuilder, state: &mut GalleryState) { 124 + let theme = Arc::new(Theme::light()); 125 + let table = HotkeyTable::new(); 126 + let mut hits = HitFrame::new(); 127 + let prev = HitState::new(); 128 + let mut input = InputSnapshot::idle(FRAME_NOW); 129 + 130 + let mut ctx = FrameCtx::new( 131 + theme, 132 + &mut input, 133 + focus, 134 + &table, 135 + StringTable::empty(), 136 + &mut hits, 137 + &prev, 138 + a11y, 139 + ); 140 + 141 + render_basics(&mut ctx, state); 142 + render_inputs(&mut ctx, state); 143 + render_collections(&mut ctx, state); 144 + render_chrome(&mut ctx, state); 145 + render_overlays(&mut ctx, state); 146 + } 147 + 148 + #[allow( 149 + clippy::too_many_lines, 150 + reason = "gallery test renders many widgets in one frame" 151 + )] 152 + fn render_basics(ctx: &mut FrameCtx<'_>, state: &mut GalleryState) { 153 + let _ = show_button( 154 + ctx, 155 + Button::new(id("button"), rect(0.0, 0.0, 80.0, 24.0), LABEL, ButtonVariant::Primary), 156 + ); 157 + let _ = show_checkbox( 158 + ctx, 159 + Checkbox::new( 160 + id("check"), 161 + rect(0.0, 28.0, 80.0, 20.0), 162 + LABEL, 163 + CheckboxState::Checked, 164 + ), 165 + ); 166 + let _ = show_toggle_button( 167 + ctx, 168 + ToggleButton::new(id("toggle"), rect(0.0, 52.0, 80.0, 20.0), LABEL, true), 169 + ); 170 + let _ = show_radio_group( 171 + ctx, 172 + RadioGroup::new( 173 + vec![ 174 + RadioOption { 175 + id: id("radio_a"), 176 + rect: rect(0.0, 76.0, 80.0, 20.0), 177 + label: LABEL, 178 + value: Choice::A, 179 + }, 180 + RadioOption { 181 + id: id("radio_b"), 182 + rect: rect(0.0, 100.0, 80.0, 20.0), 183 + label: LABEL, 184 + value: Choice::B, 185 + }, 186 + ], 187 + Choice::A, 188 + ), 189 + ); 190 + let Ok(slider_range) = SliderRange::try_new(0.0_f64, 10.0) else { 191 + panic!("slider range") 192 + }; 193 + let Ok(slider_step) = SliderStep::try_new(1.0_f64) else { 194 + panic!("slider step") 195 + }; 196 + let _ = show_slider( 197 + ctx, 198 + Slider::new( 199 + id("slider"), 200 + rect(0.0, 124.0, 200.0, 18.0), 201 + LABEL, 202 + 5.0_f64, 203 + slider_range, 204 + slider_step, 205 + ), 206 + ); 207 + let tab_items = [ 208 + Tab::new(id("tab_a"), rect(0.0, 144.0, 80.0, 24.0), LABEL), 209 + Tab::new(id("tab_b"), rect(80.0, 144.0, 80.0, 24.0), LABEL), 210 + ]; 211 + let _ = show_tabs( 212 + ctx, 213 + Tabs::new( 214 + id("tabs"), 215 + TabsOrientation::Top, 216 + LABEL, 217 + &tab_items, 218 + id("tab_a"), 219 + ), 220 + ); 221 + let status_items = [ 222 + StatusItem::new(id("status_item"), LABEL, StatusAlign::Start, LayoutPx::new(80.0)) 223 + .interactive(true), 224 + ]; 225 + let _ = show_status_bar( 226 + ctx, 227 + StatusBar::new( 228 + id("status_bar"), 229 + rect(0.0, 168.0, 200.0, 22.0), 230 + LABEL, 231 + &status_items, 232 + ), 233 + ); 234 + let _ = show_panel( 235 + ctx, 236 + Panel::new(id("panel"), rect(0.0, 192.0, 200.0, 100.0), &mut state.panel).titlebar( 237 + PanelTitlebar { 238 + label: LABEL, 239 + height: LayoutPx::new(22.0), 240 + collapsible: true, 241 + }, 242 + ), 243 + ); 244 + let _ = show_dropdown( 245 + ctx, 246 + Dropdown::new( 247 + id("dropdown"), 248 + rect(0.0, 296.0, 160.0, 24.0), 249 + LayoutPx::new(20.0), 250 + vec![ 251 + DropdownItem { 252 + value: Choice::A, 253 + label: LABEL, 254 + }, 255 + DropdownItem { 256 + value: Choice::B, 257 + label: LABEL, 258 + }, 259 + ], 260 + None, 261 + LABEL, 262 + &mut state.dropdown, 263 + ), 264 + ); 265 + } 266 + 267 + fn render_inputs(ctx: &mut FrameCtx<'_>, state: &mut GalleryState) { 268 + let _ = show_text_input( 269 + ctx, 270 + TextInput { 271 + id: id("text_input"), 272 + rect: rect(0.0, 324.0, 200.0, 24.0), 273 + placeholder: LABEL, 274 + state: &mut state.text_input, 275 + disabled: false, 276 + validator: AlwaysValid, 277 + }, 278 + &mut state.clipboard, 279 + ); 280 + let _ = show_parsed_input::<i32, _>( 281 + ctx, 282 + NumericInput::<i32>::new( 283 + id("numeric_input"), 284 + rect(0.0, 352.0, 200.0, 24.0), 285 + LABEL, 286 + &mut state.numeric_input, 287 + ), 288 + &mut state.clipboard, 289 + ); 290 + let _ = show_hotkey_capture( 291 + ctx, 292 + HotkeyCapture::new( 293 + id("hotkey_capture"), 294 + rect(0.0, 380.0, 200.0, 24.0), 295 + LABEL, 296 + &mut state.hotkey, 297 + ), 298 + ); 299 + } 300 + 301 + #[allow( 302 + clippy::too_many_lines, 303 + reason = "gallery test renders many widgets in one frame" 304 + )] 305 + fn render_collections(ctx: &mut FrameCtx<'_>, state: &mut GalleryState) { 306 + let list_items = [ 307 + ListItem { 308 + id: id("list_a"), 309 + label: LABEL, 310 + }, 311 + ListItem { 312 + id: id("list_b"), 313 + label: LABEL, 314 + }, 315 + ]; 316 + let _ = show_list_view( 317 + ctx, 318 + ListView::new( 319 + id("list_view"), 320 + rect(220.0, 0.0, 180.0, 60.0), 321 + LABEL, 322 + &list_items, 323 + &mut state.list, 324 + ), 325 + ); 326 + let columns = [ 327 + TableColumn::new(id("col_a"), LABEL, LayoutPx::new(80.0)), 328 + TableColumn::new(id("col_b"), LABEL, LayoutPx::new(80.0)), 329 + ]; 330 + let rows = [ 331 + TableRow { 332 + id: id("row_a"), 333 + cells: [LABEL, LABEL], 334 + }, 335 + TableRow { 336 + id: id("row_b"), 337 + cells: [LABEL, LABEL], 338 + }, 339 + ]; 340 + let _ = show_table( 341 + ctx, 342 + Table::new( 343 + id("table"), 344 + rect(220.0, 64.0, 180.0, 80.0), 345 + LABEL, 346 + &columns, 347 + &rows, 348 + &mut state.table, 349 + ), 350 + ); 351 + let tree_roots = [TreeNode::parent( 352 + id("tree_root"), 353 + LABEL, 354 + vec![TreeNode::leaf(id("tree_child"), LABEL)], 355 + )]; 356 + state.tree.expanded.insert(id("tree_root")); 357 + let _ = show_tree_view( 358 + ctx, 359 + TreeView::new( 360 + id("tree_view"), 361 + rect(220.0, 152.0, 180.0, 60.0), 362 + LABEL, 363 + &tree_roots, 364 + &mut state.tree, 365 + ), 366 + ); 367 + let mut rows: [PropertyRow<'_>; 5] = [ 368 + PropertyRow { 369 + id: id("prop_text"), 370 + label: StringKey::new("smoke.prop.text"), 371 + editor: &mut state.property_text, 372 + read_only: false, 373 + }, 374 + PropertyRow { 375 + id: id("prop_bool"), 376 + label: StringKey::new("smoke.prop.bool"), 377 + editor: &mut state.property_bool, 378 + read_only: false, 379 + }, 380 + PropertyRow { 381 + id: id("prop_select"), 382 + label: StringKey::new("smoke.prop.select"), 383 + editor: &mut state.property_select, 384 + read_only: false, 385 + }, 386 + PropertyRow { 387 + id: id("prop_length"), 388 + label: StringKey::new("smoke.prop.length"), 389 + editor: &mut state.property_length, 390 + read_only: false, 391 + }, 392 + PropertyRow { 393 + id: id("prop_angle"), 394 + label: StringKey::new("smoke.prop.angle"), 395 + editor: &mut state.property_angle, 396 + read_only: false, 397 + }, 398 + ]; 399 + let _ = show_property_grid( 400 + ctx, 401 + PropertyGrid::new( 402 + id("property_grid"), 403 + rect(220.0, 220.0, 240.0, 160.0), 404 + LABEL, 405 + &mut rows, 406 + ), 407 + &mut state.clipboard, 408 + ); 409 + } 410 + 411 + #[allow( 412 + clippy::too_many_lines, 413 + reason = "gallery test renders many widgets in one frame" 414 + )] 415 + fn render_chrome(ctx: &mut FrameCtx<'_>, state: &mut GalleryState) { 416 + let toolbar_items = [ 417 + ToolbarItem::new(id("toolbar_a"), LABEL), 418 + ToolbarItem::new(id("toolbar_b"), LABEL), 419 + ]; 420 + let _ = show_toolbar( 421 + ctx, 422 + Toolbar::horizontal( 423 + id("toolbar"), 424 + rect(480.0, 0.0, 160.0, 32.0), 425 + LABEL, 426 + &toolbar_items, 427 + LayoutPx::new(28.0), 428 + LayoutPx::new(4.0), 429 + ), 430 + &mut state.toolbar_overflow, 431 + ); 432 + let ribbon_toolbar_items = [ 433 + ToolbarItem::new(id("ribbon_tool_a"), LABEL), 434 + ToolbarItem::new(id("ribbon_tool_b"), LABEL), 435 + ]; 436 + let ribbon_tabs = [RibbonTab { 437 + tab: Tab::new(id("ribbon_tab"), rect(0.0, 0.0, 80.0, 24.0), LABEL), 438 + groups: vec![RibbonGroup { 439 + id: id("ribbon_group"), 440 + label: LABEL, 441 + items: ribbon_toolbar_items.to_vec(), 442 + icon_size: RibbonIconSize::Large, 443 + width: LayoutPx::new(140.0), 444 + }], 445 + }]; 446 + let _ = show_ribbon( 447 + ctx, 448 + Ribbon::new( 449 + id("ribbon"), 450 + rect(480.0, 36.0, 200.0, 96.0), 451 + LABEL, 452 + &ribbon_tabs, 453 + id("ribbon_tab"), 454 + &mut state.ribbon, 455 + ), 456 + ); 457 + let menu_items = vec![ 458 + MenuItem::Action { 459 + id: id("menu_action"), 460 + label: LABEL, 461 + shortcut: None, 462 + disabled: false, 463 + }, 464 + MenuItem::Separator, 465 + MenuItem::Submenu { 466 + id: id("menu_submenu"), 467 + label: LABEL, 468 + items: vec![MenuItem::Action { 469 + id: id("menu_subaction"), 470 + label: LABEL, 471 + shortcut: None, 472 + disabled: false, 473 + }], 474 + }, 475 + ]; 476 + let _ = show_menu( 477 + ctx, 478 + Menu::new( 479 + id("menu"), 480 + LayoutPos::new(LayoutPx::new(480.0), LayoutPx::new(140.0)), 481 + LABEL, 482 + &menu_items, 483 + &mut state.menu, 484 + ), 485 + ); 486 + let context_items = vec![MenuItem::Action { 487 + id: id("context_action"), 488 + label: LABEL, 489 + shortcut: None, 490 + disabled: false, 491 + }]; 492 + let _ = show_context_menu( 493 + ctx, 494 + ContextMenu::at_cursor( 495 + id("context_menu"), 496 + LayoutPos::new(LayoutPx::new(480.0), LayoutPx::new(220.0)), 497 + LABEL, 498 + &context_items, 499 + &mut state.context_menu, 500 + ), 501 + ); 502 + let menu_bar_entries = [MenuBarEntry { 503 + id: id("menubar_entry"), 504 + label: LABEL, 505 + items: vec![MenuItem::Action { 506 + id: id("menubar_action"), 507 + label: LABEL, 508 + shortcut: None, 509 + disabled: false, 510 + }], 511 + }]; 512 + let _ = show_menu_bar( 513 + ctx, 514 + MenuBar::new( 515 + id("menu_bar"), 516 + rect(480.0, 280.0, 200.0, 24.0), 517 + LABEL, 518 + &menu_bar_entries, 519 + &mut state.menu_bar, 520 + ), 521 + ); 522 + } 523 + 524 + fn render_overlays(ctx: &mut FrameCtx<'_>, state: &mut GalleryState) { 525 + let _ = show_tooltip( 526 + ctx, 527 + Tooltip::new( 528 + id("button"), 529 + rect(0.0, 0.0, 80.0, 24.0), 530 + LABEL, 531 + TooltipPlacement::Below, 532 + LayoutSize::new(LayoutPx::new(120.0), LayoutPx::new(20.0)), 533 + ), 534 + &mut state.tooltip, 535 + ); 536 + let _ = show_toast( 537 + ctx, 538 + Toast::new( 539 + id("toast"), 540 + rect(20.0, 560.0, 360.0, 48.0), 541 + ToastKind::Info, 542 + LABEL, 543 + &mut state.toast, 544 + ), 545 + ); 546 + let (_, ()) = show_modal( 547 + ctx, 548 + Modal::new( 549 + id("modal"), 550 + rect(700.0, 0.0, 600.0, 400.0), 551 + LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)), 552 + LABEL, 553 + ), 554 + |_ctx, _body, _paint| {}, 555 + ); 556 + let dialog_buttons = [ 557 + DialogButton::primary(id("dialog_ok"), LABEL), 558 + DialogButton::secondary(id("dialog_cancel"), LABEL), 559 + ]; 560 + let (_, ()) = show_dialog( 561 + ctx, 562 + Dialog::new( 563 + id("dialog"), 564 + rect(700.0, 410.0, 600.0, 400.0), 565 + LayoutSize::new(LayoutPx::new(360.0), LayoutPx::new(200.0)), 566 + LABEL, 567 + &dialog_buttons, 568 + ), 569 + |_ctx, _body, _paint| {}, 570 + ); 571 + let _ = show_confirmation( 572 + ctx, 573 + ConfirmationDialog { 574 + id: id("confirmation"), 575 + viewport: rect(700.0, 820.0, 600.0, 400.0), 576 + size: LayoutSize::new(LayoutPx::new(360.0), LayoutPx::new(180.0)), 577 + title: LABEL, 578 + message: LABEL, 579 + confirm_label: LABEL, 580 + cancel_label: LABEL, 581 + destructive: false, 582 + }, 583 + ); 584 + let picker_entries = [FilePickerEntry { 585 + id: id("picker_entry"), 586 + label: LABEL, 587 + }]; 588 + let _ = show_file_picker( 589 + ctx, 590 + FilePickerDialog::new( 591 + id("file_picker"), 592 + rect(700.0, 1240.0, 600.0, 500.0), 593 + FilePickerMode::Save, 594 + LABEL, 595 + &picker_entries, 596 + LABEL, 597 + &mut state.file_picker, 598 + ), 599 + ); 600 + } 601 + 602 + fn collect_reachable(update: &accesskit::TreeUpdate) -> BTreeSet<NodeId> { 603 + let nodes_by_id: std::collections::BTreeMap<NodeId, &accesskit::Node> = 604 + update.nodes.iter().map(|(id, node)| (*id, node)).collect(); 605 + let Some(tree) = update.tree.as_ref() else { 606 + return BTreeSet::new(); 607 + }; 608 + let mut seen = BTreeSet::new(); 609 + let mut stack = vec![tree.root]; 610 + while let Some(node_id) = stack.pop() { 611 + if !seen.insert(node_id) { 612 + continue; 613 + } 614 + if let Some(node) = nodes_by_id.get(&node_id) { 615 + stack.extend(node.children().iter().copied()); 616 + } 617 + } 618 + seen 619 + } 620 + 621 + #[test] 622 + fn every_a11y_entry_is_reachable_from_root() { 623 + let mut focus = FocusManager::new(); 624 + let mut a11y = AccessTreeBuilder::new(); 625 + let mut state = GalleryState::new(); 626 + render_gallery(&mut focus, &mut a11y, &mut state); 627 + 628 + let strings = StringTable::empty(); 629 + let update = a11y.build(strings, focus.focused()); 630 + let reachable = collect_reachable(&update); 631 + 632 + assert!( 633 + reachable.contains(&root_node_id()), 634 + "root node must be reachable from itself", 635 + ); 636 + let entries: Vec<WidgetId> = a11y.ids().collect(); 637 + assert!( 638 + !entries.is_empty(), 639 + "gallery must declare at least one a11y entry", 640 + ); 641 + entries.iter().for_each(|id| { 642 + let node_id = widget_node_id(*id); 643 + assert!( 644 + reachable.contains(&node_id), 645 + "a11y entry {id:?} must be reachable in the accesskit tree", 646 + ); 647 + }); 648 + } 649 + 650 + #[test] 651 + fn every_focusable_widget_appears_with_a_label() { 652 + let mut focus = FocusManager::new(); 653 + let mut a11y = AccessTreeBuilder::new(); 654 + let mut state = GalleryState::new(); 655 + render_gallery(&mut focus, &mut a11y, &mut state); 656 + 657 + let strings = StringTable::from_entries([(LABEL, "Smoke".to_owned())]); 658 + let update = a11y.build(&strings, focus.focused()); 659 + let nodes_by_id: std::collections::BTreeMap<NodeId, &accesskit::Node> = 660 + update.nodes.iter().map(|(id, node)| (*id, node)).collect(); 661 + 662 + let focusables: Vec<WidgetId> = focus.focusable_ids().collect(); 663 + assert!( 664 + !focusables.is_empty(), 665 + "gallery declares at least one focusable", 666 + ); 667 + focusables.iter().for_each(|id| { 668 + let node_id = widget_node_id(*id); 669 + let Some(node) = nodes_by_id.get(&node_id) else { 670 + panic!("focusable {id:?} missing in tree update"); 671 + }; 672 + assert!( 673 + node.label().is_some(), 674 + "focusable {id:?} must publish a label", 675 + ); 676 + }); 677 + }
+13 -3
crates/bone-ui/tests/key_ordering.rs
··· 2 2 use std::sync::Arc; 3 3 4 4 use bone_ui::{ 5 - ActionId, FocusManager, FrameCtx, FrameInstant, HitFrame, HitState, HotkeyBinding, HotkeyScope, 6 - HotkeyScopes, HotkeyTable, InputSnapshot, KeyChar, KeyChord, KeyCode, KeyEvent, ModifierMask, 7 - StringKey, StringTable, Theme, WidgetId, WidgetKey, 5 + AccessTreeBuilder, ActionId, FocusManager, FrameCtx, FrameInstant, HitFrame, HitState, 6 + HotkeyBinding, HotkeyScope, HotkeyScopes, HotkeyTable, InputSnapshot, KeyChar, KeyChord, 7 + KeyCode, KeyEvent, ModifierMask, StringKey, StringTable, Theme, WidgetId, WidgetKey, 8 8 widgets::{ 9 9 AlwaysValid, HotkeyCapture, HotkeyCaptureState, MemoryClipboard, TextInput, TextInputState, 10 10 show_hotkey_capture, show_text_input, ··· 65 65 let scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 66 66 67 67 let actions = { 68 + let mut a11y = AccessTreeBuilder::new(); 68 69 let mut ctx = FrameCtx::new( 69 70 theme, 70 71 &mut input, ··· 73 74 StringTable::empty(), 74 75 &mut hits, 75 76 &prev, 77 + &mut a11y, 76 78 ); 77 79 let widget = TextInput { 78 80 id: field_id(), ··· 115 117 let scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 116 118 117 119 let actions = { 120 + let mut a11y = AccessTreeBuilder::new(); 118 121 let mut ctx = FrameCtx::new( 119 122 theme, 120 123 &mut input, ··· 123 126 StringTable::empty(), 124 127 &mut hits, 125 128 &prev, 129 + &mut a11y, 126 130 ); 127 131 let widget = TextInput { 128 132 id: field_id(), ··· 161 165 let scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 162 166 163 167 let (actions, selection_min, selection_max) = { 168 + let mut a11y = AccessTreeBuilder::new(); 164 169 let mut ctx = FrameCtx::new( 165 170 theme, 166 171 &mut input, ··· 169 174 StringTable::empty(), 170 175 &mut hits, 171 176 &prev, 177 + &mut a11y, 172 178 ); 173 179 let actions = ctx.dispatch_hotkeys(&scopes); 174 180 let widget = TextInput { ··· 221 227 let scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 222 228 223 229 let (actions, captured) = { 230 + let mut a11y = AccessTreeBuilder::new(); 224 231 let mut ctx = FrameCtx::new( 225 232 theme, 226 233 &mut input, ··· 229 236 StringTable::empty(), 230 237 &mut hits, 231 238 &prev, 239 + &mut a11y, 232 240 ); 233 241 let widget = HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, &mut state); 234 242 let response = show_hotkey_capture(&mut ctx, widget); ··· 272 280 let scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 273 281 274 282 let (actions, captured) = { 283 + let mut a11y = AccessTreeBuilder::new(); 275 284 let mut ctx = FrameCtx::new( 276 285 theme, 277 286 &mut input, ··· 280 289 StringTable::empty(), 281 290 &mut hits, 282 291 &prev, 292 + &mut a11y, 283 293 ); 284 294 let actions = ctx.dispatch_hotkeys(&scopes); 285 295 let widget = HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, &mut state);
+36 -10
crates/bone-ui/tests/widget_label_typing.rs
··· 10 10 ("property_grid.rs", "pub value: String,"), 11 11 ]; 12 12 13 + const RAW_STRING_LEAVES: &[&str] = &[ 14 + "String", 15 + "str", 16 + "SmolStr", 17 + "CompactString", 18 + "ArcStr", 19 + "EcoString", 20 + "KString", 21 + ]; 22 + 13 23 #[test] 14 24 fn widget_public_strings_stay_on_the_user_input_allow_list() { 15 25 let widgets = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/widgets"); ··· 23 33 .collect(); 24 34 assert!( 25 35 violations.is_empty(), 26 - "found pub String/&str fields in widget public surface that are not on the user-input allow-list. \ 36 + "found pub raw-string fields in widget public surface that are not on the user-input allow-list. \ 27 37 Convert label-shaped fields to StringKey, or add the field to ALLOWED_STRING_FIELDS if it carries user-typed content:\n{}", 28 38 violations.join("\n"), 29 39 ); ··· 41 51 .enumerate() 42 52 .filter_map(|(idx, raw)| { 43 53 let line = raw.trim(); 44 - if !is_pub_string_field(line) { 54 + if !is_pub_raw_string_field(line) { 45 55 return None; 46 56 } 47 57 if ALLOWED_STRING_FIELDS ··· 55 65 .collect() 56 66 } 57 67 58 - fn is_pub_string_field(line: &str) -> bool { 59 - if !line.starts_with("pub ") { 60 - return false; 61 - } 62 - line.contains(": String,") 63 - || line.contains(": &str,") 64 - || line.contains(": &'static str,") 65 - || line.contains(": Cow<'static, str>,") 68 + fn is_pub_raw_string_field(line: &str) -> bool { 69 + field_type(line).is_some_and(type_carries_raw_string) 70 + } 71 + 72 + fn field_type(line: &str) -> Option<&str> { 73 + let body = line.strip_prefix("pub ")?; 74 + let (_, ty) = body.split_once(": ")?; 75 + ty.strip_suffix(',').map(str::trim) 76 + } 77 + 78 + fn type_carries_raw_string(ty: &str) -> bool { 79 + RAW_STRING_LEAVES.iter().any(|leaf| contains_word(ty, leaf)) 80 + } 81 + 82 + fn contains_word(haystack: &str, word: &str) -> bool { 83 + haystack.match_indices(word).any(|(idx, _)| { 84 + let before = haystack[..idx].chars().next_back(); 85 + let after = haystack[idx + word.len()..].chars().next(); 86 + before.is_none_or(|c| !is_ident_char(c)) && after.is_none_or(|c| !is_ident_char(c)) 87 + }) 88 + } 89 + 90 + const fn is_ident_char(c: char) -> bool { 91 + c.is_ascii_alphanumeric() || c == '_' 66 92 }