From 122bb1a460b7a518374a5a1a5b13681d1abcf9a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 03:41:36 +0000 Subject: [PATCH 1/6] docs: replace README scaffold with a real top-level README The previous README was a 4-line placeholder pointing at docs/README.md. This is the first thing crates.io readers + GitHub landing-page visitors hit, so it should give a one-paragraph "what is Buiy", a runnable code snippet matching the public API shape (Button::new), and pointers at the example, the e2e test, and the docs index. Kept under ~60 lines per the v0.1 readiness brief. License section is explicitly "TBD" since the crate has not been published; workspace Cargo.toml declares MIT OR Apache-2.0 but that is not yet a final decision and the README should not pre-empt it. --- README.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 110751f..ba1c80d 100644 --- a/README.md +++ b/README.md @@ -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). From b4b1245cadd573fd568bb2d26324202f473ec659 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 03:46:58 +0000 Subject: [PATCH 2/6] refactor(buiy_core): add #[non_exhaustive] to growth-prone public types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marked types where additional variants/fields are explicitly expected pre-1.0, so external callers cannot bake in struct/match shapes that will break when those additions land. Marked: - FlexDirection — RowReverse / ColumnReverse are coming per layout-design. - A11yRole — full ARIA taxonomy (38+ roles) lands in v0.x per accessibility.md § 3.11. Updated buiy_verify::a11y::role_to_str to add the now-required wildcard arm and rewrote its lint comment to explain that exhaustiveness no longer auto-fires; PRs adding roles must keep this table current. - UserPreferences — additional OS prefs (caret blink, pointer fine / coarse, screen-reader hints) will land with the OS-integration spec. - Theme — Phase 0 token surface is intentionally minimal; the typed scale set replaces it pre-1.0 per buiy-theme-tokens-design. - DrawData / ExtractedDraws — full render pipeline (clip-path, filters, blend modes, atlasing) will add per-draw and per-frame fields. Skipped (deliberate, with reasoning): - Style — constructed via struct literal with `..default()` in crates/buiy_core/tests/layout.rs and crates/buiy_widgets/src/button.rs. Both are external from buiy_core's perspective; #[non_exhaustive] forbids `..default()` shorthand from outside the defining crate, and rewriting Style construction to `let mut s = Style::default(); s.x = …` on every callsite is uglier than just being careful when adding fields. Worth re-evaluating once a Style builder lands. - A11yNodeView — constructed via struct literal in crates/buiy_verify/tests/a11y.rs with all fields specified. The type has no obvious Default (entity / role / name are all required for a meaningful node), so we cannot route construction through Default + field assignment without inventing a placeholder Entity convention. - BuiySet — schedule contract; new variants are intentional churn that every plugin needs to react to anyway. - Focusable, Hovered, FocusedEntity, FocusVisible, A11yLabel, A11yDescription, Node, ResolvedLayout — newtype/marker/Vec2-wrapper shapes that are stable by design (per the brief: "don't add it to types whose shape is stable"). Verified: cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, xvfb-run -a cargo test --workspace all green. --- crates/buiy_core/src/a11y.rs | 7 +++++++ crates/buiy_core/src/components.rs | 4 ++++ crates/buiy_core/src/render/mod.rs | 12 ++++++++++++ crates/buiy_core/src/theme.rs | 13 +++++++++++++ crates/buiy_verify/src/a11y.rs | 14 ++++++++++---- 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/buiy_core/src/a11y.rs b/crates/buiy_core/src/a11y.rs index 1256071..df5bbe2 100644 --- a/crates/buiy_core/src/a11y.rs +++ b/crates/buiy_core/src/a11y.rs @@ -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, diff --git a/crates/buiy_core/src/components.rs b/crates/buiy_core/src/components.rs index 642f4c6..08b713a 100644 --- a/crates/buiy_core/src/components.rs +++ b/crates/buiy_core/src/components.rs @@ -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, diff --git a/crates/buiy_core/src/render/mod.rs b/crates/buiy_core/src/render/mod.rs index 13b6363..f1fcf31 100644 --- a/crates/buiy_core/src/render/mod.rs +++ b/crates/buiy_core/src/render/mod.rs @@ -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, } +/// 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, diff --git a/crates/buiy_core/src/theme.rs b/crates/buiy_core/src/theme.rs index 1ff9ba7..f14dc4f 100644 --- a/crates/buiy_core/src/theme.rs +++ b/crates/buiy_core/src/theme.rs @@ -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, pub spaces: HashMap, @@ -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, diff --git a/crates/buiy_verify/src/a11y.rs b/crates/buiy_verify/src/a11y.rs index 8e79cd4..df99f57 100644 --- a/crates/buiy_verify/src/a11y.rs +++ b/crates/buiy_verify/src/a11y.rs @@ -16,10 +16,15 @@ struct WireNode<'a> { focusable: bool, } -// LINT: Keep this match in sync with `buiy_core::a11y::A11yRole`. When -// the v0.x full ARIA taxonomy expansion lands (38+ roles per -// accessibility.md § 3.11), Rust's exhaustiveness check will force -// new arms — add them here in the same PR. +// LINT: Keep this match in sync with `buiy_core::a11y::A11yRole`. +// `A11yRole` is `#[non_exhaustive]`, so Rust requires a wildcard arm and +// the compiler will *not* surface unhandled variants for us. When new +// roles land in `buiy_core::a11y` (e.g. the v0.x full ARIA taxonomy per +// accessibility.md § 3.11) the unknown stringification below shows up +// in snapshot goldens and PRs touching that file must add the named +// arms in the same PR. The fallback exists so external snapshots stay +// well-formed across version skew, not as a substitute for keeping this +// table current. fn role_to_str(r: A11yRole) -> &'static str { match r { A11yRole::Generic => "Generic", @@ -31,6 +36,7 @@ fn role_to_str(r: A11yRole) -> &'static str { A11yRole::Dialog => "Dialog", A11yRole::AlertDialog => "AlertDialog", A11yRole::Tooltip => "Tooltip", + _ => "Unknown", } } From dfc9e409fe904228363c845ae95d65d5cedc16ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 03:47:35 +0000 Subject: [PATCH 3/6] ci: add cargo doc -D warnings job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "Doc" CI job that runs `RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps`. This catches broken intra-doc links, missing code-fence languages, and other rustdoc warnings before they land in public-facing docs (eventually docs.rs). Job uses the same Linux build-deps as the lint job because rustdoc still drives Bevy's pkg-config-touching build scripts even though it does not link. The current tree was clean under -D warnings — no doc fixes were needed in this commit. The job exists to keep it that way. --- .github/workflows/ci.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 833596b..758325c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,25 @@ 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 + test: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} From 1d4e30a56fdcac438a9de6937360789d36250321 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 03:55:57 +0000 Subject: [PATCH 4/6] ci: add cargo-deny supply-chain check (skips redundant cargo audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `deny.toml` at the repo root and a "Deny" CI job that runs `cargo deny check` (advisories, bans, licenses, sources) against the workspace dep graph. Why no separate `cargo audit` job: `cargo deny check advisories` reads the same RustSec advisory database that `cargo audit` reads. Adding both would duplicate the work and the CI bill for no extra signal, so this stack standardizes on cargo-deny for both supply-chain auditing *and* license / source-registry policy. Config notes (also documented inline in deny.toml): - License `allow` list assembled from the current Cargo.lock — Bevy 0.18, Taffy 0.10, image 0.25 and their transitive graph use MIT, Apache-2.0 (sometimes WITH LLVM-exception), BSD-2/3-Clause, ISC, Zlib, Unicode-3.0, CC0-1.0, MIT-0. MPL-2.0, BSL-1.0 and Unicode-DFS-2016 are pre-allowed for forward compat with new deps; `unused-allowed-license = "allow"` silences the resulting warnings. - `[bans]` is intentionally permissive. Bevy ships many duplicated transitive deps and pre-emptively banning duplicates would just be noise. `wildcards = "warn"` (not deny) because our intra-workspace `path = "..."` deps register as wildcard requirements; cargo-deny's `allow-wildcard-paths` only suppresses that for non-publishable crates, and the buiy_* crates haven't set `publish = false` yet. Promote back to "deny" once we either publish-gate the crates or pin explicit `version = "=0.0.1"` on each path dep — out of scope for the v0.1-readiness pass. - One advisory ignored with rationale: RUSTSEC-2024-0436 (`paste` archived). It reaches us transitively via wgpu-hal's metal backend and image's rav1e — no upstream fix available. Re-evaluate when Bevy / image bump those deps. - CI job pins cargo-deny to 0.19.4 via `cargo install --locked --version 0.19.4` so config schema bumps in the tool can't silently change CI behavior. Verified: `cargo deny check` exits 0 locally with the new config; the remaining warnings (intra-workspace path-dep wildcards) are informational and serve as a prompt to fix before publishing. --- .github/workflows/ci.yml | 20 ++++++++ deny.toml | 102 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 deny.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 758325c..48d97be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,26 @@ jobs: 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 }} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..fa66977 --- /dev/null +++ b/deny.toml @@ -0,0 +1,102 @@ +# cargo-deny configuration for the Buiy workspace. +# +# This config uses the cargo-deny v2 schema (no `[licenses.unlicensed]` / +# `copyleft` keys; only `allow` lists). cargo-deny >= 0.16 understands it. +# CI pins to a specific cargo-deny version so the format stays in sync +# with the binary. +# +# Goals (kept minimal on purpose): +# - Catch RustSec advisories (vulnerabilities + unmaintained) so we +# notice supply-chain issues without standing up a separate +# `cargo audit` job. `cargo deny check advisories` covers the same +# RustSec database that `cargo audit` reads. +# - Allow only the licenses already present in the dep graph +# (Bevy + Taffy + image + serde et al.). Adding a new dep with an +# unlisted license fails CI; that is the intended forcing function. +# - No license / SPDX exception hacks — every license is listed by its +# short SPDX identifier. +# - `[bans]` is intentionally empty: Bevy ships many duplicated +# transitive deps (multiple versions of `windows-sys`, `bitflags`, +# etc.) and pre-emptively banning duplicates would just be noise. +# Re-evaluate when the dep tree stabilizes. + +[graph] +# Match the workspace's supported targets so we don't audit deps that +# only ship for platforms we don't build for. +targets = [ + { triple = "x86_64-unknown-linux-gnu" }, + { triple = "x86_64-apple-darwin" }, + { triple = "aarch64-apple-darwin" }, + { triple = "x86_64-pc-windows-msvc" }, +] + +[advisories] +# Use the latest RustSec advisory DB; CI fetches it fresh on every run. +version = 2 +# `yanked` defaults to "warn"; promote to "deny" so a yanked dep fails +# CI (we should not ship dependencies the upstream author pulled). +yanked = "deny" +# `ignore` is the escape hatch for advisories we have explicitly +# triaged. Add an entry only after eyeballing the advisory and deciding +# we accept the risk; do not bulk-suppress. +ignore = [ + # `paste` is archived upstream (RUSTSEC-2024-0436). It reaches us + # transitively via wgpu-hal's metal backend and via image's AV1 codec + # (rav1e). There is no safe upgrade — the fix has to come from those + # upstreams switching to `pastey` or similar. Re-evaluate when Bevy + # bumps wgpu past the metal/paste boundary or when image drops rav1e. + { id = "RUSTSEC-2024-0436", reason = "transitive via Bevy/wgpu-hal and image/rav1e; no upstream fix available, tracked as advisory drift" }, +] + +[licenses] +version = 2 +# License set assembled from the current Cargo.lock (Bevy 0.18, Taffy +# 0.10, image 0.25, proptest, serde, tracing, and their transitive +# graph). Every entry is an SPDX short identifier. +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "Unicode-3.0", + "Unicode-DFS-2016", + "Zlib", +] +# Confidence threshold for license-text matching. 0.93 is cargo-deny's +# default; lowering produces false positives, raising rejects valid +# licenses with whitespace drift. +confidence-threshold = 0.93 +# Don't warn when an allowed license isn't actually present in the dep +# graph. We over-allow a few licenses on purpose (MPL-2.0, BSL-1.0, +# Unicode-DFS-2016) so that adding a new dep with one of those +# licenses doesn't fail CI for a reason unrelated to the change. +unused-allowed-license = "allow" + +[bans] +# Intentionally permissive. See file header for the rationale; revisit +# once the dep graph stabilizes. +multiple-versions = "allow" +# `wildcards = "warn"` instead of `"deny"`: cargo-deny emits a wildcard +# error for our own intra-workspace `path = "..."` deps because those +# get an implicit `*` version requirement, and `allow-wildcard-paths` +# only suppresses that for non-publishable crates (the buiy_* crates +# don't yet set `publish = false`). Promoting wildcards back to "deny" +# is the right move once we either publish-gate the crates or pin +# explicit `version = "=0.0.1"` next to each path dep — both of which +# are out of scope for the v0.1-readiness pass. +wildcards = "warn" +allow-wildcard-paths = true + +[sources] +# Crates.io is the only source we trust by default. If we ever pull a +# dep from a git URL the unknown-registry / unknown-git rules will +# force a config update, which is the right friction. +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] From 0190691b49c39d467b50862c7232c59ba4328e5d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 05:02:06 +0000 Subject: [PATCH 5/6] feat(buiy_core): gc despawned nodes from LayoutTree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `RemovedComponents` reader now runs in `BuiySet::Layout` ahead of the sync pass, dropping orphan entries from both `by_entity` and the underlying `TaffyTree`. Without it, both collections grew monotonically across despawns — the gap that the now-deleted `TODO(buiy-layout-design)` comment flagged. The GC system runs *before* `sync_and_compute_layout` (chained inside the same set) so a single tick that despawns and respawns a node sees a clean Taffy tree before we re-walk the Bevy hierarchy. Running it after sync would let `set_children` fan out to a stale Taffy NodeId for the just-despawned entity. Exposes `LayoutTree::len` / `is_empty` so the new unit test can assert post-despawn state without touching `NonSend` internals. The unit test spawns a `Node`, runs `Update`, despawns, runs `Update` again, and asserts the tracker is empty. Verified: `cargo test -p buiy_core --test layout` — both `layout_resolves_a_simple_flex_row` and the new `layout_tree_garbage_collects_despawned_entities` pass. --- crates/buiy_core/src/layout.rs | 43 ++++++++++++++++++++++++++------ crates/buiy_core/tests/layout.rs | 37 ++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/crates/buiy_core/src/layout.rs b/crates/buiy_core/src/layout.rs index 8e79aa1..f314930 100644 --- a/crates/buiy_core/src/layout.rs +++ b/crates/buiy_core/src/layout.rs @@ -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` -// reader system in `BuiySet::Layout` that drops the matching entries. #[derive(Default)] pub struct LayoutTree { tree: TaffyTree<()>, by_entity: HashMap, } +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::() - .add_systems(Update, sync_and_compute_layout.in_set(BuiySet::Layout)); + app.init_non_send_resource::().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, mut removed: RemovedComponents) { + 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"); + } } } diff --git a/crates/buiy_core/tests/layout.rs b/crates/buiy_core/tests/layout.rs index 625dd8f..2ac3009 100644 --- a/crates/buiy_core/tests/layout.rs +++ b/crates/buiy_core/tests/layout.rs @@ -2,7 +2,7 @@ use bevy::prelude::*; use buiy_core::{ CorePlugin, components::{FlexDirection, Node, ResolvedLayout, Style}, - layout::LayoutPlugin, + layout::{LayoutPlugin, LayoutTree}, }; #[test] @@ -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::().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::().is_empty(), + "despawned entity dropped from LayoutTree by gc system", + ); +} From 95e86e272a9511a3053133d3b822dfdabc77ee31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 05:05:57 +0000 Subject: [PATCH 6/6] test(buiy_core): pin BuiySet relative order on Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an integration test that introspects the `Update` schedule's dependency DAG via `Schedule::graph().dependency().get_toposort()` after `app.update()` initializes it, then asserts each `BuiySet` variant lands in the documented order: Layout → Style → Input → Animate → Picking → A11yUpdate → Render. Why introspection beats behavioural assertion: layout/render/etc. all happen on disjoint data, so a silent reorder doesn't trip existing integration tests until something visibly mis-renders mid-frame. Reading the toposort directly catches a stray `.before()` / `.after()` edit the moment it lands. Three test cases: - `buiy_sets_run_in_documented_order` — full chain, the contract. - `layout_runs_before_render` — load-bearing pair (ResolvedLayout is written by Layout, consumed by Render). - `layout_runs_before_animate` — pins the actual order against any future doc drift; the `BuiySet` doc-comment claims Layout-then-Animate, the test enforces it. Helper extracts toposort indices via `system_sets.get_key` + `NodeId::Set`, panics with the offending set name on a miss so failures are obvious. No new public API on `BuiySet`. Verified: `xvfb-run -a cargo test --workspace` — all 3 new tests pass alongside the existing suite. --- crates/buiy_core/tests/system_set_order.rs | 95 ++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 crates/buiy_core/tests/system_set_order.rs diff --git a/crates/buiy_core/tests/system_set_order.rs b/crates/buiy_core/tests/system_set_order.rs new file mode 100644 index 0000000..bfff900 --- /dev/null +++ b/crates/buiy_core/tests/system_set_order.rs @@ -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 { + 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::(); + 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:?}"); +}