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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 0 additions & 8 deletions crates/rectree/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,4 @@ categories = ["gui", "data-structures", "no-std"]
readme = "README.md"

[dependencies]
sparse_map.workspace = true
hashbrown.workspace = true
kurbo.workspace = true
bitflags.workspace = true

[features]
default = ["std"]
std = ["kurbo/std"]
libm = ["kurbo/libm"]
131 changes: 119 additions & 12 deletions crates/rectree/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,132 @@ Rectree is designed to be:

## Core Concepts

- `Rectree`: a hierarchical tree of rectangular nodes.
- `Constraint`: size limitations flowing *from parent to child*.
- `Size`: resolved dimensions flowing *from child to parent*.
- `LayoutSolver`: user-defined logic that computes constraints,
sizes, and relative translations.
| Type / Trait | Role |
| ----------------- | ---------------------------------------------- |
| [`Rectree`] | tree structure and per-node layout logic |
| [`RectNodes`] | flat mutable storage for per-node numbers |
| [`NodeContext`] | restricted build-time view of `RectNodes` |
| [`RectNode`] | per-node data (size, constraint, translation) |
| [`NodeState`] | bitflags that short-circuit incremental passes |
| [`Constraint`] | min/max size bounds, flowing top-down |
| [`Size`] | resolved dimensions, flowing bottom-up |

Rectree itself does not impose a specific layout style (e.g. flexbox,
grid). Instead, it provides a strict data-flow model on top of which
layout algorithms can be built.

## Three-Pass Layout

Each call to `layout` runs three passes in order:

1. **constrain** (top-down): each node derives the constraint it
passes to its children via `Rectree::constrain`.
2. **build** (bottom-up): each node measures itself given its
constraint and child sizes via `Rectree::build`.
3. **propagate_translation** (top-down): local translations set
during build are accumulated into world-space positions.

Each pass is short-circuited by `NodeState` flags so only nodes
that actually changed are reprocessed.

### Layout Rules

1. The only data that can flow down the tree is `Constraint`.
2. The only data that can flow up the tree is `Size`.
3. Each child, no matter the order, will recieve the same `Constraint`
from the parent.
4. Given the same `Constraint`, an unmodified node must always
produce the same `Size`.
1. Constraints flow strictly top-down (parent to child).
2. Sizes flow strictly bottom-up (child to parent).
3. A parent may pass a different constraint to each child.
4. Given the same constraint, an unmodified node must always
produce the same size.
5. The build pass must not write child sizes, only child
translations. This is enforced by `NodeContext`.

## Example

```rust
use std::collections::HashMap;
use rectree::{
Constraint, NodeContext, RectNode, RectNodes,
Rectree, Size, layout,
};

#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct Id(u32);

// Flat node storage backed by a HashMap.
struct Store(HashMap<Id, RectNode<Id>>);

impl RectNodes for Store {
type Id = Id;

fn get_node(&self, id: &Id) -> Option<&RectNode<Id>> {
self.0.get(id)
}

fn get_node_mut(&mut self, id: &Id)
-> Option<&mut RectNode<Id>>
{
self.0.get_mut(id)
}
}

// Tree with one root and one child. The root fills its
// constraint; the child has a fixed 100x50 size.
struct Tree { root: Id, child: Id }

impl Rectree for Tree {
type Id = Id;
type Nodes = Store;

fn for_each_child(
&self,
id: &Id,
_nodes: &mut Store,
mut f: impl FnMut(&Id, &mut Store),
) {
if *id == self.root {
f(&self.child, _nodes);
}
}

// Pass the parent constraint to children unchanged.
fn constrain(&self, _: &Id, _nodes: &Store, parent: Constraint)
-> Constraint
{
parent
}

fn build(
&self,
id: &Id,
constraint: Constraint,
_nodes: &mut Self::Nodes,
) -> Size {
if *id == self.child {
Size::new(100.0, 50.0)
} else {
constraint.max
}
}
}

let root = Id(0);
let child = Id(1);
let tree = Tree { root, child };

let mut store = Store(HashMap::new());
store.0.insert(root, RectNode::new(None));
store.0.insert(child, RectNode::new(Some(root)));

// Constrain the root to an 800x600 window.
store.0.get_mut(&root).unwrap().constraint =
Constraint::tight(Size::new(800.0, 600.0));

layout(&tree, &mut store, &root);

// Child is placed at the origin by default.
assert_eq!(store.0[&child].world_translation.x, 0.0);
assert_eq!(store.0[&child].world_translation.y, 0.0);
assert_eq!(store.0[&child].size, Size::new(100.0, 50.0));
```

## Join the community!

Expand All @@ -55,4 +163,3 @@ You can join us on the [Voxell discord server](https://discord.gg/Mhnyp6VYEQ).

This means you can select the license you prefer!
This dual-licensing approach is the de-facto standard in the Rust ecosystem and there are [very good reasons](https://github.com/bevyengine/bevy/issues/2373) to include both.

201 changes: 201 additions & 0 deletions crates/rectree/src/geom.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/// A 2D size in resolved pixels.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Size {
pub width: f32,
pub height: f32,
}

impl Size {
/// Zero size on both axes.
pub const ZERO: Self = Self::splat(0.0);

/// Infinite size on both axes.
pub const INFINITY: Self = Self::splat(f32::INFINITY);

/// Creates a `Size` from explicit `width` and `height`.
#[inline]
pub const fn new(width: f32, height: f32) -> Self {
Self { width, height }
}

/// Creates a `Size` with the same value on both axes.
#[inline]
pub const fn splat(value: f32) -> Self {
Self::new(value, value)
}
}

/// A 2D position or translation vector.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Vec2 {
pub x: f32,
pub y: f32,
}

impl Vec2 {
/// Zero vector on both axes.
pub const ZERO: Self = Self::splat(0.0);

/// Creates a `Vec2` from explicit `x` and `y`.
#[inline]
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}

/// Creates a `Vec2` with the same value on both axes.
#[inline]
pub const fn splat(value: f32) -> Self {
Self::new(value, value)
}
}

impl core::ops::Add for Vec2 {
type Output = Self;

fn add(self, rhs: Self) -> Self {
Self::new(self.x + rhs.x, self.y + rhs.y)
}
}

/// Min/max size bounds passed top-down through the layout tree.
///
/// A `Constraint` tells a node how large it is allowed to be.
/// The node measures itself within these bounds and returns a
/// [`Size`].
///
/// # Axis independence
///
/// Each axis is constrained independently. Setting `max` to
/// [`f32::INFINITY`] leaves that axis unconstrained (see
/// [`Self::unbounded`], [`Self::fixed_width`],
/// [`Self::fixed_height`]).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Constraint {
pub min: Size,
pub max: Size,
}

impl Constraint {
/// The node must be exactly `size`, with no flexibility.
pub const fn tight(size: Size) -> Self {
Self {
min: size,
max: size,
}
}

/// The node may choose any size from zero up to `max`.
pub const fn loose(max: Size) -> Self {
Self {
min: Size::ZERO,
max,
}
}

/// No bounds on either axis; the node may be any size.
///
/// This is the [`Default`] value for `Constraint`, used for
/// root nodes that have no parent imposing bounds.
pub const fn unbounded() -> Self {
Self {
min: Size::ZERO,
max: Size::INFINITY,
}
}

/// Bounded width, unbounded height.
///
/// Use this for vertical scroll containers.
pub const fn fixed_width(width: f32) -> Self {
Self {
min: Size::ZERO,
max: Size {
width,
height: f32::INFINITY,
},
}
}

/// Bounded height, unbounded width.
///
/// Use this for horizontal scroll containers.
pub const fn fixed_height(height: f32) -> Self {
Self {
min: Size::ZERO,
max: Size {
width: f32::INFINITY,
height,
},
}
}

/// Clamps `size` so it satisfies this constraint.
///
/// Each axis is clamped independently to `[min, max]`. Use
/// this at the end of a [`crate::Rectree::build`]
/// implementation to ensure the returned [`Size`] respects
/// the constraint.
pub const fn constrain(&self, size: Size) -> Size {
Size {
width: size.width.max(self.min.width).min(self.max.width),
height: size
.height
.max(self.min.height)
.min(self.max.height),
}
}
}

impl Default for Constraint {
/// Returns [`Self::unbounded`], the default for root nodes
/// that have no parent imposing bounds.
fn default() -> Self {
Self::unbounded()
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_constrain_passes_through_within_bounds() {
let c = Constraint::loose(Size::splat(100.0));
assert_eq!(c.constrain(Size::splat(50.0)), Size::splat(50.0));
}

#[test]
fn test_constrain_clamps_below_min() {
let c = Constraint {
min: Size::splat(10.0),
max: Size::splat(100.0),
};
assert_eq!(c.constrain(Size::splat(5.0)), Size::splat(10.0));
}

#[test]
fn test_constrain_clamps_above_max() {
let c = Constraint::loose(Size::splat(100.0));
assert_eq!(
c.constrain(Size::splat(200.0)),
Size::splat(100.0)
);
}

#[test]
fn test_constrain_tight_forces_exact_size() {
let c = Constraint::tight(Size::new(30.0, 20.0));
assert_eq!(c.constrain(Size::ZERO), Size::new(30.0, 20.0));
assert_eq!(
c.constrain(Size::INFINITY),
Size::new(30.0, 20.0)
);
}

#[test]
fn test_constrain_unbounded_passes_through() {
let c = Constraint::unbounded();
let s = Size::new(999.0, 999.0);
assert_eq!(c.constrain(s), s);
}
}
Loading
Loading