1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
use lru::LruCache;
use rio_backend::sugarloaf::swash::Attributes;
use std::collections::HashMap;
use std::num::NonZeroUsize;
use tracing::debug;
use unicode_width::UnicodeWidthChar;
/// Maximum number of font cache entries to keep in memory
/// Increased for better performance with complex terminal content
const MAX_FONT_CACHE_SIZE: usize = 8192;
/// Font cache data including PUA information
#[derive(Debug, Clone, Copy)]
pub struct FontCacheData {
pub font_id: usize,
pub width: f32,
pub is_pua: bool,
}
/// LRU cache for font metrics to prevent unbounded memory growth
/// Uses a two-tier caching strategy for better performance
pub struct FontCache {
// Hot cache for most frequently used characters (ASCII)
hot_cache: HashMap<(char, Attributes), FontCacheData>,
// LRU cache for less frequent characters
cache: LruCache<(char, Attributes), FontCacheData>,
}
impl FontCache {
pub fn new() -> Self {
Self {
hot_cache: HashMap::with_capacity(128), // ASCII + common chars
cache: LruCache::new(
NonZeroUsize::new(MAX_FONT_CACHE_SIZE)
.expect("Cache size must be non-zero"),
),
}
}
/// Get font metrics from cache with hot path optimization
pub fn get(&mut self, key: &(char, Attributes)) -> Option<&FontCacheData> {
// Check hot cache first for ASCII characters
if key.0.is_ascii() {
if let Some(value) = self.hot_cache.get(key) {
return Some(value);
}
}
// Fall back to LRU cache
let result = self.cache.get(key);
// Log cache miss for debugging
if result.is_none() {
debug!("FontCache miss for char='{}' attrs={:?}", key.0, key.1);
}
result
}
/// Insert font metrics into cache with hot path optimization
pub fn insert(&mut self, key: (char, Attributes), value: FontCacheData) {
// Store ASCII characters in hot cache for faster access
if key.0.is_ascii() && self.hot_cache.len() < 128 {
self.hot_cache.insert(key, value);
} else {
self.cache.put(key, value);
}
}
/// Get current cache size (for debugging/monitoring)
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.hot_cache.len() + self.cache.len()
}
/// Check if cache is empty
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.hot_cache.is_empty() && self.cache.is_empty()
}
/// Clear all cache entries with cleanup
#[allow(dead_code)]
pub fn clear(&mut self) {
self.hot_cache.clear();
self.cache.clear();
}
/// Pre-populate cache with common characters to improve hit rate
/// This should be called during initialization with the font context
pub fn pre_populate(
&mut self,
font_context: &rio_backend::sugarloaf::font::FontLibrary,
) {
let common_chars = [
// ASCII printable characters (most common)
' ', '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.',
'/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=',
'>', '?', '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',
'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[',
'\\', ']', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y',
'z', '{', '|', '}', '~',
];
let common_attrs = [
Attributes::new(
rio_backend::sugarloaf::swash::Stretch::NORMAL,
rio_backend::sugarloaf::swash::Weight::NORMAL,
rio_backend::sugarloaf::swash::Style::Normal,
),
Attributes::new(
rio_backend::sugarloaf::swash::Stretch::NORMAL,
rio_backend::sugarloaf::swash::Weight::BOLD,
rio_backend::sugarloaf::swash::Style::Normal,
),
Attributes::new(
rio_backend::sugarloaf::swash::Stretch::NORMAL,
rio_backend::sugarloaf::swash::Weight::NORMAL,
rio_backend::sugarloaf::swash::Style::Italic,
),
];
if let Some(font_ctx) = font_context.inner.try_read() {
for &ch in &common_chars {
for &attrs in &common_attrs {
let key = (ch, attrs);
if self.get(&key).is_none() {
let style = rio_backend::sugarloaf::SpanStyle {
font_attrs: attrs,
..Default::default()
};
let mut width = ch.width().unwrap_or(1) as f32;
// No route context here — this cache is for
// layout width measurement, not pane rendering.
// PUA codepoints fall through to the regular
// font lookup, which returns a sensible width.
if let Some((font_id, is_emoji)) =
font_ctx.find_best_font_match(ch, &style, None)
{
if is_emoji {
width = 2.0;
}
self.insert(
key,
FontCacheData {
font_id,
width,
is_pua: false, // ASCII chars are never PUA
},
);
}
}
}
}
}
}
}
impl Default for FontCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rio_backend::sugarloaf::swash::{Stretch, Style, Weight};
#[test]
fn test_font_cache_basic_operations() {
let mut cache = FontCache::new();
// Test empty cache
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
// Test insertion and retrieval
let attrs = Attributes::new(Stretch::NORMAL, Weight::NORMAL, Style::Normal);
let key = ('a', attrs);
let value = FontCacheData {
font_id: 1,
width: 1.0,
is_pua: false,
};
cache.insert(key, value);
assert!(!cache.is_empty());
assert_eq!(cache.len(), 1);
let retrieved = cache.get(&key);
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().font_id, 1);
}
#[test]
fn test_font_cache_lru_eviction() {
let mut cache = FontCache::new();
// Fill cache beyond capacity to test LRU eviction
let test_size = 10;
for i in 0..=test_size {
let attrs = Attributes::new(Stretch::NORMAL, Weight::NORMAL, Style::Normal);
let key = (char::from_u32(i as u32 + 65).unwrap_or('A'), attrs);
let value = FontCacheData {
font_id: i,
width: i as f32,
is_pua: false,
};
cache.insert(key, value);
}
// Cache should have all entries since we're under the limit
assert_eq!(cache.len(), test_size + 1);
}
}