diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..833596b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + # Bevy's wayland/x11/udev features pull crates whose build.rs runs + # pkg-config; clippy still drives those build scripts even though it + # doesn't link, so the lint job needs the same system libs as 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: rustfmt + run: cargo fmt --all -- --check + - name: clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install Linux deps for Bevy + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev xvfb at-spi2-core + - name: cargo test (headless via xvfb on Linux) + if: matrix.os == 'ubuntu-latest' + run: xvfb-run -a cargo test --workspace + - name: cargo test (macOS / Windows) + if: matrix.os != 'ubuntu-latest' + run: cargo test --workspace diff --git a/.gitignore b/.gitignore index 05787f3..988010f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ .claude/worktrees/ .worktrees/ + +.superpowers/ + +target/ +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..486f044 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "buiy_workspace" +version = "0.0.1" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false +description = "Workspace root package; exists only to host workspace-level integration tests." + +[lib] +path = "src/lib.rs" + +[dev-dependencies] +bevy = { workspace = true, features = ["bevy_render", "bevy_winit", "x11", "wayland"] } +buiy = { path = "crates/buiy" } +buiy_core = { path = "crates/buiy_core" } +buiy_verify = { path = "crates/buiy_verify" } + +[workspace] +resolver = "2" +members = [ + "crates/buiy", + "crates/buiy_core", + "crates/buiy_widgets", + "crates/buiy_verify", + "examples/hello_button", +] + +[workspace.package] +version = "0.0.1" +edition = "2024" +license = "MIT OR Apache-2.0" +repository = "https://github.com/intendednull/buiy" +rust-version = "1.85" + +[workspace.dependencies] +# Phase 0 keeps the workspace dep set minimal: only crates that have a +# `use` in `src/` are listed. Speculative deps (accesskit, accesskit_winit, +# 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"] } +taffy = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +image = "0.25" +proptest = "1" +tracing = "0.1" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..5539ef0 --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +disallowed-methods = [] diff --git a/crates/buiy/Cargo.toml b/crates/buiy/Cargo.toml new file mode 100644 index 0000000..2326148 --- /dev/null +++ b/crates/buiy/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "buiy" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "A comprehensive UI library for Bevy with web-quality accessibility." + +[dependencies] +bevy.workspace = true +buiy_core = { path = "../buiy_core" } +buiy_widgets = { path = "../buiy_widgets" } diff --git a/crates/buiy/src/lib.rs b/crates/buiy/src/lib.rs new file mode 100644 index 0000000..222a45a --- /dev/null +++ b/crates/buiy/src/lib.rs @@ -0,0 +1,83 @@ +//! Buiy — comprehensive UI library for Bevy. +//! +//! See: docs/specs/2026-05-07-buiy-foundation/README.md. + +use bevy::prelude::*; + +pub use buiy_core::{ + BuiySet, CorePlugin, + a11y::{A11yDescription, A11yLabel, A11yRole, A11yTreeBuilder}, + components::{FlexDirection, Node, ResolvedLayout, Style}, + focus::{FocusVisible, Focusable, FocusedEntity}, + picking::Hovered, + theme::{Theme, UserPreferences, default_light_theme}, +}; +pub use buiy_widgets::{Button, OnPress, WidgetsPlugin}; + +// `buiy_core::render::ExtractedDraws` is intentionally NOT re-exported at +// the crate root: it is a render-world resource only, populated during the +// extract phase. Main-world consumers reading it would see an empty Vec. +// Render-world plugin authors who need it can reach `buiy::buiy_core::render` +// (or depend on `buiy_core` directly) without crate-root surface pollution. + +/// Top-level Buiy plugin. Composes sub-plugins in the documented order: +/// core → theme → a11y → focus → input → widgets. Render registration +/// happens in `Plugin::finish` so RenderApp exists when we reach it. +/// +/// See: docs/specs/2026-05-07-buiy-foundation/architecture.md § 2.8. +/// +/// # Required Bevy plugins +/// +/// `BuiyPlugin` requires `bevy::input::InputPlugin`. `DefaultPlugins` +/// includes it; if you build your app with `MinimalPlugins`, add it +/// explicitly: +/// +/// ```ignore +/// App::new() +/// .add_plugins(MinimalPlugins) +/// .add_plugins(bevy::input::InputPlugin) +/// .add_plugins(BuiyPlugin) +/// .run(); +/// ``` +/// +/// `FocusPlugin::handle_tab` reads `Res>` and the +/// `Button` click handler reads `Res>`. Bevy +/// 0.18 panics when a `Res` system param is missing, so the plugin +/// must be present. +/// +/// # Plugin order +/// +/// Add `BuiyPlugin` **after** Bevy's render plugin (i.e., after +/// `DefaultPlugins`). `BuiyPlugin::finish` registers `BuiyRenderPlugin`, +/// whose `build` reads `PipelineCache` — a resource that +/// `RenderPlugin::finish` inserts. Plugin `finish` runs in registration +/// order, so adding `BuiyPlugin` before `DefaultPlugins` flips the order +/// and panics when `BuiyRenderPlugin` reaches for the missing +/// `PipelineCache`. +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. + app.add_plugins(( + CorePlugin, + buiy_core::theme::ThemePlugin, + buiy_core::a11y::A11yPlugin, + buiy_core::focus::FocusPlugin, + buiy_core::layout::LayoutPlugin, + buiy_core::picking::PickingPlugin, + WidgetsPlugin, + )); + } + + fn finish(&self, app: &mut App) { + // RenderApp is guaranteed to exist by `finish` time. + app.add_plugins(buiy_core::render::BuiyRenderPlugin); + } +} diff --git a/crates/buiy/tests/plugin.rs b/crates/buiy/tests/plugin.rs new file mode 100644 index 0000000..2b6dda7 --- /dev/null +++ b/crates/buiy/tests/plugin.rs @@ -0,0 +1,20 @@ +use bevy::prelude::*; +use buiy::BuiyPlugin; + +#[test] +fn buiy_plugin_loads_in_correct_order() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + // `BuiyPlugin` composes systems that read keyboard / pointer input + // (focus tab handling, button click). `MinimalPlugins` does not include + // `InputPlugin`, so we add it here so `app.update()` doesn't panic on + // missing `ButtonInput` / `ButtonInput` resources. + app.add_plugins(bevy::input::InputPlugin); + app.add_plugins(BuiyPlugin); + app.update(); + + // Sanity: re-exports are accessible. + let _ = std::any::TypeId::of::(); + let _ = std::any::TypeId::of::(); + let _ = std::any::TypeId::of::(); +} diff --git a/crates/buiy_core/Cargo.toml b/crates/buiy_core/Cargo.toml new file mode 100644 index 0000000..8dd6717 --- /dev/null +++ b/crates/buiy_core/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "buiy_core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +bevy.workspace = true +taffy.workspace = true +tracing.workspace = true diff --git a/crates/buiy_core/src/a11y.rs b/crates/buiy_core/src/a11y.rs new file mode 100644 index 0000000..1256071 --- /dev/null +++ b/crates/buiy_core/src/a11y.rs @@ -0,0 +1,101 @@ +//! AccessKit integration. Phase 0 builds an in-memory snapshot and exposes +//! an `A11yTreeBuilder` that can be serialized for snapshot tests; the real +//! `accesskit_winit::Adapter` wiring per-window happens once Bevy windows +//! are introduced (Task 13, BuiyPlugin). +//! +//! See: docs/specs/2026-05-07-buiy-foundation/architecture.md § 2.6 and +//! accessibility.md § 3.11 (decomposed components per #17644). + +use crate::{BuiySet, focus::Focusable}; +use bevy::prelude::*; + +/// Decomposed AccessKit role component. +#[derive(Component, Reflect, Clone, Copy, Debug, PartialEq, Eq, Default)] +#[reflect(Component)] +pub enum A11yRole { + #[default] + Generic, + Button, + Link, + Image, + Text, + Heading, + Dialog, + AlertDialog, + Tooltip, + // Phase 0 stops here; full taxonomy is in the foundation spec accessibility.md. +} + +/// Decomposed accessible name. ACCNAME 1.2 computation is in `buiy-accessibility-design`; +/// Phase 0 is the literal-string fast path. +#[derive(Component, Reflect, Clone, Debug, Default)] +#[reflect(Component)] +pub struct A11yLabel(pub String); + +/// Decomposed accessible description. +#[derive(Component, Reflect, Clone, Debug, Default)] +#[reflect(Component)] +pub struct A11yDescription(pub String); + +/// One node in the tree as Buiy sees it. Will be translated into +/// `accesskit::Node` by the adapter in Task 13. Decoupled here so we can +/// snapshot it without needing a winit window. +#[derive(Clone, Debug, PartialEq)] +pub struct A11yNodeView { + pub entity: Entity, + pub role: A11yRole, + pub name: String, + pub description: String, + pub focusable: bool, +} + +/// Tree builder: rebuilt each frame from changed components in BuiySet::A11yUpdate. +#[derive(Resource, Default)] +pub struct A11yTreeBuilder { + nodes: Vec, +} + +impl A11yTreeBuilder { + pub fn snapshot(&self) -> &[A11yNodeView] { + &self.nodes + } +} + +pub struct A11yPlugin; + +impl Plugin for A11yPlugin { + fn build(&self, app: &mut App) { + app.register_type::() + .register_type::() + .register_type::() + .init_resource::() + .add_systems(Update, build_tree.in_set(BuiySet::A11yUpdate)); + } +} + +#[allow(clippy::type_complexity)] +fn build_tree( + mut builder: ResMut, + q: Query<( + Entity, + Option<&A11yRole>, + Option<&A11yLabel>, + Option<&A11yDescription>, + Option<&Focusable>, + )>, +) { + builder.nodes.clear(); + for (entity, role, label, desc, focusable) in q.iter() { + // Skip entities that have no a11y content at all. + if role.is_none() && label.is_none() && desc.is_none() && focusable.is_none() { + continue; + } + builder.nodes.push(A11yNodeView { + entity, + role: role.copied().unwrap_or_default(), + name: label.map(|l| l.0.clone()).unwrap_or_default(), + description: desc.map(|d| d.0.clone()).unwrap_or_default(), + focusable: focusable.is_some(), + }); + } +} diff --git a/crates/buiy_core/src/components.rs b/crates/buiy_core/src/components.rs new file mode 100644 index 0000000..642f4c6 --- /dev/null +++ b/crates/buiy_core/src/components.rs @@ -0,0 +1,58 @@ +//! Buiy's core component types. +//! +//! Every Buiy component is small, public-fielded, observable, and decomposed +//! by concern. Every component derives `Reflect + Default + Clone + Component` +//! (and Bevy 0.18's `Reflect` derive auto-generates `FromReflect`). See: +//! docs/specs/2026-05-07-buiy-foundation/architecture.md § 2.4. + +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. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum FlexDirection { + #[default] + Row, + Column, +} + +/// A Buiy node — the parallel-to-bevy_ui::Node primitive. Marker that this +/// entity participates in Buiy's layout / render / a11y trees. +#[derive(Component, Reflect, Default, Clone, Debug)] +#[reflect(Component)] +pub struct Node; + +/// Box-model + layout style. Not exhaustive in Phase 0 — only the surface +/// the layout system reads. +#[derive(Component, Reflect, Default, Clone, Debug)] +#[reflect(Component)] +pub struct Style { + /// Width in logical pixels. 0.0 = auto. + pub width: f32, + /// Height in logical pixels. 0.0 = auto. + pub height: f32, + /// Padding on all sides. + pub padding: f32, + /// Margin on all sides. + pub margin: f32, + /// Border radius (uniform; per-corner is a later sub-spec). + pub border_radius: f32, + /// Flex direction. Mapped to Taffy in `layout.rs`. + pub flex_direction: FlexDirection, + /// Token reference for background color (e.g., "color.surface.primary"). + pub background_token: String, + /// Token reference for foreground/text color. + pub foreground_token: String, +} + +/// Resolved layout output, written by the layout system in `BuiySet::Layout`. +/// Read by render and picking in subsequent sets. +#[derive(Component, Reflect, Default, Clone, Debug)] +#[reflect(Component)] +pub struct ResolvedLayout { + /// Top-left position in logical pixels (window-relative). + pub position: Vec2, + /// Size in logical pixels. + pub size: Vec2, +} diff --git a/crates/buiy_core/src/focus.rs b/crates/buiy_core/src/focus.rs new file mode 100644 index 0000000..7117254 --- /dev/null +++ b/crates/buiy_core/src/focus.rs @@ -0,0 +1,110 @@ +//! Focus model: focus tree, Tab handling, focus-visible heuristic, focus +//! restoration. Phase 0 implements ordered Tab traversal; full focus tree +//! (roving tabindex, aria-activedescendant, traps, restoration, spatial nav) +//! lives in `buiy-focus-model-design`. +//! +//! See: docs/specs/2026-05-07-buiy-foundation/architecture.md § 2.3 and +//! accessibility.md (Focus management). +//! +//! # Phase 0 deferred behavior +//! +//! - **Auto tab order is `entity.index()`-based, not full "document order".** +//! Bevy reuses entity indices after despawn; for two `Focusable`s with +//! `tab_order = 0`, the resolved order depends on entity-index allocation, +//! not insertion order. Insertion-order stability is owned by +//! `buiy-focus-model-design`. +//! - **`FocusVisible` is set to `true` on Tab and never reset to `false`.** +//! Pointer-driven focus paths (which clear `FocusVisible`) live in +//! `buiy-focus-model-design`; Phase 0 is keyboard-only so the always-true +//! state is correct for Phase 0 consumers. +//! - **Shift detection covers `ShiftLeft`/`ShiftRight` only.** Sticky-keys / +//! accessibility-shell remappings of Shift to other key codes are out of +//! scope; full key-binding abstraction lives in `buiy-input-events-design`. + +use crate::BuiySet; +use bevy::prelude::*; + +/// Marks an entity as part of the focus tree. +#[derive(Component, Reflect, Default, Clone, Debug)] +#[reflect(Component)] +pub struct Focusable { + /// Phase 0: 0 = Auto (in document order); negative = Skip; positive = explicit. + pub tab_order: i32, +} + +/// Currently focused entity (None = nothing focused). +#[derive(Resource, Reflect, Default, Clone, Debug)] +#[reflect(Resource)] +pub struct FocusedEntity(pub Option); + +/// Tracks whether the most recent focus change was keyboard / programmatic +/// (true) or pointer (false). Drives the `:focus-visible` heuristic — focus +/// rings render only when this is true. +#[derive(Resource, Reflect, Default, Clone, Debug)] +#[reflect(Resource)] +pub struct FocusVisible(pub bool); + +pub struct FocusPlugin; + +impl Plugin for FocusPlugin { + fn build(&self, app: &mut App) { + app.register_type::() + .register_type::() + .register_type::() + .init_resource::() + .init_resource::() + .add_systems(Update, handle_tab.in_set(BuiySet::Input)); + } +} + +fn handle_tab( + keys: Res>, + focusables: Query<(Entity, &Focusable)>, + mut focused: ResMut, + mut visible: ResMut, +) { + let pressed_tab = keys.just_pressed(KeyCode::Tab); + if !pressed_tab { + return; + } + let forward = !keys.pressed(KeyCode::ShiftLeft) && !keys.pressed(KeyCode::ShiftRight); + advance_focus(&focusables, &mut focused, forward); + visible.0 = true; +} + +fn advance_focus( + focusables: &Query<(Entity, &Focusable)>, + focused: &mut FocusedEntity, + forward: bool, +) { + let entries: Vec<(Entity, Focusable)> = + focusables.iter().map(|(e, f)| (e, f.clone())).collect(); + focused.0 = compute_next_focus(&entries, focused.0, forward); +} + +fn compute_next_focus( + focusables: &[(Entity, Focusable)], + current: Option, + forward: bool, +) -> Option { + let mut entries: Vec<(Entity, Focusable)> = focusables + .iter() + .filter(|(_, f)| f.tab_order >= 0) + .cloned() + .collect(); + if entries.is_empty() { + return None; + } + // Sort: explicit positive tab_order first (ascending), then Auto (0) in document order. + entries.sort_by_key(|(e, f)| (if f.tab_order > 0 { 0 } else { 1 }, f.tab_order, e.index())); + + let idx = current.and_then(|e| entries.iter().position(|(x, _)| *x == e)); + let n = entries.len(); + let next_idx = match (idx, forward) { + (None, true) => 0, + (None, false) => n - 1, + (Some(i), true) => (i + 1) % n, + (Some(i), false) => (i + n - 1) % n, + }; + Some(entries[next_idx].0) +} diff --git a/crates/buiy_core/src/layout.rs b/crates/buiy_core/src/layout.rs new file mode 100644 index 0000000..8e79aa1 --- /dev/null +++ b/crates/buiy_core/src/layout.rs @@ -0,0 +1,166 @@ +//! Layout via Taffy. +//! +//! See: docs/specs/2026-05-07-buiy-foundation/visuals.md § 3.2 and +//! architecture.md § 2.3. Phase 0 supports flex row/column with fixed +//! width/height; the full layout surface lives in `buiy-layout-design`. +//! +//! ## Why `NonSend`? +//! +//! Taffy 0.10 packs every `Dimension` into a tagged pointer +//! (`*const ()`), which makes `TaffyTree` `!Send + !Sync` regardless of +//! whether the `calc` feature is in use. Layout is inherently a +//! single-threaded pass over the tree, so storing it as a non-send +//! resource is both correct and free of `unsafe`. + +use crate::{ + BuiySet, + components::{FlexDirection as BuiyFlexDirection, Node, ResolvedLayout, Style}, +}; +use bevy::prelude::*; +use std::collections::HashMap; +use taffy::{ + AvailableSpace, Dimension, FlexDirection, NodeId as TaffyNodeId, Size, Style as TaffyStyle, + TaffyTree, +}; + +/// 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, +} + +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)); + } +} + +fn style_to_taffy(style: &Style) -> TaffyStyle { + TaffyStyle { + size: Size { + width: if style.width > 0.0 { + Dimension::length(style.width) + } else { + Dimension::auto() + }, + height: if style.height > 0.0 { + Dimension::length(style.height) + } else { + Dimension::auto() + }, + }, + flex_direction: match style.flex_direction { + BuiyFlexDirection::Row => FlexDirection::Row, + BuiyFlexDirection::Column => FlexDirection::Column, + }, + ..Default::default() + } +} + +/// One pass: ensure every Buiy entity has a Taffy node, sync style, compute +/// layout starting from roots (entities with `Node` and no Buiy parent), +/// write `ResolvedLayout` back. +#[allow(clippy::type_complexity)] +fn sync_and_compute_layout( + mut commands: Commands, + mut tree: NonSendMut, + nodes: Query<(Entity, &Style, Option<&ChildOf>, Option<&Children>), With>, + windows: Query<&bevy::window::Window>, +) { + let tree = &mut *tree; + + // Ensure every Buiy entity has a Taffy node + current style. + for (entity, style, _parent, _children) in nodes.iter() { + let taffy_style = style_to_taffy(style); + match tree.by_entity.get(&entity).copied() { + Some(id) => { + if let Err(err) = tree.tree.set_style(id, taffy_style) { + warn!(?entity, ?err, "buiy: layout set_style failed"); + } + } + None => match tree.tree.new_leaf(taffy_style) { + Ok(id) => { + tree.by_entity.insert(entity, id); + } + Err(err) => { + warn!( + ?entity, + ?err, + "buiy: layout new_leaf failed; entity will be skipped this frame" + ); + } + }, + } + } + + // Sync child relationships for each Buiy entity. + for (entity, _style, _parent, children) in nodes.iter() { + let parent_id = match tree.by_entity.get(&entity).copied() { + Some(id) => id, + None => continue, + }; + let child_ids: Vec = children + .into_iter() + .flatten() + .filter_map(|c| tree.by_entity.get(c).copied()) + .collect(); + if let Err(err) = tree.tree.set_children(parent_id, &child_ids) { + warn!(?entity, ?err, "buiy: layout set_children failed"); + } + } + + // Compute layout for roots (entities with Node and no Buiy parent). + let window_size = windows + .iter() + .next() + .map(|w| Vec2::new(w.width(), w.height())) + .unwrap_or(Vec2::new(800.0, 600.0)); + + for (entity, _style, parent, _children) in nodes.iter() { + let is_root = parent + .map(|p| !tree.by_entity.contains_key(&p.parent())) + .unwrap_or(true); + if !is_root { + continue; + } + if let Some(id) = tree.by_entity.get(&entity).copied() + && let Err(err) = tree.tree.compute_layout( + id, + Size { + width: AvailableSpace::Definite(window_size.x), + height: AvailableSpace::Definite(window_size.y), + }, + ) + { + warn!(?entity, ?err, "buiy: layout compute_layout failed"); + } + } + + // Walk the tree and write ResolvedLayout for every entity. + let mut to_write: Vec<(Entity, ResolvedLayout)> = Vec::new(); + for (&entity, &id) in tree.by_entity.iter() { + if let Ok(layout) = tree.tree.layout(id) { + to_write.push(( + entity, + ResolvedLayout { + position: Vec2::new(layout.location.x, layout.location.y), + size: Vec2::new(layout.size.width, layout.size.height), + }, + )); + } + } + for (e, rl) in to_write { + commands.entity(e).insert(rl); + } +} diff --git a/crates/buiy_core/src/lib.rs b/crates/buiy_core/src/lib.rs new file mode 100644 index 0000000..24c4f33 --- /dev/null +++ b/crates/buiy_core/src/lib.rs @@ -0,0 +1,60 @@ +//! Buiy core: components, plugin scaffolding, system sets. +//! +//! See: docs/specs/2026-05-07-buiy-foundation/architecture.md § 2.8 for +//! sub-plugin order and SystemSet definitions. + +use bevy::prelude::*; + +pub mod a11y; +pub mod components; +pub mod focus; +pub mod layout; +pub mod picking; +pub mod render; +pub mod theme; + +pub use a11y::{A11yDescription, A11yLabel, A11yNodeView, A11yPlugin, A11yRole, A11yTreeBuilder}; +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}; + +/// Top-level system sets for Buiy. Order: Layout → Style → Input → Animate +/// → Picking → A11yUpdate → Render. +#[derive(SystemSet, Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub enum BuiySet { + Layout, + Style, + Input, + Animate, + Picking, + A11yUpdate, + Render, +} + +/// Core Buiy plugin: registers types, configures system sets. +/// Composed into `BuiyPlugin` from the meta-crate; not consumed directly +/// by end users. +pub struct CorePlugin; + +impl Plugin for CorePlugin { + fn build(&self, app: &mut App) { + app.register_type::() + .register_type::