session S1 — viewport-scoped rendering + scroll (large-file perf)
Fixes the O(file)-per-keystroke slowness that made large-file editing unusable. pmacs-gpu now shapes only the visible byte slice instead of the whole rope. Core change (`reshape`): compute the visible byte range from `scroll_top` + the window's visible line count (+ small overscan), slice `current_text[vstart..vend]`, clip+rebase spans / decorations / adornments onto the slice (subtract `vstart`), and feed only that to `set_rich_text`. cosmic-text now touches ~screenful of lines, not 25k. `set_rich_text` resets scroll to the slice top (verified), so the slice renders from y=0. Scroll (line-based, Q#S1): - `scroll_top` source-line state; `visible_byte_range` line-aligns the slice (cosmic-text splits BufferLines on `\n`). - `scroll_to_cursor` (Q#S2): on `CursorByte`, if the cursor leaves the visible window, scroll to follow, re-shape, and re-declare the scoped Viewport. PageUp/Down already forward → daemon moves the cursor → this follows. No GPU-local page math. - Scoped `Viewport` declaration (Q#S5) via `viewport_send_if_changed` (coalesced): on snapshot, scroll, edit (bytes shift), and resize. The producer already clips `StyleSpans`/`Decorations` to `vp.visible`, so it now styles only what's on screen — no producer change. Rebasing (bet S2, the QB3-class risk): one primitive, `clip_rebase_range`, clips a whole-file `[start,end)` to the slice and subtracts `vstart`, returning `None` when disjoint. Caret (`caret_rect`) and both wash collectors route through it; `line_offsets` are computed on the slice. Caret returns `None` when scrolled off-screen. Also resets `scroll_top` + `last_viewport_sent` on buffer switch. Tests: `clip_rebase_range_clips_to_slice_and_subtracts_vstart`. Gates: fmt; clippy --all-targets --workspace -D warnings; pmacs-gpu unit 23 (+1). Daemon/lib untouched. Per the framing's process rule, NOT merged until visually confirmed on a large file AND after scrolling (the rebasing is only exercised once vstart > 0): editing snappy; arrows/PageUp/PageDown navigate with the caret staying visible; styling + caret correct at any scroll position; TUI stays converged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5762171831
commit
ecb0a862fc
|
|
@ -70,6 +70,10 @@ const TEXT_TOP: f32 = 16.0;
|
|||
/// B1.
|
||||
const CARET_WIDTH: f32 = 2.0;
|
||||
const CARET_COLOR: [f32; 4] = [0.90, 0.90, 0.96, 0.90];
|
||||
/// Extra source lines shaped beyond the visible window so a 1-line
|
||||
/// scroll doesn't always re-slice and the bottom partial line renders
|
||||
/// (Q#S3). Kept small — overscan is wasted shaping.
|
||||
const SCROLL_OVERSCAN: usize = 2;
|
||||
const TEXT_RIGHT_GAP: f32 = 10.0;
|
||||
const MINIMAP_WIDTH: f32 = 48.0;
|
||||
const MINIMAP_RIGHT: f32 = 12.0;
|
||||
|
|
@ -296,6 +300,23 @@ struct State {
|
|||
/// from commands this frontend never interprets locally. `None`
|
||||
/// until the first `CursorByte`.
|
||||
own_cursor: Option<OwnCursor>,
|
||||
/// Top visible *source line* (0-based). Scroll is line-based
|
||||
/// (Q#S1). `reshape` shapes only the lines from here through the
|
||||
/// visible window; `view_range` records the byte span actually fed
|
||||
/// to cosmic-text so caret/wash byte offsets can be rebased onto
|
||||
/// it.
|
||||
scroll_top: usize,
|
||||
/// Whole-file byte range `[vstart, vend)` of the slice the
|
||||
/// cosmic-text `buffer` currently holds (session S1). Everything
|
||||
/// the buffer renders is in slice coordinates (`file_byte -
|
||||
/// vstart`); this is the rebasing origin for the caret and the
|
||||
/// background washes.
|
||||
view_range: (u64, u64),
|
||||
/// Last `[vstart, vend)` declared to the daemon via a `Viewport`
|
||||
/// event. Re-declared only when it changes (scroll, edit that
|
||||
/// shifts visible bytes, buffer switch) so the producer scopes
|
||||
/// `StyleSpans` to what's on screen without per-frame churn (Q#S5).
|
||||
last_viewport_sent: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
/// pmacs-gpu's own cursor position, mirrored from `CursorByte`.
|
||||
|
|
@ -389,8 +410,15 @@ impl ApplicationHandler<AppEvent> for App {
|
|||
}
|
||||
}
|
||||
WindowEvent::Resized(size) => {
|
||||
if let Some(state) = self.state.as_mut() {
|
||||
state.resize(size.width.max(1), size.height.max(1));
|
||||
let vp = self
|
||||
.state
|
||||
.as_mut()
|
||||
.and_then(|state| state.resize(size.width.max(1), size.height.max(1)));
|
||||
if let Some(vp) = vp
|
||||
&& let Some(client) = self.attach_client.as_ref()
|
||||
&& let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
|
||||
{
|
||||
eprintln!("pmacs-gpu: resize send_viewport failed: {e}");
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
|
|
@ -610,6 +638,9 @@ impl State {
|
|||
current_summary: None,
|
||||
peer_presences: HashMap::new(),
|
||||
own_cursor: None,
|
||||
scroll_top: 0,
|
||||
view_range: (0, 0),
|
||||
last_viewport_sent: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -699,17 +730,15 @@ impl State {
|
|||
// next PresenceUpdate / CursorByte arrives.
|
||||
self.peer_presences.clear();
|
||||
self.own_cursor = None;
|
||||
// New buffer ⇒ back to the top, and force a viewport
|
||||
// re-declaration for the new buffer's scoped range.
|
||||
self.scroll_top = 0;
|
||||
self.last_viewport_sent = None;
|
||||
let _ = text_len;
|
||||
if !self.set_text(&text) {
|
||||
self.reshape();
|
||||
}
|
||||
Some(ViewportSend {
|
||||
buffer_id,
|
||||
visible: ByteRange {
|
||||
start: 0,
|
||||
end: text_len,
|
||||
},
|
||||
generation: 0,
|
||||
})
|
||||
self.viewport_send_if_changed(buffer_id)
|
||||
}
|
||||
InstanceMessage::CrdtOp { buffer_id, op } => {
|
||||
if self.current_buffer_id != Some(buffer_id) {
|
||||
|
|
@ -755,7 +784,12 @@ impl State {
|
|||
// fresh `textDocument/inlayHint` response arrives.
|
||||
let text = doc.get_text(LORO_TEXT_CONTAINER).to_string();
|
||||
self.set_text(&text);
|
||||
None
|
||||
// An edit shifts byte positions, so the scoped viewport's
|
||||
// byte range moves even at the same scroll position;
|
||||
// re-declare it so the producer styles the right bytes
|
||||
// (the generation bump already forces a full resync, so
|
||||
// this is no extra round trip).
|
||||
self.viewport_send_if_changed(buffer_id)
|
||||
}
|
||||
InstanceMessage::StyleSpans {
|
||||
buffer_id,
|
||||
|
|
@ -877,6 +911,17 @@ impl State {
|
|||
buffer_id,
|
||||
byte: byte_pos,
|
||||
});
|
||||
// Session S1 — keep the caret on screen (Q#S2). When the
|
||||
// cursor leaves the visible slice (arrows past an edge,
|
||||
// PageUp/Down), scroll to follow it, re-shape the new
|
||||
// slice, and re-declare the scoped Viewport so the
|
||||
// producer ships spans for what's now visible.
|
||||
if self.scroll_to_cursor() {
|
||||
self.reshape();
|
||||
if let Some(vp) = self.viewport_send_if_changed(buffer_id) {
|
||||
return Some(vp);
|
||||
}
|
||||
}
|
||||
self.window.request_redraw();
|
||||
None
|
||||
}
|
||||
|
|
@ -884,6 +929,49 @@ impl State {
|
|||
}
|
||||
}
|
||||
|
||||
/// A `ViewportSend` for the current `view_range` if it differs from
|
||||
/// the last one declared, else `None` (Q#S5 coalescing). `generation`
|
||||
/// is 0 — the producer's full-resync triggers on the visible-range
|
||||
/// change and on the CRDT generation bump, not this field.
|
||||
fn viewport_send_if_changed(&mut self, buffer_id: BufferId) -> Option<ViewportSend> {
|
||||
if self.last_viewport_sent == Some(self.view_range) {
|
||||
return None;
|
||||
}
|
||||
self.last_viewport_sent = Some(self.view_range);
|
||||
let (start, end) = self.view_range;
|
||||
Some(ViewportSend {
|
||||
buffer_id,
|
||||
visible: ByteRange { start, end },
|
||||
generation: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Adjust `scroll_top` so the own cursor's source line is within the
|
||||
/// visible window (Q#S2). Returns whether `scroll_top` changed (in
|
||||
/// which case the caller re-shapes + re-declares the viewport).
|
||||
fn scroll_to_cursor(&mut self) -> bool {
|
||||
let Some(own) = self.own_cursor else {
|
||||
return false;
|
||||
};
|
||||
if self.current_buffer_id != Some(own.buffer_id) {
|
||||
return false;
|
||||
}
|
||||
let line_starts = line_byte_offsets(&self.current_text);
|
||||
let cursor = own.byte.min(self.current_text.len() as u64);
|
||||
// Cursor's source line = largest i with line_starts[i] <= cursor.
|
||||
let cursor_line = line_starts
|
||||
.partition_point(|&s| s <= cursor)
|
||||
.saturating_sub(1);
|
||||
let visible = estimated_visible_lines(self.config.height).max(1);
|
||||
let old = self.scroll_top;
|
||||
if cursor_line < self.scroll_top {
|
||||
self.scroll_top = cursor_line;
|
||||
} else if cursor_line >= self.scroll_top + visible {
|
||||
self.scroll_top = cursor_line + 1 - visible;
|
||||
}
|
||||
self.scroll_top != old
|
||||
}
|
||||
|
||||
fn apply_file_style_summary(
|
||||
&mut self,
|
||||
buffer_id: BufferId,
|
||||
|
|
@ -1063,23 +1151,84 @@ impl State {
|
|||
/// this is bounded by visible bytes. A sweep-line refactor with
|
||||
/// active-set pointers is the obvious upgrade if reshape cost
|
||||
/// surfaces in profile data — recorded but not done in session 5.
|
||||
/// Whole-file byte range `[vstart, vend)` of the source lines that
|
||||
/// should be shaped: from `scroll_top` through the visible window
|
||||
/// plus a small overscan (Q#S1/S3). Both ends fall on line
|
||||
/// boundaries (cosmic-text splits `BufferLine`s on `\n`, so a
|
||||
/// mid-line slice would corrupt the first/last line).
|
||||
fn visible_byte_range(&self) -> (u64, u64) {
|
||||
let line_starts = line_byte_offsets(&self.current_text);
|
||||
let n = line_starts.len();
|
||||
let top = self.scroll_top.min(n.saturating_sub(1));
|
||||
let span = estimated_visible_lines(self.config.height).max(1) + SCROLL_OVERSCAN;
|
||||
let vstart = line_starts[top];
|
||||
let bottom = top.saturating_add(span).min(n);
|
||||
let vend = if bottom < n {
|
||||
line_starts[bottom]
|
||||
} else {
|
||||
self.current_text.len() as u64
|
||||
};
|
||||
(vstart, vend)
|
||||
}
|
||||
|
||||
fn reshape(&mut self) {
|
||||
// Session S1 — shape only the visible byte slice. Feeding the
|
||||
// whole rope to `set_rich_text` (a BufferLine per source line)
|
||||
// made large-file editing O(file) per keystroke; cosmic-text
|
||||
// touches only `current_text[vstart..vend]` now. Spans /
|
||||
// decorations / adornments arrive in whole-file coordinates and
|
||||
// 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 chunks: Vec<(String, Attrs<'static>)> = projected_rich_chunks(
|
||||
&self.current_text,
|
||||
&self.current_spans,
|
||||
&self.current_decorations,
|
||||
&self.current_adornments,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|chunk| {
|
||||
let mut attrs = default_attrs.clone();
|
||||
if let Some(c) = chunk.color {
|
||||
attrs = attrs.color(c);
|
||||
}
|
||||
(chunk.text, attrs)
|
||||
})
|
||||
.collect();
|
||||
let chunks: Vec<(String, Attrs<'static>)> =
|
||||
projected_rich_chunks(slice, &spans, &decorations, &adornments)
|
||||
.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())),
|
||||
|
|
@ -1091,7 +1240,7 @@ impl State {
|
|||
self.window.request_redraw();
|
||||
}
|
||||
|
||||
fn resize(&mut self, width: u32, height: u32) {
|
||||
fn resize(&mut self, width: u32, height: u32) -> Option<ViewportSend> {
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
|
|
@ -1102,7 +1251,12 @@ impl State {
|
|||
Some(width as f32),
|
||||
Some(height as f32),
|
||||
);
|
||||
// A taller/shorter window changes the visible line count, so the
|
||||
// slice + scoped viewport change (session S1).
|
||||
self.reshape();
|
||||
self.window.request_redraw();
|
||||
self.current_buffer_id
|
||||
.and_then(|bid| self.viewport_send_if_changed(bid))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)] // linear per-frame GPU sequence + optional timing.
|
||||
|
|
@ -1294,10 +1448,18 @@ impl State {
|
|||
let Some(buffer_id) = self.current_buffer_id else {
|
||||
return Vec::new();
|
||||
};
|
||||
let line_offsets = line_byte_offsets(&self.current_text);
|
||||
let (vstart, vend) = self.view_range;
|
||||
if vend <= vstart {
|
||||
return Vec::new();
|
||||
}
|
||||
// Glyph offsets are relative to the *slice* the buffer holds
|
||||
// (session S1), so the line table is computed on the slice and
|
||||
// every whole-file byte range is clip-rebased onto it.
|
||||
let slice = &self.current_text[vstart as usize..vend as usize];
|
||||
let line_offsets = line_byte_offsets(slice);
|
||||
let mut rects = Vec::new();
|
||||
self.collect_own_decoration_rects(&mut rects, &line_offsets);
|
||||
self.collect_peer_rects(buffer_id, &line_offsets, &mut rects);
|
||||
self.collect_own_decoration_rects(&mut rects, &line_offsets, vstart, vend);
|
||||
self.collect_peer_rects(buffer_id, &line_offsets, vstart, vend, &mut rects);
|
||||
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
|
||||
}
|
||||
|
||||
|
|
@ -1308,19 +1470,21 @@ impl State {
|
|||
/// wanted as default editor behavior (revising Q#B4: the caret is
|
||||
/// the own-cursor indicator; the line wash isn't). Peer presence
|
||||
/// still shows other frontends' lines via `collect_peer_rects`.
|
||||
fn collect_own_decoration_rects(&self, rects: &mut Vec<MinimapRect>, line_offsets: &[u64]) {
|
||||
fn collect_own_decoration_rects(
|
||||
&self,
|
||||
rects: &mut Vec<MinimapRect>,
|
||||
line_offsets: &[u64],
|
||||
vstart: u64,
|
||||
vend: u64,
|
||||
) {
|
||||
for d in &self.current_decorations {
|
||||
if d.kind == DecorationKind::CurrentLine {
|
||||
continue;
|
||||
}
|
||||
if let Some(color) = decoration_kind_to_bg_color(d.kind) {
|
||||
self.push_glyph_extent_rects(
|
||||
rects,
|
||||
line_offsets,
|
||||
d.range.start,
|
||||
d.range.end,
|
||||
color,
|
||||
);
|
||||
if let Some(color) = decoration_kind_to_bg_color(d.kind)
|
||||
&& let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend)
|
||||
{
|
||||
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1332,6 +1496,8 @@ impl State {
|
|||
&self,
|
||||
buffer_id: BufferId,
|
||||
line_offsets: &[u64],
|
||||
vstart: u64,
|
||||
vend: u64,
|
||||
rects: &mut Vec<MinimapRect>,
|
||||
) {
|
||||
let text_len = self.current_text.len() as u64;
|
||||
|
|
@ -1341,14 +1507,16 @@ impl State {
|
|||
}
|
||||
if let Some(color) = decoration_kind_to_bg_color(DecorationKind::CurrentLine) {
|
||||
let (lo, hi) = source_line_range(&self.current_text, presence.cursor);
|
||||
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color);
|
||||
if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) {
|
||||
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color);
|
||||
}
|
||||
}
|
||||
if let Some(sel) = presence.selection
|
||||
&& let Some(color) = decoration_kind_to_bg_color(DecorationKind::Selection)
|
||||
{
|
||||
let lo = sel.anchor.min(sel.active).min(text_len);
|
||||
let hi = sel.anchor.max(sel.active).min(text_len);
|
||||
if hi > lo {
|
||||
if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) {
|
||||
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color);
|
||||
}
|
||||
}
|
||||
|
|
@ -1356,27 +1524,43 @@ impl State {
|
|||
}
|
||||
|
||||
/// Vertex bytes for the caret quad, drawn *over* the text (B1).
|
||||
/// Empty when no own cursor is known or it's in another buffer.
|
||||
/// Empty when no own cursor is known, it's in another buffer, or it
|
||||
/// is scrolled out of the visible slice.
|
||||
fn caret_vertex_bytes(&self) -> Vec<u8> {
|
||||
let line_offsets = line_byte_offsets(&self.current_text);
|
||||
let Some(rect) = self.caret_rect(&line_offsets) else {
|
||||
let (vstart, vend) = self.view_range;
|
||||
if vend <= vstart {
|
||||
return Vec::new();
|
||||
}
|
||||
let slice = &self.current_text[vstart as usize..vend as usize];
|
||||
let line_offsets = line_byte_offsets(slice);
|
||||
let Some(rect) = self.caret_rect(slice, &line_offsets, vstart, vend) else {
|
||||
return Vec::new();
|
||||
};
|
||||
rects_to_vertex_bytes(&[rect], self.config.width, self.config.height)
|
||||
}
|
||||
|
||||
/// The caret rectangle for the own cursor: a thin bar at the left
|
||||
/// edge of the glyph the cursor sits before (or the right edge of
|
||||
/// the last glyph when the cursor is at line end). Byte→glyph
|
||||
/// mapping rebases per line (QB3) and keys off the cursor's source
|
||||
/// line via [`source_line_range`].
|
||||
fn caret_rect(&self, line_offsets: &[u64]) -> Option<MinimapRect> {
|
||||
/// The caret rectangle for the own cursor, in slice coordinates: a
|
||||
/// thin bar at the left edge of the glyph the cursor sits before (or
|
||||
/// the right edge of the last glyph at line end). `None` when the
|
||||
/// cursor is outside the visible slice. Byte→glyph mapping rebases
|
||||
/// per line (QB3); the cursor is rebased onto the slice first (S1).
|
||||
fn caret_rect(
|
||||
&self,
|
||||
slice: &str,
|
||||
line_offsets: &[u64],
|
||||
vstart: u64,
|
||||
vend: u64,
|
||||
) -> Option<MinimapRect> {
|
||||
let own = self.own_cursor?;
|
||||
if self.current_buffer_id != Some(own.buffer_id) {
|
||||
return None;
|
||||
}
|
||||
let cursor = own.byte.min(self.current_text.len() as u64);
|
||||
let (line_lo, _) = source_line_range(&self.current_text, cursor);
|
||||
let cursor = own.byte;
|
||||
if cursor < vstart || cursor > vend {
|
||||
return None; // scrolled off-screen
|
||||
}
|
||||
let slice_cursor = cursor - vstart;
|
||||
let (line_lo, _) = source_line_range(slice, slice_cursor);
|
||||
for run in self.buffer.layout_runs() {
|
||||
if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo {
|
||||
continue;
|
||||
|
|
@ -1384,7 +1568,7 @@ impl State {
|
|||
let line_base = line_lo;
|
||||
let mut x = TEXT_LEFT;
|
||||
for glyph in run.glyphs {
|
||||
if line_base + glyph.start as u64 >= cursor {
|
||||
if line_base + glyph.start as u64 >= slice_cursor {
|
||||
x = TEXT_LEFT + glyph.x;
|
||||
break;
|
||||
}
|
||||
|
|
@ -1846,6 +2030,20 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool {
|
|||
)
|
||||
}
|
||||
|
||||
/// Clip a whole-file byte range `[start, end)` to the visible slice
|
||||
/// `[vstart, vend)` and rebase it into slice coordinates (subtract
|
||||
/// `vstart`). Returns `None` when the range is disjoint from the slice.
|
||||
/// The single rebasing primitive for session S1 — caret and washes
|
||||
/// route through it (Q#S4).
|
||||
fn clip_rebase_range(start: u64, end: u64, vstart: u64, vend: u64) -> Option<(u64, u64)> {
|
||||
let s = start.max(vstart);
|
||||
let e = end.min(vend);
|
||||
if e <= s {
|
||||
return None;
|
||||
}
|
||||
Some((s - vstart, e - vstart))
|
||||
}
|
||||
|
||||
/// Largest char-boundary `<= index` (stable equivalent of the unstable
|
||||
/// `str::floor_char_boundary`). Used to snap externally-supplied byte
|
||||
/// offsets to valid slice points so a stale, mid-codepoint offset can't
|
||||
|
|
@ -2472,6 +2670,20 @@ mod tests {
|
|||
assert_eq!(rendered, text, "chunks must reassemble the original text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_rebase_range_clips_to_slice_and_subtracts_vstart() {
|
||||
// Visible slice is whole-file bytes [10, 20).
|
||||
assert_eq!(clip_rebase_range(12, 18, 10, 20), Some((2, 8))); // inside
|
||||
assert_eq!(clip_rebase_range(5, 15, 10, 20), Some((0, 5))); // clipped left
|
||||
assert_eq!(clip_rebase_range(15, 25, 10, 20), Some((5, 10))); // clipped right
|
||||
assert_eq!(clip_rebase_range(10, 20, 10, 20), Some((0, 10))); // exact
|
||||
assert_eq!(clip_rebase_range(0, 8, 10, 20), None); // entirely before
|
||||
assert_eq!(clip_rebase_range(20, 30, 10, 20), None); // entirely after
|
||||
assert_eq!(clip_rebase_range(14, 14, 10, 20), None); // empty range
|
||||
// vstart 0 is the unscrolled identity case.
|
||||
assert_eq!(clip_rebase_range(3, 7, 0, 100), Some((3, 7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_char_boundary_snaps_into_multibyte_char() {
|
||||
let text = "ab→cd"; // '→' = bytes [2,5)
|
||||
|
|
|
|||
Loading…
Reference in New Issue