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, line
5//! 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, 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    input: CachedInput,
45}
46
47/// Viewport input cache keyed by buffer version + highlighter revision.
48#[derive(Debug, Default)]
49pub struct LayoutCache {
50    version: Option<usize>,
51    highlighter_rev: Option<u64>,
52    rows: HashMap<usize, CachedRow>,
53    hits: u64,
54    misses: u64,
55}
56
57impl LayoutCache {
58    /// Creates an empty cache.
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Drops all cached rows and hit/miss counters.
64    pub fn clear(&mut self) {
65        self.rows.clear();
66        self.version = None;
67        self.highlighter_rev = None;
68        self.hits = 0;
69        self.misses = 0;
70    }
71
72    /// Returns `(hits, misses)` since creation or the last [`Self::clear`].
73    pub fn stats(&self) -> (u64, u64) {
74        (self.hits, self.misses)
75    }
76
77    /// Number of rows currently cached.
78    pub fn len(&self) -> usize {
79        self.rows.len()
80    }
81
82    /// Whether the cache holds no rows.
83    pub fn is_empty(&self) -> bool {
84        self.rows.is_empty()
85    }
86
87    /// Returns the cached input for `row`, computing and storing it on miss.
88    ///
89    /// `cursor_row` is hoisted by the caller so `buffer.cursor_point()` (two
90    /// `O(log n)` walks) runs once per frame, not once per row. `line_text`
91    /// must be the raw line *without* trailing `\r\n`, matching prepaint.
92    pub fn cached_input(
93        &mut self,
94        buffer: &EditorBuffer,
95        highlighter: Option<&dyn SyntaxHighlighter>,
96        highlighter_rev: u64,
97        cursor_row: usize,
98        row: usize,
99        line_text: &str,
100    ) -> &CachedInput {
101        let version = buffer.version();
102        if self.version != Some(version) || self.highlighter_rev != Some(highlighter_rev) {
103            self.rows.clear();
104            self.version = Some(version);
105            self.highlighter_rev = Some(highlighter_rev);
106        }
107        let active = row == cursor_row;
108        if let Some(cached) = self.rows.get(&row)
109            && cached.active == active
110        {
111            self.hits += 1;
112            // Re-borrow to satisfy the borrow checker across the counter bump.
113            return &self.rows.get(&row).expect("row present").input;
114        }
115        self.misses += 1;
116        if self.rows.len() >= MAX_CACHED_ROWS {
117            self.rows.clear();
118        }
119        let spans = highlighter
120            .map(|h| h.highlight_line(buffer, row, line_text))
121            .unwrap_or_default();
122        let allow_wrap = highlighter
123            .map(|h| h.should_wrap_line(buffer, row))
124            .unwrap_or(true);
125        let mut concealed = ConcealedLine::build(line_text, &spans);
126        let pads = highlighter
127            .map(|h| h.expand_line(buffer, row, &concealed))
128            .unwrap_or_default();
129        if !pads.is_empty() {
130            concealed = concealed.expanded(&pads);
131        }
132        let link_src = highlighter
133            .map(|h| h.extract_links(buffer, row, line_text))
134            .unwrap_or_default();
135        self.rows.insert(
136            row,
137            CachedRow {
138                active,
139                input: CachedInput {
140                    spans,
141                    concealed,
142                    link_src,
143                    allow_wrap,
144                },
145            },
146        );
147        &self.rows.get(&row).expect("row just inserted").input
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn empty_buffer(lines: usize) -> EditorBuffer {
156        let text = (0..lines)
157            .map(|i| format!("line {i}"))
158            .collect::<Vec<_>>()
159            .join("\n");
160        EditorBuffer::new(&text)
161    }
162
163    #[test]
164    fn second_pass_is_all_hits() {
165        let buf = empty_buffer(50);
166        let mut cache = LayoutCache::new();
167        for row in 0..buf.len_lines() {
168            let line = buf.line_to_string(row);
169            let text = line.trim_end_matches(['\r', '\n']);
170            cache.cached_input(&buf, None, 0, usize::MAX, row, text);
171        }
172        assert_eq!(cache.stats(), (0, 50));
173        for row in 0..buf.len_lines() {
174            let line = buf.line_to_string(row);
175            let text = line.trim_end_matches(['\r', '\n']);
176            cache.cached_input(&buf, None, 0, usize::MAX, row, text);
177        }
178        assert_eq!(cache.stats(), (50, 50));
179        assert_eq!(cache.len(), 50);
180    }
181
182    #[test]
183    fn version_bump_invalidates() {
184        let mut buf = empty_buffer(10);
185        let mut cache = LayoutCache::new();
186        let line = buf.line_to_string(0);
187        let text = line.trim_end_matches(['\r', '\n']).to_string();
188        cache.cached_input(&buf, None, 0, usize::MAX, 0, &text);
189        assert_eq!(cache.stats(), (0, 1));
190        buf.insert("x");
191        let line = buf.line_to_string(0);
192        let text = line.trim_end_matches(['\r', '\n']).to_string();
193        cache.cached_input(&buf, None, 0, usize::MAX, 0, &text);
194        // Epoch change clears rows: stats keep accumulating, row count restarts.
195        assert_eq!(cache.stats(), (0, 2));
196        assert_eq!(cache.len(), 1);
197    }
198
199    #[test]
200    fn cursor_row_flip_recomputes_only_flipped_rows() {
201        let buf = empty_buffer(4);
202        let mut cache = LayoutCache::new();
203        for row in 0..4 {
204            let line = buf.line_to_string(row);
205            let text = line.trim_end_matches(['\r', '\n']).to_string();
206            cache.cached_input(&buf, None, 0, 0, row, &text);
207        }
208        assert_eq!(cache.stats(), (0, 4));
209        // Same cursor row -> all hits.
210        for row in 0..4 {
211            let line = buf.line_to_string(row);
212            let text = line.trim_end_matches(['\r', '\n']).to_string();
213            cache.cached_input(&buf, None, 0, 0, row, &text);
214        }
215        assert_eq!(cache.stats(), (4, 4));
216        // Cursor moves 0 -> 1: rows 0 and 1 miss (active flag flips), 2-3 hit.
217        for row in 0..4 {
218            let line = buf.line_to_string(row);
219            let text = line.trim_end_matches(['\r', '\n']).to_string();
220            cache.cached_input(&buf, None, 0, 1, row, &text);
221        }
222        assert_eq!(cache.stats(), (6, 6));
223    }
224
225    #[test]
226    fn highlighter_rev_bump_invalidates() {
227        let buf = empty_buffer(5);
228        let mut cache = LayoutCache::new();
229        for row in 0..5 {
230            let line = buf.line_to_string(row);
231            let text = line.trim_end_matches(['\r', '\n']).to_string();
232            cache.cached_input(&buf, None, 0, usize::MAX, row, &text);
233        }
234        assert_eq!(cache.len(), 5);
235        let line = buf.line_to_string(0);
236        let text = line.trim_end_matches(['\r', '\n']).to_string();
237        cache.cached_input(&buf, None, 1, usize::MAX, 0, &text);
238        assert_eq!(cache.len(), 1);
239    }
240
241    #[test]
242    fn clear_resets_stats() {
243        let buf = empty_buffer(3);
244        let mut cache = LayoutCache::new();
245        let line = buf.line_to_string(0);
246        let text = line.trim_end_matches(['\r', '\n']).to_string();
247        cache.cached_input(&buf, None, 0, usize::MAX, 0, &text);
248        cache.clear();
249        assert_eq!(cache.stats(), (0, 0));
250        assert!(cache.is_empty());
251    }
252}