Various accessibility API updates. (#9989)

# Objective

`bevy_a11y` was impossible to integrate into some third-party projects
in part because it insisted on managing the accessibility tree on its
own.

## Solution

The changes in this PR were necessary to get `bevy_egui` working with
Bevy's AccessKit integration. They were tested on a fork of 0.11,
developed against `bevy_egui`, then ported to main and tested against
the `ui` example.

## Changelog

### Changed

* Add `bevy_a11y::ManageAccessibilityUpdates` to indicate whether the
ECS should manage accessibility tree updates.
* Add getter/setter to `bevy_a11y::AccessibilityRequested`.
* Add `bevy_a11y::AccessibilitySystem` `SystemSet` for ordering relative
to accessibility tree updates.
* Upgrade `accesskit` to v0.12.0.

### Fixed

* Correctly set initial accessibility focus to new windows on creation.

## Migration Guide

### Change direct accesses of `AccessibilityRequested` to use
`AccessibilityRequested.::get()`/`AccessibilityRequested::set()`

#### Before

```
use std::sync::atomic::Ordering;

// To access
accessibility_requested.load(Ordering::SeqCst)
// To update
accessibility_requested.store(true, Ordering::SeqCst);
```

#### After

```
// To access
accessibility_requested.get()
// To update
accessibility_requested.set(true);
```

---------

Co-authored-by: StaffEngineer <111751109+StaffEngineer@users.noreply.github.com>
This commit is contained in:
Nolan Darilek 2023-10-02 16:22:52 -05:00 committed by GitHub
parent 44a9a4cc86
commit 73e0ac26ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 108 additions and 86 deletions

View file

@ -14,4 +14,4 @@ bevy_app = { path = "../bevy_app", version = "0.12.0-dev" }
bevy_derive = { path = "../bevy_derive", version = "0.12.0-dev" } bevy_derive = { path = "../bevy_derive", version = "0.12.0-dev" }
bevy_ecs = { path = "../bevy_ecs", version = "0.12.0-dev" } bevy_ecs = { path = "../bevy_ecs", version = "0.12.0-dev" }
accesskit = "0.11" accesskit = "0.12"

View file

@ -4,17 +4,18 @@
#![allow(clippy::type_complexity)] #![allow(clippy::type_complexity)]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use std::{ use std::sync::{
num::NonZeroU128, atomic::{AtomicBool, Ordering},
sync::{atomic::AtomicBool, Arc}, Arc,
}; };
pub use accesskit; pub use accesskit;
use accesskit::{NodeBuilder, NodeId}; use accesskit::NodeBuilder;
use bevy_app::Plugin; use bevy_app::Plugin;
use bevy_derive::{Deref, DerefMut}; use bevy_derive::{Deref, DerefMut};
use bevy_ecs::{ use bevy_ecs::{
prelude::{Component, Entity, Event}, prelude::{Component, Entity, Event},
schedule::SystemSet,
system::Resource, system::Resource,
}; };
@ -30,6 +31,46 @@ pub struct ActionRequest(pub accesskit::ActionRequest);
#[derive(Resource, Default, Clone, Debug, Deref, DerefMut)] #[derive(Resource, Default, Clone, Debug, Deref, DerefMut)]
pub struct AccessibilityRequested(Arc<AtomicBool>); pub struct AccessibilityRequested(Arc<AtomicBool>);
impl AccessibilityRequested {
/// Returns `true` if an access technology is active and accessibility tree
/// updates should be sent.
pub fn get(&self) -> bool {
self.load(Ordering::SeqCst)
}
/// Sets whether accessibility updates were requested by an access technology.
pub fn set(&self, value: bool) {
self.store(value, Ordering::SeqCst);
}
}
/// Resource whose value determines whether the accessibility tree is updated
/// via the ECS.
///
/// Set to `false` in cases where an external GUI library is sending
/// accessibility updates instead. Without this, the external library and ECS
/// will generate conflicting updates.
#[derive(Resource, Clone, Debug, Deref, DerefMut)]
pub struct ManageAccessibilityUpdates(bool);
impl Default for ManageAccessibilityUpdates {
fn default() -> Self {
Self(true)
}
}
impl ManageAccessibilityUpdates {
/// Returns `true` if the ECS should update the accessibility tree.
pub fn get(&self) -> bool {
self.0
}
/// Sets whether the ECS should update the accessibility tree.
pub fn set(&mut self, value: bool) {
self.0 = value;
}
}
/// Component to wrap a [`accesskit::Node`], representing this entity to the platform's /// Component to wrap a [`accesskit::Node`], representing this entity to the platform's
/// accessibility API. /// accessibility API.
/// ///
@ -47,22 +88,16 @@ impl From<NodeBuilder> for AccessibilityNode {
} }
} }
/// Extensions to ease integrating entities with [`AccessKit`](https://accesskit.dev).
pub trait AccessKitEntityExt {
/// Convert an entity to a stable [`NodeId`].
fn to_node_id(&self) -> NodeId;
}
impl AccessKitEntityExt for Entity {
fn to_node_id(&self) -> NodeId {
let id = NonZeroU128::new(self.to_bits() as u128 + 1);
NodeId(id.unwrap())
}
}
/// Resource representing which entity has keyboard focus, if any. /// Resource representing which entity has keyboard focus, if any.
#[derive(Resource, Default, Deref, DerefMut)] #[derive(Resource, Default, Deref, DerefMut)]
pub struct Focus(Option<Entity>); pub struct Focus(pub Option<Entity>);
/// Set enum for the systems relating to accessibility
#[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)]
pub enum AccessibilitySystem {
/// Update the accessibility tree
Update,
}
/// Plugin managing non-GUI aspects of integrating with accessibility APIs. /// Plugin managing non-GUI aspects of integrating with accessibility APIs.
pub struct AccessibilityPlugin; pub struct AccessibilityPlugin;
@ -70,6 +105,7 @@ pub struct AccessibilityPlugin;
impl Plugin for AccessibilityPlugin { impl Plugin for AccessibilityPlugin {
fn build(&self, app: &mut bevy_app::App) { fn build(&self, app: &mut bevy_app::App) {
app.init_resource::<AccessibilityRequested>() app.init_resource::<AccessibilityRequested>()
.init_resource::<ManageAccessibilityUpdates>()
.init_resource::<Focus>(); .init_resource::<Focus>();
} }
} }

View file

@ -124,14 +124,14 @@ fn label_changed(
.collect::<Vec<String>>(); .collect::<Vec<String>>();
let name = Some(values.join(" ").into_boxed_str()); let name = Some(values.join(" ").into_boxed_str());
if let Some(mut accessible) = accessible { if let Some(mut accessible) = accessible {
accessible.set_role(Role::LabelText); accessible.set_role(Role::StaticText);
if let Some(name) = name { if let Some(name) = name {
accessible.set_name(name); accessible.set_name(name);
} else { } else {
accessible.clear_name(); accessible.clear_name();
} }
} else { } else {
let mut node = NodeBuilder::new(Role::LabelText); let mut node = NodeBuilder::new(Role::StaticText);
if let Some(name) = name { if let Some(name) = name {
node.set_name(name); node.set_name(name);
} }

View file

@ -14,6 +14,7 @@ serialize = ["serde"]
[dependencies] [dependencies]
# bevy # bevy
bevy_a11y = { path = "../bevy_a11y", version = "0.12.0-dev" }
bevy_app = { path = "../bevy_app", version = "0.12.0-dev" } bevy_app = { path = "../bevy_app", version = "0.12.0-dev" }
bevy_ecs = { path = "../bevy_ecs", version = "0.12.0-dev" } bevy_ecs = { path = "../bevy_ecs", version = "0.12.0-dev" }
bevy_math = { path = "../bevy_math", version = "0.12.0-dev" } bevy_math = { path = "../bevy_math", version = "0.12.0-dev" }

View file

@ -7,6 +7,8 @@
//! The [`WindowPlugin`] sets up some global window-related parameters and //! The [`WindowPlugin`] sets up some global window-related parameters and
//! is part of the [`DefaultPlugins`](https://docs.rs/bevy/latest/bevy/struct.DefaultPlugins.html). //! is part of the [`DefaultPlugins`](https://docs.rs/bevy/latest/bevy/struct.DefaultPlugins.html).
use bevy_a11y::Focus;
mod cursor; mod cursor;
mod event; mod event;
mod raw_handle; mod raw_handle;
@ -99,9 +101,14 @@ impl Plugin for WindowPlugin {
.add_event::<WindowThemeChanged>(); .add_event::<WindowThemeChanged>();
if let Some(primary_window) = &self.primary_window { if let Some(primary_window) = &self.primary_window {
app.world let initial_focus = app
.world
.spawn(primary_window.clone()) .spawn(primary_window.clone())
.insert(PrimaryWindow); .insert(PrimaryWindow)
.id();
if let Some(mut focus) = app.world.get_resource_mut::<Focus>() {
**focus = Some(initial_focus);
}
} }
match self.exit_condition { match self.exit_condition {

View file

@ -29,7 +29,7 @@ bevy_tasks = { path = "../bevy_tasks", version = "0.12.0-dev" }
# other # other
winit = { version = "0.28.7", default-features = false } winit = { version = "0.28.7", default-features = false }
accesskit_winit = { version = "0.14", default-features = false } accesskit_winit = { version = "0.15", default-features = false }
approx = { version = "0.5", default-features = false } approx = { version = "0.5", default-features = false }
raw-window-handle = "0.5" raw-window-handle = "0.5"

View file

@ -2,25 +2,28 @@
use std::{ use std::{
collections::VecDeque, collections::VecDeque,
sync::{atomic::Ordering, Arc, Mutex}, sync::{Arc, Mutex},
}; };
use accesskit_winit::Adapter; use accesskit_winit::Adapter;
use bevy_a11y::ActionRequest as ActionRequestWrapper;
use bevy_a11y::{ use bevy_a11y::{
accesskit::{ActionHandler, ActionRequest, NodeBuilder, NodeClassSet, Role, TreeUpdate}, accesskit::{
AccessKitEntityExt, AccessibilityNode, AccessibilityRequested, Focus, ActionHandler, ActionRequest, NodeBuilder, NodeClassSet, NodeId, Role, TreeUpdate,
},
AccessibilityNode, AccessibilityRequested, AccessibilitySystem, Focus,
}; };
use bevy_a11y::{ActionRequest as ActionRequestWrapper, ManageAccessibilityUpdates};
use bevy_app::{App, Plugin, PostUpdate}; use bevy_app::{App, Plugin, PostUpdate};
use bevy_derive::{Deref, DerefMut}; use bevy_derive::{Deref, DerefMut};
use bevy_ecs::{ use bevy_ecs::{
prelude::{DetectChanges, Entity, EventReader, EventWriter}, prelude::{DetectChanges, Entity, EventReader, EventWriter},
query::With, query::With,
schedule::IntoSystemConfigs,
system::{NonSend, NonSendMut, Query, Res, ResMut, Resource}, system::{NonSend, NonSendMut, Query, Res, ResMut, Resource},
}; };
use bevy_hierarchy::{Children, Parent}; use bevy_hierarchy::{Children, Parent};
use bevy_utils::{default, HashMap}; use bevy_utils::HashMap;
use bevy_window::{PrimaryWindow, Window, WindowClosed, WindowFocused}; use bevy_window::{PrimaryWindow, Window, WindowClosed};
/// Maps window entities to their `AccessKit` [`Adapter`]s. /// Maps window entities to their `AccessKit` [`Adapter`]s.
#[derive(Default, Deref, DerefMut)] #[derive(Default, Deref, DerefMut)]
@ -35,34 +38,12 @@ pub struct WinitActionHandlers(pub HashMap<Entity, WinitActionHandler>);
pub struct WinitActionHandler(pub Arc<Mutex<VecDeque<ActionRequest>>>); pub struct WinitActionHandler(pub Arc<Mutex<VecDeque<ActionRequest>>>);
impl ActionHandler for WinitActionHandler { impl ActionHandler for WinitActionHandler {
fn do_action(&self, request: ActionRequest) { fn do_action(&mut self, request: ActionRequest) {
let mut requests = self.0.lock().unwrap(); let mut requests = self.0.lock().unwrap();
requests.push_back(request); requests.push_back(request);
} }
} }
fn handle_window_focus(
focus: Res<Focus>,
adapters: NonSend<AccessKitAdapters>,
mut focused: EventReader<WindowFocused>,
) {
for event in focused.read() {
if let Some(adapter) = adapters.get(&event.window) {
adapter.update_if_active(|| {
let focus_id = (*focus).unwrap_or_else(|| event.window);
TreeUpdate {
focus: if event.focused {
Some(focus_id.to_node_id())
} else {
None
},
..default()
}
});
}
}
}
fn window_closed( fn window_closed(
mut adapters: NonSendMut<AccessKitAdapters>, mut adapters: NonSendMut<AccessKitAdapters>,
mut receivers: ResMut<WinitActionHandlers>, mut receivers: ResMut<WinitActionHandlers>,
@ -86,10 +67,16 @@ fn poll_receivers(
} }
} }
fn should_update_accessibility_nodes(
accessibility_requested: Res<AccessibilityRequested>,
manage_accessibility_updates: Res<ManageAccessibilityUpdates>,
) -> bool {
accessibility_requested.get() && manage_accessibility_updates.get()
}
fn update_accessibility_nodes( fn update_accessibility_nodes(
adapters: NonSend<AccessKitAdapters>, adapters: NonSend<AccessKitAdapters>,
focus: Res<Focus>, focus: Res<Focus>,
accessibility_requested: Res<AccessibilityRequested>,
primary_window: Query<(Entity, &Window), With<PrimaryWindow>>, primary_window: Query<(Entity, &Window), With<PrimaryWindow>>,
nodes: Query<( nodes: Query<(
Entity, Entity,
@ -99,46 +86,37 @@ fn update_accessibility_nodes(
)>, )>,
node_entities: Query<Entity, With<AccessibilityNode>>, node_entities: Query<Entity, With<AccessibilityNode>>,
) { ) {
if !accessibility_requested.load(Ordering::SeqCst) {
return;
}
if let Ok((primary_window_id, primary_window)) = primary_window.get_single() { if let Ok((primary_window_id, primary_window)) = primary_window.get_single() {
if let Some(adapter) = adapters.get(&primary_window_id) { if let Some(adapter) = adapters.get(&primary_window_id) {
let should_run = focus.is_changed() || !nodes.is_empty(); let should_run = focus.is_changed() || !nodes.is_empty();
if should_run { if should_run {
adapter.update_if_active(|| { adapter.update_if_active(|| {
let mut to_update = vec![]; let mut to_update = vec![];
let mut has_focus = false;
let mut name = None; let mut name = None;
if primary_window.focused { if primary_window.focused {
has_focus = true;
let title = primary_window.title.clone(); let title = primary_window.title.clone();
name = Some(title.into_boxed_str()); name = Some(title.into_boxed_str());
} }
let focus_id = if has_focus { let focus_id = (*focus).unwrap_or_else(|| primary_window_id).to_bits();
(*focus).or_else(|| Some(primary_window_id))
} else {
None
};
let mut root_children = vec![]; let mut root_children = vec![];
for (entity, node, children, parent) in &nodes { for (entity, node, children, parent) in &nodes {
let mut node = (**node).clone(); let mut node = (**node).clone();
if let Some(parent) = parent { if let Some(parent) = parent {
if node_entities.get(**parent).is_err() { if !node_entities.contains(**parent) {
root_children.push(entity.to_node_id()); root_children.push(NodeId(entity.to_bits()));
} }
} else { } else {
root_children.push(entity.to_node_id()); root_children.push(NodeId(entity.to_bits()));
} }
if let Some(children) = children { if let Some(children) = children {
for child in children { for child in children {
if node_entities.get(*child).is_ok() { if node_entities.contains(*child) {
node.push_child(child.to_node_id()); node.push_child(NodeId(child.to_bits()));
} }
} }
} }
to_update.push(( to_update.push((
entity.to_node_id(), NodeId(entity.to_bits()),
node.build(&mut NodeClassSet::lock_global()), node.build(&mut NodeClassSet::lock_global()),
)); ));
} }
@ -148,12 +126,12 @@ fn update_accessibility_nodes(
} }
root.set_children(root_children); root.set_children(root_children);
let root = root.build(&mut NodeClassSet::lock_global()); let root = root.build(&mut NodeClassSet::lock_global());
let window_update = (primary_window_id.to_node_id(), root); let window_update = (NodeId(primary_window_id.to_bits()), root);
to_update.insert(0, window_update); to_update.insert(0, window_update);
TreeUpdate { TreeUpdate {
nodes: to_update, nodes: to_update,
focus: focus_id.map(|v| v.to_node_id()), tree: None,
..default() focus: NodeId(focus_id),
} }
}); });
} }
@ -171,12 +149,13 @@ impl Plugin for AccessibilityPlugin {
.add_event::<ActionRequestWrapper>() .add_event::<ActionRequestWrapper>()
.add_systems( .add_systems(
PostUpdate, PostUpdate,
( (window_closed, poll_receivers).in_set(AccessibilitySystem::Update),
handle_window_focus, )
window_closed, .add_systems(
poll_receivers, PostUpdate,
update_accessibility_nodes, update_accessibility_nodes
), .run_if(should_update_accessibility_nodes)
.in_set(AccessibilitySystem::Update),
); );
} }
} }

View file

@ -1,10 +1,9 @@
#![warn(missing_docs)] #![warn(missing_docs)]
use std::sync::atomic::Ordering;
use accesskit_winit::Adapter; use accesskit_winit::Adapter;
use bevy_a11y::{ use bevy_a11y::{
accesskit::{NodeBuilder, NodeClassSet, Role, Tree, TreeUpdate}, accesskit::{NodeBuilder, NodeClassSet, NodeId, Role, Tree, TreeUpdate},
AccessKitEntityExt, AccessibilityRequested, AccessibilityRequested,
}; };
use bevy_ecs::entity::Entity; use bevy_ecs::entity::Entity;
@ -151,17 +150,17 @@ impl WinitWindows {
root_builder.set_name(name.into_boxed_str()); root_builder.set_name(name.into_boxed_str());
let root = root_builder.build(&mut NodeClassSet::lock_global()); let root = root_builder.build(&mut NodeClassSet::lock_global());
let accesskit_window_id = entity.to_node_id(); let accesskit_window_id = NodeId(entity.to_bits());
let handler = WinitActionHandler::default(); let handler = WinitActionHandler::default();
let accessibility_requested = (*accessibility_requested).clone(); let accessibility_requested = accessibility_requested.clone();
let adapter = Adapter::with_action_handler( let adapter = Adapter::with_action_handler(
&winit_window, &winit_window,
move || { move || {
accessibility_requested.store(true, Ordering::SeqCst); accessibility_requested.set(true);
TreeUpdate { TreeUpdate {
nodes: vec![(accesskit_window_id, root)], nodes: vec![(accesskit_window_id, root)],
tree: Some(Tree::new(accesskit_window_id)), tree: Some(Tree::new(accesskit_window_id)),
focus: None, focus: accesskit_window_id,
} }
}, },
Box::new(handler.clone()), Box::new(handler.clone()),