| 1 | use crate::{shell, *}; |
| 2 | use accesskit::{Action, ActionRequest, NodeId, Role, TreeId}; |
| 3 | use accesskit_consumer::{NodeRef, Tree, common_filter}; |
| 4 | use std::time::Duration; |
| 5 | use winit::keyboard::NamedKey; |
| 6 | |
| 7 | thread_local! { |
| 8 | static START: Instant = Instant::now(); |
| 9 | static FRAMES: std::cell::Cell<u32> = const { std::cell::Cell::new(0) }; |
| 10 | } |
| 11 | |
| 12 | fn new_ui() -> Ui { |
| 13 | Ui::new(Theme::dark(), Duration::from_millis(500)) |
| 14 | } |
| 15 | |
| 16 | /// Builds a frame 16 ms after this thread's previous one. |
| 17 | fn frame(ui: &mut Ui, build: impl FnOnce(&mut Ui)) { |
| 18 | let frames = FRAMES.with(|frames| { |
| 19 | frames.set(frames.get() + 1); |
| 20 | frames.get() |
| 21 | }); |
| 22 | let now = START.with(|start| *start) + Duration::from_millis(16) * frames; |
| 23 | ui.begin([600.0, 300.0], 2.0, now); |
| 24 | build(ui); |
| 25 | ui.end(); |
| 26 | } |
| 27 | |
| 28 | /// The tree as the platform sees it, generic containers left out: a line per node of its |
| 29 | /// role, name, value, states, shortcut and actions, with the focus marked. |
| 30 | fn snapshot(tree: &Tree) -> String { |
| 31 | fn write(node: &NodeRef, depth: usize, out: &mut String) { |
| 32 | let data = node.data(); |
| 33 | let mut line = format!("{}{:?}", " ".repeat(depth), node.role()); |
| 34 | if let Some(label) = data.label() { |
| 35 | line += &format!(" {label:?}"); |
| 36 | } |
| 37 | if let Some(value) = data.value() { |
| 38 | line += &format!(" = {value:?}"); |
| 39 | } |
| 40 | if let Some(placeholder) = data.placeholder() { |
| 41 | line += &format!(" ({placeholder})"); |
| 42 | } |
| 43 | for (state, on) in [ |
| 44 | ("toggled", data.toggled() == Some(accesskit::Toggled::True)), |
| 45 | ( |
| 46 | "untoggled", |
| 47 | data.toggled() == Some(accesskit::Toggled::False), |
| 48 | ), |
| 49 | ("expanded", data.is_expanded() == Some(true)), |
| 50 | ("collapsed", data.is_expanded() == Some(false)), |
| 51 | ("selected", data.is_selected() == Some(true)), |
| 52 | ("disabled", data.is_disabled()), |
| 53 | ("focused", node.is_focused()), |
| 54 | ] { |
| 55 | if on { |
| 56 | line += &format!(" [{state}]"); |
| 57 | } |
| 58 | } |
| 59 | if let Some(keys) = data.keyboard_shortcut() { |
| 60 | line += &format!(" <{keys}>"); |
| 61 | } |
| 62 | if let Some(description) = data.description() { |
| 63 | line += &format!(" -- {description}"); |
| 64 | } |
| 65 | let actions: Vec<_> = [ |
| 66 | (Action::Click, "click"), |
| 67 | (Action::Focus, "focus"), |
| 68 | (Action::SetValue, "set"), |
| 69 | ] |
| 70 | .into_iter() |
| 71 | .filter(|(action, _)| data.supports_action(*action)) |
| 72 | .map(|(_, name)| name) |
| 73 | .collect(); |
| 74 | if !actions.is_empty() { |
| 75 | line += &format!(" {{{}}}", actions.join(" ")); |
| 76 | } |
| 77 | out.push_str(&line); |
| 78 | out.push('\n'); |
| 79 | for child in node.filtered_children(common_filter) { |
| 80 | write(&child, depth + 1, out); |
| 81 | } |
| 82 | } |
| 83 | let mut out = String::new(); |
| 84 | write(&tree.state().root(), 0, &mut out); |
| 85 | out |
| 86 | } |
| 87 | |
| 88 | /// The tree built from scratch, as assistive technology first asks for it. |
| 89 | fn tree(ui: &mut Ui) -> Tree { |
| 90 | ui.deactivate_accessibility(); |
| 91 | Tree::new(ui.accessibility("Notes", 2.0), true) |
| 92 | } |
| 93 | |
| 94 | fn key(ui: &mut Ui, named: NamedKey) { |
| 95 | ui.event(Event::Key { |
| 96 | key: winit::keyboard::Key::Named(named), |
| 97 | text: None, |
| 98 | }); |
| 99 | } |
| 100 | |
| 101 | fn toolbar_id() -> Id { |
| 102 | Id::ROOT.child("toolbar") |
| 103 | } |
| 104 | |
| 105 | fn font_menu() -> Id { |
| 106 | Id::ROOT.child("font menu") |
| 107 | } |
| 108 | |
| 109 | fn more_menu() -> Id { |
| 110 | Id::ROOT.child("more menu") |
| 111 | } |
| 112 | |
| 113 | /// A toolbar of the kit's controls, as the app builds its own; returns what was clicked. |
| 114 | fn toolbar(ui: &mut Ui, bold: bool) -> Vec<&'static str> { |
| 115 | let mut clicked = Vec::new(); |
| 116 | let text = ui.theme.text; |
| 117 | ui.open_as( |
| 118 | toolbar_id(), |
| 119 | Spec { |
| 120 | size: [fill(), px(30.0)], |
| 121 | gap: 4.0, |
| 122 | role: Some(Role::Toolbar), |
| 123 | ..Spec::default() |
| 124 | }, |
| 125 | ); |
| 126 | if shell::tool_button(ui, "undo", shell::CHEVRON, text, None).clicked { |
| 127 | clicked.push("undo"); |
| 128 | } |
| 129 | popup::tooltip(ui, "Undo", "⌘Z", None); |
| 130 | shell::unavailable(ui, "cut", shell::CHEVRON, text, false); |
| 131 | popup::tooltip(ui, "Cut", "⌘X", None); |
| 132 | if shell::tool_button(ui, "bold", shell::CHEVRON, text, Some(bold)).clicked { |
| 133 | clicked.push("bold"); |
| 134 | } |
| 135 | popup::tooltip(ui, "Bold", "⌘B", Some("Makes the selected text bold.")); |
| 136 | if shell::split_button( |
| 137 | ui, |
| 138 | "paste", |
| 139 | "Paste", |
| 140 | shell::CHEVRON, |
| 141 | None, |
| 142 | None, |
| 143 | more_menu(), |
| 144 | ) |
| 145 | .clicked |
| 146 | { |
| 147 | clicked.push("paste"); |
| 148 | } |
| 149 | popup::tooltip(ui, "Paste", "⌘V", None); |
| 150 | let combo = ui.id("font"); |
| 151 | shell::combo(ui, "font", "Font", "Calibri", 120.0, font_menu(), true); |
| 152 | let level = ui.open( |
| 153 | "level", |
| 154 | Spec { |
| 155 | flags: Flags::CLICKABLE, |
| 156 | size: [px(44.0), px(22.0)], |
| 157 | text: Some("100%"), |
| 158 | role: Some(Role::Button), |
| 159 | ..Spec::default() |
| 160 | }, |
| 161 | ); |
| 162 | ui.close(); |
| 163 | popup::tooltip(ui, "Actual Size", "", None); |
| 164 | if let Some(node) = ui.access(level) { |
| 165 | node.set_value("100%"); |
| 166 | } |
| 167 | ui.close(); |
| 168 | let items = [ |
| 169 | popup::Item { |
| 170 | text: "Recent", |
| 171 | heading: true, |
| 172 | ..popup::Item::default() |
| 173 | }, |
| 174 | popup::Item { |
| 175 | text: "Calibri", |
| 176 | checked: Some(true), |
| 177 | current: true, |
| 178 | ..popup::Item::default() |
| 179 | }, |
| 180 | popup::Item { |
| 181 | text: "Arial", |
| 182 | ..popup::Item::default() |
| 183 | }, |
| 184 | popup::Item { |
| 185 | text: "Wingdings", |
| 186 | disabled: true, |
| 187 | ..popup::Item::default() |
| 188 | }, |
| 189 | ]; |
| 190 | popup::menu(ui, font_menu(), Anchor::Over(combo), &items, Some("Font")); |
| 191 | let paste = [popup::Item { |
| 192 | text: "Keep Text Only", |
| 193 | shortcut: "⌘⇧V", |
| 194 | ..popup::Item::default() |
| 195 | }]; |
| 196 | let anchor = Anchor::Below(ui.id("paste")); |
| 197 | popup::menu(ui, more_menu(), anchor, &paste, None); |
| 198 | clicked |
| 199 | } |
| 200 | |
| 201 | #[test] |
| 202 | fn the_toolbar_names_its_controls_from_their_tooltips() { |
| 203 | let mut ui = new_ui(); |
| 204 | for _ in 0..2 { |
| 205 | frame(&mut ui, |ui| { |
| 206 | toolbar(ui, true); |
| 207 | }); |
| 208 | } |
| 209 | assert_eq!( |
| 210 | snapshot(&tree(&mut ui)), |
| 211 | r#"Window "Notes" [focused] |
| 212 | Toolbar |
| 213 | Button "Undo" <⌘Z> {click focus} |
| 214 | Button "Cut" [disabled] <⌘X> |
| 215 | Button "Bold" [toggled] <⌘B> -- Makes the selected text bold. {click focus} |
| 216 | Button "Paste" <⌘V> {click focus} |
| 217 | Button "Paste Options" [collapsed] {click focus} |
| 218 | ComboBox "Font" = "Calibri" [collapsed] {click focus} |
| 219 | Button "Actual Size" = "100%" {click focus} |
| 220 | "# |
| 221 | ); |
| 222 | } |
| 223 | |
| 224 | #[test] |
| 225 | fn each_toolbar_control_has_a_name_of_its_own() { |
| 226 | let mut ui = new_ui(); |
| 227 | for _ in 0..2 { |
| 228 | frame(&mut ui, |ui| { |
| 229 | toolbar(ui, true); |
| 230 | }); |
| 231 | } |
| 232 | let tree = tree(&mut ui); |
| 233 | let toolbar = tree |
| 234 | .state() |
| 235 | .node_by_tree_local_id(toolbar_id().node(), TreeId::ROOT) |
| 236 | .unwrap(); |
| 237 | let names: Vec<_> = toolbar |
| 238 | .filtered_children(common_filter) |
| 239 | .map(|node| node.label().unwrap_or_default()) |
| 240 | .collect(); |
| 241 | for (at, name) in names.iter().enumerate() { |
| 242 | assert!( |
| 243 | !name.is_empty() && !names[at + 1..].contains(name), |
| 244 | "{names:?}" |
| 245 | ); |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | #[test] |
| 250 | fn a_disabled_combo_shows_its_value_but_takes_no_clicks_or_focus() { |
| 251 | let mut ui = new_ui(); |
| 252 | let build = |ui: &mut Ui| { |
| 253 | frame(ui, |ui| { |
| 254 | shell::combo(ui, "font", "Font", "Calibri", 120.0, font_menu(), false); |
| 255 | }); |
| 256 | }; |
| 257 | build(&mut ui); |
| 258 | let [left, top, right, bottom] = ui.laid_out(ui.id("font")).unwrap(); |
| 259 | let middle = [(left + right) / 2.0, (top + bottom) / 2.0]; |
| 260 | ui.event(Event::PointerMoved(middle)); |
| 261 | for pressed in [true, false] { |
| 262 | ui.event(Event::Button { |
| 263 | button: winit::event::MouseButton::Left, |
| 264 | pressed, |
| 265 | at: Instant::now(), |
| 266 | }); |
| 267 | build(&mut ui); |
| 268 | } |
| 269 | assert!(!ui.popup_open(font_menu())); |
| 270 | key(&mut ui, NamedKey::Tab); |
| 271 | build(&mut ui); |
| 272 | assert_ne!(ui.focused(), Some(ui.id("font"))); |
| 273 | assert!( |
| 274 | snapshot(&tree(&mut ui)).contains(r#"ComboBox "Font" = "Calibri" [collapsed] [disabled]"#), |
| 275 | "{}", |
| 276 | snapshot(&tree(&mut ui)) |
| 277 | ); |
| 278 | } |
| 279 | |
| 280 | #[test] |
| 281 | fn a_toggle_reads_as_one_while_off_and_a_plain_button_as_none() { |
| 282 | let mut ui = new_ui(); |
| 283 | for _ in 0..2 { |
| 284 | frame(&mut ui, |ui| { |
| 285 | toolbar(ui, false); |
| 286 | }); |
| 287 | } |
| 288 | let snapshot = snapshot(&tree(&mut ui)); |
| 289 | assert!( |
| 290 | snapshot.contains(r#"Button "Bold" [untoggled] <⌘B>"#), |
| 291 | "{snapshot}" |
| 292 | ); |
| 293 | assert!( |
| 294 | snapshot.contains(r#"Button "Undo" <⌘Z> {click focus}"#), |
| 295 | "{snapshot}" |
| 296 | ); |
| 297 | } |
| 298 | |
| 299 | #[test] |
| 300 | fn a_row_opening_a_menu_says_so_instead_of_naming_keys() { |
| 301 | let mut ui = new_ui(); |
| 302 | let menu = Id::ROOT.child("menu"); |
| 303 | let items = [ |
| 304 | popup::Item { |
| 305 | text: "Bullet Library", |
| 306 | submenu: true, |
| 307 | ..popup::Item::default() |
| 308 | }, |
| 309 | popup::Item { |
| 310 | text: "Bold", |
| 311 | shortcut: "⌘B", |
| 312 | checked: Some(false), |
| 313 | ..popup::Item::default() |
| 314 | }, |
| 315 | ]; |
| 316 | ui.open_popup(menu); |
| 317 | for _ in 0..2 { |
| 318 | frame(&mut ui, |ui| { |
| 319 | popup::menu(ui, menu, Anchor::Point([0.0; 2]), &items, None); |
| 320 | }); |
| 321 | } |
| 322 | let tree = tree(&mut ui); |
| 323 | let snapshot = snapshot(&tree); |
| 324 | assert!( |
| 325 | snapshot.contains(r#"MenuItem "Bullet Library" [collapsed]"#), |
| 326 | "{snapshot}" |
| 327 | ); |
| 328 | assert!(!snapshot.contains('›'), "{snapshot}"); |
| 329 | assert!( |
| 330 | snapshot.contains(r#"MenuItemCheckBox "Bold" [untoggled]"#), |
| 331 | "{snapshot}" |
| 332 | ); |
| 333 | let update = ui.accessibility_tree("Notes", 2.0); |
| 334 | let (_, row) = update |
| 335 | .nodes |
| 336 | .iter() |
| 337 | .find(|(_, node)| node.label() == Some("Bullet Library")) |
| 338 | .expect("the row"); |
| 339 | assert_eq!(row.has_popup(), Some(accesskit::HasPopup::Menu)); |
| 340 | } |
| 341 | |
| 342 | #[test] |
| 343 | fn swatches_are_named_by_their_colour_names_or_hex() { |
| 344 | let mut ui = new_ui(); |
| 345 | let grid = Id::ROOT.child("colours"); |
| 346 | let swatches = [([1.0, 1.0, 0.0, 1.0], "Yellow"), ([0.0, 0.0, 0.0, 1.0], "")]; |
| 347 | ui.open_popup(grid); |
| 348 | for _ in 0..2 { |
| 349 | frame(&mut ui, |ui| { |
| 350 | popup::colors(ui, grid, Anchor::Point([0.0; 2]), "No Color", &swatches, 2); |
| 351 | }); |
| 352 | } |
| 353 | let snapshot = snapshot(&tree(&mut ui)); |
| 354 | assert!(snapshot.contains(r#"MenuItem "Yellow""#), "{snapshot}"); |
| 355 | assert!(snapshot.contains(r##"MenuItem "#000000""##), "{snapshot}"); |
| 356 | } |
| 357 | |
| 358 | #[test] |
| 359 | fn an_open_menu_takes_the_focus_and_its_highlight_is_the_focused_item() { |
| 360 | let mut ui = new_ui(); |
| 361 | frame(&mut ui, |ui| { |
| 362 | toolbar(ui, false); |
| 363 | }); |
| 364 | // Tab enters the toolbar, and the arrows reach the font box, which Space opens. |
| 365 | key(&mut ui, NamedKey::Tab); |
| 366 | for _ in 0..4 { |
| 367 | key(&mut ui, NamedKey::ArrowRight); |
| 368 | } |
| 369 | frame(&mut ui, |ui| { |
| 370 | toolbar(ui, false); |
| 371 | }); |
| 372 | assert_eq!(ui.focused(), Some(toolbar_id().child("font"))); |
| 373 | key(&mut ui, NamedKey::Space); |
| 374 | for _ in 0..3 { |
| 375 | frame(&mut ui, |ui| { |
| 376 | toolbar(ui, false); |
| 377 | }); |
| 378 | } |
| 379 | assert!(ui.popup_open(font_menu())); |
| 380 | let snapshot = snapshot(&tree(&mut ui)); |
| 381 | assert!( |
| 382 | snapshot.contains(r#"ComboBox "Font" = "Calibri" [expanded]"#), |
| 383 | "{snapshot}" |
| 384 | ); |
| 385 | assert!( |
| 386 | snapshot.ends_with( |
| 387 | r#" Menu |
| 388 | SearchInput = "" (Font) {focus set} |
| 389 | Heading "Recent" |
| 390 | MenuItemCheckBox "Calibri" [toggled] [selected] [focused] {click focus} |
| 391 | MenuItem "Arial" {click focus} |
| 392 | MenuItem "Wingdings" [disabled] |
| 393 | "# |
| 394 | ), |
| 395 | "the checked item starts highlighted, and is the focus within the filter:\n{snapshot}" |
| 396 | ); |
| 397 | key(&mut ui, NamedKey::ArrowDown); |
| 398 | frame(&mut ui, |ui| { |
| 399 | toolbar(ui, false); |
| 400 | }); |
| 401 | let snapshot = self::snapshot(&tree(&mut ui)); |
| 402 | assert!( |
| 403 | snapshot.contains(r#"MenuItem "Arial" [selected] [focused]"#), |
| 404 | "{snapshot}" |
| 405 | ); |
| 406 | // Escape closes it, and the focus returns to the box it opened from. |
| 407 | key(&mut ui, NamedKey::Escape); |
| 408 | frame(&mut ui, |ui| { |
| 409 | toolbar(ui, false); |
| 410 | }); |
| 411 | assert!(!ui.popup_open(font_menu())); |
| 412 | assert_eq!(ui.focused(), Some(toolbar_id().child("font"))); |
| 413 | let snapshot = self::snapshot(&tree(&mut ui)); |
| 414 | assert!( |
| 415 | snapshot.contains(r#"ComboBox "Font" = "Calibri" [collapsed] [focused]"#), |
| 416 | "{snapshot}" |
| 417 | ); |
| 418 | } |
| 419 | |
| 420 | #[test] |
| 421 | fn keys_step_through_the_toolbar_press_its_buttons_and_return() { |
| 422 | let mut ui = new_ui(); |
| 423 | let page = Id::ROOT.child("page"); |
| 424 | let build = |ui: &mut Ui| { |
| 425 | let clicked = toolbar(ui, false); |
| 426 | ui.leaf( |
| 427 | "page", |
| 428 | Spec { |
| 429 | flags: Flags::CUSTOM | Flags::FOCUSABLE, |
| 430 | size: [fill(), fill()], |
| 431 | role: Some(Role::GenericContainer), |
| 432 | ..Spec::default() |
| 433 | }, |
| 434 | ); |
| 435 | clicked |
| 436 | }; |
| 437 | frame(&mut ui, |ui| { |
| 438 | build(ui); |
| 439 | }); |
| 440 | ui.set_focus(Some(page)); |
| 441 | // The page takes Tab for itself; F6 moves to the toolbar, entered at its first button. |
| 442 | key(&mut ui, NamedKey::Tab); |
| 443 | frame(&mut ui, |ui| { |
| 444 | build(ui); |
| 445 | }); |
| 446 | assert_eq!(ui.focused(), Some(page)); |
| 447 | key(&mut ui, NamedKey::F6); |
| 448 | frame(&mut ui, |ui| { |
| 449 | build(ui); |
| 450 | }); |
| 451 | assert_eq!(ui.focused(), Some(toolbar_id().child("undo"))); |
| 452 | // The disabled button is passed over, and the arrows wrap. |
| 453 | key(&mut ui, NamedKey::ArrowRight); |
| 454 | frame(&mut ui, |ui| { |
| 455 | build(ui); |
| 456 | }); |
| 457 | assert_eq!(ui.focused(), Some(toolbar_id().child("bold"))); |
| 458 | key(&mut ui, NamedKey::ArrowLeft); |
| 459 | key(&mut ui, NamedKey::ArrowLeft); |
| 460 | frame(&mut ui, |ui| { |
| 461 | build(ui); |
| 462 | }); |
| 463 | assert_eq!(ui.focused(), Some(toolbar_id().child("level"))); |
| 464 | key(&mut ui, NamedKey::Home); |
| 465 | key(&mut ui, NamedKey::ArrowRight); |
| 466 | key(&mut ui, NamedKey::Enter); |
| 467 | let mut clicked = Vec::new(); |
| 468 | frame(&mut ui, |ui| clicked = build(ui)); |
| 469 | assert_eq!(clicked, ["bold"]); |
| 470 | // Tab leaves the toolbar as one step, for the page. |
| 471 | key(&mut ui, NamedKey::Tab); |
| 472 | frame(&mut ui, |ui| { |
| 473 | build(ui); |
| 474 | }); |
| 475 | assert_eq!(ui.focused(), Some(page)); |
| 476 | key(&mut ui, NamedKey::F6); |
| 477 | key(&mut ui, NamedKey::Escape); |
| 478 | frame(&mut ui, |ui| { |
| 479 | build(ui); |
| 480 | }); |
| 481 | assert_eq!(ui.focused(), Some(page), "Escape returns to the page"); |
| 482 | } |
| 483 | |
| 484 | fn dialog_id() -> Id { |
| 485 | Id::ROOT.child("dialog") |
| 486 | } |
| 487 | |
| 488 | /// A dialog of a labelled field, a check box and two buttons; returns the buttons clicked. |
| 489 | fn dialog(ui: &mut Ui, name: &mut String, checked: bool) -> Vec<&'static str> { |
| 490 | let mut clicked = Vec::new(); |
| 491 | if !ui.popup_open(dialog_id()) { |
| 492 | return clicked; |
| 493 | } |
| 494 | ui.open_as( |
| 495 | dialog_id(), |
| 496 | Spec { |
| 497 | axis: Axis::Y, |
| 498 | size: [px(300.0), children()], |
| 499 | anchor: Some(Anchor::Dialog), |
| 500 | role: Some(Role::Dialog), |
| 501 | ..Spec::default() |
| 502 | }, |
| 503 | ); |
| 504 | if let Some(node) = ui.access(dialog_id()) { |
| 505 | node.set_label("Rename"); |
| 506 | } |
| 507 | ui.leaf( |
| 508 | "heading", |
| 509 | Spec { |
| 510 | size: [fit(), px(20.0)], |
| 511 | text: Some("Choose a name for the section."), |
| 512 | ..Spec::default() |
| 513 | }, |
| 514 | ); |
| 515 | let field = dialog_id().child("name"); |
| 516 | text_field( |
| 517 | ui, |
| 518 | field, |
| 519 | name, |
| 520 | "Untitled", |
| 521 | Spec { |
| 522 | size: [fill(), px(22.0)], |
| 523 | ..Spec::default() |
| 524 | }, |
| 525 | ); |
| 526 | if let Some(node) = ui.access(field) { |
| 527 | node.set_label("Name"); |
| 528 | } |
| 529 | if check_box(ui, "keep", "Keep a copy", checked).clicked { |
| 530 | clicked.push("keep"); |
| 531 | } |
| 532 | for label in ["OK", "Cancel"] { |
| 533 | if button(ui, label, label).clicked { |
| 534 | clicked.push(label); |
| 535 | } |
| 536 | } |
| 537 | ui.close(); |
| 538 | clicked |
| 539 | } |
| 540 | |
| 541 | #[test] |
| 542 | fn a_dialog_holds_the_focus_and_tab_steps_through_its_controls() { |
| 543 | let mut ui = new_ui(); |
| 544 | let mut name = "Notes".to_owned(); |
| 545 | let build = |ui: &mut Ui, name: &mut String| { |
| 546 | ui.leaf( |
| 547 | "behind", |
| 548 | Spec { |
| 549 | flags: Flags::CLICKABLE, |
| 550 | size: [px(40.0), px(20.0)], |
| 551 | text: Some("Behind"), |
| 552 | role: Some(Role::Button), |
| 553 | ..Spec::default() |
| 554 | }, |
| 555 | ); |
| 556 | dialog(ui, name, true) |
| 557 | }; |
| 558 | frame(&mut ui, |ui| { |
| 559 | build(ui, &mut name); |
| 560 | }); |
| 561 | ui.open_popup(dialog_id()); |
| 562 | frame(&mut ui, |ui| { |
| 563 | build(ui, &mut name); |
| 564 | }); |
| 565 | assert_eq!( |
| 566 | snapshot(&tree(&mut ui)), |
| 567 | r#"Window "Notes" |
| 568 | Button "Behind" {click focus} |
| 569 | Dialog "Rename" [focused] |
| 570 | Label = "Choose a name for the section." |
| 571 | TextInput "Name" = "Notes" (Untitled) {focus set} |
| 572 | CheckBox "Keep a copy" [toggled] {click focus} |
| 573 | Button "OK" {click focus} |
| 574 | Button "Cancel" {click focus} |
| 575 | "# |
| 576 | ); |
| 577 | let field = dialog_id().child("name"); |
| 578 | let mut focused = Vec::new(); |
| 579 | for _ in 0..5 { |
| 580 | key(&mut ui, NamedKey::Tab); |
| 581 | frame(&mut ui, |ui| { |
| 582 | build(ui, &mut name); |
| 583 | }); |
| 584 | focused.push(ui.focused()); |
| 585 | } |
| 586 | let [keep, ok, cancel] = ["keep", "OK", "Cancel"].map(|part| Some(dialog_id().child(part))); |
| 587 | assert_eq!( |
| 588 | focused, |
| 589 | [Some(field), keep, ok, cancel, Some(field)], |
| 590 | "Tab stays within the dialog" |
| 591 | ); |
| 592 | ui.event(Event::Modifiers(ModifiersState::SHIFT)); |
| 593 | key(&mut ui, NamedKey::Tab); |
| 594 | ui.event(Event::Modifiers(ModifiersState::empty())); |
| 595 | key(&mut ui, NamedKey::Space); |
| 596 | let mut clicked = Vec::new(); |
| 597 | frame(&mut ui, |ui| clicked = build(ui, &mut name)); |
| 598 | assert_eq!(clicked, ["Cancel"]); |
| 599 | } |
| 600 | |
| 601 | #[test] |
| 602 | fn assistive_technology_presses_focuses_and_sets_values() { |
| 603 | let mut ui = new_ui(); |
| 604 | let mut name = "Notes".to_owned(); |
| 605 | frame(&mut ui, |ui| { |
| 606 | dialog(ui, &mut name, false); |
| 607 | }); |
| 608 | ui.open_popup(dialog_id()); |
| 609 | frame(&mut ui, |ui| { |
| 610 | dialog(ui, &mut name, false); |
| 611 | }); |
| 612 | let request = |action, id: Id, data| { |
| 613 | Event::Access(ActionRequest { |
| 614 | action, |
| 615 | target_tree: TreeId::ROOT, |
| 616 | target_node: id.node(), |
| 617 | data, |
| 618 | }) |
| 619 | }; |
| 620 | let field = dialog_id().child("name"); |
| 621 | ui.event(request( |
| 622 | Action::SetValue, |
| 623 | field, |
| 624 | Some(accesskit::ActionData::Value("Journal".into())), |
| 625 | )); |
| 626 | ui.event(request(Action::Click, dialog_id().child("OK"), None)); |
| 627 | let mut clicked = Vec::new(); |
| 628 | frame(&mut ui, |ui| clicked = dialog(ui, &mut name, false)); |
| 629 | assert_eq!(name, "Journal"); |
| 630 | assert_eq!(clicked, ["OK"]); |
| 631 | assert_eq!(ui.focused(), Some(field)); |
| 632 | ui.event(request(Action::Focus, dialog_id().child("keep"), None)); |
| 633 | frame(&mut ui, |ui| { |
| 634 | dialog(ui, &mut name, false); |
| 635 | }); |
| 636 | assert_eq!(ui.focused(), Some(dialog_id().child("keep"))); |
| 637 | } |
| 638 | |
| 639 | #[test] |
| 640 | fn the_palette_is_a_dialog_whose_field_leads_through_its_options() { |
| 641 | let mut ui = new_ui(); |
| 642 | let id = Id::ROOT.child("palette"); |
| 643 | let items = [ |
| 644 | popup::Item { |
| 645 | text: "Bold", |
| 646 | shortcut: "⌘B", |
| 647 | ..popup::Item::default() |
| 648 | }, |
| 649 | popup::Item { |
| 650 | text: "New Page", |
| 651 | ..popup::Item::default() |
| 652 | }, |
| 653 | popup::Item { |
| 654 | text: "Italic", |
| 655 | ..popup::Item::default() |
| 656 | }, |
| 657 | ]; |
| 658 | frame(&mut ui, |ui| { |
| 659 | popup::palette(ui, id, &[("", &items)], "Search commands"); |
| 660 | }); |
| 661 | ui.open_popup(id); |
| 662 | frame(&mut ui, |ui| { |
| 663 | popup::palette(ui, id, &[("", &items)], "Search commands"); |
| 664 | }); |
| 665 | for text in ["i", "t"] { |
| 666 | ui.event(Event::Key { |
| 667 | key: winit::keyboard::Key::Character(text.into()), |
| 668 | text: Some(text.into()), |
| 669 | }); |
| 670 | frame(&mut ui, |ui| { |
| 671 | popup::palette(ui, id, &[("", &items)], "Search commands"); |
| 672 | }); |
| 673 | } |
| 674 | frame(&mut ui, |ui| { |
| 675 | popup::palette(ui, id, &[("", &items)], "Search commands"); |
| 676 | }); |
| 677 | assert_eq!( |
| 678 | snapshot(&tree(&mut ui)), |
| 679 | r#"Window "Notes" |
| 680 | Dialog "Search commands" |
| 681 | SearchInput = "it" (Search commands) {focus set} |
| 682 | ListBox |
| 683 | ListBoxOption "Italic" [selected] [focused] {click focus} |
| 684 | "# |
| 685 | ); |
| 686 | } |
| 687 | |
| 688 | #[test] |
| 689 | fn updates_send_only_what_changed_and_grafts_hold_the_host_s_tree() { |
| 690 | let mut ui = new_ui(); |
| 691 | let page = Id::ROOT.child("page"); |
| 692 | let tree_id = TreeId(accesskit::Uuid::from_u128(7)); |
| 693 | let build = |ui: &mut Ui, bold: bool| { |
| 694 | toolbar(ui, bold); |
| 695 | ui.open_as( |
| 696 | page, |
| 697 | Spec { |
| 698 | flags: Flags::CUSTOM | Flags::FOCUSABLE, |
| 699 | size: [fill(), fill()], |
| 700 | role: Some(Role::GenericContainer), |
| 701 | ..Spec::default() |
| 702 | }, |
| 703 | ); |
| 704 | if let Some(node) = ui.access(page) { |
| 705 | node.set_tree_id(tree_id); |
| 706 | } |
| 707 | // Built inside the page, the scrollbar shows beside it. |
| 708 | button(ui, "scroll", "Scroll"); |
| 709 | ui.close(); |
| 710 | }; |
| 711 | frame(&mut ui, |ui| build(ui, false)); |
| 712 | let mut tree = tree(&mut ui); |
| 713 | let mut page_node = accesskit::Node::new(Role::Group); |
| 714 | page_node.set_label("Page"); |
| 715 | tree.update_and_process_changes( |
| 716 | accesskit::TreeUpdate { |
| 717 | nodes: vec![(NodeId(0), page_node)], |
| 718 | tree: Some(accesskit::TreeInfo::new(NodeId(0))), |
| 719 | tree_id, |
| 720 | focus: NodeId(0), |
| 721 | }, |
| 722 | &mut NoChanges, |
| 723 | ); |
| 724 | ui.set_focus(Some(page)); |
| 725 | frame(&mut ui, |ui| build(ui, true)); |
| 726 | let update = ui.accessibility("Notes", 2.0); |
| 727 | let changed: Vec<_> = update.nodes.iter().map(|(id, _)| *id).collect(); |
| 728 | assert_eq!(changed, [toolbar_id().child("bold").node()]); |
| 729 | assert_eq!(update.focus, page.node()); |
| 730 | tree.update_and_process_changes(update, &mut NoChanges); |
| 731 | let snapshot = snapshot(&tree); |
| 732 | assert!( |
| 733 | snapshot.ends_with( |
| 734 | r#" Group "Page" [focused] |
| 735 | Button "Scroll" {click focus} |
| 736 | "# |
| 737 | ), |
| 738 | "{snapshot}" |
| 739 | ); |
| 740 | } |
| 741 | |
| 742 | struct NoChanges; |
| 743 | |
| 744 | impl accesskit_consumer::TreeChangeHandler for NoChanges { |
| 745 | fn node_added(&mut self, _: &NodeRef) {} |
| 746 | fn node_updated(&mut self, _: &NodeRef, _: &NodeRef) {} |
| 747 | fn focus_moved(&mut self, _: Option<&NodeRef>, _: Option<&NodeRef>) {} |
| 748 | fn node_removed(&mut self, _: &NodeRef) {} |
| 749 | } |
| 750 | |
| 751 | /// A segmented control of sides with `chosen` picked, which a pick moves. |
| 752 | fn sides(ui: &mut Ui, chosen: &mut usize) { |
| 753 | if let Some(picked) = segmented(ui, "sides", "Page tabs", &["Left", "Right"], *chosen) { |
| 754 | *chosen = picked; |
| 755 | } |
| 756 | } |
| 757 | |
| 758 | #[test] |
| 759 | fn a_segmented_control_is_a_radio_group_tab_enters_at_its_choice_and_arrows_pick() { |
| 760 | let mut ui = new_ui(); |
| 761 | let mut chosen = 1; |
| 762 | for _ in 0..2 { |
| 763 | frame(&mut ui, |ui| sides(ui, &mut chosen)); |
| 764 | } |
| 765 | assert_eq!( |
| 766 | snapshot(&tree(&mut ui)), |
| 767 | r#"Window "Notes" [focused] |
| 768 | RadioGroup "Page tabs" |
| 769 | RadioButton "Left" [untoggled] {click focus} |
| 770 | RadioButton "Right" [toggled] {click focus} |
| 771 | "# |
| 772 | ); |
| 773 | let segment = |index: usize| Some(Id::ROOT.child("sides").child(index)); |
| 774 | key(&mut ui, NamedKey::Tab); |
| 775 | frame(&mut ui, |ui| sides(ui, &mut chosen)); |
| 776 | assert_eq!(ui.focused(), segment(1), "Tab enters at the choice"); |
| 777 | assert_eq!(chosen, 1); |
| 778 | key(&mut ui, NamedKey::ArrowLeft); |
| 779 | for _ in 0..2 { |
| 780 | frame(&mut ui, |ui| sides(ui, &mut chosen)); |
| 781 | } |
| 782 | assert_eq!((ui.focused(), chosen), (segment(0), 0), "an arrow picks"); |
| 783 | // A click picks without moving the focus, which stays put on later frames. |
| 784 | let [left, top, right, bottom] = ui.laid_out(segment(1).unwrap()).unwrap(); |
| 785 | ui.event(Event::PointerMoved([ |
| 786 | (left + right) / 2.0, |
| 787 | (top + bottom) / 2.0, |
| 788 | ])); |
| 789 | for pressed in [true, false] { |
| 790 | ui.event(Event::Button { |
| 791 | button: winit::event::MouseButton::Left, |
| 792 | pressed, |
| 793 | at: Instant::now(), |
| 794 | }); |
| 795 | frame(&mut ui, |ui| sides(ui, &mut chosen)); |
| 796 | } |
| 797 | for _ in 0..2 { |
| 798 | frame(&mut ui, |ui| sides(ui, &mut chosen)); |
| 799 | } |
| 800 | assert_eq!(chosen, 1); |
| 801 | } |