tuxtui_core/
layout.rs

1//! Flexible constraint-based layout engine with caching.
2
3use crate::geometry::Rect;
4use alloc::vec::Vec;
5
6#[cfg(feature = "serde")]
7use serde::{Deserialize, Serialize};
8
9#[cfg(feature = "layout-cache")]
10use core::num::NonZeroUsize;
11#[cfg(feature = "layout-cache")]
12use lru::LruCache;
13
14/// Layout constraints for sizing components.
15///
16/// Constraints define how space should be distributed among layout elements.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
19pub enum Constraint {
20    /// Fixed length in cells
21    Length(u16),
22    /// Minimum length
23    Min(u16),
24    /// Maximum length
25    Max(u16),
26    /// Proportional fill with weight
27    Fill(u16),
28    /// Ratio of total space (numerator, denominator)
29    Ratio(u16, u16),
30    /// Percentage of total space (0-100)
31    Percentage(u16),
32}
33
34impl Constraint {
35    /// Apply this constraint to the given available space.
36    #[must_use]
37    pub fn apply(self, available: u16) -> u16 {
38        match self {
39            Self::Length(len) => len.min(available),
40            Self::Min(min) => min.min(available),
41            Self::Max(max) => available.min(max),
42            Self::Fill(_) => available,
43            Self::Ratio(num, den) => {
44                if den == 0 {
45                    0
46                } else {
47                    ((available as u32 * num as u32) / den as u32) as u16
48                }
49            }
50            Self::Percentage(pct) => ((available as u32 * pct as u32) / 100) as u16,
51        }
52    }
53}
54
55/// Flex layout modes for distributing space.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
58pub enum Flex {
59    /// Place items at the start
60    Start,
61    /// Center items
62    Center,
63    /// Place items at the end
64    End,
65    /// Distribute space evenly between items
66    SpaceBetween,
67    /// Distribute space evenly around items
68    SpaceAround,
69}
70
71impl Default for Flex {
72    fn default() -> Self {
73        Self::Start
74    }
75}
76
77/// Spacing between layout elements.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
80pub enum Spacing {
81    /// Positive spacing (gap)
82    Gap(u16),
83    /// Negative spacing (overlap)
84    Overlap(u16),
85}
86
87impl Default for Spacing {
88    fn default() -> Self {
89        Self::Gap(0)
90    }
91}
92
93/// Direction for layout flow.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
96pub enum Direction {
97    /// Horizontal layout (left to right)
98    Horizontal,
99    /// Vertical layout (top to bottom)
100    Vertical,
101}
102
103/// A layout engine for dividing rectangular areas using constraints.
104///
105/// # Example
106///
107/// ```
108/// use tuxtui_core::layout::{Layout, Constraint, Direction};
109/// use tuxtui_core::geometry::Rect;
110///
111/// let area = Rect::new(0, 0, 100, 50);
112/// let chunks = Layout::default()
113///     .direction(Direction::Vertical)
114///     .constraints([
115///         Constraint::Length(10),
116///         Constraint::Fill(1),
117///         Constraint::Length(5),
118///     ])
119///     .split(area);
120/// ```
121#[derive(Debug, Clone)]
122pub struct Layout {
123    direction: Direction,
124    constraints: Vec<Constraint>,
125    flex: Flex,
126    spacing: Spacing,
127    #[cfg(feature = "layout-cache")]
128    cache: Option<LruCache<LayoutCacheKey, Vec<Rect>>>,
129}
130
131impl Default for Layout {
132    fn default() -> Self {
133        Self {
134            direction: Direction::Vertical,
135            constraints: Vec::new(),
136            flex: Flex::default(),
137            spacing: Spacing::default(),
138            #[cfg(feature = "layout-cache")]
139            cache: None,
140        }
141    }
142}
143
144impl Layout {
145    /// Create a new layout.
146    #[must_use]
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    /// Set the layout direction.
152    #[must_use]
153    pub fn direction(mut self, direction: Direction) -> Self {
154        self.direction = direction;
155        self
156    }
157
158    /// Set the constraints.
159    #[must_use]
160    pub fn constraints<I>(mut self, constraints: I) -> Self
161    where
162        I: IntoIterator,
163        I::Item: Into<Constraint>,
164    {
165        self.constraints = constraints.into_iter().map(Into::into).collect();
166        self
167    }
168
169    /// Set the flex mode.
170    #[must_use]
171    pub fn flex(mut self, flex: Flex) -> Self {
172        self.flex = flex;
173        self
174    }
175
176    /// Set the spacing.
177    #[must_use]
178    pub fn spacing(mut self, spacing: Spacing) -> Self {
179        self.spacing = spacing;
180        self
181    }
182
183    /// Enable caching with the given capacity.
184    #[cfg(feature = "layout-cache")]
185    #[must_use]
186    pub fn cache(mut self, capacity: NonZeroUsize) -> Self {
187        self.cache = Some(LruCache::new(capacity));
188        self
189    }
190
191    /// Create a horizontal layout with the given constraints.
192    ///
193    /// This is a convenience method for `Layout::default().direction(Direction::Horizontal).constraints(...)`.
194    #[must_use]
195    pub fn horizontal<I>(constraints: I) -> Self
196    where
197        I: IntoIterator,
198        I::Item: Into<Constraint>,
199    {
200        Self::default()
201            .direction(Direction::Horizontal)
202            .constraints(constraints)
203    }
204
205    /// Create a vertical layout with the given constraints.
206    ///
207    /// This is a convenience method for `Layout::default().direction(Direction::Vertical).constraints(...)`.
208    #[must_use]
209    pub fn vertical<I>(constraints: I) -> Self
210    where
211        I: IntoIterator,
212        I::Item: Into<Constraint>,
213    {
214        Self::default()
215            .direction(Direction::Vertical)
216            .constraints(constraints)
217    }
218
219    /// Split the given area according to the constraints.
220    ///
221    /// Returns a vector of rectangles, one for each constraint.
222    #[must_use]
223    pub fn split(&mut self, area: Rect) -> Vec<Rect> {
224        #[cfg(feature = "layout-cache")]
225        {
226            let key = LayoutCacheKey {
227                area,
228                direction: self.direction,
229                constraints: self.constraints.clone(),
230                flex: self.flex,
231                spacing: self.spacing,
232            };
233
234            if let Some(cache) = &mut self.cache {
235                if let Some(rects) = cache.get(&key) {
236                    return rects.clone();
237                }
238            }
239
240            let rects = self.calculate_layout(area);
241
242            if let Some(cache) = &mut self.cache {
243                cache.put(key, rects.clone());
244            }
245
246            rects
247        }
248
249        #[cfg(not(feature = "layout-cache"))]
250        self.calculate_layout(area)
251    }
252
253    fn calculate_layout(&self, area: Rect) -> Vec<Rect> {
254        if self.constraints.is_empty() {
255            return Vec::new();
256        }
257
258        let (total_space, cross_size) = match self.direction {
259            Direction::Horizontal => (area.width, area.height),
260            Direction::Vertical => (area.height, area.width),
261        };
262
263        // Calculate sizes based on constraints
264        let mut sizes = Vec::with_capacity(self.constraints.len());
265        let mut fixed_space = 0u16;
266        let mut fill_weights = 0u32;
267
268        // First pass: calculate fixed sizes and count fill weights
269        for constraint in &self.constraints {
270            match constraint {
271                Constraint::Length(len) => {
272                    sizes.push(*len);
273                    fixed_space = fixed_space.saturating_add(*len);
274                }
275                Constraint::Min(min) => {
276                    sizes.push(*min);
277                    fixed_space = fixed_space.saturating_add(*min);
278                }
279                Constraint::Max(max) => {
280                    sizes.push(total_space.min(*max));
281                    fixed_space = fixed_space.saturating_add(total_space.min(*max));
282                }
283                Constraint::Ratio(num, den) => {
284                    let size = if *den == 0 {
285                        0
286                    } else {
287                        ((total_space as u32 * *num as u32) / *den as u32) as u16
288                    };
289                    sizes.push(size);
290                    fixed_space = fixed_space.saturating_add(size);
291                }
292                Constraint::Percentage(pct) => {
293                    let size = ((total_space as u32 * *pct as u32) / 100) as u16;
294                    sizes.push(size);
295                    fixed_space = fixed_space.saturating_add(size);
296                }
297                Constraint::Fill(weight) => {
298                    sizes.push(0); // Placeholder
299                    fill_weights += *weight as u32;
300                }
301            }
302        }
303
304        // Calculate spacing adjustments
305        let spacing_total = if self.constraints.len() > 1 {
306            match self.spacing {
307                Spacing::Gap(gap) => gap.saturating_mul((self.constraints.len() - 1) as u16),
308                Spacing::Overlap(overlap) => {
309                    0u16.saturating_sub(overlap.saturating_mul((self.constraints.len() - 1) as u16))
310                }
311            }
312        } else {
313            0
314        };
315
316        let available_for_fill = total_space
317            .saturating_sub(fixed_space)
318            .saturating_sub(spacing_total);
319
320        // Second pass: distribute remaining space to Fill constraints
321        if fill_weights > 0 {
322            for (i, constraint) in self.constraints.iter().enumerate() {
323                if let Constraint::Fill(weight) = constraint {
324                    let fill_size =
325                        ((available_for_fill as u32 * *weight as u32) / fill_weights) as u16;
326                    sizes[i] = fill_size;
327                }
328            }
329        }
330
331        // Build rectangles with flex
332        let used_space: u16 = sizes.iter().sum();
333        let flex_space = total_space.saturating_sub(used_space);
334
335        let (mut x, mut y) = match self.flex {
336            Flex::Start => (area.x, area.y),
337            Flex::Center => match self.direction {
338                Direction::Horizontal => (area.x + flex_space / 2, area.y),
339                Direction::Vertical => (area.x, area.y + flex_space / 2),
340            },
341            Flex::End => match self.direction {
342                Direction::Horizontal => (area.x + flex_space, area.y),
343                Direction::Vertical => (area.x, area.y + flex_space),
344            },
345            Flex::SpaceBetween | Flex::SpaceAround => (area.x, area.y),
346        };
347
348        let mut rects = Vec::with_capacity(self.constraints.len());
349        let gap = match self.spacing {
350            Spacing::Gap(g) => g,
351            Spacing::Overlap(o) => 0u16.saturating_sub(o),
352        };
353
354        for size in sizes {
355            let rect = match self.direction {
356                Direction::Horizontal => Rect::new(x, y, size, cross_size),
357                Direction::Vertical => Rect::new(x, y, cross_size, size),
358            };
359            rects.push(rect);
360
361            match self.direction {
362                Direction::Horizontal => x = x.saturating_add(size).saturating_add(gap),
363                Direction::Vertical => y = y.saturating_add(size).saturating_add(gap),
364            }
365        }
366
367        rects
368    }
369}
370
371#[cfg(feature = "layout-cache")]
372#[derive(Debug, Clone, PartialEq, Eq, Hash)]
373struct LayoutCacheKey {
374    area: Rect,
375    direction: Direction,
376    constraints: Vec<Constraint>,
377    flex: Flex,
378    spacing: Spacing,
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_constraint_apply() {
387        assert_eq!(Constraint::Length(10).apply(100), 10);
388        assert_eq!(Constraint::Min(50).apply(100), 50);
389        assert_eq!(Constraint::Max(50).apply(100), 50);
390        assert_eq!(Constraint::Ratio(1, 2).apply(100), 50);
391        assert_eq!(Constraint::Percentage(50).apply(100), 50);
392    }
393
394    #[test]
395    fn test_layout_split() {
396        let area = Rect::new(0, 0, 100, 100);
397        let mut layout = Layout::default()
398            .direction(Direction::Vertical)
399            .constraints([
400                Constraint::Length(10),
401                Constraint::Fill(1),
402                Constraint::Length(20),
403            ]);
404
405        let rects = layout.split(area);
406        assert_eq!(rects.len(), 3);
407        assert_eq!(rects[0].height, 10);
408        assert_eq!(rects[2].height, 20);
409    }
410
411    #[test]
412    fn test_layout_horizontal() {
413        let area = Rect::new(0, 0, 100, 50);
414        let mut layout = Layout::default()
415            .direction(Direction::Horizontal)
416            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]);
417
418        let rects = layout.split(area);
419        assert_eq!(rects.len(), 2);
420        assert_eq!(rects[0].width, 50);
421        assert_eq!(rects[1].width, 50);
422    }
423}