diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 852fb91..55f8fb6 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -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>, + /// 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 { + 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> = + 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); } } - // 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, @@ -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], 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:?}" ); }