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:
parent
7bde2e7079
commit
68a75a7422
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue