From c1122691ad09f6815f3fef5fb0ba1c6c2938cb79 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 6 Jul 2026 18:32:33 -0400 Subject: [PATCH 1/3] feat(tui): diagnostic gutter signs riding the line-number gutter (sub-arc 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- src/diag.rs | 131 +++++++++++++++++++++++++++++++++++++---- src/editor.rs | 16 +++-- src/highlight.rs | 3 + src/overlay.rs | 1 + src/search.rs | 3 + src/text_view.rs | 4 ++ src/view.rs | 6 ++ tests/m4_acceptance.rs | 1 + 8 files changed, 148 insertions(+), 17 deletions(-) diff --git a/src/diag.rs b/src/diag.rs index cc2ea7e..0db0b2e 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -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, +) { + 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, ); diff --git a/src/editor.rs b/src/editor.rs index 41108c9..cdf4ce7 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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. diff --git a/src/highlight.rs b/src/highlight.rs index 3f06e89..bce8c71 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -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(); diff --git a/src/overlay.rs b/src/overlay.rs index 8b96fdc..c079bb2 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -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, } } diff --git a/src/search.rs b/src/search.rs index 2fbffa7..1e486ec 100644 --- a/src/search.rs +++ b/src/search.rs @@ -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, ); diff --git a/src/text_view.rs b/src/text_view.rs index b552ae4..7f22daa 100644 --- a/src/text_view.rs +++ b/src/text_view.rs @@ -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, ); diff --git a/src/view.rs b/src/view.rs index 3f81367..139c599 100644 --- a/src/view.rs +++ b/src/view.rs @@ -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, } // --------------------------------------------------------------------------- diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 9e1dfd9..bfa07d4 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -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, From d941bf176b1786ed0c9473dd68eb1e7b8bd47f48 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 6 Jul 2026 18:53:22 -0400 Subject: [PATCH 2/3] feat(gpu): diagnostic gutter signs riding the line-number gutter (sub-arc 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPU half of sub-arc 2. When the gutter is on, each line carrying a diagnostic gets a thin severity-colored bar at the gutter's left edge — the GPU analogue of the TUI's leading-column E/W/I/H sign glyph. No protocol change: the per-line severity comes from `current_decorations`, already frontend-side. - collect_gutter_sign_rects: per visible line (layout_runs), find the most-severe diagnostic decoration overlapping that line's byte range and push a `GUTTER_SIGN_W`-wide full-line-height quad at `GUTTER_SIGN_X`, colored via decoration_kind_to_underline_color. Most-severe wins (diagnostic_severity_rank; min rank). Gated on line_numbers, mirroring the TUI (signs ride the line-number gutter). - The bars ride the existing background quad batch (decoration_background_vertex_bytes), so no new pipeline. Headless render test asserts a diagnostic adds ink with the gutter on. fmt + clippy --all-targets clean; 54 pmacs-gpu tests pass (render tests on the local adapter). Needs a GPU eyeball before the sub-arc 2 PR. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- pmacs-gpu/src/main.rs | 113 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 981df12..f1207ba 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -96,6 +96,12 @@ const GUTTER_GAP_PX: f32 = 10.0; /// Fallback monospace advance in px when no shaped glyph is available to /// measure (0.6 em at the 16px code font). const GUTTER_MONO_ADVANCE_FALLBACK: f32 = 9.6; +/// Diagnostic gutter sign (UX gutter sub-arc 2): a thin severity-colored +/// bar hugging the gutter's left edge, left of the line numbers — the GPU +/// analogue of the TUI's leading-column sign glyph. `X` is its left inset, +/// `W` its width; it spans the full line height. +const GUTTER_SIGN_X: f32 = 4.0; +const GUTTER_SIGN_W: f32 = 4.0; const MINIMAP_BG: [f32; 4] = [0.075, 0.075, 0.105, 0.92]; const MINIMAP_DEFAULT_LINE: [f32; 4] = [0.23, 0.23, 0.29, 0.82]; const MINIMAP_THUMB_FILL: [f32; 4] = [0.82, 0.82, 0.92, 0.18]; @@ -4338,9 +4344,62 @@ impl State { let mut rects = Vec::new(); self.collect_own_decoration_rects(&mut rects, &line_offsets, vstart, vend); self.collect_peer_rects(buffer_id, &line_offsets, vstart, vend, &mut rects); + self.collect_gutter_sign_rects(&mut rects, &line_offsets, vstart, vend); rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } + /// Per-visible-line diagnostic sign bars in the gutter (UX gutter + /// sub-arc 2): one severity-colored bar at the gutter's left edge for + /// each line carrying a diagnostic, most-severe winning. Only when the + /// gutter is on, mirroring the TUI (signs ride the line-number gutter). + /// The GPU analogue of the TUI's leading-column `E`/`W`/`I`/`H` glyph. + fn collect_gutter_sign_rects( + &self, + rects: &mut Vec, + line_offsets: &[u64], + vstart: u64, + vend: u64, + ) { + if !self.line_numbers { + return; + } + let slice_len = vend - vstart; + for run in self.buffer.layout_runs() { + let line_base = line_offsets.get(run.line_i).copied().unwrap_or(0); + let line_end = line_offsets + .get(run.line_i + 1) + .copied() + .unwrap_or(slice_len); + let mut best: Option<(u8, [f32; 4])> = None; + for d in &self.current_decorations { + let Some(rank) = diagnostic_severity_rank(d.kind) else { + continue; + }; + let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) + else { + continue; + }; + if hi <= line_base || lo >= line_end { + continue; // decoration doesn't touch this line + } + if best.is_none_or(|(r, _)| rank < r) + && let Some(color) = decoration_kind_to_underline_color(d.kind) + { + best = Some((rank, color)); + } + } + if let Some((_, color)) = best { + rects.push(MinimapRect { + x: GUTTER_SIGN_X, + y: TEXT_TOP + run.line_top, + w: GUTTER_SIGN_W, + h: run.line_height, + color, + }); + } + } + } + /// Own-window `Selection` washes from `current_decorations`. The /// caret already marks the own cursor, so the own *`CurrentLine`* /// wash is deliberately NOT rendered — a whole-line highlight on @@ -6083,6 +6142,23 @@ fn decoration_kind_to_underline_color(kind: DecorationKind) -> Option<[f32; 4]> } } +/// Severity rank of a diagnostic decoration kind (UX gutter sub-arc 2): +/// `0` = most severe (`Error`) … `3` = least (`Hint`); `None` for +/// non-diagnostic kinds. Lets the gutter sign pick the most-severe +/// diagnostic touching a line (min rank wins), mirroring the TUI. +fn diagnostic_severity_rank(kind: DecorationKind) -> Option { + match kind { + DecorationKind::DiagnosticError => Some(0), + DecorationKind::DiagnosticWarning => Some(1), + DecorationKind::DiagnosticInfo => Some(2), + DecorationKind::DiagnosticHint => Some(3), + DecorationKind::Selection + | DecorationKind::SearchMatch + | DecorationKind::SearchMatchActive + | DecorationKind::CurrentLine => None, + } +} + /// Background-bearing companion to /// [`decoration_kind_to_underline_color`]: maps each /// background-needing `DecorationKind` to its quad-pipeline color as @@ -7348,4 +7424,41 @@ mod tests { "the gutter should add ink + shift the text (only {differing} bytes differ)" ); } + + #[test] + fn headless_diagnostic_gutter_sign_changes_the_frame() { + // UX gutter sub-arc 2: with the gutter on, a diagnostic on a line + // must add a severity-colored sign bar in the gutter — the frame + // must differ from the same gutter with no diagnostics. + let text = "alpha\nbeta\ngamma\n"; + let Some(mut plain) = headless_or_skip(400, 300, text) else { + return; + }; + plain.line_numbers = true; + plain.current_buffer_id = Some(BufferId::next()); + plain.view_range = (0, text.len() as u64); + let plain_px = plain.render_offscreen(); + + let mut with_diag = + State::new_headless(400, 300, text).expect("adapter was just available"); + with_diag.line_numbers = true; + with_diag.current_buffer_id = Some(BufferId::next()); + with_diag.view_range = (0, text.len() as u64); + with_diag.current_decorations.push(Decoration { + range: ByteRange { start: 0, end: 5 }, // "alpha" + kind: DecorationKind::DiagnosticError, + }); + let diag_px = with_diag.render_offscreen(); + + assert_eq!(plain_px.len(), diag_px.len()); + let differing = plain_px + .iter() + .zip(&diag_px) + .filter(|(a, b)| a != b) + .count(); + assert!( + differing > 20, + "the diagnostic sign bar should add ink ({differing} bytes differ)" + ); + } } From 0e36f13f11360c3d1a6f24589e07825a844ab8c2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 6 Jul 2026 19:25:00 -0400 Subject: [PATCH 3/3] docs(ux): as-built for sub-arc 2 (diagnostic gutter signs) Record the sub-arc 2 as-built: signs-ride-the-gutter coupling, the Viewport.gutter_w + paint-reorder mechanism (TUI), the layout_runs bar mechanism (GPU), the TUI-glyph/GPU-bar rendering difference, the en-route multi-frontend window.close crash fix (PR #87), and the known completion-navigation follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- docs/ux-arc-framing.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/ux-arc-framing.md b/docs/ux-arc-framing.md index 4897841..21c2e2e 100644 --- a/docs/ux-arc-framing.md +++ b/docs/ux-arc-framing.md @@ -239,4 +239,46 @@ the adapter). Both frontends eyeballed for coordinate correctness. Deferred to later sub-arcs: relative/hybrid modes; diagnostic gutter signs (sub-arc 2); the exact gutter padding is a tunable to eyeball. +**Sub-arc 2 — diagnostic gutter signs (TUI + GPU).** + +Per-line severity signs riding the sub-arc-1 gutter — closing the last +deferred Task #23 item. No protocol/daemon change: the per-line severity is +already frontend-side (the TUI's diag store, the GPU's `current_decorations`). + +- **Coupling decision.** Signs ride the *line-number* gutter — they show + when line numbers are on, and vanish when off (the legacy col-0 + background marker returns in the TUI's no-gutter mode). A + signs-without-numbers mode is deferred; when it lands, the gutter's + presence test widens from "line numbers on" to "line numbers OR signs + on." +- **TUI** (`diag.rs`/`editor.rs`/`view.rs`): `Viewport` gains `gutter_w` so + overlays can address the gutter's leading column at `cell_origin.col - + gutter_w`. The gutter number pass moved *before* the overlay loop so + `DiagnosticView` can draw its sign into the gutter's blanked leading + column without the number pass erasing it. The sign is the severity + glyph (`E`/`W`/`I`/`H`) colored by `underline_color()`; most-severe wins + (the existing `line_markers` map). Extracted `paint_line_markers`. +- **GPU** (`pmacs-gpu/main.rs`): `collect_gutter_sign_rects` walks + `layout_runs()`, finds the most-severe diagnostic decoration overlapping + each line (`diagnostic_severity_rank`, min wins) and pushes a thin + severity-colored bar at the gutter's left edge, riding the existing + background quad batch. Rendered as a **bar**, not a glyph — the GPU + gutter number layer is single-color, so a per-line-colored bar was the + clean path; same convention as the TUI, per-frontend rendering (Q#UX7). + +Validated: `fmt` + `clippy --all-targets` clean both flavors; lib tests +(incl. the TUI gutter-sign placement test) + 54 pmacs-gpu (incl. a headless +sign render test on the adapter). Both frontends eyeballed. + +**Cross-cutting bug fixed en route (PR #87, off the arc):** a bare +`--daemon` + a GPU is a *two-frontend* session; `EditorCore::close_active` +/ `close_others` operated on the global `windows` set and so closed *other* +frontends' windows, dangling their `view.active` and crashing the daemon in +`active_window()`. Scoped both to the active frontend's layout. (Surfaced +while eyeballing this sub-arc against the two-frontend setup.) + +**Known follow-up (not this arc):** the GPU minibuffer completion-navigation +highlight sticks / doesn't wrap on arrow-up; reproduces on the "normal" +nav path but not the alternate one. Its own thread. +