Merge pull request #62 from levineuwirth/session-r-perline-reshape
pmacs-gpu: per-line incremental reshape (typing latency floor)
This commit is contained in:
commit
76a13ba243
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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