dampen_core/ir/
layout.rs

1//! Layout system types for Dampen UI framework
2//!
3//! This module defines the IR types for layout constraints, sizing, alignment,
4//! and responsive breakpoints. All types are backend-agnostic and serializable.
5
6use serde::{Deserialize, Serialize};
7
8/// Layout constraints for widget sizing and positioning
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
10pub struct LayoutConstraints {
11    /// Primary sizing constraints
12    pub width: Option<Length>,
13    pub height: Option<Length>,
14
15    /// Size constraints in pixels
16    pub min_width: Option<f32>,
17    pub max_width: Option<f32>,
18    pub min_height: Option<f32>,
19    pub max_height: Option<f32>,
20
21    /// Inner spacing (padding)
22    pub padding: Option<Padding>,
23
24    /// Gap between child widgets
25    pub spacing: Option<f32>,
26
27    /// Alignment properties
28    pub align_items: Option<Alignment>,
29    pub justify_content: Option<Justification>,
30    pub align_self: Option<Alignment>,
31
32    /// Direct alignment (for Container, Text, etc.)
33    pub align_x: Option<Alignment>,
34    pub align_y: Option<Alignment>,
35
36    /// Layout direction
37    pub direction: Option<Direction>,
38
39    /// Positioning
40    pub position: Option<Position>,
41    pub top: Option<f32>,
42    pub right: Option<f32>,
43    pub bottom: Option<f32>,
44    pub left: Option<f32>,
45    pub z_index: Option<i32>,
46}
47
48impl LayoutConstraints {
49    /// Validates constraint relationships
50    ///
51    /// Returns an error if:
52    /// - min_width > max_width
53    /// - min_height > max_height
54    /// - spacing is negative
55    /// - padding values are negative
56    /// - fill_portion is not 1-255
57    /// - percentage is not 0.0-100.0
58    pub fn validate(&self) -> Result<(), String> {
59        if let (Some(min), Some(max)) = (self.min_width, self.max_width) {
60            if min > max {
61                return Err(format!("min_width ({}) > max_width ({})", min, max));
62            }
63        }
64
65        if let (Some(min), Some(max)) = (self.min_height, self.max_height) {
66            if min > max {
67                return Err(format!("min_height ({}) > max_height ({})", min, max));
68            }
69        }
70
71        if let Some(spacing) = self.spacing {
72            if spacing < 0.0 {
73                return Err(format!("spacing must be non-negative, got {}", spacing));
74            }
75        }
76
77        if let Some(padding) = &self.padding {
78            if padding.top < 0.0
79                || padding.right < 0.0
80                || padding.bottom < 0.0
81                || padding.left < 0.0
82            {
83                return Err("padding values must be non-negative".to_string());
84            }
85        }
86
87        if let Some(Length::FillPortion(n)) = self.width {
88            if n == 0 {
89                return Err(format!("fill_portion must be 1-255, got {}", n));
90            }
91        }
92
93        if let Some(Length::FillPortion(n)) = self.height {
94            if n == 0 {
95                return Err(format!("fill_portion must be 1-255, got {}", n));
96            }
97        }
98
99        if let Some(Length::Percentage(p)) = self.width {
100            if !(0.0..=100.0).contains(&p) {
101                return Err(format!("percentage must be 0.0-100.0, got {}", p));
102            }
103        }
104
105        if let Some(Length::Percentage(p)) = self.height {
106            if !(0.0..=100.0).contains(&p) {
107                return Err(format!("percentage must be 0.0-100.0, got {}", p));
108            }
109        }
110
111        // Position-related validation
112        if self.position.is_some() {
113            // If position is set, at least one offset should be provided
114            if self.top.is_none()
115                && self.right.is_none()
116                && self.bottom.is_none()
117                && self.left.is_none()
118            {
119                return Err(
120                    "position requires at least one offset (top, right, bottom, or left)"
121                        .to_string(),
122                );
123            }
124        }
125
126        Ok(())
127    }
128}
129
130/// Length specification for widget sizing
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub enum Length {
133    /// Exact pixel value
134    Fixed(f32),
135    /// Expand to fill available space
136    Fill,
137    /// Minimize to content size
138    Shrink,
139    /// Proportional fill (1-255)
140    FillPortion(u8),
141    /// Percentage of parent (0.0-100.0)
142    Percentage(f32),
143}
144
145impl Length {
146    /// Parse from string representation
147    ///
148    /// # Examples
149    /// ```rust
150    /// use dampen_core::ir::layout::Length;
151    ///
152    /// assert_eq!(Length::parse("200"), Ok(Length::Fixed(200.0)));
153    /// assert_eq!(Length::parse("fill"), Ok(Length::Fill));
154    /// assert_eq!(Length::parse("shrink"), Ok(Length::Shrink));
155    /// assert_eq!(Length::parse("fill_portion(3)"), Ok(Length::FillPortion(3)));
156    /// assert_eq!(Length::parse("50%"), Ok(Length::Percentage(50.0)));
157    /// ```
158    pub fn parse(s: &str) -> Result<Self, String> {
159        let s = s.trim();
160
161        if s.eq_ignore_ascii_case("fill") {
162            return Ok(Length::Fill);
163        }
164
165        if s.eq_ignore_ascii_case("shrink") {
166            return Ok(Length::Shrink);
167        }
168
169        // Parse fill_portion(n)
170        if s.starts_with("fill_portion(") && s.ends_with(')') {
171            let inner = &s[13..s.len() - 1];
172            let n: u8 = inner
173                .parse()
174                .map_err(|_| format!("Invalid fill_portion: {}", s))?;
175            return Ok(Length::FillPortion(n));
176        }
177
178        // Parse percentage
179        if let Some(num) = s.strip_suffix('%') {
180            let p: f32 = num
181                .parse()
182                .map_err(|_| format!("Invalid percentage: {}", s))?;
183            return Ok(Length::Percentage(p));
184        }
185
186        // Parse fixed pixel value
187        let pixels: f32 = s
188            .parse()
189            .map_err(|_| format!("Invalid length value: {}", s))?;
190        Ok(Length::Fixed(pixels))
191    }
192}
193
194/// Padding specification (top, right, bottom, left)
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196pub struct Padding {
197    pub top: f32,
198    pub right: f32,
199    pub bottom: f32,
200    pub left: f32,
201}
202
203impl Padding {
204    /// Parse padding from string
205    ///
206    /// # Formats
207    /// - `"<all>"`: All sides (e.g., "10")
208    /// - `"<v> <h>"`: Vertical and horizontal (e.g., "10 20")
209    /// - `"<t> <r> <b> <l>"`: Individual sides (e.g., "10 20 30 40")
210    ///
211    /// # Examples
212    /// ```rust
213    /// use dampen_core::ir::layout::Padding;
214    ///
215    /// assert_eq!(Padding::parse("10"), Ok(Padding { top: 10.0, right: 10.0, bottom: 10.0, left: 10.0 }));
216    /// assert_eq!(Padding::parse("10 20"), Ok(Padding { top: 10.0, right: 20.0, bottom: 10.0, left: 20.0 }));
217    /// assert_eq!(Padding::parse("10 20 30 40"), Ok(Padding { top: 10.0, right: 20.0, bottom: 30.0, left: 40.0 }));
218    /// ```
219    pub fn parse(s: &str) -> Result<Self, String> {
220        let parts: Vec<&str> = s.split_whitespace().collect();
221
222        match parts.len() {
223            1 => {
224                let all: f32 = parts[0]
225                    .parse()
226                    .map_err(|_| format!("Invalid padding: {}", s))?;
227                Ok(Padding {
228                    top: all,
229                    right: all,
230                    bottom: all,
231                    left: all,
232                })
233            }
234            2 => {
235                let v: f32 = parts[0]
236                    .parse()
237                    .map_err(|_| format!("Invalid vertical padding: {}", parts[0]))?;
238                let h: f32 = parts[1]
239                    .parse()
240                    .map_err(|_| format!("Invalid horizontal padding: {}", parts[1]))?;
241                Ok(Padding {
242                    top: v,
243                    right: h,
244                    bottom: v,
245                    left: h,
246                })
247            }
248            4 => {
249                let t: f32 = parts[0]
250                    .parse()
251                    .map_err(|_| format!("Invalid top padding: {}", parts[0]))?;
252                let r: f32 = parts[1]
253                    .parse()
254                    .map_err(|_| format!("Invalid right padding: {}", parts[1]))?;
255                let b: f32 = parts[2]
256                    .parse()
257                    .map_err(|_| format!("Invalid bottom padding: {}", parts[2]))?;
258                let l: f32 = parts[3]
259                    .parse()
260                    .map_err(|_| format!("Invalid left padding: {}", parts[3]))?;
261                Ok(Padding {
262                    top: t,
263                    right: r,
264                    bottom: b,
265                    left: l,
266                })
267            }
268            _ => Err(format!(
269                "Invalid padding format: '{}'. Expected 1, 2, or 4 values",
270                s
271            )),
272        }
273    }
274}
275
276/// Widget alignment on cross-axis
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278pub enum Alignment {
279    /// Top for column, left for row
280    Start,
281    /// Centered
282    Center,
283    /// Bottom for column, right for row
284    End,
285    /// Fill cross-axis
286    Stretch,
287}
288
289impl Alignment {
290    /// Parse from string
291    pub fn parse(s: &str) -> Result<Self, String> {
292        match s.trim().to_lowercase().as_str() {
293            "start" => Ok(Alignment::Start),
294            "center" => Ok(Alignment::Center),
295            "end" => Ok(Alignment::End),
296            "stretch" => Ok(Alignment::Stretch),
297            _ => Err(format!(
298                "Invalid alignment: '{}'. Expected start, center, end, or stretch",
299                s
300            )),
301        }
302    }
303}
304
305/// Widget justification on main-axis
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307pub enum Justification {
308    /// Pack at start
309    Start,
310    /// Pack at center
311    Center,
312    /// Pack at end
313    End,
314    /// First at start, last at end, evenly spaced
315    SpaceBetween,
316    /// Equal space around each item
317    SpaceAround,
318    /// Equal space between items
319    SpaceEvenly,
320}
321
322impl Justification {
323    /// Parse from string
324    pub fn parse(s: &str) -> Result<Self, String> {
325        match s.trim().to_lowercase().as_str() {
326            "start" => Ok(Justification::Start),
327            "center" => Ok(Justification::Center),
328            "end" => Ok(Justification::End),
329            "space_between" => Ok(Justification::SpaceBetween),
330            "space_around" => Ok(Justification::SpaceAround),
331            "space_evenly" => Ok(Justification::SpaceEvenly),
332            _ => Err(format!(
333                "Invalid justification: '{}'. Expected start, center, end, space_between, space_around, or space_evenly",
334                s
335            )),
336        }
337    }
338}
339
340/// Layout direction
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
342pub enum Direction {
343    Horizontal,
344    HorizontalReverse,
345    Vertical,
346    VerticalReverse,
347}
348
349impl Direction {
350    /// Parse from string
351    pub fn parse(s: &str) -> Result<Self, String> {
352        match s.trim().to_lowercase().as_str() {
353            "horizontal" => Ok(Direction::Horizontal),
354            "horizontal_reverse" => Ok(Direction::HorizontalReverse),
355            "vertical" => Ok(Direction::Vertical),
356            "vertical_reverse" => Ok(Direction::VerticalReverse),
357            _ => Err(format!(
358                "Invalid direction: '{}'. Expected horizontal, horizontal_reverse, vertical, or vertical_reverse",
359                s
360            )),
361        }
362    }
363}
364
365/// Position type for widget positioning
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
367pub enum Position {
368    /// Relative to normal flow (default)
369    Relative,
370    /// Absolute positioning relative to nearest positioned ancestor
371    Absolute,
372}
373
374impl Position {
375    /// Parse from string
376    pub fn parse(s: &str) -> Result<Self, String> {
377        match s.trim().to_lowercase().as_str() {
378            "relative" => Ok(Position::Relative),
379            "absolute" => Ok(Position::Absolute),
380            _ => Err(format!(
381                "Invalid position: '{}'. Expected relative or absolute",
382                s
383            )),
384        }
385    }
386}
387
388/// Responsive breakpoint
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
390pub enum Breakpoint {
391    /// < 640px
392    Mobile,
393    /// 640px - 1024px
394    Tablet,
395    /// >= 1024px
396    Desktop,
397}
398
399impl Breakpoint {
400    /// Determine breakpoint from viewport width
401    pub fn from_viewport_width(width: f32) -> Self {
402        match width {
403            w if w < 640.0 => Breakpoint::Mobile,
404            w if w < 1024.0 => Breakpoint::Tablet,
405            _ => Breakpoint::Desktop,
406        }
407    }
408
409    /// Parse from string
410    pub fn parse(s: &str) -> Result<Self, String> {
411        match s.trim().to_lowercase().as_str() {
412            "mobile" => Ok(Breakpoint::Mobile),
413            "tablet" => Ok(Breakpoint::Tablet),
414            "desktop" => Ok(Breakpoint::Desktop),
415            _ => Err(format!(
416                "Invalid breakpoint: '{}'. Expected mobile, tablet, or desktop",
417                s
418            )),
419        }
420    }
421}