Skip to main content

escriba_ui/
gutter.rs

1//! The gutter — one definition, painted by every face.
2//!
3//! ## Why this is a model and not two `format!` calls
4//!
5//! The ratatui face composed its gutter inline (`format!("{:>4} │ ", ln+1)`)
6//! and the GPU face had **no gutter at all** — no line numbers, no marks.
7//! That is the same divergence the status line had before `StatusModel`, and
8//! it appeared the same way: two faces each deciding independently what a
9//! thing contains, with only one of them ever getting a new feature.
10//!
11//! So the gutter is cells, here, and a face's only job is to colour them.
12//! Adding git signs later is one change in this file rather than two that
13//! have to agree.
14
15use escriba_shirube::Severity;
16
17/// What a gutter cell MEANS. Roles, never colours — the chrome decides how a
18/// role looks, exactly as it does for the splash and the status line.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum GutterRole {
21    /// The line number.
22    Number,
23    /// A finding's severity mark.
24    Mark(Severity),
25    /// Empty space where a mark would go.
26    NoMark,
27    /// The rule between the gutter and the text.
28    Separator,
29}
30
31/// One run of gutter text with its role.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct GutterCell {
34    pub text: String,
35    pub role: GutterRole,
36}
37
38/// The narrowest the gutter ever gets — `4` number + `1` space + `1` mark +
39/// `2` rule-and-space.
40///
41/// A FLOOR, not a fixed width, and the difference is load-bearing. The first
42/// version of this file declared a constant 8 and a test immediately caught
43/// why that cannot hold: line 10 000 needs five digits, so an ordinary large
44/// file would have shifted its own text column by one — and on the GPU face,
45/// where the reserved columns are computed from this number, the text would
46/// have been painted straight over the line numbers.
47pub const MIN_GUTTER_WIDTH: usize = 8;
48
49/// The narrowest line-number field.
50const MIN_NUMBER_WIDTH: usize = 4;
51
52/// Everything in the gutter that is not the number: a space, the mark cell,
53/// and the two-column rule.
54const FURNITURE_WIDTH: usize = 4;
55
56/// How many columns the gutter needs for a buffer of `line_count` lines.
57///
58/// The invariant is **constant within a frame**, not constant forever. A file
59/// that grows past 9 999 lines widening its gutter is what vim does and what
60/// a reader expects; the text jumping sideways while they scroll is not.
61/// Every face derives its geometry from this ONE function so they cannot
62/// disagree about where a line starts.
63#[must_use]
64pub fn gutter_width(line_count: u32) -> usize {
65    number_width(line_count) + FURNITURE_WIDTH
66}
67
68/// The line-number field width for a buffer of `line_count` lines.
69#[must_use]
70pub fn number_width(line_count: u32) -> usize {
71    // `line_count` lines are numbered 1..=line_count, so the widest label is
72    // `line_count` itself — not `line_count - 1`, and not `line_count + 1`.
73    let digits = line_count.max(1).to_string().len();
74    digits.max(MIN_NUMBER_WIDTH)
75}
76
77/// Compose the gutter for one line of a buffer with `line_count` lines.
78///
79/// `mark` is the WORST severity on that line, or `None`. Callers get the
80/// worst rather than a list because the gutter has exactly one cell for it,
81/// and showing the last-arrived finding instead of the most serious one is
82/// how an error hides behind a hint.
83#[must_use]
84pub fn gutter_cells(line: u32, mark: Option<Severity>, line_count: u32) -> Vec<GutterCell> {
85    let field = number_width(line_count);
86    let mut number = (line + 1).to_string();
87    if number.len() < field {
88        number = " ".repeat(field - number.len()) + &number;
89    }
90    vec![
91        GutterCell {
92            text: number,
93            role: GutterRole::Number,
94        },
95        GutterCell {
96            text: " ".to_string(),
97            role: GutterRole::Number,
98        },
99        match mark {
100            Some(s) => GutterCell {
101                text: mark_glyph(s).to_string(),
102                role: GutterRole::Mark(s),
103            },
104            None => GutterCell {
105                text: " ".to_string(),
106                role: GutterRole::NoMark,
107            },
108        },
109        GutterCell {
110            text: "│ ".to_string(),
111            role: GutterRole::Separator,
112        },
113    ]
114}
115
116/// The single-cell glyph for a severity.
117///
118/// Deliberately avoids `◆ ▸ ▮ ●`, which `ishou_tokens::EscribaSignals` already
119/// uses for the modal pills and the modified indicator. One glyph meaning two
120/// things is a reader's problem whichever meaning they learn first — and this
121/// was found the hard way, by a gutter test failing on a `●` that belonged to
122/// the status line.
123#[must_use]
124pub const fn mark_glyph(severity: Severity) -> &'static str {
125    match severity {
126        Severity::Error => "\u{2716}",   // ✖
127        Severity::Warning => "\u{25b2}", // ▲
128        Severity::Info => "\u{2022}",    // •
129        Severity::Hint => "\u{203a}",    // ›
130    }
131}
132
133/// The gutter as plain text — what the GPU face shapes and what tests read.
134#[must_use]
135pub fn gutter_text(line: u32, mark: Option<Severity>, line_count: u32) -> String {
136    gutter_cells(line, mark, line_count)
137        .into_iter()
138        .map(|c| c.text)
139        .collect()
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn every_line_of_one_buffer_gets_the_same_width() {
148        // THE invariant. Not "the gutter is always 8" — that was the first
149        // draft and it was false for any file past 9 999 lines. What must
150        // hold is that within ONE buffer every line agrees, so neither a
151        // diagnostic arriving nor scrolling into five-digit territory moves
152        // the text column under the reader.
153        for line_count in [1u32, 9, 10, 999, 1_000, 9_999, 10_000, 123_456] {
154            let want = gutter_width(line_count);
155            assert!(want >= MIN_GUTTER_WIDTH, "{line_count}: {want} below floor");
156            for line in [0, line_count / 2, line_count.saturating_sub(1)] {
157                for mark in [None, Some(Severity::Error), Some(Severity::Hint)] {
158                    let w = gutter_text(line, mark, line_count).chars().count();
159                    assert_eq!(
160                        w, want,
161                        "buffer of {line_count} lines: line {line} mark \
162                         {mark:?} rendered {w} columns, not {want}",
163                    );
164                }
165            }
166        }
167    }
168
169    #[test]
170    fn the_last_line_of_a_buffer_always_fits_its_field() {
171        // The off-by-one that would break the invariant on exactly one line
172        // of exactly the files where it is hardest to notice: a 10 000-line
173        // buffer's last label is "10000", five digits. Sizing from
174        // `line_count - 1` would reserve four and overflow on the final row.
175        for line_count in [9u32, 10, 99, 100, 9_999, 10_000, 100_000] {
176            let label = line_count.to_string();
177            assert!(
178                label.len() <= number_width(line_count),
179                "a {line_count}-line buffer must fit the label {label:?}",
180            );
181            assert_eq!(
182                gutter_text(line_count - 1, None, line_count)
183                    .chars()
184                    .count(),
185                gutter_width(line_count),
186                "the LAST line must not be wider than every other one",
187            );
188        }
189    }
190
191    #[test]
192    fn a_small_buffer_still_gets_the_floor() {
193        // A 3-line file with a 1-column number field would look broken and
194        // would re-flow the instant it grew. The floor is the same one vim
195        // uses for the same reason.
196        assert_eq!(gutter_width(3), MIN_GUTTER_WIDTH);
197        assert_eq!(gutter_width(9_999), MIN_GUTTER_WIDTH);
198        assert_eq!(gutter_width(10_000), MIN_GUTTER_WIDTH + 1);
199    }
200
201    #[test]
202    fn every_severity_has_a_distinct_glyph() {
203        let all = [
204            Severity::Error,
205            Severity::Warning,
206            Severity::Info,
207            Severity::Hint,
208        ];
209        let mut seen = std::collections::BTreeSet::new();
210        for s in all {
211            assert!(seen.insert(mark_glyph(s)), "{s:?} duplicates another mark");
212        }
213    }
214
215    #[test]
216    fn no_mark_collides_with_a_fleet_signal() {
217        // `◆ ▸ ▮ ●` belong to the modal pills and the modified indicator.
218        // Reusing one would make the gutter say something the status line
219        // already says differently.
220        let fleet = ['\u{25c6}', '\u{25b8}', '\u{25ae}', '\u{25cf}'];
221        for s in [
222            Severity::Error,
223            Severity::Warning,
224            Severity::Info,
225            Severity::Hint,
226        ] {
227            let g = mark_glyph(s).chars().next().expect("one glyph");
228            assert!(!fleet.contains(&g), "{s:?} reuses a fleet signal glyph");
229        }
230    }
231
232    #[test]
233    fn the_mark_sits_between_the_number_and_the_rule() {
234        // Position matters: a mark after the separator would be inside the
235        // text column and would look like buffer content.
236        let cells = gutter_cells(0, Some(Severity::Error), 40);
237        let roles: Vec<GutterRole> = cells.iter().map(|c| c.role).collect();
238        assert_eq!(roles[0], GutterRole::Number);
239        assert_eq!(roles[2], GutterRole::Mark(Severity::Error));
240        assert_eq!(roles[3], GutterRole::Separator);
241    }
242}