Skip to main content

gilt/utils/
padding.rs

1//! Padding widget -- adds whitespace around renderable content.
2//!
3
4use crate::console::{Console, ConsoleOptions, Renderable};
5use crate::measure::Measurement;
6use crate::segment::Segment;
7use crate::style::Style;
8use crate::text::Text;
9
10// ---------------------------------------------------------------------------
11// PaddingDimensions
12// ---------------------------------------------------------------------------
13
14/// CSS-style padding specification.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum PaddingDimensions {
17    /// Same padding on all four sides.
18    Uniform(usize),
19    /// (vertical, horizontal) -- top & bottom share first, left & right share second.
20    Pair(usize, usize),
21    /// (top, right, bottom, left) -- explicit per-side.
22    Full(usize, usize, usize, usize),
23}
24
25impl PaddingDimensions {
26    /// Unpack any variant into `(top, right, bottom, left)`.
27    pub fn unpack(&self) -> (usize, usize, usize, usize) {
28        match *self {
29            PaddingDimensions::Uniform(v) => (v, v, v, v),
30            PaddingDimensions::Pair(vert, horiz) => (vert, horiz, vert, horiz),
31            PaddingDimensions::Full(t, r, b, l) => (t, r, b, l),
32        }
33    }
34}
35
36impl From<usize> for PaddingDimensions {
37    fn from(n: usize) -> Self {
38        PaddingDimensions::Uniform(n)
39    }
40}
41
42impl From<(usize, usize)> for PaddingDimensions {
43    fn from((v, h): (usize, usize)) -> Self {
44        PaddingDimensions::Pair(v, h)
45    }
46}
47
48impl From<(usize, usize, usize, usize)> for PaddingDimensions {
49    fn from((t, r, b, l): (usize, usize, usize, usize)) -> Self {
50        PaddingDimensions::Full(t, r, b, l)
51    }
52}
53
54// ---------------------------------------------------------------------------
55// Padding
56// ---------------------------------------------------------------------------
57
58/// A renderable that adds whitespace padding around `Text` content.
59#[derive(Debug, Clone)]
60pub struct Padding {
61    /// The inner content to pad.
62    pub content: Text,
63    /// Top padding (blank lines above content).
64    pub top: usize,
65    /// Right padding (spaces after each content line).
66    pub right: usize,
67    /// Bottom padding (blank lines below content).
68    pub bottom: usize,
69    /// Left padding (spaces before each content line).
70    pub left: usize,
71    /// Style applied to the padding whitespace.
72    pub style: Style,
73    /// If true, expand to fill the available width.
74    pub expand: bool,
75}
76
77impl Padding {
78    /// Wrap content in padding with default style and `expand: true`.
79    /// `pad` accepts `usize` (uniform), `(v, h)`, or `(t, r, b, l)`. For
80    /// styled padding background or `expand: false`, use [`new`](Self::new).
81    ///
82    /// ```
83    /// # use gilt::{padding::Padding, text::Text, style::Style};
84    /// let p = Padding::wrap(Text::new("Hello", Style::null()), (2, 4));
85    /// ```
86    pub fn wrap(content: Text, pad: impl Into<PaddingDimensions>) -> Self {
87        Self::new(content, pad.into(), Style::null(), true)
88    }
89
90    /// Create a new `Padding` around the given content.
91    pub fn new(content: Text, pad: PaddingDimensions, style: Style, expand: bool) -> Self {
92        let (top, right, bottom, left) = pad.unpack();
93        Padding {
94            content,
95            top,
96            right,
97            bottom,
98            left,
99            style,
100            expand,
101        }
102    }
103
104    /// Convenience: create padding that acts as a left-indent.
105    pub fn indent(content: Text, level: usize) -> Self {
106        Padding::new(
107            content,
108            PaddingDimensions::Full(0, 0, 0, level),
109            Style::null(),
110            true,
111        )
112    }
113
114    /// Measure the minimum and maximum width requirements.
115    pub fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
116        let max_width = options.max_width.saturating_sub(self.left + self.right);
117        let inner_opts = options.update_width(max_width.max(1));
118        // For Text, measure is the cell_len of the content
119        let content_width = self.content.cell_len();
120        let min_w = content_width + self.left + self.right;
121        let max_w = if self.expand {
122            options.max_width
123        } else {
124            min_w.min(options.max_width)
125        };
126        Measurement::new(
127            min_w.min(inner_opts.max_width + self.left + self.right),
128            max_w,
129        )
130    }
131}
132
133impl Renderable for Padding {
134    fn gilt_console(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
135        let mut segments = Vec::new();
136
137        // Compute the total available width
138        let width = if self.expand {
139            options.max_width
140        } else {
141            let content_width = self.content.cell_len();
142            (content_width + self.left + self.right).min(options.max_width)
143        };
144
145        // Compute inner width for the content
146        let inner_width = width.saturating_sub(self.left + self.right).max(1);
147
148        // Render the content into lines
149        let inner_opts = options.update_width(inner_width);
150        let lines = console.render_lines(&self.content, Some(&inner_opts), None, true, false);
151
152        // Left/right padding strings
153        let left_pad = " ".repeat(self.left);
154        let right_pad_base = self.right;
155
156        // Top blank lines
157        let blank_line = " ".repeat(width);
158        for _ in 0..self.top {
159            segments.push(Segment::styled(&blank_line, self.style.clone()));
160            segments.push(Segment::line());
161        }
162
163        // Content lines with left/right padding
164        for line in &lines {
165            // Left padding
166            if self.left > 0 {
167                segments.push(Segment::styled(&left_pad, self.style.clone()));
168            }
169
170            // Content segments
171            segments.extend(line.iter().cloned());
172
173            // Right padding -- fill remaining space to reach full width
174            let line_len = self.left + Segment::get_line_length(line);
175            let remaining = width.saturating_sub(line_len);
176            if remaining > 0 {
177                segments.push(Segment::styled(&" ".repeat(remaining), self.style.clone()));
178            } else if right_pad_base > 0 && remaining == 0 {
179                // Content exactly fills; no extra padding needed
180            }
181
182            segments.push(Segment::line());
183        }
184
185        // Bottom blank lines
186        for _ in 0..self.bottom {
187            segments.push(Segment::styled(&blank_line, self.style.clone()));
188            segments.push(Segment::line());
189        }
190
191        segments
192    }
193}
194
195// ---------------------------------------------------------------------------
196// Tests
197// ---------------------------------------------------------------------------
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::utils::cells::cell_len;
203
204    // -- PaddingDimensions --------------------------------------------------
205
206    #[test]
207    fn test_unpack_uniform() {
208        let pd = PaddingDimensions::Uniform(2);
209        assert_eq!(pd.unpack(), (2, 2, 2, 2));
210    }
211
212    #[test]
213    fn test_unpack_pair() {
214        let pd = PaddingDimensions::Pair(1, 3);
215        assert_eq!(pd.unpack(), (1, 3, 1, 3));
216    }
217
218    #[test]
219    fn test_unpack_full() {
220        let pd = PaddingDimensions::Full(1, 2, 3, 4);
221        assert_eq!(pd.unpack(), (1, 2, 3, 4));
222    }
223
224    #[test]
225    fn test_unpack_uniform_zero() {
226        let pd = PaddingDimensions::Uniform(0);
227        assert_eq!(pd.unpack(), (0, 0, 0, 0));
228    }
229
230    // -- Padding construction -----------------------------------------------
231
232    #[test]
233    fn test_padding_new() {
234        let text = Text::new("Hello", Style::null());
235        let padding = Padding::new(
236            text,
237            PaddingDimensions::Full(1, 2, 3, 4),
238            Style::null(),
239            true,
240        );
241        assert_eq!(padding.top, 1);
242        assert_eq!(padding.right, 2);
243        assert_eq!(padding.bottom, 3);
244        assert_eq!(padding.left, 4);
245        assert!(padding.expand);
246    }
247
248    #[test]
249    fn test_indent() {
250        let text = Text::new("Hello", Style::null());
251        let padding = Padding::indent(text, 4);
252        assert_eq!(padding.top, 0);
253        assert_eq!(padding.right, 0);
254        assert_eq!(padding.bottom, 0);
255        assert_eq!(padding.left, 4);
256        assert!(padding.expand);
257    }
258
259    // -- Rendering ----------------------------------------------------------
260
261    fn make_console(width: usize) -> Console {
262        Console::builder()
263            .width(width)
264            .force_terminal(true)
265            .no_color(true)
266            .markup(false)
267            .build()
268    }
269
270    fn segments_to_text(segments: &[Segment]) -> String {
271        segments.iter().map(|s| s.text.as_str()).collect()
272    }
273
274    #[test]
275    fn test_render_no_padding() {
276        let console = make_console(20);
277        let text = Text::new("Hello", Style::null());
278        let padding = Padding::new(text, PaddingDimensions::Uniform(0), Style::null(), false);
279        let opts = console.options();
280        let segments = padding.gilt_console(&console, &opts);
281        let output = segments_to_text(&segments);
282        assert!(output.contains("Hello"));
283    }
284
285    #[test]
286    fn test_render_with_left_padding() {
287        let console = make_console(20);
288        let text = Text::new("Hi", Style::null());
289        let padding = Padding::new(
290            text,
291            PaddingDimensions::Full(0, 0, 0, 4),
292            Style::null(),
293            true,
294        );
295        let opts = console.options();
296        let segments = padding.gilt_console(&console, &opts);
297        let output = segments_to_text(&segments);
298        // Should have 4 spaces before "Hi"
299        assert!(output.contains("    Hi"));
300    }
301
302    #[test]
303    fn test_render_top_bottom_padding() {
304        let console = make_console(20);
305        let text = Text::new("X", Style::null());
306        let padding = Padding::new(
307            text,
308            PaddingDimensions::Full(2, 0, 3, 0),
309            Style::null(),
310            true,
311        );
312        let opts = console.options();
313        let segments = padding.gilt_console(&console, &opts);
314        let output = segments_to_text(&segments);
315        let lines: Vec<&str> = output.split('\n').collect();
316        // 2 top blank lines + 1 content line + 3 bottom blank lines = 6 lines
317        // (each with a trailing newline, so split gives 7 with last empty)
318        let non_empty_lines: Vec<&&str> = lines.iter().filter(|l| !l.is_empty()).collect();
319        assert_eq!(non_empty_lines.len(), 6);
320    }
321
322    #[test]
323    fn test_render_expand_fills_width() {
324        let console = make_console(30);
325        let text = Text::new("Hi", Style::null());
326        let padding = Padding::new(text, PaddingDimensions::Uniform(1), Style::null(), true);
327        let opts = console.options();
328        let segments = padding.gilt_console(&console, &opts);
329        let output = segments_to_text(&segments);
330        let lines: Vec<&str> = output.split('\n').collect();
331        // First non-empty line (top padding) should be 30 chars wide
332        let top_line = lines[0];
333        assert_eq!(cell_len(top_line), 30);
334    }
335
336    #[test]
337    fn test_render_no_expand_minimal_width() {
338        let console = make_console(80);
339        let text = Text::new("AB", Style::null());
340        let padding = Padding::new(
341            text,
342            PaddingDimensions::Full(0, 1, 0, 1),
343            Style::null(),
344            false,
345        );
346        let opts = console.options();
347        let segments = padding.gilt_console(&console, &opts);
348        let output = segments_to_text(&segments);
349        // Width should be content(2) + left(1) + right(1) = 4
350        let first_line: &str = output.split('\n').next().unwrap();
351        assert_eq!(cell_len(first_line), 4);
352    }
353
354    #[test]
355    fn test_indent_rendering() {
356        let console = make_console(40);
357        let text = Text::new("indented", Style::null());
358        let padding = Padding::indent(text, 8);
359        let opts = console.options();
360        let segments = padding.gilt_console(&console, &opts);
361        let output = segments_to_text(&segments);
362        assert!(output.contains("        indented"));
363    }
364
365    #[test]
366    fn test_measure() {
367        let console = make_console(40);
368        let text = Text::new("Hello", Style::null());
369        let padding = Padding::new(
370            text,
371            PaddingDimensions::Full(0, 2, 0, 2),
372            Style::null(),
373            true,
374        );
375        let opts = console.options();
376        let m = padding.measure(&console, &opts);
377        // min: 5 + 2 + 2 = 9, max: 40 (expand)
378        assert_eq!(m.minimum, 9);
379        assert_eq!(m.maximum, 40);
380    }
381
382    #[test]
383    fn test_measure_no_expand() {
384        let console = make_console(40);
385        let text = Text::new("Hello", Style::null());
386        let padding = Padding::new(
387            text,
388            PaddingDimensions::Full(0, 2, 0, 2),
389            Style::null(),
390            false,
391        );
392        let opts = console.options();
393        let m = padding.measure(&console, &opts);
394        // min: 9, max: min(9, 40) = 9
395        assert_eq!(m.maximum, 9);
396    }
397
398    #[test]
399    fn test_padding_with_styled_content() {
400        let console = make_console(20);
401        let text = Text::styled("Bold", "bold");
402        let padding = Padding::new(text, PaddingDimensions::Uniform(1), Style::null(), true);
403        let opts = console.options();
404        let segments = padding.gilt_console(&console, &opts);
405        let plain: String = segments.iter().map(|s| s.text.as_str()).collect();
406        assert!(plain.contains("Bold"));
407    }
408
409    #[test]
410    fn test_padding_dimensions_equality() {
411        assert_eq!(PaddingDimensions::Uniform(1), PaddingDimensions::Uniform(1));
412        assert_ne!(PaddingDimensions::Uniform(1), PaddingDimensions::Uniform(2));
413        assert_eq!(PaddingDimensions::Pair(1, 2), PaddingDimensions::Pair(1, 2));
414        assert_ne!(PaddingDimensions::Pair(1, 2), PaddingDimensions::Pair(2, 1));
415    }
416}