session R — per-line incremental reshape (typing latency floor)
Per docs/pmacs-gpu-perline-reshape-framing.md. Every keystroke ran a full visible-slice reshape: rebuild all rich chunks, set_rich_text (resets every BufferLine's shape cache), shape_until_scroll re-shapes every visible line with Shaping::Advanced. Now a single-line edit — the keystroke case — rebuilds exactly ONE BufferLine; the other lines' shape caches survive and shape_until_scroll touches only the fresh line. - Q#R1: clipped_chunks_for_range is the single chunk source both the full reshape and the surgery derive from (full = slice range, surgery = the line's content range), so the two paths cannot disagree about a line's content. Parity with cosmic-text's own line splitting verified against the vendored 0.18.2 source: BidiParagraphs strips the separator per line in both its ASCII and BidiInfo paths, creates no trailing empty line, and set_rich_text assigns LineEnding::Lf uniformly + adds attr spans only when they differ from the defaults — the surgery mirrors all three. - Fallbacks to full reshape: slice origin moved, line count changed (Enter / multi-line deletes), '\n' in the inserted text, edited line outside the shaped slice (an edit entirely PAST the slice updates view_range + redraws without any shaping), exotic paragraph separators, multi-edit batches. - Q#R2: the pointer hit map goes lazy — surgery marks it dirty and hit_test_source_byte rebuilds on demand from the same chunk fn (clicks are rare next to keystrokes; the rebuild is an O(slice) byte walk, no shaping). - Pure parity test pins full-walk == concatenated per-line walks (text + colors), including the newline-anchored inlay-hint boundary case (predicted finding #1's most likely site). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
09852d10e4
commit
7bde2e7079
|
|
@ -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>>>>;
|
type LoroTextDeltaBatches = Arc<Mutex<Vec<Vec<loro::TextDelta>>>>;
|
||||||
|
|
||||||
/// All resources owned by one running pmacs-gpu instance.
|
/// 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 {
|
struct State {
|
||||||
window: Arc<Window>,
|
window: Arc<Window>,
|
||||||
device: wgpu::Device,
|
device: wgpu::Device,
|
||||||
|
|
@ -409,6 +413,11 @@ struct State {
|
||||||
/// `(when, byte)` of the last primary Down, for frontend-side
|
/// `(when, byte)` of the last primary Down, for frontend-side
|
||||||
/// double-click detection (same-hit within the interval).
|
/// double-click detection (same-hit within the interval).
|
||||||
last_pointer_down: Option<(std::time::Instant, u64)>,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// pmacs-gpu's own cursor position, mirrored from `CursorByte`.
|
/// pmacs-gpu's own cursor position, mirrored from `CursorByte`.
|
||||||
|
|
@ -1006,6 +1015,7 @@ impl State {
|
||||||
pointer_drag_active: false,
|
pointer_drag_active: false,
|
||||||
last_pointer_sent_byte: None,
|
last_pointer_sent_byte: None,
|
||||||
last_pointer_down: None,
|
last_pointer_down: None,
|
||||||
|
hit_map_dirty: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1203,6 +1213,7 @@ impl State {
|
||||||
&mut self,
|
&mut self,
|
||||||
delta_batches: &[Vec<loro::TextDelta>],
|
delta_batches: &[Vec<loro::TextDelta>],
|
||||||
) -> Result<Vec<TextProjectionEdit>, &'static str> {
|
) -> Result<Vec<TextProjectionEdit>, &'static str> {
|
||||||
|
let line_count_before = self.current_line_starts.len();
|
||||||
let edits = apply_loro_text_delta_batches(
|
let edits = apply_loro_text_delta_batches(
|
||||||
&mut self.current_text,
|
&mut self.current_text,
|
||||||
&mut self.current_line_starts,
|
&mut self.current_line_starts,
|
||||||
|
|
@ -1213,7 +1224,17 @@ impl State {
|
||||||
return Ok(edits);
|
return Ok(edits);
|
||||||
}
|
}
|
||||||
self.translate_cached_anchors(&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)
|
Ok(edits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1736,8 +1757,25 @@ impl State {
|
||||||
/// line) → projected byte → run map → slice byte → + `vstart`.
|
/// line) → projected byte → run map → slice byte → + `vstart`.
|
||||||
/// `None` when no buffer is attached or the position is outside
|
/// `None` when no buffer is attached or the position is outside
|
||||||
/// anything hit-testable.
|
/// 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?;
|
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_x = x as f32 - TEXT_LEFT;
|
||||||
let rel_y = y as f32 - TEXT_TOP;
|
let rel_y = y as f32 - TEXT_TOP;
|
||||||
let cursor = self.buffer.hit(rel_x, rel_y)?;
|
let cursor = self.buffer.hit(rel_x, rel_y)?;
|
||||||
|
|
@ -1765,6 +1803,102 @@ impl State {
|
||||||
.and_then(|bid| self.viewport_send_if_changed(bid))
|
.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();
|
||||||
|
// 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 {
|
||||||
|
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
|
||||||
|
// handles those shapes correctly.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks = 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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.shape_until_scroll(&mut self.font_system, false);
|
||||||
|
self.view_range = (vstart, vend);
|
||||||
|
self.hit_map_dirty = true;
|
||||||
|
self.window.request_redraw();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Bookkeeping for an outgoing Pointer event: it supersedes any
|
/// Bookkeeping for an outgoing Pointer event: it supersedes any
|
||||||
/// unconfirmed optimistic-cursor prediction (the daemon's answer
|
/// unconfirmed optimistic-cursor prediction (the daemon's answer
|
||||||
/// will be the click position, not the typing prediction), and
|
/// will be the click position, not the typing prediction), and
|
||||||
|
|
@ -2001,50 +2135,22 @@ impl State {
|
||||||
// are clipped + rebased onto the slice (subtract `vstart`).
|
// are clipped + rebased onto the slice (subtract `vstart`).
|
||||||
let (vstart, vend) = self.visible_byte_range();
|
let (vstart, vend) = self.visible_byte_range();
|
||||||
self.view_range = (vstart, vend);
|
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 default_attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
|
||||||
let rich = projected_rich_chunks(slice, &spans, &decorations, &adornments);
|
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
|
// Q#M2 — the pointer hit map is derived from the SAME chunks
|
||||||
// the shaped buffer is built from, so the two cannot disagree.
|
// the shaped buffer is built from, so the two cannot disagree.
|
||||||
let (hit_runs, projected_line_starts) = build_hit_runs(&rich);
|
let (hit_runs, projected_line_starts) = build_hit_runs(&rich);
|
||||||
self.current_hit_runs = hit_runs;
|
self.current_hit_runs = hit_runs;
|
||||||
self.projected_line_starts = projected_line_starts;
|
self.projected_line_starts = projected_line_starts;
|
||||||
|
self.hit_map_dirty = false;
|
||||||
let chunks: Vec<(String, Attrs<'static>)> = rich
|
let chunks: Vec<(String, Attrs<'static>)> = rich
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|chunk| {
|
.map(|chunk| {
|
||||||
|
|
@ -3485,6 +3591,52 @@ fn px_to_ndc_y(y: f32, height: u32) -> f32 {
|
||||||
/// `text` and retain source-byte styling; inline adornments create
|
/// `text` and retain source-byte styling; inline adornments create
|
||||||
/// extra chunks at their anchors and therefore do not shift any source
|
/// extra chunks at their anchors and therefore do not shift any source
|
||||||
/// span/decoration range.
|
/// 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.
|
||||||
|
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(
|
fn projected_rich_chunks(
|
||||||
text: &str,
|
text: &str,
|
||||||
spans: &[StyleSpan],
|
spans: &[StyleSpan],
|
||||||
|
|
@ -4139,6 +4291,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]
|
#[test]
|
||||||
fn hit_runs_map_projected_bytes_back_to_source() {
|
fn hit_runs_map_projected_bytes_back_to_source() {
|
||||||
// Source slice "ab\ncd" with an inlay hint ": i32 " anchored
|
// Source slice "ab\ncd" with an inlay hint ": i32 " anchored
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue