feat(tui): diagnostic gutter signs riding the line-number gutter (sub-arc 2)

Sub-arc 2 of the UX arc, TUI half. When a window reserves a line-number
gutter, lines with diagnostics get a severity-colored sign glyph (E/W/I/H)
in the gutter's leading column — closing the last deferred Task #23 item.
No protocol/daemon change: the per-line severity is already frontend-side
(the diag store the DiagnosticView already reads).

- `Viewport` gains `gutter_w` so overlays can reach the gutter's leading
  column at `cell_origin.col - gutter_w`; the text area is already shifted
  past it, so viewport-relative painters stay gutter-agnostic.
- The gutter's number pass now runs *before* the overlays (was after), so
  the DiagnosticView can draw its sign into the gutter's blanked leading
  column without the number pass erasing it.
- DiagnosticView: with a gutter, draw the severity sign glyph colored by
  `underline_color()`; without one, keep the legacy column-0 background
  marker (the "fake gutter" that predates a real gutter column). Extracted
  to `paint_line_markers` to keep `render` under the line cap.

The number never reaches column 0 (>=1 leading pad by construction), so
sign and number coexist. Diagnostic signs currently ride the line-number
gutter (visible when line numbers are on); a signs-without-numbers mode is
deferred.

Test: gutter_sign_replaces_the_column_marker_when_a_gutter_is_reserved.
Validated: fmt + clippy --all-targets clean both flavors; 1441 lib + 22
diag tests pass. Needs a TUI eyeball before the GPU half.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
This commit is contained in:
Levi Neuwirth 2026-07-06 18:32:33 -04:00
parent f3e1070c37
commit c1122691ad
8 changed files with 148 additions and 17 deletions

View File

@ -35,7 +35,7 @@ use serde_json::Value;
use unicode_width::UnicodeWidthChar;
use crate::buffer::Buffer;
use crate::cell::{CellCoord, CellGrid, Color, Style, UnderlineStyle};
use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style, UnderlineStyle};
use crate::overlay::merge_styles;
use crate::view::{View, Viewport};
@ -572,17 +572,47 @@ impl View for DiagnosticView {
}
}
// Paint the column-0 markers last so a marker is visible even
// when an underline span also touches column 0 (bg and
// underline merge independently).
if max_cols > 0 {
for (row_offset, severity) in line_markers {
let cell = cells.at(CellCoord::new(
cell_origin.row + row_offset,
cell_origin.col,
));
cell.style = merge_styles(cell.style, marker_style_for(severity));
}
// Paint the per-line severity markers last so one is visible even
// when an underline span also touches the same cell.
paint_line_markers(
cells,
cell_origin,
viewport.gutter_w,
max_cols,
&line_markers,
);
}
}
/// Paint one severity marker per diagnostic line (UX gutter sub-arc 2).
///
/// When the window reserves a gutter (`gutter_w > 0`), draw the severity
/// *sign glyph* in the gutter's leading column (`cell_origin.col -
/// gutter_w`, i.e. window column 0), colored by severity. Without a gutter,
/// fall back to the legacy column-0 *background* marker on the line's first
/// text cell — the "fake gutter" that predates a real gutter column.
fn paint_line_markers(
cells: &mut CellGrid<'_>,
cell_origin: CellCoord,
gutter_w: u32,
max_cols: u32,
line_markers: &std::collections::HashMap<u32, DiagnosticSeverity>,
) {
for (&row_offset, &severity) in line_markers {
let row = cell_origin.row + row_offset;
if gutter_w > 0 {
let cell = cells.at(CellCoord::new(
row,
cell_origin.col.saturating_sub(gutter_w),
));
cell.glyph = Glyph::Char(severity.gutter_glyph());
cell.style = Style {
fg: severity.underline_color(),
..Style::default()
};
} else if max_cols > 0 {
let cell = cells.at(CellCoord::new(row, cell_origin.col));
cell.style = merge_styles(cell.style, marker_style_for(severity));
}
}
}
@ -968,6 +998,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 10),
gutter_w: 0,
},
&mut grid,
);
@ -1031,6 +1062,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(3, 10),
gutter_w: 0,
},
&mut grid,
);
@ -1054,6 +1086,80 @@ mod tests {
assert_eq!(grid.get(CellCoord::new(2, 0)).style.bg, Color::Default);
}
#[test]
fn gutter_sign_replaces_the_column_marker_when_a_gutter_is_reserved() {
use crate::cell::{Cell, CellSize, Glyph, UnderlineStyle};
let store = make_shared_store();
{
let mut guard = store.lock().expect("diag store");
guard.set(
"file:///a",
vec![
// Line 0: Hint + Error overlap → the sign shows Error.
diag(0, DiagnosticSeverity::Hint, "h"),
diag(0, DiagnosticSeverity::Error, "e"),
// Line 1: zero-width Warning (invisible to underline).
Diagnostic {
start_line: 1,
start_col: 2,
end_line: 1,
end_col: 2,
severity: DiagnosticSeverity::Warning,
message: "w".to_owned(),
source: None,
code: None,
},
],
);
}
let mut buf = Buffer::new(crate::buffer::BufferId::next(), "test.c");
buf.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"hello\nworld\nclean\n",
})
.expect("seed buffer");
// A 2-cell gutter: text is shifted to column 2, signs land at
// window column 0 (`cell_origin.col - gutter_w`).
let mut view = DiagnosticView::new("file:///a", store);
let mut backing = vec![Cell::default(); 30];
let mut grid = CellGrid {
cells: &mut backing,
stride: 10,
size: CellSize::new(3, 10),
};
view.render(
&buf,
Viewport {
buffer_start: 0,
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 2),
cell_size: CellSize::new(3, 8),
gutter_w: 2,
},
&mut grid,
);
// Line 0: Error sign glyph 'E' in red at the gutter's leading col.
assert_eq!(grid.get(CellCoord::new(0, 0)).glyph, Glyph::Char('E'));
assert_eq!(grid.get(CellCoord::new(0, 0)).style.fg, Color::Indexed(1));
// Line 1: Warning sign 'W' in yellow.
assert_eq!(grid.get(CellCoord::new(1, 0)).glyph, Glyph::Char('W'));
assert_eq!(grid.get(CellCoord::new(1, 0)).style.fg, Color::Indexed(3));
// The legacy background marker on the first *text* cell is NOT
// painted when a gutter carries the sign instead.
assert_eq!(grid.get(CellCoord::new(0, 2)).style.bg, Color::Default);
// The squiggle still lands in the shifted text area (col 2 + 2).
assert_eq!(
grid.get(CellCoord::new(1, 4)).style.underline,
UnderlineStyle::Curly
);
// Line 2: clean — no sign glyph.
assert_eq!(grid.get(CellCoord::new(2, 0)).glyph, Glyph::Char(' '));
}
#[test]
fn end_of_line_zero_width_error_squiggles_the_cell_past_eol() {
// The missing-comma shape: rust-analyzer anchors "expected
@ -1099,6 +1205,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(2, 10),
gutter_w: 0,
},
&mut grid,
);

View File

@ -1635,16 +1635,20 @@ pub fn paint_frame(
buffer_end: buf.len(),
cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w),
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
gutter_w,
};
// Composition (T M2.9): base text_view paints first, then
// each overlay in attach order. See [`crate::view::View`].
// Composition (T M2.9): base text_view paints first, then the
// gutter numbers — before the overlays, so a diagnostic overlay
// can draw its severity sign into the gutter's leading column
// without the gutter's own blank pass erasing it — then each
// overlay in attach order. See [`crate::view::View`].
window.text_view.render(buf, viewport, grid);
for overlay in &mut window.overlays {
overlay.render(buf, viewport, grid);
}
if gutter_w > 0 {
paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w);
}
for overlay in &mut window.overlays {
overlay.render(buf, viewport, grid);
}
paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w);
// Mode line for this window. Painted last so the line
// itself is always visible regardless of overlay activity.
@ -4839,6 +4843,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: rect.origin,
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
gutter_w: 0,
};
let mut grid = CellGrid {
cells: &mut backing,
@ -4972,6 +4977,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(24, 80),
gutter_w: 0,
};
// Two no-op overlays: probe the dispatch cost only.

View File

@ -861,6 +861,7 @@ mod tests {
buffer_end: u64::MAX,
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 20),
gutter_w: 0,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
@ -957,6 +958,7 @@ mod tests {
buffer_end: u64::MAX,
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 20),
gutter_w: 0,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
@ -1075,6 +1077,7 @@ mod tests {
buffer_end: u64::MAX,
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 20),
gutter_w: 0,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();

View File

@ -431,6 +431,7 @@ mod tests {
buffer_end: u64::MAX,
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
}
}

View File

@ -590,6 +590,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 10),
gutter_w: 0,
},
&mut grid,
);
@ -617,6 +618,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 10),
gutter_w: 0,
},
&mut grid2,
);
@ -657,6 +659,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
},
&mut grid,
);

View File

@ -564,6 +564,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 16),
gutter_w: 0,
},
&mut grid,
);
@ -591,6 +592,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 16),
gutter_w: 0,
},
&mut grid,
);
@ -621,6 +623,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(5, 5),
gutter_w: 0,
},
&mut grid,
);
@ -652,6 +655,7 @@ mod tests {
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 5),
gutter_w: 0,
},
&mut grid,
);

View File

@ -136,6 +136,12 @@ pub struct Viewport {
pub cell_origin: CellCoord,
/// Number of cells the viewport occupies.
pub cell_size: CellSize,
/// Width of the line-number gutter reserved to the *left* of
/// `cell_origin` (UX gutter arc). `0` when no gutter. Overlays that
/// want to draw in the gutter (e.g. the diagnostic sign) reach it at
/// `cell_origin.col - gutter_w`; overlays that only touch the text area
/// ignore it (the origin is already shifted past the gutter).
pub gutter_w: u32,
}
// ---------------------------------------------------------------------------

View File

@ -495,6 +495,7 @@ fn render_active_window_to_grid(
buffer_end: buf.len(),
cell_origin: rect.origin,
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
gutter_w: 0,
};
let mut grid = CellGrid {
cells: &mut backing,