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}