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