From 019222660905467f0e9a64b172b093ca86e25ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Ml=C3=A1dek?= Date: Sun, 25 Jan 2026 00:53:04 +0100 Subject: [PATCH] feat: text cache has LRU eviction, memory cap --- src/text_cache.rs | 101 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 12 deletions(-) diff --git a/src/text_cache.rs b/src/text_cache.rs index a65c1f2..fbb7df1 100644 --- a/src/text_cache.rs +++ b/src/text_cache.rs @@ -23,10 +23,13 @@ const TEXTURE_OPTIONS: TextureOptions = TextureOptions { }; /// Maximum texture dimension to prevent memory issues. -const MAX_TEXTURE_DIM: u32 = 8192; +const MAX_TEXTURE_DIM: u32 = 16_384; -/// Maximum pixel count (16M pixels = 64MB for RGBA). -const MAX_PIXEL_COUNT: usize = 16 * 1024 * 1024; +/// Maximum pixel count (256M pixels ≈ 1 GiB for RGBA). +const MAX_PIXEL_COUNT: usize = 256 * 1024 * 1024; + +/// Default memory cap for the text cache (bytes). +const DEFAULT_CACHE_BYTES: usize = 1_024 * 1_024 * 1_024; // 1 GiB /// A cached rendered text texture. pub struct CachedText { @@ -45,7 +48,16 @@ pub struct TextCache { /// The font used for rendering. font: FontRef<'static>, /// Cached text textures, keyed by (content, `size_key`, render scale). - cache: HashMap<(String, u32, u32), CachedText>, + cache: HashMap<(String, u32, u32), CachedEntry>, + max_bytes: usize, + total_bytes: usize, + usage_clock: u64, +} + +struct CachedEntry { + text: CachedText, + bytes: usize, + last_used: u64, } impl TextCache { @@ -66,6 +78,9 @@ impl TextCache { Self { font, cache: HashMap::new(), + max_bytes: DEFAULT_CACHE_BYTES, + total_bytes: 0, + usage_clock: 0, } } @@ -76,10 +91,7 @@ impl TextCache { /// Returns approximate GPU memory used by cached text textures (bytes). pub fn cache_memory_bytes(&self) -> usize { - self.cache - .values() - .map(|cached| cached.width as usize * cached.height as usize * 4) - .sum() + self.total_bytes } /// Get or create a cached texture for the given text. @@ -101,12 +113,49 @@ impl TextCache { let render_scale_key = Self::render_scale_key(render_scale); let key = (text.to_owned(), size_key, render_scale_key); - if !self.cache.contains_key(&key) { - let cached = self.render_text(ctx, text, nominal_font_size, size_key, render_scale); - self.cache.insert(key.clone(), cached); + if self.cache.contains_key(&key) { + self.usage_clock = self.usage_clock.wrapping_add(1); + if let Some(entry) = self.cache.get_mut(&key) { + entry.last_used = self.usage_clock; + } + return self + .cache + .get(&key) + .map(|entry| &entry.text) + .expect("entry should exist"); } - self.cache.get(&key).expect("just inserted") + let mut cached = self.render_text(ctx, text, nominal_font_size, size_key, render_scale); + let mut bytes = texture_bytes(&cached); + + if bytes > self.max_bytes { + log::warn!( + "Text texture {} bytes exceeds cache cap {}; using empty texture", + bytes, + self.max_bytes + ); + cached = Self::create_empty_texture(ctx, size_key, cached.render_scale); + bytes = texture_bytes(&cached); + } + + // Evict least-recently-used entries until we have room. + self.ensure_capacity(bytes); + + self.usage_clock = self.usage_clock.wrapping_add(1); + self.cache.insert( + key.clone(), + CachedEntry { + text: cached, + bytes, + last_used: self.usage_clock, + }, + ); + self.total_bytes = self.total_bytes.saturating_add(bytes); + + self.cache + .get(&key) + .map(|entry| &entry.text) + .expect("just inserted") } fn pick_render_scale(nominal_font_size: f32, zoom: f32, pixels_per_point: f32) -> f32 { @@ -392,4 +441,32 @@ impl TextCache { render_scale, } } + + fn ensure_capacity(&mut self, incoming_bytes: usize) { + if incoming_bytes > self.max_bytes { + log::warn!( + "Incoming text texture ({incoming_bytes} bytes) exceeds cache cap ({}) - returning empty texture", + self.max_bytes + ); + return; + } + + while self.total_bytes.saturating_add(incoming_bytes) > self.max_bytes { + if let Some((evict_key, evict_entry)) = self + .cache + .iter() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(k, v)| (k.clone(), v.bytes)) + { + self.cache.remove(&evict_key); + self.total_bytes = self.total_bytes.saturating_sub(evict_entry); + } else { + break; + } + } + } +} + +fn texture_bytes(cached: &CachedText) -> usize { + cached.width as usize * cached.height as usize * 4 }