Skip to main content

fresh/view/
visual_row_index.rs

1//! Whole-buffer visual-row index.
2//!
3//! A second-tier cache sitting on `EditorState` alongside
4//! [`LineWrapCache`](crate::view::line_wrap_cache::LineWrapCache).
5//! Where `LineWrapCache` answers per-line questions ("layout for line K"),
6//! this index answers whole-buffer questions:
7//!
8//!   * `total_rows()` — total visual row count (O(1)).
9//!   * `position_at_row(r)` — `(line_idx, line_start_byte, offset_in_line)`
10//!     for any visual row (O(log N_lines) via `partition_point`).
11//!   * `line_first_row(i)` — cumulative visual row at the start of line `i`
12//!     (O(1)).
13//!
14//! Storage: a parallel pair of vectors with `N_lines + 1` entries.
15//!
16//!     prefix_sums[i] = sum of visual row counts of logical lines 0..i
17//!     line_starts[i] = byte offset where logical line i begins
18//!
19//!     prefix_sums[N] = total visual rows
20//!     line_starts[N] = buffer length (sentinel)
21//!
22//! Population: derived from `LineWrapCache`. Each entry `prefix_sums[i+1]
23//! - prefix_sums[i]` equals `cache_entry.len()` for line `i`. On a miss
24//! the build path falls through to `compute_line_layout` (same miss
25//! handler the per-line cache uses), so the row counts always match the
26//! pipeline output. No second wrap implementation; no drift.
27//!
28//! Invalidation: keyed on the same pipeline-input version + geometry
29//! that determines per-line row counts. Any version bump or width /
30//! gutter / wrap-flag change → key changes → stale index becomes
31//! unreachable. Build is lazy on next query.
32//!
33//! Replaces three independent O(N_lines) folds:
34//!   * scroll math's `build_visual_row_map` (per mouse event)
35//!   * scrollbar render's `scrollbar_visual_row_counts` (per frame)
36//!   * `ensure_visible` wrapped scroll-up walk (per keystroke)
37
38use crate::state::EditorState;
39use crate::view::line_wrap_cache::{
40    count_visual_rows_for_text, count_visual_rows_for_text_with_soft_breaks,
41    pipeline_inputs_version, CacheViewMode, LineWrapKey, WrapGeometry,
42};
43
44/// All inputs that determine the per-line visual row counts a buffer
45/// produces.  Identical to `LineWrapKey`'s geometry-related fields
46/// minus `line_start` (which varies across lines).  Mutating any of
47/// these → different key → stale index becomes unreachable.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct VisualRowIndexKey {
50    pub pipeline_inputs_version: u64,
51    pub view_mode: CacheViewMode,
52    pub effective_width: u32,
53    pub gutter_width: u16,
54    pub wrap_column: Option<u32>,
55    pub hanging_indent: bool,
56    pub line_wrap_enabled: bool,
57}
58
59impl VisualRowIndexKey {
60    /// Build the matching per-line `LineWrapKey` for a given line start.
61    fn line_key(&self, line_start: usize) -> LineWrapKey {
62        LineWrapKey {
63            pipeline_inputs_version: self.pipeline_inputs_version,
64            view_mode: self.view_mode,
65            line_start,
66            effective_width: self.effective_width,
67            gutter_width: self.gutter_width,
68            wrap_column: self.wrap_column,
69            hanging_indent: self.hanging_indent,
70            line_wrap_enabled: self.line_wrap_enabled,
71        }
72    }
73
74    fn geom(&self) -> WrapGeometry {
75        WrapGeometry {
76            effective_width: self.effective_width as usize,
77            gutter_width: self.gutter_width as usize,
78            hanging_indent: self.hanging_indent,
79            wrap_column: self.wrap_column,
80            line_wrap_enabled: self.line_wrap_enabled,
81            view_mode: self.view_mode,
82        }
83    }
84}
85
86/// The index itself: prefix sums + line-start byte offsets, plus the
87/// key the index was built for so callers can detect staleness.
88#[derive(Debug, Clone, Default)]
89pub struct VisualRowIndex {
90    key: Option<VisualRowIndexKey>,
91    /// `prefix_sums[i]` = total visual rows in lines 0..i.
92    /// `prefix_sums.last()` = total visual rows in the buffer.
93    /// Length = N_lines + 1.
94    prefix_sums: Vec<u32>,
95    /// `line_starts[i]` = byte offset where logical line `i` begins.
96    /// `line_starts.last()` = buffer length (sentinel).
97    /// Length = N_lines + 1.
98    line_starts: Vec<usize>,
99}
100
101impl VisualRowIndex {
102    pub fn is_built_for(&self, key: &VisualRowIndexKey) -> bool {
103        self.key.as_ref() == Some(key)
104    }
105
106    /// Discard any cached state — used when the editor knows the
107    /// underlying buffer changed in ways the key wouldn't catch (e.g.
108    /// buffer swap).
109    pub fn clear(&mut self) {
110        self.key = None;
111        self.prefix_sums.clear();
112        self.line_starts.clear();
113    }
114
115    /// Number of logical lines covered (== `prefix_sums.len() - 1`).
116    pub fn line_count(&self) -> usize {
117        self.prefix_sums.len().saturating_sub(1)
118    }
119
120    /// Total visual rows across all logical lines (O(1)).
121    pub fn total_rows(&self) -> u32 {
122        *self.prefix_sums.last().unwrap_or(&0)
123    }
124
125    /// First visual row of logical line `line_idx` (O(1)).
126    pub fn line_first_row(&self, line_idx: usize) -> u32 {
127        *self.prefix_sums.get(line_idx).unwrap_or(&self.total_rows())
128    }
129
130    /// Visual row count of logical line `line_idx` (O(1)).
131    pub fn line_row_count(&self, line_idx: usize) -> u32 {
132        let next = self
133            .prefix_sums
134            .get(line_idx + 1)
135            .copied()
136            .unwrap_or_else(|| self.total_rows());
137        next - self.line_first_row(line_idx)
138    }
139
140    /// Byte offset of the start of logical line `line_idx` (O(1)).
141    pub fn line_start_byte(&self, line_idx: usize) -> usize {
142        *self.line_starts.get(line_idx).unwrap_or(&0)
143    }
144
145    /// Find the logical line that contains byte offset `byte` (O(log N)).
146    /// Returns `(line_idx, line_start_byte)`.  Bytes past the last line
147    /// resolve to the last line.
148    pub fn line_for_byte(&self, byte: usize) -> (usize, usize) {
149        let n = self.line_count();
150        if n == 0 {
151            return (0, 0);
152        }
153        // `line_starts[N]` is the buffer-length sentinel; clamp so we
154        // never return that index.  Largest i in 0..N such that
155        // line_starts[i] <= byte.
156        let p = self.line_starts.partition_point(|&s| s <= byte);
157        let i = p.saturating_sub(1).min(n - 1);
158        (i, self.line_starts[i])
159    }
160
161    /// Convert an absolute visual row to `(line_idx, line_start_byte,
162    /// offset_in_line)`.  Saturates to the last valid row if `row` is
163    /// out of range.  O(log N).
164    pub fn position_at_row(&self, row: u32) -> (usize, usize, usize) {
165        if self.prefix_sums.is_empty() {
166            return (0, 0, 0);
167        }
168        let total = self.total_rows();
169        let target = row.min(total.saturating_sub(1));
170        // Largest i such that prefix_sums[i] <= target.
171        let p = self.prefix_sums.partition_point(|&s| s <= target);
172        let i = p.saturating_sub(1).min(self.line_count().saturating_sub(1));
173        let offset = (target - self.prefix_sums[i]) as usize;
174        (i, self.line_starts[i], offset)
175    }
176}
177
178/// Ensure `state.visual_row_index` is built for `key`.  Cheap if it
179/// already matches; otherwise re-walks all lines to compute per-line
180/// visual-row counts.
181///
182/// On hit in `LineWrapCache` (renderer-populated entries for visible
183/// lines), reads `entry.len()` for free.  On miss, runs the cheap
184/// count-only path (`count_visual_rows_for_text` — wrap + tally,
185/// skipping `ViewLineIterator` materialization and per-char `Vec<ViewLine>`
186/// allocation).  Does **not** write back into `LineWrapCache` on miss:
187/// the index has its own `prefix_sums` storage and doesn't need the
188/// cache for its own answers, and skipping the put avoids the per-char
189/// allocation churn the profile flagged (`ViewLineIterator::next` 7.4%,
190/// `Vec<ViewLine>` growth ~10–15%).  Off-screen lines that are needed
191/// by other consumers later will be filled by the renderer's writeback
192/// when they become visible.
193///
194/// Skips the build entirely when the buffer is empty or `line_count()`
195/// is unavailable.  Callers that need a guaranteed-built index should
196/// check `is_built_for(key)` after this returns.
197pub fn ensure_built(state: &mut EditorState, key: &VisualRowIndexKey) {
198    if state.visual_row_index.is_built_for(key) {
199        return;
200    }
201
202    let buffer_len = state.buffer.len();
203    let line_count = state
204        .buffer
205        .line_count()
206        .unwrap_or_else(|| (buffer_len / state.buffer.estimated_line_length()).max(1));
207    if line_count == 0 {
208        // No lines yet — store an empty index keyed so we don't rebuild
209        // every call.
210        state.visual_row_index = VisualRowIndex {
211            key: Some(*key),
212            prefix_sums: vec![0],
213            line_starts: vec![0],
214        };
215        return;
216    }
217
218    let effective_width = key.effective_width as usize;
219    let gutter_width = key.gutter_width as usize;
220    let hanging_indent = key.hanging_indent;
221
222    // Pre-fetch the buffer-wide soft breaks and virtual lines once,
223    // then per-line we slice into them with `partition_point`.  Each
224    // slice walk is O(N_breaks_in_line) which is tiny vs the per-line
225    // wrap work.  Without these the index undercounts:
226    //   * soft breaks: the renderer wraps each segment between breaks
227    //     independently and can produce more rows than the segments'
228    //     count + 1 (each segment may itself need word-wrap).
229    //   * virtual lines: plugin-injected `LineAbove` / `LineBelow`
230    //     entries (e.g. markdown_compose's table borders) draw real
231    //     rows that scrollbar / PageDown / mouse-wheel `max_scroll_row`
232    //     must include or the user can't reach the buffer's tail.
233    let soft_break_pairs: Vec<(usize, u16)> = if state.soft_breaks.is_empty() {
234        Vec::new()
235    } else {
236        state
237            .soft_breaks
238            .query_viewport(0, buffer_len + 1, &state.marker_list)
239    };
240    let virtual_line_positions: Vec<usize> = if state.virtual_texts.is_empty() {
241        Vec::new()
242    } else {
243        let mut v: Vec<usize> = state
244            .virtual_texts
245            .query_lines_in_range(&state.marker_list, 0, buffer_len + 1)
246            .into_iter()
247            .map(|(pos, _)| pos)
248            .collect();
249        v.sort_unstable();
250        v
251    };
252
253    // Build into local Vecs first so we don't fight the borrow checker
254    // when re-borrowing `state` per line.
255    let mut prefix_sums: Vec<u32> = Vec::with_capacity(line_count + 1);
256    let mut line_starts: Vec<usize> = Vec::with_capacity(line_count + 1);
257    let mut running: u32 = 0;
258    prefix_sums.push(0);
259
260    for line_idx in 0..line_count {
261        let line_start = state
262            .buffer
263            .line_start_offset(line_idx)
264            .unwrap_or(buffer_len);
265        let line_end = if line_idx + 1 < line_count {
266            state
267                .buffer
268                .line_start_offset(line_idx + 1)
269                .unwrap_or(buffer_len)
270        } else {
271            buffer_len
272        };
273        line_starts.push(line_start);
274
275        let line_breaks = slice_in_range(&soft_break_pairs, line_start, line_end);
276        let virtual_rows = count_in_range(&virtual_line_positions, line_start, line_end) as u32;
277
278        let line_key = key.line_key(line_start);
279        let wrap_rows: u32 = if let Some(cached) = state.line_wrap_cache.get(&line_key) {
280            // Renderer (or a previous full-fidelity miss handler) put
281            // a real layout here — read its row count for free.
282            // The cached layout already reflects soft breaks (the
283            // renderer applies them before wrapping); virtual lines
284            // are added separately below.
285            (cached.len() as u32).max(1)
286        } else if !key.line_wrap_enabled {
287            // Without wrap, every logical line is exactly one visual row.
288            // Don't bother running the pipeline.
289            1
290        } else {
291            // Cache miss: compute the row count via the cheapest
292            // pipeline tap — wrap-only, no ViewLine materialization.
293            // We deliberately skip `LineWrapCache.put()` here: the
294            // index stores `prefix_sums` standalone, and writing back
295            // would cost the per-char `Vec<ViewLine>` allocation the
296            // profile flagged.  When the line later becomes visible,
297            // the renderer's writeback will fill the cache with the
298            // full-fidelity layout.
299            let Some(bytes) = state.buffer.get_line(line_idx) else {
300                // Best-effort: missing line still counts as 1 row.
301                running = running.saturating_add(1 + virtual_rows);
302                prefix_sums.push(running);
303                continue;
304            };
305            let line_content = String::from_utf8_lossy(&bytes);
306            let trimmed = line_content.trim_end_matches('\n').trim_end_matches('\r');
307            if line_breaks.is_empty() {
308                count_visual_rows_for_text(trimmed, effective_width, gutter_width, hanging_indent)
309            } else {
310                count_visual_rows_for_text_with_soft_breaks(
311                    trimmed,
312                    line_start,
313                    line_breaks,
314                    effective_width,
315                    gutter_width,
316                    hanging_indent,
317                )
318            }
319        };
320
321        running = running.saturating_add(wrap_rows.saturating_add(virtual_rows));
322        prefix_sums.push(running);
323    }
324    line_starts.push(buffer_len);
325
326    state.visual_row_index = VisualRowIndex {
327        key: Some(*key),
328        prefix_sums,
329        line_starts,
330    };
331}
332
333/// `partition_point`-based slice for a sorted `(byte_position, indent)`
334/// list, returning the entries with `byte_position` in `[start, end)`.
335fn slice_in_range(pairs: &[(usize, u16)], start: usize, end: usize) -> &[(usize, u16)] {
336    let lo = pairs.partition_point(|(p, _)| *p < start);
337    let hi = pairs.partition_point(|(p, _)| *p < end);
338    &pairs[lo..hi]
339}
340
341/// Count of entries in a sorted `usize` list with `value` in `[start, end)`.
342fn count_in_range(positions: &[usize], start: usize, end: usize) -> usize {
343    let lo = positions.partition_point(|p| *p < start);
344    let hi = positions.partition_point(|p| *p < end);
345    hi - lo
346}
347
348/// Convenience: build the index for `state` from a `WrapGeometry`,
349/// using the state's current pipeline-input versions.
350pub fn ensure_built_from_geom(state: &mut EditorState, geom: &WrapGeometry) {
351    let key = VisualRowIndexKey {
352        pipeline_inputs_version: pipeline_inputs_version(
353            state.buffer.version(),
354            state.soft_breaks.version(),
355            state.conceals.version(),
356            state.virtual_texts.version(),
357        ),
358        view_mode: geom.view_mode,
359        effective_width: geom.effective_width as u32,
360        gutter_width: geom.gutter_width as u16,
361        wrap_column: geom.wrap_column,
362        hanging_indent: geom.hanging_indent,
363        line_wrap_enabled: geom.line_wrap_enabled,
364    };
365    ensure_built(state, &key);
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    fn idx_with(prefix: Vec<u32>, starts: Vec<usize>) -> VisualRowIndex {
373        VisualRowIndex {
374            key: Some(VisualRowIndexKey {
375                pipeline_inputs_version: 0,
376                view_mode: CacheViewMode::Source,
377                effective_width: 80,
378                gutter_width: 6,
379                wrap_column: None,
380                hanging_indent: false,
381                line_wrap_enabled: true,
382            }),
383            prefix_sums: prefix,
384            line_starts: starts,
385        }
386    }
387
388    #[test]
389    fn empty_index_total_is_zero() {
390        let idx = VisualRowIndex::default();
391        assert_eq!(idx.total_rows(), 0);
392        assert_eq!(idx.line_count(), 0);
393    }
394
395    #[test]
396    fn single_line_one_row() {
397        let idx = idx_with(vec![0, 1], vec![0, 10]);
398        assert_eq!(idx.total_rows(), 1);
399        assert_eq!(idx.line_count(), 1);
400        assert_eq!(idx.line_first_row(0), 0);
401        assert_eq!(idx.line_row_count(0), 1);
402        assert_eq!(idx.position_at_row(0), (0, 0, 0));
403    }
404
405    #[test]
406    fn multi_line_no_wrap() {
407        // 3 lines, 1 row each.
408        let idx = idx_with(vec![0, 1, 2, 3], vec![0, 10, 20, 30]);
409        assert_eq!(idx.total_rows(), 3);
410        assert_eq!(idx.line_count(), 3);
411        assert_eq!(idx.position_at_row(0), (0, 0, 0));
412        assert_eq!(idx.position_at_row(1), (1, 10, 0));
413        assert_eq!(idx.position_at_row(2), (2, 20, 0));
414        // Out-of-range saturates to the last row.
415        assert_eq!(idx.position_at_row(99), (2, 20, 0));
416    }
417
418    #[test]
419    fn wrapped_line_offsets() {
420        // Line 0: 1 row.  Line 1: 3 rows (wrapped).  Line 2: 2 rows.
421        let idx = idx_with(vec![0, 1, 4, 6], vec![0, 10, 200, 300]);
422        assert_eq!(idx.total_rows(), 6);
423        assert_eq!(idx.line_row_count(0), 1);
424        assert_eq!(idx.line_row_count(1), 3);
425        assert_eq!(idx.line_row_count(2), 2);
426        // Row 0 → line 0, offset 0.
427        assert_eq!(idx.position_at_row(0), (0, 0, 0));
428        // Rows 1..4 → line 1, offsets 0..3.
429        assert_eq!(idx.position_at_row(1), (1, 10, 0));
430        assert_eq!(idx.position_at_row(2), (1, 10, 1));
431        assert_eq!(idx.position_at_row(3), (1, 10, 2));
432        // Rows 4..6 → line 2, offsets 0..2.
433        assert_eq!(idx.position_at_row(4), (2, 200, 0));
434        assert_eq!(idx.position_at_row(5), (2, 200, 1));
435    }
436
437    #[test]
438    fn line_for_byte_resolves_to_containing_line() {
439        let idx = idx_with(vec![0, 1, 2, 3], vec![0, 10, 20, 30]);
440        assert_eq!(idx.line_for_byte(0), (0, 0));
441        assert_eq!(idx.line_for_byte(5), (0, 0));
442        assert_eq!(idx.line_for_byte(10), (1, 10));
443        assert_eq!(idx.line_for_byte(15), (1, 10));
444        assert_eq!(idx.line_for_byte(20), (2, 20));
445        assert_eq!(idx.line_for_byte(29), (2, 20));
446        // Past last line start: maps to last line index.
447        assert_eq!(idx.line_for_byte(99), (2, 20));
448    }
449
450    #[test]
451    fn is_built_for_detects_key_mismatch() {
452        let idx = idx_with(vec![0, 1], vec![0, 10]);
453        let mut k = idx.key.unwrap();
454        assert!(idx.is_built_for(&k));
455        k.effective_width += 1;
456        assert!(!idx.is_built_for(&k));
457    }
458
459    #[test]
460    fn clear_resets_to_default() {
461        let mut idx = idx_with(vec![0, 1, 2, 3], vec![0, 10, 20, 30]);
462        idx.clear();
463        assert_eq!(idx.total_rows(), 0);
464        assert_eq!(idx.line_count(), 0);
465        assert!(idx.key.is_none());
466    }
467}