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    /// A debug breakpoint the operator set on this line.
28    Breakpoint,
29    /// Empty space where a breakpoint would go.
30    NoBreakpoint,
31    /// The rule between the gutter and the text.
32    Separator,
33}
34
35/// Everything the gutter has to say about ONE line.
36///
37/// ## Why this is a struct and not a second `Option<Severity>` parameter
38///
39/// The gutter had exactly one mark cell and `gutter_cells` took exactly one
40/// `Option<Severity>`. A breakpoint is not a severity — it is a thing the
41/// OPERATOR put there, not a thing a producer found — so squeezing it into
42/// that cell would mean a line carrying both an error and a breakpoint could
43/// only show one of them, and whichever lost would be invisible with no
44/// indication that anything had been hidden.
45///
46/// So each plane gets its own cell, and they arrive together in one value.
47/// There is deliberately **no** `From<Option<Severity>>` and no `Default`:
48/// every construction names both planes, which is what makes adding a third
49/// (git hunks are next) a compile error at every call site rather than a
50/// silently-omitted column.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct GutterMarks {
53    /// The WORST finding severity on the line, or `None`.
54    pub severity: Option<Severity>,
55    /// Whether a debug breakpoint sits on the line.
56    pub breakpoint: bool,
57}
58
59impl GutterMarks {
60    /// Both planes, named.
61    #[must_use]
62    pub const fn new(severity: Option<Severity>, breakpoint: bool) -> Self {
63        Self {
64            severity,
65            breakpoint,
66        }
67    }
68
69    /// Nothing on this line. Spelled out rather than `Default` so a caller
70    /// that means "I have not looked yet" cannot spell it the same way.
71    #[must_use]
72    pub const fn clear() -> Self {
73        Self::new(None, false)
74    }
75}
76
77/// One run of gutter text with its role.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct GutterCell {
80    pub text: String,
81    pub role: GutterRole,
82}
83
84/// The narrowest the gutter ever gets — `4` number + `1` space +
85/// `1` breakpoint + `1` mark + `2` rule-and-space.
86///
87/// A FLOOR, not a fixed width, and the difference is load-bearing. The first
88/// version of this file declared a constant 8 and a test immediately caught
89/// why that cannot hold: line 10 000 needs five digits, so an ordinary large
90/// file would have shifted its own text column by one — and on the GPU face,
91/// where the reserved columns are computed from this number, the text would
92/// have been painted straight over the line numbers.
93pub const MIN_GUTTER_WIDTH: usize = 9;
94
95/// The narrowest line-number field.
96const MIN_NUMBER_WIDTH: usize = 4;
97
98/// Everything in the gutter that is not the number: a space, the breakpoint
99/// cell, the severity-mark cell, and the two-column rule.
100///
101/// The breakpoint cell is reserved on EVERY line of every buffer, including
102/// buffers with no breakpoints at all. vim's `signcolumn=auto` grows the
103/// column when the first sign appears, and the cost of that is the whole
104/// file sliding one column sideways on the keystroke that sets a breakpoint —
105/// which is the same defect a fixed-8 gutter had for 10 000-line files, just
106/// triggered by a different event. One reserved column is cheaper than a
107/// re-flow the operator did not ask for.
108const FURNITURE_WIDTH: usize = 5;
109
110/// How many columns the gutter needs for a buffer of `line_count` lines.
111///
112/// The invariant is **constant within a frame**, not constant forever. A file
113/// that grows past 9 999 lines widening its gutter is what vim does and what
114/// a reader expects; the text jumping sideways while they scroll is not.
115/// Every face derives its geometry from this ONE function so they cannot
116/// disagree about where a line starts.
117#[must_use]
118pub fn gutter_width(line_count: u32) -> usize {
119    number_width(line_count) + FURNITURE_WIDTH
120}
121
122/// The line-number field width for a buffer of `line_count` lines.
123#[must_use]
124pub fn number_width(line_count: u32) -> usize {
125    // `line_count` lines are numbered 1..=line_count, so the widest label is
126    // `line_count` itself — not `line_count - 1`, and not `line_count + 1`.
127    let digits = line_count.max(1).to_string().len();
128    digits.max(MIN_NUMBER_WIDTH)
129}
130
131/// Compose the gutter for one line of a buffer with `line_count` lines.
132///
133/// `marks.severity` is the WORST severity on that line, or `None`. Callers
134/// get the worst rather than a list because the gutter has exactly one cell
135/// for it, and showing the last-arrived finding instead of the most serious
136/// one is how an error hides behind a hint. `marks.breakpoint` gets its OWN
137/// cell — see [`GutterMarks`] for why sharing one would hide half of it.
138#[must_use]
139pub fn gutter_cells(line: u32, marks: GutterMarks, line_count: u32) -> Vec<GutterCell> {
140    let field = number_width(line_count);
141    let mut number = (line + 1).to_string();
142    if number.len() < field {
143        number = " ".repeat(field - number.len()) + &number;
144    }
145    vec![
146        GutterCell {
147            text: number,
148            role: GutterRole::Number,
149        },
150        GutterCell {
151            text: " ".to_string(),
152            role: GutterRole::Number,
153        },
154        // The breakpoint sits LEFT of the severity mark, so the mark stays
155        // adjacent to the rule where every existing face and test already
156        // reads it from.
157        if marks.breakpoint {
158            GutterCell {
159                text: BREAKPOINT_GLYPH.to_string(),
160                role: GutterRole::Breakpoint,
161            }
162        } else {
163            GutterCell {
164                text: " ".to_string(),
165                role: GutterRole::NoBreakpoint,
166            }
167        },
168        match marks.severity {
169            Some(s) => GutterCell {
170                text: mark_glyph(s).to_string(),
171                role: GutterRole::Mark(s),
172            },
173            None => GutterCell {
174                text: " ".to_string(),
175                role: GutterRole::NoMark,
176            },
177        },
178        GutterCell {
179            text: "│ ".to_string(),
180            role: GutterRole::Separator,
181        },
182    ]
183}
184
185/// The single-cell glyph for a severity.
186///
187/// Deliberately avoids `◆ ▸ ▮ ●`, which `ishou_tokens::EscribaSignals` already
188/// uses for the modal pills and the modified indicator. One glyph meaning two
189/// things is a reader's problem whichever meaning they learn first — and this
190/// was found the hard way, by a gutter test failing on a `●` that belonged to
191/// the status line.
192#[must_use]
193pub const fn mark_glyph(severity: Severity) -> &'static str {
194    match severity {
195        Severity::Error => "\u{2716}",   // ✖
196        Severity::Warning => "\u{25b2}", // ▲
197        Severity::Info => "\u{2022}",    // •
198        Severity::Hint => "\u{203a}",    // ›
199    }
200}
201
202/// The single-cell glyph for a breakpoint.
203///
204/// Chosen under the same two constraints [`mark_glyph`] documents — it must
205/// collide with neither a severity mark (`✖ ▲ • ›`) nor an
206/// `ishou_tokens::EscribaSignals` glyph (`◆ ▸ ▮ ●`), and both are gated by
207/// tests below. `●` is the obvious debugger dot and is exactly the one that
208/// is taken: it is the modified-buffer indicator on the status line, and a
209/// reader who learns it there would read a breakpoint as "unsaved".
210const BREAKPOINT_GLYPH: &str = "\u{25c9}"; // ◉
211
212/// The glyph a breakpoint paints, for a face or a test that needs to NAME it.
213#[must_use]
214pub const fn breakpoint_glyph() -> &'static str {
215    BREAKPOINT_GLYPH
216}
217
218/// The gutter as plain text — what the GPU face shapes and what tests read.
219#[must_use]
220pub fn gutter_text(line: u32, marks: GutterMarks, line_count: u32) -> String {
221    gutter_cells(line, marks, line_count)
222        .into_iter()
223        .map(|c| c.text)
224        .collect()
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn every_line_of_one_buffer_gets_the_same_width() {
233        // THE invariant. Not "the gutter is always 8" — that was the first
234        // draft and it was false for any file past 9 999 lines. What must
235        // hold is that within ONE buffer every line agrees, so neither a
236        // diagnostic arriving nor scrolling into five-digit territory moves
237        // the text column under the reader.
238        for line_count in [1u32, 9, 10, 999, 1_000, 9_999, 10_000, 123_456] {
239            let want = gutter_width(line_count);
240            assert!(want >= MIN_GUTTER_WIDTH, "{line_count}: {want} below floor");
241            for line in [0, line_count / 2, line_count.saturating_sub(1)] {
242                for severity in [None, Some(Severity::Error), Some(Severity::Hint)] {
243                    // Both breakpoint states, because a toggle that changed
244                    // the width would slide the whole file sideways on one
245                    // keypress — the same defect a fixed-8 gutter had, on a
246                    // different trigger.
247                    for breakpoint in [false, true] {
248                        let marks = GutterMarks::new(severity, breakpoint);
249                        let w = gutter_text(line, marks, line_count).chars().count();
250                        assert_eq!(
251                            w, want,
252                            "buffer of {line_count} lines: line {line} marks \
253                             {marks:?} rendered {w} columns, not {want}",
254                        );
255                    }
256                }
257            }
258        }
259    }
260
261    #[test]
262    fn the_last_line_of_a_buffer_always_fits_its_field() {
263        // The off-by-one that would break the invariant on exactly one line
264        // of exactly the files where it is hardest to notice: a 10 000-line
265        // buffer's last label is "10000", five digits. Sizing from
266        // `line_count - 1` would reserve four and overflow on the final row.
267        for line_count in [9u32, 10, 99, 100, 9_999, 10_000, 100_000] {
268            let label = line_count.to_string();
269            assert!(
270                label.len() <= number_width(line_count),
271                "a {line_count}-line buffer must fit the label {label:?}",
272            );
273            assert_eq!(
274                gutter_text(line_count - 1, GutterMarks::clear(), line_count)
275                    .chars()
276                    .count(),
277                gutter_width(line_count),
278                "the LAST line must not be wider than every other one",
279            );
280        }
281    }
282
283    #[test]
284    fn a_small_buffer_still_gets_the_floor() {
285        // A 3-line file with a 1-column number field would look broken and
286        // would re-flow the instant it grew. The floor is the same one vim
287        // uses for the same reason.
288        assert_eq!(gutter_width(3), MIN_GUTTER_WIDTH);
289        assert_eq!(gutter_width(9_999), MIN_GUTTER_WIDTH);
290        assert_eq!(gutter_width(10_000), MIN_GUTTER_WIDTH + 1);
291    }
292
293    #[test]
294    fn every_severity_has_a_distinct_glyph() {
295        let all = [
296            Severity::Error,
297            Severity::Warning,
298            Severity::Info,
299            Severity::Hint,
300        ];
301        let mut seen = std::collections::BTreeSet::new();
302        for s in all {
303            assert!(seen.insert(mark_glyph(s)), "{s:?} duplicates another mark");
304        }
305    }
306
307    #[test]
308    fn no_mark_collides_with_a_fleet_signal() {
309        // `◆ ▸ ▮ ●` belong to the modal pills and the modified indicator.
310        // Reusing one would make the gutter say something the status line
311        // already says differently.
312        let fleet = ['\u{25c6}', '\u{25b8}', '\u{25ae}', '\u{25cf}'];
313        for s in [
314            Severity::Error,
315            Severity::Warning,
316            Severity::Info,
317            Severity::Hint,
318        ] {
319            let g = mark_glyph(s).chars().next().expect("one glyph");
320            assert!(!fleet.contains(&g), "{s:?} reuses a fleet signal glyph");
321        }
322    }
323
324    #[test]
325    fn the_mark_sits_between_the_number_and_the_rule() {
326        // Position matters: a mark after the separator would be inside the
327        // text column and would look like buffer content.
328        let cells = gutter_cells(0, GutterMarks::new(Some(Severity::Error), false), 40);
329        let roles: Vec<GutterRole> = cells.iter().map(|c| c.role).collect();
330        assert_eq!(roles[0], GutterRole::Number);
331        assert_eq!(roles[2], GutterRole::NoBreakpoint);
332        assert_eq!(roles[3], GutterRole::Mark(Severity::Error));
333        assert_eq!(roles[4], GutterRole::Separator);
334    }
335
336    #[test]
337    fn a_breakpoint_and_an_error_on_one_line_are_both_visible() {
338        // THE reason the signature widened. Before `GutterMarks` there was
339        // one mark cell, so a breakpoint published through it would have
340        // replaced the error glyph — or been replaced by it — and the
341        // operator would have seen exactly one of two true facts with no
342        // indication that the other had been dropped.
343        //
344        // RED RUN (2026-08-12): reverting `gutter_cells` to emit one shared
345        // cell (`if marks.breakpoint { breakpoint } else { severity }`) fails
346        // this test on the `✖` assertion — the error is gone from the frame —
347        // while `every_line_of_one_buffer_gets_the_same_width` stays green,
348        // which is exactly why the width test could not have caught this.
349        let text = gutter_text(0, GutterMarks::new(Some(Severity::Error), true), 40);
350        assert!(
351            text.contains(breakpoint_glyph()),
352            "the breakpoint must be painted: {text:?}",
353        );
354        assert!(
355            text.contains(mark_glyph(Severity::Error)),
356            "and the error must STILL be painted: {text:?}",
357        );
358    }
359
360    #[test]
361    fn the_breakpoint_glyph_collides_with_nothing_the_reader_already_knows() {
362        // Two vocabularies, both already spoken in this gutter and the status
363        // line beside it. `●` (U+25CF) is the tempting debugger dot and is
364        // taken by the modified-buffer indicator.
365        let g = breakpoint_glyph().chars().next().expect("one glyph");
366        for s in [
367            Severity::Error,
368            Severity::Warning,
369            Severity::Info,
370            Severity::Hint,
371        ] {
372            assert_ne!(
373                breakpoint_glyph(),
374                mark_glyph(s),
375                "the breakpoint reuses {s:?}'s mark",
376            );
377        }
378        let fleet = ['\u{25c6}', '\u{25b8}', '\u{25ae}', '\u{25cf}'];
379        assert!(
380            !fleet.contains(&g),
381            "the breakpoint reuses a fleet signal glyph",
382        );
383        assert_eq!(
384            breakpoint_glyph().chars().count(),
385            1,
386            "the breakpoint cell is ONE column",
387        );
388    }
389
390    #[test]
391    fn a_line_with_no_breakpoint_still_reserves_its_column() {
392        // The always-reserved cell, stated as a property rather than left to
393        // the width test: a blank breakpoint cell is a SPACE with its own
394        // role, not an absent cell. A face that skipped emitting it would
395        // paint a gutter one column narrower than `gutter_width` declares,
396        // and the text would start inside the rule.
397        let cells = gutter_cells(0, GutterMarks::clear(), 40);
398        assert_eq!(cells[2].role, GutterRole::NoBreakpoint);
399        assert_eq!(cells[2].text, " ");
400        assert_eq!(
401            cells.iter().map(|c| c.text.chars().count()).sum::<usize>(),
402            gutter_width(40),
403        );
404    }
405}