Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,16 @@ once it reaches `0.1.0`. Pre-`0.1.0` releases are pre-alpha; APIs may break in a

Pre-`0.1.0` development. Detailed change tracking begins with the first
tagged release.

### Added
- Render pipeline now produces real pixels for Buiy nodes (instance-buffer
construction, clip-space conversion, draw call). Closes the Phase 0
render deferral.
- Per-window AccessKit tree-update bridge. Buiy translates its widget tree
to `accesskit::TreeUpdate` each frame and pushes it through bevy_winit's
`ACCESS_KIT_ADAPTERS` so real screen readers attached to a Buiy window
see the live tree. (Bevy 0.18 owns adapter creation, so Buiy bridges
rather than owning `Adapter` objects directly.) Closes the Phase 0 a11y
deferral.
- `bevy_picking` backend. `Hovered` becomes a thin layer over the standard
`PointerHits` event flow. Closes the Phase 0 picking deferral.
10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,18 @@ rust-version = "1.85"
# bevy_picking backend, image-compare for SSIM, thiserror for typed errors)
# return when the consuming task lands. See review notes in
# docs/plans/2026-05-07-buiy-phase-0-foundations.md.
bevy = { version = "0.18", default-features = false, features = ["bevy_render", "bevy_core_pipeline", "bevy_winit", "bevy_window", "bevy_asset", "bevy_log", "x11", "wayland"] }
# Phase 0 closeout (2026-05-08) added: bytemuck (instance buffer POD),
# accesskit + accesskit_winit (per-window adapter), bevy_picking feature
# on bevy. Still deferred: image-compare (visual harness upgrade is v0.x
# verification work), thiserror (no error-typing pressure yet).
bevy = { version = "0.18", default-features = false, features = ["bevy_render", "bevy_core_pipeline", "bevy_winit", "bevy_window", "bevy_asset", "bevy_log", "bevy_picking", "x11", "wayland"] }
taffy = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
image = "0.25"
proptest = "1"
tracing = "0.1"
# Phase 0 closeout deps (closeout plan 2026-05-08).
bytemuck = { version = "1", features = ["derive"] }
accesskit = "0.21"
accesskit_winit = "0.29"
30 changes: 21 additions & 9 deletions crates/buiy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ use bevy::prelude::*;

pub use buiy_core::{
BuiySet, CorePlugin,
a11y::{A11yDescription, A11yLabel, A11yRole, A11yTreeBuilder},
a11y::{A11yDescription, A11yLabel, A11yRole, A11yTreeBuilder, AccessKitAdapterPlugin},
components::{FlexDirection, Node, ResolvedLayout, Style},
focus::{FocusVisible, Focusable, FocusedEntity},
picking::Hovered,
picking::{BuiyPickingBackendPlugin, Hovered},
theme::{Theme, UserPreferences, default_light_theme},
};
pub use buiy_widgets::{Button, OnPress, WidgetsPlugin};
Expand Down Expand Up @@ -45,6 +45,11 @@ pub use buiy_widgets::{Button, OnPress, WidgetsPlugin};
/// 0.18 panics when a `Res<T>` system param is missing, so the plugin
/// must be present.
///
/// `BuiyPlugin` also composes `bevy::picking::PickingPlugin` (the core
/// bevy_picking infrastructure), so you do not need to add it separately.
/// If you are using `DefaultPlugins`, bevy_picking is not included by
/// default; `BuiyPlugin` adds it for you.
///
/// # Plugin order
///
/// Add `BuiyPlugin` **after** Bevy's render plugin (i.e., after
Expand All @@ -58,20 +63,27 @@ pub struct BuiyPlugin;

impl Plugin for BuiyPlugin {
fn build(&self, app: &mut App) {
// Sub-plugin order matches architecture.md § 2.8 documented order:
// core → theme → a11y → focus → input → text → widgets → ...
// Phase 0 omits text / animation / forms / devtools; LayoutPlugin
// and PickingPlugin (which aren't enumerated as sub-plugins in § 2.8
// because their work lives in BuiySet::Layout and BuiySet::Picking)
// are slotted between Focus and Widgets so widgets see resolved
// layout + hit-test results when they run in the same frame.
// Sub-plugin order matches architecture.md § 2.8: core → theme → a11y →
// focus → input → text → widgets. Phase 0 omits text/animation/forms/
// devtools. LayoutPlugin, bevy::picking::PickingPlugin, Buiy's
// PickingPlugin, and BuiyPickingBackendPlugin slot between Focus and
// Widgets so widgets see resolved layout + hit-test results in the same
// frame. bevy_picking must come first because it registers PickingSystems
// sets + Messages<PointerHits>; the two Buiy plugins consume both.
app.add_plugins((
CorePlugin,
buiy_core::theme::ThemePlugin,
buiy_core::a11y::A11yPlugin,
buiy_core::a11y::AccessKitAdapterPlugin,
buiy_core::focus::FocusPlugin,
buiy_core::layout::LayoutPlugin,
// bevy_picking::PickingPlugin registers PickingSystems system sets
// and the Messages<PointerHits> message resource that both
// PickingPlugin and BuiyPickingBackendPlugin depend on. Must come
// before the two Buiy picking plugins.
bevy::picking::PickingPlugin,
buiy_core::picking::PickingPlugin,
buiy_core::picking::BuiyPickingBackendPlugin,
WidgetsPlugin,
));
}
Expand Down
3 changes: 3 additions & 0 deletions crates/buiy_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ license.workspace = true
bevy.workspace = true
taffy.workspace = true
tracing.workspace = true
bytemuck.workspace = true
accesskit.workspace = true
accesskit_winit.workspace = true
72 changes: 72 additions & 0 deletions crates/buiy_core/src/a11y/adapter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//! Per-window AccessKit adapter bridge. Buiy pushes its `A11yTreeBuilder`
//! snapshot into bevy_winit's existing `ACCESS_KIT_ADAPTERS` thread-local
//! each frame so that real screen readers attached to the window see Buiy's
//! widget tree.
//!
//! Architecture: foundation spec architecture.md § 2.6, accessibility.md § 3.11.
//!
//! # Adapter lifecycle ownership
//!
//! Bevy 0.18 (`bevy_winit`) owns the `Adapter` objects — they are created in
//! `prepare_accessibility_for_window` (called from the winit runner with
//! `ActiveEventLoop` in hand) and stored in the `ACCESS_KIT_ADAPTERS`
//! thread-local. `AccessKitAdapterPlugin` does *not* create or destroy
//! adapters; it only pushes `TreeUpdate` payloads each frame via
//! `update_if_active`.
//!
//! # Test-friendliness note
//!
//! Tests using `MinimalPlugins` (no winit) see an empty `ACCESS_KIT_ADAPTERS`
//! thread-local, so `push_tree_updates` is a no-op and the pure-translation
//! behavior is covered by `tests/a11y_translate.rs` independently.

use crate::BuiySet;
use crate::a11y::A11yTreeBuilder;
use crate::a11y::translate::build_tree_update;
use crate::focus::FocusedEntity;
use bevy::prelude::*;
use bevy::winit::accessibility::ACCESS_KIT_ADAPTERS;

/// Plugin that wires `A11yTreeBuilder` → bevy_winit's per-window
/// AccessKit adapters each frame.
///
/// `push_tree_updates` is ordered `.after(crate::a11y::build_tree)` so it
/// observes the freshly built snapshot for the current frame, not the
/// previous one. Both systems live in `BuiySet::A11yUpdate`; without the
/// explicit dependency Bevy's scheduler is free to reorder them
/// (ambiguity-detection defaults to `LogLevel::Ignore`).
pub struct AccessKitAdapterPlugin;

impl Plugin for AccessKitAdapterPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
push_tree_updates
.in_set(BuiySet::A11yUpdate)
.after(crate::a11y::build_tree),
);
}
}

fn push_tree_updates(builder: Res<A11yTreeBuilder>, focused: Res<FocusedEntity>) {
use crate::a11y::translate::node_id_for;

let focused_id = focused.0.map(node_id_for);
let snapshot = builder.snapshot();

// Always build and push, even when the snapshot is empty. An empty
// TreeUpdate (root-only) is the correct signal for the AT to clear
// stale state when all widgets are removed (dialog close, scene
// transition, etc.). `accesskit_unix::Adapter::update_if_active` diffs
// and emits `ChildRemoved` events when given a root-only TreeUpdate.
// The `with_borrow_mut` loop is a no-op when no winit windows exist
// (e.g. tests with `MinimalPlugins`).
let update = build_tree_update(snapshot, focused_id);

ACCESS_KIT_ADAPTERS.with_borrow_mut(|ak_adapters| {
for (_window_id, adapter) in ak_adapters.iter_mut() {
let cloned = update.clone();
adapter.update_if_active(|| cloned);
}
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
use crate::{BuiySet, focus::Focusable};
use bevy::prelude::*;

pub mod adapter;
pub mod translate;

pub use adapter::AccessKitAdapterPlugin;
pub use translate::{build_tree_update, to_accesskit_node};

/// Decomposed AccessKit role component.
///
/// Marked `#[non_exhaustive]` because the v0.x full ARIA taxonomy
Expand Down Expand Up @@ -81,7 +87,7 @@ impl Plugin for A11yPlugin {
}

#[allow(clippy::type_complexity)]
fn build_tree(
pub(crate) fn build_tree(
mut builder: ResMut<A11yTreeBuilder>,
q: Query<(
Entity,
Expand Down
85 changes: 85 additions & 0 deletions crates/buiy_core/src/a11y/translate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! Pure translation from Buiy's frame-built `A11yNodeView` snapshot into the
//! AccessKit data model. Keeping this module winit-free means we can
//! unit-test it without provisioning a real window.

use crate::a11y::{A11yNodeView, A11yRole};
use accesskit::{Node, NodeId, Role, Tree, TreeUpdate};
use bevy::prelude::Entity;

/// Stable AccessKit root node id. Every adapter pushes the same root so the
/// AT sees one tree per Buiy window. v0.x may key this off the window entity
/// when multi-window-aware ATs become a target.
pub const ROOT_NODE_ID: NodeId = NodeId(0);

/// Convert a Bevy [`Entity`] into a stable [`NodeId`]. Entity::to_bits is
/// deterministic within a session, which is sufficient for AT consumption
/// (the AT doesn't compare across sessions).
pub fn node_id_for(entity: Entity) -> NodeId {
// Avoid 0 (reserved for ROOT_NODE_ID). +1 is safe because Bevy never
// produces an `Entity` whose bits are `u64::MAX`.
NodeId(entity.to_bits().saturating_add(1))
}

/// Translate one [`A11yNodeView`] into an [`accesskit::Node`].
///
/// Note: `Node::set_label` takes `impl Into<Box<str>>` in accesskit 0.21;
/// `String` and `&str` both satisfy that bound.
pub fn to_accesskit_node(view: &A11yNodeView) -> Node {
let mut node = Node::new(role_to_accesskit(view.role));
if !view.name.is_empty() {
node.set_label(view.name.clone());
}
if !view.description.is_empty() {
node.set_description(view.description.clone());
}
// Phase 0 closeout: focusable widgets get the AccessKit "focusable"
// semantic. Full keyboard-action contract is widget-specific
// (`buiy-widget-catalog-design`).
if view.focusable {
node.add_action(accesskit::Action::Focus);
}
node
}

fn role_to_accesskit(role: A11yRole) -> Role {
match role {
A11yRole::Generic => Role::GenericContainer,
A11yRole::Button => Role::Button,
A11yRole::Link => Role::Link,
A11yRole::Image => Role::Image,
A11yRole::Text => Role::Label,
A11yRole::Heading => Role::Heading,
A11yRole::Dialog => Role::Dialog,
A11yRole::AlertDialog => Role::AlertDialog,
A11yRole::Tooltip => Role::Tooltip,
}
}

/// Build a full [`TreeUpdate`] containing a synthetic root plus one node
/// per [`A11yNodeView`]. Children are listed under the root in iteration
/// order; nesting is a v0.x topic (`buiy-accessibility-design`).
pub fn build_tree_update(views: &[A11yNodeView], focused: Option<NodeId>) -> TreeUpdate {
let mut nodes = Vec::with_capacity(views.len() + 1);

// Children first — we still need to materialize their NodeIds before
// we can list them under the root.
let mut child_ids = Vec::with_capacity(views.len());
for view in views {
let id = node_id_for(view.entity);
child_ids.push(id);
nodes.push((id, to_accesskit_node(view)));
}

// Root.
let mut root = Node::new(Role::Window);
for cid in &child_ids {
root.push_child(*cid);
}
nodes.insert(0, (ROOT_NODE_ID, root));

TreeUpdate {
nodes,
tree: Some(Tree::new(ROOT_NODE_ID)),
focus: focused.unwrap_or(ROOT_NODE_ID),
}
}
2 changes: 1 addition & 1 deletion crates/buiy_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub use a11y::{A11yDescription, A11yLabel, A11yNodeView, A11yPlugin, A11yRole, A
pub use components::{FlexDirection, Node, ResolvedLayout, Style};
pub use focus::{FocusPlugin, FocusVisible, Focusable, FocusedEntity};
pub use layout::LayoutPlugin;
pub use picking::{Hovered, PickingPlugin, hit_test};
pub use picking::{BuiyPickingBackendPlugin, Hovered, PickingPlugin, hit_test};

/// Top-level system sets for Buiy. Order: Layout → Style → Input → Animate
/// → Picking → A11yUpdate → Render.
Expand Down
76 changes: 76 additions & 0 deletions crates/buiy_core/src/picking/backend.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! Buiy's `bevy_picking` backend. Reads `PointerLocation` and produces
//! `PointerHits` from `ResolvedLayout` AABBs.
//!
//! Phase 0 closeout scope: per-pointer hits, top-most-by-area resolution
//! (matches the Phase 0 `hit_test` semantics in `mod.rs`). Multi-pointer
//! arbitration, pointer-target window filtering, and full backend priority
//! land in `buiy-input-events-design`.

use crate::components::ResolvedLayout;
use crate::picking::point_in_aabb;
use bevy::picking::PickingSystems;
use bevy::picking::backend::{HitData, PointerHits};
use bevy::picking::pointer::{PointerId, PointerLocation};
use bevy::prelude::*;

/// Buiy's `bevy_picking` backend plugin. Registers `emit_picks` in
/// [`PickingSystems::Backend`] so bevy_picking can composite Buiy's
/// AABB hit results with any other active backends.
pub struct BuiyPickingBackendPlugin;

impl Plugin for BuiyPickingBackendPlugin {
fn build(&self, app: &mut App) {
app.add_systems(PreUpdate, emit_picks.in_set(PickingSystems::Backend));
}
}

fn emit_picks(
pointers: Query<(&PointerId, &PointerLocation)>,
nodes: Query<(Entity, &ResolvedLayout)>,
mut output: MessageWriter<PointerHits>,
) {
for (pointer, location) in pointers.iter() {
let Some(loc) = location.location() else {
continue;
};
let cursor = loc.position;

// Collect every Buiy node under the cursor, with its area as the
// tie-break for "top-most".
let mut hits: Vec<(Entity, f32)> = Vec::new();
for (entity, layout) in nodes.iter() {
if point_in_aabb(cursor, layout) {
let area = layout.size.x * layout.size.y;
hits.push((entity, area));
}
}
if hits.is_empty() {
continue;
}
// Smallest area = closest to the user (top of the stack). bevy_picking
// expects depth-sorted; emit one HitData per entity with depth derived
// from area rank.
hits.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let picks: Vec<(Entity, HitData)> = hits
.iter()
.enumerate()
.map(|(i, (e, _))| {
(
*e,
HitData::new(
// Camera entity unknown to Buiy in Phase 0 closeout; the
// render-graph node draws into the active 2D camera's
// ViewTarget, but bevy_picking expects a camera ref for
// its own back-projection. v0.x sub-spec wires this.
Entity::PLACEHOLDER,
i as f32,
None,
None,
),
)
})
.collect();

output.write(PointerHits::new(*pointer, picks, 0.0));
}
}
Loading
Loading