use std::collections::HashMap;
use std::collections::hash_map::Entry as MapEntry;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use egui::{Galley, Mesh, text::LayoutJob};
pub fn hash_of(value: &impl Hash) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut h);
h.finish()
}
struct Entry<V> {
revision: u64,
last_used: u64,
value: V,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Stats {
pub hits: u64,
pub misses: u64,
}
pub struct RevisionCache<K, V> {
entries: HashMap<K, Entry<V>>,
frame: u64,
keep_frames: u64,
max_entries: usize,
stats: Stats,
}
impl<K: Eq + Hash, V> RevisionCache<K, V> {
pub fn new(keep_frames: u64, max_entries: usize) -> Self {
Self { entries: HashMap::new(), frame: 0, keep_frames, max_entries: max_entries.max(1), stats: Stats::default() }
}
pub fn get(&mut self, key: &K, revision: u64) -> Option<&V> {
let frame = self.frame;
match self.entries.get_mut(key) {
Some(e) if e.revision == revision => {
e.last_used = frame;
self.stats.hits += 1;
Some(&e.value)
}
_ => None,
}
}
pub fn get_or_insert_with(&mut self, key: K, revision: u64, make: impl FnOnce() -> V) -> &V {
let frame = self.frame;
if !self.entries.contains_key(&key) && self.entries.len() >= self.max_entries {
self.evict_older_than(frame);
}
match self.entries.entry(key) {
MapEntry::Occupied(o) => {
let e = o.into_mut();
if e.revision == revision {
self.stats.hits += 1;
} else {
self.stats.misses += 1;
e.value = make();
e.revision = revision;
}
e.last_used = frame;
&e.value
}
MapEntry::Vacant(v) => {
self.stats.misses += 1;
&v.insert(Entry { revision, last_used: frame, value: make() }).value
}
}
}
pub fn end_frame(&mut self) {
self.frame = self.frame.wrapping_add(1);
let oldest = self.frame.saturating_sub(self.keep_frames);
if self.frame.is_multiple_of(self.keep_frames.clamp(1, 64)) {
self.evict_older_than(oldest);
}
}
fn evict_older_than(&mut self, frame: u64) {
self.entries.retain(|_, e| e.last_used >= frame);
if self.entries.len() >= self.max_entries {
self.entries.clear();
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn clear(&mut self) {
self.entries.clear();
}
pub fn stats(&self) -> Stats {
self.stats
}
pub fn reset_stats(&mut self) {
self.stats = Stats::default();
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct FontEpoch {
pixels_per_point: f32,
image_size: [usize; 2],
faces: usize,
}
impl FontEpoch {
fn read(ctx: &egui::Context) -> Self {
let pixels_per_point = ctx.pixels_per_point();
ctx.fonts(|f| FontEpoch { pixels_per_point, image_size: f.font_image_size(), faces: f.definitions().font_data.len() })
}
fn still_valid(self, now: FontEpoch) -> bool {
self.pixels_per_point == now.pixels_per_point
&& self.faces == now.faces
&& now.image_size[0] >= self.image_size[0]
&& now.image_size[1] >= self.image_size[1]
}
}
fn layout(ctx: &egui::Context, job: impl FnOnce() -> LayoutJob) -> Arc<Galley> {
let job = job();
ctx.fonts_mut(|f| f.layout_job(job))
}
pub struct GalleyCache {
cache: RevisionCache<u64, Arc<Galley>>,
epoch: Option<FontEpoch>,
pass: u64,
}
impl Default for GalleyCache {
fn default() -> Self {
Self::new(120, 8192)
}
}
impl GalleyCache {
pub fn new(keep_frames: u64, max_entries: usize) -> Self {
Self { cache: RevisionCache::new(keep_frames, max_entries), epoch: None, pass: u64::MAX }
}
fn begin(&mut self, ctx: &egui::Context) {
let pass = ctx.cumulative_pass_nr();
if pass == self.pass {
return;
}
if self.pass != u64::MAX {
self.cache.end_frame();
}
self.pass = pass;
let now = FontEpoch::read(ctx);
if !self.epoch.is_some_and(|e| e.still_valid(now)) {
self.cache.clear();
}
self.epoch = Some(now);
}
pub fn galley(&mut self, ctx: &egui::Context, key: u64, revision: u64, job: impl FnOnce() -> LayoutJob) -> Arc<Galley> {
if let Some(g) = self.lookup(ctx, key, revision) {
return g;
}
let galley = layout(ctx, job);
self.insert(key, revision, Arc::clone(&galley));
galley
}
pub fn lookup(&mut self, ctx: &egui::Context, key: u64, revision: u64) -> Option<Arc<Galley>> {
self.begin(ctx);
self.cache.get(&key, revision).map(Arc::clone)
}
pub fn insert(&mut self, key: u64, revision: u64, galley: Arc<Galley>) {
self.cache.get_or_insert_with(key, revision, || galley);
}
pub fn stats(&self) -> Stats {
self.cache.stats()
}
pub fn len(&self) -> usize {
self.cache.len()
}
pub fn is_empty(&self) -> bool {
self.cache.is_empty()
}
}
pub struct MeshCache {
cache: RevisionCache<u64, Arc<Mesh>>,
}
impl Default for MeshCache {
fn default() -> Self {
Self { cache: RevisionCache::new(120, 4096) }
}
}
impl MeshCache {
pub fn new(keep_frames: u64, max_entries: usize) -> Self {
Self { cache: RevisionCache::new(keep_frames, max_entries) }
}
pub fn mesh(&mut self, key: u64, revision: u64, make: impl FnOnce() -> Mesh) -> Arc<Mesh> {
Arc::clone(self.cache.get_or_insert_with(key, revision, || Arc::new(make())))
}
pub fn paint(&mut self, painter: &egui::Painter, key: u64, revision: u64, make: impl FnOnce() -> Mesh) {
painter.add(egui::Shape::Mesh(self.mesh(key, revision, make)));
}
pub fn end_frame(&mut self) {
self.cache.end_frame();
}
pub fn stats(&self) -> Stats {
self.cache.stats()
}
}
#[derive(Clone, Default)]
struct Shared(Arc<egui::mutex::Mutex<GalleyCache>>);
fn shared(ctx: &egui::Context) -> Shared {
ctx.data_mut(|d| d.get_temp_mut_or_default::<Shared>(egui::Id::new("ashlar_ui::cache::galleys")).clone())
}
pub fn galley(ui: &egui::Ui, key: impl Hash, revision: u64, job: impl FnOnce() -> LayoutJob) -> Arc<Galley> {
galley_in(ui.ctx(), hash_of(&key), revision, job)
}
pub fn galley_in(ctx: &egui::Context, key: u64, revision: u64, job: impl FnOnce() -> LayoutJob) -> Arc<Galley> {
let cache = shared(ctx);
if let Some(g) = cache.0.lock().lookup(ctx, key, revision) {
return g;
}
let galley = layout(ctx, job);
cache.0.lock().insert(key, revision, Arc::clone(&galley));
galley
}
pub fn galley_stats(ctx: &egui::Context) -> Stats {
shared(ctx).0.lock().stats()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::run;
#[test]
fn revision_cache_rebuilds_only_on_revision_change_and_evicts_idle_entries() {
let mut c: RevisionCache<u32, String> = RevisionCache::new(4, 100);
let mut builds = 0;
for _ in 0..3 {
c.get_or_insert_with(1, 10, || {
builds += 1;
"a".into()
});
c.end_frame();
}
assert_eq!(builds, 1);
c.get_or_insert_with(1, 11, || {
builds += 1;
"b".into()
});
assert_eq!(builds, 2);
assert_eq!(c.get(&1, 11).map(String::as_str), Some("b"));
assert_eq!(c.get(&1, 10), None);
c.get_or_insert_with(2, 0, || "idle".into());
for _ in 0..16 {
c.get_or_insert_with(1, 11, unreachable_build);
c.end_frame();
}
assert_eq!(c.len(), 1, "key 2 was idle and got evicted");
assert_eq!(c.stats().misses, 3);
}
fn unreachable_build() -> String {
String::from("rebuilt")
}
#[test]
fn revision_cache_stays_bounded() {
let mut c: RevisionCache<u32, u32> = RevisionCache::new(1000, 8);
for i in 0..100 {
c.get_or_insert_with(i, 0, || i);
}
assert!(c.len() <= 8);
}
#[test]
fn galleys_are_reused_across_frames_and_rebuilt_on_scale_change() {
let ctx = egui::Context::default();
let job = || LayoutJob::simple_singleline("cached label".into(), egui::FontId::proportional(13.0), egui::Color32::WHITE);
let mut first = None;
for _ in 0..3 {
run(&ctx, egui::RawInput::default(), |ui| {
let g = galley(ui, "label", 1, job);
if let Some(prev) = &first {
assert!(Arc::ptr_eq(prev, &g));
}
first = Some(g);
});
}
let s = galley_stats(&ctx);
assert_eq!((s.hits, s.misses), (2, 1));
ctx.set_pixels_per_point(2.0);
run(&ctx, egui::RawInput::default(), |_| {});
run(&ctx, egui::RawInput::default(), |ui| {
let g = galley(ui, "label", 1, job);
assert!(!Arc::ptr_eq(first.as_ref().unwrap(), &g), "relaid out for the new scale");
assert_eq!(g.pixels_per_point, 2.0);
});
}
#[test]
fn a_job_may_measure_text_and_use_the_cache_itself() {
let ctx = egui::Context::default();
run(&ctx, egui::RawInput::default(), |ui| {
let outer = galley(ui, "outer", 1, || {
let inner =
galley(ui, "inner", 1, || LayoutJob::simple_singleline("in".into(), egui::FontId::proportional(13.0), egui::Color32::WHITE));
let w = ui.ctx().fonts_mut(|f| f.glyph_width(&egui::FontId::proportional(13.0), 'x'));
LayoutJob::simple_singleline(format!("{} {w}", inner.text()), egui::FontId::proportional(13.0), egui::Color32::WHITE)
});
assert!(outer.text().starts_with("in "));
});
}
#[test]
fn mesh_cache_shares_the_mesh() {
let mut m = MeshCache::default();
let a = m.mesh(1, 1, Mesh::default);
let b = m.mesh(1, 1, panic_free_mesh);
assert!(Arc::ptr_eq(&a, &b));
let c = m.mesh(1, 2, Mesh::default);
assert!(!Arc::ptr_eq(&a, &c));
}
fn panic_free_mesh() -> Mesh {
let mut mesh = Mesh::default();
mesh.add_colored_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::Vec2::splat(1.0)), egui::Color32::RED);
mesh
}
}