Skip to main content

limit_tui/
layout.rs

1// Flexbox layout system for terminal UI
2
3use crate::vdom::VNode;
4use ratatui::layout::Rect;
5
6/// Main axis direction for flex layout
7#[derive(Debug, Clone, Copy, PartialEq, Default)]
8pub enum FlexDirection {
9    #[default]
10    Row,
11    Column,
12}
13
14/// Main axis alignment
15#[derive(Debug, Clone, Copy, PartialEq, Default)]
16pub enum JustifyContent {
17    #[default]
18    Start,
19    Center,
20    End,
21    SpaceBetween,
22    SpaceAround,
23}
24
25/// Cross axis alignment
26#[derive(Debug, Clone, Copy, PartialEq, Default)]
27pub enum AlignItems {
28    #[default]
29    Start,
30    Center,
31    End,
32    Stretch,
33}
34
35/// Flex layout style properties
36#[derive(Debug, Clone, Copy, Default)]
37pub struct FlexStyle {
38    pub direction: FlexDirection,
39    pub justify_content: JustifyContent,
40    pub align_items: AlignItems,
41    pub gap: u16,
42    pub flex_grow: f32,
43    pub flex_shrink: f32,
44}
45
46impl FlexStyle {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    pub fn row(mut self) -> Self {
52        self.direction = FlexDirection::Row;
53        self
54    }
55
56    pub fn column(mut self) -> Self {
57        self.direction = FlexDirection::Column;
58        self
59    }
60
61    pub fn justify(mut self, justify: JustifyContent) -> Self {
62        self.justify_content = justify;
63        self
64    }
65
66    pub fn align(mut self, align: AlignItems) -> Self {
67        self.align_items = align;
68        self
69    }
70
71    pub fn gap(mut self, gap: u16) -> Self {
72        self.gap = gap;
73        self
74    }
75
76    pub fn flex_grow(mut self, grow: f32) -> Self {
77        self.flex_grow = grow;
78        self
79    }
80
81    pub fn flex_shrink(mut self, shrink: f32) -> Self {
82        self.flex_shrink = shrink;
83        self
84    }
85}
86
87/// Flexbox layout engine
88#[derive(Debug, Clone, Copy, Default)]
89pub struct FlexboxLayout;
90
91impl FlexboxLayout {
92    pub fn new() -> Self {
93        Self
94    }
95
96    /// Calculate child positions for a flex container
97    pub fn calculate(node: &VNode, constraints: Rect) -> Vec<Rect> {
98        let children = match node.children() {
99            Some(children) if !children.is_empty() => children,
100            _ => return vec![],
101        };
102
103        // Extract flex style from node attributes
104        let style = Self::extract_style(node);
105
106        // Get base sizes for children
107        let base_sizes: Vec<Rect> = children
108            .iter()
109            .map(|_| Rect {
110                x: 0,
111                y: 0,
112                width: 1,  // Default minimum width
113                height: 1, // Default minimum height
114            })
115            .collect();
116
117        Self::layout_children(&base_sizes, constraints, &style, children.len())
118    }
119
120    /// Extract flex style from node attributes
121    fn extract_style(node: &VNode) -> FlexStyle {
122        let attrs = node.attrs().unwrap();
123
124        FlexStyle {
125            direction: attrs
126                .get("direction")
127                .and_then(|v| match v.as_str() {
128                    "row" => Some(FlexDirection::Row),
129                    "column" => Some(FlexDirection::Column),
130                    _ => None,
131                })
132                .unwrap_or(FlexDirection::Row),
133
134            justify_content: attrs
135                .get("justify")
136                .and_then(|v| match v.as_str() {
137                    "start" => Some(JustifyContent::Start),
138                    "center" => Some(JustifyContent::Center),
139                    "end" => Some(JustifyContent::End),
140                    "space-between" => Some(JustifyContent::SpaceBetween),
141                    "space-around" => Some(JustifyContent::SpaceAround),
142                    _ => None,
143                })
144                .unwrap_or(JustifyContent::Start),
145
146            align_items: attrs
147                .get("align")
148                .and_then(|v| match v.as_str() {
149                    "start" => Some(AlignItems::Start),
150                    "center" => Some(AlignItems::Center),
151                    "end" => Some(AlignItems::End),
152                    "stretch" => Some(AlignItems::Stretch),
153                    _ => None,
154                })
155                .unwrap_or(AlignItems::Start),
156
157            gap: attrs.get("gap").and_then(|v| v.parse().ok()).unwrap_or(0),
158
159            flex_grow: attrs
160                .get("flex-grow")
161                .and_then(|v| v.parse().ok())
162                .unwrap_or(0.0),
163
164            flex_shrink: attrs
165                .get("flex-shrink")
166                .and_then(|v| v.parse().ok())
167                .unwrap_or(1.0),
168        }
169    }
170
171    /// Layout children based on flex properties
172    fn layout_children(
173        _base_sizes: &[Rect],
174        constraints: Rect,
175        style: &FlexStyle,
176        child_count: usize,
177    ) -> Vec<Rect> {
178        if child_count == 0 {
179            return vec![];
180        }
181
182        let main_axis_size = match style.direction {
183            FlexDirection::Row => constraints.width,
184            FlexDirection::Column => constraints.height,
185        };
186
187        let cross_axis_size = match style.direction {
188            FlexDirection::Row => constraints.height,
189            FlexDirection::Column => constraints.width,
190        };
191
192        // Calculate total gap space
193        let total_gap = style
194            .gap
195            .saturating_mul(child_count.saturating_sub(1) as u16);
196        let available_space = main_axis_size.saturating_sub(total_gap);
197
198        // Distribute main axis space
199        let main_positions =
200            Self::distribute_main_axis(available_space, style, child_count, main_axis_size);
201
202        // Calculate cross axis positions
203        let cross_positions =
204            Self::distribute_cross_axis(cross_axis_size, &style.align_items, child_count);
205
206        // Build final rects
207        let mut results = Vec::with_capacity(child_count);
208
209        for i in 0..child_count {
210            let child_width = match style.direction {
211                FlexDirection::Row => main_positions[i].size,
212                FlexDirection::Column => cross_axis_size,
213            };
214
215            let child_height = match style.direction {
216                FlexDirection::Row => cross_axis_size,
217                FlexDirection::Column => main_positions[i].size,
218            };
219
220            let x = match style.direction {
221                FlexDirection::Row => constraints.x + main_positions[i].pos,
222                FlexDirection::Column => constraints.x + cross_positions[i].pos,
223            };
224
225            let y = match style.direction {
226                FlexDirection::Row => constraints.y + cross_positions[i].pos,
227                FlexDirection::Column => constraints.y + main_positions[i].pos,
228            };
229
230            results.push(Rect {
231                x,
232                y,
233                width: child_width,
234                height: child_height,
235            });
236        }
237
238        results
239    }
240
241    /// Distribute space along main axis
242    fn distribute_main_axis(
243        available_space: u16,
244        style: &FlexStyle,
245        child_count: usize,
246        _container_size: u16,
247    ) -> Vec<PositionInfo> {
248        let mut positions = Vec::with_capacity(child_count);
249
250        if child_count == 0 {
251            return positions;
252        }
253
254        // Calculate gap space
255        let gap_space = style
256            .gap
257            .saturating_mul(child_count.saturating_sub(1) as u16);
258        let usable_space = available_space.saturating_sub(gap_space);
259
260        // Distribute space based on flex_grow
261        let total_flex_grow = if style.flex_grow > 0.0 {
262            style.flex_grow * child_count as f32
263        } else {
264            child_count as f32 // Default to equal distribution
265        };
266
267        // Calculate base size for each child
268        let base_size = if total_flex_grow > 0.0 {
269            (usable_space as f32 / total_flex_grow) as u16
270        } else {
271            0
272        };
273
274        // Calculate positions based on justify_content
275        let total_used = base_size
276            .saturating_mul(child_count as u16)
277            .saturating_add(gap_space);
278
279        let mut current_pos = match style.justify_content {
280            JustifyContent::Start => 0,
281            JustifyContent::Center => available_space.saturating_sub(total_used) / 2,
282            JustifyContent::End => available_space.saturating_sub(total_used),
283            JustifyContent::SpaceBetween => {
284                if child_count > 1 {
285                    let _extra_gap = (usable_space
286                        .saturating_sub(base_size.saturating_mul(child_count as u16)))
287                    .saturating_div(child_count.saturating_sub(1) as u16);
288                    0
289                } else {
290                    0
291                }
292            }
293            JustifyContent::SpaceAround => {
294                let extra_gap = (usable_space
295                    .saturating_sub(base_size.saturating_mul(child_count as u16)))
296                .saturating_div(child_count as u16);
297                extra_gap / 2
298            }
299        };
300
301        let mut gap = style.gap;
302
303        if style.justify_content == JustifyContent::SpaceBetween && child_count > 1 {
304            let extra = usable_space.saturating_sub(base_size.saturating_mul(child_count as u16));
305            gap = gap.saturating_add(extra / (child_count.saturating_sub(1) as u16));
306        }
307
308        if style.justify_content == JustifyContent::SpaceAround {
309            let extra = usable_space.saturating_sub(base_size.saturating_mul(child_count as u16));
310            let extra_gap = extra / (child_count as u16);
311            gap = extra_gap;
312        }
313
314        for _i in 0..child_count {
315            positions.push(PositionInfo {
316                pos: current_pos,
317                size: base_size,
318            });
319
320            current_pos = current_pos.saturating_add(base_size).saturating_add(gap);
321        }
322
323        positions
324    }
325
326    /// Distribute positions along cross axis
327    fn distribute_cross_axis(
328        cross_axis_size: u16,
329        style: &AlignItems,
330        child_count: usize,
331    ) -> Vec<PositionInfo> {
332        let mut positions = Vec::with_capacity(child_count);
333
334        if child_count == 0 {
335            return positions;
336        }
337
338        // Default to full cross axis size for each child
339        let child_size = cross_axis_size;
340
341        for _i in 0..child_count {
342            let pos = match style {
343                AlignItems::Start => 0,
344                AlignItems::Center => 0, // Centered by taking full size
345                AlignItems::End => 0,    // Aligned by taking full size
346                AlignItems::Stretch => 0,
347            };
348
349            positions.push(PositionInfo {
350                pos,
351                size: child_size,
352            });
353        }
354
355        positions
356    }
357}
358
359/// Helper struct for position calculations
360#[derive(Debug, Clone, Copy)]
361struct PositionInfo {
362    pos: u16,
363    size: u16,
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::vdom::VNode;
370    use std::collections::HashMap;
371
372    fn create_flex_container(attrs: HashMap<String, String>, children: Vec<VNode>) -> VNode {
373        VNode::Element {
374            tag: "flex".to_string(),
375            attrs,
376            children,
377        }
378    }
379
380    fn create_text_node(text: &str) -> VNode {
381        VNode::Text(text.to_string())
382    }
383
384    #[test]
385    fn test_layout_simple_row() {
386        let children = vec![
387            create_text_node("A"),
388            create_text_node("B"),
389            create_text_node("C"),
390        ];
391
392        let container = create_flex_container(HashMap::new(), children);
393
394        let constraints = Rect {
395            x: 0,
396            y: 0,
397            width: 20,
398            height: 10,
399        };
400
401        let results = FlexboxLayout::calculate(&container, constraints);
402
403        assert_eq!(results.len(), 3);
404
405        // Verify horizontal layout (x positions increase)
406        assert!(results[0].x < results[1].x);
407        assert!(results[1].x < results[2].x);
408
409        // All children have same height (full container height)
410        assert_eq!(results[0].height, 10);
411        assert_eq!(results[1].height, 10);
412        assert_eq!(results[2].height, 10);
413
414        // All have same y position
415        assert_eq!(results[0].y, 0);
416        assert_eq!(results[1].y, 0);
417        assert_eq!(results[2].y, 0);
418    }
419
420    #[test]
421    fn test_layout_simple_column() {
422        let children = vec![
423            create_text_node("A"),
424            create_text_node("B"),
425            create_text_node("C"),
426        ];
427
428        let mut attrs = HashMap::new();
429        attrs.insert("direction".to_string(), "column".to_string());
430
431        let container = create_flex_container(attrs, children);
432
433        let constraints = Rect {
434            x: 0,
435            y: 0,
436            width: 10,
437            height: 20,
438        };
439
440        let results = FlexboxLayout::calculate(&container, constraints);
441
442        assert_eq!(results.len(), 3);
443
444        // Verify vertical layout (y positions increase)
445        assert!(results[0].y < results[1].y);
446        assert!(results[1].y < results[2].y);
447
448        // All children have same width (full container width)
449        assert_eq!(results[0].width, 10);
450        assert_eq!(results[1].width, 10);
451        assert_eq!(results[2].width, 10);
452
453        // All have same x position
454        assert_eq!(results[0].x, 0);
455        assert_eq!(results[1].x, 0);
456        assert_eq!(results[2].x, 0);
457    }
458
459    #[test]
460    fn test_layout_justify_center() {
461        let children = vec![
462            create_text_node("A"),
463            create_text_node("B"),
464            create_text_node("C"),
465        ];
466
467        let mut attrs = HashMap::new();
468        attrs.insert("justify".to_string(), "center".to_string());
469
470        let container = create_flex_container(attrs, children);
471
472        let constraints = Rect {
473            x: 0,
474            y: 0,
475            width: 20,
476            height: 10,
477        };
478
479        let results = FlexboxLayout::calculate(&container, constraints);
480
481        assert_eq!(results.len(), 3);
482
483        // First child should start at center position (offset > 0)
484        let total_width = results[2].x + results[2].width;
485        assert!(total_width <= 20);
486        // With centering, first child should have some positive offset
487        assert!(results[0].x > 0 || total_width == 20);
488    }
489
490    #[test]
491    fn test_layout_flex_grow() {
492        let children = vec![
493            create_text_node("A"),
494            create_text_node("B"),
495            create_text_node("C"),
496        ];
497
498        let mut attrs = HashMap::new();
499        attrs.insert("flex-grow".to_string(), "1.0".to_string());
500
501        let container = create_flex_container(attrs, children);
502
503        let constraints = Rect {
504            x: 0,
505            y: 0,
506            width: 20,
507            height: 10,
508        };
509        let results = FlexboxLayout::calculate(&container, constraints);
510
511        assert_eq!(results.len(), 3);
512
513        // With flex-grow=1.0, children should have non-zero width
514        assert!(results[0].width > 0);
515        assert!(results[1].width > 0);
516        assert!(results[2].width > 0);
517    }
518
519    #[test]
520    fn test_layout_gap() {
521        let children = vec![
522            create_text_node("A"),
523            create_text_node("B"),
524            create_text_node("C"),
525        ];
526
527        let mut attrs = HashMap::new();
528        attrs.insert("gap".to_string(), "2".to_string());
529
530        let container = create_flex_container(attrs, children);
531
532        let constraints = Rect {
533            x: 0,
534            y: 0,
535            width: 20,
536            height: 10,
537        };
538
539        let results = FlexboxLayout::calculate(&container, constraints);
540
541        assert_eq!(results.len(), 3);
542
543        // Gap should affect positioning
544        // Distance between children should account for gap
545        let gap_1 = results[1].x - (results[0].x + results[0].width);
546        let gap_2 = results[2].x - (results[1].x + results[1].width);
547
548        // Gap should be present (though exact value depends on calculation)
549        assert!(gap_1 >= 2 || gap_2 >= 2);
550    }
551
552    #[test]
553    fn test_layout_nested() {
554        // Create inner flex container
555        let inner_children = vec![create_text_node("A"), create_text_node("B")];
556
557        let mut inner_attrs = HashMap::new();
558        inner_attrs.insert("direction".to_string(), "row".to_string());
559
560        let inner_container = create_flex_container(inner_attrs, inner_children);
561
562        // Create outer flex container
563        let outer_children = vec![
564            create_text_node("X"),
565            inner_container,
566            create_text_node("Y"),
567        ];
568
569        let mut outer_attrs = HashMap::new();
570        outer_attrs.insert("direction".to_string(), "column".to_string());
571
572        let outer_container = create_flex_container(outer_attrs, outer_children);
573
574        let constraints = Rect {
575            x: 0,
576            y: 0,
577            width: 20,
578            height: 30,
579        };
580
581        let results = FlexboxLayout::calculate(&outer_container, constraints);
582
583        // Outer container has 3 children
584        assert_eq!(results.len(), 3);
585
586        // Verify vertical layout (y positions increase)
587        assert!(results[0].y < results[1].y);
588        assert!(results[1].y < results[2].y);
589
590        // All children have full width
591        assert_eq!(results[0].width, 20);
592        assert_eq!(results[1].width, 20);
593        assert_eq!(results[2].width, 20);
594    }
595
596    #[test]
597    fn test_flex_style_builder() {
598        let style = FlexStyle::new()
599            .row()
600            .justify(JustifyContent::Center)
601            .align(AlignItems::Stretch)
602            .gap(2)
603            .flex_grow(1.0)
604            .flex_shrink(0.5);
605
606        assert_eq!(style.direction, FlexDirection::Row);
607        assert_eq!(style.justify_content, JustifyContent::Center);
608        assert_eq!(style.align_items, AlignItems::Stretch);
609        assert_eq!(style.gap, 2);
610        assert_eq!(style.flex_grow, 1.0);
611        assert_eq!(style.flex_shrink, 0.5);
612    }
613
614    #[test]
615    fn test_empty_children() {
616        let container = create_flex_container(HashMap::new(), vec![]);
617
618        let constraints = Rect {
619            x: 0,
620            y: 0,
621            width: 20,
622            height: 10,
623        };
624
625        let results = FlexboxLayout::calculate(&container, constraints);
626
627        assert_eq!(results.len(), 0);
628    }
629
630    #[test]
631    fn test_single_child() {
632        let children = vec![create_text_node("A")];
633
634        let container = create_flex_container(HashMap::new(), children);
635
636        let constraints = Rect {
637            x: 0,
638            y: 0,
639            width: 20,
640            height: 10,
641        };
642
643        let results = FlexboxLayout::calculate(&container, constraints);
644
645        assert_eq!(results.len(), 1);
646        assert_eq!(results[0].x, 0);
647        assert_eq!(results[0].y, 0);
648        assert_eq!(results[0].height, 10);
649    }
650
651    #[test]
652    fn test_justify_end() {
653        let children = vec![create_text_node("A"), create_text_node("B")];
654
655        let mut attrs = HashMap::new();
656        attrs.insert("justify".to_string(), "end".to_string());
657
658        let container = create_flex_container(attrs, children);
659
660        let constraints = Rect {
661            x: 0,
662            y: 0,
663            width: 20,
664            height: 10,
665        };
666
667        let results = FlexboxLayout::calculate(&container, constraints);
668
669        assert_eq!(results.len(), 2);
670
671        // Last child should be at or near the end
672        let total_width = results[1].x + results[1].width;
673        assert!(total_width <= 20);
674    }
675
676    #[test]
677    fn test_space_between() {
678        let children = vec![
679            create_text_node("A"),
680            create_text_node("B"),
681            create_text_node("C"),
682        ];
683
684        let mut attrs = HashMap::new();
685        attrs.insert("justify".to_string(), "space-between".to_string());
686
687        let container = create_flex_container(attrs, children);
688
689        let constraints = Rect {
690            x: 0,
691            y: 0,
692            width: 20,
693            height: 10,
694        };
695
696        let results = FlexboxLayout::calculate(&container, constraints);
697
698        assert_eq!(results.len(), 3);
699
700        // First child at start
701        assert_eq!(results[0].x, 0);
702
703        // Last child at end
704        let last_end = results[2].x + results[2].width;
705        assert!(last_end <= 20);
706    }
707
708    #[test]
709    fn test_space_around() {
710        let children = vec![
711            create_text_node("A"),
712            create_text_node("B"),
713            create_text_node("C"),
714        ];
715
716        let mut attrs = HashMap::new();
717        attrs.insert("justify".to_string(), "space-around".to_string());
718
719        let container = create_flex_container(attrs, children);
720
721        let constraints = Rect {
722            x: 0,
723            y: 0,
724            width: 20,
725            height: 10,
726        };
727
728        let results = FlexboxLayout::calculate(&container, constraints);
729
730        assert_eq!(results.len(), 3);
731
732        // First child should exist and have valid position
733        assert!(!results.is_empty());
734        assert!(results[0].width > 0);
735    }
736}