Skip to main content

twrite_gpui/
layout_cache.rs

1//! Per-version viewport cache for expensive per-line inputs.
2//!
3//! `highlight_line` (pulldown parse), `ConcealedLine::build`, and `extract_links`
4//! are pure in `(buffer version, highlighter revision, row, active-row flag,
5//! cursor byte column, line text)` but were recomputed for every visible row on every prepaint *and* again
6//! on every hit-test (`offset_for_position`). This cache computes each row once
7//! per epoch and shares it across prepaint, hover, and click paths.
8//!
9//! Deliberately *not* cached here: `shape_text` output (`WrappedLine` is neither
10//! `Clone` nor reconstructible via public GPUI API) and `TextRun`s (depend on the
11//! live selection). Glyph layout itself is already deduped inside GPUI's
12//! `line_layout_cache` across consecutive frames.
13
14use std::collections::HashMap;
15use std::ops::Range;
16
17use twrite_core::{ConcealedLine, EditorBuffer, Point, StyleSpan, SyntaxHighlighter};
18
19/// Upper bound on cached rows; exceeded maps are dropped wholesale (one full
20/// re-parse, no incremental eviction bookkeeping).
21const MAX_CACHED_ROWS: usize = 2048;
22
23/// Owned per-line inputs shared by prepaint and hit-testing.
24#[derive(Debug, Clone)]
25pub struct CachedInput {
26    /// Original (pre-concealment) highlight spans.
27    pub spans: Vec<StyleSpan>,
28    /// Concealed display text, remapped spans, and source/display mapping.
29    ///
30    /// Includes display-only [`twrite_core::DisplayPad`] expansion when the
31    /// highlighter returns any from `expand_line`.
32    pub concealed: ConcealedLine,
33    /// Hyperlink source ranges and URLs from `SyntaxHighlighter::extract_links`.
34    pub link_src: Vec<(Range<usize>, String)>,
35    /// Whether this line may soft-wrap (global `line_wrap` still applies).
36    pub allow_wrap: bool,
37}
38
39#[derive(Debug, Clone)]
40struct CachedRow {
41    /// Whether the row was the cursor row when computed (active lines expose
42    /// markers instead of concealing them, so spans differ).
43    active: bool,
44    /// Cursor byte column the active row was computed with. Concealment on
45    /// the cursor row is word-level, so sliding the cursor along the row
46    /// changes spans without flipping `active`.
47    cursor_column: usize,
48    input: CachedInput,
49}
50
51/// Viewport input cache keyed by buffer version + highlighter revision.
52#[derive(Debug, Default)]
53pub struct LayoutCache {
54    version: Option<usize>,
55    highlighter_rev: Option<u64>,
56    rows: HashMap<usize, CachedRow>,
57    hits: u64,
58    misses: u64,
59}
60
61impl LayoutCache {
62    /// Creates an empty cache.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Drops all cached rows and hit/miss counters.
68    pub fn clear(&mut self) {
69        self.rows.clear();
70        self.version = None;
71        self.highlighter_rev = None;
72        self.hits = 0;
73        self.misses = 0;
74    }
75
76    /// Returns `(hits, misses)` since creation or the last [`Self::clear`].
77    pub fn stats(&self) -> (u64, u64) {
78        (self.hits, self.misses)
79    }
80
81    /// Number of rows currently cached.
82    pub fn len(&self) -> usize {
83        self.rows.len()
84    }
85
86    /// Whether the cache holds no rows.
87    pub fn is_empty(&self) -> bool {
88        self.rows.is_empty()
89    }
90
91    /// Returns the cached input for `row`, computing and storing it on miss.
92    ///
93    /// `cursor` is hoisted by the caller so `buffer.cursor_point()` (two
94    /// `O(log n)` walks) runs once per frame, not once per row. `line_text`
95    /// must be the raw line *without* trailing `\r\n`, matching prepaint.
96    /// Only the cursor row's entry depends on the cursor column: concealment
97    /// there is word-level, so sliding along the row changes spans.
98    pub fn cached_input(
99        &mut self,
100        buffer: &EditorBuffer,
101        highlighter: Option<&dyn SyntaxHighlighter>,
102        highlighter_rev: u64,
103        cursor: Point,
104        row: usize,
105        line_text: &str,
106    ) -> &CachedInput {
107        let version = buffer.version();
108        if self.version != Some(version) || self.highlighter_rev != Some(highlighter_rev) {
109            self.rows.clear();
110            self.version = Some(version);
111            self.highlighter_rev = Some(highlighter_rev);
112        }
113        let active = row == cursor.row;
114        if let Some(cached) = self.rows.get(&row)
115            && cached.active == active
116            && (!active || cached.cursor_column == cursor.column)
117        {
118            self.hits += 1;
119            // Re-borrow to satisfy the borrow checker across the counter bump.
120            return &self.rows.get(&row).expect("row present").input;
121        }
122        self.misses += 1;
123        if self.rows.len() >= MAX_CACHED_ROWS {
124            self.rows.clear();
125        }
126        let spans = highlighter
127            .map(|h| h.highlight_line(buffer, row, line_text))
128            .unwrap_or_default();
129        let allow_wrap = highlighter
130            .map(|h| h.should_wrap_line(buffer, row))
131            .unwrap_or(true);
132        let mut concealed = ConcealedLine::build(line_text, &spans);
133        let pads = highlighter
134            .map(|h| h.expand_line(buffer, row, &concealed))
135            .unwrap_or_default();
136        if !pads.is_empty() {
137            concealed = concealed.expanded(&pads);
138        }
139        let link_src = highlighter
140            .map(|h| h.extract_links(buffer, row, line_text))
141            .unwrap_or_default();
142        self.rows.insert(
143            row,
144            CachedRow {
145                active,
146                cursor_column: cursor.column,
147                input: CachedInput {
148                    spans,
149                    concealed,
150                    link_src,
151                    allow_wrap,
152                },
153            },
154        );
155        &self.rows.get(&row).expect("row just inserted").input
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn empty_buffer(lines: usize) -> EditorBuffer {
164        let text = (0..lines)
165            .map(|i| format!("line {i}"))
166            .collect::<Vec<_>>()
167            .join("\n");
168        EditorBuffer::new(&text)
169    }
170
171    #[test]
172    fn second_pass_is_all_hits() {
173        let buf = empty_buffer(50);
174        let mut cache = LayoutCache::new();
175        for row in 0..buf.len_lines() {
176            let line = buf.line_to_string(row);
177            let text = line.trim_end_matches(['\r', '\n']);
178            cache.cached_input(&buf, None, 0, Point::new(usize::MAX, 0), row, text);
179        }
180        assert_eq!(cache.stats(), (0, 50));
181        for row in 0..buf.len_lines() {
182            let line = buf.line_to_string(row);
183            let text = line.trim_end_matches(['\r', '\n']);
184            cache.cached_input(&buf, None, 0, Point::new(usize::MAX, 0), row, text);
185        }
186        assert_eq!(cache.stats(), (50, 50));
187        assert_eq!(cache.len(), 50);
188    }
189
190    #[test]
191    fn version_bump_invalidates() {
192        let mut buf = empty_buffer(10);
193        let mut cache = LayoutCache::new();
194        let line = buf.line_to_string(0);
195        let text = line.trim_end_matches(['\r', '\n']).to_string();
196        cache.cached_input(&buf, None, 0, Point::new(usize::MAX, 0), 0, &text);
197        assert_eq!(cache.stats(), (0, 1));
198        buf.insert("x");
199        let line = buf.line_to_string(0);
200        let text = line.trim_end_matches(['\r', '\n']).to_string();
201        cache.cached_input(&buf, None, 0, Point::new(usize::MAX, 0), 0, &text);
202        // Epoch change clears rows: stats keep accumulating, row count restarts.
203        assert_eq!(cache.stats(), (0, 2));
204        assert_eq!(cache.len(), 1);
205    }
206
207    #[test]
208    fn cursor_row_flip_recomputes_only_flipped_rows() {
209        let buf = empty_buffer(4);
210        let mut cache = LayoutCache::new();
211        for row in 0..4 {
212            let line = buf.line_to_string(row);
213            let text = line.trim_end_matches(['\r', '\n']).to_string();
214            cache.cached_input(&buf, None, 0, Point::new(0, 0), row, &text);
215        }
216        assert_eq!(cache.stats(), (0, 4));
217        // Same cursor row -> all hits.
218        for row in 0..4 {
219            let line = buf.line_to_string(row);
220            let text = line.trim_end_matches(['\r', '\n']).to_string();
221            cache.cached_input(&buf, None, 0, Point::new(0, 0), row, &text);
222        }
223        assert_eq!(cache.stats(), (4, 4));
224        // Cursor moves 0 -> 1: rows 0 and 1 miss (active flag flips), 2-3 hit.
225        for row in 0..4 {
226            let line = buf.line_to_string(row);
227            let text = line.trim_end_matches(['\r', '\n']).to_string();
228            cache.cached_input(&buf, None, 0, Point::new(1, 0), row, &text);
229        }
230        assert_eq!(cache.stats(), (6, 6));
231    }
232
233    #[test]
234    fn highlighter_rev_bump_invalidates() {
235        let buf = empty_buffer(5);
236        let mut cache = LayoutCache::new();
237        for row in 0..5 {
238            let line = buf.line_to_string(row);
239            let text = line.trim_end_matches(['\r', '\n']).to_string();
240            cache.cached_input(&buf, None, 0, Point::new(usize::MAX, 0), row, &text);
241        }
242        assert_eq!(cache.len(), 5);
243        let line = buf.line_to_string(0);
244        let text = line.trim_end_matches(['\r', '\n']).to_string();
245        cache.cached_input(&buf, None, 1, Point::new(usize::MAX, 0), 0, &text);
246        assert_eq!(cache.len(), 1);
247    }
248
249    #[test]
250    fn clear_resets_stats() {
251        let buf = empty_buffer(3);
252        let mut cache = LayoutCache::new();
253        let line = buf.line_to_string(0);
254        let text = line.trim_end_matches(['\r', '\n']).to_string();
255        cache.cached_input(&buf, None, 0, Point::new(usize::MAX, 0), 0, &text);
256        cache.clear();
257        assert_eq!(cache.stats(), (0, 0));
258        assert!(cache.is_empty());
259    }
260
261    #[test]
262    fn cursor_column_slide_recomputes_only_active_row() {
263        let buf = empty_buffer(4);
264        let mut cache = LayoutCache::new();
265        // Cursor on row 0, column 0.
266        for row in 0..4 {
267            let line = buf.line_to_string(row);
268            let text = line.trim_end_matches(['\r', '\n']).to_string();
269            cache.cached_input(&buf, None, 0, Point::new(0, 0), row, &text);
270        }
271        assert_eq!(cache.stats(), (0, 4));
272        // Same column -> all hits.
273        for row in 0..4 {
274            let line = buf.line_to_string(row);
275            let text = line.trim_end_matches(['\r', '\n']).to_string();
276            cache.cached_input(&buf, None, 0, Point::new(0, 0), row, &text);
277        }
278        assert_eq!(cache.stats(), (4, 4));
279        // Column slides 0 -> 5: only row 0 (active) misses, rows 1-3 hit.
280        for row in 0..4 {
281            let line = buf.line_to_string(row);
282            let text = line.trim_end_matches(['\r', '\n']).to_string();
283            cache.cached_input(&buf, None, 0, Point::new(0, 5), row, &text);
284        }
285        assert_eq!(cache.stats(), (7, 5));
286    }
287}