Skip to main content

rich_rs/
padding.rs

1//! Padding: CSS-style spacing wrapper for renderables.
2//!
3//! Padding draws space around content, similar to CSS padding.
4//!
5//! # Example
6//!
7//! ```ignore
8//! use rich_rs::{Padding, Text, Style, SimpleColor};
9//!
10//! let text = Text::plain("Hello, World!");
11//! let padded = Padding::new(Box::new(text), (2, 4))
12//!     .with_style(Style::new().with_bgcolor(SimpleColor::Standard(4)));
13//! ```
14
15use std::io::Stdout;
16
17use crate::console::ConsoleOptions;
18use crate::measure::Measurement;
19use crate::segment::{Segment, Segments};
20use crate::style::Style;
21use crate::{Console, Renderable};
22
23/// CSS-style padding dimensions.
24///
25/// Supports three forms:
26/// - Single value: all sides use the same padding
27/// - Two values: (vertical, horizontal) - top/bottom and left/right
28/// - Four values: (top, right, bottom, left) - CSS order
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum PaddingDimensions {
31    /// All sides use the same padding.
32    All(usize),
33    /// (vertical, horizontal) - top/bottom share one value, left/right share another.
34    TwoWay(usize, usize),
35    /// (top, right, bottom, left) - CSS order, all specified individually.
36    FourWay(usize, usize, usize, usize),
37}
38
39impl PaddingDimensions {
40    /// Unpack padding dimensions to (top, right, bottom, left).
41    ///
42    /// This follows CSS padding order.
43    pub fn unpack(&self) -> (usize, usize, usize, usize) {
44        match *self {
45            PaddingDimensions::All(v) => (v, v, v, v),
46            PaddingDimensions::TwoWay(vert, horiz) => (vert, horiz, vert, horiz),
47            PaddingDimensions::FourWay(top, right, bottom, left) => (top, right, bottom, left),
48        }
49    }
50}
51
52impl From<usize> for PaddingDimensions {
53    fn from(v: usize) -> Self {
54        PaddingDimensions::All(v)
55    }
56}
57
58impl From<(usize,)> for PaddingDimensions {
59    fn from(v: (usize,)) -> Self {
60        PaddingDimensions::All(v.0)
61    }
62}
63
64impl From<(usize, usize)> for PaddingDimensions {
65    fn from(v: (usize, usize)) -> Self {
66        PaddingDimensions::TwoWay(v.0, v.1)
67    }
68}
69
70impl From<(usize, usize, usize, usize)> for PaddingDimensions {
71    fn from(v: (usize, usize, usize, usize)) -> Self {
72        PaddingDimensions::FourWay(v.0, v.1, v.2, v.3)
73    }
74}
75
76/// Draw space around content.
77///
78/// Padding wraps a renderable and adds blank space around it, similar to CSS padding.
79///
80/// # Example
81///
82/// ```ignore
83/// use rich_rs::{Padding, Text, Style, SimpleColor};
84///
85/// let text = Text::plain("Hello, World!");
86/// // Create padding with 2 lines top/bottom, 4 spaces left/right
87/// let padded = Padding::new(Box::new(text), (2, 4))
88///     .with_style(Style::new().with_bgcolor(SimpleColor::Standard(4)));
89/// ```
90pub struct Padding {
91    /// The wrapped renderable.
92    renderable: Box<dyn Renderable + Send + Sync>,
93    /// Padding at the top (number of blank lines).
94    top: usize,
95    /// Padding on the right (number of spaces).
96    right: usize,
97    /// Padding at the bottom (number of blank lines).
98    bottom: usize,
99    /// Padding on the left (number of spaces).
100    left: usize,
101    /// Style for padding characters.
102    style: Style,
103    /// Whether to expand to fill available width (default true).
104    expand: bool,
105}
106
107impl std::fmt::Debug for Padding {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.debug_struct("Padding")
110            .field("top", &self.top)
111            .field("right", &self.right)
112            .field("bottom", &self.bottom)
113            .field("left", &self.left)
114            .field("style", &self.style)
115            .field("expand", &self.expand)
116            .finish_non_exhaustive()
117    }
118}
119
120impl Padding {
121    /// Create a new Padding wrapper.
122    ///
123    /// # Arguments
124    ///
125    /// * `renderable` - The content to wrap.
126    /// * `pad` - Padding dimensions (CSS-style: 1, 2, or 4 values).
127    ///
128    /// # Example
129    ///
130    /// ```ignore
131    /// use rich_rs::{Padding, Text};
132    ///
133    /// let text = Text::plain("Hello");
134    /// // Single value: all sides same
135    /// let p1 = Padding::new(Box::new(text.clone()), 2);
136    /// // Two values: (vertical, horizontal)
137    /// let p2 = Padding::new(Box::new(text.clone()), (1, 4));
138    /// // Four values: (top, right, bottom, left)
139    /// let p3 = Padding::new(Box::new(text), (1, 2, 3, 4));
140    /// ```
141    pub fn new(
142        renderable: Box<dyn Renderable + Send + Sync>,
143        pad: impl Into<PaddingDimensions>,
144    ) -> Self {
145        let (top, right, bottom, left) = pad.into().unpack();
146        Padding {
147            renderable,
148            top,
149            right,
150            bottom,
151            left,
152            style: Style::default(),
153            expand: true,
154        }
155    }
156
157    /// Create a Padding that indents content (left padding only).
158    ///
159    /// This is a convenience method for creating left-only indentation.
160    /// The expand flag is set to false.
161    ///
162    /// # Arguments
163    ///
164    /// * `renderable` - The content to indent.
165    /// * `level` - Number of spaces to indent.
166    ///
167    /// # Example
168    ///
169    /// ```ignore
170    /// use rich_rs::{Padding, Text};
171    ///
172    /// let text = Text::plain("Indented text");
173    /// let indented = Padding::indent(Box::new(text), 4);
174    /// ```
175    pub fn indent(renderable: Box<dyn Renderable + Send + Sync>, level: usize) -> Self {
176        Padding {
177            renderable,
178            top: 0,
179            right: 0,
180            bottom: 0,
181            left: level,
182            style: Style::default(),
183            expand: false,
184        }
185    }
186
187    /// Unpack padding dimensions to (top, right, bottom, left).
188    ///
189    /// This is the CSS-style unpacking function that can be used
190    /// independently of creating a Padding struct.
191    ///
192    /// # Example
193    ///
194    /// ```ignore
195    /// use rich_rs::Padding;
196    ///
197    /// // Single value
198    /// assert_eq!(Padding::unpack(2), (2, 2, 2, 2));
199    /// // Two values
200    /// assert_eq!(Padding::unpack((1, 4)), (1, 4, 1, 4));
201    /// // Four values
202    /// assert_eq!(Padding::unpack((1, 2, 3, 4)), (1, 2, 3, 4));
203    /// ```
204    pub fn unpack(pad: impl Into<PaddingDimensions>) -> (usize, usize, usize, usize) {
205        pad.into().unpack()
206    }
207
208    /// Set the style for padding characters.
209    ///
210    /// # Arguments
211    ///
212    /// * `style` - Style to apply to padding spaces.
213    pub fn with_style(mut self, style: Style) -> Self {
214        self.style = style;
215        self
216    }
217
218    /// Set whether to expand to fill available width.
219    ///
220    /// When true (default), the padding expands to fill `max_width`.
221    /// When false, the width is based on the inner content's measurement.
222    ///
223    /// # Arguments
224    ///
225    /// * `expand` - Whether to expand to fill width.
226    pub fn with_expand(mut self, expand: bool) -> Self {
227        self.expand = expand;
228        self
229    }
230
231    /// Get the top padding.
232    pub fn top(&self) -> usize {
233        self.top
234    }
235
236    /// Get the right padding.
237    pub fn right(&self) -> usize {
238        self.right
239    }
240
241    /// Get the bottom padding.
242    pub fn bottom(&self) -> usize {
243        self.bottom
244    }
245
246    /// Get the left padding.
247    pub fn left(&self) -> usize {
248        self.left
249    }
250
251    /// Get the style.
252    pub fn style(&self) -> Style {
253        self.style
254    }
255
256    /// Get whether expand is enabled.
257    pub fn expand(&self) -> bool {
258        self.expand
259    }
260}
261
262impl Renderable for Padding {
263    fn render(&self, console: &Console<Stdout>, options: &ConsoleOptions) -> Segments {
264        let mut result = Segments::new();
265
266        // Determine the width to use
267        let width = if self.expand {
268            options.max_width
269        } else {
270            // Measure inner content and add padding
271            let inner_measurement = self.renderable.measure(console, options);
272            (inner_measurement.maximum + self.left + self.right).min(options.max_width)
273        };
274
275        // Calculate inner width (width available for content)
276        // If padding exceeds available width, clamp it proportionally
277        let total_padding = self.left + self.right;
278        let (effective_left, effective_right, inner_width) = if total_padding >= width {
279            // No room for content, collapse padding proportionally to original ratio
280            if total_padding == 0 {
281                (0, 0, width)
282            } else {
283                let ratio_left = self.left as f64 / total_padding as f64;
284                let scaled_left = (width as f64 * ratio_left).round() as usize;
285                let scaled_right = width.saturating_sub(scaled_left);
286                (scaled_left, scaled_right, 0)
287            }
288        } else {
289            (self.left, self.right, width - total_padding)
290        };
291
292        // Create render options for inner content
293        let render_options = options.update_width(inner_width);
294
295        // Adjust height if specified
296        let render_options = if let Some(h) = render_options.height {
297            let new_height = h.saturating_sub(self.top + self.bottom);
298            render_options.update_height(new_height)
299        } else {
300            render_options
301        };
302
303        // Render inner content to lines.
304        // Python Rich passes the padding style to render_lines, which applies it as a
305        // base style to all content. This ensures background colors extend across the
306        // full width including content (not just padding spaces).
307        let style_arg = if self.style.is_null() {
308            None
309        } else {
310            Some(self.style)
311        };
312        let lines = console.render_lines(
313            self.renderable.as_ref(),
314            Some(&render_options),
315            style_arg, // Apply padding style to content (matches Python Rich)
316            true,      // pad=true
317            false,     // new_lines=false (we add them ourselves)
318        );
319
320        // Create padding segments (using clamped values)
321        let left_padding = if effective_left > 0 {
322            Some(Segment::styled(" ".repeat(effective_left), self.style))
323        } else {
324            None
325        };
326
327        let right_padding_and_newline = if effective_right > 0 {
328            vec![
329                Segment::styled(" ".repeat(effective_right), self.style),
330                Segment::line(),
331            ]
332        } else {
333            vec![Segment::line()]
334        };
335
336        // Create blank line for top/bottom padding
337        let blank_line = Segment::styled(format!("{}\n", " ".repeat(width)), self.style);
338
339        // Add top padding
340        for _ in 0..self.top {
341            result.push(blank_line.clone());
342        }
343
344        // Add content lines with left/right padding
345        for line in lines {
346            if let Some(ref left) = left_padding {
347                result.push(left.clone());
348            }
349            for segment in line {
350                result.push(segment);
351            }
352            for segment in &right_padding_and_newline {
353                result.push(segment.clone());
354            }
355        }
356
357        // Add bottom padding
358        for _ in 0..self.bottom {
359            result.push(blank_line.clone());
360        }
361
362        result
363    }
364
365    fn measure(&self, console: &Console<Stdout>, options: &ConsoleOptions) -> Measurement {
366        let max_width = options.max_width;
367        let extra_width = self.left + self.right;
368
369        // If there's not enough room for content, return max_width
370        if max_width < extra_width + 1 {
371            return Measurement::new(max_width, max_width);
372        }
373
374        // Measure inner content
375        let inner_measurement = self.renderable.measure(console, options);
376
377        // Add padding to measurement
378        let measurement = Measurement::new(
379            inner_measurement.minimum + extra_width,
380            inner_measurement.maximum + extra_width,
381        );
382
383        // Clamp to max_width
384        measurement.with_maximum(max_width)
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::text::Text;
392
393    // ==================== PaddingDimensions tests ====================
394
395    #[test]
396    fn test_unpack_single_value() {
397        assert_eq!(Padding::unpack(5), (5, 5, 5, 5));
398    }
399
400    #[test]
401    fn test_unpack_single_tuple() {
402        assert_eq!(Padding::unpack((5,)), (5, 5, 5, 5));
403    }
404
405    #[test]
406    fn test_unpack_two_values() {
407        // (vertical, horizontal)
408        assert_eq!(Padding::unpack((2, 4)), (2, 4, 2, 4));
409    }
410
411    #[test]
412    fn test_unpack_four_values() {
413        // (top, right, bottom, left) - CSS order
414        assert_eq!(Padding::unpack((1, 2, 3, 4)), (1, 2, 3, 4));
415    }
416
417    #[test]
418    fn test_unpack_zero() {
419        assert_eq!(Padding::unpack(0), (0, 0, 0, 0));
420    }
421
422    // ==================== Padding creation tests ====================
423
424    #[test]
425    fn test_padding_new() {
426        let text = Text::plain("Hello");
427        let padding = Padding::new(Box::new(text), (1, 2, 3, 4));
428        assert_eq!(padding.top(), 1);
429        assert_eq!(padding.right(), 2);
430        assert_eq!(padding.bottom(), 3);
431        assert_eq!(padding.left(), 4);
432        assert!(padding.expand());
433    }
434
435    #[test]
436    fn test_padding_indent() {
437        let text = Text::plain("Hello");
438        let padding = Padding::indent(Box::new(text), 4);
439        assert_eq!(padding.top(), 0);
440        assert_eq!(padding.right(), 0);
441        assert_eq!(padding.bottom(), 0);
442        assert_eq!(padding.left(), 4);
443        assert!(!padding.expand());
444    }
445
446    #[test]
447    fn test_padding_with_style() {
448        let text = Text::plain("Hello");
449        let style = Style::new().with_bold(true);
450        let padding = Padding::new(Box::new(text), 1).with_style(style);
451        assert_eq!(padding.style().bold, Some(true));
452    }
453
454    #[test]
455    fn test_padding_with_expand() {
456        let text = Text::plain("Hello");
457        let padding = Padding::new(Box::new(text), 1).with_expand(false);
458        assert!(!padding.expand());
459    }
460
461    // ==================== Padding render tests ====================
462
463    #[test]
464    fn test_padding_render_basic() {
465        let text = Text::plain("Hello");
466        let padding = Padding::new(Box::new(text), (0, 2, 0, 2));
467        let console = Console::with_options(ConsoleOptions {
468            max_width: 20,
469            ..Default::default()
470        });
471        let options = console.options().clone();
472
473        let segments = padding.render(&console, &options);
474        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
475
476        // Should have left padding + "Hello" + right padding + newline
477        assert!(output.contains("  Hello")); // 2 spaces left + Hello
478        assert!(output.ends_with('\n'));
479    }
480
481    #[test]
482    fn test_padding_render_with_top_bottom() {
483        let text = Text::plain("X");
484        let padding = Padding::new(Box::new(text), (1, 0, 1, 0));
485        let console = Console::with_options(ConsoleOptions {
486            max_width: 10,
487            ..Default::default()
488        });
489        let options = console.options().clone();
490
491        let segments = padding.render(&console, &options);
492        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
493        let lines: Vec<&str> = output.lines().collect();
494
495        // Should have blank line, content line, blank line (3 lines total)
496        // But the last line may not have a trailing newline visible in lines()
497        assert!(lines.len() >= 2); // At least top blank + content
498    }
499
500    #[test]
501    fn test_padding_render_expand_true() {
502        let text = Text::plain("Hi");
503        let padding = Padding::new(Box::new(text), (0, 0, 0, 0)).with_expand(true);
504        let console = Console::with_options(ConsoleOptions {
505            max_width: 10,
506            ..Default::default()
507        });
508        let options = console.options().clone();
509
510        let segments = padding.render(&console, &options);
511
512        // When expand=true and pad=true, the line should be padded to max_width
513        let total_text: String = segments
514            .iter()
515            .filter(|s| !s.text.contains('\n'))
516            .map(|s| s.text.to_string())
517            .collect();
518
519        // The content should be "Hi" padded to 10 characters
520        assert_eq!(crate::cells::cell_len(&total_text), 10);
521    }
522
523    #[test]
524    fn test_padding_render_expand_false() {
525        let text = Text::plain("Hi");
526        let padding = Padding::new(Box::new(text), (0, 1, 0, 1)).with_expand(false);
527        let console = Console::with_options(ConsoleOptions {
528            max_width: 20,
529            ..Default::default()
530        });
531        let options = console.options().clone();
532
533        let segments = padding.render(&console, &options);
534        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
535
536        // With expand=false, width = content_max(2) + left(1) + right(1) = 4
537        // So we should have: " Hi " + newline
538        assert!(output.contains(" Hi ")); // left + Hi + right
539    }
540
541    // ==================== Padding measure tests ====================
542
543    #[test]
544    fn test_padding_measure_basic() {
545        let text = Text::plain("Hello"); // 5 chars
546        let padding = Padding::new(Box::new(text), (0, 2, 0, 2)); // +4 horizontal
547        let console = Console::with_options(ConsoleOptions {
548            max_width: 80,
549            ..Default::default()
550        });
551        let options = console.options().clone();
552
553        let measurement = padding.measure(&console, &options);
554        // "Hello" has min=max=5 (no spaces)
555        // With left=2, right=2, we add 4 to both
556        assert_eq!(measurement.minimum, 9);
557        assert_eq!(measurement.maximum, 9);
558    }
559
560    #[test]
561    fn test_padding_measure_with_words() {
562        let text = Text::plain("Hello World"); // min=5 (longest word), max=11
563        let padding = Padding::new(Box::new(text), (0, 1, 0, 1)); // +2 horizontal
564        let console = Console::with_options(ConsoleOptions {
565            max_width: 80,
566            ..Default::default()
567        });
568        let options = console.options().clone();
569
570        let measurement = padding.measure(&console, &options);
571        assert_eq!(measurement.minimum, 7); // 5 + 2
572        assert_eq!(measurement.maximum, 13); // 11 + 2
573    }
574
575    #[test]
576    fn test_padding_measure_clamped() {
577        let text = Text::plain("Hello World");
578        let padding = Padding::new(Box::new(text), (0, 2, 0, 2)); // +4 horizontal
579        let console = Console::with_options(ConsoleOptions {
580            max_width: 10,
581            ..Default::default()
582        });
583        let options = console.options().clone();
584
585        let measurement = padding.measure(&console, &options);
586        // max should be clamped to max_width
587        assert!(measurement.maximum <= 10);
588    }
589
590    #[test]
591    fn test_padding_measure_insufficient_width() {
592        let text = Text::plain("Hi");
593        let padding = Padding::new(Box::new(text), (0, 5, 0, 5)); // +10 horizontal
594        let console = Console::with_options(ConsoleOptions {
595            max_width: 8,
596            ..Default::default()
597        });
598        let options = console.options().clone();
599
600        let measurement = padding.measure(&console, &options);
601        // When max_width < extra_width + 1, return max_width for both
602        assert_eq!(measurement.minimum, 8);
603        assert_eq!(measurement.maximum, 8);
604    }
605
606    #[test]
607    fn test_padding_style_applies_to_content() {
608        use crate::color::SimpleColor;
609
610        let text = Text::plain("Hi");
611        let style = Style::new().with_bgcolor(SimpleColor::Standard(4)); // blue bg
612        let padding = Padding::new(Box::new(text), (0, 0, 0, 0)).with_style(style);
613        let console = Console::with_options(ConsoleOptions {
614            max_width: 10,
615            ..Default::default()
616        });
617        let options = console.options().clone();
618
619        let segments = padding.render(&console, &options);
620
621        // Content segments should have the padding style applied (blue background)
622        let content_seg = segments.iter().find(|s| s.text.contains("Hi"));
623        assert!(content_seg.is_some(), "Should find 'Hi' segment");
624        let seg = content_seg.unwrap();
625        let seg_style = seg.style.unwrap_or_default();
626        assert!(
627            seg_style.bgcolor.is_some(),
628            "Content should have background color from padding style"
629        );
630    }
631
632    // ==================== Send + Sync tests ====================
633
634    #[test]
635    fn test_padding_is_send_sync() {
636        fn assert_send<T: Send>() {}
637        fn assert_sync<T: Sync>() {}
638        assert_send::<Padding>();
639        assert_sync::<Padding>();
640    }
641
642    #[test]
643    fn test_padding_dimensions_is_send_sync() {
644        fn assert_send<T: Send>() {}
645        fn assert_sync<T: Sync>() {}
646        assert_send::<PaddingDimensions>();
647        assert_sync::<PaddingDimensions>();
648    }
649
650    // ==================== Debug tests ====================
651
652    #[test]
653    fn test_padding_debug() {
654        let text = Text::plain("Hello");
655        let padding = Padding::new(Box::new(text), (1, 2, 3, 4));
656        let debug_str = format!("{:?}", padding);
657        assert!(debug_str.contains("Padding"));
658        assert!(debug_str.contains("top: 1"));
659        assert!(debug_str.contains("right: 2"));
660        assert!(debug_str.contains("bottom: 3"));
661        assert!(debug_str.contains("left: 4"));
662    }
663}