Skip to main content

ftui_widgets/
group.rs

1#![forbid(unsafe_code)]
2
3//! Group container widget.
4//!
5//! A composition primitive that renders multiple child widgets into the same
6//! area in deterministic order. Unlike layout containers (Flex, Grid), Group
7//! does not reposition children — each child receives the full parent area
8//! and is rendered in sequence, with later children drawn on top of earlier
9//! ones.
10//!
11//! This is useful for layering decorations, overlays, or combining widgets
12//! that partition the area themselves.
13
14use crate::Widget;
15use ftui_core::geometry::Rect;
16use ftui_render::frame::Frame;
17
18/// A composite container that renders multiple widgets in order.
19///
20/// Children are rendered in the order they were added. Each child receives
21/// the same area, so later children may overwrite earlier ones.
22///
23/// # Example
24///
25/// ```ignore
26/// use ftui_widgets::group::Group;
27///
28/// let group = Group::new()
29///     .push(background_widget)
30///     .push(foreground_widget);
31/// group.render(area, &mut frame);
32/// ```
33pub struct Group<'a> {
34    children: Vec<Box<dyn Widget + 'a>>,
35}
36
37impl<'a> Group<'a> {
38    /// Create a new empty group.
39    pub fn new() -> Self {
40        Self {
41            children: Vec::new(),
42        }
43    }
44
45    /// Add a widget to the group.
46    #[must_use]
47    pub fn push<W: Widget + 'a>(mut self, widget: W) -> Self {
48        self.children.push(Box::new(widget));
49        self
50    }
51
52    /// Add a boxed widget to the group.
53    #[must_use]
54    pub fn push_boxed(mut self, widget: Box<dyn Widget + 'a>) -> Self {
55        self.children.push(widget);
56        self
57    }
58
59    /// Number of children in the group.
60    #[inline]
61    pub fn len(&self) -> usize {
62        self.children.len()
63    }
64
65    /// Whether the group has no children.
66    #[inline]
67    pub fn is_empty(&self) -> bool {
68        self.children.is_empty()
69    }
70}
71
72impl Default for Group<'_> {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl Widget for Group<'_> {
79    fn render(&self, area: Rect, frame: &mut Frame) {
80        if area.is_empty() {
81            return;
82        }
83
84        for child in &self.children {
85            child.render(area, frame);
86        }
87    }
88
89    fn is_essential(&self) -> bool {
90        self.children.iter().any(|c| c.is_essential())
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use ftui_render::cell::Cell;
98    use ftui_render::grapheme_pool::GraphemePool;
99
100    fn buf_to_lines(buf: &ftui_render::buffer::Buffer) -> Vec<String> {
101        let mut lines = Vec::new();
102        for y in 0..buf.height() {
103            let mut row = String::with_capacity(buf.width() as usize);
104            for x in 0..buf.width() {
105                let ch = buf
106                    .get(x, y)
107                    .and_then(|c| c.content.as_char())
108                    .unwrap_or(' ');
109                row.push(ch);
110            }
111            lines.push(row);
112        }
113        lines
114    }
115
116    #[derive(Debug, Clone, Copy)]
117    struct Fill(char);
118
119    impl Widget for Fill {
120        fn render(&self, area: Rect, frame: &mut Frame) {
121            for y in area.y..area.bottom() {
122                for x in area.x..area.right() {
123                    frame.buffer.set(x, y, Cell::from_char(self.0));
124                }
125            }
126        }
127    }
128
129    /// Renders a single character at a fixed position within the area.
130    #[derive(Debug, Clone, Copy)]
131    struct Dot {
132        ch: char,
133        dx: u16,
134        dy: u16,
135    }
136
137    impl Widget for Dot {
138        fn render(&self, area: Rect, frame: &mut Frame) {
139            let x = area.x.saturating_add(self.dx);
140            let y = area.y.saturating_add(self.dy);
141            if x < area.right() && y < area.bottom() {
142                frame.buffer.set(x, y, Cell::from_char(self.ch));
143            }
144        }
145    }
146
147    #[test]
148    fn empty_group_is_noop() {
149        let group = Group::new();
150        let area = Rect::new(0, 0, 5, 3);
151        let mut pool = GraphemePool::new();
152        let mut frame = Frame::new(5, 3, &mut pool);
153        group.render(area, &mut frame);
154
155        for y in 0..3 {
156            for x in 0..5u16 {
157                assert!(frame.buffer.get(x, y).unwrap().is_empty());
158            }
159        }
160    }
161
162    #[test]
163    fn single_child_renders() {
164        let group = Group::new().push(Fill('A'));
165        let area = Rect::new(0, 0, 3, 2);
166        let mut pool = GraphemePool::new();
167        let mut frame = Frame::new(3, 2, &mut pool);
168        group.render(area, &mut frame);
169
170        assert_eq!(buf_to_lines(&frame.buffer), vec!["AAA", "AAA"]);
171    }
172
173    #[test]
174    fn later_children_overwrite_earlier() {
175        let group = Group::new().push(Fill('A')).push(Dot {
176            ch: 'X',
177            dx: 1,
178            dy: 0,
179        });
180        let area = Rect::new(0, 0, 3, 1);
181        let mut pool = GraphemePool::new();
182        let mut frame = Frame::new(3, 1, &mut pool);
183        group.render(area, &mut frame);
184
185        assert_eq!(buf_to_lines(&frame.buffer), vec!["AXA"]);
186    }
187
188    #[test]
189    fn deterministic_render_order() {
190        // Fill with A, then overwrite entire area with B
191        let group = Group::new().push(Fill('A')).push(Fill('B'));
192        let area = Rect::new(0, 0, 3, 1);
193        let mut pool = GraphemePool::new();
194        let mut frame = Frame::new(3, 1, &mut pool);
195        group.render(area, &mut frame);
196
197        // B should win everywhere
198        assert_eq!(buf_to_lines(&frame.buffer), vec!["BBB"]);
199    }
200
201    #[test]
202    fn multiple_dots_compose() {
203        let group = Group::new()
204            .push(Dot {
205                ch: '1',
206                dx: 0,
207                dy: 0,
208            })
209            .push(Dot {
210                ch: '2',
211                dx: 2,
212                dy: 0,
213            })
214            .push(Dot {
215                ch: '3',
216                dx: 1,
217                dy: 1,
218            });
219        let area = Rect::new(0, 0, 3, 2);
220        let mut pool = GraphemePool::new();
221        let mut frame = Frame::new(3, 2, &mut pool);
222        group.render(area, &mut frame);
223
224        assert_eq!(buf_to_lines(&frame.buffer), vec!["1 2", " 3 "]);
225    }
226
227    #[test]
228    fn zero_area_is_noop() {
229        let group = Group::new().push(Fill('X'));
230        let area = Rect::new(0, 0, 0, 0);
231        let mut pool = GraphemePool::new();
232        let mut frame = Frame::new(5, 5, &mut pool);
233        group.render(area, &mut frame);
234
235        for y in 0..5 {
236            for x in 0..5u16 {
237                assert!(frame.buffer.get(x, y).unwrap().is_empty());
238            }
239        }
240    }
241
242    #[test]
243    fn len_and_is_empty() {
244        let g0 = Group::new();
245        assert!(g0.is_empty());
246        assert_eq!(g0.len(), 0);
247
248        let g1 = Group::new().push(Fill('A'));
249        assert!(!g1.is_empty());
250        assert_eq!(g1.len(), 1);
251
252        let g3 = Group::new().push(Fill('A')).push(Fill('B')).push(Fill('C'));
253        assert_eq!(g3.len(), 3);
254    }
255
256    #[test]
257    fn is_essential_any_child() {
258        struct Essential;
259        impl Widget for Essential {
260            fn render(&self, _: Rect, _: &mut Frame) {}
261            fn is_essential(&self) -> bool {
262                true
263            }
264        }
265
266        assert!(!Group::new().push(Fill('A')).is_essential());
267        assert!(Group::new().push(Essential).is_essential());
268        assert!(Group::new().push(Fill('A')).push(Essential).is_essential());
269    }
270
271    #[test]
272    fn push_boxed_works() {
273        let boxed: Box<dyn Widget> = Box::new(Fill('Z'));
274        let group = Group::new().push_boxed(boxed);
275        assert_eq!(group.len(), 1);
276
277        let area = Rect::new(0, 0, 2, 1);
278        let mut pool = GraphemePool::new();
279        let mut frame = Frame::new(2, 1, &mut pool);
280        group.render(area, &mut frame);
281
282        assert_eq!(buf_to_lines(&frame.buffer), vec!["ZZ"]);
283    }
284
285    #[test]
286    fn nested_groups_compose() {
287        let inner = Group::new().push(Fill('I'));
288        let outer = Group::new().push(Fill('O')).push(inner);
289
290        let area = Rect::new(0, 0, 3, 1);
291        let mut pool = GraphemePool::new();
292        let mut frame = Frame::new(3, 1, &mut pool);
293        outer.render(area, &mut frame);
294
295        // Inner group (last child) overwrites outer
296        assert_eq!(buf_to_lines(&frame.buffer), vec!["III"]);
297    }
298
299    #[test]
300    fn default_group_is_empty() {
301        let g = Group::default();
302        assert!(g.is_empty());
303        assert_eq!(g.len(), 0);
304    }
305
306    #[test]
307    fn large_group_all_render() {
308        let n = 20;
309        let group = (0..n).fold(Group::new(), |g, _| g.push(Fill('X')));
310        assert_eq!(group.len(), n);
311
312        let area = Rect::new(0, 0, 3, 1);
313        let mut pool = GraphemePool::new();
314        let mut frame = Frame::new(3, 1, &mut pool);
315        group.render(area, &mut frame);
316        assert_eq!(buf_to_lines(&frame.buffer), vec!["XXX"]);
317    }
318
319    #[test]
320    fn group_with_offset_area() {
321        let group = Group::new().push(Fill('X'));
322        let area = Rect::new(2, 1, 3, 2);
323        let mut pool = GraphemePool::new();
324        let mut frame = Frame::new(6, 4, &mut pool);
325        group.render(area, &mut frame);
326
327        // Only the specified area should be filled
328        assert_eq!(
329            buf_to_lines(&frame.buffer),
330            vec!["      ", "  XXX ", "  XXX ", "      "]
331        );
332    }
333}