Skip to main content

presentar_terminal/widgets/
layout.rs

1//! Layout system for compositional widget arrangement.
2//!
3//! Provides `Layout::rows()` and `Layout::columns()` for building UI hierarchies.
4//! Supports percentage-based sizing, fixed sizes, and flex expansion.
5
6use presentar_core::{
7    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Constraints, Event,
8    LayoutResult, Rect, Size, TypeId, Widget,
9};
10use std::any::Any;
11use std::time::Duration;
12
13/// Size specification for layout children.
14#[derive(Debug, Clone, Copy)]
15pub enum SizeSpec {
16    /// Fixed size in terminal cells.
17    Fixed(f32),
18    /// Percentage of parent (0.0 - 100.0).
19    Percent(f32),
20    /// Expand to fill remaining space (with weight).
21    Flex(f32),
22    /// Use child's natural size.
23    Auto,
24}
25
26impl Default for SizeSpec {
27    fn default() -> Self {
28        Self::Flex(1.0)
29    }
30}
31
32/// A single item in a layout with its size specification.
33pub struct LayoutItem {
34    widget: Box<dyn Widget>,
35    size: SizeSpec,
36}
37
38impl std::fmt::Debug for LayoutItem {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("LayoutItem")
41            .field("widget", &"..")
42            .field("size", &self.size)
43            .finish()
44    }
45}
46
47impl LayoutItem {
48    /// Create a new layout item wrapping a widget.
49    pub fn new(widget: impl Widget + 'static) -> Self {
50        Self {
51            widget: Box::new(widget),
52            size: SizeSpec::default(),
53        }
54    }
55
56    /// Set fixed height/width.
57    #[must_use]
58    pub fn fixed(mut self, size: f32) -> Self {
59        self.size = SizeSpec::Fixed(size);
60        self
61    }
62
63    /// Set percentage of parent.
64    #[must_use]
65    pub fn percent(mut self, pct: f32) -> Self {
66        self.size = SizeSpec::Percent(pct);
67        self
68    }
69
70    /// Expand to fill remaining space.
71    #[must_use]
72    pub fn expanded(mut self) -> Self {
73        self.size = SizeSpec::Flex(1.0);
74        self
75    }
76
77    /// Expand with specific weight.
78    #[must_use]
79    pub fn flex(mut self, weight: f32) -> Self {
80        self.size = SizeSpec::Flex(weight);
81        self
82    }
83
84    /// Use child's natural size.
85    #[must_use]
86    pub fn auto(mut self) -> Self {
87        self.size = SizeSpec::Auto;
88        self
89    }
90}
91
92/// Layout direction.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Direction {
95    /// Stack children vertically (rows).
96    Vertical,
97    /// Stack children horizontally (columns).
98    Horizontal,
99}
100
101/// Compositional layout widget.
102///
103/// # Example
104/// ```ignore
105/// Layout::rows([
106///     LayoutItem::new(header).fixed(1),
107///     Layout::columns([
108///         LayoutItem::new(sidebar).percent(25.0),
109///         LayoutItem::new(content).expanded(),
110///     ]).into_item().expanded(),
111///     LayoutItem::new(footer).fixed(1),
112/// ])
113/// ```
114pub struct Layout {
115    direction: Direction,
116    children: Vec<LayoutItem>,
117    bounds: Rect,
118    /// Cached child bounds after layout.
119    child_bounds: Vec<Rect>,
120}
121
122impl std::fmt::Debug for Layout {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct("Layout")
125            .field("direction", &self.direction)
126            .field("children", &self.children.len())
127            .field("bounds", &self.bounds)
128            .field("child_bounds", &self.child_bounds)
129            .finish()
130    }
131}
132
133impl Layout {
134    /// Create a vertical layout (rows stacked top to bottom).
135    pub fn rows(items: impl IntoIterator<Item = LayoutItem>) -> Self {
136        Self {
137            direction: Direction::Vertical,
138            children: items.into_iter().collect(),
139            bounds: Rect::default(),
140            child_bounds: Vec::new(),
141        }
142    }
143
144    /// Create a horizontal layout (columns side by side).
145    pub fn columns(items: impl IntoIterator<Item = LayoutItem>) -> Self {
146        Self {
147            direction: Direction::Horizontal,
148            children: items.into_iter().collect(),
149            bounds: Rect::default(),
150            child_bounds: Vec::new(),
151        }
152    }
153
154    /// Convert this layout into a `LayoutItem` for nesting.
155    #[must_use]
156    pub fn into_item(self) -> LayoutItem {
157        LayoutItem::new(self)
158    }
159
160    /// Add a child item.
161    pub fn push(&mut self, item: LayoutItem) {
162        self.children.push(item);
163    }
164
165    /// Get child count.
166    #[must_use]
167    pub fn len(&self) -> usize {
168        self.children.len()
169    }
170
171    /// Check if empty.
172    #[must_use]
173    pub fn is_empty(&self) -> bool {
174        self.children.is_empty()
175    }
176
177    /// Calculate sizes for all children given total available space.
178    fn calculate_sizes(&self, total: f32) -> Vec<f32> {
179        let mut sizes = vec![0.0; self.children.len()];
180        let mut remaining = total;
181        let mut flex_total = 0.0;
182
183        // First pass: allocate fixed and percentage sizes
184        for (i, item) in self.children.iter().enumerate() {
185            match item.size {
186                SizeSpec::Fixed(s) => {
187                    sizes[i] = s.min(remaining);
188                    remaining -= sizes[i];
189                }
190                SizeSpec::Percent(pct) => {
191                    sizes[i] = (total * pct / 100.0).min(remaining);
192                    remaining -= sizes[i];
193                }
194                SizeSpec::Auto => {
195                    // For auto, we'll use a reasonable default or measure
196                    // For now, treat as small fixed
197                    sizes[i] = 1.0_f32.min(remaining);
198                    remaining -= sizes[i];
199                }
200                SizeSpec::Flex(weight) => {
201                    flex_total += weight;
202                }
203            }
204        }
205
206        // Second pass: distribute remaining space to flex items
207        if flex_total > 0.0 && remaining > 0.0 {
208            for (i, item) in self.children.iter().enumerate() {
209                if let SizeSpec::Flex(weight) = item.size {
210                    sizes[i] = remaining * (weight / flex_total);
211                }
212            }
213        }
214
215        sizes
216    }
217}
218
219impl Brick for Layout {
220    fn brick_name(&self) -> &'static str {
221        "layout"
222    }
223
224    fn assertions(&self) -> &[BrickAssertion] {
225        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(8)];
226        ASSERTIONS
227    }
228
229    fn budget(&self) -> BrickBudget {
230        BrickBudget::uniform(8)
231    }
232
233    fn verify(&self) -> BrickVerification {
234        BrickVerification {
235            passed: vec![BrickAssertion::max_latency_ms(8)],
236            failed: vec![],
237            verification_time: Duration::from_micros(10),
238        }
239    }
240
241    fn to_html(&self) -> String {
242        String::new()
243    }
244
245    fn to_css(&self) -> String {
246        String::new()
247    }
248}
249
250impl Widget for Layout {
251    fn type_id(&self) -> TypeId {
252        TypeId::of::<Self>()
253    }
254
255    fn measure(&self, constraints: Constraints) -> Size {
256        constraints.constrain(Size::new(constraints.max_width, constraints.max_height))
257    }
258
259    fn layout(&mut self, bounds: Rect) -> LayoutResult {
260        self.bounds = bounds;
261        self.child_bounds.clear();
262
263        let total = match self.direction {
264            Direction::Vertical => bounds.height,
265            Direction::Horizontal => bounds.width,
266        };
267
268        let sizes = self.calculate_sizes(total);
269        let mut offset = 0.0;
270
271        for (i, item) in self.children.iter_mut().enumerate() {
272            let size = sizes[i];
273            let child_bounds = match self.direction {
274                Direction::Vertical => Rect::new(bounds.x, bounds.y + offset, bounds.width, size),
275                Direction::Horizontal => {
276                    Rect::new(bounds.x + offset, bounds.y, size, bounds.height)
277                }
278            };
279
280            self.child_bounds.push(child_bounds);
281            item.widget.layout(child_bounds);
282            offset += size;
283        }
284
285        LayoutResult {
286            size: Size::new(bounds.width, bounds.height),
287        }
288    }
289
290    fn paint(&self, canvas: &mut dyn Canvas) {
291        for (i, item) in self.children.iter().enumerate() {
292            if i < self.child_bounds.len() {
293                // Push clip for child bounds
294                canvas.push_clip(self.child_bounds[i]);
295                item.widget.paint(canvas);
296                canvas.pop_clip();
297            }
298        }
299    }
300
301    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
302        // Propagate to all children
303        for item in &mut self.children {
304            if let Some(result) = item.widget.event(event) {
305                return Some(result);
306            }
307        }
308        None
309    }
310
311    fn children(&self) -> &[Box<dyn Widget>] {
312        // Can't return slice of nested items easily
313        &[]
314    }
315
316    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
317        &mut []
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::widgets::Border;
325
326    #[test]
327    fn test_layout_rows_empty() {
328        let layout = Layout::rows([]);
329        assert!(layout.is_empty());
330    }
331
332    #[test]
333    fn test_layout_columns_empty() {
334        let layout = Layout::columns([]);
335        assert!(layout.is_empty());
336    }
337
338    #[test]
339    fn test_layout_item_fixed() {
340        let item = LayoutItem::new(Border::new()).fixed(10.0);
341        assert!(matches!(item.size, SizeSpec::Fixed(10.0)));
342    }
343
344    #[test]
345    fn test_layout_item_percent() {
346        let item = LayoutItem::new(Border::new()).percent(25.0);
347        assert!(matches!(item.size, SizeSpec::Percent(25.0)));
348    }
349
350    #[test]
351    fn test_layout_item_expanded() {
352        let item = LayoutItem::new(Border::new()).expanded();
353        assert!(matches!(item.size, SizeSpec::Flex(1.0)));
354    }
355
356    #[test]
357    fn test_layout_item_flex() {
358        let item = LayoutItem::new(Border::new()).flex(2.0);
359        assert!(matches!(item.size, SizeSpec::Flex(2.0)));
360    }
361
362    #[test]
363    fn test_layout_calculate_sizes_fixed() {
364        let layout = Layout::rows([
365            LayoutItem::new(Border::new()).fixed(10.0),
366            LayoutItem::new(Border::new()).fixed(20.0),
367        ]);
368        let sizes = layout.calculate_sizes(100.0);
369        assert_eq!(sizes[0], 10.0);
370        assert_eq!(sizes[1], 20.0);
371    }
372
373    #[test]
374    fn test_layout_calculate_sizes_percent() {
375        let layout = Layout::rows([
376            LayoutItem::new(Border::new()).percent(25.0),
377            LayoutItem::new(Border::new()).percent(75.0),
378        ]);
379        let sizes = layout.calculate_sizes(100.0);
380        assert_eq!(sizes[0], 25.0);
381        assert_eq!(sizes[1], 75.0);
382    }
383
384    #[test]
385    fn test_layout_calculate_sizes_flex() {
386        let layout = Layout::rows([
387            LayoutItem::new(Border::new()).flex(1.0),
388            LayoutItem::new(Border::new()).flex(3.0),
389        ]);
390        let sizes = layout.calculate_sizes(100.0);
391        assert_eq!(sizes[0], 25.0);
392        assert_eq!(sizes[1], 75.0);
393    }
394
395    #[test]
396    fn test_layout_calculate_sizes_mixed() {
397        let layout = Layout::rows([
398            LayoutItem::new(Border::new()).fixed(20.0),
399            LayoutItem::new(Border::new()).expanded(),
400        ]);
401        let sizes = layout.calculate_sizes(100.0);
402        assert_eq!(sizes[0], 20.0);
403        assert_eq!(sizes[1], 80.0);
404    }
405
406    #[test]
407    fn test_layout_nested() {
408        let inner = Layout::columns([
409            LayoutItem::new(Border::new()).percent(50.0),
410            LayoutItem::new(Border::new()).percent(50.0),
411        ]);
412        let outer = Layout::rows([
413            LayoutItem::new(Border::new()).fixed(5.0),
414            inner.into_item().expanded(),
415        ]);
416        assert_eq!(outer.len(), 2);
417    }
418
419    #[test]
420    fn test_layout_push() {
421        let mut layout = Layout::rows([]);
422        layout.push(LayoutItem::new(Border::new()));
423        assert_eq!(layout.len(), 1);
424    }
425
426    #[test]
427    fn test_layout_brick_name() {
428        let layout = Layout::rows([]);
429        assert_eq!(layout.brick_name(), "layout");
430    }
431
432    #[test]
433    fn test_layout_verify() {
434        let layout = Layout::rows([]);
435        assert!(layout.verify().is_valid());
436    }
437
438    #[test]
439    fn test_layout_measure() {
440        let layout = Layout::rows([]);
441        let size = layout.measure(Constraints::new(0.0, 100.0, 0.0, 50.0));
442        assert_eq!(size.width, 100.0);
443        assert_eq!(size.height, 50.0);
444    }
445
446    #[test]
447    fn test_layout_vertical_layout() {
448        let mut layout = Layout::rows([
449            LayoutItem::new(Border::new()).fixed(10.0),
450            LayoutItem::new(Border::new()).fixed(20.0),
451        ]);
452        layout.layout(Rect::new(0.0, 0.0, 80.0, 40.0));
453        assert_eq!(layout.child_bounds.len(), 2);
454        assert_eq!(layout.child_bounds[0].y, 0.0);
455        assert_eq!(layout.child_bounds[0].height, 10.0);
456        assert_eq!(layout.child_bounds[1].y, 10.0);
457        assert_eq!(layout.child_bounds[1].height, 20.0);
458    }
459
460    #[test]
461    fn test_layout_horizontal_layout() {
462        let mut layout = Layout::columns([
463            LayoutItem::new(Border::new()).fixed(20.0),
464            LayoutItem::new(Border::new()).fixed(30.0),
465        ]);
466        layout.layout(Rect::new(0.0, 0.0, 80.0, 40.0));
467        assert_eq!(layout.child_bounds.len(), 2);
468        assert_eq!(layout.child_bounds[0].x, 0.0);
469        assert_eq!(layout.child_bounds[0].width, 20.0);
470        assert_eq!(layout.child_bounds[1].x, 20.0);
471        assert_eq!(layout.child_bounds[1].width, 30.0);
472    }
473
474    #[test]
475    fn test_size_spec_default() {
476        let spec = SizeSpec::default();
477        assert!(matches!(spec, SizeSpec::Flex(1.0)));
478    }
479
480    #[test]
481    fn test_layout_item_auto() {
482        let item = LayoutItem::new(Border::new()).auto();
483        assert!(matches!(item.size, SizeSpec::Auto));
484    }
485
486    #[test]
487    fn test_layout_calculate_sizes_auto() {
488        let layout = Layout::rows([
489            LayoutItem::new(Border::new()).auto(),
490            LayoutItem::new(Border::new()).expanded(),
491        ]);
492        let sizes = layout.calculate_sizes(100.0);
493        // Auto gets 1.0 (minimum)
494        assert_eq!(sizes[0], 1.0);
495        // Flex gets the remaining
496        assert_eq!(sizes[1], 99.0);
497    }
498
499    #[test]
500    fn test_layout_brick_assertions() {
501        let layout = Layout::rows([]);
502        let assertions = layout.assertions();
503        assert!(!assertions.is_empty());
504    }
505
506    #[test]
507    fn test_layout_brick_budget() {
508        let layout = Layout::rows([]);
509        let budget = layout.budget();
510        assert_eq!(budget.total_ms, 8);
511    }
512
513    #[test]
514    fn test_layout_to_html() {
515        let layout = Layout::rows([]);
516        assert_eq!(layout.to_html(), "");
517    }
518
519    #[test]
520    fn test_layout_to_css() {
521        let layout = Layout::rows([]);
522        assert_eq!(layout.to_css(), "");
523    }
524
525    #[test]
526    fn test_layout_type_id() {
527        let layout = Layout::rows([]);
528        let id = Widget::type_id(&layout);
529        assert_eq!(id, TypeId::of::<Layout>());
530    }
531
532    #[test]
533    fn test_layout_paint() {
534        use crate::tools::HeadlessCanvas;
535        let mut layout = Layout::rows([
536            LayoutItem::new(Border::new()).fixed(10.0),
537            LayoutItem::new(Border::new()).fixed(10.0),
538        ]);
539        layout.layout(Rect::new(0.0, 0.0, 80.0, 24.0));
540        let mut canvas = HeadlessCanvas::new(80, 24);
541        layout.paint(&mut canvas);
542        // Painting should work without panic
543    }
544
545    #[test]
546    fn test_layout_event() {
547        use presentar_core::Event;
548        let mut layout = Layout::rows([LayoutItem::new(Border::new()).fixed(10.0)]);
549        let event = Event::Resize {
550            width: 80.0,
551            height: 24.0,
552        };
553        let result = layout.event(&event);
554        assert!(result.is_none()); // Border doesn't handle events
555    }
556
557    #[test]
558    fn test_layout_children() {
559        let layout = Layout::rows([LayoutItem::new(Border::new()).fixed(10.0)]);
560        // children() returns empty slice (items are wrapped)
561        assert!(layout.children().is_empty());
562    }
563
564    #[test]
565    fn test_layout_children_mut() {
566        let mut layout = Layout::rows([LayoutItem::new(Border::new()).fixed(10.0)]);
567        // children_mut() returns empty slice (items are wrapped)
568        assert!(layout.children_mut().is_empty());
569    }
570}