Skip to main content

ftui_widgets/
padding.rs

1#![forbid(unsafe_code)]
2
3//! Padding container widget.
4//!
5//! This is a small compositional building block: it shrinks the render area
6//! passed to a child widget by applying [`Sides`] padding, and uses the
7//! buffer's scissor stack to guarantee the child cannot write outside the
8//! padded inner rectangle.
9
10use crate::{StatefulWidget, Widget};
11use ftui_core::geometry::{Rect, Sides};
12use ftui_render::frame::Frame;
13
14/// A widget wrapper that applies padding around an inner widget.
15#[derive(Debug, Clone)]
16pub struct Padding<W> {
17    inner: W,
18    padding: Sides,
19}
20
21impl<W> Padding<W> {
22    /// Create a new padding wrapper.
23    pub const fn new(inner: W, padding: Sides) -> Self {
24        Self { inner, padding }
25    }
26
27    /// Set the padding (builder-style).
28    #[must_use]
29    pub const fn padding(mut self, padding: Sides) -> Self {
30        self.padding = padding;
31        self
32    }
33
34    /// Get the configured padding.
35    pub const fn padding_sides(&self) -> Sides {
36        self.padding
37    }
38
39    /// Compute the inner rect for a given outer area.
40    #[inline]
41    pub fn inner_area(&self, area: Rect) -> Rect {
42        area.inner(self.padding)
43    }
44
45    /// Get a shared reference to the inner widget.
46    pub const fn inner(&self) -> &W {
47        &self.inner
48    }
49
50    /// Get a mutable reference to the inner widget.
51    pub fn inner_mut(&mut self) -> &mut W {
52        &mut self.inner
53    }
54
55    /// Consume and return the inner widget.
56    pub fn into_inner(self) -> W {
57        self.inner
58    }
59}
60
61struct ScissorGuard<'a, 'pool> {
62    frame: &'a mut Frame<'pool>,
63}
64
65impl<'a, 'pool> ScissorGuard<'a, 'pool> {
66    fn new(frame: &'a mut Frame<'pool>, rect: Rect) -> Self {
67        frame.buffer.push_scissor(rect);
68        Self { frame }
69    }
70}
71
72impl Drop for ScissorGuard<'_, '_> {
73    fn drop(&mut self) {
74        self.frame.buffer.pop_scissor();
75    }
76}
77
78impl<W: Widget> Widget for Padding<W> {
79    fn render(&self, area: Rect, frame: &mut Frame) {
80        #[cfg(feature = "tracing")]
81        let _span = tracing::debug_span!(
82            "widget_render",
83            widget = "Padding",
84            x = area.x,
85            y = area.y,
86            w = area.width,
87            h = area.height
88        )
89        .entered();
90
91        if area.is_empty() {
92            return;
93        }
94
95        // Padding owns the full wrapper rect. Clear stale child glyphs before
96        // rendering the current inner area while preserving any parent-applied
97        // styling already present in the buffer.
98        for y in area.y..area.bottom() {
99            for x in area.x..area.right() {
100                if let Some(cell) = frame.buffer.get_mut(x, y) {
101                    cell.content = ftui_render::cell::CellContent::EMPTY;
102                }
103            }
104        }
105
106        let inner = self.inner_area(area);
107        if inner.is_empty() {
108            return;
109        }
110
111        let guard = ScissorGuard::new(frame, inner);
112        self.inner.render(inner, guard.frame);
113    }
114
115    fn is_essential(&self) -> bool {
116        self.inner.is_essential()
117    }
118}
119
120impl<W: StatefulWidget> StatefulWidget for Padding<W> {
121    type State = W::State;
122
123    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
124        #[cfg(feature = "tracing")]
125        let _span = tracing::debug_span!(
126            "widget_render",
127            widget = "PaddingStateful",
128            x = area.x,
129            y = area.y,
130            w = area.width,
131            h = area.height
132        )
133        .entered();
134
135        if area.is_empty() {
136            return;
137        }
138
139        for y in area.y..area.bottom() {
140            for x in area.x..area.right() {
141                if let Some(cell) = frame.buffer.get_mut(x, y) {
142                    cell.content = ftui_render::cell::CellContent::EMPTY;
143                }
144            }
145        }
146
147        let inner = self.inner_area(area);
148        if inner.is_empty() {
149            return;
150        }
151
152        let guard = ScissorGuard::new(frame, inner);
153        self.inner.render(inner, guard.frame, state);
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use ftui_render::buffer::Buffer;
161    use ftui_render::cell::Cell;
162    use ftui_render::grapheme_pool::GraphemePool;
163
164    fn buf_to_lines(buf: &Buffer) -> Vec<String> {
165        let mut lines = Vec::new();
166        for y in 0..buf.height() {
167            let mut row = String::with_capacity(buf.width() as usize);
168            for x in 0..buf.width() {
169                let ch = buf
170                    .get(x, y)
171                    .and_then(|c| c.content.as_char())
172                    .unwrap_or(' ');
173                row.push(ch);
174            }
175            lines.push(row);
176        }
177        lines
178    }
179
180    #[derive(Debug, Clone, Copy)]
181    struct Fill(char);
182
183    impl Widget for Fill {
184        fn render(&self, area: Rect, frame: &mut Frame) {
185            for y in area.y..area.bottom() {
186                for x in area.x..area.right() {
187                    frame.buffer.set(x, y, Cell::from_char(self.0));
188                }
189            }
190        }
191    }
192
193    #[derive(Debug, Clone, Copy)]
194    struct Naughty;
195
196    impl Widget for Naughty {
197        fn render(&self, _area: Rect, frame: &mut Frame) {
198            // Intentionally ignore the provided area and attempt to write outside.
199            frame.buffer.set(0, 0, Cell::from_char('X'));
200            frame.buffer.set(2, 2, Cell::from_char('Y'));
201        }
202    }
203
204    #[derive(Debug, Clone, Copy)]
205    struct Boom;
206
207    impl Widget for Boom {
208        fn render(&self, _area: Rect, _frame: &mut Frame) {
209            unreachable!("boom");
210        }
211    }
212
213    #[test]
214    fn inner_area_zero_padding_is_identity() {
215        let pad = Padding::new(Fill('A'), Sides::all(0));
216        let area = Rect::new(3, 4, 10, 7);
217        assert_eq!(pad.inner_area(area), area);
218    }
219
220    #[test]
221    fn inner_area_asymmetric_padding() {
222        let pad = Padding::new(Fill('A'), Sides::new(1, 2, 1, 3));
223        let area = Rect::new(0, 0, 10, 4);
224        assert_eq!(pad.inner_area(area), Rect::new(3, 1, 5, 2));
225    }
226
227    #[test]
228    fn inner_area_clamps_when_padding_exceeds_area() {
229        let pad = Padding::new(Fill('A'), Sides::all(5));
230        let inner = pad.inner_area(Rect::new(0, 0, 2, 2));
231        assert_eq!(inner.width, 0);
232        assert_eq!(inner.height, 0);
233    }
234
235    #[test]
236    fn render_padding_shifts_child_and_leaves_gutter_blank() {
237        let pad = Padding::new(Fill('A'), Sides::all(1));
238        let area = Rect::from_size(5, 5);
239        let mut pool = GraphemePool::new();
240        let mut frame = Frame::new(5, 5, &mut pool);
241        pad.render(area, &mut frame);
242
243        assert_eq!(
244            buf_to_lines(&frame.buffer),
245            vec![
246                "     ".to_string(),
247                " AAA ".to_string(),
248                " AAA ".to_string(),
249                " AAA ".to_string(),
250                "     ".to_string(),
251            ]
252        );
253    }
254
255    #[test]
256    fn render_is_clipped_to_inner_rect_via_scissor() {
257        let pad = Padding::new(Naughty, Sides::all(1));
258        let area = Rect::from_size(5, 5);
259        let mut pool = GraphemePool::new();
260        let mut frame = Frame::new(5, 5, &mut pool);
261        pad.render(area, &mut frame);
262
263        // (0,0) is outside the inner rect, so it must not be written.
264        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
265        // (2,2) is inside the inner rect and should be written.
266        assert_eq!(frame.buffer.get(2, 2).unwrap().content.as_char(), Some('Y'));
267    }
268
269    #[test]
270    fn scissor_stack_restores_on_panic() {
271        let pad = Padding::new(Boom, Sides::all(1));
272        let area = Rect::from_size(5, 5);
273        let mut pool = GraphemePool::new();
274        let mut frame = Frame::new(5, 5, &mut pool);
275        assert_eq!(frame.buffer.scissor_depth(), 1);
276
277        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
278            pad.render(area, &mut frame);
279        }));
280        assert!(result.is_err());
281        assert_eq!(frame.buffer.scissor_depth(), 1);
282    }
283
284    #[test]
285    fn render_empty_area_is_noop() {
286        let pad = Padding::new(Fill('X'), Sides::all(1));
287        let area = Rect::new(0, 0, 0, 0);
288        let mut pool = GraphemePool::new();
289        let mut frame = Frame::new(5, 5, &mut pool);
290        pad.render(area, &mut frame);
291        for y in 0..5 {
292            for x in 0..5u16 {
293                assert!(frame.buffer.get(x, y).unwrap().is_empty());
294            }
295        }
296    }
297
298    #[test]
299    fn padding_larger_than_area_renders_nothing() {
300        let pad = Padding::new(Fill('X'), Sides::all(10));
301        let area = Rect::from_size(5, 5);
302        let mut pool = GraphemePool::new();
303        let mut frame = Frame::new(5, 5, &mut pool);
304        pad.render(area, &mut frame);
305        // Inner area is empty, so nothing should be rendered
306        for y in 0..5 {
307            for x in 0..5u16 {
308                assert!(frame.buffer.get(x, y).unwrap().is_empty());
309            }
310        }
311    }
312
313    #[test]
314    fn smaller_inner_area_clears_old_content() {
315        let area = Rect::from_size(5, 3);
316        let mut pool = GraphemePool::new();
317        let mut frame = Frame::new(5, 3, &mut pool);
318
319        Padding::new(Fill('X'), Sides::all(0)).render(area, &mut frame);
320        Padding::new(Fill('O'), Sides::all(1)).render(area, &mut frame);
321
322        assert_eq!(
323            buf_to_lines(&frame.buffer),
324            vec![
325                "     ".to_string(),
326                " OOO ".to_string(),
327                "     ".to_string()
328            ]
329        );
330    }
331
332    #[test]
333    fn empty_inner_area_clears_previous_content() {
334        let area = Rect::from_size(5, 3);
335        let mut pool = GraphemePool::new();
336        let mut frame = Frame::new(5, 3, &mut pool);
337
338        Padding::new(Fill('X'), Sides::all(0)).render(area, &mut frame);
339        Padding::new(Fill('O'), Sides::all(10)).render(area, &mut frame);
340
341        for y in 0..area.height {
342            for x in 0..area.width {
343                assert!(frame.buffer.get(x, y).unwrap().is_empty());
344            }
345        }
346    }
347
348    #[test]
349    fn asymmetric_padding_top_left() {
350        let pad = Padding::new(Fill('A'), Sides::new(2, 0, 0, 1));
351        let area = Rect::from_size(5, 5);
352        let mut pool = GraphemePool::new();
353        let mut frame = Frame::new(5, 5, &mut pool);
354        pad.render(area, &mut frame);
355
356        let lines = buf_to_lines(&frame.buffer);
357        // top=2, right=0, bottom=0, left=1
358        assert_eq!(lines[0], "     "); // top padding row 0
359        assert_eq!(lines[1], "     "); // top padding row 1
360        assert_eq!(lines[2], " AAAA"); // content starts at x=1
361        assert_eq!(lines[3], " AAAA");
362        assert_eq!(lines[4], " AAAA");
363    }
364
365    #[test]
366    fn padding_sides_accessor() {
367        let pad = Padding::new(Fill('A'), Sides::new(1, 2, 3, 4));
368        let s = pad.padding_sides();
369        assert_eq!(s.top, 1);
370        assert_eq!(s.right, 2);
371        assert_eq!(s.bottom, 3);
372        assert_eq!(s.left, 4);
373    }
374
375    #[test]
376    fn inner_accessor() {
377        let pad = Padding::new(Fill('A'), Sides::all(0));
378        assert_eq!(pad.inner().0, 'A');
379    }
380
381    #[test]
382    fn inner_mut_accessor() {
383        let mut pad = Padding::new(Fill('A'), Sides::all(0));
384        pad.inner_mut().0 = 'B';
385        assert_eq!(pad.inner().0, 'B');
386    }
387
388    #[test]
389    fn into_inner() {
390        let pad = Padding::new(Fill('Z'), Sides::all(0));
391        let inner = pad.into_inner();
392        assert_eq!(inner.0, 'Z');
393    }
394
395    #[test]
396    fn padding_builder() {
397        let pad = Padding::new(Fill('A'), Sides::all(0)).padding(Sides::all(2));
398        assert_eq!(pad.padding_sides(), Sides::all(2));
399    }
400
401    #[test]
402    fn is_essential_delegates_to_inner() {
403        #[derive(Debug, Clone, Copy)]
404        struct Essential;
405        impl Widget for Essential {
406            fn render(&self, _: Rect, _: &mut Frame) {}
407            fn is_essential(&self) -> bool {
408                true
409            }
410        }
411
412        let non_essential = Padding::new(Fill('A'), Sides::all(0));
413        assert!(!non_essential.is_essential());
414
415        let essential = Padding::new(Essential, Sides::all(0));
416        assert!(essential.is_essential());
417    }
418
419    #[test]
420    fn stateful_render_with_padding() {
421        #[derive(Debug, Clone, Copy)]
422        struct StateFill(char);
423
424        impl StatefulWidget for StateFill {
425            type State = usize;
426            fn render(&self, area: Rect, frame: &mut Frame, state: &mut usize) {
427                *state += 1;
428                for y in area.y..area.bottom() {
429                    for x in area.x..area.right() {
430                        frame.buffer.set(x, y, Cell::from_char(self.0));
431                    }
432                }
433            }
434        }
435
436        let pad = Padding::new(StateFill('S'), Sides::all(1));
437        let area = Rect::from_size(5, 5);
438        let mut pool = GraphemePool::new();
439        let mut frame = Frame::new(5, 5, &mut pool);
440        let mut state: usize = 0;
441        StatefulWidget::render(&pad, area, &mut frame, &mut state);
442
443        assert_eq!(state, 1);
444        let lines = buf_to_lines(&frame.buffer);
445        assert_eq!(lines[0], "     ");
446        assert_eq!(lines[1], " SSS ");
447        assert_eq!(lines[2], " SSS ");
448    }
449
450    #[test]
451    fn stateful_smaller_inner_area_clears_old_content() {
452        #[derive(Debug, Clone, Copy)]
453        struct StateFill(char);
454
455        impl StatefulWidget for StateFill {
456            type State = ();
457
458            fn render(&self, area: Rect, frame: &mut Frame, _state: &mut Self::State) {
459                for y in area.y..area.bottom() {
460                    for x in area.x..area.right() {
461                        frame.buffer.set(x, y, Cell::from_char(self.0));
462                    }
463                }
464            }
465        }
466
467        let area = Rect::from_size(5, 3);
468        let mut pool = GraphemePool::new();
469        let mut frame = Frame::new(5, 3, &mut pool);
470        let mut state = ();
471
472        StatefulWidget::render(
473            &Padding::new(StateFill('X'), Sides::all(0)),
474            area,
475            &mut frame,
476            &mut state,
477        );
478        StatefulWidget::render(
479            &Padding::new(StateFill('O'), Sides::all(1)),
480            area,
481            &mut frame,
482            &mut state,
483        );
484
485        assert_eq!(
486            buf_to_lines(&frame.buffer),
487            vec![
488                "     ".to_string(),
489                " OOO ".to_string(),
490                "     ".to_string()
491            ]
492        );
493    }
494
495    #[test]
496    fn large_padding_single_cell_inner() {
497        let pad = Padding::new(Fill('X'), Sides::new(1, 1, 1, 1));
498        let area = Rect::from_size(3, 3);
499        let mut pool = GraphemePool::new();
500        let mut frame = Frame::new(3, 3, &mut pool);
501        pad.render(area, &mut frame);
502
503        let lines = buf_to_lines(&frame.buffer);
504        assert_eq!(lines[0], "   ");
505        assert_eq!(lines[1], " X ");
506        assert_eq!(lines[2], "   ");
507    }
508
509    #[test]
510    fn naughty_widget_with_asymmetric_padding() {
511        // Test that scissor correctly clips even with non-uniform padding
512        let pad = Padding::new(Naughty, Sides::new(0, 0, 0, 2));
513        let area = Rect::from_size(5, 3);
514        let mut pool = GraphemePool::new();
515        let mut frame = Frame::new(5, 3, &mut pool);
516        pad.render(area, &mut frame);
517
518        // Naughty writes at (0,0) which is outside inner (x>=2), should be clipped
519        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
520        // (2,2) is inside inner, should be written
521        assert_eq!(frame.buffer.get(2, 2).unwrap().content.as_char(), Some('Y'));
522    }
523}