1use std::collections::HashMap;
15use std::ops::Range;
16
17use twrite_core::{ConcealedLine, EditorBuffer, Point, StyleSpan, SyntaxHighlighter};
18
19const MAX_CACHED_ROWS: usize = 2048;
22
23#[derive(Debug, Clone)]
25pub struct CachedInput {
26 pub spans: Vec<StyleSpan>,
28 pub concealed: ConcealedLine,
33 pub link_src: Vec<(Range<usize>, String)>,
35 pub allow_wrap: bool,
37}
38
39#[derive(Debug, Clone)]
40struct CachedRow {
41 active: bool,
44 cursor_column: usize,
48 input: CachedInput,
49}
50
51#[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 pub fn new() -> Self {
64 Self::default()
65 }
66
67 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 pub fn stats(&self) -> (u64, u64) {
78 (self.hits, self.misses)
79 }
80
81 pub fn len(&self) -> usize {
83 self.rows.len()
84 }
85
86 pub fn is_empty(&self) -> bool {
88 self.rows.is_empty()
89 }
90
91 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 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 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 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 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 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 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 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}