diff --git a/docs/pmacs-gpu-diagnostic-parity-framing.md b/docs/pmacs-gpu-diagnostic-parity-framing.md new file mode 100644 index 0000000..e738186 --- /dev/null +++ b/docs/pmacs-gpu-diagnostic-parity-framing.md @@ -0,0 +1,87 @@ +# pmacs-gpu diagnostic parity — framing pass + +Date: 2026-06-12. The TUI's M4.6 surface (PR #64) gives diagnostics +severity-colored squiggles, column-0 line markers, and mode-line +counts. The GPU frontend renders none of that: diagnostics arrive as +`Decorations` with per-severity kinds and are consumed by +**recoloring the text foreground** (`decoration_kind_to_color`, +main.rs:4041 → `source_color_at` :3933 → chunk `Attrs::color`) — the +exact syntax-color-clobbering approach the TUI just abandoned, and +the reason `underline_color` exists in protocol v6. + +## Verified facts (survey 2026-06-12) + +- glyphon/cosmic-text 0.18 draw **no underlines**; `line_from_chunks` + sets only family + color. Squiggles cannot come from text attrs. +- The quad pipeline draws arbitrary pixel rects: + `push_glyph_extent_rects` (:2696) walks `layout_runs()` and maps a + byte range to per-visual-line glyph x-extents; `MinimapRect {x, y, + w, h, color}` → 6 vertices (:3716–:3728). Selection / CurrentLine + backgrounds already ship through it; all four diagnostic kinds + return `None` from `decoration_kind_to_bg_color` today by design. +- `FileStyleSummary` (one dominant `Style` per line, minimap-wide) is + computed in `scoped_file_summary` (semantic_render.rs:1156) from + **style spans only** — diagnostics never reach the minimap. The + minimap consumer reads only `Style.fg` (`minimap_style_color`, + :3063). +- Decorations are viewport-scoped and dirty-merged + (`current_decorations`, :299), translated through unconfirmed edits + (M11.4) — the squiggle source data is already maintained correctly. + +## Q#D1 — squiggle mechanism + +**Stance: quad-pipeline underline bars.** A 2px severity-colored bar +at the bottom of each glyph extent the decoration covers, emitted +next to the existing background quads in +`decoration_background_vertex_bytes`. Straight bars first; wavy needs +a shader or texture and buys nothing until the straight bar is +proven. Geometry reuses `push_glyph_extent_rects` with a height/y +override rather than a parallel walk. + +## Q#D2 — retire the fg recolor + +**Stance: yes, squiggles replace text recoloring for all four +severities.** Parity with the TUI rationale: the error is *under* the +text; the text keeps its syntax color. `decoration_kind_to_color` +returns `None` for diagnostic kinds; its RGB constants move to the +new severity→bar-color map so the palette is unchanged. + +## Q#D3 — minimap diagnostic marks (the GPU's gutter signs) + +**Stance: producer-side.** `scoped_file_summary` additionally sets +`underline_color` (severity-max, same indexed palette as the TUI) on +the dominant style of any line a diagnostic touches, honoring the +existing hold-while-stale discipline; the minimap consumer draws its +line stroke in `underline_color` when set, else `fg`. This rides the +v6 field end-to-end and costs no new message. Whole-file diagnostic +positions are available instance-side where the summary is computed — +the viewport-scoping of `Decorations` is irrelevant here. + +## Q#D4 — counts surface + +**Stance: defer.** The GPU has no status band, and viewport-scoped +decorations cannot produce whole-file counts frontend-side. A status +band is its own session (layout, font sizing, what else lives there); +the minimap marks from Q#D3 carry the at-a-glance signal until then. + +## Predicted findings (categorical bets) + +1. **Bar placement tuning**: the first y-position for the 2px bar + will collide with descenders or the next line's ascenders on some + line-height; expect one round of "squiggle looks off" feedback. +2. **A test pins the fg recolor**: at least one pmacs-gpu test + asserts diagnostic text color; it fails and gets rewritten to + assert bar quads instead. +3. **Summary churn**: folding diagnostics into `FileStyleSummary` + makes it recompute on diagnostic publish, not just generation + advance — if the producer's "recompute when generation advances" + gate isn't widened, minimap marks lag one edit behind; if it is + widened naively, summary traffic grows on every publish. + +## Session plan + +Single session, three commits: (1) quad squiggles + fg-recolor +retirement, (2) producer `underline_color` in `FileStyleSummary` + +minimap stroke color, (3) tests/polish. Manual validation: rust file +with error+warning+hint, scroll the viewport, edit near a squiggle +(translation), check minimap marks track publishes. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 928b27f..ad17dc3 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1626,26 +1626,20 @@ impl State { // Same staleness translation as the StyleSpans arm. self.prune_unconfirmed_edits(generation); let segments = translate_decoration_segments(segments, &self.unconfirmed_edits); - // Only diagnostic decorations affect the *rich text* - // (they override glyph fg in `projected_rich_chunks`); - // background kinds (Selection / CurrentLine / Search) - // are quads rebuilt cheaply in `render()`. A full - // `reshape()` (set_rich_text + shape_until_scroll) on - // every decoration change made cursor motion crawl — - // B1's own `CurrentLine` changes on every up/down move. - // Reshape only when the fg-affecting set changed; else - // just repaint the quads. - let fg_before = fg_decoration_fingerprint(&self.current_decorations); if full { self.replace_decorations(segments); } else { self.merge_decorations(segments); } - if fg_before == fg_decoration_fingerprint(&self.current_decorations) { - self.window.request_redraw(); - } else { - self.refresh_changed_lines(); - } + // Every decoration kind is now a quad (backgrounds + // for Selection/CurrentLine, underline bars for the + // diagnostics — the fg-recolor path retired with T + // M4.6 parity), and quads rebuild cheaply per frame + // in `render()`. No decoration change needs a + // reshape, so none triggers one — diagnostic + // publishes no longer pay set_rich_text + + // shape_until_scroll. + self.window.request_redraw(); None } InstanceMessage::InlineAdornments { buffer_id, items } => { @@ -1853,7 +1847,6 @@ impl State { let rich = clipped_chunks_for_range( &self.current_text, &self.current_spans, - &self.current_decorations, &self.current_adornments, vstart, vend, @@ -1976,7 +1969,6 @@ impl State { clipped_chunks_for_range( &self.current_text, &self.current_spans, - &self.current_decorations, &self.current_adornments, line_start, content_end, @@ -2581,10 +2573,21 @@ impl State { if d.kind == DecorationKind::CurrentLine { continue; } - if let Some(color) = decoration_kind_to_bg_color(d.kind) - && let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) - { - self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); + let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) else { + continue; + }; + if let Some(color) = decoration_kind_to_bg_color(d.kind) { + self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None); + } + if let Some(color) = decoration_kind_to_underline_color(d.kind) { + self.push_glyph_extent_rects( + rects, + line_offsets, + lo, + hi, + color, + Some(DIAG_UNDERLINE_PX), + ); } } } @@ -2608,7 +2611,7 @@ impl State { if let Some(color) = decoration_kind_to_bg_color(DecorationKind::CurrentLine) { let (lo, hi) = source_line_range(&self.current_text, presence.cursor); if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) { - self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); + self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None); } } if let Some(sel) = presence.selection @@ -2617,7 +2620,7 @@ impl State { let lo = sel.anchor.min(sel.active).min(text_len); let hi = sel.anchor.max(sel.active).min(text_len); if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) { - self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); + self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None); } } } @@ -2700,6 +2703,7 @@ impl State { lo: u64, hi: u64, color: [f32; 4], + bar_px: Option, ) { if hi <= lo { return; @@ -2722,11 +2726,18 @@ impl State { if let (Some(x0), Some(x1)) = (min_x, max_x) && x1 > x0 { + // `bar_px`: an underline bar hugging the bottom of the + // line box instead of a full-height wash — the GPU's + // diagnostic squiggle (T M4.6 parity, straight-bar v1). + let (y, h) = match bar_px { + Some(bar) => (TEXT_TOP + run.line_top + run.line_height - bar, bar), + None => (TEXT_TOP + run.line_top, run.line_height), + }; rects.push(MinimapRect { x: TEXT_LEFT + x0, - y: TEXT_TOP + run.line_top, + y, w: x1 - x0, - h: run.line_height, + h, color, }); } @@ -3060,7 +3071,15 @@ fn advance_minimap_col(col: usize, ch: char) -> usize { } fn minimap_style_color(style: CellStyle) -> [f32; 4] { - match style.fg { + // A set underline_color is the producer's diagnostic mark for the + // line (protocol v6, T M4.6 parity) — the minimap's gutter sign. + // It outranks the syntax-dominant fg so error/warning lines read + // at a glance. + let color = match style.underline_color { + CellColor::Default => style.fg, + marked => marked, + }; + match color { CellColor::Default => MINIMAP_DEFAULT_LINE, CellColor::Rgb(r, g, b) => rgb_to_minimap_color(r, g, b), CellColor::Indexed(idx) => { @@ -3792,7 +3811,6 @@ fn line_from_chunks(chunks: &[RichChunk]) -> glyphon::cosmic_text::BufferLine { fn clipped_chunks_for_range( text: &str, spans: &[StyleSpan], - decorations: &[Decoration], adornments: &[InlineAdornment], start: u64, end: u64, @@ -3807,15 +3825,6 @@ fn clipped_chunks_for_range( }) }) .collect(); - let decorations: Vec = decorations - .iter() - .filter_map(|d| { - clip_rebase_range(d.range.start, d.range.end, start, end).map(|(s, e)| Decoration { - range: ByteRange { start: s, end: e }, - kind: d.kind, - }) - }) - .collect(); let adornments: Vec = adornments .iter() .filter(|a| a.at >= start && a.at <= end) @@ -3825,13 +3834,12 @@ fn clipped_chunks_for_range( a }) .collect(); - projected_rich_chunks(range_text, &spans, &decorations, &adornments) + projected_rich_chunks(range_text, &spans, &adornments) } fn projected_rich_chunks( text: &str, spans: &[StyleSpan], - decorations: &[Decoration], adornments: &[InlineAdornment], ) -> Vec { let text_len = text.len() as u64; @@ -3848,10 +3856,6 @@ fn projected_rich_chunks( boundaries.push(snap(sp.range.start)); boundaries.push(snap(sp.range.end)); } - for d in decorations { - boundaries.push(snap(d.range.start)); - boundaries.push(snap(d.range.end)); - } let mut renderable_adornments: Vec<(usize, u64, &InlineAdornment)> = adornments .iter() .enumerate() @@ -3872,7 +3876,7 @@ fn projected_rich_chunks( if a < b { chunks.push(RichChunk { text: text[a as usize..b as usize].to_owned(), - color: source_color_at(a, spans, decorations), + color: source_color_at(a, spans), source: ChunkSource::Source { start: a }, }); } @@ -3930,19 +3934,7 @@ fn adornment_text_color(fg: CellColor) -> glyphon::Color { cell_color_to_glyphon(fg).unwrap_or_else(|| glyphon::Color::rgb(130, 130, 140)) } -fn source_color_at( - byte: u64, - spans: &[StyleSpan], - decorations: &[Decoration], -) -> Option { - for d in decorations { - if d.range.start <= byte - && byte < d.range.end - && let Some(c) = decoration_kind_to_color(d.kind) - { - return Some(c); - } - } +fn source_color_at(byte: u64, spans: &[StyleSpan]) -> Option { for sp in spans { if sp.range.start <= byte && byte < sp.range.end { return cell_color_to_glyphon(sp.style.fg); @@ -4010,46 +4002,34 @@ fn indexed_to_glyphon(idx: u8) -> glyphon::Color { glyphon::Color::rgb(level, level, level) } -/// The decorations that affect the *rich text* (a glyph fg override in -/// `projected_rich_chunks`), as an ordered `(range, kind)` set. Only -/// kinds with a foreground color qualify — i.e. the diagnostic -/// severities; background kinds (`Selection` / `CurrentLine` / search) -/// are quads. Equal fingerprints across a `Decorations` update mean the -/// shaped text is unaffected and a `reshape()` can be skipped (the perf -/// fix for cursor-motion-driven `CurrentLine` churn). -fn fg_decoration_fingerprint(decos: &[Decoration]) -> Vec<(ByteRange, DecorationKind)> { - decos - .iter() - .filter(|d| decoration_kind_to_color(d.kind).is_some()) - .map(|d| (d.range, d.kind)) - .collect() -} +/// Height of the diagnostic underline bar, in pixels. Straight-bar +/// v1; a wavy squiggle needs shader/texture work and waits until the +/// straight bar is proven (framing Q#D1). +const DIAG_UNDERLINE_PX: f32 = 2.0; -/// Map a [`DecorationKind`] to a foreground color override, or `None` -/// for kinds whose visual is a background and can't be expressed in -/// the current `Attrs`-only rendering pipeline. +/// Map a [`DecorationKind`] to an underline-bar color, or `None` for +/// kinds that don't underline. /// -/// Session 5 ships **fg-only** decoration rendering. The four -/// background-needing kinds (`Selection`, `SearchMatch`, -/// `SearchMatchActive`, `CurrentLine`) return `None` here because the -/// glyph-color path can only render foregrounds; they route through -/// [`decoration_kind_to_bg_color`] and the quad pipeline instead. -/// -/// Color choices match the conventional editor palette (red errors, -/// yellow warnings, light blue info, dim hints) so the GPU window's -/// visual matches what the pmacs TUI paints via terminal color codes. -fn decoration_kind_to_color(kind: DecorationKind) -> Option { +/// Session 5 originally rendered diagnostics by *recoloring the text +/// foreground*, which clobbered the syntax color of the very token +/// the diagnostic points at — the same flaw the TUI fixed with +/// protocol v6's `underline_color` (T M4.6). The GPU's equivalent is +/// a [`DIAG_UNDERLINE_PX`]-tall quad hugging the bottom of the glyph +/// extent; the text keeps its syntax color. Same RGB palette the fg +/// path used (red / yellow / light blue / dim gray), so the window's +/// severity language is unchanged. +fn decoration_kind_to_underline_color(kind: DecorationKind) -> Option<[f32; 4]> { match kind { // ANSI bright red — matches TUI diagnostic-error palette. - DecorationKind::DiagnosticError => Some(glyphon::Color::rgb(241, 76, 76)), + DecorationKind::DiagnosticError => Some([0.945, 0.298, 0.298, 1.0]), // ANSI bright yellow. - DecorationKind::DiagnosticWarning => Some(glyphon::Color::rgb(245, 245, 67)), + DecorationKind::DiagnosticWarning => Some([0.961, 0.961, 0.263, 1.0]), // ANSI bright blue. - DecorationKind::DiagnosticInfo => Some(glyphon::Color::rgb(59, 142, 234)), + DecorationKind::DiagnosticInfo => Some([0.231, 0.557, 0.918, 1.0]), // ANSI bright black (dim gray — hints should be visible but // visually quietest of the diagnostic four). - DecorationKind::DiagnosticHint => Some(glyphon::Color::rgb(102, 102, 102)), - // Background-needing kinds route through the quad pipeline. + DecorationKind::DiagnosticHint => Some([0.4, 0.4, 0.4, 1.0]), + // Background kinds wash the full line box instead. DecorationKind::Selection | DecorationKind::SearchMatch | DecorationKind::SearchMatchActive @@ -4057,9 +4037,10 @@ fn decoration_kind_to_color(kind: DecorationKind) -> Option { } } -/// Background-bearing companion to [`decoration_kind_to_color`]: maps -/// each background-needing `DecorationKind` to its quad-pipeline color -/// as an RGBA tuple in 0..=1 space. Returns `None` for foreground-only +/// Background-bearing companion to +/// [`decoration_kind_to_underline_color`]: maps each +/// background-needing `DecorationKind` to its quad-pipeline color as +/// an RGBA tuple in 0..=1 space. Returns `None` for underline-only /// kinds (the four diagnostic severities) so the two helpers form a /// total cover with no overlap. /// @@ -4085,7 +4066,8 @@ fn decoration_kind_to_bg_color(kind: DecorationKind) -> Option<[f32; 4]> { DecorationKind::CurrentLine => Some([0.55, 0.60, 0.75, 0.22]), // Deferred to the search-feature arc. DecorationKind::SearchMatch | DecorationKind::SearchMatchActive => None, - // Foreground-only — handled by [`decoration_kind_to_color`]. + // Underline-only — handled by + // [`decoration_kind_to_underline_color`]. DecorationKind::DiagnosticError | DecorationKind::DiagnosticWarning | DecorationKind::DiagnosticInfo @@ -4256,40 +4238,6 @@ mod tests { assert!(!m.contains(Modifiers::SHIFT)); } - #[test] - fn fg_fingerprint_ignores_background_decoration_changes() { - let deco = |start, end, kind| Decoration { - range: ByteRange { start, end }, - kind, - }; - // A diagnostic (fg) decoration + a CurrentLine (bg) decoration. - let before = vec![ - deco(10, 14, DecorationKind::DiagnosticError), - deco(0, 20, DecorationKind::CurrentLine), - ]; - // The cursor moved: CurrentLine now spans a different line, the - // diagnostic is unchanged. - let after = vec![ - deco(10, 14, DecorationKind::DiagnosticError), - deco(40, 60, DecorationKind::CurrentLine), - ]; - assert_eq!( - fg_decoration_fingerprint(&before), - fg_decoration_fingerprint(&after), - "a CurrentLine-only change must not change the fg fingerprint (no reshape)" - ); - - // A diagnostic change DOES alter the fingerprint (reshape needed). - let after_diag = vec![ - deco(10, 18, DecorationKind::DiagnosticError), - deco(0, 20, DecorationKind::CurrentLine), - ]; - assert_ne!( - fg_decoration_fingerprint(&before), - fg_decoration_fingerprint(&after_diag) - ); - } - #[test] fn line_byte_offsets_indexes_each_logical_line() { // "abc\nde\nfgh": lines start at bytes 0, 4, 7. Indexed by @@ -4518,10 +4466,6 @@ mod tests { ..CellStyle::default() }, }]; - let decorations = vec![Decoration { - range: ByteRange { start: 0, end: 5 }, - kind: DecorationKind::DiagnosticWarning, - }]; let hint = |at: u64, label: &str| InlineAdornment { at, placement: AdornmentPlacement::AtOffset, @@ -4535,7 +4479,6 @@ mod tests { let full = flat(&clipped_chunks_for_range( text, &spans, - &decorations, &adornments, 0, text.len() as u64, @@ -4549,7 +4492,6 @@ mod tests { per_line.extend(flat(&clipped_chunks_for_range( text, &spans, - &decorations, &adornments, start, content_end, @@ -4564,12 +4506,12 @@ mod tests { // The boundary hint landed on line 0 (before its newline), not // line 1. - let line0 = clipped_chunks_for_range(text, &spans, &decorations, &adornments, 0, 10); + let line0 = clipped_chunks_for_range(text, &spans, &adornments, 0, 10); assert!( line0.iter().any(|c| c.text == ""), "newline-anchored hint belongs to the line it terminates" ); - let line1 = clipped_chunks_for_range(text, &spans, &decorations, &adornments, 11, 22); + let line1 = clipped_chunks_for_range(text, &spans, &adornments, 11, 22); assert!( line1.iter().all(|c| c.text != ""), "newline-anchored hint must not duplicate onto the next line" @@ -4784,7 +4726,8 @@ mod tests { assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatch).is_none()); assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatchActive).is_none()); - // Foreground-only kinds belong to the fg helper. + // Underline-only kinds belong to the underline helper (T M4.6 + // parity: squiggle bars, not text recoloring). for kind in [ DecorationKind::DiagnosticError, DecorationKind::DiagnosticWarning, @@ -4792,12 +4735,12 @@ mod tests { DecorationKind::DiagnosticHint, ] { assert!(decoration_kind_to_bg_color(kind).is_none()); - assert!(decoration_kind_to_color(kind).is_some()); + assert!(decoration_kind_to_underline_color(kind).is_some()); } } #[test] - fn fg_and_bg_helpers_are_disjoint_total_cover() { + fn underline_and_bg_helpers_are_disjoint_total_cover() { // Every DecorationKind is renderable by exactly one helper. // Adding a new kind without updating one of the helpers should // fail this assertion. @@ -4811,19 +4754,18 @@ mod tests { DecorationKind::DiagnosticInfo, DecorationKind::DiagnosticHint, ] { - let fg = decoration_kind_to_color(kind).is_some(); + let ul = decoration_kind_to_underline_color(kind).is_some(); let bg = decoration_kind_to_bg_color(kind).is_some(); - // Background helper returns None for the search pair — - // deferred to the search-feature arc. For both of those, - // decoration_kind_to_color is also None. That is the - // "neither yet" state — the exclusive-or test exempts it. + // Both helpers return None for the search pair — deferred + // to the search-feature arc. That is the "neither yet" + // state — the exclusive-or test exempts it. let deferred = matches!( kind, DecorationKind::SearchMatch | DecorationKind::SearchMatchActive ); assert!( - deferred || (fg ^ bg), - "{kind:?}: fg={fg} bg={bg} — should be exactly one (unless deferred)" + deferred || (ul ^ bg), + "{kind:?}: underline={ul} bg={bg} — should be exactly one (unless deferred)" ); } } @@ -4837,11 +4779,10 @@ mod tests { let text = "ab→cd"; let chunks = projected_rich_chunks( text, - &[span(0, 3, CellColor::Indexed(1))], - &[Decoration { - range: ByteRange { start: 4, end: 9 }, - kind: DecorationKind::DiagnosticError, - }], + &[ + span(0, 3, CellColor::Indexed(1)), + span(4, 9, CellColor::Indexed(2)), + ], &[], ); let rendered: String = chunks.iter().map(|chunk| chunk.text.as_str()).collect(); @@ -4878,7 +4819,6 @@ mod tests { let chunks = projected_rich_chunks( "abcd", &[], - &[], &[adornment(2, AdornmentPlacement::AtOffset, "X")], ); @@ -4892,7 +4832,6 @@ mod tests { let chunks = projected_rich_chunks( "abcd", &[span(2, 4, CellColor::Indexed(1))], - &[], &[adornment(2, AdornmentPlacement::AtOffset, "X")], ); @@ -4908,36 +4847,11 @@ mod tests { ); } - #[test] - fn inline_adornment_does_not_shift_source_decoration_ranges() { - let chunks = projected_rich_chunks( - "abcd", - &[], - &[Decoration { - range: ByteRange { start: 2, end: 4 }, - kind: DecorationKind::DiagnosticError, - }], - &[adornment(2, AdornmentPlacement::AtOffset, "X")], - ); - - assert_eq!(chunk_texts(&chunks), vec!["ab", "X", "cd"]); - assert!(chunks[0].color.is_none()); - assert!( - chunks[1].color.is_some(), - "default-styled virtual text should render as muted adornment text" - ); - assert!( - chunks[2].color.is_some(), - "diagnostic fg override must still begin at source byte 2" - ); - } - #[test] fn unsupported_adornment_placements_are_ignored_for_session_6() { let chunks = projected_rich_chunks( "abcd", &[], - &[], &[ adornment(0, AdornmentPlacement::BeforeLine, "before"), adornment(4, AdornmentPlacement::EndOfLine, "end"), @@ -4953,7 +4867,6 @@ mod tests { let chunks = projected_rich_chunks( "abcd", &[], - &[], &[adornment(99, AdornmentPlacement::AtOffset, "X")], ); diff --git a/src/diag.rs b/src/diag.rs index 994569b..97b30c7 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -81,6 +81,21 @@ impl DiagnosticSeverity { } } + /// Canonical severity color (T M4.6 / protocol v6): the + /// `underline_color` of this severity's squiggle, the column-0 + /// marker background, and the minimap mark all share it. Indexed + /// 1/3/6/8 (red / yellow / cyan / gray) for 8/16-color terminal + /// portability. + #[must_use] + pub fn underline_color(self) -> Color { + match self { + Self::Error => Color::Indexed(1), + Self::Warning => Color::Indexed(3), + Self::Information => Color::Indexed(6), + Self::Hint => Color::Indexed(8), + } + } + fn from_lsp_value(v: Option<&Value>) -> Self { match v.and_then(Value::as_i64) { Some(1) => Self::Error, @@ -187,6 +202,12 @@ pub struct DiagnosticStore { /// here on the assumption that a fresh `publishDiagnostics` /// corresponds to the latest sent version. stale_uris: std::collections::HashSet, + /// Per-URI change counter, bumped on every [`Self::set`] / + /// [`Self::clear`]. Diagnostics arrive without a CRDT generation + /// bump, so generation-keyed caches (the `FileStyleSummary` + /// producer's) additionally key on this to know a republish + /// happened (T M4.6 GPU parity). + epochs: HashMap, } impl DiagnosticStore { @@ -207,6 +228,7 @@ impl DiagnosticStore { diags.sort_by(Diagnostic::compare_by_position); let uri = uri.into(); self.stale_uris.remove(&uri); + *self.epochs.entry(uri.clone()).or_insert(0) += 1; if diags.is_empty() { self.by_uri.remove(&uri); } else { @@ -219,6 +241,16 @@ impl DiagnosticStore { pub fn clear(&mut self, uri: &str) { self.by_uri.remove(uri); self.stale_uris.remove(uri); + *self.epochs.entry(uri.to_owned()).or_insert(0) += 1; + } + + /// Monotonic per-URI change counter: how many times `set` / + /// `clear` ran for this URI. `0` for a URI never written. + /// Consumers cache against this to detect republishes that no + /// CRDT generation bump announces. + #[must_use] + pub fn epoch_for(&self, uri: &str) -> u64 { + self.epochs.get(uri).copied().unwrap_or(0) } /// Mark `uri`'s stored diagnostics as stale (T M11.8). Called @@ -338,8 +370,7 @@ const TAB_WIDTH: u32 = 8; fn error_style() -> Style { Style { underline: UnderlineStyle::Curly, - // Indexed 1 (red) is portable across 8/16-color terminals. - underline_color: Color::Indexed(1), + underline_color: DiagnosticSeverity::Error.underline_color(), ..Style::default() } } @@ -348,8 +379,7 @@ fn error_style() -> Style { fn warning_style() -> Style { Style { underline: UnderlineStyle::Curly, - // Indexed 3: yellow. - underline_color: Color::Indexed(3), + underline_color: DiagnosticSeverity::Warning.underline_color(), ..Style::default() } } @@ -358,8 +388,7 @@ fn warning_style() -> Style { fn info_style() -> Style { Style { underline: UnderlineStyle::Single, - // Indexed 6: cyan. - underline_color: Color::Indexed(6), + underline_color: DiagnosticSeverity::Information.underline_color(), ..Style::default() } } @@ -368,9 +397,7 @@ fn info_style() -> Style { fn hint_style() -> Style { Style { underline: UnderlineStyle::Dotted, - // Indexed 8: bright black ("gray") — present on 16-color - // terminals, subtle by design for hints. - underline_color: Color::Indexed(8), + underline_color: DiagnosticSeverity::Hint.underline_color(), ..Style::default() } } @@ -391,14 +418,8 @@ fn style_for(severity: DiagnosticSeverity) -> Style { /// style-only), and zero-width diagnostics — invisible to the /// underline pass — still get a visible artifact. fn marker_style_for(severity: DiagnosticSeverity) -> Style { - let bg = match severity { - DiagnosticSeverity::Error => Color::Indexed(1), - DiagnosticSeverity::Warning => Color::Indexed(3), - DiagnosticSeverity::Information => Color::Indexed(6), - DiagnosticSeverity::Hint => Color::Indexed(8), - }; Style { - bg, + bg: severity.underline_color(), ..Style::default() } } @@ -881,6 +902,24 @@ mod tests { } } + #[test] + fn epoch_bumps_on_set_and_clear_per_uri() { + let mut store = DiagnosticStore::new(); + assert_eq!(store.epoch_for("file:///a"), 0); + store.set("file:///a", vec![diag(0, DiagnosticSeverity::Error, "x")]); + assert_eq!(store.epoch_for("file:///a"), 1); + // An empty set (server reports clean) still counts — the + // consumer must refresh to drop its marks. + store.set("file:///a", vec![]); + assert_eq!(store.epoch_for("file:///a"), 2); + store.clear("file:///a"); + assert_eq!(store.epoch_for("file:///a"), 3); + // mark_stale is not a content change; other URIs are isolated. + store.mark_stale("file:///a"); + assert_eq!(store.epoch_for("file:///a"), 3); + assert_eq!(store.epoch_for("file:///b"), 0); + } + #[test] fn view_advertises_diagnostic_kind() { // `pmacs.window._overlay_kinds()` introspection (task #23 wire-up, diff --git a/src/semantic_render.rs b/src/semantic_render.rs index aee239b..ba77da7 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -113,7 +113,11 @@ pub struct SemanticRenderState { /// buffer at the same generation re-uses what the frontend /// already has and emits nothing. First emission happens on the /// first frame for a buffer; further emissions only after edits. - last_summary: HashMap, + /// `(crdt_generation, diag_epoch)` the last emitted summary was + /// computed against. Diagnostics arrive without a generation + /// bump, so the epoch half catches republishes (minimap marks, + /// T M4.6 GPU parity). + last_summary: HashMap, /// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs /// the tree-sitter highlights query over the *whole declared /// viewport* (which the GPU frontend sets to the entire buffer) @@ -464,11 +468,17 @@ impl SemanticRenderState { if grammar_style_parse_not_ready(state, buffer_id) { return None; } - if self.last_summary.get(&buffer_id).copied() == Some(generation) { + // Diagnostics fold into the summary (minimap marks) but + // publish without a generation bump — key the cache on the + // diag store's per-URI epoch as well, so a republish + // refreshes the marks and anything else stays suppressed. + let diag_epoch = diagnostics_epoch(state, buffer_id); + if self.last_summary.get(&buffer_id).copied() == Some((generation, diag_epoch)) { return None; } let lines = scoped_file_summary(state, buffer_id); - self.last_summary.insert(buffer_id, generation); + self.last_summary + .insert(buffer_id, (generation, diag_epoch)); Some(InstanceMessage::FileStyleSummary { buffer_id, generation, @@ -628,6 +638,8 @@ impl SemanticRenderState { for d in &diags { let lo = line_col_to_byte(line_starts, source_len, d.start_line, d.start_col); let hi = line_col_to_byte(line_starts, source_len, d.end_line, d.end_col); + let (lo, hi) = + widen_zero_width_diag(lo, hi, d.start_line, line_starts, source_len); if let Some(range) = clip_to_viewport(lo, hi, vp) { out.push(Decoration { range, @@ -723,6 +735,38 @@ fn scoped_inline_adornments(state: &EditorState, vp: &DeclaredViewport) -> Vec (u64, u64) { + if hi > lo { + return (lo, hi); + } + // Content end excludes the trailing newline, same semantics as + // the summary's per-line ranges. + let content_end = line_starts + .get(start_line as usize + 1) + .map_or(source_len, |&next| next.saturating_sub(1)); + if lo >= content_end { + (lo.saturating_sub(1), lo) + } else { + (lo, (lo + 1).min(source_len)) + } +} + fn clip_to_viewport(lo: u64, hi: u64, vp: &DeclaredViewport) -> Option { let start = lo.max(vp.visible.start); let end = hi.min(vp.visible.end); @@ -838,6 +882,19 @@ fn diagnostics_store_stale(state: &EditorState, buffer_id: BufferId) -> bool { guard.is_stale(&uri) } +/// The diag store's per-URI change epoch for `buffer_id`'s file, `0` +/// for buffers with no file URI or no diagnostics history. Keys the +/// `FileStyleSummary` cache (see [`SemanticRenderState::last_summary`]). +fn diagnostics_epoch(state: &EditorState, buffer_id: BufferId) -> u64 { + let core = state.core.borrow(); + let Some(uri) = buffer_file_uri(&core, buffer_id) else { + return 0; + }; + let store = state.lsp_manager.borrow().diag_store(); + let guard = store.lock().expect("diag store mutex poisoned"); + guard.epoch_for(&uri) +} + /// Style-family staleness for the LSP-token authority. True only for /// a buffer with **no** tree-sitter view (policy A routes those /// through `lsp_scoped_style_spans`) whose semantic-token store entry @@ -1182,6 +1239,9 @@ fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec