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    pub fn push<W: Widget + 'a>(mut self, widget: W) -> Self {
47        self.children.push(Box::new(widget));
48        self
49    }
50
51    /// Add a boxed widget to the group.
52    pub fn push_boxed(mut self, widget: Box<dyn Widget + 'a>) -> Self {
53        self.children.push(widget);
54        self
55    }
56
57    /// Number of children in the group.
58    pub fn len(&self) -> usize {
59        self.children.len()
60    }
61
62    /// Whether the group has no children.
63    pub fn is_empty(&self) -> bool {
64        self.children.is_empty()
65    }
66}
67
68impl Default for Group<'_> {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl Widget for Group<'_> {
75    fn render(&self, area: Rect, frame: &mut Frame) {
76        if area.is_empty() {
77            return;
78        }
79
80        for child in &self.children {
81            child.render(area, frame);
82        }
83    }
84
85    fn is_essential(&self) -> bool {
86        self.children.iter().any(|c| c.is_essential())
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use ftui_render::cell::Cell;
94    use ftui_render::grapheme_pool::GraphemePool;
95
96    fn buf_to_lines(buf: &ftui_render::buffer::Buffer) -> Vec<String> {
97        let mut lines = Vec::new();
98        for y in 0..buf.height() {
99            let mut row = String::with_capacity(buf.width() as usize);
100            for x in 0..buf.width() {
101                let ch = buf
102                    .get(x, y)
103                    .and_then(|c| c.content.as_char())
104                    .unwrap_or(' ');
105                row.push(ch);
106            }
107            lines.push(row);
108        }
109        lines
110    }
111
112    #[derive(Debug, Clone, Copy)]
113    struct Fill(char);
114
115    impl Widget for Fill {
116        fn render(&self, area: Rect, frame: &mut Frame) {
117            for y in area.y..area.bottom() {
118                for x in area.x..area.right() {
119                    frame.buffer.set(x, y, Cell::from_char(self.0));
120                }
121            }
122        }
123    }
124
125    /// Renders a single character at a fixed position within the area.
126    #[derive(Debug, Clone, Copy)]
127    struct Dot {
128        ch: char,
129        dx: u16,
130        dy: u16,
131    }
132
133    impl Widget for Dot {
134        fn render(&self, area: Rect, frame: &mut Frame) {
135            let x = area.x.saturating_add(self.dx);
136            let y = area.y.saturating_add(self.dy);
137            if x < area.right() && y < area.bottom() {
138                frame.buffer.set(x, y, Cell::from_char(self.ch));
139            }
140        }
141    }
142
143    #[test]
144    fn empty_group_is_noop() {
145        let group = Group::new();
146        let area = Rect::new(0, 0, 5, 3);
147        let mut pool = GraphemePool::new();
148        let mut frame = Frame::new(5, 3, &mut pool);
149        group.render(area, &mut frame);
150
151        for y in 0..3 {
152            for x in 0..5u16 {
153                assert!(frame.buffer.get(x, y).unwrap().is_empty());
154            }
155        }
156    }
157
158    #[test]
159    fn single_child_renders() {
160        let group = Group::new().push(Fill('A'));
161        let area = Rect::new(0, 0, 3, 2);
162        let mut pool = GraphemePool::new();
163        let mut frame = Frame::new(3, 2, &mut pool);
164        group.render(area, &mut frame);
165
166        assert_eq!(buf_to_lines(&frame.buffer), vec!["AAA", "AAA"]);
167    }
168
169    #[test]
170    fn later_children_overwrite_earlier() {
171        let group = Group::new().push(Fill('A')).push(Dot {
172            ch: 'X',
173            dx: 1,
174            dy: 0,
175        });
176        let area = Rect::new(0, 0, 3, 1);
177        let mut pool = GraphemePool::new();
178        let mut frame = Frame::new(3, 1, &mut pool);
179        group.render(area, &mut frame);
180
181        assert_eq!(buf_to_lines(&frame.buffer), vec!["AXA"]);
182    }
183
184    #[test]
185    fn deterministic_render_order() {
186        // Fill with A, then overwrite entire area with B
187        let group = Group::new().push(Fill('A')).push(Fill('B'));
188        let area = Rect::new(0, 0, 3, 1);
189        let mut pool = GraphemePool::new();
190        let mut frame = Frame::new(3, 1, &mut pool);
191        group.render(area, &mut frame);
192
193        // B should win everywhere
194        assert_eq!(buf_to_lines(&frame.buffer), vec!["BBB"]);
195    }
196
197    #[test]
198    fn multiple_dots_compose() {
199        let group = Group::new()
200            .push(Dot {
201                ch: '1',
202                dx: 0,
203                dy: 0,
204            })
205            .push(Dot {
206                ch: '2',
207                dx: 2,
208                dy: 0,
209            })
210            .push(Dot {
211                ch: '3',
212                dx: 1,
213                dy: 1,
214            });
215        let area = Rect::new(0, 0, 3, 2);
216        let mut pool = GraphemePool::new();
217        let mut frame = Frame::new(3, 2, &mut pool);
218        group.render(area, &mut frame);
219
220        assert_eq!(buf_to_lines(&frame.buffer), vec!["1 2", " 3 "]);
221    }
222
223    #[test]
224    fn zero_area_is_noop() {
225        let group = Group::new().push(Fill('X'));
226        let area = Rect::new(0, 0, 0, 0);
227        let mut pool = GraphemePool::new();
228        let mut frame = Frame::new(5, 5, &mut pool);
229        group.render(area, &mut frame);
230
231        for y in 0..5 {
232            for x in 0..5u16 {
233                assert!(frame.buffer.get(x, y).unwrap().is_empty());
234            }
235        }
236    }
237
238    #[test]
239    fn len_and_is_empty() {
240        let g0 = Group::new();
241        assert!(g0.is_empty());
242        assert_eq!(g0.len(), 0);
243
244        let g1 = Group::new().push(Fill('A'));
245        assert!(!g1.is_empty());
246        assert_eq!(g1.len(), 1);
247
248        let g3 = Group::new().push(Fill('A')).push(Fill('B')).push(Fill('C'));
249        assert_eq!(g3.len(), 3);
250    }
251
252    #[test]
253    fn is_essential_any_child() {
254        struct Essential;
255        impl Widget for Essential {
256            fn render(&self, _: Rect, _: &mut Frame) {}
257            fn is_essential(&self) -> bool {
258                true
259            }
260        }
261
262        assert!(!Group::new().push(Fill('A')).is_essential());
263        assert!(Group::new().push(Essential).is_essential());
264        assert!(Group::new().push(Fill('A')).push(Essential).is_essential());
265    }
266
267    #[test]
268    fn push_boxed_works() {
269        let boxed: Box<dyn Widget> = Box::new(Fill('Z'));
270        let group = Group::new().push_boxed(boxed);
271        assert_eq!(group.len(), 1);
272
273        let area = Rect::new(0, 0, 2, 1);
274        let mut pool = GraphemePool::new();
275        let mut frame = Frame::new(2, 1, &mut pool);
276        group.render(area, &mut frame);
277
278        assert_eq!(buf_to_lines(&frame.buffer), vec!["ZZ"]);
279    }
280
281    #[test]
282    fn nested_groups_compose() {
283        let inner = Group::new().push(Fill('I'));
284        let outer = Group::new().push(Fill('O')).push(inner);
285
286        let area = Rect::new(0, 0, 3, 1);
287        let mut pool = GraphemePool::new();
288        let mut frame = Frame::new(3, 1, &mut pool);
289        outer.render(area, &mut frame);
290
291        // Inner group (last child) overwrites outer
292        assert_eq!(buf_to_lines(&frame.buffer), vec!["III"]);
293    }
294
295    #[test]
296    fn group_with_offset_area() {
297        let group = Group::new().push(Fill('X'));
298        let area = Rect::new(2, 1, 3, 2);
299        let mut pool = GraphemePool::new();
300        let mut frame = Frame::new(6, 4, &mut pool);
301        group.render(area, &mut frame);
302
303        // Only the specified area should be filled
304        assert_eq!(
305            buf_to_lines(&frame.buffer),
306            vec!["      ", "  XXX ", "  XXX ", "      "]
307        );
308    }
309}