Skip to main content

tui/components/
layout.rs

1use crate::rendering::frame::{Cursor, Frame};
2use crate::rendering::line::Line;
3
4/// Stacks content sections vertically with automatic cursor offset tracking.
5///
6/// Replaces the manual `Panel::render_with_offsets()` + index tracking pattern.
7/// Use `section()` for non-interactive content and `section_with_cursor()` for the
8/// section that owns the cursor.
9pub struct Layout {
10    sections: Vec<Vec<Line>>,
11    cursor: Option<Cursor>,
12    cursor_section_index: Option<usize>,
13}
14
15impl Layout {
16    pub fn new() -> Self {
17        Self { sections: Vec::new(), cursor: None, cursor_section_index: None }
18    }
19
20    /// Add a content section (no cursor).
21    pub fn section(&mut self, lines: Vec<Line>) {
22        self.sections.push(lines);
23    }
24
25    /// Add a content section that owns the cursor.
26    pub fn section_with_cursor(&mut self, lines: Vec<Line>, cursor: Cursor) {
27        self.cursor_section_index = Some(self.sections.len());
28        self.cursor = Some(cursor);
29        self.sections.push(lines);
30    }
31
32    /// Flatten all sections into a Frame, auto-computing cursor Y offset.
33    pub fn into_frame(self) -> Frame {
34        let mut all_lines = Vec::new();
35        let mut section_offsets = Vec::with_capacity(self.sections.len());
36
37        for section in &self.sections {
38            section_offsets.push(all_lines.len());
39            all_lines.extend(section.iter().cloned());
40        }
41
42        let cursor = match (self.cursor_section_index, self.cursor) {
43            (Some(idx), Some(c)) => Cursor { row: section_offsets[idx] + c.row, col: c.col, is_visible: c.is_visible },
44            _ => Cursor { row: 0, col: 0, is_visible: false },
45        };
46
47        Frame::new(all_lines).with_cursor(cursor)
48    }
49}
50
51impl Default for Layout {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn empty_layout_produces_empty_frame() {
63        let layout = Layout::new();
64        let frame = layout.into_frame();
65        assert!(frame.lines().is_empty());
66        assert!(!frame.cursor().is_visible);
67    }
68
69    #[test]
70    fn sections_are_stacked_in_order() {
71        let mut layout = Layout::new();
72        layout.section(vec![Line::new("a1"), Line::new("a2")]);
73        layout.section(vec![Line::new("b1")]);
74        let frame = layout.into_frame();
75        assert_eq!(frame.lines().len(), 3);
76        assert_eq!(frame.lines()[0].plain_text(), "a1");
77        assert_eq!(frame.lines()[2].plain_text(), "b1");
78    }
79
80    #[test]
81    fn cursor_offset_is_computed_from_section_position() {
82        let mut layout = Layout::new();
83        layout.section(vec![Line::new("header1"), Line::new("header2")]);
84        layout.section_with_cursor(vec![Line::new("input")], Cursor { row: 0, col: 5, is_visible: true });
85        layout.section(vec![Line::new("footer")]);
86
87        let frame = layout.into_frame();
88        assert_eq!(frame.cursor().row, 2); // 2 header lines
89        assert_eq!(frame.cursor().col, 5);
90        assert!(frame.cursor().is_visible);
91    }
92
93    #[test]
94    fn cursor_row_adds_section_offset_and_local_row() {
95        let mut layout = Layout::new();
96        layout.section(vec![Line::new("a")]);
97        layout.section_with_cursor(
98            vec![Line::new("b1"), Line::new("b2"), Line::new("b3")],
99            Cursor { row: 2, col: 3, is_visible: true },
100        );
101
102        let frame = layout.into_frame();
103        assert_eq!(frame.cursor().row, 3); // 1 + 2
104        assert_eq!(frame.cursor().col, 3);
105    }
106}