Skip to content

Commit e645038

Browse files
ndarilekProfLander
authored andcommitted
Integrate AccessKit (bevyengine#6874)
# Objective UIs created for Bevy cannot currently be made accessible. This PR aims to address that. ## Solution Integrate AccessKit as a dependency, adding accessibility support to existing bevy_ui widgets. ## Changelog ### Added * Integrate with and expose [AccessKit](https://accesskit.dev) for platform accessibility. * Add `Label` for marking text specifically as a label for UI controls.
1 parent 852787e commit e645038

File tree

19 files changed

+597
-27
lines changed

19 files changed

+597
-27
lines changed

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,9 @@ detailed_trace = ["bevy_internal/detailed_trace"]
209209
# Include tonemapping Look Up Tables KTX2 files
210210
tonemapping_luts = ["bevy_internal/tonemapping_luts"]
211211

212+
# Enable AccessKit on Unix backends (currently only works with experimental screen readers and forks.)
213+
accesskit_unix = ["bevy_internal/accesskit_unix"]
214+
212215
[dependencies]
213216
bevy_dylib = { path = "crates/bevy_dylib", version = "0.9.0", default-features = false, optional = true }
214217
bevy_internal = { path = "crates/bevy_internal", version = "0.9.0", default-features = false }

crates/bevy_a11y/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "bevy_a11y"
3+
version = "0.9.0"
4+
edition = "2021"
5+
description = "Provides accessibility support for Bevy Engine"
6+
homepage = "https://bevyengine.org"
7+
repository = "https://github.com/bevyengine/bevy"
8+
license = "MIT OR Apache-2.0"
9+
keywords = ["bevy", "accessibility", "a11y"]
10+
11+
[dependencies]
12+
# bevy
13+
bevy_app = { path = "../bevy_app", version = "0.9.0" }
14+
bevy_derive = { path = "../bevy_derive", version = "0.9.0" }
15+
bevy_ecs = { path = "../bevy_ecs", version = "0.9.0" }
16+
17+
accesskit = "0.10"

crates/bevy_a11y/src/lib.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//! Accessibility for Bevy
2+
3+
#![warn(missing_docs)]
4+
#![forbid(unsafe_code)]
5+
6+
use std::{
7+
num::NonZeroU128,
8+
sync::{atomic::AtomicBool, Arc},
9+
};
10+
11+
pub use accesskit;
12+
use accesskit::{NodeBuilder, NodeId};
13+
use bevy_app::Plugin;
14+
use bevy_derive::{Deref, DerefMut};
15+
use bevy_ecs::{
16+
prelude::{Component, Entity},
17+
system::Resource,
18+
};
19+
20+
/// Resource that tracks whether an assistive technology has requested
21+
/// accessibility information.
22+
///
23+
/// Useful if a third-party plugin needs to conditionally integrate with
24+
/// `AccessKit`
25+
#[derive(Resource, Default, Clone, Debug, Deref, DerefMut)]
26+
pub struct AccessibilityRequested(Arc<AtomicBool>);
27+
28+
/// Component to wrap a [`accesskit::Node`], representing this entity to the platform's
29+
/// accessibility API.
30+
///
31+
/// If an entity has a parent, and that parent also has an `AccessibilityNode`,
32+
/// the entity's node will be a child of the parent's node.
33+
///
34+
/// If the entity doesn't have a parent, or if the immediate parent doesn't have
35+
/// an `AccessibilityNode`, its node will be an immediate child of the primary window.
36+
#[derive(Component, Clone, Deref, DerefMut)]
37+
pub struct AccessibilityNode(pub NodeBuilder);
38+
39+
impl From<NodeBuilder> for AccessibilityNode {
40+
fn from(node: NodeBuilder) -> Self {
41+
Self(node)
42+
}
43+
}
44+
45+
/// Extensions to ease integrating entities with [`AccessKit`](https://accesskit.dev).
46+
pub trait AccessKitEntityExt {
47+
/// Convert an entity to a stable [`NodeId`].
48+
fn to_node_id(&self) -> NodeId;
49+
}
50+
51+
impl AccessKitEntityExt for Entity {
52+
fn to_node_id(&self) -> NodeId {
53+
let id = NonZeroU128::new(self.to_bits() as u128 + 1);
54+
NodeId(id.unwrap())
55+
}
56+
}
57+
58+
/// Resource representing which entity has keyboard focus, if any.
59+
#[derive(Resource, Default, Deref, DerefMut)]
60+
pub struct Focus(Option<Entity>);
61+
62+
/// Plugin managing non-GUI aspects of integrating with accessibility APIs.
63+
pub struct AccessibilityPlugin;
64+
65+
impl Plugin for AccessibilityPlugin {
66+
fn build(&self, app: &mut bevy_app::App) {
67+
app.init_resource::<AccessibilityRequested>()
68+
.init_resource::<Focus>();
69+
}
70+
}

crates/bevy_internal/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,15 @@ dynamic_linking = ["bevy_diagnostic/dynamic_linking"]
8282
# Enable using a shared stdlib for cxx on Android.
8383
android_shared_stdcxx = ["bevy_audio/android_shared_stdcxx"]
8484

85+
# Enable AccessKit on Unix backends (currently only works with experimental
86+
# screen readers and forks.)
87+
accesskit_unix = ["bevy_winit/accesskit_unix"]
88+
8589
bevy_text = ["dep:bevy_text", "bevy_ui?/bevy_text"]
8690

8791
[dependencies]
8892
# bevy
93+
bevy_a11y = { path = "../bevy_a11y", version = "0.9.0" }
8994
bevy_app = { path = "../bevy_app", version = "0.9.0" }
9095
bevy_core = { path = "../bevy_core", version = "0.9.0" }
9196
bevy_derive = { path = "../bevy_derive", version = "0.9.0" }

crates/bevy_internal/src/default_plugins.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ impl PluginGroup for DefaultPlugins {
5050
.add(bevy_hierarchy::HierarchyPlugin::default())
5151
.add(bevy_diagnostic::DiagnosticsPlugin::default())
5252
.add(bevy_input::InputPlugin::default())
53-
.add(bevy_window::WindowPlugin::default());
53+
.add(bevy_window::WindowPlugin::default())
54+
.add(bevy_a11y::AccessibilityPlugin);
5455

5556
#[cfg(feature = "bevy_asset")]
5657
{

crates/bevy_internal/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ pub mod prelude;
77
mod default_plugins;
88
pub use default_plugins::*;
99

10+
pub mod a11y {
11+
//! Integrate with platform accessibility APIs.
12+
pub use bevy_a11y::*;
13+
}
14+
1015
pub mod app {
1116
//! Build bevy apps, create plugins, and read events.
1217
pub use bevy_app::*;

crates/bevy_ui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ keywords = ["bevy"]
1010

1111
[dependencies]
1212
# bevy
13+
bevy_a11y = { path = "../bevy_a11y", version = "0.9.0" }
1314
bevy_app = { path = "../bevy_app", version = "0.9.0" }
1415
bevy_asset = { path = "../bevy_asset", version = "0.9.0" }
1516
bevy_core_pipeline = { path = "../bevy_core_pipeline", version = "0.9.0" }

crates/bevy_ui/src/accessibility.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
use bevy_a11y::{
2+
accesskit::{NodeBuilder, Rect, Role},
3+
AccessibilityNode,
4+
};
5+
use bevy_app::{App, Plugin};
6+
7+
use bevy_ecs::{
8+
prelude::Entity,
9+
query::{Changed, Or, Without},
10+
system::{Commands, Query},
11+
};
12+
use bevy_hierarchy::Children;
13+
14+
use bevy_render::prelude::Camera;
15+
use bevy_text::Text;
16+
use bevy_transform::prelude::GlobalTransform;
17+
18+
use crate::{
19+
prelude::{Button, Label},
20+
Node, UiImage,
21+
};
22+
23+
fn calc_name(texts: &Query<&Text>, children: &Children) -> Option<Box<str>> {
24+
let mut name = None;
25+
for child in children.iter() {
26+
if let Ok(text) = texts.get(*child) {
27+
let values = text
28+
.sections
29+
.iter()
30+
.map(|v| v.value.to_string())
31+
.collect::<Vec<String>>();
32+
name = Some(values.join(" "));
33+
}
34+
}
35+
name.map(|v| v.into_boxed_str())
36+
}
37+
38+
fn calc_bounds(
39+
camera: Query<(&Camera, &GlobalTransform)>,
40+
mut nodes: Query<
41+
(&mut AccessibilityNode, &Node, &GlobalTransform),
42+
Or<(Changed<Node>, Changed<GlobalTransform>)>,
43+
>,
44+
) {
45+
if let Ok((camera, camera_transform)) = camera.get_single() {
46+
for (mut accessible, node, transform) in &mut nodes {
47+
if let Some(translation) =
48+
camera.world_to_viewport(camera_transform, transform.translation())
49+
{
50+
let bounds = Rect::new(
51+
translation.x.into(),
52+
translation.y.into(),
53+
(translation.x + node.calculated_size.x).into(),
54+
(translation.y + node.calculated_size.y).into(),
55+
);
56+
accessible.set_bounds(bounds);
57+
}
58+
}
59+
}
60+
}
61+
62+
fn button_changed(
63+
mut commands: Commands,
64+
mut query: Query<(Entity, &Children, Option<&mut AccessibilityNode>), Changed<Button>>,
65+
texts: Query<&Text>,
66+
) {
67+
for (entity, children, accessible) in &mut query {
68+
let name = calc_name(&texts, children);
69+
if let Some(mut accessible) = accessible {
70+
accessible.set_role(Role::Button);
71+
if let Some(name) = name {
72+
accessible.set_name(name);
73+
} else {
74+
accessible.clear_name();
75+
}
76+
} else {
77+
let mut node = NodeBuilder::new(Role::Button);
78+
if let Some(name) = name {
79+
node.set_name(name);
80+
}
81+
commands
82+
.entity(entity)
83+
.insert(AccessibilityNode::from(node));
84+
}
85+
}
86+
}
87+
88+
fn image_changed(
89+
mut commands: Commands,
90+
mut query: Query<
91+
(Entity, &Children, Option<&mut AccessibilityNode>),
92+
(Changed<UiImage>, Without<Button>),
93+
>,
94+
texts: Query<&Text>,
95+
) {
96+
for (entity, children, accessible) in &mut query {
97+
let name = calc_name(&texts, children);
98+
if let Some(mut accessible) = accessible {
99+
accessible.set_role(Role::Image);
100+
if let Some(name) = name {
101+
accessible.set_name(name);
102+
} else {
103+
accessible.clear_name();
104+
}
105+
} else {
106+
let mut node = NodeBuilder::new(Role::Image);
107+
if let Some(name) = name {
108+
node.set_name(name);
109+
}
110+
commands
111+
.entity(entity)
112+
.insert(AccessibilityNode::from(node));
113+
}
114+
}
115+
}
116+
117+
fn label_changed(
118+
mut commands: Commands,
119+
mut query: Query<(Entity, &Text, Option<&mut AccessibilityNode>), Changed<Label>>,
120+
) {
121+
for (entity, text, accessible) in &mut query {
122+
let values = text
123+
.sections
124+
.iter()
125+
.map(|v| v.value.to_string())
126+
.collect::<Vec<String>>();
127+
let name = Some(values.join(" ").into_boxed_str());
128+
if let Some(mut accessible) = accessible {
129+
accessible.set_role(Role::LabelText);
130+
if let Some(name) = name {
131+
accessible.set_name(name);
132+
} else {
133+
accessible.clear_name();
134+
}
135+
} else {
136+
let mut node = NodeBuilder::new(Role::LabelText);
137+
if let Some(name) = name {
138+
node.set_name(name);
139+
}
140+
commands
141+
.entity(entity)
142+
.insert(AccessibilityNode::from(node));
143+
}
144+
}
145+
}
146+
147+
/// `AccessKit` integration for `bevy_ui`.
148+
pub(crate) struct AccessibilityPlugin;
149+
150+
impl Plugin for AccessibilityPlugin {
151+
fn build(&self, app: &mut App) {
152+
app.add_system(calc_bounds)
153+
.add_system(button_changed)
154+
.add_system(image_changed)
155+
.add_system(label_changed);
156+
}
157+
}

crates/bevy_ui/src/lib.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod render;
99
mod stack;
1010
mod ui_node;
1111

12+
mod accessibility;
1213
pub mod camera_config;
1314
pub mod node_bundles;
1415
pub mod update;
@@ -27,8 +28,7 @@ pub use ui_node::*;
2728
pub mod prelude {
2829
#[doc(hidden)]
2930
pub use crate::{
30-
camera_config::*, geometry::*, node_bundles::*, ui_node::*, widget::Button, Interaction,
31-
UiScale,
31+
camera_config::*, geometry::*, node_bundles::*, ui_node::*, widget::*, Interaction, UiScale,
3232
};
3333
}
3434

@@ -102,6 +102,8 @@ impl Plugin for UiPlugin {
102102
.register_type::<UiImage>()
103103
.register_type::<Val>()
104104
.register_type::<widget::Button>()
105+
.register_type::<widget::Label>()
106+
.add_plugin(accessibility::AccessibilityPlugin)
105107
.configure_set(UiSystem::Focus.in_base_set(CoreSet::PreUpdate))
106108
.configure_set(UiSystem::Flex.in_base_set(CoreSet::PostUpdate))
107109
.configure_set(UiSystem::Stack.in_base_set(CoreSet::PostUpdate))

crates/bevy_ui/src/widget/label.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
use bevy_ecs::prelude::Component;
2+
use bevy_ecs::reflect::ReflectComponent;
3+
use bevy_reflect::std_traits::ReflectDefault;
4+
use bevy_reflect::Reflect;
5+
6+
/// Marker struct for labels
7+
#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
8+
#[reflect(Component, Default)]
9+
pub struct Label;

crates/bevy_ui/src/widget/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
33
mod button;
44
mod image;
5+
mod label;
56
#[cfg(feature = "bevy_text")]
67
mod text;
78

89
pub use button::*;
910
pub use image::*;
11+
pub use label::*;
1012
#[cfg(feature = "bevy_text")]
1113
pub use text::*;

crates/bevy_winit/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,23 @@ keywords = ["bevy"]
1212
trace = []
1313
wayland = ["winit/wayland"]
1414
x11 = ["winit/x11"]
15+
accesskit_unix = ["accesskit_winit/accesskit_unix"]
1516

1617
[dependencies]
1718
# bevy
19+
bevy_a11y = { path = "../bevy_a11y", version = "0.9.0" }
1820
bevy_app = { path = "../bevy_app", version = "0.9.0" }
21+
bevy_derive = { path = "../bevy_derive", version = "0.9.0" }
1922
bevy_ecs = { path = "../bevy_ecs", version = "0.9.0" }
23+
bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.9.0" }
2024
bevy_input = { path = "../bevy_input", version = "0.9.0" }
2125
bevy_math = { path = "../bevy_math", version = "0.9.0" }
2226
bevy_window = { path = "../bevy_window", version = "0.9.0" }
2327
bevy_utils = { path = "../bevy_utils", version = "0.9.0" }
2428

2529
# other
2630
winit = { version = "0.28", default-features = false }
31+
accesskit_winit = { version = "0.12", default-features = false }
2732
approx = { version = "0.5", default-features = false }
2833
raw-window-handle = "0.5"
2934

0 commit comments

Comments
 (0)