Skip to main content

pepl_ui/components/
layout.rs

1//! Layout component builders — Column, Row, Scroll.
2//!
3//! These builders produce [`SurfaceNode`] trees with correct prop types
4//! and validated structure. They are convenience wrappers used by the
5//! evaluator when constructing Surface trees from PEPL UI blocks.
6//!
7//! # Components
8//!
9//! | Component | Props | Children |
10//! |-----------|-------|----------|
11//! | `Column` | `spacing?: number`, `align?: alignment`, `padding?: edges` | Yes |
12//! | `Row` | `spacing?: number`, `align?: alignment`, `padding?: edges` | Yes |
13//! | `Scroll` | `direction?: "vertical"\|"horizontal"\|"both"` | Yes |
14
15use crate::accessibility;
16use crate::prop_value::PropValue;
17use crate::surface::SurfaceNode;
18use crate::types::{Alignment, Edges};
19use serde_json;
20
21// ── Column ────────────────────────────────────────────────────────────────────
22
23/// Builder for the `Column` layout component (vertical stack).
24///
25/// ```ignore
26/// Column { spacing: 8, align: Center, padding: 16 } {
27///     Text { value: "Hello" }
28///     Button { label: "OK", on_tap: submit }
29/// }
30/// ```
31pub struct ColumnBuilder {
32    spacing: Option<f64>,
33    align: Option<Alignment>,
34    padding: Option<Edges>,
35    children: Vec<SurfaceNode>,
36}
37
38impl ColumnBuilder {
39    pub fn new() -> Self {
40        Self {
41            spacing: None,
42            align: None,
43            padding: None,
44            children: Vec::new(),
45        }
46    }
47
48    pub fn spacing(mut self, spacing: f64) -> Self {
49        self.spacing = Some(spacing);
50        self
51    }
52
53    pub fn align(mut self, align: Alignment) -> Self {
54        self.align = Some(align);
55        self
56    }
57
58    pub fn padding(mut self, padding: Edges) -> Self {
59        self.padding = Some(padding);
60        self
61    }
62
63    pub fn child(mut self, child: SurfaceNode) -> Self {
64        self.children.push(child);
65        self
66    }
67
68    pub fn children(mut self, children: Vec<SurfaceNode>) -> Self {
69        self.children = children;
70        self
71    }
72
73    pub fn build(self) -> SurfaceNode {
74        let mut node = SurfaceNode::new("Column");
75
76        if let Some(spacing) = self.spacing {
77            node.set_prop("spacing", PropValue::Number(spacing));
78        }
79        if let Some(align) = self.align {
80            node.set_prop("align", alignment_to_prop(align));
81        }
82        if let Some(padding) = self.padding {
83            node.set_prop("padding", edges_to_prop(padding));
84        }
85
86        node.children = self.children;
87        accessibility::ensure_accessible(&mut node);
88        node
89    }
90}
91
92impl Default for ColumnBuilder {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98// ── Row ───────────────────────────────────────────────────────────────────────
99
100/// Builder for the `Row` layout component (horizontal stack).
101///
102/// Same prop signature as Column but lays out children horizontally.
103pub struct RowBuilder {
104    spacing: Option<f64>,
105    align: Option<Alignment>,
106    padding: Option<Edges>,
107    children: Vec<SurfaceNode>,
108}
109
110impl RowBuilder {
111    pub fn new() -> Self {
112        Self {
113            spacing: None,
114            align: None,
115            padding: None,
116            children: Vec::new(),
117        }
118    }
119
120    pub fn spacing(mut self, spacing: f64) -> Self {
121        self.spacing = Some(spacing);
122        self
123    }
124
125    pub fn align(mut self, align: Alignment) -> Self {
126        self.align = Some(align);
127        self
128    }
129
130    pub fn padding(mut self, padding: Edges) -> Self {
131        self.padding = Some(padding);
132        self
133    }
134
135    pub fn child(mut self, child: SurfaceNode) -> Self {
136        self.children.push(child);
137        self
138    }
139
140    pub fn children(mut self, children: Vec<SurfaceNode>) -> Self {
141        self.children = children;
142        self
143    }
144
145    pub fn build(self) -> SurfaceNode {
146        let mut node = SurfaceNode::new("Row");
147
148        if let Some(spacing) = self.spacing {
149            node.set_prop("spacing", PropValue::Number(spacing));
150        }
151        if let Some(align) = self.align {
152            node.set_prop("align", alignment_to_prop(align));
153        }
154        if let Some(padding) = self.padding {
155            node.set_prop("padding", edges_to_prop(padding));
156        }
157
158        node.children = self.children;
159        accessibility::ensure_accessible(&mut node);
160        node
161    }
162}
163
164impl Default for RowBuilder {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170// ── Scroll ────────────────────────────────────────────────────────────────────
171
172/// Scroll direction for the Scroll component.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174pub enum ScrollDirection {
175    #[default]
176    Vertical,
177    Horizontal,
178    Both,
179}
180
181impl ScrollDirection {
182    /// Returns the string value used in the Surface tree.
183    pub fn as_str(&self) -> &'static str {
184        match self {
185            ScrollDirection::Vertical => "vertical",
186            ScrollDirection::Horizontal => "horizontal",
187            ScrollDirection::Both => "both",
188        }
189    }
190}
191
192/// Builder for the `Scroll` layout component (scrollable container).
193///
194/// Default direction is `"vertical"`.
195pub struct ScrollBuilder {
196    direction: ScrollDirection,
197    children: Vec<SurfaceNode>,
198}
199
200impl ScrollBuilder {
201    pub fn new() -> Self {
202        Self {
203            direction: ScrollDirection::default(),
204            children: Vec::new(),
205        }
206    }
207
208    pub fn direction(mut self, direction: ScrollDirection) -> Self {
209        self.direction = direction;
210        self
211    }
212
213    pub fn child(mut self, child: SurfaceNode) -> Self {
214        self.children.push(child);
215        self
216    }
217
218    pub fn children(mut self, children: Vec<SurfaceNode>) -> Self {
219        self.children = children;
220        self
221    }
222
223    pub fn build(self) -> SurfaceNode {
224        let mut node = SurfaceNode::new("Scroll");
225        node.set_prop(
226            "direction",
227            PropValue::String(self.direction.as_str().to_string()),
228        );
229        node.children = self.children;
230        accessibility::ensure_accessible(&mut node);
231        node
232    }
233}
234
235impl Default for ScrollBuilder {
236    fn default() -> Self {
237        Self::new()
238    }
239}
240
241// ── Helpers ───────────────────────────────────────────────────────────────────
242
243/// Convert an `Alignment` enum to a `PropValue` for the Surface tree.
244fn alignment_to_prop(align: Alignment) -> PropValue {
245    let s = match align {
246        Alignment::Start => "start",
247        Alignment::Center => "center",
248        Alignment::End => "end",
249        Alignment::Stretch => "stretch",
250        Alignment::SpaceBetween => "space_between",
251        Alignment::SpaceAround => "space_around",
252    };
253    PropValue::String(s.to_string())
254}
255
256/// Convert an `Edges` value to a `PropValue` for the Surface tree.
257///
258/// - `Uniform(n)` → `PropValue::Number(n)` (number literal coercion)
259/// - `Sides { top, bottom, start, end }` → `PropValue::Record { top, bottom, start, end }`
260fn edges_to_prop(edges: Edges) -> PropValue {
261    match edges {
262        Edges::Uniform(n) => PropValue::Number(n),
263        Edges::Sides { .. } => {
264            let s = serde_json::to_value(&edges).expect("Edges serialization should never fail");
265            serde_json::from_value(s).expect("Edges deserialization should never fail")
266        }
267    }
268}
269
270/// Validate that a component node has valid prop types.
271///
272/// Returns a list of validation errors. Empty means valid.
273pub fn validate_layout_node(node: &SurfaceNode) -> Vec<String> {
274    let mut errors = Vec::new();
275
276    match node.component_type.as_str() {
277        "Column" | "Row" => {
278            for (key, val) in &node.props {
279                match key.as_str() {
280                    "spacing" => {
281                        if !matches!(val, PropValue::Number(_)) {
282                            errors.push(format!(
283                                "{}: 'spacing' must be a number, got {}",
284                                node.component_type,
285                                val.type_name()
286                            ));
287                        }
288                    }
289                    "align" => {
290                        if let PropValue::String(s) = val {
291                            let valid = [
292                                "start",
293                                "center",
294                                "end",
295                                "stretch",
296                                "space_between",
297                                "space_around",
298                            ];
299                            if !valid.contains(&s.as_str()) {
300                                errors.push(format!(
301                                    "{}: invalid alignment '{s}'",
302                                    node.component_type
303                                ));
304                            }
305                        } else {
306                            errors.push(format!(
307                                "{}: 'align' must be a string, got {}",
308                                node.component_type,
309                                val.type_name()
310                            ));
311                        }
312                    }
313                    "padding" => {
314                        // Number (Uniform coercion) or Record (Sides)
315                        if !matches!(val, PropValue::Number(_) | PropValue::Record(_)) {
316                            errors.push(format!(
317                                "{}: 'padding' must be a number or record, got {}",
318                                node.component_type,
319                                val.type_name()
320                            ));
321                        }
322                    }
323                    "accessible" => {
324                        errors.extend(accessibility::validate_accessible_prop(
325                            &node.component_type,
326                            val,
327                        ));
328                    }
329                    other => {
330                        errors.push(format!("{}: unknown prop '{other}'", node.component_type));
331                    }
332                }
333            }
334        }
335        "Scroll" => {
336            for (key, val) in &node.props {
337                match key.as_str() {
338                    "direction" => {
339                        if let PropValue::String(s) = val {
340                            let valid = ["vertical", "horizontal", "both"];
341                            if !valid.contains(&s.as_str()) {
342                                errors.push(format!("Scroll: invalid direction '{s}'"));
343                            }
344                        } else {
345                            errors.push(format!(
346                                "Scroll: 'direction' must be a string, got {}",
347                                val.type_name()
348                            ));
349                        }
350                    }
351                    "accessible" => {
352                        errors.extend(accessibility::validate_accessible_prop("Scroll", val));
353                    }
354                    other => {
355                        errors.push(format!("Scroll: unknown prop '{other}'"));
356                    }
357                }
358            }
359        }
360        _ => {} // Not a layout component — skip validation
361    }
362
363    errors
364}