Skip to main content

motion_core/authoring/
layout.rs

1//! Measured row/column arrangement inside the bounded node traversal.
2
3use super::spatial::{Bounds, Placement, Resolved};
4use crate::{Node, ValidationError, Vector2};
5
6#[cfg(test)]
7mod tests;
8
9/// Cross-axis alignment of measured child layout bounds.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11pub enum Alignment {
12    /// Align upper edges in rows, or left edges in columns; the default.
13    #[default]
14    Start,
15    /// Center each child within the largest cross-axis extent.
16    Center,
17    /// Align lower edges in rows, or right edges in columns.
18    End,
19    /// Align real text baselines in rows, including measured text parts.
20    /// Columns, rotated/upside-down nodes and baseline-less children are unsupported.
21    Baseline,
22}
23
24/// A measured layout container, evaluated before its own node placement.
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub enum Layout {
27    /// Arrange children horizontally, with vertical alignment.
28    Row {
29        /// Finite separation between nonempty children; negatives overlap.
30        gap: f64,
31        /// Alignment on the vertical axis.
32        alignment: Alignment,
33    },
34    /// Arrange children vertically, with horizontal alignment.
35    Column {
36        /// Finite separation between nonempty children; negatives overlap.
37        gap: f64,
38        /// Alignment on the horizontal axis.
39        alignment: Alignment,
40    },
41}
42
43/// Builds a horizontal measured layout; defaults to zero gap and Start alignment.
44///
45/// Children are positioned in insertion order, then painted in stable z order.
46/// Empty nodes consume no gap; declared points and lines still occupy a slot.
47/// A child's existing transformed AABB supplies its size; its previous position
48/// is replaced by arrangement. Parent placement and opacity apply afterwards.
49///
50/// ```
51/// use motion_core::prelude::*;
52/// use motion_core::{
53///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
54///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
55/// };
56/// let tile = || Node::from(Rect::new(0.0, 0.0, 10.0, 20.0).fill(Color::rgb(20, 150, 125)));
57/// let row = Row::new().gap(12.0).children([tile(), tile()]);
58/// assert_eq!(
59///     row.measure(ValidationLimits::default())?,
60///     Rect::new(0.0, 0.0, 32.0, 20.0)
61/// );
62/// let column = Column::new()
63///     .gap(4.0)
64///     .alignment(Alignment::End)
65///     .children([row, tile()])
66///     .at(Point::new(100.0, 80.0));
67/// assert_eq!(
68///     column.measure(ValidationLimits::default())?,
69///     Rect::new(84.0, 58.0, 116.0, 102.0)
70/// );
71/// let list = column.compile(
72///     Size::new(200.0, 160.0),
73///     PixelSize::new(200, 160),
74///     ValidationLimits::default(),
75/// )?;
76/// assert_eq!(list.commands.len(), 3);
77/// # Ok::<(), motion_core::ValidationError>(())
78/// ```
79#[derive(Clone, Copy, Debug, Default)]
80pub struct Row {
81    gap: f64,
82    alignment: Alignment,
83}
84
85impl Row {
86    /// Creates a horizontal layout builder.
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Replaces finite spacing; negative spacing intentionally overlaps.
92    pub fn gap(mut self, gap: f64) -> Self {
93        self.gap = gap;
94        self
95    }
96
97    /// Replaces cross-axis alignment.
98    pub fn alignment(mut self, alignment: Alignment) -> Self {
99        self.alignment = alignment;
100        self
101    }
102
103    /// Captures children for deferred, bounded measurement and arrangement.
104    pub fn children<Child: Into<Node>>(self, children: impl IntoIterator<Item = Child>) -> Node {
105        Node::layout(
106            Layout::Row {
107                gap: self.gap,
108                alignment: self.alignment,
109            },
110            children,
111        )
112    }
113}
114
115/// Builds a vertical measured layout; defaults to zero gap and Start alignment.
116#[derive(Clone, Copy, Debug, Default)]
117pub struct Column {
118    gap: f64,
119    alignment: Alignment,
120}
121
122impl Column {
123    /// Creates a vertical layout builder.
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    /// Replaces finite spacing; negative spacing intentionally overlaps.
129    pub fn gap(mut self, gap: f64) -> Self {
130        self.gap = gap;
131        self
132    }
133
134    /// Replaces cross-axis alignment.
135    pub fn alignment(mut self, alignment: Alignment) -> Self {
136        self.alignment = alignment;
137        self
138    }
139
140    /// Captures children for deferred, bounded measurement and arrangement.
141    pub fn children<Child: Into<Node>>(self, children: impl IntoIterator<Item = Child>) -> Node {
142        Node::layout(
143            Layout::Column {
144                gap: self.gap,
145                alignment: self.alignment,
146            },
147            children,
148        )
149    }
150}
151
152impl Layout {
153    pub(super) fn baselines(
154        self,
155        children: &[Node],
156        context: &crate::DrawContext,
157        path: &str,
158    ) -> Result<Vec<Option<f64>>, ValidationError> {
159        if !matches!(
160            self,
161            Self::Row {
162                alignment: Alignment::Baseline,
163                ..
164            }
165        ) {
166            return Ok(vec![None; children.len()]);
167        }
168        children
169            .iter()
170            .enumerate()
171            .map(|(index, child)| child.baseline(context, &format!("{path}.children[{index}]")))
172            .collect()
173    }
174
175    pub(super) fn placements(
176        self,
177        children: &[(usize, Bounds)],
178        baselines: &[Option<f64>],
179        path: &str,
180    ) -> Result<Vec<Option<(Resolved, Bounds)>>, ValidationError> {
181        let (horizontal, gap, alignment) = match self {
182            Self::Row { gap, alignment } => (true, gap, alignment),
183            Self::Column { gap, alignment } => (false, gap, alignment),
184        };
185        if !gap.is_finite() {
186            return Err(ValidationError::invalid(
187                path,
188                "gap",
189                "Layout gap must be finite",
190            ));
191        }
192        if alignment == Alignment::Baseline && !horizontal {
193            return Err(ValidationError::unsupported(
194                path,
195                "alignment",
196                "Baseline alignment is supported only in horizontal rows",
197            ));
198        }
199        let mut cross_extent = 0.0_f64;
200        let mut common_baseline = 0.0_f64;
201        for (index, bounds) in children {
202            if matches!(bounds, Bounds::Empty) {
203                continue;
204            }
205            let rect = bounds.rect(&format!("{path}.children[{index}]"))?;
206            if alignment == Alignment::Baseline {
207                let baseline = baselines[*index].ok_or_else(|| {
208                    ValidationError::unsupported(
209                        path,
210                        "alignment",
211                        "Baseline requires text metrics for every nonempty child",
212                    )
213                })?;
214                let offset = baseline - rect.top;
215                if !offset.is_finite() {
216                    return Err(ValidationError::invalid(
217                        path,
218                        "baseline",
219                        "Baseline offset must be finite",
220                    ));
221                }
222                common_baseline = common_baseline.max(offset);
223            }
224            if !rect.width().is_finite() || !rect.height().is_finite() {
225                return Err(ValidationError::invalid(
226                    path,
227                    "layout_bounds",
228                    "Layout spans must be finite",
229                ));
230            }
231            cross_extent = cross_extent.max(if horizontal {
232                rect.height()
233            } else {
234                rect.width()
235            });
236        }
237        let mut cursor = 0.0_f64;
238        let mut occupied = false;
239        let mut placements = Vec::with_capacity(children.len());
240        for (index, bounds) in children {
241            if matches!(bounds, Bounds::Empty) {
242                placements.push(None);
243                continue;
244            }
245            let child_path = format!("{path}.children[{index}]");
246            let rect = bounds.rect(&child_path)?;
247            if occupied {
248                cursor += gap;
249            }
250            let (main_size, cross_size) = if horizontal {
251                (rect.width(), rect.height())
252            } else {
253                (rect.height(), rect.width())
254            };
255            let cross = match alignment {
256                Alignment::Start => 0.0,
257                Alignment::Center => (cross_extent - cross_size) * 0.5,
258                Alignment::End => cross_extent - cross_size,
259                Alignment::Baseline => {
260                    common_baseline - (baselines[*index].expect("validated baseline") - rect.top)
261                }
262            };
263            let offset = if horizontal {
264                Vector2::new(cursor - rect.left, cross - rect.top)
265            } else {
266                Vector2::new(cross - rect.left, cursor - rect.top)
267            };
268            let placement = Placement {
269                translation: offset,
270                ..Placement::default()
271            }
272            .resolve(*bounds, &child_path)?;
273            let placed_bounds = bounds.transformed(placement.transform, &child_path)?;
274            placements.push(Some((placement, placed_bounds)));
275            cursor += main_size;
276            if !cursor.is_finite() {
277                return Err(ValidationError::invalid(
278                    path,
279                    "layout_bounds",
280                    "Layout extent must remain finite",
281                ));
282            }
283            occupied = true;
284        }
285        Ok(placements)
286    }
287}