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