Another project
1
fork

Configure Feed

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

feat(ui): a11y framectx prework

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

+531 -97
+16
Cargo.lock
··· 19 19 checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" 20 20 21 21 [[package]] 22 + name = "accesskit" 23 + version = "0.24.0" 24 + source = "registry+https://github.com/rust-lang/crates.io-index" 25 + checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a" 26 + dependencies = [ 27 + "uuid", 28 + ] 29 + 30 + [[package]] 22 31 name = "adler2" 23 32 version = "2.0.1" 24 33 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 300 309 name = "bone-ui" 301 310 version = "0.0.0" 302 311 dependencies = [ 312 + "accesskit", 303 313 "bone-text", 304 314 "insta", 305 315 "lyon_tessellation", ··· 3195 3205 version = "1.0.4" 3196 3206 source = "registry+https://github.com/rust-lang/crates.io-index" 3197 3207 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 3208 + 3209 + [[package]] 3210 + name = "uuid" 3211 + version = "1.23.1" 3212 + source = "registry+https://github.com/rust-lang/crates.io-index" 3213 + checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" 3198 3214 3199 3215 [[package]] 3200 3216 name = "valuable"
+1
Cargo.toml
··· 38 38 bone-text = { path = "crates/bone-text" } 39 39 bone-ui = { path = "crates/bone-ui" } 40 40 41 + accesskit = "0.24" 41 42 blake3 = { version = "1", default-features = false, features = ["std"] } 42 43 bytemuck = { version = "1", default-features = false, features = ["derive"] } 43 44 faer = { version = "0.24", default-features = false, features = ["std"] }
+1
crates/bone-ui/Cargo.toml
··· 6 6 rust-version.workspace = true 7 7 8 8 [dependencies] 9 + accesskit = { workspace = true } 9 10 bone-text = { workspace = true } 10 11 lyon_tessellation = { workspace = true } 11 12 palette = { workspace = true }
+381
crates/bone-ui/src/a11y.rs
··· 1 + use std::collections::BTreeSet; 2 + 3 + use accesskit::{Node, NodeId, Rect, Tree, TreeId, TreeUpdate}; 4 + 5 + pub use accesskit::{Role, Toggled}; 6 + 7 + use crate::layout::LayoutRect; 8 + use crate::strings::{StringKey, StringTable}; 9 + use crate::widget_id::WidgetId; 10 + 11 + #[derive(Copy, Clone, Debug, Default, PartialEq)] 12 + pub struct AccessState { 13 + pub disabled: bool, 14 + pub selected: Option<bool>, 15 + pub expanded: Option<bool>, 16 + pub toggled: Option<Toggled>, 17 + } 18 + 19 + #[derive(Copy, Clone, Debug, PartialEq)] 20 + pub struct AccessRange { 21 + pub value: f64, 22 + pub min: f64, 23 + pub max: f64, 24 + pub step: f64, 25 + } 26 + 27 + #[derive(Copy, Clone, Debug, PartialEq)] 28 + pub struct AccessNode { 29 + pub role: Role, 30 + pub label: Option<StringKey>, 31 + pub description: Option<StringKey>, 32 + pub state: AccessState, 33 + pub range: Option<AccessRange>, 34 + } 35 + 36 + impl AccessNode { 37 + #[must_use] 38 + pub const fn new(role: Role) -> Self { 39 + Self { 40 + role, 41 + label: None, 42 + description: None, 43 + state: AccessState { 44 + disabled: false, 45 + selected: None, 46 + expanded: None, 47 + toggled: None, 48 + }, 49 + range: None, 50 + } 51 + } 52 + 53 + #[must_use] 54 + pub const fn with_label(self, key: StringKey) -> Self { 55 + Self { 56 + label: Some(key), 57 + ..self 58 + } 59 + } 60 + 61 + #[must_use] 62 + pub const fn with_description(self, key: StringKey) -> Self { 63 + Self { 64 + description: Some(key), 65 + ..self 66 + } 67 + } 68 + 69 + #[must_use] 70 + pub const fn with_disabled(self, disabled: bool) -> Self { 71 + Self { 72 + state: AccessState { 73 + disabled, 74 + ..self.state 75 + }, 76 + ..self 77 + } 78 + } 79 + 80 + #[must_use] 81 + pub const fn with_selected(self, selected: bool) -> Self { 82 + Self { 83 + state: AccessState { 84 + selected: Some(selected), 85 + ..self.state 86 + }, 87 + ..self 88 + } 89 + } 90 + 91 + #[must_use] 92 + pub const fn with_expanded(self, expanded: bool) -> Self { 93 + Self { 94 + state: AccessState { 95 + expanded: Some(expanded), 96 + ..self.state 97 + }, 98 + ..self 99 + } 100 + } 101 + 102 + #[must_use] 103 + pub const fn with_toggled(self, toggled: Toggled) -> Self { 104 + Self { 105 + state: AccessState { 106 + toggled: Some(toggled), 107 + ..self.state 108 + }, 109 + ..self 110 + } 111 + } 112 + 113 + #[must_use] 114 + pub const fn with_range(self, range: AccessRange) -> Self { 115 + Self { 116 + range: Some(range), 117 + ..self 118 + } 119 + } 120 + } 121 + 122 + #[derive(Clone, Debug, PartialEq)] 123 + struct AccessEntry { 124 + id: WidgetId, 125 + rect: LayoutRect, 126 + node: AccessNode, 127 + } 128 + 129 + #[derive(Clone, Debug, Default)] 130 + pub struct AccessTreeBuilder { 131 + entries: Vec<AccessEntry>, 132 + seen: BTreeSet<WidgetId>, 133 + } 134 + 135 + impl AccessTreeBuilder { 136 + #[must_use] 137 + pub fn new() -> Self { 138 + Self::default() 139 + } 140 + 141 + pub fn begin_frame(&mut self) { 142 + self.entries.clear(); 143 + self.seen.clear(); 144 + } 145 + 146 + pub fn push(&mut self, id: WidgetId, rect: LayoutRect, node: AccessNode) { 147 + assert!( 148 + self.seen.insert(id), 149 + "AccessTreeBuilder::push duplicate widget id {id:?}; pushes must be unique per frame", 150 + ); 151 + self.entries.push(AccessEntry { id, rect, node }); 152 + } 153 + 154 + #[must_use] 155 + pub fn contains(&self, id: WidgetId) -> bool { 156 + self.seen.contains(&id) 157 + } 158 + 159 + #[must_use] 160 + pub fn len(&self) -> usize { 161 + self.entries.len() 162 + } 163 + 164 + #[must_use] 165 + pub fn is_empty(&self) -> bool { 166 + self.entries.is_empty() 167 + } 168 + 169 + pub fn ids(&self) -> impl Iterator<Item = WidgetId> + '_ { 170 + self.entries.iter().map(|e| e.id) 171 + } 172 + 173 + #[must_use] 174 + pub fn build(&self, strings: &StringTable, focused: Option<WidgetId>) -> TreeUpdate { 175 + let root_id = node_id(WidgetId::ROOT); 176 + let mut root = Node::new(Role::Window); 177 + let children: Vec<NodeId> = self.entries.iter().map(|e| node_id(e.id)).collect(); 178 + if !children.is_empty() { 179 + root.set_children(children); 180 + } 181 + let nodes = std::iter::once((root_id, root)) 182 + .chain( 183 + self.entries 184 + .iter() 185 + .map(|entry| (node_id(entry.id), build_node(strings, entry))), 186 + ) 187 + .collect(); 188 + TreeUpdate { 189 + nodes, 190 + tree: Some(Tree::new(root_id)), 191 + tree_id: TreeId::ROOT, 192 + focus: focused.map_or(root_id, node_id), 193 + } 194 + } 195 + } 196 + 197 + fn node_id(id: WidgetId) -> NodeId { 198 + NodeId::from(id.raw().get()) 199 + } 200 + 201 + fn build_node(strings: &StringTable, entry: &AccessEntry) -> Node { 202 + let mut node = Node::new(entry.node.role); 203 + node.set_bounds(rect_to_accesskit(entry.rect)); 204 + if let Some(key) = entry.node.label { 205 + node.set_label(strings.resolve(key)); 206 + } 207 + if let Some(key) = entry.node.description { 208 + node.set_description(strings.resolve(key)); 209 + } 210 + if entry.node.state.disabled { 211 + node.set_disabled(); 212 + } 213 + if let Some(selected) = entry.node.state.selected { 214 + node.set_selected(selected); 215 + } 216 + if let Some(expanded) = entry.node.state.expanded { 217 + node.set_expanded(expanded); 218 + } 219 + if let Some(toggled) = entry.node.state.toggled { 220 + node.set_toggled(toggled); 221 + } 222 + if let Some(range) = entry.node.range { 223 + node.set_numeric_value(range.value); 224 + node.set_min_numeric_value(range.min); 225 + node.set_max_numeric_value(range.max); 226 + node.set_numeric_value_step(range.step); 227 + } 228 + node 229 + } 230 + 231 + fn rect_to_accesskit(rect: LayoutRect) -> Rect { 232 + Rect { 233 + x0: f64::from(rect.min_x().value()), 234 + y0: f64::from(rect.min_y().value()), 235 + x1: f64::from(rect.max_x().value()), 236 + y1: f64::from(rect.max_y().value()), 237 + } 238 + } 239 + 240 + #[must_use] 241 + pub fn root_node_id() -> NodeId { 242 + node_id(WidgetId::ROOT) 243 + } 244 + 245 + #[must_use] 246 + pub fn widget_node_id(id: WidgetId) -> NodeId { 247 + node_id(id) 248 + } 249 + 250 + #[cfg(test)] 251 + mod tests { 252 + use accesskit::{Role, Toggled}; 253 + 254 + use super::{AccessNode, AccessRange, AccessTreeBuilder}; 255 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 256 + use crate::strings::{StringKey, StringTable}; 257 + use crate::widget_id::{WidgetId, WidgetKey}; 258 + 259 + const LABEL: StringKey = StringKey::new("smoke.label"); 260 + const DESC: StringKey = StringKey::new("smoke.desc"); 261 + 262 + fn id(name: &'static str) -> WidgetId { 263 + WidgetId::ROOT.child(WidgetKey::new(name)) 264 + } 265 + 266 + fn rect() -> LayoutRect { 267 + LayoutRect::new( 268 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 269 + LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(20.0)), 270 + ) 271 + } 272 + 273 + #[test] 274 + #[should_panic(expected = "AccessTreeBuilder::push duplicate widget id")] 275 + fn push_panics_on_duplicate_id_in_debug() { 276 + let mut builder = AccessTreeBuilder::new(); 277 + let node = AccessNode::new(Role::Button).with_label(LABEL); 278 + builder.push(id("a"), rect(), node); 279 + builder.push(id("a"), rect(), node); 280 + } 281 + 282 + #[test] 283 + fn begin_frame_clears() { 284 + let mut builder = AccessTreeBuilder::new(); 285 + builder.push(id("a"), rect(), AccessNode::new(Role::Button)); 286 + builder.begin_frame(); 287 + assert!(builder.is_empty()); 288 + assert!(!builder.contains(id("a"))); 289 + } 290 + 291 + #[test] 292 + fn build_emits_root_with_children() { 293 + let mut builder = AccessTreeBuilder::new(); 294 + builder.push( 295 + id("a"), 296 + rect(), 297 + AccessNode::new(Role::Button).with_label(LABEL), 298 + ); 299 + builder.push( 300 + id("b"), 301 + rect(), 302 + AccessNode::new(Role::CheckBox) 303 + .with_label(LABEL) 304 + .with_toggled(Toggled::True), 305 + ); 306 + let strings = StringTable::from_entries([(LABEL, "Save".to_owned())]); 307 + let update = builder.build(&strings, Some(id("b"))); 308 + assert_eq!(update.nodes.len(), 3); 309 + assert!(update.tree.is_some()); 310 + let (root_id, root_node) = &update.nodes[0]; 311 + assert_eq!(*root_id, super::root_node_id()); 312 + assert_eq!(root_node.children().len(), 2); 313 + assert_eq!(update.focus, super::widget_node_id(id("b"))); 314 + } 315 + 316 + #[test] 317 + fn build_resolves_label_through_string_table() { 318 + let mut builder = AccessTreeBuilder::new(); 319 + builder.push( 320 + id("a"), 321 + rect(), 322 + AccessNode::new(Role::Button) 323 + .with_label(LABEL) 324 + .with_description(DESC), 325 + ); 326 + let strings = StringTable::from_entries([ 327 + (LABEL, "Save".to_owned()), 328 + (DESC, "Persist current part".to_owned()), 329 + ]); 330 + let update = builder.build(&strings, None); 331 + let (_, button) = &update.nodes[1]; 332 + assert_eq!(button.label(), Some("Save")); 333 + assert_eq!(button.description(), Some("Persist current part")); 334 + } 335 + 336 + #[test] 337 + fn build_emits_numeric_value_for_ranges() { 338 + let mut builder = AccessTreeBuilder::new(); 339 + builder.push( 340 + id("s"), 341 + rect(), 342 + AccessNode::new(Role::Slider) 343 + .with_label(LABEL) 344 + .with_range(AccessRange { 345 + value: 5.0, 346 + min: 0.0, 347 + max: 10.0, 348 + step: 1.0, 349 + }), 350 + ); 351 + let update = builder.build(StringTable::empty(), None); 352 + let (_, slider) = &update.nodes[1]; 353 + assert_eq!(slider.numeric_value(), Some(5.0)); 354 + assert_eq!(slider.min_numeric_value(), Some(0.0)); 355 + assert_eq!(slider.max_numeric_value(), Some(10.0)); 356 + assert_eq!(slider.numeric_value_step(), Some(1.0)); 357 + } 358 + 359 + #[test] 360 + fn build_marks_disabled_state() { 361 + let mut builder = AccessTreeBuilder::new(); 362 + builder.push( 363 + id("a"), 364 + rect(), 365 + AccessNode::new(Role::Button) 366 + .with_label(LABEL) 367 + .with_disabled(true), 368 + ); 369 + let update = builder.build(StringTable::empty(), None); 370 + let (_, node) = &update.nodes[1]; 371 + assert!(node.is_disabled()); 372 + } 373 + 374 + #[test] 375 + fn empty_builder_emits_root_only_focus_falls_back() { 376 + let builder = AccessTreeBuilder::new(); 377 + let update = builder.build(StringTable::empty(), None); 378 + assert_eq!(update.nodes.len(), 1); 379 + assert_eq!(update.focus, super::root_node_id()); 380 + } 381 + }
+4
crates/bone-ui/src/focus.rs
··· 158 158 &self.tab_stops 159 159 } 160 160 161 + pub fn focusable_ids(&self) -> impl Iterator<Item = WidgetId> + '_ { 162 + self.focusable.iter().copied() 163 + } 164 + 161 165 pub fn request(&mut self, request: FocusRequest) { 162 166 self.request = Some(request); 163 167 }
+48 -3
crates/bone-ui/src/frame.rs
··· 1 1 use std::sync::Arc; 2 2 3 + use crate::a11y::{AccessNode, AccessTreeBuilder}; 3 4 use crate::focus::FocusManager; 4 5 use crate::hit_test::{HitFrame, HitItem, HitState, Interaction, Sense, ZLayer}; 5 6 use crate::hotkey::{ActionId, HotkeyScopes, HotkeyTable, KeyChord}; ··· 18 19 pub disabled: bool, 19 20 pub focusable: bool, 20 21 pub active: bool, 22 + pub a11y: Option<AccessNode>, 21 23 } 22 24 23 25 impl InteractDeclaration { ··· 31 33 disabled: false, 32 34 focusable: false, 33 35 active: false, 36 + a11y: None, 34 37 } 35 38 } 36 39 ··· 53 56 pub const fn active(self, active: bool) -> Self { 54 57 Self { active, ..self } 55 58 } 59 + 60 + #[must_use] 61 + pub const fn a11y(self, node: AccessNode) -> Self { 62 + Self { 63 + a11y: Some(node), 64 + ..self 65 + } 66 + } 56 67 } 57 68 58 69 pub struct FrameCtx<'a> { ··· 63 74 pub strings: &'a StringTable, 64 75 pub hits: &'a mut HitFrame, 65 76 pub previous: &'a HitState, 77 + pub a11y: &'a mut AccessTreeBuilder, 66 78 } 67 79 68 80 impl<'a> FrameCtx<'a> { 69 81 #[must_use] 82 + #[allow( 83 + clippy::too_many_arguments, 84 + reason = "FrameCtx threads every per-frame subsystem; bundling them obscures lifetimes" 85 + )] 70 86 pub fn new( 71 87 theme: Arc<Theme>, 72 88 input: &'a mut InputSnapshot, ··· 75 91 strings: &'a StringTable, 76 92 hits: &'a mut HitFrame, 77 93 previous: &'a HitState, 94 + a11y: &'a mut AccessTreeBuilder, 78 95 ) -> Self { 79 96 focus.begin_frame(); 80 97 focus.observe_input(input); 98 + a11y.begin_frame(); 81 99 Self { 82 100 theme, 83 101 input, ··· 86 104 strings, 87 105 hits, 88 106 previous, 107 + a11y, 89 108 } 90 109 } 91 110 ··· 124 143 disabled: declaration.disabled, 125 144 active: declaration.active, 126 145 }); 146 + if let Some(node) = declaration.a11y { 147 + self.a11y.push(declaration.id, declaration.rect, node); 148 + } 127 149 let interaction = self.previous.interaction(declaration.id); 128 150 if interaction.click() && declaration.focusable && !declaration.disabled { 129 151 self.focus.request_focus(declaration.id); ··· 174 196 use std::sync::Arc; 175 197 176 198 use super::{FrameCtx, InteractDeclaration}; 199 + use crate::a11y::AccessTreeBuilder; 177 200 use crate::focus::FocusManager; 178 201 use crate::hit_test::{HitFrame, HitState, Sense, resolve}; 179 202 use crate::hotkey::{ ··· 218 241 let mut hits = HitFrame::new(); 219 242 let prev = HitState::new(); 220 243 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 244 + let mut a11y = AccessTreeBuilder::new(); 221 245 { 222 246 let mut frame = FrameCtx::new( 223 247 theme, ··· 227 251 StringTable::empty(), 228 252 &mut hits, 229 253 &prev, 254 + &mut a11y, 230 255 ); 231 256 let _ = frame.interact( 232 257 InteractDeclaration::new(id("button"), rect(), Sense::INTERACTIVE).focusable(true), ··· 254 279 let mut focus = FocusManager::new(); 255 280 let mut hits = HitFrame::new(); 256 281 let prev = HitState::new(); 282 + let mut a11y = AccessTreeBuilder::new(); 257 283 let actions = { 258 284 let mut frame = FrameCtx::new( 259 285 Arc::new(Theme::light()), ··· 263 289 StringTable::empty(), 264 290 &mut hits, 265 291 &prev, 292 + &mut a11y, 266 293 ); 267 294 frame.dispatch_hotkeys(&global_scope()) 268 295 }; ··· 279 306 let mut focus = FocusManager::new(); 280 307 let mut hits = HitFrame::new(); 281 308 let prev = HitState::new(); 309 + let mut a11y = AccessTreeBuilder::new(); 282 310 let actions = { 283 311 let mut frame = FrameCtx::new( 284 312 Arc::new(Theme::light()), ··· 288 316 StringTable::empty(), 289 317 &mut hits, 290 318 &prev, 319 + &mut a11y, 291 320 ); 292 321 frame.dispatch_hotkeys(&global_scope()) 293 322 }; ··· 309 338 ))); 310 339 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 311 340 341 + let mut a11y = AccessTreeBuilder::new(); 312 342 { 313 343 let mut frame = FrameCtx::new( 314 344 theme.clone(), ··· 318 348 StringTable::empty(), 319 349 &mut hits, 320 350 &state, 351 + &mut a11y, 321 352 ); 322 353 let _ = frame.interact( 323 354 InteractDeclaration::new(id("btn"), rect(), Sense::INTERACTIVE).focusable(true), ··· 344 375 StringTable::empty(), 345 376 &mut hits, 346 377 &state, 378 + &mut a11y, 347 379 ); 348 380 let _ = frame.interact( 349 381 InteractDeclaration::new(id("btn"), rect(), Sense::INTERACTIVE).focusable(true), ··· 363 395 StringTable::empty(), 364 396 &mut hits, 365 397 &state, 398 + &mut a11y, 366 399 ); 367 400 let _ = frame.interact( 368 401 InteractDeclaration::new(id("btn"), rect(), Sense::INTERACTIVE).focusable(true), ··· 382 415 let mut hits = HitFrame::new(); 383 416 let prev = HitState::new(); 384 417 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 418 + let mut a11y = AccessTreeBuilder::new(); 385 419 let mut frame = FrameCtx::new( 386 420 Arc::new(Theme::light()), 387 421 &mut input, ··· 390 424 StringTable::empty(), 391 425 &mut hits, 392 426 &prev, 427 + &mut a11y, 393 428 ); 394 429 assert_eq!(frame.theme().mode, ThemeMode::Light); 395 430 let inner_mode = frame.theme_scope(|_| Theme::dark(), |frame| frame.theme().mode); ··· 404 439 let mut hits = HitFrame::new(); 405 440 let prev = HitState::new(); 406 441 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 442 + let mut a11y = AccessTreeBuilder::new(); 407 443 let mut frame = FrameCtx::new( 408 444 Arc::new(Theme::light()), 409 445 &mut input, ··· 412 448 StringTable::empty(), 413 449 &mut hits, 414 450 &prev, 451 + &mut a11y, 415 452 ); 416 453 let modes = frame.theme_scope( 417 454 |_| Theme::dark(), ··· 433 470 let mut hits = HitFrame::new(); 434 471 let prev = HitState::new(); 435 472 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 473 + let mut a11y = AccessTreeBuilder::new(); 436 474 let mut frame = FrameCtx::new( 437 475 Arc::new(Theme::light()), 438 476 &mut input, ··· 441 479 StringTable::empty(), 442 480 &mut hits, 443 481 &prev, 482 + &mut a11y, 444 483 ); 445 484 let outer_accent = frame.theme().colors.accent_solid(); 446 485 let outer_ring = frame.theme().colors.focus_ring(); ··· 472 511 let read_tokens = |theme: Arc<Theme>, 473 512 focus: &mut FocusManager, 474 513 hits: &mut HitFrame, 475 - input: &mut InputSnapshot| { 476 - let frame = FrameCtx::new(theme, input, focus, &hotkeys, strings, hits, &prev); 514 + input: &mut InputSnapshot, 515 + a11y: &mut AccessTreeBuilder| { 516 + let frame = FrameCtx::new(theme, input, focus, &hotkeys, strings, hits, &prev, a11y); 477 517 ( 478 518 frame.theme().mode, 479 519 frame.theme().colors.text_primary(), ··· 483 523 484 524 let mut hits_a = HitFrame::new(); 485 525 let mut input_a = InputSnapshot::idle(FrameInstant::ZERO); 526 + let mut a11y_a = AccessTreeBuilder::new(); 486 527 let (mode_a, text_a, surface_a) = read_tokens( 487 528 Arc::new(Theme::light()), 488 529 &mut focus, 489 530 &mut hits_a, 490 531 &mut input_a, 532 + &mut a11y_a, 491 533 ); 492 534 493 535 let mut hits_b = HitFrame::new(); 494 536 let mut input_b = InputSnapshot::idle(FrameInstant::ZERO); 537 + let mut a11y_b = AccessTreeBuilder::new(); 495 538 let (mode_b, text_b, surface_b) = read_tokens( 496 539 Arc::new(Theme::dark()), 497 540 &mut focus, 498 541 &mut hits_b, 499 542 &mut input_b, 543 + &mut a11y_b, 500 544 ); 501 545 502 546 assert_eq!(mode_a, ThemeMode::Light); ··· 516 560 let prev = HitState::new(); 517 561 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 518 562 let strings = StringTable::for_locale(Locale::ArXb); 563 + let mut a11y = AccessTreeBuilder::new(); 519 564 let frame = FrameCtx::new( 520 - theme, &mut input, &mut focus, &hotkeys, &strings, &mut hits, &prev, 565 + theme, &mut input, &mut focus, &hotkeys, &strings, &mut hits, &prev, &mut a11y, 521 566 ); 522 567 assert_eq!(frame.locale(), Locale::ArXb); 523 568 assert_eq!(frame.direction(), LayoutDirection::Rtl);
+3 -2
crates/bone-ui/src/layout/engine.rs
··· 391 391 retained: &RetainedLayout, 392 392 ) -> Result<NodeId, LayoutError> { 393 393 let kid = self.lower(child, retained)?; 394 + let (left, right) = padding.physical(self.direction); 394 395 let style = Style { 395 396 padding: TaffyRect { 396 - left: length(padding.physical_left(self.direction).value_px()), 397 - right: length(padding.physical_right(self.direction).value_px()), 397 + left: length(left.value_px()), 398 + right: length(right.value_px()), 398 399 top: length(padding.top.value_px()), 399 400 bottom: length(padding.bottom.value_px()), 400 401 },
+21 -11
crates/bone-ui/src/layout/geometry.rs
··· 188 188 && y >= self.min_y().value() 189 189 && y < self.max_y().value() 190 190 } 191 + 192 + #[must_use] 193 + pub fn union(self, other: Self) -> Self { 194 + let min_x = self.min_x().min(other.min_x()); 195 + let min_y = self.min_y().min(other.min_y()); 196 + let max_x = self.max_x().max(other.max_x()); 197 + let max_y = self.max_y().max(other.max_y()); 198 + Self::new( 199 + LayoutPos::new(min_x, min_y), 200 + LayoutSize::new( 201 + LayoutPx::saturating_nonneg(max_x.value() - min_x.value()), 202 + LayoutPx::saturating_nonneg(max_y.value() - min_y.value()), 203 + ), 204 + ) 205 + } 191 206 } 192 207 193 208 #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] ··· 210 225 } 211 226 212 227 #[must_use] 213 - pub fn physical_left(self, direction: super::axis::LayoutDirection) -> crate::theme::Spacing { 228 + pub fn physical( 229 + self, 230 + direction: super::axis::LayoutDirection, 231 + ) -> (crate::theme::Spacing, crate::theme::Spacing) { 214 232 match direction { 215 - super::axis::LayoutDirection::Ltr => self.start, 216 - super::axis::LayoutDirection::Rtl => self.end, 217 - } 218 - } 219 - 220 - #[must_use] 221 - pub fn physical_right(self, direction: super::axis::LayoutDirection) -> crate::theme::Spacing { 222 - match direction { 223 - super::axis::LayoutDirection::Ltr => self.end, 224 - super::axis::LayoutDirection::Rtl => self.start, 233 + super::axis::LayoutDirection::Ltr => (self.start, self.end), 234 + super::axis::LayoutDirection::Rtl => (self.end, self.start), 225 235 } 226 236 } 227 237 }
+10 -2
crates/bone-ui/src/layout/tests.rs
··· 340 340 let rtl_cell = rtl.node(rtl.root_node().children[0]); 341 341 assert!(approx_eq(ltr_cell.rect.min_x().value(), 0.0, PX_EPSILON)); 342 342 assert!(approx_eq(rtl_cell.rect.min_x().value(), 80.0, PX_EPSILON)); 343 - assert!(approx_eq(ltr_cell.rect.size.width.value(), 20.0, PX_EPSILON)); 344 - assert!(approx_eq(rtl_cell.rect.size.width.value(), 20.0, PX_EPSILON)); 343 + assert!(approx_eq( 344 + ltr_cell.rect.size.width.value(), 345 + 20.0, 346 + PX_EPSILON 347 + )); 348 + assert!(approx_eq( 349 + rtl_cell.rect.size.width.value(), 350 + 20.0, 351 + PX_EPSILON 352 + )); 345 353 } 346 354 347 355 #[test]
+2
crates/bone-ui/src/lib.rs
··· 1 + pub mod a11y; 1 2 pub mod focus; 2 3 pub mod frame; 3 4 pub mod hit_test; ··· 10 11 mod widget_id; 11 12 pub mod widgets; 12 13 14 + pub use a11y::{AccessNode, AccessRange, AccessState, AccessTreeBuilder, Role, Toggled}; 13 15 pub use focus::{ 14 16 FocusManager, FocusRequest, FocusScopeId, FocusScopeKind, InputModality, RovingDirection, 15 17 };
+42 -77
crates/bone-ui/src/strings.rs
··· 52 52 Self::ArXb => LayoutDirection::Rtl, 53 53 } 54 54 } 55 + 56 + #[must_use] 57 + pub const fn plural_category(self, n: u64) -> PluralCategory { 58 + match self { 59 + Self::EnGb => english_plural(n), 60 + Self::ArXb => arabic_plural(n), 61 + } 62 + } 55 63 } 56 64 57 65 impl Default for Locale { ··· 70 78 Other, 71 79 } 72 80 73 - impl Locale { 74 - #[must_use] 75 - pub const fn plural_category(self, n: u64) -> PluralCategory { 76 - match self { 77 - Self::EnGb => { 78 - if n == 1 { 79 - PluralCategory::One 80 - } else { 81 - PluralCategory::Other 82 - } 83 - } 84 - Self::ArXb => arabic_plural(n), 85 - } 81 + const fn english_plural(n: u64) -> PluralCategory { 82 + match n { 83 + 1 => PluralCategory::One, 84 + _ => PluralCategory::Other, 86 85 } 87 86 } 88 87 89 88 const fn arabic_plural(n: u64) -> PluralCategory { 90 - match n { 91 - 0 => PluralCategory::Zero, 92 - 1 => PluralCategory::One, 93 - 2 => PluralCategory::Two, 94 - _ => match n % 100 { 95 - 3..=10 => PluralCategory::Few, 96 - 11..=99 => PluralCategory::Many, 97 - _ => PluralCategory::Other, 98 - }, 89 + match (n, n % 100) { 90 + (0, _) => PluralCategory::Zero, 91 + (1, _) => PluralCategory::One, 92 + (2, _) => PluralCategory::Two, 93 + (_, 3..=10) => PluralCategory::Few, 94 + (_, 11..=99) => PluralCategory::Many, 95 + _ => PluralCategory::Other, 99 96 } 100 97 } 101 98 ··· 112 109 impl PluralEntry { 113 110 #[must_use] 114 111 pub fn other(other: impl Into<String>) -> Self { 115 - Self { 116 - other: other.into(), 117 - zero: None, 118 - one: None, 119 - two: None, 120 - few: None, 121 - many: None, 122 - } 112 + Self::default().with(PluralCategory::Other, other) 123 113 } 124 114 125 115 #[must_use] 126 116 pub fn new(one: impl Into<String>, other: impl Into<String>) -> Self { 127 - Self::other(other).with_one(one) 117 + Self::other(other).with(PluralCategory::One, one) 128 118 } 129 119 130 120 #[must_use] 131 - pub fn with_zero(mut self, value: impl Into<String>) -> Self { 132 - self.zero = Some(value.into()); 133 - self 134 - } 135 - 136 - #[must_use] 137 - pub fn with_one(mut self, value: impl Into<String>) -> Self { 138 - self.one = Some(value.into()); 139 - self 140 - } 141 - 142 - #[must_use] 143 - pub fn with_two(mut self, value: impl Into<String>) -> Self { 144 - self.two = Some(value.into()); 145 - self 146 - } 147 - 148 - #[must_use] 149 - pub fn with_few(mut self, value: impl Into<String>) -> Self { 150 - self.few = Some(value.into()); 151 - self 152 - } 153 - 154 - #[must_use] 155 - pub fn with_many(mut self, value: impl Into<String>) -> Self { 156 - self.many = Some(value.into()); 121 + pub fn with(mut self, category: PluralCategory, value: impl Into<String>) -> Self { 122 + let value = value.into(); 123 + match category { 124 + PluralCategory::Zero => self.zero = Some(value), 125 + PluralCategory::One => self.one = Some(value), 126 + PluralCategory::Two => self.two = Some(value), 127 + PluralCategory::Few => self.few = Some(value), 128 + PluralCategory::Many => self.many = Some(value), 129 + PluralCategory::Other => self.other = value, 130 + } 157 131 self 158 132 } 159 133 ··· 247 221 248 222 #[must_use] 249 223 pub fn format_count(&self, n: u64) -> String { 250 - format_integer(n, self.locale) 224 + match self.locale { 225 + Locale::EnGb => group_thousands(n, ','), 226 + Locale::ArXb => group_thousands(n, '\u{066C}') 227 + .chars() 228 + .map(map_arabic_indic) 229 + .collect(), 230 + } 251 231 } 252 232 253 233 #[must_use] ··· 269 249 #[must_use] 270 250 pub fn is_empty(&self) -> bool { 271 251 self.entries.is_empty() && self.plural_entries.is_empty() 272 - } 273 - } 274 - 275 - fn format_integer(n: u64, locale: Locale) -> String { 276 - let raw = group_thousands(n, thousands_separator(locale)); 277 - match locale { 278 - Locale::EnGb => raw, 279 - Locale::ArXb => raw.chars().map(map_arabic_indic).collect(), 280 - } 281 - } 282 - 283 - const fn thousands_separator(locale: Locale) -> char { 284 - match locale { 285 - Locale::EnGb => ',', 286 - Locale::ArXb => '\u{066C}', 287 252 } 288 253 } 289 254 ··· 433 398 table.insert_plural( 434 399 ITEMS, 435 400 PluralEntry::new("one", "other") 436 - .with_zero("zero") 437 - .with_two("two") 438 - .with_few("few") 439 - .with_many("many"), 401 + .with(PluralCategory::Zero, "zero") 402 + .with(PluralCategory::Two, "two") 403 + .with(PluralCategory::Few, "few") 404 + .with(PluralCategory::Many, "many"), 440 405 ); 441 406 assert_eq!(table.resolve_plural(ITEMS, 0), "zero"); 442 407 assert_eq!(table.resolve_plural(ITEMS, 1), "one");
+2 -2
crates/bone-ui/src/widgets/mod.rs
··· 31 31 }; 32 32 pub use checkbox::{Checkbox, CheckboxResponse, CheckboxState, show_checkbox}; 33 33 pub use dialog::{ 34 - BackdropStyle, ConfirmationDialog, ConfirmationOutcome, ConfirmationResponse, Dialog, 35 - DialogButton, DialogResponse, Modal, ModalResponse, show_confirmation, show_dialog, show_modal, 34 + ConfirmationDialog, ConfirmationOutcome, ConfirmationResponse, Dialog, DialogButton, 35 + DialogResponse, Modal, ModalResponse, show_confirmation, show_dialog, show_modal, 36 36 }; 37 37 pub use dimensioned_input::{DimensionedInput, DimensionedInputResponse, DimensionedParseError}; 38 38 pub use dropdown::{Dropdown, DropdownItem, DropdownResponse, DropdownState, show_dropdown};