Integrate AccessKit (#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.
This commit is contained in:
Nolan Darilek 2023-03-01 22:45:04 +00:00
parent abcb0661e3
commit 8d1f6ff7fa
19 changed files with 597 additions and 27 deletions

View file

@ -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 }

View file

@ -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"

View file

@ -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>();
}
}

View file

@ -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" }

View file

@ -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")]
{

View file

@ -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::*;

View file

@ -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" }

View file

@ -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);
}
}

View file

@ -9,6 +9,7 @@ mod render;
mod stack;
mod ui_node;
mod accessibility;
pub mod camera_config;
pub mod node_bundles;
pub mod update;
@ -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,
};
}
@ -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))

View file

@ -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;

View file

@ -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::*;

View file

@ -12,11 +12,15 @@ 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" }
@ -24,6 +28,7 @@ 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"

View file

@ -0,0 +1,171 @@
use std::{
collections::VecDeque,
sync::{atomic::Ordering, Arc, Mutex},
};
use accesskit_winit::Adapter;
use bevy_a11y::{
accesskit::{ActionHandler, ActionRequest, NodeBuilder, NodeClassSet, Role, TreeUpdate},
AccessKitEntityExt, AccessibilityNode, AccessibilityRequested, Focus,
};
use bevy_app::{App, Plugin};
use bevy_derive::{Deref, DerefMut};
use bevy_ecs::{
prelude::{DetectChanges, Entity, EventReader, EventWriter},
query::With,
system::{NonSend, NonSendMut, Query, Res, ResMut, Resource},
};
use bevy_hierarchy::{Children, Parent};
use bevy_utils::{default, HashMap};
use bevy_window::{PrimaryWindow, Window, WindowClosed, WindowFocused};
/// Maps window entities to their `AccessKit` [`Adapter`]s.
#[derive(Default, Deref, DerefMut)]
pub struct AccessKitAdapters(pub HashMap<Entity, Adapter>);
/// Maps window entities to their respective [`WinitActionHandler`]s.
#[derive(Resource, Default, Deref, DerefMut)]
pub struct WinitActionHandlers(pub HashMap<Entity, WinitActionHandler>);
/// Forwards `AccessKit` [`ActionRequest`]s from winit to an event channel.
#[derive(Clone, Default, Deref, DerefMut)]
pub struct WinitActionHandler(pub Arc<Mutex<VecDeque<ActionRequest>>>);
impl ActionHandler for WinitActionHandler {
fn do_action(&self, request: ActionRequest) {
let mut requests = self.0.lock().unwrap();
requests.push_back(request);
}
}
fn handle_window_focus(
focus: Res<Focus>,
adapters: NonSend<AccessKitAdapters>,
mut focused: EventReader<WindowFocused>,
) {
for event in focused.iter() {
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(
mut adapters: NonSendMut<AccessKitAdapters>,
mut receivers: ResMut<WinitActionHandlers>,
mut events: EventReader<WindowClosed>,
) {
for WindowClosed { window, .. } in events.iter() {
adapters.remove(window);
receivers.remove(window);
}
}
fn poll_receivers(handlers: Res<WinitActionHandlers>, mut actions: EventWriter<ActionRequest>) {
for (_id, handler) in handlers.iter() {
let mut handler = handler.lock().unwrap();
while let Some(event) = handler.pop_front() {
actions.send(event);
}
}
}
fn update_accessibility_nodes(
adapters: NonSend<AccessKitAdapters>,
focus: Res<Focus>,
accessibility_requested: Res<AccessibilityRequested>,
primary_window: Query<(Entity, &Window), With<PrimaryWindow>>,
nodes: Query<(
Entity,
&AccessibilityNode,
Option<&Children>,
Option<&Parent>,
)>,
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 Some(adapter) = adapters.get(&primary_window_id) {
let should_run = focus.is_changed() || !nodes.is_empty();
if should_run {
adapter.update_if_active(|| {
let mut to_update = vec![];
let mut has_focus = false;
let mut name = None;
if primary_window.focused {
has_focus = true;
let title = primary_window.title.clone();
name = Some(title.into_boxed_str());
}
let focus_id = if has_focus {
(*focus).or_else(|| Some(primary_window_id))
} else {
None
};
let mut root_children = vec![];
for (entity, node, children, parent) in &nodes {
let mut node = (**node).clone();
if let Some(parent) = parent {
if node_entities.get(**parent).is_err() {
root_children.push(entity.to_node_id());
}
} else {
root_children.push(entity.to_node_id());
}
if let Some(children) = children {
for child in children.iter() {
if node_entities.get(*child).is_ok() {
node.push_child(child.to_node_id());
}
}
}
to_update.push((
entity.to_node_id(),
node.build(&mut NodeClassSet::lock_global()),
));
}
let mut root = NodeBuilder::new(Role::Window);
if let Some(name) = name {
root.set_name(name);
}
root.set_children(root_children);
let root = root.build(&mut NodeClassSet::lock_global());
let window_update = (primary_window_id.to_node_id(), root);
to_update.insert(0, window_update);
TreeUpdate {
nodes: to_update,
focus: focus_id.map(|v| v.to_node_id()),
..default()
}
});
}
}
}
}
/// Implements winit-specific `AccessKit` functionality.
pub struct AccessibilityPlugin;
impl Plugin for AccessibilityPlugin {
fn build(&self, app: &mut App) {
app.init_non_send_resource::<AccessKitAdapters>()
.init_resource::<WinitActionHandlers>()
.add_event::<ActionRequest>()
.add_system(handle_window_focus)
.add_system(window_closed)
.add_system(poll_receivers)
.add_system(update_accessibility_nodes);
}
}

View file

@ -1,3 +1,4 @@
pub mod accessibility;
mod converters;
mod system;
#[cfg(target_arch = "wasm32")]
@ -5,6 +6,7 @@ mod web_resize;
mod winit_config;
mod winit_windows;
use bevy_a11y::AccessibilityRequested;
use bevy_ecs::system::{SystemParam, SystemState};
use system::{changed_window, create_window, despawn_window, CachedWindow};
@ -39,6 +41,8 @@ use winit::{
event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopWindowTarget},
};
use crate::accessibility::{AccessKitAdapters, AccessibilityPlugin, WinitActionHandlers};
#[cfg(target_arch = "wasm32")]
use crate::web_resize::{CanvasParentResizeEventChannel, CanvasParentResizePlugin};
@ -80,6 +84,8 @@ impl Plugin for WinitPlugin {
.in_base_set(CoreSet::Last),
);
app.add_plugin(AccessibilityPlugin);
#[cfg(target_arch = "wasm32")]
app.add_plugin(CanvasParentResizePlugin);
@ -90,6 +96,9 @@ impl Plugin for WinitPlugin {
Query<(Entity, &mut Window)>,
EventWriter<WindowCreated>,
NonSendMut<WinitWindows>,
NonSendMut<AccessKitAdapters>,
ResMut<WinitActionHandlers>,
ResMut<AccessibilityRequested>,
)> = SystemState::from_world(&mut app.world);
#[cfg(target_arch = "wasm32")]
@ -99,6 +108,9 @@ impl Plugin for WinitPlugin {
Query<(Entity, &mut Window)>,
EventWriter<WindowCreated>,
NonSendMut<WinitWindows>,
NonSendMut<AccessKitAdapters>,
ResMut<WinitActionHandlers>,
ResMut<AccessibilityRequested>,
ResMut<CanvasParentResizeEventChannel>,
)> = SystemState::from_world(&mut app.world);
@ -107,12 +119,29 @@ impl Plugin for WinitPlugin {
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
{
#[cfg(not(target_arch = "wasm32"))]
let (commands, event_loop, mut new_windows, event_writer, winit_windows) =
create_window_system_state.get_mut(&mut app.world);
let (
commands,
event_loop,
mut new_windows,
event_writer,
winit_windows,
adapters,
handlers,
accessibility_requested,
) = create_window_system_state.get_mut(&mut app.world);
#[cfg(target_arch = "wasm32")]
let (commands, event_loop, mut new_windows, event_writer, winit_windows, event_channel) =
create_window_system_state.get_mut(&mut app.world);
let (
commands,
event_loop,
mut new_windows,
event_writer,
winit_windows,
adapters,
handlers,
accessibility_requested,
event_channel,
) = create_window_system_state.get_mut(&mut app.world);
// Here we need to create a winit-window and give it a WindowHandle which the renderer can use.
// It needs to be spawned before the start of the startup-stage, so we cannot use a regular system.
@ -123,6 +152,9 @@ impl Plugin for WinitPlugin {
new_windows.iter_mut(),
event_writer,
winit_windows,
adapters,
handlers,
accessibility_requested,
#[cfg(target_arch = "wasm32")]
event_channel,
);
@ -264,6 +296,9 @@ pub fn winit_runner(mut app: App) {
Query<(Entity, &mut Window), Added<Window>>,
EventWriter<WindowCreated>,
NonSendMut<WinitWindows>,
NonSendMut<AccessKitAdapters>,
ResMut<WinitActionHandlers>,
ResMut<AccessibilityRequested>,
)> = SystemState::from_world(&mut app.world);
#[cfg(target_arch = "wasm32")]
@ -272,6 +307,9 @@ pub fn winit_runner(mut app: App) {
Query<(Entity, &mut Window), Added<Window>>,
EventWriter<WindowCreated>,
NonSendMut<WinitWindows>,
NonSendMut<AccessKitAdapters>,
ResMut<WinitActionHandlers>,
ResMut<AccessibilityRequested>,
ResMut<CanvasParentResizeEventChannel>,
)> = SystemState::from_world(&mut app.world);
@ -646,8 +684,15 @@ pub fn winit_runner(mut app: App) {
if winit_state.active {
#[cfg(not(target_arch = "wasm32"))]
let (commands, mut new_windows, created_window_writer, winit_windows) =
create_window_system_state.get_mut(&mut app.world);
let (
commands,
mut new_windows,
created_window_writer,
winit_windows,
adapters,
handlers,
accessibility_requested,
) = create_window_system_state.get_mut(&mut app.world);
#[cfg(target_arch = "wasm32")]
let (
@ -655,6 +700,9 @@ pub fn winit_runner(mut app: App) {
mut new_windows,
created_window_writer,
winit_windows,
adapters,
handlers,
accessibility_requested,
canvas_parent_resize_channel,
) = create_window_system_state.get_mut(&mut app.world);
@ -665,6 +713,9 @@ pub fn winit_runner(mut app: App) {
new_windows.iter_mut(),
created_window_writer,
winit_windows,
adapters,
handlers,
accessibility_requested,
#[cfg(target_arch = "wasm32")]
canvas_parent_resize_channel,
);

View file

@ -1,9 +1,10 @@
use bevy_a11y::AccessibilityRequested;
use bevy_ecs::{
entity::Entity,
event::EventWriter,
prelude::{Changed, Component, Resource},
removal_detection::RemovedComponents,
system::{Commands, NonSendMut, Query},
system::{Commands, NonSendMut, Query, ResMut},
world::Mut,
};
use bevy_utils::{
@ -21,22 +22,25 @@ use winit::{
#[cfg(target_arch = "wasm32")]
use crate::web_resize::{CanvasParentResizeEventChannel, WINIT_CANVAS_SELECTOR};
use crate::{
accessibility::{AccessKitAdapters, WinitActionHandlers},
converters::{self, convert_window_level},
get_best_videomode, get_fitting_videomode, WinitWindows,
};
#[cfg(target_arch = "wasm32")]
use bevy_ecs::system::ResMut;
/// System responsible for creating new windows whenever a `Window` component is added
/// to an entity.
///
/// This will default any necessary components if they are not already added.
#[allow(clippy::too_many_arguments)]
pub(crate) fn create_window<'a>(
mut commands: Commands,
event_loop: &EventLoopWindowTarget<()>,
created_windows: impl Iterator<Item = (Entity, Mut<'a, Window>)>,
mut event_writer: EventWriter<WindowCreated>,
mut winit_windows: NonSendMut<WinitWindows>,
mut adapters: NonSendMut<AccessKitAdapters>,
mut handlers: ResMut<WinitActionHandlers>,
mut accessibility_requested: ResMut<AccessibilityRequested>,
#[cfg(target_arch = "wasm32")] event_channel: ResMut<CanvasParentResizeEventChannel>,
) {
for (entity, mut window) in created_windows {
@ -50,7 +54,14 @@ pub(crate) fn create_window<'a>(
entity
);
let winit_window = winit_windows.create_window(event_loop, entity, &window);
let winit_window = winit_windows.create_window(
event_loop,
entity,
&window,
&mut adapters,
&mut handlers,
&mut accessibility_requested,
);
window
.resolution
.set_scale_factor(winit_window.scale_factor());

View file

@ -1,3 +1,10 @@
use std::sync::atomic::Ordering;
use accesskit_winit::Adapter;
use bevy_a11y::{
accesskit::{NodeBuilder, NodeClassSet, Role, Tree, TreeUpdate},
AccessKitEntityExt, AccessibilityRequested,
};
use bevy_ecs::entity::Entity;
use bevy_utils::{tracing::warn, HashMap};
@ -8,7 +15,10 @@ use winit::{
monitor::MonitorHandle,
};
use crate::converters::convert_window_level;
use crate::{
accessibility::{AccessKitAdapters, WinitActionHandler, WinitActionHandlers},
converters::convert_window_level,
};
#[derive(Debug, Default)]
pub struct WinitWindows {
@ -27,9 +37,16 @@ impl WinitWindows {
event_loop: &winit::event_loop::EventLoopWindowTarget<()>,
entity: Entity,
window: &Window,
adapters: &mut AccessKitAdapters,
handlers: &mut WinitActionHandlers,
accessibility_requested: &mut AccessibilityRequested,
) -> &winit::window::Window {
let mut winit_window_builder = winit::window::WindowBuilder::new();
// Due to a UIA limitation, winit windows need to be invisible for the
// AccessKit adapter is initialized.
winit_window_builder = winit_window_builder.with_visible(false);
winit_window_builder = match window.mode {
WindowMode::BorderlessFullscreen => winit_window_builder.with_fullscreen(Some(
winit::window::Fullscreen::Borderless(event_loop.primary_monitor()),
@ -118,6 +135,30 @@ impl WinitWindows {
}
let winit_window = winit_window_builder.build(event_loop).unwrap();
let name = window.title.clone();
let mut root_builder = NodeBuilder::new(Role::Window);
root_builder.set_name(name.into_boxed_str());
let root = root_builder.build(&mut NodeClassSet::lock_global());
let accesskit_window_id = entity.to_node_id();
let handler = WinitActionHandler::default();
let accessibility_requested = (*accessibility_requested).clone();
let adapter = Adapter::with_action_handler(
&winit_window,
move || {
accessibility_requested.store(true, Ordering::SeqCst);
TreeUpdate {
nodes: vec![(accesskit_window_id, root)],
tree: Some(Tree::new(accesskit_window_id)),
focus: None,
}
},
Box::new(handler.clone()),
);
adapters.insert(entity, adapter);
handlers.insert(entity, handler);
winit_window.set_visible(true);
// Do not set the grab mode on window creation if it's none, this can fail on mobile
if window.cursor.grab_mode != CursorGrabMode::None {

View file

@ -39,6 +39,7 @@ The default feature set enables most of the expected features of a game engine,
|feature name|description|
|-|-|
|accesskit_unix|Enable AccessKit on Unix backends (currently only works with experimental screen readers and forks.)|
|basis-universal|Basis Universal compressed texture support|
|bevy_ci_testing|Enable systems that allow for automated testing on CI|
|bevy_dynamic_plugin|Plugin for dynamic loading (using [libloading](https://crates.io/crates/libloading))|

View file

@ -1,6 +1,10 @@
//! This example illustrates the various features of Bevy UI.
use bevy::{
a11y::{
accesskit::{NodeBuilder, Role},
AccessibilityNode,
},
input::mouse::{MouseScrollUnit, MouseWheel},
prelude::*,
winit::WinitSettings,
@ -55,7 +59,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
})
.with_children(|parent| {
// text
parent.spawn(
parent.spawn((
TextBundle::from_section(
"Text Example",
TextStyle {
@ -68,7 +72,11 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
margin: UiRect::all(Val::Px(5.0)),
..default()
}),
);
// Because this is a distinct label widget and
// not button/list item text, this is necessary
// for accessibility to treat the text accordingly.
Label,
));
});
});
// right vertical fill
@ -86,7 +94,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
})
.with_children(|parent| {
// Title
parent.spawn(
parent.spawn((
TextBundle::from_section(
"Scrolling list",
TextStyle {
@ -99,7 +107,8 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
size: Size::height(Val::Px(25.)),
..default()
}),
);
Label,
));
// List with hidden overflow
parent
.spawn(NodeBundle {
@ -128,11 +137,12 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
..default()
},
ScrollingList::default(),
AccessibilityNode(NodeBuilder::new(Role::List)),
))
.with_children(|parent| {
// List items
for i in 0..30 {
parent.spawn(
parent.spawn((
TextBundle::from_section(
format!("Item {i}"),
TextStyle {
@ -147,7 +157,9 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
size: Size::new(Val::Undefined, Val::Px(20.)),
..default()
}),
);
Label,
AccessibilityNode(NodeBuilder::new(Role::ListItem)),
));
}
});
});
@ -275,14 +287,19 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
})
.with_children(|parent| {
// bevy logo (image)
parent.spawn(ImageBundle {
style: Style {
size: Size::width(Val::Px(500.0)),
parent
.spawn(ImageBundle {
style: Style {
size: Size::width(Val::Px(500.0)),
..default()
},
..default()
},
image: asset_server.load("branding/bevy_logo_dark_big.png").into(),
..default()
});
})
.with_children(|parent| {
// alt text
parent
.spawn(TextBundle::from_section("Bevy logo", TextStyle::default()));
});
});
});
}

View file

@ -34,6 +34,7 @@ crates=(
bevy_scene
bevy_sprite
bevy_text
bevy_a11y
bevy_ui
bevy_winit
bevy_internal