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}