Merge pull request #62 from levineuwirth/session-r-perline-reshape

pmacs-gpu: per-line incremental reshape (typing latency floor)
This commit is contained in:
Levi Neuwirth 2026-06-10 16:24:57 -04:00 committed by GitHub
commit 76a13ba243
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 541 additions and 87 deletions

View File

@ -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<BufferLine>`; 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.

View File

@ -225,6 +225,10 @@ struct App {
type LoroTextDeltaBatches = Arc<Mutex<Vec<Vec<loro::TextDelta>>>>;
/// 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<Window>,
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<Vec<RichChunk>>,
/// 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<loro::TextDelta>],
) -> Result<Vec<TextProjectionEdit>, &'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<u64> {
fn hit_test_source_byte(&mut self, x: f64, y: f64) -> Option<u64> {
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<RichChunk> {
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<Option<glyphon::cosmic_text::BufferLine>> =
std::mem::take(&mut self.buffer.lines)
.into_iter()
.map(Some)
.collect();
let mut old_cache: Vec<Option<Vec<RichChunk>>> = 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<StyleSpan> = 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<Decoration> = 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<InlineAdornment> = 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<glyphon::Color>,
@ -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<RichChunk> {
let range_text = &text[start as usize..end as usize];
let spans: Vec<StyleSpan> = 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<Decoration> = 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<InlineAdornment> = 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<u32>)> {
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::<Vec<_>>()
})
.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, "<eol>"), 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 == "<eol>"),
"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 != "<eol>"),
"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

View File

@ -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:?}"
);
}