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 ratio 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
91            .push(Column::new(widget, Constraint::Ratio(1, 1)));
92        self
93    }
94}
95
96struct ScissorGuard<'a, 'pool> {
97    frame: &'a mut Frame<'pool>,
98}
99
100impl<'a, 'pool> ScissorGuard<'a, 'pool> {
101    fn new(frame: &'a mut Frame<'pool>, rect: Rect) -> Self {
102        frame.buffer.push_scissor(rect);
103        Self { frame }
104    }
105
106    fn frame_mut(&mut self) -> &mut Frame<'pool> {
107        self.frame
108    }
109}
110
111impl Drop for ScissorGuard<'_, '_> {
112    fn drop(&mut self) {
113        self.frame.buffer.pop_scissor();
114    }
115}
116
117impl Widget for Columns<'_> {
118    fn render(&self, area: Rect, frame: &mut Frame) {
119        #[cfg(feature = "tracing")]
120        let _span = tracing::debug_span!(
121            "widget_render",
122            widget = "Columns",
123            x = area.x,
124            y = area.y,
125            w = area.width,
126            h = area.height
127        )
128        .entered();
129
130        if area.is_empty() || self.columns.is_empty() {
131            return;
132        }
133
134        if !frame.buffer.degradation.render_content() {
135            return;
136        }
137
138        let flex = Flex::horizontal()
139            .gap(self.gap)
140            .constraints(self.columns.iter().map(|c| c.constraint));
141        let rects = flex.split(area);
142
143        for (col, rect) in self.columns.iter().zip(rects) {
144            if rect.is_empty() {
145                continue;
146            }
147            let inner = rect.inner(col.padding);
148            if inner.is_empty() {
149                continue;
150            }
151
152            let mut guard = ScissorGuard::new(frame, inner);
153            col.widget.render(inner, guard.frame_mut());
154        }
155    }
156
157    fn is_essential(&self) -> bool {
158        self.columns.iter().any(|c| c.widget.is_essential())
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use ftui_render::grapheme_pool::GraphemePool;
166    use std::cell::RefCell;
167    use std::rc::Rc;
168
169    #[derive(Clone, Debug)]
170    struct Record {
171        rects: Rc<RefCell<Vec<Rect>>>,
172    }
173
174    impl Record {
175        fn new() -> (Self, Rc<RefCell<Vec<Rect>>>) {
176            let rects = Rc::new(RefCell::new(Vec::new()));
177            (
178                Self {
179                    rects: rects.clone(),
180                },
181                rects,
182            )
183        }
184    }
185
186    impl Widget for Record {
187        fn render(&self, area: Rect, _frame: &mut Frame) {
188            self.rects.borrow_mut().push(area);
189        }
190    }
191
192    #[test]
193    fn equal_columns_split_evenly() {
194        let (a, a_rects) = Record::new();
195        let (b, b_rects) = Record::new();
196        let (c, c_rects) = Record::new();
197
198        let columns = Columns::new().add(a).add(b).add(c).gap(0);
199
200        let mut pool = GraphemePool::new();
201        let mut frame = Frame::new(12, 2, &mut pool);
202        columns.render(Rect::new(0, 0, 12, 2), &mut frame);
203
204        let a = a_rects.borrow()[0];
205        let b = b_rects.borrow()[0];
206        let c = c_rects.borrow()[0];
207
208        assert_eq!(a, Rect::new(0, 0, 4, 2));
209        assert_eq!(b, Rect::new(4, 0, 4, 2));
210        assert_eq!(c, Rect::new(8, 0, 4, 2));
211    }
212
213    #[test]
214    fn fixed_columns_with_gap() {
215        let (a, a_rects) = Record::new();
216        let (b, b_rects) = Record::new();
217
218        let columns = Columns::new()
219            .column(a, Constraint::Fixed(4))
220            .column(b, Constraint::Fixed(4))
221            .gap(2);
222
223        let mut pool = GraphemePool::new();
224        let mut frame = Frame::new(20, 1, &mut pool);
225        columns.render(Rect::new(0, 0, 20, 1), &mut frame);
226
227        let a = a_rects.borrow()[0];
228        let b = b_rects.borrow()[0];
229
230        assert_eq!(a, Rect::new(0, 0, 4, 1));
231        assert_eq!(b, Rect::new(6, 0, 4, 1));
232    }
233
234    #[test]
235    fn ratio_columns_split_proportionally() {
236        let (a, a_rects) = Record::new();
237        let (b, b_rects) = Record::new();
238
239        let columns = Columns::new()
240            .column(a, Constraint::Ratio(1, 3))
241            .column(b, Constraint::Ratio(2, 3));
242
243        let mut pool = GraphemePool::new();
244        let mut frame = Frame::new(30, 1, &mut pool);
245        columns.render(Rect::new(0, 0, 30, 1), &mut frame);
246
247        let a = a_rects.borrow()[0];
248        let b = b_rects.borrow()[0];
249
250        assert_eq!(a.width + b.width, 30);
251        assert_eq!(a.width, 10);
252        assert_eq!(b.width, 20);
253    }
254
255    #[test]
256    fn column_padding_applies_to_child_area() {
257        let (a, a_rects) = Record::new();
258        let columns =
259            Columns::new().push(Column::new(a, Constraint::Fixed(6)).padding(Sides::all(1)));
260
261        let mut pool = GraphemePool::new();
262        let mut frame = Frame::new(6, 3, &mut pool);
263        columns.render(Rect::new(0, 0, 6, 3), &mut frame);
264
265        let rect = a_rects.borrow()[0];
266        assert_eq!(rect, Rect::new(1, 1, 4, 1));
267    }
268
269    #[test]
270    fn empty_columns_does_not_panic() {
271        let columns = Columns::new();
272        let mut pool = GraphemePool::new();
273        let mut frame = Frame::new(10, 5, &mut pool);
274        columns.render(Rect::new(0, 0, 10, 5), &mut frame);
275    }
276
277    #[test]
278    fn zero_area_does_not_panic() {
279        let (a, a_rects) = Record::new();
280        let columns = Columns::new().add(a);
281        let mut pool = GraphemePool::new();
282        let mut frame = Frame::new(1, 1, &mut pool);
283        columns.render(Rect::new(0, 0, 0, 0), &mut frame);
284        assert!(a_rects.borrow().is_empty());
285    }
286
287    #[test]
288    fn single_column_gets_full_width() {
289        let (a, a_rects) = Record::new();
290        let columns = Columns::new().column(a, Constraint::Min(0));
291
292        let mut pool = GraphemePool::new();
293        let mut frame = Frame::new(20, 3, &mut pool);
294        columns.render(Rect::new(0, 0, 20, 3), &mut frame);
295
296        let rect = a_rects.borrow()[0];
297        assert_eq!(rect.width, 20);
298        assert_eq!(rect.height, 3);
299    }
300
301    #[test]
302    fn fixed_and_fill_columns() {
303        let (a, a_rects) = Record::new();
304        let (b, b_rects) = Record::new();
305
306        let columns = Columns::new()
307            .column(a, Constraint::Fixed(5))
308            .column(b, Constraint::Min(0));
309
310        let mut pool = GraphemePool::new();
311        let mut frame = Frame::new(20, 1, &mut pool);
312        columns.render(Rect::new(0, 0, 20, 1), &mut frame);
313
314        let a = a_rects.borrow()[0];
315        let b = b_rects.borrow()[0];
316        assert_eq!(a.width, 5);
317        assert_eq!(b.width, 15);
318    }
319
320    #[test]
321    fn is_essential_delegates_to_children() {
322        struct Essential;
323        impl Widget for Essential {
324            fn render(&self, _area: Rect, _frame: &mut Frame) {}
325            fn is_essential(&self) -> bool {
326                true
327            }
328        }
329
330        let columns = Columns::new().add(Essential);
331        assert!(columns.is_essential());
332
333        let (non_essential, _) = Record::new();
334        let columns2 = Columns::new().add(non_essential);
335        assert!(!columns2.is_essential());
336    }
337
338    #[test]
339    fn column_constraint_setter() {
340        let (a, _) = Record::new();
341        let col = Column::new(a, Constraint::Fixed(5)).constraint(Constraint::Fixed(10));
342        assert_eq!(col.constraint, Constraint::Fixed(10));
343    }
344
345    #[test]
346    fn all_columns_receive_same_height() {
347        let (a, a_rects) = Record::new();
348        let (b, b_rects) = Record::new();
349        let (c, c_rects) = Record::new();
350
351        let columns = Columns::new().add(a).add(b).add(c);
352
353        let mut pool = GraphemePool::new();
354        let mut frame = Frame::new(12, 5, &mut pool);
355        columns.render(Rect::new(0, 0, 12, 5), &mut frame);
356
357        let a = a_rects.borrow()[0];
358        let b = b_rects.borrow()[0];
359        let c = c_rects.borrow()[0];
360
361        assert_eq!(a.height, 5);
362        assert_eq!(b.height, 5);
363        assert_eq!(c.height, 5);
364    }
365
366    #[test]
367    fn column_debug_format() {
368        let (a, _) = Record::new();
369        let col = Column::new(a, Constraint::Fixed(5));
370        let dbg = format!("{:?}", col);
371        assert!(dbg.contains("Column"));
372        assert!(dbg.contains("<dyn Widget>"));
373    }
374
375    #[test]
376    fn columns_default_is_empty() {
377        let cols = Columns::default();
378        assert!(cols.columns.is_empty());
379        assert_eq!(cols.gap, 0);
380    }
381
382    #[test]
383    fn column_builder_chain() {
384        let (a, _) = Record::new();
385        let col = Column::new(a, Constraint::Fixed(5))
386            .padding(Sides::all(2))
387            .constraint(Constraint::Ratio(1, 3));
388        assert_eq!(col.constraint, Constraint::Ratio(1, 3));
389        assert_eq!(col.padding, Sides::all(2));
390    }
391
392    #[test]
393    fn many_columns_with_gap() {
394        let mut rects_all = Vec::new();
395        let mut cols = Columns::new().gap(1);
396        for _ in 0..5 {
397            let (rec, rects) = Record::new();
398            rects_all.push(rects);
399            cols = cols.column(rec, Constraint::Fixed(2));
400        }
401
402        let mut pool = GraphemePool::new();
403        let mut frame = Frame::new(20, 1, &mut pool);
404        cols.render(Rect::new(0, 0, 20, 1), &mut frame);
405
406        // 5 fixed cols of width 2 with gap 1 between them
407        for (i, rects) in rects_all.iter().enumerate() {
408            let r = rects.borrow()[0];
409            assert_eq!(r.width, 2, "column {i} should be width 2");
410        }
411
412        // Ensure no overlap
413        for i in 0..4 {
414            let a = rects_all[i].borrow()[0];
415            let b = rects_all[i + 1].borrow()[0];
416            assert!(
417                b.x >= a.right(),
418                "column {} (right={}) overlaps column {} (x={})",
419                i,
420                a.right(),
421                i + 1,
422                b.x
423            );
424        }
425    }
426}