GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(text): rescan_fonts picks up fonts installed or removed since launch
A FontSystem's database was the disk as it stood at startup: a font picker
that relisted on refresh only relisted what it already had, so cce-fonts
showed a newly installed font only after a restart -- and rebuilding the
app's database alone would not do, since the renderer's is separate.
`rescan_fonts(&mut fs)` scans the font dirs the way `build_font_system`
does and records what changed against `fs`. The change rides the face-alias
log, now a general ordered font-op log: every FontSystem replays it in one
order, so databases loaded alike keep giving every face -- the new ones and
the aliases either side of them -- the same ID, which the shared shaped-
buffer cache depends on. New faces take fresh slotmap IDs, so no glyph
cache is invalidated; a removed file's faces go (its aliases stay, as sync
markers); a bundled-only database takes no system file; and a database
built after the rescan replays it as a no-op. `sync_font_set` brings a
database up to date before it is read directly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/backend/text.rs | 317 +++++++++++++++++++++++++++++++++++++++++-----------
src/lib.rs | 49 ++++++++
2 files changed, 302 insertions(+), 64 deletions(-)
diff --git a/src/backend/text.rs b/src/backend/text.rs
index 47b524c..f414ea2 100644
--- a/src/backend/text.rs
+++ b/src/backend/text.rs
@@ -146,26 +146,66 @@ fn buffer_cache_put(text: &str, key: &BufferKey<'_>, buffer: Rc<Buffer>, voff: f
const FACE_ALIAS_PREFIX: &str = "cce-face:";
/// One [`face_family`] alias: the face at `path`#`index`, named `cce-face:<n>`
-/// for its place `n` in [`FACE_ALIASES`].
+/// for its place `n` in [`FONT_OPS`].
struct FaceAlias {
path: std::path::PathBuf,
index: u32,
}
-/// Every face alias handed out, in the order they were. Process-wide, and only
-/// ever appended to: each `FontSystem` pushes them into its database in this
-/// order (see [`sync_face_aliases`]), so two databases loaded alike give every
-/// alias the same fontdb ID. They must — the shaped-buffer cache is shared by
+/// A font-directory rescan ([`crate::rescan_fonts`]): what changed on disk,
+/// as fixed lists rather than a scan each database makes for itself, so every
+/// database applies the same change however long after the others. A change,
+/// not the whole set: a database built after the rescan already has it, and
+/// replaying it there is a no-op rather than a drop of whatever arrived since.
+#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // only `rescan_fonts` makes one, and the browser has no font dirs
+pub(crate) struct Rescan {
+ /// Font files new on disk, in a system-fonts build's load order.
+ added: Vec<std::path::PathBuf>,
+ /// Font files gone from disk.
+ removed: std::collections::HashSet<std::path::PathBuf>,
+ /// The files a bundled-only build loads ([`crate::create_font_system`]):
+ /// the CCE fonts dir and the fallback faces.
+ bundled: std::collections::HashSet<std::path::PathBuf>,
+}
+
+#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // only `rescan_fonts` makes one, and the browser has no font dirs
+impl Rescan {
+ pub(crate) fn new(
+ added: Vec<std::path::PathBuf>,
+ removed: std::collections::HashSet<std::path::PathBuf>,
+ bundled: std::collections::HashSet<std::path::PathBuf>,
+ ) -> Self {
+ Rescan { added, removed, bundled }
+ }
+
+ pub(crate) fn is_empty(&self) -> bool {
+ self.added.is_empty() && self.removed.is_empty()
+ }
+}
+
+/// A change every `FontSystem` makes to its database, in one order.
+#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // only `rescan_fonts` makes one, and the browser has no font dirs
+enum FontOp {
+ Alias(FaceAlias),
+ Rescan(std::sync::Arc<Rescan>),
+}
+
+/// Every change to the font set, in the order made. Process-wide, and only
+/// ever appended to: each `FontSystem` replays it into its database in this
+/// order (see [`sync_font_ops`]), so two databases loaded alike give every
+/// face the same fontdb ID. They must — the shaped-buffer cache is shared by
/// every `FontSystem` on the thread, and a buffer the app's measuring system
-/// shaped is rasterized by the engine's with the face IDs it carries.
-static FACE_ALIASES: std::sync::Mutex<Vec<FaceAlias>> = std::sync::Mutex::new(Vec::new());
+/// shaped is rasterized by the engine's with the face IDs it carries. A
+/// rescan rides the same log for that reason: applied at a different point
+/// relative to the aliases in two databases, it would number them apart.
+static FONT_OPS: std::sync::Mutex<Vec<FontOp>> = std::sync::Mutex::new(Vec::new());
thread_local! {
- /// Database address → how many of [`FACE_ALIASES`] it has been synced
- /// with, and the ID and alias number of the last face pushed — checked
- /// before it is trusted, since a dropped `FontSystem`'s address can be
- /// reused by a fresh one.
- static FACE_ALIASES_SYNCED: std::cell::RefCell<
+ /// Database address → how many of [`FONT_OPS`] it has applied, and the ID
+ /// and op number of the last alias it pushed — checked before it is
+ /// trusted, since a dropped `FontSystem`'s address can be reused by a
+ /// fresh one.
+ static FONT_OPS_SYNCED: std::cell::RefCell<
std::collections::HashMap<usize, (usize, Option<(cosmic_text::fontdb::ID, usize)>)>,
> = std::cell::RefCell::new(std::collections::HashMap::new());
}
@@ -188,75 +228,145 @@ thread_local! {
/// falls back as an unknown family's would.
pub fn face_family(path: impl AsRef<std::path::Path>, index: u32) -> String {
let path = path.as_ref();
- let mut aliases = FACE_ALIASES.lock().unwrap_or_else(|e| e.into_inner());
- let n = match aliases.iter().position(|a| a.path == path && a.index == index) {
+ let mut ops = FONT_OPS.lock().unwrap_or_else(|e| e.into_inner());
+ let n = match ops.iter().position(|op| matches!(op, FontOp::Alias(a) if a.path == path && a.index == index)) {
Some(n) => n,
None => {
- aliases.push(FaceAlias { path: path.to_path_buf(), index });
- aliases.len() - 1
+ ops.push(FontOp::Alias(FaceAlias { path: path.to_path_buf(), index }));
+ ops.len() - 1
}
};
format!("{FACE_ALIAS_PREFIX}{n}")
}
-/// Push the [`face_family`] aliases `fs` does not have yet into its database,
-/// in their order. Cheap when there is nothing new: a map lookup and one face
-/// check.
-fn sync_face_aliases(fs: &mut FontSystem) {
+/// Record a rescan for every `FontSystem` to apply (see [`FontOp`]), and drop
+/// this thread's caches that a family appearing or vanishing makes stale:
+/// the shaped buffers (a family that fell back before resolves now, and a
+/// removed face's ID must not reach the glyph pass) and the per-family face
+/// lookups.
+#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // only `rescan_fonts` makes one, and the browser has no font dirs
+pub(crate) fn push_rescan(rescan: Rescan) {
+ FONT_OPS.lock().unwrap_or_else(|e| e.into_inner()).push(FontOp::Rescan(std::sync::Arc::new(rescan)));
+ BUFFER_CACHE.with(|c| c.borrow_mut().clear());
+ BUFFER_COUNT.with(|c| c.set(0));
+ MONO_FAMILY_CACHE.with(|c| c.borrow_mut().clear());
+ FAMILY_FACES_CACHE.with(|c| c.borrow_mut().clear());
+}
+
+/// Bring `fs` up to date with every font-set change made so far — the face
+/// aliases and the rescans ([`crate::rescan_fonts`]). The text backend does
+/// this itself before it shapes or draws; call it before reading a database
+/// directly (a font picker listing its faces) after a rescan.
+pub fn sync_font_set(fs: &mut FontSystem) {
+ sync_font_ops(fs);
+}
+
+pub(crate) fn is_alias_face(face: &cosmic_text::fontdb::FaceInfo) -> bool {
+ face.families.first().is_some_and(|(fam, _)| fam.starts_with(FACE_ALIAS_PREFIX))
+}
+
+/// Replay the [`FONT_OPS`] `fs` has not applied yet into its database, in
+/// their order. Cheap when there is nothing new: a map lookup and one face
+/// check. Every op is idempotent, so a database the record cannot vouch for
+/// (never seen, or a reused address) replays the whole log safely.
+fn sync_font_ops(fs: &mut FontSystem) {
+ let ops = FONT_OPS.lock().unwrap_or_else(|e| e.into_inner());
+ replay_font_ops(fs, &ops);
+}
+
+/// [`sync_font_ops`] against a given log (the tests keep their own, so their
+/// rescans reach no other test's databases).
+fn replay_font_ops(fs: &mut FontSystem, ops: &[FontOp]) {
use cosmic_text::fontdb::{Language, Source};
- let aliases = FACE_ALIASES.lock().unwrap_or_else(|e| e.into_inner());
- if aliases.is_empty() {
+ if ops.is_empty() {
return;
}
let key = fs.db() as *const cosmic_text::fontdb::Database as usize;
- let is_alias = |id: cosmic_text::fontdb::ID, n: usize| {
- let name = format!("{FACE_ALIAS_PREFIX}{n}");
- fs.db().face(id).is_some_and(|f| f.families.first().is_some_and(|(fam, _)| *fam == name))
+ let alias_name = |n: usize| format!("{FACE_ALIAS_PREFIX}{n}");
+ let is_alias = |fs: &FontSystem, id: cosmic_text::fontdb::ID, n: usize| {
+ fs.db().face(id).is_some_and(|f| f.families.first().is_some_and(|(fam, _)| *fam == alias_name(n)))
};
- let synced = FACE_ALIASES_SYNCED.with(|m| m.borrow().get(&key).copied());
+ let synced = FONT_OPS_SYNCED.with(|m| m.borrow().get(&key).copied());
let (mut done, mut last) = match synced {
- Some((done, last)) if last.map_or(done == 0, |(id, n)| is_alias(id, n)) => (done, last),
- // Unknown (or a reused address): count the aliases already present.
- _ => {
- let present: std::collections::HashSet<&str> = fs
- .db()
- .faces()
- .filter_map(|f| f.families.first().map(|(fam, _)| fam.as_str()))
- .filter(|fam| fam.starts_with(FACE_ALIAS_PREFIX))
- .collect();
- let done = (0..aliases.len()).take_while(|n| present.contains(format!("{FACE_ALIAS_PREFIX}{n}").as_str())).count();
- let last = done.checked_sub(1).and_then(|n| {
- let name = format!("{FACE_ALIAS_PREFIX}{n}");
- fs.db().faces().find(|f| f.families.first().is_some_and(|(fam, _)| *fam == name)).map(|f| (f.id, n))
- });
- (done, last)
- }
+ Some((done, last)) if last.map_or(true, |(id, n)| is_alias(fs, id, n)) => (done, last),
+ _ => (0, None),
};
- if done == aliases.len() && synced.is_some() {
+ if done == ops.len() {
return;
}
- for (n, alias) in aliases.iter().enumerate().skip(done) {
- let source = fs
- .db()
- .faces()
- .find(|f| {
- f.index == alias.index
- && matches!(&f.source, Source::File(p) | Source::SharedFile(p, _) if *p == alias.path)
- })
- .cloned();
- if let Some(mut face) = source {
- let name = format!("{FACE_ALIAS_PREFIX}{n}");
- face.families = vec![(name.clone(), Language::English_UnitedStates)];
- fs.db_mut().push_face_info(face);
- last = fs
- .db()
- .faces()
- .find(|f| f.families.first().is_some_and(|(fam, _)| *fam == name))
- .map(|f| (f.id, n));
+ // Replaying from the start: the aliases already present, so none is
+ // pushed twice. (Past a trusted count none can be.)
+ let mut present: std::collections::HashMap<String, cosmic_text::fontdb::ID> = if done == 0 {
+ fs.db().faces().filter(|f| is_alias_face(f)).map(|f| (f.families[0].0.clone(), f.id)).collect()
+ } else {
+ Default::default()
+ };
+ for (n, op) in ops.iter().enumerate().skip(done) {
+ match op {
+ FontOp::Alias(alias) => {
+ let name = alias_name(n);
+ if let Some(&id) = present.get(&name) {
+ last = Some((id, n));
+ } else {
+ let source = fs
+ .db()
+ .faces()
+ .find(|f| {
+ f.index == alias.index
+ && matches!(&f.source, Source::File(p) | Source::SharedFile(p, _) if *p == alias.path)
+ })
+ .cloned();
+ if let Some(mut face) = source {
+ face.families = vec![(name.clone(), Language::English_UnitedStates)];
+ fs.db_mut().push_face_info(face);
+ if let Some(f) = fs.db().faces().find(|f| f.families.first().is_some_and(|(fam, _)| *fam == name)) {
+ present.insert(name, f.id);
+ last = Some((f.id, n));
+ }
+ }
+ }
+ }
+ FontOp::Rescan(rescan) => apply_rescan(fs, rescan),
}
done = n + 1;
}
- FACE_ALIASES_SYNCED.with(|m| m.borrow_mut().insert(key, (done, last)));
+ FONT_OPS_SYNCED.with(|m| m.borrow_mut().insert(key, (done, last)));
+}
+
+/// One rescan, into one database: the faces of removed files are dropped,
+/// and added files are loaded — in the rescan's order, so databases alike stay
+/// alike. A bundled-only database (one holding nothing outside the bundled
+/// set) takes only added bundled files: a rescan does not hand the system's
+/// fonts to an app that never loaded them. Alias faces stay even when their
+/// file goes — they mark how far a database has synced; one whose file is
+/// gone simply no longer resolves.
+fn apply_rescan(fs: &mut FontSystem, rescan: &Rescan) {
+ use cosmic_text::fontdb::Source;
+ let mut present = std::collections::HashSet::new();
+ let mut gone = Vec::new();
+ let mut has_system = false;
+ for f in fs.db().faces() {
+ if is_alias_face(f) {
+ continue;
+ }
+ let (Source::File(p) | Source::SharedFile(p, _)) = &f.source else { continue };
+ if rescan.removed.contains(p) {
+ gone.push(f.id);
+ continue;
+ }
+ has_system |= !rescan.bundled.contains(p);
+ present.insert(p.clone());
+ }
+ let db = fs.db_mut();
+ for id in gone {
+ db.remove_face(id);
+ }
+ for p in &rescan.added {
+ if !present.contains(p) && (has_system || rescan.bundled.contains(p)) {
+ let _ = db.load_font_file(p);
+ present.insert(p.clone());
+ }
+ }
}
fn find_cased_family(fs: &FontSystem, name: &str) -> Option<String> {
@@ -453,7 +563,7 @@ pub(crate) fn shared_text_buffer(
return buf;
}
// Before any family resolves: a face alias must be in this database first.
- sync_face_aliases(fs);
+ sync_font_ops(fs);
let line_height = if is_vertical {
physical_size * 1.05
@@ -724,7 +834,7 @@ pub struct DlText {
pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<DlText>) {
// A cached buffer may have been shaped by another `FontSystem` (the app's
// measuring one); the glyph pass rasterizes its face-alias IDs with this one.
- sync_face_aliases(fs);
+ sync_font_ops(fs);
for item in &dl.items {
if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
@@ -1007,4 +1117,83 @@ mod text_cache_tests {
assert_eq!((one_a.0, one_b.0), (two_a.0, two_b.0), "the same IDs in both systems");
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// A rescan rides the alias log: two databases loaded alike — one synced
+ /// after every change, one only at the end — give the new file's face and
+ /// the aliases either side of it the same IDs; a removed file's face goes
+ /// from both while its alias stays; a database built after the rescan
+ /// replays it as a no-op; and a bundled-only one takes no system file.
+ /// (A log of the test's own: through the real one its rescans would
+ /// reach every other test's databases.)
+ #[test]
+ fn a_rescan_keeps_databases_alike() {
+ use cosmic_text::fontdb::{Database, Source};
+ use std::collections::HashSet;
+ use std::path::{Path, PathBuf};
+ use std::sync::Arc;
+ let src = {
+ let fs = crate::geometry_font_system().lock().unwrap();
+ let found = fs.db().faces().find_map(|f| match &f.source {
+ Source::File(p) | Source::SharedFile(p, _) if f.index == 0 && p.extension().is_some_and(|e| e == "ttf") => Some(p.clone()),
+ _ => None,
+ });
+ found.expect("a .ttf in the font set")
+ };
+ let dir = std::env::temp_dir().join(format!("cce-ui-rescan-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let (a, b) = (dir.join("a.ttf"), dir.join("b.ttf"));
+ std::fs::copy(&src, &a).unwrap();
+ std::fs::copy(&src, &b).unwrap();
+ let system = |file: &Path| {
+ let mut db = Database::new();
+ db.load_font_file(file).unwrap();
+ FontSystem::new_with_locale_and_db("en-US".into(), db)
+ };
+ let faces = |fs: &FontSystem| {
+ fs.db()
+ .faces()
+ .map(|f| {
+ let (Source::File(p) | Source::SharedFile(p, _)) = &f.source else { unreachable!() };
+ (f.id, f.families[0].0.clone(), p.clone())
+ })
+ .collect::<Vec<_>>()
+ };
+ // (is an alias, file) per face, in database order.
+ let names = |fs: &FontSystem| faces(fs).into_iter().map(|(_, fam, p)| (fam.starts_with(FACE_ALIAS_PREFIX), p)).collect::<Vec<_>>();
+ let alias = |path: &Path| FontOp::Alias(FaceAlias { path: path.to_path_buf(), index: 0 });
+ let rescan = |added: Vec<PathBuf>, removed: &[&Path], bundled: &[&Path]| {
+ let set = |ps: &[&Path]| ps.iter().map(|p| p.to_path_buf()).collect::<HashSet<_>>();
+ FontOp::Rescan(Arc::new(Rescan::new(added, set(removed), set(bundled))))
+ };
+
+ let (mut eager, mut lazy) = (system(&a), system(&a));
+ let mut ops = vec![alias(&a)];
+ replay_font_ops(&mut eager, &ops);
+ ops.push(rescan(vec![b.clone()], &[], &[]));
+ replay_font_ops(&mut eager, &ops);
+ ops.push(alias(&b));
+ replay_font_ops(&mut eager, &ops);
+ replay_font_ops(&mut lazy, &ops);
+ assert_eq!(
+ names(&eager),
+ [(false, a.clone()), (true, a.clone()), (false, b.clone()), (true, b.clone())],
+ "the new file lands between the aliases"
+ );
+ assert_eq!(faces(&eager), faces(&lazy), "the same IDs in both");
+
+ ops.push(rescan(Vec::new(), &[&a], &[]));
+ replay_font_ops(&mut eager, &ops);
+ replay_font_ops(&mut lazy, &ops);
+ assert_eq!(names(&eager), [(true, a.clone()), (false, b.clone()), (true, b.clone())], "the removed file's face goes, its alias stays");
+ assert_eq!(faces(&eager), faces(&lazy), "the same IDs in both");
+
+ let mut later = system(&b);
+ replay_font_ops(&mut later, &ops);
+ assert_eq!(names(&later), [(false, b.clone()), (true, b.clone())], "built after the rescan: nothing dropped or doubled");
+
+ let mut bundled_only = system(&a);
+ replay_font_ops(&mut bundled_only, &[rescan(vec![b.clone()], &[], &[&a])]);
+ assert_eq!(names(&bundled_only), [(false, a.clone())], "a bundled-only database takes no system file");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
diff --git a/src/lib.rs b/src/lib.rs
index b9146ed..9b3237d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -272,6 +272,55 @@ pub fn create_font_system_with_system_fonts() -> cosmic_text::FontSystem {
build_font_system(true)
}
+/// Rescan the font directories — the CCE fonts dir, the fallback faces and the
+/// system's (fontconfig's) — for fonts installed or removed since `fs` was
+/// loaded, and bring every `FontSystem` in the process up to date with them:
+/// `fs` now, the rest (the engine's renderer among them) the next time they
+/// shape or draw. Without it a database is the disk as it was at startup.
+///
+/// A font picker's refresh: it reads every font file's metadata, so it takes
+/// a moment and does not belong in a frame loop. Returns whether anything
+/// changed.
+#[cfg(not(target_arch = "wasm32"))]
+pub fn rescan_fonts(fs: &mut cosmic_text::FontSystem) -> bool {
+ use cosmic_text::fontdb::{Database, Source};
+ use std::collections::HashSet;
+ use std::path::PathBuf;
+ // Each font file once, in the order the database loaded it.
+ fn files(db: &Database) -> Vec<PathBuf> {
+ let mut seen = HashSet::new();
+ db.faces()
+ .filter(|f| !crate::backend::text::is_alias_face(f))
+ .filter_map(|f| match &f.source {
+ Source::File(p) | Source::SharedFile(p, _) => Some(p.clone()),
+ _ => None,
+ })
+ .filter(|p| seen.insert(p.clone()))
+ .collect()
+ }
+ // The same discovery `build_font_system` makes.
+ let mut bundled = Database::new();
+ bundled.load_fonts_dir(fonts_dir());
+ load_fallback_fonts(&mut bundled);
+ let mut all = bundled.clone();
+ all.load_system_fonts();
+ let now = files(&all);
+ let now_set: HashSet<PathBuf> = now.iter().cloned().collect();
+
+ // `fs` as every earlier change leaves it, so the delta is against that.
+ crate::backend::text::sync_font_set(fs);
+ let before: HashSet<PathBuf> = files(fs.db()).into_iter().collect();
+ let added: Vec<PathBuf> = now.into_iter().filter(|p| !before.contains(p)).collect();
+ let removed: HashSet<PathBuf> = before.difference(&now_set).cloned().collect();
+ let rescan = crate::backend::text::Rescan::new(added, removed, files(&bundled).into_iter().collect());
+ if rescan.is_empty() {
+ return false;
+ }
+ crate::backend::text::push_rescan(rescan);
+ crate::backend::text::sync_font_set(fs);
+ true
+}
+
/// Targeted script-fallback faces loaded alongside the bundled house fonts.
/// The bundled set covers Latin; anything else shaped to tofu unless
/// `$CCE_LOAD_SYSTEM_FONTS` pulled in the entire system set. Probing a short