session R follow-up — post-burst navigation cost (scroll reuse, changed-line frames, carried diags)

Typing was fixed but arrow navigation after a burst — especially
Shift+arrows — stayed slow. Three compounding mechanisms:

1. Edge navigation ran the FULL pipeline per scrolled line: slice
   reshape + viewport re-declaration + a full StyleSpans frame +
   another full reshape on its arrival. The shaped slice is now
   rebuilt by REUSING retained BufferLines (their shape caches
   survive; only newly exposed lines shape), keyed by absolute line
   index — sound because every builder keeps the per-line chunk
   cache current.
2. Every incoming frame (StyleSpans / fg Decorations /
   InlineAdornments) re-shaped the whole slice even when one line's
   styling changed. refresh_changed_lines compares each line's fresh
   chunk set against the cache and re-shapes only differing lines —
   a parse-settle frame after a burst recolors a line or two, and a
   scroll-triggered resync only the newly exposed ones.
3. Daemon: a selection change during the post-burst stale window
   (didChange debounce + server latency) broke the diagnostics hold
   with a FULL Decorations frame per Shift+arrow press that also
   dropped the held diagnostics (blink + churn). The producer now
   CARRIES the previously shipped diagnostic items through the
   frame set while stale — selection motion diffs as a tiny
   selection-only segment, carried diag ranges never re-ship at
   stale positions, and the baseline's generation stays current so
   the eventual unstale frame diffs instead of full-resyncing.

set_rich_text is gone: full reshape, line surgery, scroll reuse, and
frame refresh all assemble lines through one builder
(chunks_for_line + line_from_chunks), so all paths agree by
construction and the per-line chunk cache is always authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-10 15:35:11 -04:00
parent 7bde2e7079
commit 68a75a7422
2 changed files with 224 additions and 99 deletions

View File

@ -418,6 +418,13 @@ struct State {
/// 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`.
@ -1016,6 +1023,8 @@ impl State {
last_pointer_sent_byte: None,
last_pointer_down: None,
hit_map_dirty: false,
line_chunk_cache: Vec::new(),
shaped_top: 0,
}
}
@ -1193,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
@ -1537,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 {
@ -1570,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
}
@ -1580,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 {
@ -1676,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);
}
@ -1798,7 +1813,7 @@ 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))
}
@ -1834,69 +1849,148 @@ impl State {
return false;
}
let next_start = self.current_line_starts.get(line_idx + 1).copied();
// Content end excludes the `\n` — matching cosmic-text's
// BidiParagraphs, which strips the separator from every line
// in both its ASCII and BidiInfo paths.
let content_end = next_start
.map_or(self.current_text.len() as u64, |n| n.saturating_sub(1))
.min(vend);
let top = self
.scroll_top
.min(self.current_line_starts.len().saturating_sub(1));
let Some(shaped_idx) = line_idx.checked_sub(top) else {
let Some(shaped_idx) = line_idx.checked_sub(self.shaped_top) else {
return false;
};
if shaped_idx >= self.buffer.lines.len() {
// E.g. the phantom empty line after a trailing `\n`
// (cosmic creates no BufferLine for it) — full reshape
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
}
let chunks = clipped_chunks_for_range(
/// `(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,
);
// Parity guard: any paragraph separator other than `\n`
// (e.g. inside injected hint text) would have split this line
// in the full set_rich_text path.
if chunks.iter().any(|c| {
c.text
.chars()
.any(|ch| matches!(ch, '\r' | '\u{0085}' | '\u{2028}' | '\u{2029}'))
}) {
return false;
}
)
}
let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
let mut attrs_list = glyphon::cosmic_text::AttrsList::new(&default_attrs);
let mut line_text = String::new();
for chunk in &chunks {
let s = line_text.len();
line_text.push_str(&chunk.text);
if let Some(c) = chunk.color {
// Mirrors set_rich_text: spans only when they differ
// from the defaults (ours differ exactly when colored).
attrs_list.add_span(s..line_text.len(), &default_attrs.clone().color(c));
/// 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);
}
}
// Every line gets LineEnding::Lf — set_rich_text assigns
// LineEnding::default() uniformly, including the final line.
self.buffer.lines[shaped_idx] = glyphon::cosmic_text::BufferLine::new(
line_text,
glyphon::cosmic_text::LineEnding::Lf,
attrs_list,
Shaping::Advanced,
);
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.view_range = (vstart, vend);
self.hit_map_dirty = true;
self.window.request_redraw();
true
}
/// 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
@ -2135,40 +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 default_attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
let rich = clipped_chunks_for_range(
&self.current_text,
&self.current_spans,
&self.current_decorations,
&self.current_adornments,
vstart,
vend,
);
// 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;
self.hit_map_dirty = false;
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();
}
@ -2581,7 +2658,7 @@ struct MinimapLineShape {
content_cols: usize,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
struct RichChunk {
text: String,
color: Option<glyphon::Color>,
@ -3598,6 +3675,29 @@ fn px_to_ndc_y(y: f32, height: u32) -> f32 {
/// 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],

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