mirror of
https://github.com/bevyengine/bevy
synced 2024-11-22 04:33:37 +00:00
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:
parent
abcb0661e3
commit
8d1f6ff7fa
19 changed files with 597 additions and 27 deletions
|
@ -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 }
|
||||
|
|
17
crates/bevy_a11y/Cargo.toml
Normal file
17
crates/bevy_a11y/Cargo.toml
Normal 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"
|
70
crates/bevy_a11y/src/lib.rs
Normal file
70
crates/bevy_a11y/src/lib.rs
Normal 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>();
|
||||
}
|
||||
}
|
|
@ -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" }
|
||||
|
|
|
@ -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")]
|
||||
{
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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" }
|
||||
|
|
157
crates/bevy_ui/src/accessibility.rs
Normal file
157
crates/bevy_ui/src/accessibility.rs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
|
|
9
crates/bevy_ui/src/widget/label.rs
Normal file
9
crates/bevy_ui/src/widget/label.rs
Normal 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;
|
|
@ -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::*;
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
171
crates/bevy_winit/src/accessibility.rs
Normal file
171
crates/bevy_winit/src/accessibility.rs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))|
|
||||
|
|
|
@ -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,13 +287,18 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
|
|||
})
|
||||
.with_children(|parent| {
|
||||
// bevy logo (image)
|
||||
parent.spawn(ImageBundle {
|
||||
parent
|
||||
.spawn(ImageBundle {
|
||||
style: Style {
|
||||
size: Size::width(Val::Px(500.0)),
|
||||
..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()));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -34,6 +34,7 @@ crates=(
|
|||
bevy_scene
|
||||
bevy_sprite
|
||||
bevy_text
|
||||
bevy_a11y
|
||||
bevy_ui
|
||||
bevy_winit
|
||||
bevy_internal
|
||||
|
|
Loading…
Reference in a new issue