Skip to main content

photon_ui/components/
container.rs

1use crate::{
2    Component,
3    InputResult,
4    RenderError,
5    Rendered,
6    events::Event,
7    layout::{
8        Layout,
9        Rect,
10    },
11};
12
13/// A component that lays out its children using a `Layout`.
14///
15/// Each child is rendered into the rect assigned by the layout via
16/// [`render_rect`](Component::render_rect).
17pub struct Container {
18    layout: Layout,
19    children: Vec<Box<dyn Component>>,
20}
21
22impl Container {
23    /// Create a new container with the given layout.
24    pub fn new(layout: Layout) -> Self {
25        Self {
26            layout,
27            children: Vec::new(),
28        }
29    }
30
31    /// Add a child component.
32    pub fn push(&mut self, child: Box<dyn Component>) {
33        self.children.push(child);
34    }
35
36    /// Builder-style add.
37    pub fn with_child(mut self, child: Box<dyn Component>) -> Self {
38        self.children.push(child);
39        self
40    }
41}
42
43impl Component for Container {
44    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
45        let rect = Rect::new(0, 0, width, self.children.len() as u16 * 3);
46        self.render_rect(rect)
47    }
48
49    fn render_rect(&self, rect: Rect) -> Result<Rendered, RenderError> {
50        let mut screen = Rendered::empty();
51        let areas = self.layout.split(rect);
52
53        for (child, area) in self.children.iter().zip(areas.iter()) {
54            if let Ok(rendered) = child.render_rect(*area) {
55                // Blit into the local buffer using coordinates relative to this container's
56                // origin. `layout.split()` returns areas in terminal
57                // coordinates (they include rect.x/y), but `screen` is a fresh
58                // local buffer whose origin is (0, 0).
59                let rel_area = Rect::new(
60                    area.x.saturating_sub(rect.x),
61                    area.y.saturating_sub(rect.y),
62                    area.width,
63                    area.height,
64                );
65                rendered.blit_into_rect(&mut screen, rel_area);
66            }
67        }
68
69        Ok(screen)
70    }
71
72    fn handle_input(&mut self, event: &Event) -> InputResult {
73        for child in &mut self.children {
74            let result = child.handle_input(event);
75            if result != InputResult::Ignored {
76                return result;
77            }
78        }
79        InputResult::Ignored
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::{
87        components::Text,
88        layout::Constraint,
89    };
90
91    #[test]
92    fn container_renders_children() {
93        let mut container = Container::new(Layout::horizontal([
94            Constraint::Length(10),
95            Constraint::Length(10),
96        ]));
97        container.push(Box::new(Text::new("left", 0, 0)));
98        container.push(Box::new(Text::new("right", 0, 0)));
99
100        let rendered = container.render_rect(Rect::new(0, 0, 20, 1)).unwrap();
101        assert_eq!(rendered.lines.len(), 1);
102        assert!(rendered.lines[0].contains("left"));
103        assert!(rendered.lines[0].contains("right"));
104    }
105
106    #[test]
107    fn container_with_child_builder() {
108        let container = Container::new(Layout::horizontal([
109            Constraint::Length(10),
110            Constraint::Length(10),
111        ]))
112        .with_child(Box::new(Text::new("left", 0, 0)))
113        .with_child(Box::new(Text::new("right", 0, 0)));
114
115        let rendered = container.render_rect(Rect::new(0, 0, 20, 1)).unwrap();
116        assert!(rendered.lines[0].contains("left"));
117        assert!(rendered.lines[0].contains("right"));
118    }
119
120    #[test]
121    fn container_render_uses_child_count_height() {
122        let mut container = Container::new(Layout::vertical([
123            Constraint::Length(1),
124            Constraint::Length(1),
125        ]));
126        container.push(Box::new(Text::new("top", 0, 0)));
127        container.push(Box::new(Text::new("bottom", 0, 0)));
128
129        let rendered = container.render(10).unwrap();
130        assert_eq!(rendered.lines.len(), 2);
131    }
132
133    #[test]
134    fn container_handle_input_falls_through() {
135        use crossterm::event::{
136            KeyCode,
137            KeyEvent,
138            KeyEventKind,
139            KeyModifiers,
140        };
141
142        let mut container = Container::new(Layout::horizontal([Constraint::Length(10)]));
143        container.push(Box::new(Text::new("text", 0, 0)));
144
145        let event = Event::Key(KeyEvent {
146            code: KeyCode::Char('x'),
147            modifiers: KeyModifiers::empty(),
148            kind: KeyEventKind::Press,
149            state: crossterm::event::KeyEventState::empty(),
150        });
151        let result = container.handle_input(&event);
152        assert_eq!(result, InputResult::Ignored);
153    }
154
155    #[test]
156    fn container_vertical_split() {
157        let mut container = Container::new(Layout::vertical([
158            Constraint::Length(1),
159            Constraint::Length(1),
160        ]));
161        container.push(Box::new(Text::new("top", 0, 0)));
162        container.push(Box::new(Text::new("bottom", 0, 0)));
163
164        let rendered = container.render_rect(Rect::new(0, 0, 10, 2)).unwrap();
165        assert_eq!(rendered.lines.len(), 2);
166        assert!(rendered.lines[0].contains("top"));
167        assert!(rendered.lines[1].contains("bottom"));
168    }
169
170    /// Regression test: nested containers with non-zero rect coordinates must
171    /// not double-offset content.
172    #[test]
173    fn container_nonzero_rect_no_double_offset() {
174        let mut outer = Container::new(Layout::horizontal([
175            Constraint::Length(10),
176            Constraint::Length(10),
177        ]));
178
179        let mut inner = Container::new(Layout::vertical([
180            Constraint::Length(1),
181            Constraint::Length(1),
182        ]));
183        inner.push(Box::new(Text::new("a", 0, 0)));
184        inner.push(Box::new(Text::new("b", 0, 0)));
185
186        outer.push(Box::new(Text::new("left", 0, 0)));
187        outer.push(Box::new(inner));
188
189        // Outer rect starts at (0, 2). Inner container gets y = 2 from the layout.
190        let rendered = outer.render_rect(Rect::new(0, 2, 20, 2)).unwrap();
191        // The inner container's content should be at local rows 0 and 1,
192        // NOT shifted down by 2 due to double-offsetting.
193        assert_eq!(
194            rendered.lines.len(),
195            2,
196            "expected 2 lines, got {}",
197            rendered.lines.len()
198        );
199        assert!(rendered.lines[0].contains("left"));
200        assert!(rendered.lines[0].contains("a"));
201        assert!(rendered.lines[1].contains("b"));
202    }
203}