Skip to main content

tui/rendering/
gutter.rs

1use super::frame::{FitOptions, Frame};
2use super::line::Line;
3
4/// Number of decimal digits in `value`, minimum 1 (so `0` returns `1`).
5/// Useful for sizing gutters that show line numbers.
6pub fn digit_count(value: usize) -> usize {
7    value.checked_ilog10().map_or(1, |d| d as usize + 1)
8}
9
10/// Soft-wrap `content` to fit inside `total_width` columns and prepend a
11/// gutter: the first visual row gets `head`, continuation rows get `tail`.
12/// `head` and `tail` must have equal display width (debug-asserted by
13/// [`Frame::prefix`]); that width is reserved for the gutter and the remainder
14/// is used for content.
15///
16/// Each wrapped row has its background extended to the inner width before the
17/// gutter is prepended, so row-fill (e.g. diff added/removed stripes) spans
18/// the full content column without bleeding into the gutter column.
19pub fn wrap_with_gutter(content: Line, total_width: u16, head: &Line, tail: &Line) -> Frame {
20    let gutter_width = u16::try_from(head.display_width()).unwrap_or(u16::MAX);
21    let inner_width = total_width.saturating_sub(gutter_width);
22    let inner_width_usize = usize::from(inner_width);
23    Frame::new(vec![content])
24        .fit(inner_width, FitOptions::wrap())
25        .map_lines(|mut line| {
26            line.extend_bg_to_width(inner_width_usize);
27            line
28        })
29        .prefix(head, tail)
30}
31
32#[cfg(test)]
33mod tests {
34    use super::*;
35    use crate::rendering::style::Style;
36    use crossterm::style::Color;
37
38    fn head_tail(width: usize) -> (Line, Line) {
39        let head = Line::with_style(format!("{:>width$}", 7, width = width), Style::default());
40        let tail = Line::new(" ".repeat(width));
41        (head, tail)
42    }
43
44    #[test]
45    fn short_content_produces_single_row_with_head() {
46        let (head, tail) = head_tail(2);
47        let content = Line::new("hi");
48        let frame = wrap_with_gutter(content, 10, &head, &tail);
49        let lines = frame.into_lines();
50        assert_eq!(lines.len(), 1);
51        assert!(lines[0].plain_text().starts_with(" 7"), "expected head on first row: {:?}", lines[0].plain_text());
52    }
53
54    #[test]
55    fn long_content_wraps_and_tail_prefixes_continuations() {
56        let (head, tail) = head_tail(2);
57        let long = "x".repeat(30);
58        let content = Line::new(long);
59        let frame = wrap_with_gutter(content, 10, &head, &tail);
60        let lines = frame.into_lines();
61        assert!(lines.len() >= 2, "expected wrap into multiple rows, got {}", lines.len());
62        assert!(lines[0].plain_text().starts_with(" 7"), "row 0 should start with head");
63        for (i, line) in lines.iter().enumerate().skip(1) {
64            assert!(
65                line.plain_text().starts_with("  "),
66                "row {i} should start with tail spaces, got {:?}",
67                line.plain_text()
68            );
69        }
70    }
71
72    #[test]
73    fn content_background_is_extended_but_does_not_cover_gutter() {
74        let head = Line::with_style("77 ", Style::default());
75        let tail = Line::new("   ");
76        let bg = Color::Rgb { r: 10, g: 20, b: 30 };
77        let content = Line::with_style("short", Style::default().bg_color(bg));
78        let frame = wrap_with_gutter(content, 20, &head, &tail);
79        let lines = frame.into_lines();
80        assert_eq!(lines.len(), 1);
81        let spans = lines[0].spans();
82        assert_eq!(spans[0].style().bg, None, "gutter span should not carry content bg");
83        let any_content_bg = spans.iter().skip(1).any(|s| s.style().bg == Some(bg));
84        assert!(any_content_bg, "content spans should retain their bg");
85    }
86
87    #[test]
88    #[should_panic(expected = "head and tail must have equal display width")]
89    fn head_tail_width_mismatch_panics_in_debug() {
90        let head = Line::new("12");
91        let tail = Line::new("   ");
92        let _ = wrap_with_gutter(Line::new("hi"), 10, &head, &tail);
93    }
94
95    #[test]
96    fn digit_count_works() {
97        assert_eq!(digit_count(0), 1);
98        assert_eq!(digit_count(1), 1);
99        assert_eq!(digit_count(9), 1);
100        assert_eq!(digit_count(10), 2);
101        assert_eq!(digit_count(99), 2);
102        assert_eq!(digit_count(100), 3);
103        assert_eq!(digit_count(999), 3);
104    }
105}