diff --git a/Cargo.toml b/Cargo.toml index f334b03b4d3aa..e6b38cbe7ff81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -209,6 +209,9 @@ 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 } diff --git a/crates/bevy_a11y/Cargo.toml b/crates/bevy_a11y/Cargo.toml new file mode 100644 index 0000000000000..30b44331f3cef --- /dev/null +++ b/crates/bevy_a11y/Cargo.toml @@ -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" diff --git a/crates/bevy_a11y/src/lib.rs b/crates/bevy_a11y/src/lib.rs new file mode 100644 index 0000000000000..ed91fe26e0415 --- /dev/null +++ b/crates/bevy_a11y/src/lib.rs @@ -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); + +/// 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 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); + +/// 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::() + .init_resource::(); + } +} diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index 3bbb87ca1d1a5..d39935294f449 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -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" } diff --git a/crates/bevy_internal/src/default_plugins.rs b/crates/bevy_internal/src/default_plugins.rs index 04b2e0fe5aff8..2c3e637de8e0f 100644 --- a/crates/bevy_internal/src/default_plugins.rs +++ b/crates/bevy_internal/src/default_plugins.rs @@ -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")] { diff --git a/crates/bevy_internal/src/lib.rs b/crates/bevy_internal/src/lib.rs index f1bcfa57a2b3a..b2211a5e3f751 100644 --- a/crates/bevy_internal/src/lib.rs +++ b/crates/bevy_internal/src/lib.rs @@ -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::*; diff --git a/crates/bevy_ui/Cargo.toml b/crates/bevy_ui/Cargo.toml index 697fee31492e0..9a48945147c32 100644 --- a/crates/bevy_ui/Cargo.toml +++ b/crates/bevy_ui/Cargo.toml @@ -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" } diff --git a/crates/bevy_ui/src/accessibility.rs b/crates/bevy_ui/src/accessibility.rs new file mode 100644 index 0000000000000..ee79773189bd1 --- /dev/null +++ b/crates/bevy_ui/src/accessibility.rs @@ -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> { + 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::>(); + 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, Changed)>, + >, +) { + 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