diff --git a/crates/fresh-editor/src/app/scrollbar_math.rs b/crates/fresh-editor/src/app/scrollbar_math.rs index 2e45c28114..1172e5bbd8 100644 --- a/crates/fresh-editor/src/app/scrollbar_math.rs +++ b/crates/fresh-editor/src/app/scrollbar_math.rs @@ -68,6 +68,7 @@ fn ensure_index( hanging_indent: false, line_wrap_enabled: true, grid_wrap: true, + tab_size: state.buffer_settings.tab_size as u16, }; ensure_built(state, &key); return; @@ -91,6 +92,7 @@ fn ensure_index( hanging_indent: wrap_config.hanging_indent, line_wrap_enabled: true, grid_wrap: false, + tab_size: state.buffer_settings.tab_size as u16, }; ensure_built(state, &key); } diff --git a/crates/fresh-editor/src/model/buffer/mod.rs b/crates/fresh-editor/src/model/buffer/mod.rs index 82e6de63be..2f76912a48 100644 --- a/crates/fresh-editor/src/model/buffer/mod.rs +++ b/crates/fresh-editor/src/model/buffer/mod.rs @@ -1717,6 +1717,13 @@ impl TextBuffer { line_feed_cnt, &self.buffers, ); + + // Content changed: layout caches (line-wrap cache, visual-row + // index) key on the version and would otherwise serve + // pre-append state. Deliberately not `mark_content_modified` + // — the buffer still matches the on-disk stream, so it isn't + // dirty. + self.bump_version(); } /// Check if the buffer has been modified since last save diff --git a/crates/fresh-editor/src/primitives/grapheme.rs b/crates/fresh-editor/src/primitives/grapheme.rs index d90a31b211..bdb72f8953 100644 --- a/crates/fresh-editor/src/primitives/grapheme.rs +++ b/crates/fresh-editor/src/primitives/grapheme.rs @@ -9,7 +9,20 @@ //! A grapheme cluster is what a user perceives as a single character. //! For example, Thai "ที่" looks like one character but is 3 Unicode code points. -use unicode_segmentation::UnicodeSegmentation; +use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; + +/// Snap `pos` down to a UTF-8 code-point boundary so it can be handed to +/// [`GraphemeCursor`], which requires boundary-aligned offsets. A position +/// inside a code point can never be a grapheme boundary, so snapping down +/// preserves the semantics of every caller below. +#[inline] +fn snap_to_char_boundary(s: &str, mut pos: usize) -> usize { + pos = pos.min(s.len()); + while pos > 0 && !s.is_char_boundary(pos) { + pos -= 1; + } + pos +} /// Find the byte position of the previous grapheme cluster boundary. /// @@ -28,18 +41,19 @@ pub fn prev_grapheme_boundary(s: &str, pos: usize) -> usize { return 0; } - let pos = pos.min(s.len()); - - // Find all grapheme boundaries up to our position - let mut last_boundary = 0; - for (idx, _) in s.grapheme_indices(true) { - if idx >= pos { - break; - } - last_boundary = idx; + // A position INSIDE a code point is inside that code point's + // cluster: the boundary strictly before it is the containing + // cluster's START, which the snapped char boundary may already be + // (single-code-point cluster). Stepping `prev_boundary` from the + // snapped position instead would overshoot one cluster left. + let snapped = snap_to_char_boundary(s, pos); + if snapped < pos { + return snap_to_grapheme_boundary(s, snapped); } - - last_boundary + let mut cursor = GraphemeCursor::new(snapped, s.len(), true); + // With the whole string as a single chunk (chunk_start == 0) the cursor + // never needs pre-context, so this cannot fail. + cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0) } /// Find the byte position of the next grapheme cluster boundary. @@ -59,20 +73,11 @@ pub fn next_grapheme_boundary(s: &str, pos: usize) -> usize { return s.len(); } - // Find the grapheme that contains our position, then return its end - for (idx, grapheme) in s.grapheme_indices(true) { - let end = idx + grapheme.len(); - if idx >= pos { - // This grapheme starts at or after our position - return end; - } - if end > pos { - // Our position is within this grapheme - return end; - } - } - - s.len() + // A mid-cluster position (snapped or not) advances to the end of the + // containing cluster — the next boundary after it. + let pos = snap_to_char_boundary(s, pos); + let mut cursor = GraphemeCursor::new(pos, s.len(), true); + cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len()) } /// Get the grapheme cluster at the given position. @@ -87,14 +92,9 @@ pub fn grapheme_at(s: &str, pos: usize) -> Option<(&str, usize, usize)> { return None; } - for (idx, grapheme) in s.grapheme_indices(true) { - let end = idx + grapheme.len(); - if idx <= pos && pos < end { - return Some((grapheme, idx, end)); - } - } - - None + let start = snap_to_grapheme_boundary(s, pos); + let end = next_grapheme_boundary(s, start); + Some((&s[start..end], start, end)) } /// Snap a byte position **down** to the nearest grapheme-cluster @@ -120,17 +120,13 @@ pub fn snap_to_grapheme_boundary(s: &str, pos: usize) -> usize { if pos >= s.len() { return s.len(); } - let mut last_boundary = 0; - for (idx, _) in s.grapheme_indices(true) { - if idx == pos { - return pos; - } - if idx > pos { - break; - } - last_boundary = idx; + let pos = snap_to_char_boundary(s, pos); + let mut cursor = GraphemeCursor::new(pos, s.len(), true); + match cursor.is_boundary(s, 0) { + Ok(true) => pos, + // Not a boundary → start of the containing cluster. + _ => cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0), } - last_boundary } /// Count the number of grapheme clusters in a string. diff --git a/crates/fresh-editor/src/view/folding.rs b/crates/fresh-editor/src/view/folding.rs index 9d0fbe49a5..da38abe348 100644 --- a/crates/fresh-editor/src/view/folding.rs +++ b/crates/fresh-editor/src/view/folding.rs @@ -396,27 +396,27 @@ impl Default for FoldManager { /// ([`PatternIndentCalculator::count_leading_indent`]). pub mod indent_folding { use crate::model::buffer::Buffer; - use crate::primitives::indent_pattern::PatternIndentCalculator; + + /// Chunk size for the line-boundary scans below. Scanning via + /// per-byte `byte_at` calls costs a full `slice_bytes` round-trip + /// per byte, which is what made cursor movement inside a very long + /// line slow (these scans run per rendered frame); block reads make + /// the scans effectively memchr-speed. + const LINE_SCAN_CHUNK: usize = 4096; /// Find the byte offset of the start of the line containing `pos`. /// Scans backward for `\n` (or returns 0). pub fn find_line_start_byte(buffer: &Buffer, pos: usize) -> usize { - if pos == 0 { - return 0; - } - let mut p = pos.min(buffer.len()).saturating_sub(1); - loop { - match PatternIndentCalculator::byte_at(buffer, p) { - Some(b'\n') => return p + 1, - None => return 0, - _ => { - if p == 0 { - return 0; - } - p -= 1; - } + let mut end = pos.min(buffer.len()); + while end > 0 { + let start = end.saturating_sub(LINE_SCAN_CHUNK); + let bytes = buffer.slice_bytes(start..end); + if let Some(i) = bytes.iter().rposition(|&b| b == b'\n') { + return start + i + 1; } + end = start; } + 0 } /// Find the exclusive byte offset just past the line containing `pos` @@ -424,13 +424,14 @@ pub mod indent_folding { /// line has no trailing newline). Scans forward for `\n`. pub fn find_line_end_byte(buffer: &Buffer, pos: usize) -> usize { let buf_len = buffer.len(); - let mut p = pos; - while p < buf_len { - match PatternIndentCalculator::byte_at(buffer, p) { - Some(b'\n') => return p + 1, - None => return buf_len, - _ => p += 1, + let mut start = pos.min(buf_len); + while start < buf_len { + let end = (start + LINE_SCAN_CHUNK).min(buf_len); + let bytes = buffer.slice_bytes(start..end); + if let Some(i) = bytes.iter().position(|&b| b == b'\n') { + return start + i + 1; } + start = end; } buf_len } @@ -582,16 +583,7 @@ pub mod indent_folding { /// Scans forward for `\n` and returns the byte after it. If no `\n` is /// found, returns `buffer.len()`. pub fn find_next_line_start_byte(buffer: &Buffer, pos: usize) -> usize { - let mut p = pos; - let len = buffer.len(); - while p < len { - match PatternIndentCalculator::byte_at(buffer, p) { - Some(b'\n') => return p + 1, - None => return len, - _ => p += 1, - } - } - len + find_line_end_byte(buffer, pos) } /// Byte-range of a fold that contains `target_byte`. diff --git a/crates/fresh-editor/src/view/line_wrap_cache.rs b/crates/fresh-editor/src/view/line_wrap_cache.rs index 2c40101255..0a2aa6d2a5 100644 --- a/crates/fresh-editor/src/view/line_wrap_cache.rs +++ b/crates/fresh-editor/src/view/line_wrap_cache.rs @@ -88,6 +88,13 @@ pub struct LineWrapKey { /// buffers set this; it keys separately from word-wrap entries at the /// same geometry. pub grid_wrap: bool, + /// Tab width the pipeline renders `\t` at (`buffer_settings.tab_size`). + /// A layout input like the geometry fields: changing it (e.g. the + /// `set tab_size` command) reshapes visual columns and wrap points + /// without bumping any pipeline-inputs version, so it must key the + /// entry or the renderer's cached-window fast path would serve + /// stale layouts after a tab-size change. + pub tab_size: u16, /// Signature of the cursor positions inside this line (see /// [`cursor_sig_for_line`]). Cursor-dependent conceal/soft-break /// activation makes the cursor line's layout a function of where the @@ -119,6 +126,19 @@ pub fn cursor_sig_for_line(cursors: &[usize], line_start: usize, line_end: usize sig } +/// True when the wrap pipeline's output can actually depend on cursor +/// positions. Cursor-dependent activation only exists for soft breaks +/// and conceals; when both managers are empty the pipeline never reads +/// the cursor list, so the cursor line's layout is identical to the +/// cursor-free form and can be keyed with `cursor_sig: 0`. This keeps +/// the cursor line's cache entry valid as the cursor moves within it — +/// without it, every cursor move forced a full re-wrap of the line it +/// sits on (very slow inside a very long wrapped line). +#[inline] +pub fn layout_depends_on_cursors(state: &EditorState) -> bool { + !state.soft_breaks.is_empty() || !state.conceals.is_empty() +} + /// Derive the combined pipeline-inputs version from the three source /// versions. Any change to any of them flips the combined value. This /// is not a hash — it's a packed integer with enough bit-budget to make @@ -178,12 +198,27 @@ fn estimate_view_lines_bytes(lines: &[ViewLine]) -> usize { /// rarely again. FIFO is simpler to reason about and matches this /// pattern well enough. If future profiling shows churn we can swap the /// eviction policy — the external API doesn't change. +/// +/// Eviction is generation-aware: entries written or touched during the +/// current frame (see [`LineWrapCache::begin_frame`]) are never evicted +/// by inserts from the same frame. Without this, a single logical line +/// whose layout alone exceeds the byte budget (a multi-hundred-KB +/// wrapped line) and its neighbours evict each other within one frame, +/// so the render fast path never hits. The budget can therefore be +/// exceeded by at most one frame's visible-window worth of entries. +#[derive(Debug, Clone)] +struct CacheEntry { + value: Arc>, + generation: u64, +} + #[derive(Debug, Clone)] pub struct LineWrapCache { - map: HashMap>>, + map: HashMap, order: VecDeque, byte_budget: usize, current_bytes: usize, + generation: u64, } impl Default for LineWrapCache { @@ -200,9 +235,17 @@ impl LineWrapCache { order: VecDeque::new(), byte_budget, current_bytes: 0, + generation: 0, } } + /// Start a new eviction generation. Called once per buffer render; + /// entries written or touched after this call are protected from + /// eviction until the next `begin_frame`. + pub fn begin_frame(&mut self) { + self.generation = self.generation.wrapping_add(1); + } + pub fn len(&self) -> usize { debug_assert_eq!( self.map.len(), @@ -228,7 +271,18 @@ impl LineWrapCache { /// `Arc` is a cheap clone; callers can hold it without copying the /// underlying `Vec`. pub fn get(&self, key: &LineWrapKey) -> Option>> { - self.map.get(key).cloned() + self.map.get(key).map(|e| e.value.clone()) + } + + /// Like [`get`](Self::get), but also stamps the entry with the + /// current eviction generation so same-frame inserts won't evict it. + /// Used by the renderer's cached-window fast path. + pub fn get_touch(&mut self, key: &LineWrapKey) -> Option>> { + let generation = self.generation; + self.map.get_mut(key).map(|e| { + e.generation = generation; + e.value.clone() + }) } /// Query by key; on miss, run `compute` and store its result. The @@ -243,7 +297,7 @@ impl LineWrapCache { F: FnOnce() -> Vec, { if let Some(v) = self.map.get(&key) { - return v.clone(); + return v.value.clone(); } let value = Arc::new(compute()); self.insert_fresh(key, value.clone()); @@ -255,10 +309,12 @@ impl LineWrapCache { /// **not** changed (this keeps the queue simple — re-inserts don't /// refresh age). Byte-budget accounting is updated. pub fn put(&mut self, key: LineWrapKey, value: Arc>) { + let generation = self.generation; if let Some(existing) = self.map.get_mut(&key) { - let old_bytes = estimate_view_lines_bytes(existing); + let old_bytes = estimate_view_lines_bytes(&existing.value); let new_bytes = estimate_view_lines_bytes(&value); - *existing = value; + existing.value = value; + existing.generation = generation; self.current_bytes = self.current_bytes + new_bytes - old_bytes.min(self.current_bytes); return; } @@ -279,21 +335,47 @@ impl LineWrapCache { debug_assert!(!self.map.contains_key(&key)); let new_bytes = estimate_view_lines_bytes(&value); - // Evict until (current_bytes + new_bytes) fits. Always keep at - // least one slot — if the single new entry alone exceeds the - // budget, we still accept it (the cache was asked to hold it; - // the alternative is silently dropping data the caller just - // paid to compute). - while self.current_bytes + new_bytes > self.byte_budget && !self.order.is_empty() { - if let Some(oldest_key) = self.order.pop_front() { - if let Some(oldest_val) = self.map.remove(&oldest_key) { - let shed = estimate_view_lines_bytes(&oldest_val); - self.current_bytes = self.current_bytes.saturating_sub(shed); - } + // Evict until (current_bytes + new_bytes) fits. Entries written + // or touched in the current frame generation are spared as long + // as the total stays under 2× the budget — the renderer needs + // the whole visible window alive at once, and a huge line's + // entry can single-handedly exceed the budget (without this + // guard it and its neighbours would evict each other every + // frame, defeating the cached-window fast path). Generation 0 + // means `begin_frame` has never run (cursor-nav / scroll-math + // only usage), where the plain budget applies. A single new + // entry larger than the whole budget is still accepted (the + // cache was asked to hold it; the alternative is silently + // dropping data the caller just paid to compute). + while self.current_bytes + new_bytes > self.byte_budget { + let Some(oldest_key) = self.order.front() else { + break; + }; + let oldest_is_current_frame = self.generation != 0 + && self + .map + .get(oldest_key) + .is_some_and(|e| e.generation == self.generation); + if oldest_is_current_frame + && self.current_bytes + new_bytes <= self.byte_budget.saturating_mul(2) + { + break; + } + let oldest_key = *oldest_key; + self.order.pop_front(); + if let Some(oldest) = self.map.remove(&oldest_key) { + let shed = estimate_view_lines_bytes(&oldest.value); + self.current_bytes = self.current_bytes.saturating_sub(shed); } } - self.map.insert(key, value); + self.map.insert( + key, + CacheEntry { + value, + generation: self.generation, + }, + ); self.order.push_back(key); self.current_bytes += new_bytes; debug_assert_eq!(self.map.len(), self.order.len()); @@ -365,6 +447,14 @@ pub fn layout_for_line( geom: &WrapGeometry, cursors: &[usize], ) -> Arc> { + // When the pipeline is cursor-independent, key with `cursor_sig: 0` + // so entries are shared with cursor-blind consumers and stay valid + // across cursor movement (see `layout_depends_on_cursors`). + let cursors: &[usize] = if layout_depends_on_cursors(state) { + cursors + } else { + &[] + }; let version = pipeline_inputs_version( state.buffer.version(), state.soft_breaks.version(), @@ -459,6 +549,8 @@ pub struct WrapGeometry { /// breaks at `effective_width`, ignoring `gutter_width` / /// `hanging_indent` / `wrap_column`. pub grid_wrap: bool, + /// Tab width (see [`LineWrapKey::tab_size`]). + pub tab_size: usize, pub view_mode: CacheViewMode, } @@ -483,6 +575,7 @@ impl WrapGeometry { hanging_indent: self.hanging_indent, line_wrap_enabled: self.line_wrap_enabled, grid_wrap: self.grid_wrap, + tab_size: self.tab_size as u16, cursor_sig, } } @@ -985,6 +1078,7 @@ mod tests { hanging_indent: false, line_wrap_enabled: true, grid_wrap: false, + tab_size: 4, cursor_sig: 0, } } @@ -1584,6 +1678,7 @@ mod tests { hanging_indent: false, line_wrap_enabled: true, grid_wrap: false, + tab_size: 4, cursor_sig: 0, }; let real_val = real.get_or_insert_with(key, || dummy_lines(shadow_rows)); @@ -1618,6 +1713,7 @@ mod tests { hanging_indent: false, line_wrap_enabled: true, grid_wrap: false, + tab_size: 4, cursor_sig: 0, }; cache.get_or_insert_with(key_v0, || dummy_lines(5)); @@ -1662,11 +1758,12 @@ mod tests { hanging_indent: false, line_wrap_enabled: true, grid_wrap: false, + tab_size: 4, cursor_sig: 0, }; // Vary each field in turn; each variation must be a distinct key. - let variations: [LineWrapKey; 9] = [ + let variations: [LineWrapKey; 10] = [ LineWrapKey { pipeline_inputs_version: 2, ..base @@ -1704,6 +1801,10 @@ mod tests { grid_wrap: true, ..base }, + LineWrapKey { + tab_size: 8, + ..base + }, ]; let mut cache = LineWrapCache::with_byte_budget(ROOMY); diff --git a/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_buffer.rs b/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_buffer.rs index de02f6bd13..51ccc018b0 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_buffer.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_buffer.rs @@ -118,6 +118,12 @@ pub(crate) fn compute_buffer_layout( ) -> BufferLayoutOutput { let _span = tracing::trace_span!("compute_buffer_layout").entered(); + // New line-wrap-cache eviction generation for this render: entries + // this frame writes or reads (via the cached-window fast path) are + // protected from evicting each other, even when a single huge line's + // layout exceeds the cache's byte budget on its own. + state.line_wrap_cache.begin_frame(); + // Configure shared margin layout for this split's line number setting. state.margins.configure_for_line_numbers(show_line_numbers); diff --git a/crates/fresh-editor/src/view/ui/split_rendering/scrollbar.rs b/crates/fresh-editor/src/view/ui/split_rendering/scrollbar.rs index 41710ecfa8..aa06256d2b 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering/scrollbar.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering/scrollbar.rs @@ -119,6 +119,7 @@ pub(super) fn scrollbar_visual_row_counts( hanging_indent, line_wrap_enabled: viewport.line_wrap_enabled, grid_wrap: viewport.grid_wrap, + tab_size: state.buffer_settings.tab_size as u16, }; ensure_built(state, &key); diff --git a/crates/fresh-editor/src/view/ui/split_rendering/view_data.rs b/crates/fresh-editor/src/view/ui/split_rendering/view_data.rs index ab3eca5a42..23942fc94c 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering/view_data.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering/view_data.rs @@ -74,6 +74,82 @@ pub(super) fn build_view_data( // depth for any tokens produced by plugin view transforms). let fold_skip = fold_skip_set(&state.buffer, &state.marker_list, folds); + // Wrap geometry, computed up-front so both the cached-window fast + // path and the full pipeline (and its cache writeback) agree on it. + // + // When line_wrap is on: wrap at viewport width (or wrap_column if + // set), reserving the last content column so the end-of-line cursor + // never lands on top of the vertical scrollbar (the cursor sits one + // column past the last rendered character, so a row that fills + // `content_width` exactly would place the EOL cursor on the + // scrollbar track). `saturating_sub` keeps this safe at very small + // widths where the guard inside `apply_wrapping_transform` will + // short-circuit anyway. When line_wrap is off: wrap at + // MAX_SAFE_LINE_WIDTH to prevent memory exhaustion from extremely + // long lines. + let effective_width = if line_wrap_enabled { + if viewport.grid_wrap { + // Terminal-grid wrap (fresh#2649): wrap at exactly the + // capture-time PTY column count. No EOL-cursor column is + // reserved and no clamp to the content width — the grid is one + // column wider than the scroll-back content area (the live view + // reclaims the scrollbar column), and clamping or reserving + // would re-wrap every full-width grid row one cell early, + // reflowing the whole view on entry. Full rows render with + // their last cell under the scrollbar, exactly like the + // non-wrapped exit frame always has. + viewport.grid_cols() + } else { + let base = if let Some(col) = viewport.wrap_column { + col.min(content_width) + } else { + content_width + }; + base.saturating_sub(1).max(1) + } + } else { + MAX_SAFE_LINE_WIDTH + }; + let hanging_indent = line_wrap_enabled && viewport.wrap_indent && !viewport.grid_wrap; + let is_compose = matches!(view_mode, ViewMode::PageView); + + // Fast path: when the pipeline's output for this window is fully + // determined by per-line layouts already in the line-wrap cache, + // assemble the window from those entries instead of re-tokenising + // and re-wrapping every visible logical line. This is what makes + // cursor movement inside a very long wrapped line responsive: the + // full pipeline is O(line length) per frame, while a warm cache + // walk is O(visible rows). Eligibility mirrors the writeback + // conditions below, plus cursor-independence (no soft breaks / + // conceals) so entries keyed `cursor_sig: 0` are exact. Any miss + // or stale entry falls through to the full pipeline, whose + // writeback repopulates the cache for the next frame. + if view_transform.is_none() + && line_wrap_enabled + && !viewport.grid_wrap + // Scroll-to-end sync reads the view's full extent; the fast + // path's row-bounded window deliberately truncates it. + && !viewport.sync_scroll_to_end + && !is_binary + && fold_skip.is_empty() + && state.virtual_texts.is_empty() + && state.soft_breaks.is_empty() + && state.conceals.is_empty() + { + if let Some(lines) = try_cached_window( + state, + viewport, + estimated_line_length, + adjusted_visible_count, + effective_width, + gutter_width, + hanging_indent, + is_compose, + ) { + return ViewData { lines }; + } + } + // Build base token stream from source, skipping any source-byte range // that falls inside a collapsed fold. let base_tokens = build_base_tokens( @@ -93,7 +169,6 @@ pub(super) fn build_view_data( // Apply soft breaks — marker-based line wrapping that survives edits // without flicker. Only apply in Compose mode; Source mode shows the raw // unwrapped text. - let is_compose = matches!(view_mode, ViewMode::PageView); if is_compose && !state.soft_breaks.is_empty() { let viewport_end = tokens .iter() @@ -141,45 +216,6 @@ pub(super) fn build_view_data( } } - // Apply wrapping transform - always enabled for safety, but with - // different thresholds. When line_wrap is on: wrap at viewport width (or - // wrap_column if set). When line_wrap is off: wrap at - // MAX_SAFE_LINE_WIDTH to prevent memory exhaustion from extremely long - // lines. - // - // When wrapping is on, reserve the last content column so the - // end-of-line cursor never lands on top of the vertical scrollbar. - // The cursor sits one column past the last rendered character, so - // a row that fills `content_width` exactly would place the EOL - // cursor on the scrollbar track (which is drawn in the column - // immediately to the right of the content area). `saturating_sub` - // keeps this safe at very small widths where the guard inside - // `apply_wrapping_transform` will short-circuit anyway. - let effective_width = if line_wrap_enabled { - if viewport.grid_wrap { - // Terminal-grid wrap (fresh#2649): wrap at exactly the - // capture-time PTY column count. No EOL-cursor column is - // reserved and no clamp to the content width — the grid is one - // column wider than the scroll-back content area (the live view - // reclaims the scrollbar column), and clamping or reserving - // would re-wrap every full-width grid row one cell early, - // reflowing the whole view on entry. Full rows render with - // their last cell under the scrollbar, exactly like the - // non-wrapped exit frame always has. - viewport.grid_cols() - } else { - let base = if let Some(col) = viewport.wrap_column { - col.min(content_width) - } else { - content_width - }; - base.saturating_sub(1).max(1) - } - } else { - MAX_SAFE_LINE_WIDTH - }; - let hanging_indent = line_wrap_enabled && viewport.wrap_indent && !viewport.grid_wrap; - // Splice inline virtual text (inlay hints) into the stream BEFORE // wrapping so its display width participates in wrap boundaries, the // per-character visual-column map, and horizontal scrolling. Done here @@ -273,11 +309,22 @@ pub(super) fn build_view_data( && state.virtual_texts.is_empty() { use crate::view::line_wrap_cache::{ - cursor_sig_for_line, pipeline_inputs_version, CacheViewMode, LineWrapKey, + cursor_sig_for_line, layout_depends_on_cursors, pipeline_inputs_version, CacheViewMode, + LineWrapKey, }; use crate::view::ui::view_pipeline::LineStart; use std::sync::Arc; + // When the pipeline is cursor-independent (no soft breaks or + // conceals), key every line with `cursor_sig: 0`: the layout + // cannot differ by cursor position, and a stable key keeps the + // cursor line's entry valid as the cursor moves within it (the + // cached-window fast path above depends on this). + let sig_cursors: &[usize] = if layout_depends_on_cursors(state) { + cursor_positions + } else { + &[] + }; let cache_view_mode = if matches!(view_mode, ViewMode::PageView) { CacheViewMode::Compose } else { @@ -289,6 +336,7 @@ pub(super) fn build_view_data( state.conceals.version(), state.virtual_texts.version(), ); + let tab_size = state.buffer_settings.tab_size as u16; let make_key = |line_start: usize, mode: CacheViewMode, cursor_sig: u64| LineWrapKey { pipeline_inputs_version: pipeline_inputs_ver, view_mode: mode, @@ -299,6 +347,7 @@ pub(super) fn build_view_data( hanging_indent, line_wrap_enabled: true, grid_wrap: viewport.grid_wrap, + tab_size, cursor_sig, }; @@ -361,7 +410,7 @@ pub(super) fn build_view_data( .max() .map(|b| b + 1) .unwrap_or(line_start_byte); - let cursor_sig = cursor_sig_for_line(cursor_positions, line_start_byte, sig_end); + let cursor_sig = cursor_sig_for_line(sig_cursors, line_start_byte, sig_end); let arc = Arc::new(slice); state.line_wrap_cache.put( make_key(line_start_byte, cache_view_mode, cursor_sig), @@ -421,3 +470,227 @@ pub(super) fn build_view_data( ViewData { lines } } + +/// Assemble the visible window straight from per-line entries in the +/// line-wrap cache. +/// +/// Returns `Some(lines)` only when every logical line the window needs +/// (plus the trailing empty EOF line, when applicable) has a complete +/// cached layout under the current pipeline-inputs version and +/// geometry; otherwise returns `None` and the caller runs the full +/// pipeline, whose writeback repopulates the cache so the next frame +/// hits. +/// +/// "Needs" is row-bounded, not line-counted: the walk stops demanding +/// further lines once the accumulated entries cover the viewport's +/// scroll offset plus two screens of rows. Counting logical lines +/// instead (the pipeline's `visible_count + 4`) would demand lines the +/// pipeline's chunk-based budget never tokenises — with the viewport +/// inside a long wrapped line mid-file, those trailing lines are never +/// cached and the fast path would never engage (review finding 1). +/// +/// Caller must guarantee (checked at the call site): no plugin view +/// transform, line wrap on, non-binary buffer, no folds, and empty +/// virtual-text / soft-break / conceal managers — the exact conditions +/// under which the writeback in `build_view_data` stores entries whose +/// content matches the full pipeline's output and is keyed with +/// `cursor_sig: 0`. +fn try_cached_window( + state: &mut EditorState, + viewport: &Viewport, + estimated_line_length: usize, + visible_count: usize, + effective_width: usize, + gutter_width: usize, + hanging_indent: bool, + is_compose: bool, +) -> Option> { + let _span = tracing::trace_span!("try_cached_window").entered(); + use crate::view::line_wrap_cache::{pipeline_inputs_version, CacheViewMode, LineWrapKey}; + use crate::view::ui::view_pipeline::LineStart; + + let buffer_len = state.buffer.len(); + if buffer_len == 0 { + return None; + } + // Upper bound on logical lines, mirroring `build_base_tokens`' + // budget; the row bound below usually stops the walk first. + let max_lines = visible_count.saturating_add(4); + // Visual rows the assembled window must cover: everything scrolled + // above the viewport top, one screen, and one further screen of + // slack so `ensure_visible_in_layout`'s clamp math (which reads + // `view_lines.len()`) never binds earlier than it would on the + // full pipeline's output for any single-keypress motion. + let rows_needed = viewport + .top_view_line_offset + .saturating_add(visible_count.saturating_mul(2)) + .saturating_add(8); + + let version = pipeline_inputs_version( + state.buffer.version(), + state.soft_breaks.version(), + state.conceals.version(), + state.virtual_texts.version(), + ); + let cache_view_mode = if is_compose { + CacheViewMode::Compose + } else { + CacheViewMode::Source + }; + let tab_size = state.buffer_settings.tab_size as u16; + let make_key = move |line_start: usize| LineWrapKey { + pipeline_inputs_version: version, + view_mode: cache_view_mode, + line_start, + effective_width: effective_width as u32, + gutter_width: gutter_width as u16, + wrap_column: viewport.wrap_column.map(|c| c as u32), + hanging_indent, + line_wrap_enabled: true, + // The fast path is gated off under terminal grid-wrap (see the + // eligibility check at the call site). + grid_wrap: false, + tab_size, + cursor_sig: 0, + }; + // Reject entries truncated by the huge-line safety caps + // (`MAX_SAFE_LINE_WIDTH` segment budget in `build_base_tokens`, + // `max_lines` in `compute_line_layout`): a complete layout's last + // row reaches the logical line's final byte. The -2 slack covers + // CRLF, whose Newline token sits on the `\r`. The requirement is + // EXACT — the walk knows each line's ending, so the entry's last + // mapped byte must be the Newline token's own offset (`end - 2` for + // CRLF whose token sits on the `\r`, `end - 1` otherwise, which + // also covers a final line with no newline at EOF). A tolerance + // here would admit an entry truncated within that many bytes of + // the line end — an entry missing exactly its trailing newline + // cell, the shape a chunk-budget truncation at a line's final + // character produces (review finding 2). + let is_complete = |entry: &[ViewLine], expected_end: usize, ended_crlf: bool| { + let required = if ended_crlf { + expected_end.wrapping_sub(2) + } else { + expected_end.wrapping_sub(1) + }; + entry + .last() + .and_then(|row| row.char_source_bytes.iter().rev().find_map(|b| *b)) + .is_some_and(|b| b == required) + }; + + // Walk logical-line boundaries covering the window, looking each + // line's entry up as its extent becomes known (an entry can only be + // validated against the NEXT line's start). `next_line` yields at + // most MAX_LINE_BYTES per call, so a huge line arrives as several + // segments — only a segment following a newline starts a new + // logical line. + // + // The walk is additionally byte-bounded to what the full pipeline + // would tokenise: beyond `max_lines` chunk budgets the pipeline's + // entries are truncated and rejected anyway, so without this cap + // the walk would read an arbitrarily large single line end-to-end + // (unbounded disk I/O on lazily-loaded large files) only to + // conclude "fall back". ×4 converts the char budget to a byte + // bound that can't under-count multi-byte text. + let max_walk_bytes = max_lines + .saturating_mul(MAX_SAFE_LINE_WIDTH) + .saturating_mul(4); + let mut walked_bytes = 0usize; + let mut entries: Vec>> = Vec::new(); + let mut rows_accumulated = 0usize; + // The most recent line's entry, awaiting its end boundary. + let mut pending: Option>> = None; + let mut lines_taken = 0usize; + let mut window_covered = false; + let mut prev_ended_with_newline = true; + let mut last_crlf = false; + let mut reached_eof = false; + { + let mut iter = state + .buffer + .line_iterator(viewport.top_byte, estimated_line_length); + loop { + let Some((start, content)) = iter.next_line() else { + reached_eof = true; + break; + }; + if start >= buffer_len { + // The iterator's synthesized trailing empty line after a + // final newline; represented in the cache by its own + // entry, handled below. + reached_eof = true; + break; + } + if prev_ended_with_newline { + // `start` bounds the previous line: validate its entry + // against the ending the walk just read for it. + if let Some(arc) = pending.take() { + if !is_complete(&arc, start, last_crlf) { + return None; + } + rows_accumulated += arc.len(); + entries.push(arc); + } + if rows_accumulated >= rows_needed || lines_taken == max_lines { + window_covered = true; + break; + } + lines_taken += 1; + pending = Some(state.line_wrap_cache.get_touch(&make_key(start))?); + } + walked_bytes += content.len(); + if walked_bytes > max_walk_bytes { + return None; + } + prev_ended_with_newline = content.ends_with('\n'); + last_crlf = content.ends_with("\r\n"); + } + } + if !window_covered { + // EOF bounded the walk: the last line ends at the buffer end + // (with `last_crlf`/no-newline endings both handled by the + // exact-requirement arithmetic). + if let Some(arc) = pending.take() { + if !is_complete(&arc, buffer_len, last_crlf) { + return None; + } + entries.push(arc); + } + } + if entries.is_empty() { + return None; + } + if reached_eof && !window_covered && prev_ended_with_newline { + // The trailing empty EOF row's cache key: the byte just past + // the final Newline *token* (which sits on the `\r` for CRLF), + // matching the `source_start_byte` the ViewLineIterator gives + // that row. + let trailing_start = if last_crlf { + buffer_len - 1 + } else { + buffer_len + }; + entries.push(state.line_wrap_cache.get_touch(&make_key(trailing_start))?); + } + + // Assemble, normalizing each entry's first-row `line_start` to its + // position in THIS window (an entry written while its line was at + // the top of the window carries `Beginning`; reused mid-window it + // must read `AfterSourceNewline`, and vice versa). + let total_rows = entries.iter().map(|e| e.len()).sum(); + let mut lines: Vec = Vec::with_capacity(total_rows); + for (i, entry) in entries.iter().enumerate() { + let first_row = lines.len(); + lines.extend(entry.iter().cloned()); + if let Some(row) = lines.get_mut(first_row) { + if !matches!(row.line_start, LineStart::AfterBreak) { + row.line_start = if i == 0 { + LineStart::Beginning + } else { + LineStart::AfterSourceNewline + }; + } + } + } + Some(lines) +} diff --git a/crates/fresh-editor/src/view/viewport.rs b/crates/fresh-editor/src/view/viewport.rs index f75ede440c..f620c7b8a1 100644 --- a/crates/fresh-editor/src/view/viewport.rs +++ b/crates/fresh-editor/src/view/viewport.rs @@ -373,6 +373,11 @@ impl Viewport { line_wrap_enabled: true, // Grid mode returns before this cache path. grid_wrap: false, + // This is the viewport-local count-only cache — its + // entries are placeholders never shared with the + // renderer's per-state cache, and the count helper is + // tab-blind, so a fixed sentinel keys them fine. + tab_size: 0, // Scroll math is cursor-blind by convention (matches // `VisualRowIndex` and its own cursor-free inputs). cursor_sig: 0, diff --git a/crates/fresh-editor/src/view/visual_row_index.rs b/crates/fresh-editor/src/view/visual_row_index.rs index f66260f9cb..a35b3cb037 100644 --- a/crates/fresh-editor/src/view/visual_row_index.rs +++ b/crates/fresh-editor/src/view/visual_row_index.rs @@ -59,6 +59,9 @@ pub struct VisualRowIndexKey { /// Terminal-grid wrap (fresh#2649): per-line counts use exact-column /// breaks at `effective_width` instead of the word-boundary wrap. pub grid_wrap: bool, + /// Tab width (see [`LineWrapKey::tab_size`]): changing it reshapes + /// wrap points, so per-line row counts must rebuild. + pub tab_size: u16, } impl VisualRowIndexKey { @@ -79,6 +82,7 @@ impl VisualRowIndexKey { hanging_indent: self.hanging_indent, line_wrap_enabled: self.line_wrap_enabled, grid_wrap: self.grid_wrap, + tab_size: self.tab_size, cursor_sig: 0, } } @@ -369,6 +373,7 @@ pub fn ensure_built_from_geom(state: &mut EditorState, geom: &WrapGeometry) { hanging_indent: geom.hanging_indent, line_wrap_enabled: geom.line_wrap_enabled, grid_wrap: geom.grid_wrap, + tab_size: geom.tab_size as u16, }; ensure_built(state, &key); } @@ -388,6 +393,7 @@ mod tests { hanging_indent: false, line_wrap_enabled: true, grid_wrap: false, + tab_size: 4, }), prefix_sums: prefix, line_starts: starts, diff --git a/crates/fresh-editor/tests/e2e/line_wrap_cache_consistency.rs b/crates/fresh-editor/tests/e2e/line_wrap_cache_consistency.rs index d791e38b38..b9b349f387 100644 --- a/crates/fresh-editor/tests/e2e/line_wrap_cache_consistency.rs +++ b/crates/fresh-editor/tests/e2e/line_wrap_cache_consistency.rs @@ -105,6 +105,7 @@ fn current_keys(harness: &EditorTestHarness, line_start: usize) -> (LineWrapKey, hanging_indent, line_wrap_enabled: true, grid_wrap: false, + tab_size: harness.editor().active_state().buffer_settings.tab_size as u16, cursor_sig: 0, }; let source = LineWrapKey { diff --git a/crates/fresh-editor/tests/streaming_and_grapheme_regression_tests.rs b/crates/fresh-editor/tests/streaming_and_grapheme_regression_tests.rs new file mode 100644 index 0000000000..d2b0146816 --- /dev/null +++ b/crates/fresh-editor/tests/streaming_and_grapheme_regression_tests.rs @@ -0,0 +1,77 @@ +//! Regression tests for two fixes that came out of the wrapped-line +//! navigation performance review: +//! +//! * `extend_streaming` mutates buffer content (appends bytes) but did +//! not bump `buffer.version()`, so version-keyed layout caches +//! (visual-row index, line-wrap cache) could remain reachable with +//! pre-append state. +//! +//! * `prev_grapheme_boundary` stepped one cluster too far when given a +//! position INSIDE a multi-byte code point whose cluster is a single +//! code point (accented latin, CJK): the snapped char boundary IS the +//! containing cluster's start, and stepping `prev_boundary` from it +//! overshoots. + +mod common; + +use common::harness::EditorTestHarness; + +/// `extend_streaming` appends content and must bump `buffer.version()` +/// like every other mutation path — layout caches key on the version, +/// so an unchanged version can serve pre-append layout. +#[test] +fn extend_streaming_bumps_buffer_version() { + use std::io::Write; + + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("stream.txt"); + std::fs::write(&path, "hello\n").unwrap(); + + let mut harness = EditorTestHarness::new(80, 24).unwrap(); + harness.open_file(&path).unwrap(); + harness.render().unwrap(); + + let before = harness.editor().active_state().buffer.version(); + + // The stream grows on disk; the editor is told to extend. + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + f.write_all(b"appended\n").unwrap(); + f.flush().unwrap(); + let new_size = std::fs::metadata(&path).unwrap().len() as usize; + harness + .editor_mut() + .active_state_mut() + .buffer + .extend_streaming(&path, new_size); + + let after = harness.editor().active_state().buffer.version(); + assert_ne!( + before, + after, + "extend_streaming appended {} bytes but buffer.version() did not change — \ + version-keyed layout caches will serve pre-append content", + new_size - 6, + ); +} + +/// For a position INSIDE a multi-byte code point, +/// `prev_grapheme_boundary` must return the start of the CONTAINING +/// cluster, not the boundary one cluster earlier. +#[test] +fn prev_grapheme_boundary_mid_code_point_returns_containing_cluster_start() { + use fresh::primitives::grapheme::prev_grapheme_boundary; + + // "aé": 'a' at 0, 'é' occupies bytes 1..3. Position 2 is inside + // 'é'; its containing cluster starts at 1. + assert_eq!(prev_grapheme_boundary("aé", 2), 1); + + // Sanity: a position on a char boundary INSIDE a multi-code-point + // cluster still resolves to the cluster start... + assert_eq!(prev_grapheme_boundary("ที่", 3), 0); + // ...and a position on a cluster boundary steps to the previous one. + assert_eq!(prev_grapheme_boundary("aé", 1), 0); + assert_eq!(prev_grapheme_boundary("aé", 3), 1); +} diff --git a/crates/fresh-editor/tests/wrapped_line_cache_bug_repros.rs b/crates/fresh-editor/tests/wrapped_line_cache_bug_repros.rs new file mode 100644 index 0000000000..be95052646 --- /dev/null +++ b/crates/fresh-editor/tests/wrapped_line_cache_bug_repros.rs @@ -0,0 +1,204 @@ +//! Regression tests for the confirmed findings of the cached-window +//! fast-path review. Each test asserts the CORRECT behavior; all four +//! findings are fixed and these run live. +//! +//! Observability status (what a user can actually see): +//! +//! * Finding 1 was the only finding with a USER-OBSERVABLE symptom: +//! navigation through a long wrapped line mid-file was several times +//! slower than the same line at end-of-file, because the fast path +//! never engaged there. FIXED: the cached-window walk is now +//! row-bounded instead of demanding `visible_count + 4` logical +//! lines; `midfile_long_line_navigation_matches_eof_throughput` is +//! the live end-to-end regression test (keystrokes in, throughput +//! out). +//! +//! * Findings 2-4 were LATENT contract violations (end-to-end probing +//! found no rendering difference while they existed — masked by +//! redundant downstream mappings and fallback-on-miss). FIXED: +//! the completeness check now requires the exact newline-token +//! offset per line ending (2), `extend_streaming` bumps +//! `buffer.version()` (3), and `prev_grapheme_boundary` returns the +//! containing cluster's start for mid-code-point input (4). These +//! tests pin the contracts at the level where they are provable. +//! +//! Finding 1 — mid-file fast-path gap: the cached-window walk demands a +//! cache entry for `visible_count + 4` *logical lines*, but the full +//! pipeline spends that same budget in `MAX_SAFE_LINE_WIDTH`-char +//! chunks, so with the viewport inside a long line mid-file the +//! writeback never caches the trailing lines the walk demands and the +//! fast path never engages. +//! +//! Finding 2 — completeness-check slack: the fast path rejects +//! truncated entries via `last_source_byte + 2 >= expected_end`; the +//! 2-byte slack (needed for CRLF) also admits an LF entry missing +//! exactly its trailing newline cell, which corrupts the served row's +//! end-of-line mapping. +//! +//! Findings 3 (`extend_streaming` version bump) and 4 +//! (`prev_grapheme_boundary` mid-code-point) are fixed in the base PR; +//! their regression tests live in +//! `streaming_and_grapheme_regression_tests.rs`. + +mod common; + +use common::harness::EditorTestHarness; +use crossterm::event::{KeyCode, KeyModifiers}; +use fresh::view::line_wrap_cache::{pipeline_inputs_version, CacheViewMode, LineWrapKey}; +use std::time::{Duration, Instant}; + +/// Down-arrow keypresses (each including a render, like an interactive +/// session) completed within `budget`, after a warm-up so the first +/// paint and cache fill are excluded. +fn moves_in_budget(content: &str, budget: Duration) -> u32 { + let mut harness = EditorTestHarness::new(80, 24).unwrap(); + let _fx = harness.load_buffer_from_text(content).unwrap(); + harness.render().unwrap(); + for _ in 0..3 { + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + } + let start = Instant::now(); + let mut moves = 0u32; + while start.elapsed() < budget { + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + moves += 1; + } + moves +} + +/// Finding 1, end-to-end observable symptom: navigating the SAME long +/// wrapped line must not become several times slower just because +/// other lines follow it in the file. Today the mid-file shape never +/// engages the cached-window fast path, so its throughput is ~2.5x +/// lower; the 1.75x threshold leaves margin on both sides (post-fix +/// the ratio is ~1.0). +#[test] +fn midfile_long_line_navigation_matches_eof_throughput() { + let eof_shape = format!("{}\n", "x".repeat(200_000)); + let mut midfile_shape = format!("{}\n", "x".repeat(200_000)); + for i in 0..40 { + midfile_shape.push_str(&format!("short line {i}\n")); + } + + let budget = Duration::from_millis(2500); + let eof_moves = moves_in_budget(&eof_shape, budget); + let midfile_moves = moves_in_budget(&midfile_shape, budget); + + assert!( + eof_moves >= 4, + "degenerate run: only {eof_moves} moves completed in the budget" + ); + let ratio = eof_moves as f64 / midfile_moves.max(1) as f64; + assert!( + ratio <= 1.75, + "navigating a 200 KB wrapped line mid-file is {ratio:.2}x slower than \ + the same line at EOF ({midfile_moves} vs {eof_moves} moves in {budget:?}) — \ + the cached-window fast path is not engaging for the mid-file shape" + ); +} + +/// Build the Source-mode cache key the renderer's writeback and the +/// fast-path walk both construct for `line_start` under the harness's +/// current geometry (mirrors `current_keys` in +/// `e2e/line_wrap_cache_consistency.rs`). +fn source_key(harness: &EditorTestHarness, line_start: usize) -> LineWrapKey { + let editor = harness.editor(); + let viewport = editor.active_viewport(); + let state = editor.active_state(); + let gutter = viewport.gutter_width(&state.buffer) as u16; + let effective = (viewport.width as usize).saturating_sub(1).max(1); + LineWrapKey { + pipeline_inputs_version: pipeline_inputs_version( + state.buffer.version(), + state.soft_breaks.version(), + state.conceals.version(), + state.virtual_texts.version(), + ), + view_mode: CacheViewMode::Source, + line_start, + effective_width: effective as u32, + gutter_width: gutter, + wrap_column: viewport.wrap_column.map(|c| c as u32), + hanging_indent: viewport.wrap_indent, + line_wrap_enabled: true, + grid_wrap: false, + tab_size: state.buffer_settings.tab_size as u16, + cursor_sig: 0, + } +} + +/// Finding 2: the fast path must not serve an entry whose last row is +/// missing its trailing newline cell, but the completeness check's +/// 2-byte slack (needed for CRLF) accepts exactly that shape — the one +/// a chunk-budget truncation at a line's final character produces. +/// +/// Detection: if the incomplete entry is rejected, the frame falls back +/// to the full pipeline, whose writeback REPLACES the entry with the +/// complete form. So after a render, the cached entry carrying the +/// newline cell again is the signature of correct behavior; the entry +/// remaining newline-less proves the fast path accepted and served it. +/// (Downstream click/caret paths happen to mask visible corruption for +/// this minimal fixture — the served window is still wrong: its row +/// text and byte mappings disagree with the buffer.) +#[test] +fn fast_path_must_not_serve_entry_missing_its_newline_cell() { + let content = "abcdef\nsecond line\n"; + let mut harness = EditorTestHarness::new(80, 24).unwrap(); + let _fx = harness.load_buffer_from_text(content).unwrap(); + harness.render().unwrap(); // writeback populates complete entries + + // Doctor line 0's entry: strip the trailing newline cell from its + // last row — exactly the shape a chunk-budget truncation at the + // line's final character produces. `last_source_byte` becomes 5 + // ('f'); `expected_end` is 7 (start of line 1); 5 + 2 >= 7 passes + // the slack, so the fast path serves this entry. + let key = source_key(&harness, 0); + let doctored = { + let entry = harness + .editor() + .active_state() + .line_wrap_cache + .get(&key) + .expect("line 0 must be cached after a render"); + let mut rows = (*entry).clone(); + let last = rows.last_mut().expect("entry has rows"); + assert!( + last.text.ends_with('\n'), + "precondition: complete entry carries the newline cell" + ); + last.text.pop(); + let removed_char_idx = last.char_source_bytes.len() - 1; + last.char_source_bytes.pop(); + last.char_styles.pop(); + last.char_visual_cols.pop(); + while last.visual_to_char.last() == Some(&removed_char_idx) { + last.visual_to_char.pop(); + } + last.ends_with_newline = false; + rows + }; + harness + .editor_mut() + .active_state_mut() + .line_wrap_cache + .put(key, std::sync::Arc::new(doctored)); + + // Render. A correct implementation rejects the incomplete entry, + // falls back to the full pipeline, and the writeback replaces the + // entry with its complete form (newline cell restored). + harness.render().unwrap(); + + let entry_after = harness + .editor() + .active_state() + .line_wrap_cache + .get(&source_key(&harness, 0)) + .expect("entry present"); + let last = entry_after.last().unwrap(); + assert!( + last.ends_with_newline && last.text.ends_with('\n'), + "the incomplete (newline-less) entry survived the render — the \ + fast path's completeness check accepted it and served a window \ + whose row text/byte mappings disagree with the buffer" + ); +} diff --git a/crates/fresh-editor/tests/wrapped_line_nav_perf.rs b/crates/fresh-editor/tests/wrapped_line_nav_perf.rs new file mode 100644 index 0000000000..5aa754deb8 --- /dev/null +++ b/crates/fresh-editor/tests/wrapped_line_nav_perf.rs @@ -0,0 +1,131 @@ +//! Coverage for the wrapped-line navigation fast path. +//! +//! Moving the cursor up/down through a very long soft-wrapped line used +//! to be extremely slow: every keypress re-tokenised and re-wrapped the +//! whole logical line (the cursor's line was keyed in the line-wrap +//! cache by a cursor-position signature, so each move invalidated it), +//! the reference-highlight word scan walked the line with quadratic +//! grapheme lookups, and several line-boundary scans read the buffer one +//! byte at a time. +//! +//! The fix serves the visible window straight from the per-line +//! line-wrap cache (`try_cached_window` in `view_data.rs`), keyed with +//! `cursor_sig: 0` whenever no soft breaks / conceals make the layout +//! cursor-dependent. These tests pin the correctness side: a render +//! served from warm cache entries must be indistinguishable from a cold +//! full-pipeline render of the same state. + +mod common; + +use common::harness::EditorTestHarness; +use crossterm::event::{KeyCode, KeyModifiers}; + +/// A buffer mixing short lines, an empty line, and one very long line so +/// the window crosses cached-entry boundaries in every configuration. +fn mixed_content(long_len: usize, line_ending: &str) -> String { + let mut s = String::new(); + s.push_str(&format!("fn main() {{{line_ending}")); + s.push_str(&format!(" let a = 1;{line_ending}")); + s.push_str(line_ending); // empty line + s.push_str(&"x".repeat(long_len)); + s.push_str(line_ending); + s.push_str(&format!(" let b = 2;{line_ending}")); + s.push_str(&format!("}}{line_ending}")); + s +} + +/// After scrolling with a render per keypress (so the window is being +/// assembled by the cached-window fast path), clearing the line-wrap +/// cache and re-rendering the SAME state — forcing the full pipeline — +/// must produce an identical screen. Any divergence means the fast +/// path assembled something the full pipeline would not have produced. +fn assert_warm_equals_cold(content: &str, downs: usize) { + let mut harness = EditorTestHarness::new(80, 24).unwrap(); + let _fx = harness.load_buffer_from_text(content).unwrap(); + harness.render().unwrap(); + for _ in 0..downs { + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + } + let warm_screen = harness.screen_to_string(); + + // Drop every cached per-line layout, then render the same state + // again: the fast path misses, and the full pipeline rebuilds the + // window from scratch. + harness + .editor_mut() + .active_state_mut() + .line_wrap_cache + .clear(); + harness.render().unwrap(); + let cold_screen = harness.screen_to_string(); + + assert_eq!( + warm_screen, cold_screen, + "screen diverged after {downs} Downs" + ); +} + +#[test] +fn warm_cache_render_matches_cold_render_through_long_line() { + // 5000 chars wraps to ~65 visual rows at width 80: the walk starts + // above the long line, traverses through it (viewport fully inside + // the line), and exits below it. + let content = mixed_content(5000, "\n"); + for downs in [3, 10, 40, 80] { + assert_warm_equals_cold(&content, downs); + } +} + +#[test] +fn warm_cache_render_matches_cold_render_crlf() { + // CRLF exercises the trailing-empty-EOF-row key, which sits one + // byte earlier than for LF (the Newline token lives on the `\r`). + let content = mixed_content(3000, "\r\n"); + for downs in [10, 60] { + assert_warm_equals_cold(&content, downs); + } +} + +#[test] +fn warm_cache_up_down_round_trip_is_stable() { + let content = mixed_content(5000, "\n"); + let mut harness = EditorTestHarness::new(80, 24).unwrap(); + let _fx = harness.load_buffer_from_text(&content).unwrap(); + harness.render().unwrap(); + let initial = harness.screen_to_string(); + + for _ in 0..30 { + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + } + for _ in 0..30 { + harness.send_key(KeyCode::Up, KeyModifiers::NONE).unwrap(); + } + // 30 Down then 30 Up over uniformly wrapped content returns the + // cursor to its start; the screen must be back to the initial + // state (no drift from cached-window assembly). + assert_eq!(harness.cursor_position(), 0); + assert_eq!(harness.screen_to_string(), initial); +} + +#[test] +fn edit_inside_long_line_invalidates_cached_window() { + // Typing inside the long line bumps the buffer version, so every + // cached entry goes stale at once; the next frame must re-run the + // full pipeline and show the edit. + let content = mixed_content(2000, "\n"); + let mut harness = EditorTestHarness::new(80, 24).unwrap(); + let _fx = harness.load_buffer_from_text(&content).unwrap(); + harness.render().unwrap(); + // Move into the long wrapped line (warming the cache), then type. + for _ in 0..10 { + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + } + harness.type_text("HELLO").unwrap(); + let screen = harness.screen_to_string(); + assert!( + screen.contains("HELLO"), + "edit did not appear in the rendered screen:\n{screen}" + ); + let buffer = harness.get_buffer_content().unwrap(); + assert!(buffer.contains("HELLO")); +}