From c5baa829e3202990c22a2c34b74626fb75e7f457 Mon Sep 17 00:00:00 2001 From: Bryan Mark Date: Fri, 16 Oct 2026 14:26:53 +0900 Subject: [PATCH] Memory: interface fonts are read once and shared with egui, not copied into it egui copies an owned font's bytes into its text engine (epaint's blob_from_font_data clones the Cow) and keeps the original FontData too, so every interface and script fallback font was in memory twice: about 100 MB on a Mac, where the fallbacks alone (Hiragino Sans GB, Hiragino Sans, Apple SD Gothic Neo, Thonburi...) are 89 MB of files. try_load now keeps each file it reads for the rest of the run and hands egui borrowed 'static bytes, which it shares. These faces are loaded by every font rebuild anyway, so keeping them costs nothing, and rebuilds no longer read them from disk again. A missing or broken file is looked for again next time. Which faces load, and their order, are unchanged; workbook fonts (bind_faces) load as before. Measured on macOS 27 (Apple M3 Max), offscreen snapshot held after its picture, settled footprint: empty workbook 258 -> 158 MB, the sample workbook 261 -> 160 MB, a cell in five scripts 258 -> 158 MB; the rendered pictures are byte-for-byte the same. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++- crates/ui-egui/src/theme.rs | 51 ++++++++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..648137851 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** minor (interface fonts held once instead of twice) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 485b9a2f3..e39ba89e3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -209,12 +209,8 @@ pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool { #[cfg(not(target_arch = "wasm32"))] fn try_load(paths: &[(&str, u32)]) -> Option { for (p, index) in paths { - if let Ok(bytes) = std::fs::read(p) { - if !font_parses(&bytes, *index) { - log::warn!("skipping font {p} (face {index}): not a font file egui can read"); - continue; - } - let mut fd = FontData::from_owned(bytes); + if let Some(bytes) = font_file(p, *index) { + let mut fd = FontData::from_static(bytes); fd.index = *index; return Some(fd); } @@ -222,6 +218,30 @@ fn try_load(paths: &[(&str, u32)]) -> Option { None } +/// The interface and script fallback font files, each read once per run and kept for the rest of +/// it. egui copies an owned font's bytes into its text engine and keeps the original too +/// (`epaint`'s `blob_from_font_data`), so every one of these files was in memory twice, about +/// 100 MB on a Mac; bytes it borrows for `'static` it shares instead. These faces are loaded by +/// every font rebuild for as long as the app runs, so keeping them costs nothing, and rebuilds no +/// longer read them from disk again. Only a file that parses is kept: a missing or broken one is +/// looked for again next time. +#[cfg(not(target_arch = "wasm32"))] +fn font_file(path: &str, index: u32) -> Option<&'static [u8]> { + static FILES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); + let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(&bytes) = files.get(path) { + return font_parses(bytes, index).then_some(bytes); + } + let bytes = std::fs::read(path).ok()?; + if !font_parses(&bytes, index) { + log::warn!("skipping font {path} (face {index}): not a font file egui can read"); + return None; + } + let bytes: &'static [u8] = Box::leak(bytes.into_boxed_slice()); + files.insert(path.to_string(), bytes); + Some(bytes) +} + /// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not /// parse (and its key from every family), so a bad font costs a fallback, never a crash. pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) { @@ -934,4 +954,23 @@ mod tests { assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len())); let _ = std::fs::remove_dir_all(&dir); } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn interface_fonts_are_read_once_and_shared_with_egui() { + // egui copies owned font bytes into its text engine and keeps the original too; borrowed + // bytes it shares. A rebuild of the font set reuses the file read the first time. + let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id())); + std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap(); + let p = path.to_string_lossy().into_owned(); + let first = try_load(&[(p.as_str(), 0)]).unwrap(); + std::fs::remove_file(&path).unwrap(); + let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read"); + let (std::borrow::Cow::Borrowed(a), std::borrow::Cow::Borrowed(b)) = (&first.font, &second.font) else { + panic!("interface font bytes must be borrowed, so egui shares them instead of copying"); + }; + assert!(std::ptr::eq(*a, *b)); + assert_eq!(*a, epaint_default_fonts::UBUNTU_LIGHT); + assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused"); + } }