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"); + } }