diff --git a/docs/pmacs-gpu-perline-reshape-framing.md b/docs/pmacs-gpu-perline-reshape-framing.md new file mode 100644 index 0000000..53bcf85 --- /dev/null +++ b/docs/pmacs-gpu-perline-reshape-framing.md @@ -0,0 +1,79 @@ +# pmacs-gpu per-line incremental reshape — framing pass + +Date: 2026-06-10. The per-keystroke latency floor after the +optimistic-typing arc: every keystroke runs `reshape()` — full +visible-slice chunk rebuild + `set_rich_text` (resets every +`BufferLine`'s shape cache) + `shape_until_scroll` (re-shapes every +visible line with `Shaping::Advanced`). + +## Verified cosmic-text facts (0.18.2, vendored source) + +- `Buffer::lines` is `pub Vec`; replacing one element with + `BufferLine::new(text, ending, attrs_list, shaping)` leaves the + other lines' shape caches intact, and `shape_until_scroll` re-shapes + only the fresh line (`shape_opt: Cached::Empty`). +- `set_rich_text` splits lines via `BidiParagraphs`, which strips the + trailing paragraph separator from every yielded line in BOTH the + ASCII fast path and the `BidiInfo` general path, yields **no + trailing empty line** for text ending in `\n`, and assigns + `LineEnding::default()` (= `Lf`) to every line including the last. + Attr spans are added only when they differ from the defaults. +- Parity hazard: separators other than `\n` (`\r`, U+0085, U+2028, + U+2029) split lines in the full path; a surgically built line + containing one would diverge. + +## Q#R1 — surgery vs full rebuild + +**Stance: per-line surgery for the single-line edit (the keystroke +case), full `reshape()` for everything else.** Fallback conditions +(any ⇒ full): slice origin moved; line count changed (Enter, +multi-line delete); inserted text contains `\n`; edited line outside +the shaped slice (except: an edit entirely *past* the slice end +changes no visible line — update `view_range` and redraw only); the +rebuilt line's projected text contains a non-`\n` paragraph +separator; more than one edit in the batch. + +Equivalence invariant: the surgically built line's (text, attr spans) +must equal what a full `set_rich_text` over the slice would produce +for that line. Achieved by deriving both from one shared chunk +function (`clipped_chunks_for_range`) — the full path calls it with +the slice range, surgery with the line's content range — and pinned +by a pure unit test (full-walk output split at line boundaries == +concatenated per-line walks). + +## Q#R2 — the M-2 hit map under surgery + +The pointer hit map (projected runs + projected line starts) is +derived from the full chunk walk. **Stance: make it lazy** — surgery +marks it dirty; `hit_test_source_byte` rebuilds it on demand from the +same shared chunk function. Clicks are rare relative to keystrokes, +and the rebuild is an O(slice) byte walk (no shaping), microseconds. +Deriving from the same inputs keeps the map consistent with the +shaped buffer by construction. + +## Q#R3 — scope + +Only the local text-delta apply path (`apply_loro_text_delta_batches` +callers: optimistic edits + incoming CrdtOps) takes the surgery path. +StyleSpans/Decorations/adornment arrivals, scroll, resize, snapshot +keep full reshape — they change content across the slice and arrive +at far lower cadence (parse-settle / server-response rate). + +## Predicted findings (categorical bets) + +1. **Parity miss** in some attrs/boundary case the unit test doesn't + cover (most likely: adornment anchored exactly at a line edge) — + surfaces as one mis-styled line until the next full reshape. +2. **Staleness leak**: some consumer besides the hit map silently + depended on full-reshape side effects (`view_range` freshness, + redraw requests) — surfaces as a paint lag. +3. The win is real but the *remaining* per-keystroke cost shifts to + glyphon `prepare` (full-buffer glyph pass per redraw), capping the + perceived improvement. + +## Session plan + +Single session: shared chunk fn refactor → surgery + fallbacks → +lazy hit map → pure parity tests. Manual validation: type mid-file +(fast burst), Enter (fallback), type on a line with inlay hints, +click after typing (lazy map), peer-edit while typing. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 33173c2..55f8fb6 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -225,6 +225,10 @@ struct App { type LoroTextDeltaBatches = Arc>>>; /// All resources owned by one running pmacs-gpu instance. +#[allow( + clippy::struct_excessive_bools, + reason = "independent render/input state flags, not a config bitset" +)] struct State { window: Arc, device: wgpu::Device, @@ -409,6 +413,18 @@ struct State { /// `(when, byte)` of the last primary Down, for frontend-side /// double-click detection (same-hit within the interval). last_pointer_down: Option<(std::time::Instant, u64)>, + /// Q#R2 — the per-line surgery path skips rebuilding the pointer + /// hit map (clicks are rare next to keystrokes); this marks it + /// stale so `hit_test_source_byte` rebuilds on demand from the + /// same shared chunk function. + hit_map_dirty: bool, + /// Per-shaped-line chunk cache: `line_chunk_cache[i]` is the + /// chunk set `buffer.lines[i]` was built from. Lets incoming + /// frames re-shape ONLY lines whose styling actually changed, and + /// lets scroll reuse retained lines wholesale. + line_chunk_cache: Vec>, + /// Absolute source-line index of `buffer.lines[0]`. + shaped_top: usize, } /// pmacs-gpu's own cursor position, mirrored from `CursorByte`. @@ -1006,6 +1022,9 @@ impl State { pointer_drag_active: false, last_pointer_sent_byte: None, last_pointer_down: None, + hit_map_dirty: false, + line_chunk_cache: Vec::new(), + shaped_top: 0, } } @@ -1183,7 +1202,7 @@ impl State { // top) moves it outside the slice, and waiting a round trip // to scroll reads as a hitch. let viewport = if self.scroll_to_cursor() { - self.reshape(); + self.rebuild_lines_reusing_scroll(); self.viewport_send_if_changed(predicted.buffer_id) } else { None @@ -1203,6 +1222,7 @@ impl State { &mut self, delta_batches: &[Vec], ) -> Result, &'static str> { + let line_count_before = self.current_line_starts.len(); let edits = apply_loro_text_delta_batches( &mut self.current_text, &mut self.current_line_starts, @@ -1213,7 +1233,17 @@ impl State { return Ok(edits); } self.translate_cached_anchors(&edits); - self.reshape(); + // Q#R1 — the keystroke case (one edit, no line-structure + // change) re-shapes only the affected BufferLine; everything + // else falls back to the full slice reshape. + let single_line_edit = edits.len() == 1 + && self.current_line_starts.len() == line_count_before + && !self.current_text + [edits[0].start as usize..(edits[0].start + edits[0].inserted_len) as usize] + .contains('\n'); + if !(single_line_edit && self.try_reshape_line(edits[0])) { + self.reshape(); + } Ok(edits) } @@ -1516,7 +1546,11 @@ impl State { } else { self.merge_style_spans(segments); } - self.reshape(); + // Re-shape only lines whose styling actually changed + // — a parse-settle frame after a burst usually + // recolors a line or two, and a scroll-triggered + // resync only the newly exposed ones. + self.refresh_changed_lines(); None } InstanceMessage::Decorations { @@ -1549,7 +1583,7 @@ impl State { if fg_before == fg_decoration_fingerprint(&self.current_decorations) { self.window.request_redraw(); } else { - self.reshape(); + self.refresh_changed_lines(); } None } @@ -1559,7 +1593,7 @@ impl State { } self.current_adornments = items; self.current_adornments.sort_by_key(|a| a.at); - self.reshape(); + self.refresh_changed_lines(); None } InstanceMessage::FileStyleSummary { @@ -1655,7 +1689,9 @@ impl State { // slice, and re-declare the scoped Viewport so the // producer ships spans for what's now visible. if self.scroll_to_cursor() { - self.reshape(); + // Pure scroll: retained lines keep their shape + // caches; only newly exposed lines shape. + self.rebuild_lines_reusing_scroll(); if let Some(vp) = self.viewport_send_if_changed(buffer_id) { return Some(vp); } @@ -1736,8 +1772,25 @@ impl State { /// line) → projected byte → run map → slice byte → + `vstart`. /// `None` when no buffer is attached or the position is outside /// anything hit-testable. - fn hit_test_source_byte(&self, x: f64, y: f64) -> Option { + fn hit_test_source_byte(&mut self, x: f64, y: f64) -> Option { self.current_buffer_id?; + if self.hit_map_dirty { + // Q#R2 — a per-line reshape deferred this; rebuild from + // the same chunk source the shaped buffer was built from. + let (vstart, vend) = self.view_range; + let rich = clipped_chunks_for_range( + &self.current_text, + &self.current_spans, + &self.current_decorations, + &self.current_adornments, + vstart, + vend, + ); + let (hit_runs, projected_line_starts) = build_hit_runs(&rich); + self.current_hit_runs = hit_runs; + self.projected_line_starts = projected_line_starts; + self.hit_map_dirty = false; + } let rel_x = x as f32 - TEXT_LEFT; let rel_y = y as f32 - TEXT_TOP; let cursor = self.buffer.hit(rel_x, rel_y)?; @@ -1760,11 +1813,186 @@ impl State { return None; } self.scroll_top = new_top; - self.reshape(); + self.rebuild_lines_reusing_scroll(); self.current_buffer_id .and_then(|bid| self.viewport_send_if_changed(bid)) } + /// Q#R1 — per-line incremental reshape for a single-line text + /// edit: rebuild ONE `BufferLine` instead of re-shaping the whole + /// visible slice. Returns `false` when the edit needs the full + /// `reshape` (slice origin moved, edited line outside the shaped + /// slice, exotic paragraph separators that the full path would + /// have split on). The caller has already established the edit is + /// single-line (line count unchanged, no `\n` inserted). + fn try_reshape_line(&mut self, edit: TextProjectionEdit) -> bool { + let (vstart, vend) = self.visible_byte_range(); + if vstart != self.view_range.0 { + // The slice origin moved (edit before the viewport): the + // whole slice shifts; surgery can't help. + return false; + } + if edit.start >= vend { + // Entirely past the visible slice: no shaped line's + // content changes; offsets are clip-rebased per frame. + self.view_range = (vstart, vend); + self.hit_map_dirty = true; + self.window.request_redraw(); + return true; + } + let line_idx = self + .current_line_starts + .partition_point(|&s| s <= edit.start) + .saturating_sub(1); + let line_start = self.current_line_starts[line_idx]; + if line_start < vstart { + return false; + } + let next_start = self.current_line_starts.get(line_idx + 1).copied(); + let content_end = next_start + .map_or(self.current_text.len() as u64, |n| n.saturating_sub(1)) + .min(vend); + let Some(shaped_idx) = line_idx.checked_sub(self.shaped_top) else { + return false; + }; + if shaped_idx >= self.buffer.lines.len() || shaped_idx >= self.line_chunk_cache.len() { + // E.g. typing on the phantom empty line after a trailing + // newline — no BufferLine exists for it; full reshape + // handles those shapes correctly. + return false; + } + let chunks = self.chunks_for_line(line_start, content_end); + self.buffer.lines[shaped_idx] = line_from_chunks(&chunks); + self.line_chunk_cache[shaped_idx] = chunks; + self.buffer.shape_until_scroll(&mut self.font_system, false); + self.view_range = (vstart, vend); + self.hit_map_dirty = true; + self.window.request_redraw(); + true + } + + /// `(top, [(line_start, content_end)])` for the slice + /// `[vstart, vend)`: one entry per shaped line, content excluding + /// the `\n`. A line starting exactly at `vend` (incl. the phantom + /// line after a trailing `\n`) is not shaped — matching the line + /// splitting `set_rich_text` used to do. + fn slice_line_ranges(&self, vstart: u64, vend: u64) -> (usize, Vec<(u64, u64)>) { + let starts = &self.current_line_starts; + let n = starts.len(); + let top = self.scroll_top.min(n.saturating_sub(1)); + let mut ranges = Vec::new(); + let mut idx = top; + while idx < n { + let ls = starts[idx]; + if ls >= vend { + break; + } + let ce = starts + .get(idx + 1) + .map_or(self.current_text.len() as u64, |&next| next - 1) + .min(vend); + ranges.push((ls, ce)); + idx += 1; + } + if ranges.is_empty() { + ranges.push((vstart, vstart)); + } + (top, ranges) + } + + fn chunks_for_line(&self, line_start: u64, content_end: u64) -> Vec { + clipped_chunks_for_range( + &self.current_text, + &self.current_spans, + &self.current_decorations, + &self.current_adornments, + line_start, + content_end, + ) + } + + /// Rebuild the shaped slice, reusing any retained line whose + /// absolute index was already shaped (pure scroll: content and + /// styling unchanged for retained lines, their shape caches + /// survive — only newly exposed lines pay shaping). Falls back to + /// building everything when nothing overlaps. Every builder keeps + /// `line_chunk_cache` current, so reuse is always sound here. + fn rebuild_lines_reusing_scroll(&mut self) { + let (vstart, vend) = self.visible_byte_range(); + self.view_range = (vstart, vend); + let (new_top, ranges) = self.slice_line_ranges(vstart, vend); + let old_top = self.shaped_top; + let mut old_lines: Vec> = + std::mem::take(&mut self.buffer.lines) + .into_iter() + .map(Some) + .collect(); + let mut old_cache: Vec>> = std::mem::take(&mut self.line_chunk_cache) + .into_iter() + .map(Some) + .collect(); + let mut lines = Vec::with_capacity(ranges.len()); + let mut cache = Vec::with_capacity(ranges.len()); + for (i, &(ls, ce)) in ranges.iter().enumerate() { + let abs = new_top + i; + let reused = abs.checked_sub(old_top).and_then(|j| { + if j < old_lines.len() && j < old_cache.len() { + old_lines[j].take().zip(old_cache[j].take()) + } else { + None + } + }); + if let Some((line, chunks)) = reused { + lines.push(line); + cache.push(chunks); + } else { + let chunks = self.chunks_for_line(ls, ce); + lines.push(line_from_chunks(&chunks)); + cache.push(chunks); + } + } + self.buffer.lines = lines; + self.line_chunk_cache = cache; + self.shaped_top = new_top; + self.buffer + .set_scroll(glyphon::cosmic_text::Scroll::default()); + self.buffer.shape_until_scroll(&mut self.font_system, false); + self.hit_map_dirty = true; + self.window.request_redraw(); + } + + /// Re-shape ONLY lines whose chunk set changed — the incoming + /// frame path (`StyleSpans` / fg `Decorations` / `InlineAdornments`). + /// A parse-settle frame after a typing burst usually recolors a + /// line or two; re-shaping the whole slice for it was a full + /// keystroke-cost stall. + fn refresh_changed_lines(&mut self) { + let (vstart, vend) = self.visible_byte_range(); + let (top, ranges) = self.slice_line_ranges(vstart, vend); + if (vstart, vend) != self.view_range + || top != self.shaped_top + || ranges.len() != self.line_chunk_cache.len() + || ranges.len() != self.buffer.lines.len() + { + self.reshape(); + return; + } + let mut any = false; + for (i, &(ls, ce)) in ranges.iter().enumerate() { + let chunks = self.chunks_for_line(ls, ce); + if chunks != self.line_chunk_cache[i] { + self.buffer.lines[i] = line_from_chunks(&chunks); + self.line_chunk_cache[i] = chunks; + any = true; + } + } + if any { + self.buffer.shape_until_scroll(&mut self.font_system, false); + self.hit_map_dirty = true; + } + self.window.request_redraw(); + } + /// Bookkeeping for an outgoing Pointer event: it supersedes any /// unconfirmed optimistic-cursor prediction (the daemon's answer /// will be the click position, not the typing prediction), and @@ -2001,68 +2229,23 @@ impl State { // are clipped + rebased onto the slice (subtract `vstart`). let (vstart, vend) = self.visible_byte_range(); self.view_range = (vstart, vend); - let slice = &self.current_text[vstart as usize..vend as usize]; - - let spans: Vec = self - .current_spans - .iter() - .filter_map(|sp| { - clip_rebase_range(sp.range.start, sp.range.end, vstart, vend).map(|(s, e)| { - StyleSpan { - range: ByteRange { start: s, end: e }, - style: sp.style, - } - }) - }) - .collect(); - let decorations: Vec = self - .current_decorations - .iter() - .filter_map(|d| { - clip_rebase_range(d.range.start, d.range.end, vstart, vend).map(|(s, e)| { - Decoration { - range: ByteRange { start: s, end: e }, - kind: d.kind, - } - }) - }) - .collect(); - let adornments: Vec = self - .current_adornments - .iter() - .filter(|a| a.at >= vstart && a.at <= vend) - .map(|a| { - let mut a = a.clone(); - a.at -= vstart; - a - }) - .collect(); - - let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono")); - let rich = projected_rich_chunks(slice, &spans, &decorations, &adornments); - // Q#M2 — the pointer hit map is derived from the SAME chunks - // the shaped buffer is built from, so the two cannot disagree. - let (hit_runs, projected_line_starts) = build_hit_runs(&rich); - self.current_hit_runs = hit_runs; - self.projected_line_starts = projected_line_starts; - let chunks: Vec<(String, Attrs<'static>)> = rich - .into_iter() - .map(|chunk| { - let mut attrs = default_attrs.clone(); - if let Some(c) = chunk.color { - attrs = attrs.color(c); - } - (chunk.text, attrs) - }) - .collect(); - self.buffer.set_rich_text( - &mut self.font_system, - chunks.iter().map(|(s, a)| (s.as_str(), a.clone())), - &default_attrs, - Shaping::Advanced, - None, - ); + let (top, ranges) = self.slice_line_ranges(vstart, vend); + let mut lines = Vec::with_capacity(ranges.len()); + let mut cache = Vec::with_capacity(ranges.len()); + for &(ls, ce) in &ranges { + let chunks = self.chunks_for_line(ls, ce); + lines.push(line_from_chunks(&chunks)); + cache.push(chunks); + } + self.buffer.lines = lines; + self.line_chunk_cache = cache; + self.shaped_top = top; + self.buffer + .set_scroll(glyphon::cosmic_text::Scroll::default()); self.buffer.shape_until_scroll(&mut self.font_system, false); + // The pointer hit map rebuilds lazily from the same caches + // (Q#R2) — clicks are rare next to keystrokes/frames. + self.hit_map_dirty = true; self.window.request_redraw(); } @@ -2475,7 +2658,7 @@ struct MinimapLineShape { content_cols: usize, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] struct RichChunk { text: String, color: Option, @@ -3485,6 +3668,75 @@ fn px_to_ndc_y(y: f32, height: u32) -> f32 { /// `text` and retain source-byte styling; inline adornments create /// extra chunks at their anchors and therefore do not shift any source /// span/decoration range. +/// Clip + rebase the whole-file styling caches onto the byte range +/// `[start, end)` and build its projected chunks (Q#R1: the ONE chunk +/// source both the full `reshape` and the per-line surgery derive +/// from, so the two paths cannot disagree about a line's content). +/// Adornment anchors use an inclusive end — an anchor exactly at +/// `end` (a line's `\n`, or the slice end) injects after the last +/// content byte, matching the full-walk boundary behavior. +/// Assemble one shaped line from its chunks: concatenated projected +/// text + an attrs span per colored chunk (mirroring `set_rich_text`'s +/// only-when-non-default rule). Every line gets `LineEnding::Lf` — +/// the separator byte itself never enters a line's text. +fn line_from_chunks(chunks: &[RichChunk]) -> glyphon::cosmic_text::BufferLine { + let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono")); + let mut attrs_list = glyphon::cosmic_text::AttrsList::new(&default_attrs); + let mut text = String::new(); + for chunk in chunks { + let start = text.len(); + text.push_str(&chunk.text); + if let Some(c) = chunk.color { + attrs_list.add_span(start..text.len(), &default_attrs.clone().color(c)); + } + } + glyphon::cosmic_text::BufferLine::new( + text, + glyphon::cosmic_text::LineEnding::Lf, + attrs_list, + Shaping::Advanced, + ) +} + +fn clipped_chunks_for_range( + text: &str, + spans: &[StyleSpan], + decorations: &[Decoration], + adornments: &[InlineAdornment], + start: u64, + end: u64, +) -> Vec { + let range_text = &text[start as usize..end as usize]; + let spans: Vec = spans + .iter() + .filter_map(|sp| { + clip_rebase_range(sp.range.start, sp.range.end, start, end).map(|(s, e)| StyleSpan { + range: ByteRange { start: s, end: e }, + style: sp.style, + }) + }) + .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) + .map(|a| { + let mut a = a.clone(); + a.at -= start; + a + }) + .collect(); + projected_rich_chunks(range_text, &spans, &decorations, &adornments) +} + fn projected_rich_chunks( text: &str, spans: &[StyleSpan], @@ -4139,6 +4391,104 @@ mod tests { ); } + /// Q#R1 parity invariant: the per-line surgery's chunk source + /// (`clipped_chunks_for_range` over one line's content range) + /// must agree byte-for-byte — text AND color — with the full + /// slice walk split at line boundaries. Pinned here so the + /// surgically rebuilt `BufferLine` can't drift from what a full + /// `set_rich_text` would have produced. + #[test] + fn per_line_chunks_match_the_full_walk() { + // (byte, color) stream, with `\n` bytes dropped — the full + // walk keeps them inside source chunks; per-line walks + // exclude them (cosmic strips the separator per line). + fn flat(chunks: &[RichChunk]) -> Vec<(u8, Option)> { + chunks + .iter() + .flat_map(|c| { + let color = c.color.map(|col| col.0); + c.text + .bytes() + .filter(|&b| b != b'\n') + .map(move |b| (b, color)) + .collect::>() + }) + .collect() + } + // Two content lines + trailing newline. A span crossing the + // line break, plus two inlay hints: one mid-line-1, one + // anchored EXACTLY at line 0's newline (the predicted-finding + // #1 boundary case — it must belong to line 0, before the \n). + let text = "alpha BETA\ngamma delta\n"; + let spans = vec![StyleSpan { + range: ByteRange { start: 6, end: 16 }, + style: CellStyle { + fg: CellColor::Indexed(2), + ..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, + content: AdornmentContent::Text { + text: label.to_owned(), + style: CellStyle::default(), + }, + }; + let adornments = vec![hint(10, ""), hint(17, ": T ")]; + + let full = flat(&clipped_chunks_for_range( + text, + &spans, + &decorations, + &adornments, + 0, + text.len() as u64, + )); + + // Line ranges as the surgery computes them: content excludes + // the newline; the phantom line after the trailing `\n` is + // empty. + let mut per_line = Vec::new(); + for (start, content_end) in [(0u64, 10u64), (11, 22), (23, 23)] { + per_line.extend(flat(&clipped_chunks_for_range( + text, + &spans, + &decorations, + &adornments, + start, + content_end, + ))); + } + + assert_eq!( + per_line, full, + "per-line chunk walks must reproduce the full walk exactly \ + (text and colors, newlines excluded)" + ); + + // 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); + 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); + assert!( + line1.iter().all(|c| c.text != ""), + "newline-anchored hint must not duplicate onto the next line" + ); + assert!( + line1.iter().any(|c| c.text == ": T "), + "mid-line hint renders on its own line" + ); + } + #[test] fn hit_runs_map_projected_bytes_back_to_source() { // Source slice "ab\ncd" with an inlay hint ": i32 " anchored diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 22ef343..aee239b 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -272,8 +272,27 @@ impl SemanticRenderState { } // --- Decorations (T M11.3 producer, T M11.4 diff) --- - let decorations = self.scoped_decorations(state, &vp); + let mut decorations = self.scoped_decorations(state, &vp); let prev = self.last_decorations.get(&vp.buffer_id); + // Hold-while-stale, part 2 (selection navigation): while the + // diag store is stale, CARRY the previously shipped diagnostic + // items through this frame's set instead of dropping them. + // A shift+arrow during the post-burst stale window then diffs + // as a tiny selection-only segment (the carried diag ranges + // are unchanged, so they fall outside the changed intervals + // and are never re-shipped at stale positions) — instead of + // a full frame per keypress that also blinked the frontend's + // held diagnostics out. + let diag_hold = diagnostics_store_stale(state, vp.buffer_id); + if diag_hold && let Some(p) = prev { + decorations.extend( + p.items + .iter() + .filter(|d| is_diagnostic_kind(d.kind)) + .cloned(), + ); + decorations.sort_by_key(|d| d.range.start); + } // Hold-while-stale: while the diag store is stale (document // edited since the last `publishDiagnostics`), this frame has // no authoritative diagnostic positions. The frontend's @@ -288,18 +307,23 @@ impl SemanticRenderState { // the baseline untouched — staleness clears on the next // publishDiagnostics absorption, and the generation transition // since the held baseline forces that frame full. - let held = diagnostics_store_stale(state, vp.buffer_id) - && prev.is_some_and(|p| { - decorations - .iter() - .eq(p.items.iter().filter(|d| !is_diagnostic_kind(d.kind))) - }); - let full = prev.is_none_or(|p| p.visible != vp.visible || p.generation != generation); + let held = diag_hold + && prev + .is_some_and(|p| p.visible == vp.visible && decorations.iter().eq(p.items.iter())); + // During a hold, only the GENERATION trigger for a full frame + // is suppressed (edits bump it every keystroke; the carried + // set diffs instead). First-ever frames and viewport changes + // still resync in full. + let full = prev + .is_none_or(|p| p.visible != vp.visible || (!diag_hold && p.generation != generation)); if held { - // No new information for the frontend this frame. (A - // selection change during the stale window falls through - // to the branches below and ships without diagnostics — - // rare, and better than pinning a dead selection.) + // No new information for the frontend this frame. Keep + // the baseline's generation current so the eventual + // unstale frame diffs instead of full-resyncing (the diff + // covers the diagnostics' post-publish positions). + if let Some(p) = self.last_decorations.get_mut(&vp.buffer_id) { + p.generation = generation; + } } else if full { let suppress_empty_generation_bump = prev.is_some_and(|p| { p.visible == vp.visible && p.items.is_empty() && decorations.is_empty() @@ -1632,8 +1656,9 @@ mod tests { "no stale-positioned diagnostics ride along; got {decos:?}" ); - // The next publishDiagnostics clears the flag; diagnostics - // re-emit on the following frame. + // The next publishDiagnostics clears the flag. An IDENTICAL + // publish stays silent — the carried baseline already matches + // the frontend's cache. A *changed* diagnostic diffs through. state .lsp_manager .borrow() @@ -1644,7 +1669,7 @@ mod tests { &uri, vec![crate::diag::Diagnostic { start_line: 1, - start_col: 0, + start_col: 1, end_line: 1, end_col: 2, severity: crate::diag::DiagnosticSeverity::Warning, @@ -1654,12 +1679,12 @@ mod tests { }], ); let (_full, decos) = decorations_of(&s.render_frame(&state)) - .expect("post-publish frame re-ships diagnostics"); + .expect("post-publish frame ships the moved diagnostic"); assert!( decos .iter() .any(|d| d.kind == DecorationKind::DiagnosticWarning), - "diagnostics return once the store is fresh; got {decos:?}" + "diagnostics update once the store is fresh; got {decos:?}" ); }