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