Skip to main content

stipple_layout/
lib.rs

1//! Stipple's layout primitives.
2//!
3//! A self-contained flex/box model (no `taffy`): [`Constraints`] describe the
4//! size range a parent offers a child, [`Axis`] picks main vs. cross, and
5//! [`solve_main_axis`] distributes space along the main axis among fixed-size
6//! and flexible children. `stipple-core` composes these into a full tree layout
7//! pass; the algorithms here are deliberately small and independently testable.
8
9#![forbid(unsafe_code)]
10
11use stipple_geometry::Size;
12
13/// A layout axis.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum Axis {
16    Horizontal,
17    Vertical,
18}
19
20impl Axis {
21    /// The component of `size` along this axis (width for horizontal).
22    #[inline]
23    pub fn main(self, size: Size) -> f64 {
24        match self {
25            Axis::Horizontal => size.width,
26            Axis::Vertical => size.height,
27        }
28    }
29
30    /// The component of `size` across this axis.
31    #[inline]
32    pub fn cross(self, size: Size) -> f64 {
33        match self {
34            Axis::Horizontal => size.height,
35            Axis::Vertical => size.width,
36        }
37    }
38
39    /// Build a [`Size`] from main/cross extents along this axis.
40    #[inline]
41    pub fn size(self, main: f64, cross: f64) -> Size {
42        match self {
43            Axis::Horizontal => Size::new(main, cross),
44            Axis::Vertical => Size::new(cross, main),
45        }
46    }
47}
48
49/// The size range a parent offers a child: every laid-out size must satisfy
50/// `min <= size <= max` component-wise.
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct Constraints {
53    pub min: Size,
54    pub max: Size,
55}
56
57impl Constraints {
58    /// Loose constraints: anything from zero up to `max`.
59    #[inline]
60    pub fn loose(max: Size) -> Self {
61        Self {
62            min: Size::ZERO,
63            max,
64        }
65    }
66
67    /// Tight constraints: exactly `size`.
68    #[inline]
69    pub fn tight(size: Size) -> Self {
70        Self {
71            min: size,
72            max: size,
73        }
74    }
75
76    /// Clamp `size` into the allowed range.
77    #[inline]
78    pub fn constrain(&self, size: Size) -> Size {
79        size.clamp(self.min, self.max)
80    }
81
82    /// Shrink the available maximum by `amount` on each side's total (e.g. for
83    /// padding), keeping `min` no larger than the new `max`.
84    pub fn deflate(&self, horizontal: f64, vertical: f64) -> Self {
85        let max = Size::new(
86            (self.max.width - horizontal).max(0.0),
87            (self.max.height - vertical).max(0.0),
88        );
89        Self {
90            min: self.min.clamp(Size::ZERO, max),
91            max,
92        }
93    }
94}
95
96/// One participant in a main-axis flex layout.
97#[derive(Clone, Copy, Debug, PartialEq)]
98pub struct FlexItem {
99    /// The item's natural size along the main axis, in logical pixels.
100    pub basis: f64,
101    /// Share of leftover free space this item absorbs. `0.0` = fixed size.
102    pub grow: f64,
103}
104
105impl FlexItem {
106    /// A fixed-size item that never grows.
107    #[inline]
108    pub fn fixed(basis: f64) -> Self {
109        Self { basis, grow: 0.0 }
110    }
111
112    /// A flexible item with the given grow weight and zero basis.
113    #[inline]
114    pub fn flex(grow: f64) -> Self {
115        Self { basis: 0.0, grow }
116    }
117}
118
119/// The resolved position of one item along the main axis.
120#[derive(Clone, Copy, Debug, PartialEq)]
121pub struct Span {
122    /// Offset from the start of the content area.
123    pub offset: f64,
124    /// Length along the main axis.
125    pub length: f64,
126}
127
128/// Lay items out along a single axis within `available` main-axis space,
129/// separated by `gap`. Free space (after bases and gaps) is distributed to
130/// items in proportion to their `grow` weight; if no item grows, leftover
131/// space is left unused (items pack at the start).
132pub fn solve_main_axis(available: f64, gap: f64, items: &[FlexItem]) -> Vec<Span> {
133    if items.is_empty() {
134        return Vec::new();
135    }
136    let total_gap = gap * (items.len() as f64 - 1.0);
137    let total_basis: f64 = items.iter().map(|i| i.basis).sum();
138    let total_grow: f64 = items.iter().map(|i| i.grow).sum();
139    let free = (available - total_basis - total_gap).max(0.0);
140
141    let mut spans = Vec::with_capacity(items.len());
142    let mut cursor = 0.0;
143    for item in items {
144        let extra = if total_grow > 0.0 {
145            free * (item.grow / total_grow)
146        } else {
147            0.0
148        };
149        let length = item.basis + extra;
150        spans.push(Span {
151            offset: cursor,
152            length,
153        });
154        cursor += length + gap;
155    }
156    spans
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn fixed_items_pack_with_gap() {
165        let spans = solve_main_axis(200.0, 10.0, &[FlexItem::fixed(40.0), FlexItem::fixed(60.0)]);
166        assert_eq!(
167            spans[0],
168            Span {
169                offset: 0.0,
170                length: 40.0
171            }
172        );
173        assert_eq!(
174            spans[1],
175            Span {
176                offset: 50.0,
177                length: 60.0
178            }
179        );
180    }
181
182    #[test]
183    fn grow_distributes_free_space() {
184        // 200 available, 20 gap (×2 = 40), one fixed 40 + two flex(1):
185        // free = 200 - 40 - 40 = 120, split 60/60.
186        let spans = solve_main_axis(
187            200.0,
188            20.0,
189            &[
190                FlexItem::fixed(40.0),
191                FlexItem::flex(1.0),
192                FlexItem::flex(1.0),
193            ],
194        );
195        assert_eq!(spans[0].length, 40.0);
196        assert_eq!(spans[1].length, 60.0);
197        assert_eq!(spans[2].length, 60.0);
198        // offsets account for the 20px gaps
199        assert_eq!(spans[1].offset, 60.0);
200        assert_eq!(spans[2].offset, 140.0);
201    }
202
203    #[test]
204    fn overflow_clamps_free_to_zero() {
205        // Basis (40) already exceeds available (30): no free space, grow gets 0.
206        let spans = solve_main_axis(30.0, 0.0, &[FlexItem::fixed(40.0), FlexItem::flex(1.0)]);
207        assert_eq!(spans[1].length, 0.0);
208    }
209
210    #[test]
211    fn axis_main_cross_roundtrip() {
212        let s = Axis::Vertical.size(10.0, 4.0);
213        assert_eq!(s, Size::new(4.0, 10.0));
214        assert_eq!(Axis::Vertical.main(s), 10.0);
215        assert_eq!(Axis::Vertical.cross(s), 4.0);
216    }
217}