Skip to main content

cdx_core/presentation/
layout.rs

1//! Layout configuration for multi-column and grid layouts.
2
3use serde::{Deserialize, Serialize};
4
5/// Multi-column layout configuration.
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8pub struct ColumnLayout {
9    /// Number of columns.
10    pub columns: u32,
11
12    /// Gap between columns (e.g., "20px", "1em").
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub gap: Option<String>,
15
16    /// Whether to balance content across columns.
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub balance: Option<bool>,
19}
20
21impl ColumnLayout {
22    /// Create a new column layout.
23    #[must_use]
24    pub const fn new(columns: u32) -> Self {
25        Self {
26            columns,
27            gap: None,
28            balance: None,
29        }
30    }
31
32    /// Set the column gap.
33    #[must_use]
34    pub fn with_gap(mut self, gap: impl Into<String>) -> Self {
35        self.gap = Some(gap.into());
36        self
37    }
38
39    /// Enable column balancing.
40    #[must_use]
41    pub const fn balanced(mut self) -> Self {
42        self.balance = Some(true);
43        self
44    }
45}
46
47/// CSS Grid layout configuration.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct GridLayout {
51    /// Column definition (e.g., "12", "1fr 2fr", "repeat(3, 1fr)").
52    pub columns: String,
53
54    /// Row definition (e.g., "auto 1fr auto").
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub rows: Option<String>,
57
58    /// Gap between grid cells (e.g., "10px").
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub gap: Option<String>,
61
62    /// Named grid areas.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub areas: Option<Vec<GridArea>>,
65}
66
67impl GridLayout {
68    /// Create a new grid layout.
69    #[must_use]
70    pub fn new(columns: impl Into<String>) -> Self {
71        Self {
72            columns: columns.into(),
73            rows: None,
74            gap: None,
75            areas: None,
76        }
77    }
78
79    /// Set grid rows.
80    #[must_use]
81    pub fn with_rows(mut self, rows: impl Into<String>) -> Self {
82        self.rows = Some(rows.into());
83        self
84    }
85
86    /// Set the grid gap.
87    #[must_use]
88    pub fn with_gap(mut self, gap: impl Into<String>) -> Self {
89        self.gap = Some(gap.into());
90        self
91    }
92
93    /// Set named grid areas.
94    #[must_use]
95    pub fn with_areas(mut self, areas: Vec<GridArea>) -> Self {
96        self.areas = Some(areas);
97        self
98    }
99}
100
101/// A named area within a grid layout.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct GridArea {
105    /// Area name.
106    pub name: String,
107
108    /// Column span (e.g., "1 / 13", "span 6").
109    pub column: String,
110
111    /// Row span (e.g., "1 / 3", "span 2").
112    pub row: String,
113}
114
115impl GridArea {
116    /// Create a new grid area.
117    #[must_use]
118    pub fn new(name: impl Into<String>, column: impl Into<String>, row: impl Into<String>) -> Self {
119        Self {
120            name: name.into(),
121            column: column.into(),
122            row: row.into(),
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_column_layout_serde() {
133        let layout = ColumnLayout::new(2).with_gap("20px").balanced();
134        let json = serde_json::to_string_pretty(&layout).unwrap();
135        assert!(json.contains("\"columns\": 2"));
136        assert!(json.contains("\"gap\": \"20px\""));
137        assert!(json.contains("\"balance\": true"));
138
139        let parsed: ColumnLayout = serde_json::from_str(&json).unwrap();
140        assert_eq!(parsed, layout);
141    }
142
143    #[test]
144    fn test_grid_layout_serde() {
145        let layout = GridLayout::new("1fr 2fr")
146            .with_rows("auto 1fr")
147            .with_gap("10px");
148
149        let json = serde_json::to_string(&layout).unwrap();
150        assert!(json.contains("\"columns\":\"1fr 2fr\""));
151
152        let parsed: GridLayout = serde_json::from_str(&json).unwrap();
153        assert_eq!(parsed, layout);
154    }
155
156    #[test]
157    fn test_grid_area_serde() {
158        let area = GridArea::new("header", "1 / 13", "1 / 2");
159        let json = serde_json::to_string(&area).unwrap();
160        assert!(json.contains("\"name\":\"header\""));
161        assert!(json.contains("\"column\":\"1 / 13\""));
162
163        let parsed: GridArea = serde_json::from_str(&json).unwrap();
164        assert_eq!(parsed, area);
165    }
166
167    #[test]
168    fn test_grid_with_areas() {
169        let layout = GridLayout::new("repeat(12, 1fr)").with_areas(vec![
170            GridArea::new("header", "1 / 13", "1 / 2"),
171            GridArea::new("sidebar", "1 / 4", "2 / 3"),
172            GridArea::new("content", "4 / 13", "2 / 3"),
173        ]);
174
175        let json = serde_json::to_string_pretty(&layout).unwrap();
176        let parsed: GridLayout = serde_json::from_str(&json).unwrap();
177        assert_eq!(parsed, layout);
178        assert_eq!(parsed.areas.unwrap().len(), 3);
179    }
180
181    #[test]
182    fn test_column_layout_defaults() {
183        let json = r#"{"columns": 3}"#;
184        let layout: ColumnLayout = serde_json::from_str(json).unwrap();
185        assert_eq!(layout.columns, 3);
186        assert!(layout.gap.is_none());
187        assert!(layout.balance.is_none());
188    }
189}