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
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,45 @@ jobs:
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings

doc:
name: Doc
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# rustdoc still drives Bevy's pkg-config-touching build scripts even
# though it doesn't link, so this job needs the same Linux deps as
# lint and test.
- name: Install Linux deps for Bevy
run: |
sudo apt-get update
sudo apt-get install -y libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: cargo doc (warnings as errors)
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --no-deps

deny:
name: Deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
# Pin cargo-deny to a known version so the config schema (v2)
# stays in sync with the binary; bump in a dedicated PR that also
# re-runs the check. `cargo audit` is intentionally not added —
# `cargo deny check advisories` reads the same RustSec database,
# so adding both would be redundant.
- name: Install cargo-deny
run: cargo install cargo-deny --locked --version 0.19.4
# Runs all four checks (advisories, bans, licenses, sources).
# The advisories check fails CI on RustSec vulnerabilities and on
# `unmaintained` advisories that we have not explicitly ignored
# in deny.toml.
- name: cargo deny check
run: cargo deny check

test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
Expand Down
60 changes: 59 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,61 @@
# Buiy

Repository scaffold. See [`docs/README.md`](docs/README.md) for the docs index and [`CLAUDE.md`](CLAUDE.md) for the development guide once they land.
Buiy is a comprehensive UI library for [Bevy](https://bevyengine.org). It provides a token-themed, accessibility-first widget toolkit on top of Bevy's ECS — Taffy-based layout, an AccessKit-shaped a11y tree, focus and picking, and a small render pipeline that draws into Bevy's render graph. Phase 0 ships the foundation (one `Button`, the plugin scaffolding, and the verification harness); the full widget catalog and feature surface are tracked in `docs/`.

## Status

Pre-0.1. APIs may change. The repo currently lives at version `0.0.1` and has not been tagged or published.

## Quick start

Add Buiy alongside Bevy in your `Cargo.toml`, then add `BuiyPlugin` after `DefaultPlugins`:

```rust
use bevy::prelude::*;
use buiy::*;

fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(BuiyPlugin)
.add_systems(Startup, setup)
.run();
}

fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
commands.spawn(Button::new("Save"));
}
```

`Button::new` returns a bundle wired up with `Node`, `Style`, `Focusable`, an `A11yRole::Button`, and an `A11yLabel`. See `examples/hello_button/` and `tests/hello_button_e2e.rs` for the full shape.

## Requirements

- **Rust:** stable (see `rust-toolchain.toml`).
- **Bevy:** 0.18.
- **Linux system deps for Bevy:** `libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev` (the CI lint and test jobs install these explicitly).

## Repository layout

```
crates/
├── buiy/ — public umbrella crate; the one users depend on
├── buiy_core/ — components, plugins, layout, a11y, focus, picking, render, theme
├── buiy_widgets/ — widget implementations (Phase 0: Button)
└── buiy_verify/ — verification helpers (a11y snapshots, contrast linter)

examples/
└── hello_button/ — minimal end-to-end runnable scene

tests/ — workspace-level integration tests (e.g. hello_button_e2e)
docs/ — design specs, implementation plans, and reports
```

## Design docs

Architectural specs, migration plans, and audit reports all live under [`docs/`](docs/README.md). Start at the docs index, which is grouped by feature area and tagged with status.

## License

TBD (license placeholder; the workspace `Cargo.toml` currently declares `MIT OR Apache-2.0`, but the crate has not been published).
7 changes: 7 additions & 0 deletions crates/buiy_core/src/a11y.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@ use crate::{BuiySet, focus::Focusable};
use bevy::prelude::*;

/// Decomposed AccessKit role component.
///
/// Marked `#[non_exhaustive]` because the v0.x full ARIA taxonomy
/// expansion (38+ roles, see foundation spec accessibility.md § 3.11)
/// will add variants pre-1.0. External matches must include a wildcard
/// arm; the in-tree `buiy_verify::a11y::role_to_str` is structured this
/// way already.
#[derive(Component, Reflect, Clone, Copy, Debug, PartialEq, Eq, Default)]
#[reflect(Component)]
#[non_exhaustive]
pub enum A11yRole {
#[default]
Generic,
Expand Down
4 changes: 4 additions & 0 deletions crates/buiy_core/src/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ use bevy::prelude::*;
/// Flex layout direction. Mirrors Taffy's `FlexDirection` for the
/// row / column subset used in Phase 0; v0.x layout-design will widen
/// this to include `RowReverse` / `ColumnReverse` when needed.
///
/// Marked `#[non_exhaustive]` because new variants are expected pre-1.0
/// and external matches must opt in to handling them.
#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum FlexDirection {
#[default]
Row,
Expand Down
43 changes: 36 additions & 7 deletions crates/buiy_core/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,52 @@ use taffy::{
/// Maps Bevy `Entity` to Taffy node IDs. Reused across frames to keep
/// Taffy's internal cache warm. Stored as a `NonSend` resource because
/// Taffy's compact-length representation contains a `*const ()`.
//
// TODO(buiy-layout-design): garbage-collect entries when their `Entity`
// is despawned. Currently `by_entity` and the underlying `TaffyTree`
// grow monotonically across despawns. v0.x adds a `RemovedComponents<Node>`
// reader system in `BuiySet::Layout` that drops the matching entries.
#[derive(Default)]
pub struct LayoutTree {
tree: TaffyTree<()>,
by_entity: HashMap<Entity, TaffyNodeId>,
}

impl LayoutTree {
/// Number of entity-to-Taffy-node mappings currently held. Exposed for
/// tests that need to assert GC actually freed orphan entries.
pub fn len(&self) -> usize {
self.by_entity.len()
}

/// Whether the tracker holds no entity-to-Taffy-node mappings.
pub fn is_empty(&self) -> bool {
self.by_entity.is_empty()
}
}

pub struct LayoutPlugin;

impl Plugin for LayoutPlugin {
fn build(&self, app: &mut App) {
app.init_non_send_resource::<LayoutTree>()
.add_systems(Update, sync_and_compute_layout.in_set(BuiySet::Layout));
app.init_non_send_resource::<LayoutTree>().add_systems(
Update,
// GC must run before sync so the same tick's despawns don't
// leave dangling parent/child refs visible to Taffy's
// set_children call.
(gc_removed_nodes, sync_and_compute_layout)
.chain()
.in_set(BuiySet::Layout),
);
}
}

/// Drop Taffy nodes for entities whose `Node` component was removed
/// (despawn or component-remove). Without this, `by_entity` and the
/// underlying `TaffyTree` grow monotonically across despawns.
fn gc_removed_nodes(mut tree: NonSendMut<LayoutTree>, mut removed: RemovedComponents<Node>) {
let tree = &mut *tree;
for entity in removed.read() {
if let Some(id) = tree.by_entity.remove(&entity)
&& let Err(err) = tree.tree.remove(id)
{
warn!(?entity, ?err, "buiy: layout gc remove failed");
}
}
}

Expand Down
12 changes: 12 additions & 0 deletions crates/buiy_core/src/render/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,24 @@ pub mod pipeline;

/// What the render world needs from the main world per frame: a list of
/// (rect, color, radius) tuples in window-local logical pixels.
///
/// `#[non_exhaustive]` — the full pipeline (top-layer compositing,
/// clip-path, filters, blend modes, atlasing per
/// `buiy-render-pipeline-design`) will add fields here. The render
/// world does not re-export this for main-world consumers, so the only
/// out-of-crate audience is render-graph plugin authors who already
/// know to update through `..Default::default()` shims.
#[derive(Resource, Default, Clone)]
#[non_exhaustive]
pub struct ExtractedDraws {
pub draws: Vec<DrawData>,
}

/// One rectangle in the Phase 0 render queue. Marked `#[non_exhaustive]`
/// because the full pipeline (clip-path, filters, blend modes, etc.)
/// will add per-draw fields pre-1.0.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct DrawData {
pub position: Vec2,
pub size: Vec2,
Expand Down
13 changes: 13 additions & 0 deletions crates/buiy_core/src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ use std::collections::HashMap;
/// A single theme variant. Phase 0 stores tokens as flat string-keyed maps.
/// The full token system replaces this with typed scales in
/// `buiy-theme-tokens-design`.
///
/// Marked `#[non_exhaustive]` because the field set is explicitly Phase 0
/// minimal — typed token scales (typography, motion, elevation, etc.) will
/// add fields pre-1.0. External callers should use `Theme::default()` or
/// `default_light_theme()` and mutate fields rather than struct literals.
#[derive(Resource, Reflect, Clone, Debug, Default)]
#[reflect(Resource)]
#[non_exhaustive]
pub struct Theme {
pub colors: HashMap<String, Color>,
pub spaces: HashMap<String, f32>,
Expand All @@ -33,8 +39,15 @@ impl Theme {
/// OS preference resource. Updated by a system in BuiySet::Input that reads
/// from winit (or platform-specific sources). Phase 0 populates with
/// defaults; full OS-pref plumbing is `buiy-clipboard-and-os-integration-design`.
///
/// Marked `#[non_exhaustive]` because additional preferences (caret
/// blink, pointer fine/coarse, prefers-color-scheme states beyond
/// dark/light, NVDA / VoiceOver-specific hints) will be added pre-1.0.
/// External callers should construct via `UserPreferences::default()`
/// and override fields rather than using struct literals.
#[derive(Resource, Reflect, Clone, Debug, Default)]
#[reflect(Resource)]
#[non_exhaustive]
pub struct UserPreferences {
pub prefers_dark: bool,
pub prefers_reduced_motion: bool,
Expand Down
37 changes: 36 additions & 1 deletion crates/buiy_core/tests/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use bevy::prelude::*;
use buiy_core::{
CorePlugin,
components::{FlexDirection, Node, ResolvedLayout, Style},
layout::LayoutPlugin,
layout::{LayoutPlugin, LayoutTree},
};

#[test]
Expand Down Expand Up @@ -48,3 +48,38 @@ fn layout_resolves_a_simple_flex_row() {
assert!((layout.size.x - 50.0).abs() < 0.5, "child width ~ 50");
assert!((layout.size.y - 50.0).abs() < 0.5, "child height ~ 50");
}

#[test]
fn layout_tree_garbage_collects_despawned_entities() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(CorePlugin);
app.add_plugins(LayoutPlugin);

let entity = app
.world_mut()
.spawn((
Node,
Style {
width: 100.0,
height: 100.0,
..default()
},
))
.id();

app.update();
assert_eq!(
app.world().non_send_resource::<LayoutTree>().len(),
1,
"spawned entity registered in LayoutTree after first update",
);

app.world_mut().entity_mut(entity).despawn();
app.update();

assert!(
app.world().non_send_resource::<LayoutTree>().is_empty(),
"despawned entity dropped from LayoutTree by gc system",
);
}
95 changes: 95 additions & 0 deletions crates/buiy_core/tests/system_set_order.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//! Pin the relative order of `BuiySet` variants on the `Update` schedule.
//!
//! `CorePlugin` configures the seven Buiy sets with `.chain()` so they run in
//! the order documented on `BuiySet`: Layout → Style → Input → Animate →
//! Picking → A11yUpdate → Render. Downstream plugins (`LayoutPlugin`,
//! `RenderPlugin`, …) attach their systems to those sets and rely on that
//! order. A silent reorder would break the contract without any compile-time
//! signal — so introspect the schedule's dependency graph and assert it
//! directly.

use bevy::ecs::schedule::NodeId;
use bevy::prelude::*;
use buiy_core::{BuiySet, CorePlugin};

/// Driver: build a fresh app with `CorePlugin`, run one tick to force the
/// `Update` schedule to be initialized (which triggers the dependency-graph
/// toposort), then read the cached toposort back out and locate each
/// requested set inside it. Returns the toposort indices of the supplied
/// sets, in the same order they were passed in.
fn set_indices(sets: &[BuiySet]) -> Vec<usize> {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(CorePlugin);
// Forces `Schedule::initialize`, which calls `build_schedule` and caches
// a toposort on the dependency DAG.
app.update();

let schedules = app.world().resource::<Schedules>();
let schedule = schedules
.get(Update)
.expect("CorePlugin registered systems on Update");
let graph = schedule.graph();
let toposort = graph
.dependency()
.get_toposort()
.expect("dependency DAG is toposorted after Schedule::initialize");

sets.iter()
.map(|set| {
let key = graph
.system_sets
.get_key(set.intern())
.unwrap_or_else(|| panic!("BuiySet::{set:?} not registered on Update"));
let node = NodeId::Set(key);
toposort
.iter()
.position(|n| *n == node)
.unwrap_or_else(|| panic!("BuiySet::{set:?} missing from toposort"))
})
.collect()
}

#[test]
fn buiy_sets_run_in_documented_order() {
// Order documented on `BuiySet`: Layout → Style → Input → Animate →
// Picking → A11yUpdate → Render. If any pair flips, downstream plugins
// that read `ResolvedLayout` from render or fire focus changes after
// input would silently break.
let order = [
BuiySet::Layout,
BuiySet::Style,
BuiySet::Input,
BuiySet::Animate,
BuiySet::Picking,
BuiySet::A11yUpdate,
BuiySet::Render,
];

let idx = set_indices(&order);
for window in idx.windows(2) {
assert!(
window[0] < window[1],
"BuiySet order violated: indices {idx:?} for {order:?}",
);
}
}

#[test]
fn layout_runs_before_render() {
// Spot-check the load-bearing pair: layout writes `ResolvedLayout`,
// render reads it. The full-order test above already covers this, but
// a focused regression test makes the failure mode obvious.
let idx = set_indices(&[BuiySet::Layout, BuiySet::Render]);
assert!(idx[0] < idx[1], "Layout must run before Render: {idx:?}");
}

#[test]
fn layout_runs_before_animate() {
// The brief asked for "Layout after Animate"; the actual `CorePlugin`
// chain places Animate *after* Layout (Layout → Style → Input →
// Animate). This test pins the real order so the documentation in
// `BuiySet` cannot drift from the configured chain.
let idx = set_indices(&[BuiySet::Layout, BuiySet::Animate]);
assert!(idx[0] < idx[1], "Layout must run before Animate: {idx:?}");
}
Loading
Loading