Skip to main content

ftui_widgets/
columns.rs

1#![forbid(unsafe_code)]
2
3//! Columns widget: lays out children side-by-side using Flex constraints.
4
5use crate::Widget;
6use ftui_core::geometry::{Rect, Sides};
7use ftui_layout::{Constraint, Flex};
8use ftui_render::frame::Frame;
9
10/// A single column definition.
11pub struct Column<'a> {
12    widget: Box<dyn Widget + 'a>,
13    constraint: Constraint,
14    padding: Sides,
15}
16
17impl std::fmt::Debug for Column<'_> {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        f.debug_struct("Column")
20            .field("widget", &"<dyn Widget>")
21            .field("constraint", &self.constraint)
22            .field("padding", &self.padding)
23            .finish()
24    }
25}
26
27impl<'a> Column<'a> {
28    /// Create a new column with a widget and constraint.
29    pub fn new(widget: impl Widget + 'a, constraint: Constraint) -> Self {
30        Self {
31            widget: Box::new(widget),
32            constraint,
33            padding: Sides::default(),
34        }
35    }
36
37    /// Set the column padding.
38    #[must_use]
39    pub fn padding(mut self, padding: Sides) -> Self {
40        self.padding = padding;
41        self
42    }
43
44    /// Set the column constraint.
45    #[must_use]
46    pub fn constraint(mut self, constraint: Constraint) -> Self {
47        self.constraint = constraint;
48        self
49    }
50}
51
52/// A horizontal column layout container.
53#[derive(Debug, Default)]
54pub struct Columns<'a> {
55    columns: Vec<Column<'a>>,
56    gap: u16,
57}
58
59impl<'a> Columns<'a> {
60    /// Create an empty columns container.
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Set the gap between columns.
66    #[must_use]
67    pub fn gap(mut self, gap: u16) -> Self {
68        self.gap = gap;
69        self
70    }
71
72    /// Add a column definition.
73    #[must_use]
74    pub fn push(mut self, column: Column<'a>) -> Self {
75        self.columns.push(column);
76        self
77    }
78
79    /// Add a column with a widget and constraint.
80    #[must_use]
81    pub fn column(mut self, widget: impl Widget + 'a, constraint: Constraint) -> Self {
82        self.columns.push(Column::new(widget, constraint));
83        self
84    }
85
86    /// Add a column with equal fill sizing.
87    #[must_use]
88    #[allow(clippy::should_implement_trait)] // Builder pattern, not std::ops::Add
89    pub fn add(mut self, widget: impl Widget + 'a) -> Self {
90        self.columns.push(Column::new(widget, Constraint::Fill));
91        self
92    }
93}
94
95struct ScissorGuard<'a, 'pool> {
96    frame: &'a mut Frame<'pool>,
97}
98
99impl<'a, 'pool> ScissorGuard<'a, 'pool> {
100    fn new(frame: &'a mut Frame<'pool>, rect: Rect) -> Self {
101        frame.buffer.push_scissor(rect);
102        Self { frame }
103    }
104
105    fn frame_mut(&mut self) -> &mut Frame<'pool> {
106        self.frame
107    }
108}
109
110impl Drop for ScissorGuard<'_, '_> {
111    fn drop(&mut self) {
112        self.frame.buffer.pop_scissor();
113    }
114}
115
116impl Widget for Columns<'_> {
117    fn render(&self, area: Rect, frame: &mut Frame) {
118        #[cfg(feature = "tracing")]
119        let _span = tracing::debug_span!(
120            "widget_render",
121            widget = "Columns",
122            x = area.x,
123            y = area.y,
124            w = area.width,
125            h = area.height
126        )
127        .entered();
128
129        if area.is_empty() {
130            return;
131        }
132
133        // Columns owns the full layout rect. Clear stale child glyphs before
134        // rendering the current column set while preserving any parent-applied
135        // styling already present in the buffer.
136        for y in area.y..area.bottom() {
137            for x in area.x..area.right() {
138                if let Some(cell) = frame.buffer.get_mut(x, y) {
139                    cell.content = ftui_render::cell::CellContent::EMPTY;
140                }
141            }
142        }
143
144        if self.columns.is_empty() {
145            return;
146        }
147
148        let flex = Flex::horizontal()
149            .gap(self.gap)
150            .constraints(self.columns.iter().map(|c| c.constraint));
151        let rects = flex.split(area);
152
153        for (col, rect) in self.columns.iter().zip(rects) {
154            if rect.is_empty() {
155                continue;
156            }
157            let inner = rect.inner(col.padding);
158            if inner.is_empty() {
159                continue;
160            }
161
162            let mut guard = ScissorGuard::new(frame, inner);
163            col.widget.render(inner, guard.frame_mut());
164        }
165    }
166
167    fn is_essential(&self) -> bool {
168        self.columns.iter().any(|c| c.widget.is_essential())
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use ftui_render::budget::DegradationLevel;
176    use ftui_render::cell::Cell;
177    use ftui_render::grapheme_pool::GraphemePool;
178    use std::cell::RefCell;
179    use std::rc::Rc;
180
181    #[derive(Clone, Debug)]
182    struct Record {
183        rects: Rc<RefCell<Vec<Rect>>>,
184    }
185
186    impl Record {
187        fn new() -> (Self, Rc<RefCell<Vec<Rect>>>) {
188            let rects = Rc::new(RefCell::new(Vec::new()));
189            (
190                Self {
191                    rects: rects.clone(),
192                },
193                rects,
194            )
195        }
196    }
197
198    impl Widget for Record {
199        fn render(&self, area: Rect, _frame: &mut Frame) {
200            self.rects.borrow_mut().push(area);
201        }
202    }
203
204    #[test]
205    fn equal_columns_split_evenly() {
206        let (a, a_rects) = Record::new();
207        let (b, b_rects) = Record::new();
208        let (c, c_rects) = Record::new();
209
210        let columns = Columns::new().add(a).add(b).add(c).gap(0);
211
212        let mut pool = GraphemePool::new();
213        let mut frame = Frame::new(12, 2, &mut pool);
214        columns.render(Rect::new(0, 0, 12, 2), &mut frame);
215
216        let a = a_rects.borrow()[0];
217        let b = b_rects.borrow()[0];
218        let c = c_rects.borrow()[0];
219
220        assert_eq!(a, Rect::new(0, 0, 4, 2));
221        assert_eq!(b, Rect::new(4, 0, 4, 2));
222        assert_eq!(c, Rect::new(8, 0, 4, 2));
223    }
224
225    #[test]
226    fn fixed_columns_with_gap() {
227        let (a, a_rects) = Record::new();
228        let (b, b_rects) = Record::new();
229
230        let columns = Columns::new()
231            .column(a, Constraint::Fixed(4))
232            .column(b, Constraint::Fixed(4))
233            .gap(2);
234
235        let mut pool = GraphemePool::new();
236        let mut frame = Frame::new(20, 1, &mut pool);
237        columns.render(Rect::new(0, 0, 20, 1), &mut frame);
238
239        let a = a_rects.borrow()[0];
240        let b = b_rects.borrow()[0];
241
242        assert_eq!(a, Rect::new(0, 0, 4, 1));
243        assert_eq!(b, Rect::new(6, 0, 4, 1));
244    }
245
246    #[test]
247    fn ratio_columns_split_proportionally() {
248        let (a, a_rects) = Record::new();
249        let (b, b_rects) = Record::new();
250
251        let columns = Columns::new()
252            .column(a, Constraint::Ratio(1, 3))
253            .column(b, Constraint::Ratio(2, 3));
254
255        let mut pool = GraphemePool::new();
256        let mut frame = Frame::new(30, 1, &mut pool);
257        columns.render(Rect::new(0, 0, 30, 1), &mut frame);
258
259        let a = a_rects.borrow()[0];
260        let b = b_rects.borrow()[0];
261
262        assert_eq!(a.width + b.width, 30);
263        assert_eq!(a.width, 10);
264        assert_eq!(b.width, 20);
265    }
266
267    #[test]
268    fn column_padding_applies_to_child_area() {
269        let (a, a_rects) = Record::new();
270        let columns =
271            Columns::new().push(Column::new(a, Constraint::Fixed(6)).padding(Sides::all(1)));
272
273        let mut pool = GraphemePool::new();
274        let mut frame = Frame::new(6, 3, &mut pool);
275        columns.render(Rect::new(0, 0, 6, 3), &mut frame);
276
277        let rect = a_rects.borrow()[0];
278        assert_eq!(rect, Rect::new(1, 1, 4, 1));
279    }
280
281    #[test]
282    fn empty_columns_does_not_panic() {
283        let columns = Columns::new();
284        let mut pool = GraphemePool::new();
285        let mut frame = Frame::new(10, 5, &mut pool);
286        columns.render(Rect::new(0, 0, 10, 5), &mut frame);
287    }
288
289    #[test]
290    fn zero_area_does_not_panic() {
291        let (a, a_rects) = Record::new();
292        let columns = Columns::new().add(a);
293        let mut pool = GraphemePool::new();
294        let mut frame = Frame::new(1, 1, &mut pool);
295        columns.render(Rect::new(0, 0, 0, 0), &mut frame);
296        assert!(a_rects.borrow().is_empty());
297    }
298
299    #[test]
300    fn single_column_gets_full_width() {
301        let (a, a_rects) = Record::new();
302        let columns = Columns::new().column(a, Constraint::Min(0));
303
304        let mut pool = GraphemePool::new();
305        let mut frame = Frame::new(20, 3, &mut pool);
306        columns.render(Rect::new(0, 0, 20, 3), &mut frame);
307
308        let rect = a_rects.borrow()[0];
309        assert_eq!(rect.width, 20);
310        assert_eq!(rect.height, 3);
311    }
312
313    #[test]
314    fn fixed_and_fill_columns() {
315        let (a, a_rects) = Record::new();
316        let (b, b_rects) = Record::new();
317
318        let columns = Columns::new()
319            .column(a, Constraint::Fixed(5))
320            .column(b, Constraint::Min(0));
321
322        let mut pool = GraphemePool::new();
323        let mut frame = Frame::new(20, 1, &mut pool);
324        columns.render(Rect::new(0, 0, 20, 1), &mut frame);
325
326        let a = a_rects.borrow()[0];
327        let b = b_rects.borrow()[0];
328        assert_eq!(a.width, 5);
329        assert_eq!(b.width, 15);
330    }
331
332    #[test]
333    fn is_essential_delegates_to_children() {
334        struct Essential;
335        impl Widget for Essential {
336            fn render(&self, _area: Rect, _frame: &mut Frame) {}
337            fn is_essential(&self) -> bool {
338                true
339            }
340        }
341
342        let columns = Columns::new().add(Essential);
343        assert!(columns.is_essential());
344
345        let (non_essential, _) = Record::new();
346        let columns2 = Columns::new().add(non_essential);
347        assert!(!columns2.is_essential());
348    }
349
350    #[test]
351    fn column_constraint_setter() {
352        let (a, _) = Record::new();
353        let col = Column::new(a, Constraint::Fixed(5)).constraint(Constraint::Fixed(10));
354        assert_eq!(col.constraint, Constraint::Fixed(10));
355    }
356
357    #[test]
358    fn all_columns_receive_same_height() {
359        let (a, a_rects) = Record::new();
360        let (b, b_rects) = Record::new();
361        let (c, c_rects) = Record::new();
362
363        let columns = Columns::new().add(a).add(b).add(c);
364
365        let mut pool = GraphemePool::new();
366        let mut frame = Frame::new(12, 5, &mut pool);
367        columns.render(Rect::new(0, 0, 12, 5), &mut frame);
368
369        let a = a_rects.borrow()[0];
370        let b = b_rects.borrow()[0];
371        let c = c_rects.borrow()[0];
372
373        assert_eq!(a.height, 5);
374        assert_eq!(b.height, 5);
375        assert_eq!(c.height, 5);
376    }
377
378    #[test]
379    fn column_debug_format() {
380        let (a, _) = Record::new();
381        let col = Column::new(a, Constraint::Fixed(5));
382        let dbg = format!("{:?}", col);
383        assert!(dbg.contains("Column"));
384        assert!(dbg.contains("<dyn Widget>"));
385    }
386
387    #[test]
388    fn columns_default_is_empty() {
389        let cols = Columns::default();
390        assert!(cols.columns.is_empty());
391        assert_eq!(cols.gap, 0);
392    }
393
394    #[test]
395    fn column_builder_chain() {
396        let (a, _) = Record::new();
397        let col = Column::new(a, Constraint::Fixed(5))
398            .padding(Sides::all(2))
399            .constraint(Constraint::Ratio(1, 3));
400        assert_eq!(col.constraint, Constraint::Ratio(1, 3));
401        assert_eq!(col.padding, Sides::all(2));
402    }
403
404    #[test]
405    fn many_columns_with_gap() {
406        let mut rects_all = Vec::new();
407        let mut cols = Columns::new().gap(1);
408        for _ in 0..5 {
409            let (rec, rects) = Record::new();
410            rects_all.push(rects);
411            cols = cols.column(rec, Constraint::Fixed(2));
412        }
413
414        let mut pool = GraphemePool::new();
415        let mut frame = Frame::new(20, 1, &mut pool);
416        cols.render(Rect::new(0, 0, 20, 1), &mut frame);
417
418        // 5 fixed cols of width 2 with gap 1 between them
419        for (i, rects) in rects_all.iter().enumerate() {
420            let r = rects.borrow()[0];
421            assert_eq!(r.width, 2, "column {i} should be width 2");
422        }
423
424        // Ensure no overlap
425        for i in 0..4 {
426            let a = rects_all[i].borrow()[0];
427            let b = rects_all[i + 1].borrow()[0];
428            assert!(
429                b.x >= a.right(),
430                "column {} (right={}) overlaps column {} (x={})",
431                i,
432                a.right(),
433                i + 1,
434                b.x
435            );
436        }
437    }
438
439    #[test]
440    fn skeleton_still_renders_essential_child() {
441        struct Essential;
442
443        impl Widget for Essential {
444            fn render(&self, area: Rect, frame: &mut Frame) {
445                frame.buffer.set(area.x, area.y, Cell::from_char('E'));
446            }
447
448            fn is_essential(&self) -> bool {
449                true
450            }
451        }
452
453        let columns = Columns::new().add(Essential);
454        let mut pool = GraphemePool::new();
455        let mut frame = Frame::new(4, 1, &mut pool);
456        frame.buffer.degradation = DegradationLevel::Skeleton;
457
458        columns.render(Rect::new(0, 0, 4, 1), &mut frame);
459
460        assert_eq!(
461            frame
462                .buffer
463                .get(0, 0)
464                .and_then(|cell| cell.content.as_char()),
465            Some('E')
466        );
467    }
468
469    #[derive(Debug)]
470    struct Marker(char);
471
472    impl Widget for Marker {
473        fn render(&self, area: Rect, frame: &mut Frame) {
474            frame.buffer.set(area.x, area.y, Cell::from_char(self.0));
475        }
476    }
477
478    #[test]
479    fn render_fewer_columns_clears_removed_column_output() {
480        let two = Columns::new().add(Marker('A')).add(Marker('B'));
481        let one = Columns::new().add(Marker('A'));
482        let area = Rect::new(0, 0, 8, 1);
483        let mut pool = GraphemePool::new();
484        let mut frame = Frame::new(8, 1, &mut pool);
485
486        two.render(area, &mut frame);
487        one.render(area, &mut frame);
488
489        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
490        assert!(frame.buffer.get(4, 0).unwrap().is_empty());
491    }
492
493    #[test]
494    fn empty_columns_clears_previous_content() {
495        let filled = Columns::new().add(Marker('A'));
496        let empty = Columns::new();
497        let area = Rect::new(0, 0, 4, 1);
498        let mut pool = GraphemePool::new();
499        let mut frame = Frame::new(4, 1, &mut pool);
500
501        filled.render(area, &mut frame);
502        empty.render(area, &mut frame);
503
504        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
505        assert!(frame.buffer.get(3, 0).unwrap().is_empty());
506    }
507}