diff --git a/CHANGELOG.md b/CHANGELOG.md index eddfb6d..257c190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,3 +23,32 @@ tagged release. deferral. - `bevy_picking` backend. `Hovered` becomes a thin layer over the standard `PointerHits` event flow. Closes the Phase 0 picking deferral. +- `buiy_core::components::Visual` component (`background_token`, + `foreground_token`, `border_radius`) carrying the render-side surface + formerly mixed into the Phase 0 mega-`Style`. Authors who want themed + widgets insert `Visual` alongside the new layout `Style` builder. + Eventual home is `buiy-render-pipeline-design`. +- `buiy_core::layout` module: 8-step layout pipeline (`BuiyLayoutStep` + system sets), decomposed `BoxModel` / `Display` / `Position` / + `FlexParams` / `FlexItem` components, hybrid `Style` builder that + expands to a `Bundle` on spawn. + +### Changed +- Layout subsystem foundation rewritten. Phase 0's flat `layout.rs` is + replaced by a `layout/` directory module. The pipeline is an 8-step + ordered chain (`BuiyLayoutStep` system sets) inside `BuiySet::Layout`; + Phase 1 implements steps 0/1/3/7 and stubs the remaining four for + later phase plans. +- `Style` is now a `Bundle` that decomposes on insert, not a reflectable + `Component`. Reflection / inspectors / BSN see the decomposed + components (`BoxModel`, `Display`, `Position`, `FlexParams`). +- The render extract now queries `(&Visual, &ResolvedLayout)` instead + of `(&Style, &ResolvedLayout)`; entities without `Visual` are skipped + by render. `Button::new` inserts a `Visual` carrying the same theme + tokens Phase 0's `Style` did, so visual appearance is preserved. + +### Removed +- `buiy_core::components::Style` (the Phase 0 mega-component) and + `buiy_core::components::FlexDirection`. Their roles are taken by + `buiy_core::layout::Style` (the hybrid builder) and + `buiy_core::layout::FlexAxis` (the four-variant axis enum). diff --git a/crates/buiy/src/lib.rs b/crates/buiy/src/lib.rs index fe44632..ae0dec0 100644 --- a/crates/buiy/src/lib.rs +++ b/crates/buiy/src/lib.rs @@ -7,8 +7,13 @@ use bevy::prelude::*; pub use buiy_core::{ BuiySet, CorePlugin, a11y::{A11yDescription, A11yLabel, A11yRole, A11yTreeBuilder, AccessKitAdapterPlugin}, - components::{FlexDirection, Node, ResolvedLayout, Style}, + components::{Node, ResolvedLayout, Visual}, focus::{FocusVisible, Focusable, FocusedEntity}, + layout::{ + AlignContent, AlignItems, AspectRatio, BoxModel, BoxSizing, BuiyLayoutStep, Display, Edges, + FlexAxis, FlexGap, FlexItem, FlexParams, FlexWrap, Inset, JustifyContent, LayoutPlugin, + Length, Position, PositionKind, Sizing, Style, + }, picking::{BuiyPickingBackendPlugin, Hovered}, theme::{Theme, UserPreferences, default_light_theme}, }; diff --git a/crates/buiy_core/src/components.rs b/crates/buiy_core/src/components.rs index 08b713a..781a9b6 100644 --- a/crates/buiy_core/src/components.rs +++ b/crates/buiy_core/src/components.rs @@ -1,57 +1,24 @@ //! 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. +//! Every Buiy component is small, public-fielded, observable, and +//! decomposed by concern. Layout components live in +//! `crate::layout::components`; this file holds the cross-cutting +//! `Node` marker, the shared `ResolvedLayout` output, and the temporary +//! `Visual` component (token-based rendering surface — eventual owner +//! is `buiy-render-pipeline-design`). +//! +//! 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. -/// -/// 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, - Column, -} - -/// A Buiy node — the parallel-to-bevy_ui::Node primitive. Marker that this -/// entity participates in Buiy's layout / render / a11y trees. +/// 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. +/// Resolved layout output, written by `BuiyLayoutStep::WriteResolvedLayout`. +/// Read by render, picking, and any other downstream subsystem. #[derive(Component, Reflect, Default, Clone, Debug)] #[reflect(Component)] pub struct ResolvedLayout { @@ -60,3 +27,27 @@ pub struct ResolvedLayout { /// Size in logical pixels. pub size: Vec2, } + +/// Visual surface: theme-token references and corner radius for the +/// Phase 0/1 render pipeline. Optional — entities without `Visual` are +/// skipped by the render extract. +/// +/// **Temporary home.** This is a Phase 0 carry-over, kept alive in +/// Phase 1 only because the render extract still consumes +/// `background_token` / `border_radius`. The eventual owner of these +/// concerns is `buiy-render-pipeline-design` (unwritten as of Phase 1); +/// when that spec lands, `Visual` is replaced by richer +/// `Background` / `Border` / `Stroke` / etc. components. +#[derive(Component, Reflect, Default, Clone, Debug)] +#[reflect(Component)] +pub struct Visual { + /// Theme token for the fill (e.g. `"color.surface.secondary"`). + /// Empty string → render skips the fill (transparent). + pub background_token: String, + /// Theme token for foreground / text color (e.g. `"color.text.primary"`). + /// Reserved for the text-rendering integration; Phase 1 render does + /// not consume it. + pub foreground_token: String, + /// Uniform corner radius in logical pixels. + pub border_radius: f32, +} diff --git a/crates/buiy_core/src/layout.rs b/crates/buiy_core/src/layout.rs deleted file mode 100644 index f314930..0000000 --- a/crates/buiy_core/src/layout.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! 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 ()`. -#[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, - // 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"); - } - } -} - -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/layout/components.rs b/crates/buiy_core/src/layout/components.rs new file mode 100644 index 0000000..dece6d3 --- /dev/null +++ b/crates/buiy_core/src/layout/components.rs @@ -0,0 +1,188 @@ +//! Decomposed layout components. +//! +//! Spec: docs/specs/2026-05-08-buiy-layout-design/architecture.md § 2.1. +//! +//! Each component is small, public-fielded, and derives +//! `Reflect + Default + Clone + Component`. Phase 1 covers the surface +//! Phase 0's mega-`Style` reaches: `BoxModel`, `Display`, `Position`, +//! `FlexParams`, `FlexItem`. Other components (`Anchor`, `GridParams`, +//! `Container`, `WritingMode`, `Overflow`, `Scroll`, `Stacking`, +//! `Transform`, `Containment`, `MultiColumn`, `GridItem`) land in their +//! respective phase plans (see foundation plan §"Phasing strategy"). + +use super::types::{ + AlignContent, AlignItems, AspectRatio, BoxSizing, Edges, FlexAxis, FlexGap, FlexWrap, Inset, + JustifyContent, PositionKind, Sizing, +}; +use bevy::prelude::*; + +/// Box-model dimensions: width / height (incl. min/max), padding, margin, +/// border, box-sizing, aspect-ratio. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/box-model.md § 2. +/// +/// Phase 1 omits the spec's `gap` / `row_gap` / `column_gap` fields — +/// they are not yet wired to Taffy and `FlexParams.gap` carries the +/// flex-gap surface in this phase. A follow-up phase that wires +/// block-layout gap to Taffy adds them back. +#[derive(Component, Reflect, Default, Clone, Debug, PartialEq)] +#[reflect(Component, Default)] +pub struct BoxModel { + pub width: Sizing, + pub height: Sizing, + pub min_width: Sizing, + pub min_height: Sizing, + pub max_width: Sizing, + pub max_height: Sizing, + pub padding: Edges, + pub margin: Edges, + pub border: Edges, + pub box_sizing: BoxSizing, + pub aspect_ratio: Option, +} + +/// `display` value. Phase 1 implements `Block` and `Flex(FlexAxis)`; other +/// variants are reserved and translate to `Block` (Taffy default). +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md § 1. +#[derive(Component, Reflect, Default, Clone, Copy, Debug, PartialEq)] +#[reflect(Component)] +pub enum Display { + #[default] + Block, + Inline, + InlineBlock, + Flex(FlexAxis), + InlineFlex(FlexAxis), + Grid, + InlineGrid, + FlowRoot, + Contents, + Table, + TableRowGroup, + TableHeaderGroup, + TableFooterGroup, + TableRow, + TableCell, + TableCaption, + TableColumnGroup, + TableColumn, + ListItem, + Ruby, + None, +} + +impl Display { + pub const fn flex_row() -> Self { + Self::Flex(FlexAxis::Row) + } + + pub const fn flex_column() -> Self { + Self::Flex(FlexAxis::Column) + } +} + +/// `position` + `inset`. Phase 1 implements `Static`, `Relative`, +/// `Absolute`. `Fixed` and `Sticky` ship as variants but currently +/// translate to `Absolute` / `Relative`; Phases 7/8 wire the real semantics. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/display-and-positioning.md § 2. +#[derive(Component, Reflect, Default, Clone, Debug, PartialEq)] +#[reflect(Component, Default)] +pub struct Position { + pub kind: PositionKind, + pub inset: Inset, +} + +/// Flex container parameters. Active when the entity's `Display` is +/// `Display::Flex(_)` or `Display::InlineFlex(_)`; otherwise ignored. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/flex-and-grid.md § 1.1. +#[derive(Component, Reflect, Default, Clone, Copy, Debug, PartialEq)] +#[reflect(Component, Default)] +pub struct FlexParams { + pub direction: FlexAxis, + pub wrap: FlexWrap, + pub justify_content: JustifyContent, + pub align_items: AlignItems, + pub align_content: AlignContent, + pub gap: FlexGap, +} + +/// Per-child flex parameters. +/// +/// Spec: docs/specs/2026-05-08-buiy-layout-design/flex-and-grid.md § 1.2. +#[derive(Component, Reflect, Clone, Copy, Debug, PartialEq)] +#[reflect(Component)] +pub struct FlexItem { + pub grow: f32, + pub shrink: f32, + pub basis: Sizing, + pub order: i32, + pub align_self: Option, +} + +impl Default for FlexItem { + fn default() -> Self { + Self { + grow: 0.0, + shrink: 1.0, + basis: Sizing::Auto, + order: 0, + align_self: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn box_model_default_is_auto_zero_padding() { + let bm = BoxModel::default(); + assert_eq!(bm.width, Sizing::Auto); + assert_eq!(bm.height, Sizing::Auto); + assert_eq!(bm.padding, Edges::ZERO); + assert_eq!(bm.margin, Edges::ZERO); + assert_eq!(bm.border, Edges::ZERO); + assert_eq!(bm.box_sizing, BoxSizing::ContentBox); + assert_eq!(bm.aspect_ratio, None); + } + + #[test] + fn display_default_is_block() { + assert_eq!(Display::default(), Display::Block); + } + + #[test] + fn position_default_is_static_with_auto_inset() { + let pos = Position::default(); + assert_eq!(pos.kind, PositionKind::Static); + assert_eq!(pos.inset, Inset::default()); + } + + #[test] + fn flex_params_and_item_defaults_match_spec() { + let fp = FlexParams::default(); + assert_eq!(fp.direction, FlexAxis::Row); + assert_eq!(fp.wrap, FlexWrap::NoWrap); + assert_eq!(fp.justify_content, JustifyContent::FlexStart); + assert_eq!(fp.align_items, AlignItems::Stretch); + assert_eq!(fp.align_content, AlignContent::Stretch); + assert_eq!(fp.gap, FlexGap::default()); + + let fi = FlexItem::default(); + assert_eq!(fi.grow, 0.0); + assert_eq!(fi.shrink, 1.0); + assert_eq!(fi.basis, Sizing::Auto); + assert_eq!(fi.order, 0); + assert_eq!(fi.align_self, None); + } + + #[test] + fn display_helpers_produce_flex_axis() { + assert_eq!(Display::flex_row(), Display::Flex(FlexAxis::Row)); + assert_eq!(Display::flex_column(), Display::Flex(FlexAxis::Column)); + } +} diff --git a/crates/buiy_core/src/layout/mod.rs b/crates/buiy_core/src/layout/mod.rs new file mode 100644 index 0000000..0695a31 --- /dev/null +++ b/crates/buiy_core/src/layout/mod.rs @@ -0,0 +1,54 @@ +//! Buiy layout subsystem. +//! +//! Spec: docs/specs/2026-05-08-buiy-layout-design/. + +mod components; +mod pipeline; +mod style; +mod systems; +pub(crate) mod translate; +mod tree; +mod types; + +pub use components::{BoxModel, Display, FlexItem, FlexParams, Position}; +pub use pipeline::BuiyLayoutStep; +pub use style::Style; +pub use tree::LayoutTree; +pub use types::{ + AlignContent, AlignItems, AspectRatio, BoxSizing, Edges, FlexAxis, FlexGap, FlexWrap, Inset, + JustifyContent, Length, PositionKind, Sizing, +}; + +use bevy::prelude::*; + +pub struct LayoutPlugin; + +impl Plugin for LayoutPlugin { + fn build(&self, app: &mut App) { + app.init_non_send_resource::(); + + // Register decomposed components for reflection / BSN / inspectors. + app.register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::(); + + pipeline::configure_pipeline(app); + + app.add_systems( + Update, + ( + systems::gc_removed_nodes.in_set(BuiyLayoutStep::RemovedNodesGc), + systems::sync_styles.in_set(BuiyLayoutStep::SyncStyles), + systems::taffy_compute.in_set(BuiyLayoutStep::TaffyCompute), + systems::write_resolved_layout.in_set(BuiyLayoutStep::WriteResolvedLayout), + ), + ); + } +} diff --git a/crates/buiy_core/src/layout/pipeline.rs b/crates/buiy_core/src/layout/pipeline.rs new file mode 100644 index 0000000..276476b --- /dev/null +++ b/crates/buiy_core/src/layout/pipeline.rs @@ -0,0 +1,56 @@ +//! Layout pipeline ordering. +//! +//! Spec: docs/specs/2026-05-08-buiy-layout-design/architecture.md § 3. +//! +//! Eight ordered sub-sets of `BuiySet::Layout`. Phase 1 wires all eight; +//! steps 2 (`CqActivate`), 4 (`CqFlipCheck`), 5 (`CqFlipReRun`), and 6 +//! (`PostTaffyOverrides`) are no-ops in Phase 1. Later phases attach +//! systems to those sub-sets without reordering. + +use bevy::prelude::*; + +/// Phase 1 ships every step as a system set; later phases populate the +/// stub steps. The order is asserted by `tests/layout_pipeline_order.rs`. +#[derive(SystemSet, Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub enum BuiyLayoutStep { + /// Step 0 — drop despawned entities from `LayoutTree`. + RemovedNodesGc, + /// Step 1 — translate changed Buiy components → `taffy::Style` and + /// sync hierarchy. + SyncStyles, + /// Step 2 — set/clear container-query marker components. + /// **Phase 5.** + CqActivate, + /// Step 3 — call `tree.compute_layout` from each root. + TaffyCompute, + /// Step 4 — re-evaluate queries against fresh sizes. + /// **Phase 5.** + CqFlipCheck, + /// Step 5 — conditional re-run of steps 1+3. + /// **Phase 5.** Phase 1 leaves this as an empty set. + CqFlipReRun, + /// Step 6 — sub-passes (sticky, table, multicol, anchor). + /// **Phases 6/7.** + PostTaffyOverrides, + /// Step 7 — push positions+sizes to Bevy components. + WriteResolvedLayout, +} + +/// Configure the 8-step chain inside `BuiySet::Layout`. +pub fn configure_pipeline(app: &mut App) { + app.configure_sets( + Update, + ( + BuiyLayoutStep::RemovedNodesGc, + BuiyLayoutStep::SyncStyles, + BuiyLayoutStep::CqActivate, + BuiyLayoutStep::TaffyCompute, + BuiyLayoutStep::CqFlipCheck, + BuiyLayoutStep::CqFlipReRun, + BuiyLayoutStep::PostTaffyOverrides, + BuiyLayoutStep::WriteResolvedLayout, + ) + .chain() + .in_set(crate::BuiySet::Layout), + ); +} diff --git a/crates/buiy_core/src/layout/style.rs b/crates/buiy_core/src/layout/style.rs new file mode 100644 index 0000000..85aa16a --- /dev/null +++ b/crates/buiy_core/src/layout/style.rs @@ -0,0 +1,306 @@ +//! `Style` — the hybrid builder over decomposed layout components. +//! +//! Spec: docs/specs/2026-05-08-buiy-layout-design/architecture.md § 2.2-2.4. +//! +//! Two equally valid authoring forms write the same fields; on insert, +//! Bundle expansion produces the four decomposed components Phase 1 +//! ships (`Display`, `BoxModel`, `Position`, `FlexParams`). Defaulted +//! fields produce defaulted components — the Phase 1 simplification is +//! that components are always inserted, not skipped on default. +//! Phase 4's `LogicalBoxModel` revisit will switch to skip-on-default. +//! +//! `FlexItem` is decomposed-only (per spec § 2.4); it is not included in +//! `Style`. + +use super::components::{BoxModel, Display, FlexParams, Position}; +use super::types::{ + AlignContent, AlignItems, AspectRatio, BoxSizing, Edges, FlexAxis, FlexGap, FlexWrap, Inset, + JustifyContent, Length, PositionKind, Sizing, +}; +use bevy::ecs::bundle::Bundle; + +/// Hybrid builder over an entity's self-styling layout components. +/// +/// Two authoring forms over the same fields: +/// +/// ```ignore +/// // Struct-literal form. +/// let s = Style { display: Display::flex_row(), ..Default::default() }; +/// +/// // Fluent form. +/// let s = Style::default().flex_row(); +/// ``` +/// +/// On `commands.spawn(s)` (or `entity.insert(s)`), expands into a Bundle +/// of `Display`, `BoxModel`, `Position`, `FlexParams`. Decomposed +/// components are canonical; the builder is sugar. +#[derive(Bundle, Clone, Debug, Default)] +pub struct Style { + pub display: Display, + pub box_model: BoxModel, + pub position: Position, + pub flex_params: FlexParams, +} + +impl Style { + // ---- Display ---- + + pub fn block(mut self) -> Self { + self.display = Display::Block; + self + } + + pub fn flex_row(mut self) -> Self { + self.display = Display::flex_row(); + self.flex_params.direction = FlexAxis::Row; + self + } + + pub fn flex_column(mut self) -> Self { + self.display = Display::flex_column(); + self.flex_params.direction = FlexAxis::Column; + self + } + + pub fn flex_axis(mut self, axis: FlexAxis) -> Self { + self.display = Display::Flex(axis); + self.flex_params.direction = axis; + self + } + + pub fn display(mut self, d: Display) -> Self { + self.display = d; + if let Display::Flex(axis) | Display::InlineFlex(axis) = d { + self.flex_params.direction = axis; + } + self + } + + // ---- BoxModel: dimensions ---- + + pub fn width(mut self, w: Sizing) -> Self { + self.box_model.width = w; + self + } + + pub fn height(mut self, h: Sizing) -> Self { + self.box_model.height = h; + self + } + + pub fn width_px(self, px: f32) -> Self { + self.width(Sizing::Length(Length::Px(px))) + } + + pub fn height_px(self, px: f32) -> Self { + self.height(Sizing::Length(Length::Px(px))) + } + + pub fn min_width(mut self, w: Sizing) -> Self { + self.box_model.min_width = w; + self + } + + pub fn min_height(mut self, h: Sizing) -> Self { + self.box_model.min_height = h; + self + } + + pub fn max_width(mut self, w: Sizing) -> Self { + self.box_model.max_width = w; + self + } + + pub fn max_height(mut self, h: Sizing) -> Self { + self.box_model.max_height = h; + self + } + + pub fn aspect_ratio(mut self, ratio: AspectRatio) -> Self { + self.box_model.aspect_ratio = Some(ratio); + self + } + + // ---- BoxModel: edges ---- + + pub fn padding(mut self, px: f32) -> Self { + self.box_model.padding = Edges::all(px); + self + } + + pub fn padding_edges(mut self, e: Edges) -> Self { + self.box_model.padding = e; + self + } + + pub fn margin(mut self, px: f32) -> Self { + self.box_model.margin = Edges::all(px); + self + } + + pub fn margin_edges(mut self, e: Edges) -> Self { + self.box_model.margin = e; + self + } + + pub fn border(mut self, px: f32) -> Self { + self.box_model.border = Edges::all(px); + self + } + + pub fn border_edges(mut self, e: Edges) -> Self { + self.box_model.border = e; + self + } + + // ---- BoxModel: box-sizing ---- + + pub fn content_box(mut self) -> Self { + self.box_model.box_sizing = BoxSizing::ContentBox; + self + } + + pub fn border_box(mut self) -> Self { + self.box_model.box_sizing = BoxSizing::BorderBox; + self + } + + pub fn box_sizing(mut self, b: BoxSizing) -> Self { + self.box_model.box_sizing = b; + self + } + + // ---- Gap (Phase 1 surfaces gap exclusively via FlexParams.gap; + // BoxModel.gap is deferred — see Task 2 doc comment) ---- + + pub fn gap_px(mut self, px: f32) -> Self { + self.flex_params.gap = FlexGap { + row: Length::Px(px), + column: Length::Px(px), + }; + self + } + + // ---- Position ---- + + pub fn position(mut self, kind: PositionKind) -> Self { + self.position.kind = kind; + self + } + + pub fn relative(mut self) -> Self { + self.position.kind = PositionKind::Relative; + self + } + + pub fn absolute(mut self) -> Self { + self.position.kind = PositionKind::Absolute; + self + } + + pub fn inset(mut self, i: Inset) -> Self { + self.position.inset = i; + self + } + + // ---- FlexParams ---- + + pub fn flex_wrap(mut self, w: FlexWrap) -> Self { + self.flex_params.wrap = w; + self + } + + pub fn justify_content(mut self, j: JustifyContent) -> Self { + self.flex_params.justify_content = j; + self + } + + pub fn align_items(mut self, a: AlignItems) -> Self { + self.flex_params.align_items = a; + self + } + + pub fn align_content(mut self, a: AlignContent) -> Self { + self.flex_params.align_content = a; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::components::{BoxModel, Display, FlexParams, Position}; + use crate::layout::types::{ + AlignItems, BoxSizing, Edges, FlexAxis, FlexGap, JustifyContent, Length, Sizing, + }; + use bevy::app::App; + use bevy::prelude::MinimalPlugins; + + fn spawn_and_extract(style: Style) -> (Display, BoxModel, Position, FlexParams) { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + let entity = app.world_mut().spawn(style).id(); + let world = app.world(); + let display = *world.get::(entity).expect("Display inserted"); + let box_model = world + .get::(entity) + .expect("BoxModel inserted") + .clone(); + let position = world + .get::(entity) + .expect("Position inserted") + .clone(); + let flex_params = *world + .get::(entity) + .expect("FlexParams inserted"); + (display, box_model, position, flex_params) + } + + #[test] + fn struct_literal_and_fluent_produce_identical_components() { + let literal = Style { + display: Display::Flex(FlexAxis::Column), + box_model: BoxModel { + padding: Edges::all(16.0), + box_sizing: BoxSizing::BorderBox, + width: Sizing::Length(Length::Px(200.0)), + height: Sizing::Length(Length::Px(100.0)), + ..Default::default() + }, + flex_params: FlexParams { + direction: FlexAxis::Column, + gap: FlexGap { + row: Length::Px(8.0), + column: Length::Px(8.0), + }, + justify_content: JustifyContent::SpaceBetween, + align_items: AlignItems::Center, + ..Default::default() + }, + ..Default::default() + }; + let fluent = Style::default() + .flex_column() + .padding(16.0) + .border_box() + .width_px(200.0) + .height_px(100.0) + .gap_px(8.0) + .justify_content(JustifyContent::SpaceBetween) + .align_items(AlignItems::Center); + + assert_eq!(spawn_and_extract(literal), spawn_and_extract(fluent)); + } + + #[test] + fn default_style_inserts_every_decomposed_component() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + let entity = app.world_mut().spawn(Style::default()).id(); + let world = app.world(); + assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_some()); + } +} diff --git a/crates/buiy_core/src/layout/systems.rs b/crates/buiy_core/src/layout/systems.rs new file mode 100644 index 0000000..b97f4a9 --- /dev/null +++ b/crates/buiy_core/src/layout/systems.rs @@ -0,0 +1,200 @@ +//! Per-step systems for the layout pipeline. +//! +//! Spec: docs/specs/2026-05-08-buiy-layout-design/architecture.md § 3-4. +//! +//! Phase 1 implements: +//! - Step 0 `gc_removed_nodes` — `LayoutTree` GC from `RemovedComponents`. +//! - Step 1 `sync_styles` — translate changed components and sync hierarchy. +//! - Step 3 `taffy_compute` — `tree.compute_layout` from each root. +//! - Step 7 `write_resolved_layout` — write `ResolvedLayout` back to entities. +//! +//! Steps 2/4/5/6 are empty sub-sets in Phase 1; later phases attach +//! systems to them. + +use super::components::{BoxModel, Display, FlexItem, FlexParams, Position}; +use super::translate::{StyleView, style_to_taffy}; +use super::tree::LayoutTree; +use crate::components::{Node, ResolvedLayout}; +use bevy::prelude::*; +use taffy::{AvailableSpace, NodeId as TaffyNodeId, Size}; + +/// Step 0 — drop Taffy nodes for entities whose `Node` component was +/// removed (despawn or component-remove). `RemovedComponents` +/// ordering across a parent/child despawn pair is not guaranteed by +/// Bevy, so the GC tolerates either order: parent-first leaves children +/// orphaned in Taffy (cleaned up by entity), child-first leaves the +/// parent's `set_children` reference dangling (Taffy's `remove(parent)` +/// cleans that up). +/// +/// Phase 1 keeps Phase 0's blanket-warn behavior. The spec's +/// architecture.md § 4.3 calls for silently swallowing `NotFound`; the +/// Taffy 0.10 error variant for that case is uncertain enough that the +/// pinning is deferred to a follow-up task that audits Taffy's error +/// enum and refines the match. +pub(super) 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"); + } + } +} + +/// Step 1 — for every entity with `Node`, translate its decomposed +/// components into a `taffy::Style` and ensure the entity has a Taffy +/// node + correct child list. The query carries an `Or<(Changed<...>)>` +/// filter so steady-state frames (no layout component or hierarchy +/// changes anywhere in the world) iterate **zero** entities, matching +/// spec architecture.md § 9's O(0) steady-state contract. +/// +/// `Changed` triggers on insertion as well as modification, so newly +/// spawned entities are picked up on their first frame. +/// +/// Phase 1 trigger set: `Changed`, `Changed`, +/// `Changed`, `Changed`, `Changed`, +/// `Changed`, `Changed`. Phase 4–9 widen it as new +/// components land. +#[allow(clippy::type_complexity)] +pub(super) fn sync_styles( + mut tree: NonSendMut, + nodes: Query< + ( + Entity, + &Display, + &BoxModel, + &Position, + &FlexParams, + Option<&FlexItem>, + Option<&Children>, + ), + ( + With, + Or<( + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + )>, + ), + >, +) { + let tree = &mut *tree; + + // Ensure every Buiy entity has a Taffy node + current style. Insert + // happens for entities new this frame (Changed triggers on insert); + // existing entities run set_style only when something in the trigger + // set actually changed — see foundation/architecture.md § 1.2. + for (entity, display, bm, position, flex, flex_item, _children) in nodes.iter() { + let view = StyleView { + display, + box_model: bm, + position, + flex_params: flex, + flex_item, + }; + let taffy_style = style_to_taffy(view); + 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, _display, _bm, _position, _flex, _flex_item, 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"); + } + } +} + +/// Step 3 — call `tree.compute_layout` from each root. A root is an +/// entity with `Node` and either no `ChildOf`, or a `ChildOf` whose +/// target is not in `LayoutTree` (i.e., a non-Buiy parent). +pub(super) fn taffy_compute( + mut tree: NonSendMut, + nodes: Query<(Entity, Option<&ChildOf>), With>, + windows: Query<&bevy::window::Window>, +) { + let tree = &mut *tree; + + // Layout root sizing falls back to 800x600 if no Window exists (test + // harnesses with MinimalPlugins). Phase 0 used the same default. + let window_size = windows + .iter() + .next() + .map(|w| Vec2::new(w.width(), w.height())) + .unwrap_or(Vec2::new(800.0, 600.0)); + + for (entity, parent) 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"); + } + } +} + +/// Step 7 — read `tree.layout(id)` for every tracked entity and write +/// the resulting position+size into `ResolvedLayout`. On Taffy `Err`, +/// retain the previous frame's value. +pub(super) fn write_resolved_layout(mut commands: Commands, tree: NonSend) { + 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/layout/translate.rs b/crates/buiy_core/src/layout/translate.rs new file mode 100644 index 0000000..2f315ee --- /dev/null +++ b/crates/buiy_core/src/layout/translate.rs @@ -0,0 +1,368 @@ +//! Translation layer: decomposed Buiy layout components → `taffy::Style`. +//! +//! Spec: docs/specs/2026-05-08-buiy-layout-design/architecture.md § 1.2. +//! +//! Pure function. Read by `sync_styles` (pipeline step 1). Phase 1 only +//! resolves `Length::Px` and `Length::Percent` — every other variant +//! lands in Phase 10 (`buiy-layout-units-calc`). + +use super::components::{BoxModel, Display, FlexItem, FlexParams, Position}; +use super::types::{ + AlignContent, AlignItems, BoxSizing, Edges, FlexAxis, FlexWrap, Inset, JustifyContent, Length, + PositionKind, Sizing, +}; + +/// View into the Phase 1 decomposed-component set for one entity. Built +/// by `sync_styles`'s query and passed to `style_to_taffy`. +pub struct StyleView<'a> { + pub display: &'a Display, + pub box_model: &'a BoxModel, + pub position: &'a Position, + pub flex_params: &'a FlexParams, + pub flex_item: Option<&'a FlexItem>, +} + +pub fn style_to_taffy(view: StyleView<'_>) -> taffy::Style { + let mut s = taffy::Style { + display: map_display(view.display), + box_sizing: map_box_sizing(view.box_model.box_sizing), + position: map_position_kind(view.position.kind), + size: taffy::Size { + width: sizing_to_dim(view.box_model.width), + height: sizing_to_dim(view.box_model.height), + }, + min_size: taffy::Size { + width: sizing_to_dim(view.box_model.min_width), + height: sizing_to_dim(view.box_model.min_height), + }, + max_size: taffy::Size { + width: sizing_to_dim(view.box_model.max_width), + height: sizing_to_dim(view.box_model.max_height), + }, + padding: edges_to_lp(view.box_model.padding), + margin: edges_to_lpa(view.box_model.margin), + border: edges_to_lp(view.box_model.border), + inset: inset_to_lpa(view.position.inset), + flex_direction: map_flex_axis(view.flex_params.direction), + flex_wrap: map_flex_wrap(view.flex_params.wrap), + justify_content: Some(map_justify_content(view.flex_params.justify_content)), + align_items: Some(map_align_items(view.flex_params.align_items)), + align_content: Some(map_align_content(view.flex_params.align_content)), + gap: taffy::Size { + width: length_to_lp(view.flex_params.gap.column), + height: length_to_lp(view.flex_params.gap.row), + }, + ..Default::default() + }; + + if let Some(item) = view.flex_item { + s.flex_grow = item.grow; + s.flex_shrink = item.shrink; + s.flex_basis = sizing_to_dim(item.basis); + // Taffy 0.10 has no `order` field on Style. CSS `order` would + // need a Buiy-side sibling sort before `set_children`; that + // lands later (tracked under flex-and-grid follow-ups). Phase 1 + // stores `FlexItem.order` but does not act on it; document this + // as a Phase 1 limitation (warn once per session). + let _unused_order_in_phase_1 = item.order; + s.align_self = item.align_self.map(map_align_items_as_self); + } + + if let Some(ar) = view.box_model.aspect_ratio { + s.aspect_ratio = Some(ar.ratio); + } + + s +} + +fn map_display(d: &Display) -> taffy::Display { + use Display::*; + // Phase 1 maps Grid/InlineGrid to Block. Translating them to + // taffy::Display::Grid without GridParams/GridItem would silently + // create templateless grid containers and tempt premature reliance + // on Grid before Phase 3 ships the components. Phase 3 replaces + // this row with `Grid | InlineGrid => taffy::Display::Grid`. + match d { + Block | Inline | InlineBlock | FlowRoot | Contents | ListItem | Ruby | Table + | TableRowGroup | TableHeaderGroup | TableFooterGroup | TableRow | TableCell + | TableCaption | TableColumnGroup | TableColumn | Grid | InlineGrid => { + taffy::Display::Block + } + Flex(_) | InlineFlex(_) => taffy::Display::Flex, + None => taffy::Display::None, + } +} + +fn map_box_sizing(b: BoxSizing) -> taffy::BoxSizing { + match b { + BoxSizing::ContentBox => taffy::BoxSizing::ContentBox, + BoxSizing::BorderBox => taffy::BoxSizing::BorderBox, + } +} + +fn map_position_kind(k: PositionKind) -> taffy::Position { + use PositionKind::*; + // Phase 1: Static / Relative / Absolute pass through; Fixed translates + // to Absolute and Sticky translates to Relative. Phase 7 (sticky) and + // Phase 8 (top-layer / fixed-as-viewport) wire the real semantics. + match k { + Static | Relative | Sticky => taffy::Position::Relative, + Absolute | Fixed => taffy::Position::Absolute, + } +} + +fn map_flex_axis(a: FlexAxis) -> taffy::FlexDirection { + match a { + FlexAxis::Row => taffy::FlexDirection::Row, + FlexAxis::Column => taffy::FlexDirection::Column, + FlexAxis::RowReverse => taffy::FlexDirection::RowReverse, + FlexAxis::ColumnReverse => taffy::FlexDirection::ColumnReverse, + } +} + +fn map_flex_wrap(w: FlexWrap) -> taffy::FlexWrap { + match w { + FlexWrap::NoWrap => taffy::FlexWrap::NoWrap, + FlexWrap::Wrap => taffy::FlexWrap::Wrap, + FlexWrap::WrapReverse => taffy::FlexWrap::WrapReverse, + } +} + +fn map_justify_content(j: JustifyContent) -> taffy::JustifyContent { + match j { + JustifyContent::FlexStart => taffy::JustifyContent::FlexStart, + JustifyContent::FlexEnd => taffy::JustifyContent::FlexEnd, + JustifyContent::Center => taffy::JustifyContent::Center, + JustifyContent::SpaceBetween => taffy::JustifyContent::SpaceBetween, + JustifyContent::SpaceAround => taffy::JustifyContent::SpaceAround, + JustifyContent::SpaceEvenly => taffy::JustifyContent::SpaceEvenly, + } +} + +fn map_align_items(a: AlignItems) -> taffy::AlignItems { + match a { + AlignItems::Stretch => taffy::AlignItems::Stretch, + AlignItems::FlexStart => taffy::AlignItems::FlexStart, + AlignItems::FlexEnd => taffy::AlignItems::FlexEnd, + AlignItems::Center => taffy::AlignItems::Center, + AlignItems::Baseline => taffy::AlignItems::Baseline, + } +} + +fn map_align_items_as_self(a: AlignItems) -> taffy::AlignSelf { + match a { + AlignItems::Stretch => taffy::AlignSelf::Stretch, + AlignItems::FlexStart => taffy::AlignSelf::FlexStart, + AlignItems::FlexEnd => taffy::AlignSelf::FlexEnd, + AlignItems::Center => taffy::AlignSelf::Center, + AlignItems::Baseline => taffy::AlignSelf::Baseline, + } +} + +fn map_align_content(a: AlignContent) -> taffy::AlignContent { + match a { + AlignContent::Stretch => taffy::AlignContent::Stretch, + AlignContent::FlexStart => taffy::AlignContent::FlexStart, + AlignContent::FlexEnd => taffy::AlignContent::FlexEnd, + AlignContent::Center => taffy::AlignContent::Center, + AlignContent::SpaceBetween => taffy::AlignContent::SpaceBetween, + AlignContent::SpaceAround => taffy::AlignContent::SpaceAround, + AlignContent::SpaceEvenly => taffy::AlignContent::SpaceEvenly, + } +} + +fn sizing_to_dim(s: Sizing) -> taffy::Dimension { + // Phase 1 ships Auto / None / Length / Stretch as the "real" surface; + // intrinsic keywords resolve silently to Auto until Phase 10 + text + // rendering integrate. + match s { + Sizing::Auto | Sizing::MinContent | Sizing::MaxContent | Sizing::FitContent(_) => { + taffy::Dimension::auto() + } + Sizing::None => taffy::Dimension::auto(), + Sizing::Length(l) => length_to_dim(l), + Sizing::Stretch => taffy::Dimension::auto(), // taffy 0.10 doesn't ship `stretch`; treated as auto. + } +} + +fn length_to_dim(l: Length) -> taffy::Dimension { + match l { + Length::Px(v) => taffy::Dimension::length(v), + Length::Percent(p) => taffy::Dimension::percent(p / 100.0), + } +} + +fn length_to_lp(l: Length) -> taffy::LengthPercentage { + match l { + Length::Px(v) => taffy::LengthPercentage::length(v), + Length::Percent(p) => taffy::LengthPercentage::percent(p / 100.0), + } +} + +fn length_to_lpa(l: Length) -> taffy::LengthPercentageAuto { + match l { + Length::Px(v) => taffy::LengthPercentageAuto::length(v), + Length::Percent(p) => taffy::LengthPercentageAuto::percent(p / 100.0), + } +} + +fn sizing_to_lpa(s: Sizing) -> taffy::LengthPercentageAuto { + match s { + Sizing::Auto + | Sizing::None + | Sizing::MinContent + | Sizing::MaxContent + | Sizing::FitContent(_) + | Sizing::Stretch => taffy::LengthPercentageAuto::auto(), + Sizing::Length(l) => length_to_lpa(l), + } +} + +fn edges_to_lp(e: Edges) -> taffy::Rect { + taffy::Rect { + top: length_to_lp(e.top), + right: length_to_lp(e.right), + bottom: length_to_lp(e.bottom), + left: length_to_lp(e.left), + } +} + +fn edges_to_lpa(e: Edges) -> taffy::Rect { + taffy::Rect { + top: length_to_lpa(e.top), + right: length_to_lpa(e.right), + bottom: length_to_lpa(e.bottom), + left: length_to_lpa(e.left), + } +} + +fn inset_to_lpa(i: Inset) -> taffy::Rect { + taffy::Rect { + top: sizing_to_lpa(i.top), + right: sizing_to_lpa(i.right), + bottom: sizing_to_lpa(i.bottom), + left: sizing_to_lpa(i.left), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::components::{BoxModel, Display, FlexItem, FlexParams, Position}; + use crate::layout::types::{ + AlignItems, BoxSizing, Edges, FlexAxis, FlexGap, FlexWrap, JustifyContent, Length, + PositionKind, Sizing, + }; + + #[test] + fn translate_default_components_to_taffy_default() { + let bm = BoxModel::default(); + let display = Display::default(); + let position = Position::default(); + let flex = FlexParams::default(); + let item: Option<&FlexItem> = None; + let taffy = style_to_taffy(StyleView { + display: &display, + box_model: &bm, + position: &position, + flex_params: &flex, + flex_item: item, + }); + // Default Display::Block + ContentBox + everything Auto produces taffy default Display::Block. + assert_eq!(taffy.display, taffy::Display::Block); + assert_eq!(taffy.size.width, taffy::Dimension::auto()); + assert_eq!(taffy.size.height, taffy::Dimension::auto()); + } + + #[test] + fn translate_flex_row_with_dimensions() { + let display = Display::Flex(FlexAxis::Row); + let bm = BoxModel { + width: Sizing::Length(Length::Px(200.0)), + height: Sizing::Length(Length::Px(100.0)), + padding: Edges::all(8.0), + box_sizing: BoxSizing::BorderBox, + ..Default::default() + }; + let position = Position::default(); + let flex = FlexParams { + direction: FlexAxis::Row, + gap: FlexGap { + row: Length::Px(4.0), + column: Length::Px(4.0), + }, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + wrap: FlexWrap::NoWrap, + ..Default::default() + }; + let taffy = style_to_taffy(StyleView { + display: &display, + box_model: &bm, + position: &position, + flex_params: &flex, + flex_item: None, + }); + assert_eq!(taffy.display, taffy::Display::Flex); + assert_eq!(taffy.flex_direction, taffy::FlexDirection::Row); + assert_eq!(taffy.size.width, taffy::Dimension::length(200.0)); + assert_eq!(taffy.size.height, taffy::Dimension::length(100.0)); + assert_eq!(taffy.box_sizing, taffy::BoxSizing::BorderBox); + assert_eq!(taffy.justify_content, Some(taffy::JustifyContent::Center)); + assert_eq!(taffy.align_items, Some(taffy::AlignItems::Center)); + } + + #[test] + fn translate_position_absolute_emits_absolute_with_inset() { + let display = Display::default(); + let bm = BoxModel::default(); + let position = Position { + kind: PositionKind::Absolute, + inset: crate::layout::types::Inset { + top: Sizing::Length(Length::Px(10.0)), + left: Sizing::Length(Length::Px(20.0)), + ..Default::default() + }, + }; + let flex = FlexParams::default(); + let taffy = style_to_taffy(StyleView { + display: &display, + box_model: &bm, + position: &position, + flex_params: &flex, + flex_item: None, + }); + assert_eq!(taffy.position, taffy::Position::Absolute); + assert_eq!(taffy.inset.top, taffy::LengthPercentageAuto::length(10.0)); + assert_eq!(taffy.inset.left, taffy::LengthPercentageAuto::length(20.0)); + } + + #[test] + fn translate_flex_item_basis_grow_shrink() { + let display = Display::default(); + let bm = BoxModel::default(); + let position = Position::default(); + let flex = FlexParams::default(); + let item = FlexItem { + grow: 2.0, + shrink: 0.5, + basis: Sizing::Length(Length::Px(100.0)), + order: 3, + align_self: Some(AlignItems::Center), + }; + let taffy = style_to_taffy(StyleView { + display: &display, + box_model: &bm, + position: &position, + flex_params: &flex, + flex_item: Some(&item), + }); + assert_eq!(taffy.flex_grow, 2.0); + assert_eq!(taffy.flex_shrink, 0.5); + assert_eq!(taffy.flex_basis, taffy::Dimension::length(100.0)); + assert_eq!(taffy.align_self, Some(taffy::AlignSelf::Center)); + // FlexItem.order is stored but Taffy 0.10 has no `order` on + // Style; Phase 1 does not honor it. Documented as a Phase 1 + // limitation in the translation module's doc comment. + } +} diff --git a/crates/buiy_core/src/layout/tree.rs b/crates/buiy_core/src/layout/tree.rs new file mode 100644 index 0000000..17a6134 --- /dev/null +++ b/crates/buiy_core/src/layout/tree.rs @@ -0,0 +1,28 @@ +//! `LayoutTree` — the bridge state between Buiy entities and Taffy. +//! +//! Spec: docs/specs/2026-05-08-buiy-layout-design/architecture.md § 1.1. +//! +//! Stored as a `NonSendResource` because Taffy's `Dimension` packs a +//! `*const ()` regardless of the `calc` feature, so `TaffyTree` is +//! `!Send + !Sync`. Reused frame-to-frame so Taffy's internal cache stays +//! warm. GC handled by `systems::gc_removed_nodes`. + +use bevy::prelude::Entity; +use std::collections::HashMap; +use taffy::{NodeId as TaffyNodeId, TaffyTree}; + +#[derive(Default)] +pub struct LayoutTree { + pub(crate) tree: TaffyTree<()>, + pub(crate) by_entity: HashMap, +} + +impl LayoutTree { + pub fn len(&self) -> usize { + self.by_entity.len() + } + + pub fn is_empty(&self) -> bool { + self.by_entity.is_empty() + } +} diff --git a/crates/buiy_core/src/layout/types.rs b/crates/buiy_core/src/layout/types.rs new file mode 100644 index 0000000..1b39b94 --- /dev/null +++ b/crates/buiy_core/src/layout/types.rs @@ -0,0 +1,249 @@ +//! Layout value types — units, edges, axis enums, position kind. +//! +//! Spec: docs/specs/2026-05-08-buiy-layout-design/box-model.md and +//! display-and-positioning.md. +//! +//! Phase 1 ships `Length::Px` / `Length::Percent` and the `Sizing` / +//! `Edges` / `BoxSizing` shapes. Em / Rem / viewport / container / Fr / +//! Calc resolution lands in Phase 10 (`buiy-layout-units-calc`); intrinsic +//! sizing keywords resolve to `Auto` until text rendering integrates. + +use bevy::prelude::*; + +/// CSS-style length value. Phase 1 ships only `Px` and `Percent`; other +/// variants are reserved for later phases. The variants present here cover +/// every value the Phase 1 translation layer can emit to Taffy without +/// further resolution. +#[derive(Reflect, Clone, Copy, Debug, PartialEq)] +pub enum Length { + /// Absolute logical pixels. + Px(f32), + /// Percentage of the containing block dimension on the relevant axis. + Percent(f32), +} + +impl Length { + pub const ZERO: Self = Self::Px(0.0); + + pub const fn px(v: f32) -> Self { + Self::Px(v) + } + + pub const fn percent(v: f32) -> Self { + Self::Percent(v) + } +} + +impl Default for Length { + fn default() -> Self { + Self::ZERO + } +} + +/// Width / height / min / max value type. Phase 1 ships `Auto`, `None` +/// (max-only), `Length`, and `Stretch`. Intrinsic keywords ship as +/// variants but resolve to `Auto` until Phase 10 + text rendering land. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq)] +pub enum Sizing { + #[default] + Auto, + /// Valid only on `max-*` (semantics: no upper bound). + None, + Length(Length), + /// CSS `min-content`. Resolves to `Auto` until text rendering integrates. + MinContent, + /// CSS `max-content`. Resolves to `Auto` until text rendering integrates. + MaxContent, + /// CSS `fit-content()`. Resolves to `Auto` until text rendering integrates. + FitContent(Length), + /// CSS `stretch` — fills the parent's free space along the affected axis. + Stretch, +} + +/// Per-edge length values for padding, margin, border, inset. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq)] +pub struct Edges { + pub top: Length, + pub right: Length, + pub bottom: Length, + pub left: Length, +} + +impl Edges { + pub const ZERO: Self = Self { + top: Length::ZERO, + right: Length::ZERO, + bottom: Length::ZERO, + left: Length::ZERO, + }; + + /// Uniform value on every edge. + pub const fn all(v: f32) -> Self { + Self { + top: Length::Px(v), + right: Length::Px(v), + bottom: Length::Px(v), + left: Length::Px(v), + } + } + + /// Distinct horizontal vs. vertical values. + pub const fn axis(x: f32, y: f32) -> Self { + Self { + top: Length::Px(y), + right: Length::Px(x), + bottom: Length::Px(y), + left: Length::Px(x), + } + } +} + +/// `box-sizing` policy. CSS default is `ContentBox`; app UIs typically prefer +/// `BorderBox`. The Buiy default theme does not override the component +/// default — authors opt in. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum BoxSizing { + #[default] + ContentBox, + BorderBox, +} + +/// `aspect-ratio` value. Phase 1 stores a single ratio; CSS's +/// `aspect-ratio: auto` (intrinsic dimensions take precedence) is +/// represented by *not setting* `BoxModel.aspect_ratio` (the field is +/// `Option`). Stored on `BoxModel` only when the author +/// explicitly opts in. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq)] +pub struct AspectRatio { + pub ratio: f32, +} + +/// Flex / inline-flex main axis. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum FlexAxis { + #[default] + Row, + Column, + RowReverse, + ColumnReverse, +} + +/// Flex wrap mode. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum FlexWrap { + #[default] + NoWrap, + Wrap, + WrapReverse, +} + +/// Main-axis distribution of flex / grid items. CSS `justify-content`. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum JustifyContent { + #[default] + FlexStart, + FlexEnd, + Center, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +/// Cross-axis alignment of flex / grid items within their line. CSS `align-items`. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum AlignItems { + #[default] + Stretch, + FlexStart, + FlexEnd, + Center, + Baseline, +} + +/// Cross-axis distribution of flex / grid lines (multi-line containers). +/// CSS `align-content`. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum AlignContent { + #[default] + Stretch, + FlexStart, + FlexEnd, + Center, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +/// Flex / grid gap, distinguished by axis. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq)] +pub struct FlexGap { + pub row: Length, + pub column: Length, +} + +/// Position kind. Phase 1 implements `Static`, `Relative`, `Absolute`; +/// `Fixed` and `Sticky` ship as variants but emit a one-shot `warn!` and +/// translate to `Absolute` / `Relative` respectively until Phases 7/8 land. +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum PositionKind { + #[default] + Static, + Relative, + Absolute, + Fixed, + Sticky, +} + +/// Inset values (`top`/`right`/`bottom`/`left`). +#[derive(Reflect, Default, Clone, Copy, Debug, PartialEq)] +pub struct Inset { + pub top: Sizing, + pub right: Sizing, + pub bottom: Sizing, + pub left: Sizing, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn length_constructors_round_trip() { + assert_eq!(Length::px(10.0), Length::Px(10.0)); + assert_eq!(Length::percent(50.0), Length::Percent(50.0)); + assert_eq!(Length::ZERO, Length::Px(0.0)); + } + + #[test] + fn edges_helpers_produce_uniform_and_axis_values() { + let all = Edges::all(8.0); + assert_eq!(all.top, Length::Px(8.0)); + assert_eq!(all.right, Length::Px(8.0)); + assert_eq!(all.bottom, Length::Px(8.0)); + assert_eq!(all.left, Length::Px(8.0)); + + let axis = Edges::axis(4.0, 12.0); + assert_eq!(axis.left, Length::Px(4.0)); + assert_eq!(axis.right, Length::Px(4.0)); + assert_eq!(axis.top, Length::Px(12.0)); + assert_eq!(axis.bottom, Length::Px(12.0)); + + assert_eq!(Edges::ZERO, Edges::all(0.0)); + } + + #[test] + fn enum_defaults_match_spec() { + assert_eq!(BoxSizing::default(), BoxSizing::ContentBox); + assert_eq!(FlexAxis::default(), FlexAxis::Row); + assert_eq!(FlexWrap::default(), FlexWrap::NoWrap); + assert_eq!(JustifyContent::default(), JustifyContent::FlexStart); + assert_eq!(AlignItems::default(), AlignItems::Stretch); + assert_eq!(AlignContent::default(), AlignContent::Stretch); + assert_eq!(PositionKind::default(), PositionKind::Static); + } + + #[test] + fn sizing_default_is_auto() { + assert_eq!(Sizing::default(), Sizing::Auto); + } +} diff --git a/crates/buiy_core/src/lib.rs b/crates/buiy_core/src/lib.rs index 1f6e203..d80e76f 100644 --- a/crates/buiy_core/src/lib.rs +++ b/crates/buiy_core/src/lib.rs @@ -14,9 +14,13 @@ pub mod render; pub mod theme; pub use a11y::{A11yDescription, A11yLabel, A11yNodeView, A11yPlugin, A11yRole, A11yTreeBuilder}; -pub use components::{FlexDirection, Node, ResolvedLayout, Style}; +pub use components::{Node, ResolvedLayout, Visual}; pub use focus::{FocusPlugin, FocusVisible, Focusable, FocusedEntity}; -pub use layout::LayoutPlugin; +pub use layout::{ + AlignContent, AlignItems, AspectRatio, BoxModel, BoxSizing, BuiyLayoutStep, Display, Edges, + FlexAxis, FlexGap, FlexItem, FlexParams, FlexWrap, Inset, JustifyContent, LayoutPlugin, + LayoutTree, Length, Position, PositionKind, Sizing, Style, +}; pub use picking::{BuiyPickingBackendPlugin, Hovered, PickingPlugin, hit_test}; /// Top-level system sets for Buiy. Order: Layout → Style → Input → Animate @@ -40,9 +44,8 @@ pub struct CorePlugin; impl Plugin for CorePlugin { fn build(&self, app: &mut App) { app.register_type::() - .register_type::