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