use std::collections::HashMap;
use std::ops::Range;
use twrite_core::{ConcealedLine, EditorBuffer, StyleSpan, SyntaxHighlighter};
const MAX_CACHED_ROWS: usize = 2048;
#[derive(Debug, Clone)]
pub struct CachedInput {
pub spans: Vec<StyleSpan>,
pub concealed: ConcealedLine,
pub link_src: Vec<(Range<usize>, String)>,
pub allow_wrap: bool,
}
#[derive(Debug, Clone)]
struct CachedRow {
active: bool,
input: CachedInput,
}
#[derive(Debug, Default)]
pub struct LayoutCache {
version: Option<usize>,
highlighter_rev: Option<u64>,
rows: HashMap<usize, CachedRow>,
hits: u64,
misses: u64,
}
impl LayoutCache {
pub fn new() -> Self {
Self::default()
}
pub fn clear(&mut self) {
self.rows.clear();
self.version = None;
self.highlighter_rev = None;
self.hits = 0;
self.misses = 0;
}
pub fn stats(&self) -> (u64, u64) {
(self.hits, self.misses)
}
pub fn len(&self) -> usize {
self.rows.len()
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
pub fn cached_input(
&mut self,
buffer: &EditorBuffer,
highlighter: Option<&dyn SyntaxHighlighter>,
highlighter_rev: u64,
cursor_row: usize,
row: usize,
line_text: &str,
) -> &CachedInput {
let version = buffer.version();
if self.version != Some(version) || self.highlighter_rev != Some(highlighter_rev) {
self.rows.clear();
self.version = Some(version);
self.highlighter_rev = Some(highlighter_rev);
}
let active = row == cursor_row;
if let Some(cached) = self.rows.get(&row)
&& cached.active == active
{
self.hits += 1;
return &self.rows.get(&row).expect("row present").input;
}
self.misses += 1;
if self.rows.len() >= MAX_CACHED_ROWS {
self.rows.clear();
}
let spans = highlighter
.map(|h| h.highlight_line(buffer, row, line_text))
.unwrap_or_default();
let allow_wrap = highlighter
.map(|h| h.should_wrap_line(buffer, row))
.unwrap_or(true);
let mut concealed = ConcealedLine::build(line_text, &spans);
let pads = highlighter
.map(|h| h.expand_line(buffer, row, &concealed))
.unwrap_or_default();
if !pads.is_empty() {
concealed = concealed.expanded(&pads);
}
let link_src = highlighter
.map(|h| h.extract_links(buffer, row, line_text))
.unwrap_or_default();
self.rows.insert(
row,
CachedRow {
active,
input: CachedInput {
spans,
concealed,
link_src,
allow_wrap,
},
},
);
&self.rows.get(&row).expect("row just inserted").input
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_buffer(lines: usize) -> EditorBuffer {
let text = (0..lines)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
EditorBuffer::new(&text)
}
#[test]
fn second_pass_is_all_hits() {
let buf = empty_buffer(50);
let mut cache = LayoutCache::new();
for row in 0..buf.len_lines() {
let line = buf.line_to_string(row);
let text = line.trim_end_matches(['\r', '\n']);
cache.cached_input(&buf, None, 0, usize::MAX, row, text);
}
assert_eq!(cache.stats(), (0, 50));
for row in 0..buf.len_lines() {
let line = buf.line_to_string(row);
let text = line.trim_end_matches(['\r', '\n']);
cache.cached_input(&buf, None, 0, usize::MAX, row, text);
}
assert_eq!(cache.stats(), (50, 50));
assert_eq!(cache.len(), 50);
}
#[test]
fn version_bump_invalidates() {
let mut buf = empty_buffer(10);
let mut cache = LayoutCache::new();
let line = buf.line_to_string(0);
let text = line.trim_end_matches(['\r', '\n']).to_string();
cache.cached_input(&buf, None, 0, usize::MAX, 0, &text);
assert_eq!(cache.stats(), (0, 1));
buf.insert("x");
let line = buf.line_to_string(0);
let text = line.trim_end_matches(['\r', '\n']).to_string();
cache.cached_input(&buf, None, 0, usize::MAX, 0, &text);
assert_eq!(cache.stats(), (0, 2));
assert_eq!(cache.len(), 1);
}
#[test]
fn cursor_row_flip_recomputes_only_flipped_rows() {
let buf = empty_buffer(4);
let mut cache = LayoutCache::new();
for row in 0..4 {
let line = buf.line_to_string(row);
let text = line.trim_end_matches(['\r', '\n']).to_string();
cache.cached_input(&buf, None, 0, 0, row, &text);
}
assert_eq!(cache.stats(), (0, 4));
for row in 0..4 {
let line = buf.line_to_string(row);
let text = line.trim_end_matches(['\r', '\n']).to_string();
cache.cached_input(&buf, None, 0, 0, row, &text);
}
assert_eq!(cache.stats(), (4, 4));
for row in 0..4 {
let line = buf.line_to_string(row);
let text = line.trim_end_matches(['\r', '\n']).to_string();
cache.cached_input(&buf, None, 0, 1, row, &text);
}
assert_eq!(cache.stats(), (6, 6));
}
#[test]
fn highlighter_rev_bump_invalidates() {
let buf = empty_buffer(5);
let mut cache = LayoutCache::new();
for row in 0..5 {
let line = buf.line_to_string(row);
let text = line.trim_end_matches(['\r', '\n']).to_string();
cache.cached_input(&buf, None, 0, usize::MAX, row, &text);
}
assert_eq!(cache.len(), 5);
let line = buf.line_to_string(0);
let text = line.trim_end_matches(['\r', '\n']).to_string();
cache.cached_input(&buf, None, 1, usize::MAX, 0, &text);
assert_eq!(cache.len(), 1);
}
#[test]
fn clear_resets_stats() {
let buf = empty_buffer(3);
let mut cache = LayoutCache::new();
let line = buf.line_to_string(0);
let text = line.trim_end_matches(['\r', '\n']).to_string();
cache.cached_input(&buf, None, 0, usize::MAX, 0, &text);
cache.clear();
assert_eq!(cache.stats(), (0, 0));
assert!(cache.is_empty());
}
}