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    pub const fn padding(mut self, padding: Sides) -> Self {
29        self.padding = padding;
30        self
31    }
32
33    /// Get the configured padding.
34    pub const fn padding_sides(&self) -> Sides {
35        self.padding
36    }
37
38    /// Compute the inner rect for a given outer area.
39    #[inline]
40    pub fn inner_area(&self, area: Rect) -> Rect {
41        area.inner(self.padding)
42    }
43
44    /// Get a shared reference to the inner widget.
45    pub const fn inner(&self) -> &W {
46        &self.inner
47    }
48
49    /// Get a mutable reference to the inner widget.
50    pub fn inner_mut(&mut self) -> &mut W {
51        &mut self.inner
52    }
53
54    /// Consume and return the inner widget.
55    pub fn into_inner(self) -> W {
56        self.inner
57    }
58}
59
60struct ScissorGuard<'a, 'pool> {
61    frame: &'a mut Frame<'pool>,
62}
63
64impl<'a, 'pool> ScissorGuard<'a, 'pool> {
65    fn new(frame: &'a mut Frame<'pool>, rect: Rect) -> Self {
66        frame.buffer.push_scissor(rect);
67        Self { frame }
68    }
69}
70
71impl Drop for ScissorGuard<'_, '_> {
72    fn drop(&mut self) {
73        self.frame.buffer.pop_scissor();
74    }
75}
76
77impl<W: Widget> Widget for Padding<W> {
78    fn render(&self, area: Rect, frame: &mut Frame) {
79        #[cfg(feature = "tracing")]
80        let _span = tracing::debug_span!(
81            "widget_render",
82            widget = "Padding",
83            x = area.x,
84            y = area.y,
85            w = area.width,
86            h = area.height
87        )
88        .entered();
89
90        if area.is_empty() {
91            return;
92        }
93
94        let inner = self.inner_area(area);
95        if inner.is_empty() {
96            return;
97        }
98
99        let guard = ScissorGuard::new(frame, inner);
100        self.inner.render(inner, guard.frame);
101    }
102
103    fn is_essential(&self) -> bool {
104        self.inner.is_essential()
105    }
106}
107
108impl<W: StatefulWidget> StatefulWidget for Padding<W> {
109    type State = W::State;
110
111    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
112        #[cfg(feature = "tracing")]
113        let _span = tracing::debug_span!(
114            "widget_render",
115            widget = "PaddingStateful",
116            x = area.x,
117            y = area.y,
118            w = area.width,
119            h = area.height
120        )
121        .entered();
122
123        if area.is_empty() {
124            return;
125        }
126
127        let inner = self.inner_area(area);
128        if inner.is_empty() {
129            return;
130        }
131
132        let guard = ScissorGuard::new(frame, inner);
133        self.inner.render(inner, guard.frame, state);
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use ftui_render::buffer::Buffer;
141    use ftui_render::cell::Cell;
142    use ftui_render::grapheme_pool::GraphemePool;
143
144    fn buf_to_lines(buf: &Buffer) -> Vec<String> {
145        let mut lines = Vec::new();
146        for y in 0..buf.height() {
147            let mut row = String::with_capacity(buf.width() as usize);
148            for x in 0..buf.width() {
149                let ch = buf
150                    .get(x, y)
151                    .and_then(|c| c.content.as_char())
152                    .unwrap_or(' ');
153                row.push(ch);
154            }
155            lines.push(row);
156        }
157        lines
158    }
159
160    #[derive(Debug, Clone, Copy)]
161    struct Fill(char);
162
163    impl Widget for Fill {
164        fn render(&self, area: Rect, frame: &mut Frame) {
165            for y in area.y..area.bottom() {
166                for x in area.x..area.right() {
167                    frame.buffer.set(x, y, Cell::from_char(self.0));
168                }
169            }
170        }
171    }
172
173    #[derive(Debug, Clone, Copy)]
174    struct Naughty;
175
176    impl Widget for Naughty {
177        fn render(&self, _area: Rect, frame: &mut Frame) {
178            // Intentionally ignore the provided area and attempt to write outside.
179            frame.buffer.set(0, 0, Cell::from_char('X'));
180            frame.buffer.set(2, 2, Cell::from_char('Y'));
181        }
182    }
183
184    #[derive(Debug, Clone, Copy)]
185    struct Boom;
186
187    impl Widget for Boom {
188        fn render(&self, _area: Rect, _frame: &mut Frame) {
189            unreachable!("boom");
190        }
191    }
192
193    #[test]
194    fn inner_area_zero_padding_is_identity() {
195        let pad = Padding::new(Fill('A'), Sides::all(0));
196        let area = Rect::new(3, 4, 10, 7);
197        assert_eq!(pad.inner_area(area), area);
198    }
199
200    #[test]
201    fn inner_area_asymmetric_padding() {
202        let pad = Padding::new(Fill('A'), Sides::new(1, 2, 1, 3));
203        let area = Rect::new(0, 0, 10, 4);
204        assert_eq!(pad.inner_area(area), Rect::new(3, 1, 5, 2));
205    }
206
207    #[test]
208    fn inner_area_clamps_when_padding_exceeds_area() {
209        let pad = Padding::new(Fill('A'), Sides::all(5));
210        let inner = pad.inner_area(Rect::new(0, 0, 2, 2));
211        assert_eq!(inner.width, 0);
212        assert_eq!(inner.height, 0);
213    }
214
215    #[test]
216    fn render_padding_shifts_child_and_leaves_gutter_blank() {
217        let pad = Padding::new(Fill('A'), Sides::all(1));
218        let area = Rect::from_size(5, 5);
219        let mut pool = GraphemePool::new();
220        let mut frame = Frame::new(5, 5, &mut pool);
221        pad.render(area, &mut frame);
222
223        assert_eq!(
224            buf_to_lines(&frame.buffer),
225            vec![
226                "     ".to_string(),
227                " AAA ".to_string(),
228                " AAA ".to_string(),
229                " AAA ".to_string(),
230                "     ".to_string(),
231            ]
232        );
233    }
234
235    #[test]
236    fn render_is_clipped_to_inner_rect_via_scissor() {
237        let pad = Padding::new(Naughty, 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        // (0,0) is outside the inner rect, so it must not be written.
244        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
245        // (2,2) is inside the inner rect and should be written.
246        assert_eq!(frame.buffer.get(2, 2).unwrap().content.as_char(), Some('Y'));
247    }
248
249    #[test]
250    fn scissor_stack_restores_on_panic() {
251        let pad = Padding::new(Boom, Sides::all(1));
252        let area = Rect::from_size(5, 5);
253        let mut pool = GraphemePool::new();
254        let mut frame = Frame::new(5, 5, &mut pool);
255        assert_eq!(frame.buffer.scissor_depth(), 1);
256
257        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
258            pad.render(area, &mut frame);
259        }));
260        assert!(result.is_err());
261        assert_eq!(frame.buffer.scissor_depth(), 1);
262    }
263
264    #[test]
265    fn render_empty_area_is_noop() {
266        let pad = Padding::new(Fill('X'), Sides::all(1));
267        let area = Rect::new(0, 0, 0, 0);
268        let mut pool = GraphemePool::new();
269        let mut frame = Frame::new(5, 5, &mut pool);
270        pad.render(area, &mut frame);
271        for y in 0..5 {
272            for x in 0..5u16 {
273                assert!(frame.buffer.get(x, y).unwrap().is_empty());
274            }
275        }
276    }
277
278    #[test]
279    fn padding_larger_than_area_renders_nothing() {
280        let pad = Padding::new(Fill('X'), Sides::all(10));
281        let area = Rect::from_size(5, 5);
282        let mut pool = GraphemePool::new();
283        let mut frame = Frame::new(5, 5, &mut pool);
284        pad.render(area, &mut frame);
285        // Inner area is empty, so nothing should be rendered
286        for y in 0..5 {
287            for x in 0..5u16 {
288                assert!(frame.buffer.get(x, y).unwrap().is_empty());
289            }
290        }
291    }
292
293    #[test]
294    fn asymmetric_padding_top_left() {
295        let pad = Padding::new(Fill('A'), Sides::new(2, 0, 0, 1));
296        let area = Rect::from_size(5, 5);
297        let mut pool = GraphemePool::new();
298        let mut frame = Frame::new(5, 5, &mut pool);
299        pad.render(area, &mut frame);
300
301        let lines = buf_to_lines(&frame.buffer);
302        // top=2, right=0, bottom=0, left=1
303        assert_eq!(lines[0], "     "); // top padding row 0
304        assert_eq!(lines[1], "     "); // top padding row 1
305        assert_eq!(lines[2], " AAAA"); // content starts at x=1
306        assert_eq!(lines[3], " AAAA");
307        assert_eq!(lines[4], " AAAA");
308    }
309
310    #[test]
311    fn padding_sides_accessor() {
312        let pad = Padding::new(Fill('A'), Sides::new(1, 2, 3, 4));
313        let s = pad.padding_sides();
314        assert_eq!(s.top, 1);
315        assert_eq!(s.right, 2);
316        assert_eq!(s.bottom, 3);
317        assert_eq!(s.left, 4);
318    }
319
320    #[test]
321    fn inner_accessor() {
322        let pad = Padding::new(Fill('A'), Sides::all(0));
323        assert_eq!(pad.inner().0, 'A');
324    }
325
326    #[test]
327    fn inner_mut_accessor() {
328        let mut pad = Padding::new(Fill('A'), Sides::all(0));
329        pad.inner_mut().0 = 'B';
330        assert_eq!(pad.inner().0, 'B');
331    }
332
333    #[test]
334    fn into_inner() {
335        let pad = Padding::new(Fill('Z'), Sides::all(0));
336        let inner = pad.into_inner();
337        assert_eq!(inner.0, 'Z');
338    }
339
340    #[test]
341    fn padding_builder() {
342        let pad = Padding::new(Fill('A'), Sides::all(0)).padding(Sides::all(2));
343        assert_eq!(pad.padding_sides(), Sides::all(2));
344    }
345
346    #[test]
347    fn is_essential_delegates_to_inner() {
348        #[derive(Debug, Clone, Copy)]
349        struct Essential;
350        impl Widget for Essential {
351            fn render(&self, _: Rect, _: &mut Frame) {}
352            fn is_essential(&self) -> bool {
353                true
354            }
355        }
356
357        let non_essential = Padding::new(Fill('A'), Sides::all(0));
358        assert!(!non_essential.is_essential());
359
360        let essential = Padding::new(Essential, Sides::all(0));
361        assert!(essential.is_essential());
362    }
363
364    #[test]
365    fn stateful_render_with_padding() {
366        #[derive(Debug, Clone, Copy)]
367        struct StateFill(char);
368
369        impl StatefulWidget for StateFill {
370            type State = usize;
371            fn render(&self, area: Rect, frame: &mut Frame, state: &mut usize) {
372                *state += 1;
373                for y in area.y..area.bottom() {
374                    for x in area.x..area.right() {
375                        frame.buffer.set(x, y, Cell::from_char(self.0));
376                    }
377                }
378            }
379        }
380
381        let pad = Padding::new(StateFill('S'), Sides::all(1));
382        let area = Rect::from_size(5, 5);
383        let mut pool = GraphemePool::new();
384        let mut frame = Frame::new(5, 5, &mut pool);
385        let mut state: usize = 0;
386        StatefulWidget::render(&pad, area, &mut frame, &mut state);
387
388        assert_eq!(state, 1);
389        let lines = buf_to_lines(&frame.buffer);
390        assert_eq!(lines[0], "     ");
391        assert_eq!(lines[1], " SSS ");
392        assert_eq!(lines[2], " SSS ");
393    }
394
395    #[test]
396    fn large_padding_single_cell_inner() {
397        let pad = Padding::new(Fill('X'), Sides::new(1, 1, 1, 1));
398        let area = Rect::from_size(3, 3);
399        let mut pool = GraphemePool::new();
400        let mut frame = Frame::new(3, 3, &mut pool);
401        pad.render(area, &mut frame);
402
403        let lines = buf_to_lines(&frame.buffer);
404        assert_eq!(lines[0], "   ");
405        assert_eq!(lines[1], " X ");
406        assert_eq!(lines[2], "   ");
407    }
408
409    #[test]
410    fn naughty_widget_with_asymmetric_padding() {
411        // Test that scissor correctly clips even with non-uniform padding
412        let pad = Padding::new(Naughty, Sides::new(0, 0, 0, 2));
413        let area = Rect::from_size(5, 3);
414        let mut pool = GraphemePool::new();
415        let mut frame = Frame::new(5, 3, &mut pool);
416        pad.render(area, &mut frame);
417
418        // Naughty writes at (0,0) which is outside inner (x>=2), should be clipped
419        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
420        // (2,2) is inside inner, should be written
421        assert_eq!(frame.buffer.get(2, 2).unwrap().content.as_char(), Some('Y'));
422    }
423}