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