Skip to content

Commit

Permalink
Integrate AccessKit (#6874)
Browse files Browse the repository at this point in the history
# 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.
  • Loading branch information
ndarilek committed Mar 1, 2023
1 parent abcb066 commit 17fa32d
Show file tree
Hide file tree
Showing 18 changed files with 597 additions and 27 deletions.
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ detailed_trace = ["bevy_internal/detailed_trace"]
# Include tonemapping Look Up Tables KTX2 files
tonemapping_luts = ["bevy_internal/tonemapping_luts"]

# Enable AccessKit on Unix backends (currently only works with experimental
# screen readers and forks.)
accesskit_unix = ["bevy_internal/accesskit_unix"]

[dependencies]
bevy_dylib = { path = "crates/bevy_dylib", version = "0.9.0", default-features = false, optional = true }
bevy_internal = { path = "crates/bevy_internal", version = "0.9.0", default-features = false }
Expand Down
17 changes: 17 additions & 0 deletions crates/bevy_a11y/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
name = "bevy_a11y"
version = "0.9.0"
edition = "2021"
description = "Provides accessibility support for Bevy Engine"
homepage = "https://bevyengine.org"
repository = "https://github.com/bevyengine/bevy"
license = "MIT OR Apache-2.0"
keywords = ["bevy", "accessibility", "a11y"]

[dependencies]
# bevy
bevy_app = { path = "../bevy_app", version = "0.9.0" }
bevy_derive = { path = "../bevy_derive", version = "0.9.0" }
bevy_ecs = { path = "../bevy_ecs", version = "0.9.0" }

accesskit = "0.10"
70 changes: 70 additions & 0 deletions crates/bevy_a11y/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! Accessibility for Bevy

#![warn(missing_docs)]
#![forbid(unsafe_code)]

use std::{
num::NonZeroU128,
sync::{atomic::AtomicBool, Arc},
};

pub use accesskit;
use accesskit::{NodeBuilder, NodeId};
use bevy_app::Plugin;
use bevy_derive::{Deref, DerefMut};
use bevy_ecs::{
prelude::{Component, Entity},
system::Resource,
};

/// Resource that tracks whether an assistive technology has requested
/// accessibility information.
///
/// Useful if a third-party plugin needs to conditionally integrate with
/// `AccessKit`
#[derive(Resource, Default, Clone, Debug, Deref, DerefMut)]
pub struct AccessibilityRequested(Arc<AtomicBool>);

/// Component to wrap a [`accesskit::Node`], representing this entity to the platform's
/// accessibility API.
///
/// If an entity has a parent, and that parent also has an `AccessibilityNode`,
/// the entity's node will be a child of the parent's node.
///
/// If the entity doesn't have a parent, or if the immediate parent doesn't have
/// an `AccessibilityNode`, its node will be an immediate child of the primary window.
#[derive(Component, Clone, Deref, DerefMut)]
pub struct AccessibilityNode(pub NodeBuilder);

impl From<NodeBuilder> for AccessibilityNode {
fn from(node: NodeBuilder) -> Self {
Self(node)
}
}

/// 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.
#[derive(Resource, Default, Deref, DerefMut)]
pub struct Focus(Option<Entity>);

/// Plugin managing non-GUI aspects of integrating with accessibility APIs.
pub struct AccessibilityPlugin;

impl Plugin for AccessibilityPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.init_resource::<AccessibilityRequested>()
.init_resource::<Focus>();
}
}
5 changes: 5 additions & 0 deletions crates/bevy_internal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,15 @@ dynamic_linking = ["bevy_diagnostic/dynamic_linking"]
# Enable using a shared stdlib for cxx on Android.
android_shared_stdcxx = ["bevy_audio/android_shared_stdcxx"]

# Enable AccessKit on Unix backends (currently only works with experimental
# screen readers and forks.)
accesskit_unix = ["bevy_winit/accesskit_unix"]

bevy_text = ["dep:bevy_text", "bevy_ui?/bevy_text"]

[dependencies]
# bevy
bevy_a11y = { path = "../bevy_a11y", version = "0.9.0" }
bevy_app = { path = "../bevy_app", version = "0.9.0" }
bevy_core = { path = "../bevy_core", version = "0.9.0" }
bevy_derive = { path = "../bevy_derive", version = "0.9.0" }
Expand Down
3 changes: 2 additions & 1 deletion crates/bevy_internal/src/default_plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ impl PluginGroup for DefaultPlugins {
.add(bevy_hierarchy::HierarchyPlugin::default())
.add(bevy_diagnostic::DiagnosticsPlugin::default())
.add(bevy_input::InputPlugin::default())
.add(bevy_window::WindowPlugin::default());
.add(bevy_window::WindowPlugin::default())
.add(bevy_a11y::AccessibilityPlugin);

#[cfg(feature = "bevy_asset")]
{
Expand Down
5 changes: 5 additions & 0 deletions crates/bevy_internal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ pub mod prelude;
mod default_plugins;
pub use default_plugins::*;

pub mod a11y {
//! Integrate with platform accessibility APIs.
pub use bevy_a11y::*;
}

pub mod app {
//! Build bevy apps, create plugins, and read events.
pub use bevy_app::*;
Expand Down
1 change: 1 addition & 0 deletions crates/bevy_ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ keywords = ["bevy"]

[dependencies]
# bevy
bevy_a11y = { path = "../bevy_a11y", version = "0.9.0" }
bevy_app = { path = "../bevy_app", version = "0.9.0" }
bevy_asset = { path = "../bevy_asset", version = "0.9.0" }
bevy_core_pipeline = { path = "../bevy_core_pipeline", version = "0.9.0" }
Expand Down
157 changes: 157 additions & 0 deletions crates/bevy_ui/src/accessibility.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
use bevy_a11y::{
accesskit::{NodeBuilder, Rect, Role},
AccessibilityNode,
};
use bevy_app::{App, Plugin};

use bevy_ecs::{
prelude::Entity,
query::{Changed, Or, Without},
system::{Commands, Query},
};
use bevy_hierarchy::Children;

use bevy_render::prelude::Camera;
use bevy_text::Text;
use bevy_transform::prelude::GlobalTransform;

use crate::{
prelude::{Button, Label},
Node, UiImage,
};

fn calc_name(texts: &Query<&Text>, children: &Children) -> Option<Box<str>> {
let mut name = None;
for child in children.iter() {
if let Ok(text) = texts.get(*child) {
let values = text
.sections
.iter()
.map(|v| v.value.to_string())
.collect::<Vec<String>>();
name = Some(values.join(" "));
}
}
name.map(|v| v.into_boxed_str())
}

fn calc_bounds(
camera: Query<(&Camera, &GlobalTransform)>,
mut nodes: Query<
(&mut AccessibilityNode, &Node, &GlobalTransform),
Or<(Changed<Node>, Changed<GlobalTransform>)>,
>,
) {
if let Ok((camera, camera_transform)) = camera.get_single() {
for (mut accessible, node, transform) in &mut nodes {
if let Some(translation) =
camera.world_to_viewport(camera_transform, transform.translation())
{
let bounds = Rect::new(
translation.x.into(),
translation.y.into(),
(translation.x + node.calculated_size.x).into(),
(translation.y + node.calculated_size.y).into(),
);
accessible.set_bounds(bounds);
}
}
}
}

fn button_changed(
mut commands: Commands,
mut query: Query<(Entity, &Children, Option<&mut AccessibilityNode>), Changed<Button>>,
texts: Query<&Text>,
) {
for (entity, children, accessible) in &mut query {
let name = calc_name(&texts, children);
if let Some(mut accessible) = accessible {
accessible.set_role(Role::Button);
if let Some(name) = name {
accessible.set_name(name);
} else {
accessible.clear_name();
}
} else {
let mut node = NodeBuilder::new(Role::Button);
if let Some(name) = name {
node.set_name(name);
}
commands
.entity(entity)
.insert(AccessibilityNode::from(node));
}
}
}

fn image_changed(
mut commands: Commands,
mut query: Query<
(Entity, &Children, Option<&mut AccessibilityNode>),
(Changed<UiImage>, Without<Button>),
>,
texts: Query<&Text>,
) {
for (entity, children, accessible) in &mut query {
let name = calc_name(&texts, children);
if let Some(mut accessible) = accessible {
accessible.set_role(Role::Image);
if let Some(name) = name {
accessible.set_name(name);
} else {
accessible.clear_name();
}
} else {
let mut node = NodeBuilder::new(Role::Image);
if let Some(name) = name {
node.set_name(name);
}
commands
.entity(entity)
.insert(AccessibilityNode::from(node));
}
}
}

fn label_changed(
mut commands: Commands,
mut query: Query<(Entity, &Text, Option<&mut AccessibilityNode>), Changed<Label>>,
) {
for (entity, text, accessible) in &mut query {
let values = text
.sections
.iter()
.map(|v| v.value.to_string())
.collect::<Vec<String>>();
let name = Some(values.join(" ").into_boxed_str());
if let Some(mut accessible) = accessible {
accessible.set_role(Role::LabelText);
if let Some(name) = name {
accessible.set_name(name);
} else {
accessible.clear_name();
}
} else {
let mut node = NodeBuilder::new(Role::LabelText);
if let Some(name) = name {
node.set_name(name);
}
commands
.entity(entity)
.insert(AccessibilityNode::from(node));
}
}
}

/// `AccessKit` integration for `bevy_ui`.
pub(crate) struct AccessibilityPlugin;

impl Plugin for AccessibilityPlugin {
fn build(&self, app: &mut App) {
app.add_system(calc_bounds)
.add_system(button_changed)
.add_system(image_changed)
.add_system(label_changed);
}
}
6 changes: 4 additions & 2 deletions crates/bevy_ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod render;
mod stack;
mod ui_node;

mod accessibility;
pub mod camera_config;
pub mod node_bundles;
pub mod update;
Expand All @@ -27,8 +28,7 @@ pub use ui_node::*;
pub mod prelude {
#[doc(hidden)]
pub use crate::{
camera_config::*, geometry::*, node_bundles::*, ui_node::*, widget::Button, Interaction,
UiScale,
camera_config::*, geometry::*, node_bundles::*, ui_node::*, widget::*, Interaction, UiScale,
};
}

Expand Down Expand Up @@ -102,6 +102,8 @@ impl Plugin for UiPlugin {
.register_type::<UiImage>()
.register_type::<Val>()
.register_type::<widget::Button>()
.register_type::<widget::Label>()
.add_plugin(accessibility::AccessibilityPlugin)
.configure_set(UiSystem::Focus.in_base_set(CoreSet::PreUpdate))
.configure_set(UiSystem::Flex.in_base_set(CoreSet::PostUpdate))
.configure_set(UiSystem::Stack.in_base_set(CoreSet::PostUpdate))
Expand Down
9 changes: 9 additions & 0 deletions crates/bevy_ui/src/widget/label.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use bevy_ecs::prelude::Component;
use bevy_ecs::reflect::ReflectComponent;
use bevy_reflect::std_traits::ReflectDefault;
use bevy_reflect::Reflect;

/// Marker struct for labels
#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
#[reflect(Component, Default)]
pub struct Label;
2 changes: 2 additions & 0 deletions crates/bevy_ui/src/widget/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

mod button;
mod image;
mod label;
#[cfg(feature = "bevy_text")]
mod text;

pub use button::*;
pub use image::*;
pub use label::*;
#[cfg(feature = "bevy_text")]
pub use text::*;
5 changes: 5 additions & 0 deletions crates/bevy_winit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,23 @@ keywords = ["bevy"]
trace = []
wayland = ["winit/wayland"]
x11 = ["winit/x11"]
accesskit_unix = ["accesskit_winit/accesskit_unix"]

[dependencies]
# bevy
bevy_a11y = { path = "../bevy_a11y", version = "0.9.0" }
bevy_app = { path = "../bevy_app", version = "0.9.0" }
bevy_derive = { path = "../bevy_derive", version = "0.9.0" }
bevy_ecs = { path = "../bevy_ecs", version = "0.9.0" }
bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.9.0" }
bevy_input = { path = "../bevy_input", version = "0.9.0" }
bevy_math = { path = "../bevy_math", version = "0.9.0" }
bevy_window = { path = "../bevy_window", version = "0.9.0" }
bevy_utils = { path = "../bevy_utils", version = "0.9.0" }

# other
winit = { version = "0.28", default-features = false }
accesskit_winit = { version = "0.12", default-features = false }
approx = { version = "0.5", default-features = false }
raw-window-handle = "0.5"

Expand Down
Loading

0 comments on commit 17fa32d

Please sign in to comment.