collective

Browse / github.com/storytold/craft-libs / patch

craft-ui: system font fallback over the font catalog's collection

craft-ui: system font fallback over the font catalog's collection craft-ui's installed-font fallback (`system_fonts`) is built over `craft_font_catalog::system::collection()`, so fonts in the Craft font store reach the UI as well as the system's fonts, and it follows the catalog when that collection is rebuilt. Part of moving the apps onto `craft-font-catalog` (#125). ## Behaviour - The index records the catalog generation it was built from. Each lookup and each pass of an installed egui context compare it with `system::generation()`. - On a change, the index is rebuilt on a background thread. The rebuild clones the catalog's collection; it never enumerates

Status
open
Score
0.00
Adopters
0 other operators
Author
aiongg (imported from pull request #377, open)
Base
9ff719499d8f4730b93071abed993e5f12fe1be8
Size
+415 −56 in 8 files
Review
pending
Shared
2026-10-28
Id
05f5c26dbfdb5e6df0b893cf

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock crates/font-catalog/README.md crates/font-catalog/src/system.rs crates/ui/Cargo.toml crates/ui/README.md crates/ui/src/fonts/mod.rs crates/ui/src/fonts/system.rs crates/ui/src/fonts/system_index.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -1018,6 +1018,7 @@ dependencies = [ name = "craft-ui" version = "0.1.0" dependencies = [+ "craft-font-catalog",  "craft-fonts-build",  "craft-i18n",  "eframe",diff --git a/crates/font-catalog/README.md b/crates/font-catalog/README.md--- a/crates/font-catalog/README.md+++ b/crates/font-catalog/README.md@@ -72,7 +72,7 @@ Each face (`FaceInfo`) has its family and style, legacy family and style, family  ## One enumeration for the UI and documents -With the `system` feature, `system::collection()` returns the process's fontique `Collection`: the system's fonts and the font store's. The catalog lists its files, and craft-ui's font fallback can query the same collection instead of building its own, so the system's fonts are enumerated once per process. A rescan builds a new collection; `system::generation()` tells holders of an older clone to take a new one.+With the `system` feature, `system::collection()` returns the process's fontique `Collection`: the system's fonts and the font store's. The catalog lists its files, and craft-ui's font fallback (its `system_fonts` feature) queries the same collection, so the system's fonts are enumerated once per process. A rescan builds a new collection; `system::generation()` tells holders of an older clone to take a new one. It doesn't wait for a rescan in progress, so a UI thread can check it every frame. `system::on_change(callback)` runs a callback after every build, on the thread that built it.  ## Try it diff --git a/crates/font-catalog/src/system.rs b/crates/font-catalog/src/system.rs--- a/crates/font-catalog/src/system.rs+++ b/crates/font-catalog/src/system.rs@@ -10,7 +10,8 @@ #[cfg(all(feature = "system", not(target_arch = "wasm32")))] mod imp {     use std::path::PathBuf;-    use std::sync::{Mutex, PoisonError};+    use std::sync::atomic::{AtomicU64, Ordering};+    use std::sync::{Arc, Mutex, PoisonError};      use fontique::{Collection, CollectionOptions, SourceKind}; @@ -22,6 +23,11 @@ mod imp {     }      static SHARED: Mutex<Option<Shared>> = Mutex::new(None);+    /// `SHARED`'s generation, readable while a build holds the lock (a UI thread checks it every+    /// frame). Stored before the lock is released, so a [`collection`] taken after reading it is+    /// at least that generation.+    static GENERATION: AtomicU64 = AtomicU64::new(0);+    static ON_CHANGE: Mutex<Vec<Arc<dyn Fn() + Send + Sync>>> = Mutex::new(Vec::new());      /// A new collection of the system's fonts and the font store's. `None` when fontique panics     /// (its DirectWrite backend unwraps a failed factory).@@ -39,14 +45,36 @@ mod imp {     fn with<R>(refresh: bool, f: impl FnOnce(&mut Shared) -> R) -> R {         let mut guard = SHARED.lock().unwrap_or_else(PoisonError::into_inner);         let generation = guard.as_ref().map_or(0, |s| s.generation);+        let built = refresh || guard.is_none();         let shared = match &mut *guard {             Some(shared) if !refresh => shared,             slot => {                 let collection = build().unwrap_or_else(|| Collection::new(CollectionOptions { shared: false, system_fonts: false }));+                GENERATION.store(generation + 1, Ordering::Release);                 slot.insert(Shared { generation: generation + 1, collection })             }         };-        f(shared)+        let result = f(shared);+        drop(guard);+        if built {+            // A clone of the list, so a callback may take the collection or register another.+            let callbacks = ON_CHANGE.lock().unwrap_or_else(PoisonError::into_inner).clone();+            for callback in callbacks {+                callback();+            }+        }+        result+    }++    pub fn on_change(callback: Box<dyn Fn() + Send + Sync>) {+        ON_CHANGE.lock().unwrap_or_else(PoisonError::into_inner).push(Arc::from(callback));+    }++    /// Run `f` while holding the lock a build holds, as a rescan on another thread would.+    #[cfg(test)]+    pub(super) fn while_building<R>(f: impl FnOnce() -> R) -> R {+        let _guard = SHARED.lock().unwrap_or_else(PoisonError::into_inner);+        f()     }      pub fn collection() -> Collection {@@ -54,7 +82,7 @@ mod imp {     }      pub fn generation() -> u64 {-        SHARED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map_or(0, |s| s.generation)+        GENERATION.load(Ordering::Acquire)     }      pub fn refresh() {@@ -105,11 +133,20 @@ pub fn collection() -> fontique::Collection {  #[cfg(all(feature = "system", not(target_arch = "wasm32")))] /// Counts the builds of [`collection`]: 0 before the first, one more after each rescan that asked-/// the font service again.+/// the font service again. It doesn't wait for a build in progress, so a UI thread can check it+/// every frame. pub fn generation() -> u64 {     imp::generation() } +#[cfg(all(feature = "system", not(target_arch = "wasm32")))]+/// Call `callback` after every build of [`collection`], once [`generation`] has moved on: the first+/// build and every rescan. It runs on the thread that built, after the build's lock is released,+/// so it may take the new [`collection`]. Callbacks stay registered for the life of the process.+pub fn on_change(callback: Box<dyn Fn() + Send + Sync>) {+    imp::on_change(callback);+}+ #[cfg(all(feature = "system", not(target_arch = "wasm32")))] /// Enumerate the system's fonts again (a catalog's [`crate::Catalog::rescan`] does this). pub fn refresh() {@@ -120,3 +157,20 @@ pub fn refresh() { pub(crate) fn font_files(refresh: bool) -> Vec<std::path::PathBuf> {     imp::font_files(refresh) }++#[cfg(all(test, feature = "system", not(target_arch = "wasm32")))]+mod tests {+    use std::time::Duration;++    #[test]+    fn generation_does_not_wait_for_a_rescan() {+        let read = super::imp::while_building(|| {+            let (sent, received) = std::sync::mpsc::channel();+            std::thread::spawn(move || {+                let _ = sent.send(super::generation());+            });+            received.recv_timeout(Duration::from_secs(10))+        });+        assert!(read.is_ok(), "generation() returns while a build holds the collection's lock");+    }+}diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml--- a/crates/ui/Cargo.toml+++ b/crates/ui/Cargo.toml@@ -23,6 +23,8 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Screenshot and input tests of an app's screens (the screen-testing feature). egui_kittest = { version = "0.37", features = ["snapshot", "wgpu"], optional = true }+# The installed fonts the UI falls back to: the system's and the Craft font store's.+craft-font-catalog = { workspace = true, features = ["system"], optional = true } # The versions egui_system_font_provider 0.37 uses. fontique = { version = "0.11.1", default-features = false, features = ["system", "fontconfig-dlopen"], optional = true } unicode-script = { version = "0.5.8", optional = true }@@ -48,7 +50,7 @@ native-windows = [] # Draw colour glyphs (COLR, sbix, CBDT), e.g. colour emoji. color_fonts = ["epaint/color_fonts"] # Find installed fonts for characters the app's faces lack (desktop only).-system_fonts = ["dep:fontique", "dep:unicode-script"]+system_fonts = ["dep:craft-font-catalog", "dep:fontique", "dep:unicode-script"] # `docking::testing`: a headless driver for application docking tests. testing = [] # craft_ui::testing: per-language screenshot tests and IME smoke tests with egui_kittestdiff --git a/crates/ui/README.md b/crates/ui/README.md--- a/crates/ui/README.md+++ b/crates/ui/README.md@@ -406,10 +406,14 @@ directory out of version control. A build filters faces for the web as for the d Two features, both off by default, change what every craft-ui user draws, so the app turns them on:  - `system_fonts` (desktop): the stack asks the installed fonts, at regular weight, and `install`-  replaces the context's font providers, including the one eframe adds. One index of the-  installed fonts serves every UI language, so a language switch doesn't scan them again.-  `install` starts building it on a background thread; until it is ready, text draws with the-  stack's own faces, and the context repaints when it arrives. Without it,+  replaces the context's font providers, including the one eframe adds. The installed fonts are+  craft-font-catalog's (`system::collection()`): the system's fonts and those in the Craft font+  store. One index of them serves every UI language, so a language switch doesn't scan them+  again. `install` starts building it on a background thread; until it is ready, text draws with+  the stack's own faces, and the context repaints when it arrives. When the process's catalog+  rescans (`system::on_change`), e.g. after another app installed a font, the index is rebuilt in the background and every installed context repaints, idle or not, and reloads its+  fonts, so a font installed meanwhile draws the characters that had none. craft-ui never+  rescans on its own; the app decides when. Without it,   `install` keeps the providers the context has (eframe's `system_fonts`, for one) and asks the   stack's own faces after them. `UiFonts::system_provider` supplies a provider either way, and   `SystemFonts::Off` drops system fonts.diff --git a/crates/ui/src/fonts/mod.rs b/crates/ui/src/fonts/mod.rs--- a/crates/ui/src/fonts/mod.rs+++ b/crates/ui/src/fonts/mod.rs@@ -987,9 +987,10 @@ impl Stack { }  /// A system font provider that prefers `locale`'s fonts, shared by every stack and check that asks-/// for the same locale. Every locale's provider asks one index of the installed fonts, built once-/// on a background thread; a lookup before it is built waits for it. `None` without the-/// `system_fonts` feature and on the web.+/// for the same locale. Every locale's provider asks one index of the installed fonts (the system's+/// and the Craft font store's, from craft-font-catalog), built on a background thread and rebuilt+/// when the catalog rescans; a lookup before it is built, or while it is rebuilt, waits for it.+/// `None` without the `system_fonts` feature and on the web. /// /// To ask whether installed fonts can draw a language, pass it to /// [`UiFonts::system_provider`] and check with [`assert_covers_with_system`] ordiff --git a/crates/ui/src/fonts/system.rs b/crates/ui/src/fonts/system.rs--- a/crates/ui/src/fonts/system.rs+++ b/crates/ui/src/fonts/system.rs@@ -1,10 +1,21 @@-//! A font index built once on a background thread and shared by every locale's provider.+//! A font index built on a background thread, shared by every locale's provider, and rebuilt when+//! the fonts it was built from change. //! //! Lookups that may not block (the UI thread's) find nothing until the index is built: egui //! draws with the faces it has, and every context that was turned away reloads its fonts when the //! index arrives, which forgets egui's cached misses and repaints. If the build panics, every-//! lookup finds nothing from then on and egui keeps the faces it has.+//! lookup finds nothing and egui keeps the faces it has, until the fonts change and the build is+//! tried again.+//!+//! The index remembers the generation of the fonts it was built from. When the fonts' source+//! reports a change, and on every lookup and every pass of a context that started the index, it is+//! compared with the current generation, which is cheap. On a change the index is rebuilt in the+//! background while lookups keep asking the old one. Once the new index is in, every context that+//! started the index repaints, idle or not, and reloads its fonts at the start of that pass, so the+//! characters it had no font for are asked again; the faces egui already has stay. A rebuild that+//! panics keeps the old index until the fonts change again. +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};  use egui::{FallbackRequest, FontInsert, FontProvider};@@ -14,62 +25,128 @@ pub(super) trait FontIndex: Send + 'static {     /// The provider's preference between fonts for one script, e.g. a locale.     type Locale: Send + Sync + 'static; +    /// The generation of the fonts the index was built from, as [`Source::generation`] counts+    /// them.+    fn generation(&self) -> u64;+     fn font_for(&mut self, request: &FallbackRequest<'_>, locale: &Self::Locale) -> Option<FontInsert>; } +/// Where an index's fonts come from: how to read their generation and hear of a change.+pub(super) struct Source {+    /// The fonts' current generation. It must not block: every UI pass reads it.+    pub(super) generation: fn() -> u64,+    /// Registers a callback for every change of the generation, e.g. a rescan on another thread.+    pub(super) on_change: fn(Box<dyn Fn() + Send + Sync>),+}+ enum State<T> {     Idle,-    /// `turned_away`: a lookup found nothing because the index wasn't built, so `contexts` reload-    /// their fonts when it is.+    /// `turned_away`: a lookup found nothing because the index wasn't built, so the contexts+    /// reload their fonts when it is.     Building {         turned_away: bool,-        contexts: Vec<egui::Context>,     },-    Ready(Arc<Mutex<T>>),-    Failed,+    Ready {+        index: Arc<Mutex<T>>,+        generation: u64,+    },+    /// The fonts changed since `index` was built. Lookups that don't wait ask it until the new+    /// one is in.+    Rebuilding {+        index: Arc<Mutex<T>>,+    },+    /// The build at `generation` panicked; a later generation tries again.+    Failed {+        generation: u64,+    }, } -/// An index built at most once per process, on first use.+/// An index built on first use, and again whenever the fonts' generation changes. pub(super) struct Shared<T> {     state: Mutex<State<T>>,     built: Condvar,     build: fn() -> T,+    source: Source,+    /// Counts the rebuilds that replaced the index. A context reloads its fonts when this moves+    /// past the count it last saw.+    replaced: AtomicU64,+    /// The contexts that started the index, to repaint after a rebuild. egui has no weak handle to+    /// a context, so each stays until its integration calls [`egui::Context::on_exit`].+    contexts: Mutex<Vec<egui::Context>>, }  impl<T: FontIndex> Shared<T> {-    pub(super) const fn new(build: fn() -> T) -> Self {-        Self { state: Mutex::new(State::Idle), built: Condvar::new(), build }+    pub(super) const fn new(build: fn() -> T, source: Source) -> Self {+        Self { state: Mutex::new(State::Idle), built: Condvar::new(), build, source, replaced: AtomicU64::new(0), contexts: Mutex::new(Vec::new()) }     }      /// Start building the index on a background thread unless it is built or building. `ctx`-    /// reloads its fonts when the index arrives if a lookup was turned away meanwhile.+    /// reloads its fonts when the index arrives if a lookup was turned away meanwhile, and follows+    /// the fonts' generation from then on.     pub(super) fn start(&'static self, ctx: Option<&egui::Context>) {+        if let Some(ctx) = ctx {+            let replaced = self.replaced.load(Ordering::Acquire);+            ctx.data_mut(|d| d.insert_temp(self.seen_id(), replaced));+            let mut contexts = self.contexts();+            if !contexts.contains(ctx) {+                contexts.push(ctx.clone());+            }+            drop(contexts);+            ctx.add_plugin(Follow(self));+        }         let idle = {             let mut state = self.lock();             let idle = matches!(*state, State::Idle);             if idle {-                *state = State::Building { turned_away: false, contexts: Vec::new() };-            }-            if let (State::Building { contexts, .. }, Some(ctx)) = (&mut *state, ctx) {-                contexts.push(ctx.clone());+                *state = State::Building { turned_away: false };             }             idle         };-        if idle && std::thread::Builder::new().name("craft-ui system fonts".into()).spawn(|| self.finish()).is_err() {-            self.finish();+        if idle {+            (self.source.on_change)(Box::new(|| self.follow()));+            self.spawn_build();+        }+    }++    /// Rebuild the index in the background if the fonts changed since it was built, or retry a+    /// failed build at a new generation.+    fn follow(&'static self) {+        let current = (self.source.generation)();+        let rebuild = {+            let mut state = self.lock();+            match &mut *state {+                State::Ready { index, generation } if *generation != current => {+                    let index = Arc::clone(index);+                    *state = State::Rebuilding { index };+                    true+                }+                State::Failed { generation } if *generation != current => {+                    // Every lookup since the failure found nothing.+                    *state = State::Building { turned_away: true };+                    true+                }+                _ => false,+            }+        };+        if rebuild {+            self.spawn_build();         }     }      /// The index, or `None` while it builds unless `wait`, which blocks until it is built, or-    /// `None` if its build failed.+    /// `None` if its build failed. While it is rebuilt, `wait` blocks for the new index and a+    /// lookup without gets the old one.     fn get(&'static self, wait: bool) -> Option<Arc<Mutex<T>>> {         self.start(None);+        self.follow();         let mut state = self.lock();         loop {             match &mut *state {-                State::Ready(index) => return Some(Arc::clone(index)),-                State::Failed => return None,-                State::Building { turned_away, .. } if !wait => {+                State::Ready { index, .. } => return Some(Arc::clone(index)),+                State::Rebuilding { index } if !wait => return Some(Arc::clone(index)),+                State::Failed { .. } => return None,+                State::Building { turned_away } if !wait => {                     *turned_away = true;                     return None;                 }@@ -78,29 +155,87 @@ impl<T: FontIndex> Shared<T> {         }     } -    fn finish(&self) {+    fn spawn_build(&'static self) {+        if std::thread::Builder::new().name("craft-ui system fonts".into()).spawn(|| self.finish()).is_err() {+            self.finish();+        }+    }++    fn finish(&'static self) {+        let before = (self.source.generation)();         let built = std::panic::catch_unwind(self.build).map_err(|payload| {             let message = payload                 .downcast_ref::<String>()                 .map(String::as_str)                 .or_else(|| payload.downcast_ref::<&str>().copied())                 .unwrap_or("non-string panic payload");-            eprintln!("craft-ui: building the system font index failed, so it adds no fonts: {message}");+            eprintln!("craft-ui: building the system font index failed: {message}");         });-        let next = built.map_or(State::Failed, |index| State::Ready(Arc::new(Mutex::new(index))));-        let previous = std::mem::replace(&mut *self.lock(), next);+        let ready = |index: T| State::Ready { generation: index.generation(), index: Arc::new(Mutex::new(index)) };+        let mut state = self.lock();+        let (next, reload, repaint) = match (built, std::mem::replace(&mut *state, State::Idle)) {+            (Ok(index), State::Rebuilding { .. }) => (ready(index), false, true),+            (Err(()), State::Rebuilding { index }) => (State::Ready { index, generation: before }, false, false),+            (Ok(index), State::Building { turned_away }) => (ready(index), turned_away, false),+            (Ok(index), _) => (ready(index), false, false),+            (Err(()), _) => (State::Failed { generation: before }, false, false),+        };+        *state = next;+        if repaint {+            self.replaced.fetch_add(1, Ordering::Release);+        }+        drop(state);         self.built.notify_all();         // Outside the lock: the UI thread holds the context's fonts while it asks for the index.-        if let State::Building { turned_away: true, contexts } = previous {-            for ctx in contexts {+        // A repainted context reloads its fonts in its next pass (see `Follow`).+        let contexts = if reload || repaint { self.contexts().clone() } else { Vec::new() };+        for ctx in contexts {+            if reload {                 super::providers::reload_font_providers(&ctx);+            } else {+                ctx.request_repaint();             }         }+        // The fonts may have changed again while this build ran.+        self.follow();+    }++    /// Where a context keeps the [`Self::replaced`] count its fonts were last loaded at.+    fn seen_id(&self) -> egui::Id {+        egui::Id::unique(("craft_ui::fonts::system::seen", std::ptr::from_ref(self) as usize))     }      fn lock(&self) -> MutexGuard<'_, State<T>> {         self.state.lock().unwrap_or_else(PoisonError::into_inner)     }++    fn contexts(&self) -> MutexGuard<'_, Vec<egui::Context>> {+        self.contexts.lock().unwrap_or_else(PoisonError::into_inner)+    }+}++/// Follows the fonts for a context that started a [`Shared`] index: at the start of each pass it+/// has the index rebuilt if they changed, and reloads the context's fonts once a new index is in.+struct Follow<T: 'static>(&'static Shared<T>);++impl<T: FontIndex> egui::plugin::Plugin for Follow<T> {+    fn debug_name(&self) -> &'static str {+        "craft_ui::fonts::system::Follow"+    }++    fn on_begin_pass(&mut self, ui: &mut egui::Ui) {+        let ctx = ui.ctx().clone();+        self.0.follow();+        let replaced = self.0.replaced.load(Ordering::Acquire);+        let seen = ctx.data_mut(|d| std::mem::replace(d.get_temp_mut_or_insert_with(self.0.seen_id(), || replaced), replaced));+        if seen != replaced {+            super::providers::reload_font_providers(&ctx);+        }+    }++    fn on_exit(&mut self, ctx: &egui::Context) {+        self.0.contexts().retain(|c| c != ctx);+    } }  /// One locale's provider over a [`Shared`] index.@@ -128,7 +263,7 @@ impl<T: FontIndex> FontProvider for Lookup<T> { #[cfg(test)] mod tests {     use std::sync::Arc;-    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};+    use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};     use std::time::{Duration, Instant};      use egui::{FontData, FontFamily, FontId, FontPriority, InsertFontFamily};@@ -153,6 +288,10 @@ mod tests {     impl FontIndex for Gated {         type Locale = (); +        fn generation(&self) -> u64 {+            0+        }+         fn font_for(&mut self, request: &FallbackRequest<'_>, _: &()) -> Option<FontInsert> {             Some(FontInsert::new(                 "index:Arabic",@@ -162,7 +301,15 @@ mod tests {         }     } -    static GATED: Shared<Gated> = Shared::new(build_when_open);+    fn unchanging() -> u64 {+        0+    }++    fn never_changes(_: Box<dyn Fn() + Send + Sync>) {}++    const UNCHANGING: Source = Source { generation: unchanging, on_change: never_changes };++    static GATED: Shared<Gated> = Shared::new(build_when_open, UNCHANGING);      #[test]     fn ui_lookups_skip_the_index_until_it_is_built_then_the_context_reloads() {@@ -197,7 +344,7 @@ mod tests {         panic!("stand-in index build failure");     } -    static PANICS: Shared<Gated> = Shared::new(build_panics);+    static PANICS: Shared<Gated> = Shared::new(build_panics, UNCHANGING);      #[test]     fn a_panicking_build_leaves_lookups_finding_nothing() {@@ -213,4 +360,146 @@ mod tests {         assert!(Lookup::new(&PANICS, (), false).font_for(&request).is_none());         assert!(Lookup::new(&PANICS, (), true).font_for(&request).is_none());     }++    /// An index of the fonts at a generation; Arabic is installed from generation 2.+    struct Installed(u64);++    static FONTS: AtomicU64 = AtomicU64::new(1);+    static INSTALLED_BUILDS: AtomicUsize = AtomicUsize::new(0);++    fn fonts_generation() -> u64 {+        FONTS.load(Ordering::Acquire)+    }++    fn build_installed() -> Installed {+        INSTALLED_BUILDS.fetch_add(1, Ordering::Relaxed);+        Installed(fonts_generation())+    }++    impl FontIndex for Installed {+        type Locale = ();++        fn generation(&self) -> u64 {+            self.0+        }++        fn font_for(&mut self, request: &FallbackRequest<'_>, _: &()) -> Option<FontInsert> {+            if self.0 < 2 {+                return None;+            }+            Gated.font_for(request, &())+        }+    }++    static INSTALLED: Shared<Installed> = Shared::new(build_installed, Source { generation: fonts_generation, on_change: never_changes });++    #[test]+    fn a_change_in_the_fonts_rebuilds_the_index_and_the_context_reloads() {+        let ctx = egui::Context::default();+        let font_id = FontId::new(14.0, FontFamily::Proportional);+        let ui: Arc<dyn FontProvider> = Arc::new(Lookup::new(&INSTALLED, (), false));+        super::super::providers::set_font_providers(&ctx, vec![ui]);+        INSTALLED.start(Some(&ctx));+        let draws_arabic = || {+            ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();+            ctx.fonts_mut(|f| f.has_glyph(&font_id, 'ع'))+        };+        let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional };+        assert!(Lookup::new(&INSTALLED, (), true).font_for(&request).is_none(), "no Arabic font at generation 1");+        for _ in 0..3 {+            assert!(!draws_arabic());+        }+        assert_eq!(INSTALLED_BUILDS.load(Ordering::Relaxed), 1, "an unchanged generation keeps the index");++        FONTS.store(2, Ordering::Release);+        let deadline = Instant::now() + Duration::from_secs(10);+        while !draws_arabic() {+            assert!(Instant::now() < deadline, "the context reloads its fonts once the index is rebuilt");+            std::thread::sleep(Duration::from_millis(5));+        }+        assert_eq!(INSTALLED_BUILDS.load(Ordering::Relaxed), 2, "one rebuild per change");+    }++    static IDLE_FONTS: AtomicU64 = AtomicU64::new(1);+    static IDLE_BUILDS: AtomicUsize = AtomicUsize::new(0);+    static IDLE_CALLBACKS: Mutex<Vec<Box<dyn Fn() + Send + Sync>>> = Mutex::new(Vec::new());++    fn idle_generation() -> u64 {+        IDLE_FONTS.load(Ordering::Acquire)+    }++    fn build_idle() -> Installed {+        IDLE_BUILDS.fetch_add(1, Ordering::Relaxed);+        Installed(idle_generation())+    }++    fn on_idle_change(callback: Box<dyn Fn() + Send + Sync>) {+        IDLE_CALLBACKS.lock().unwrap().push(callback);+    }++    static IDLE: Shared<Installed> = Shared::new(build_idle, Source { generation: idle_generation, on_change: on_idle_change });++    #[test]+    fn an_idle_context_repaints_when_the_fonts_change() {+        let ctx = egui::Context::default();+        let ui: Arc<dyn FontProvider> = Arc::new(Lookup::new(&IDLE, (), false));+        super::super::providers::set_font_providers(&ctx, vec![ui]);+        IDLE.start(Some(&ctx));+        let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional };+        assert!(Lookup::new(&IDLE, (), true).font_for(&request).is_none());+        let draws_arabic = || {+            ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();+            ctx.fonts_mut(|f| f.has_glyph(&FontId::new(14.0, FontFamily::Proportional), 'ع'))+        };+        assert!(!draws_arabic());+        // Idle: no repaint pending, as an app between inputs. egui calls the callback only for a+        // repaint requested after that.+        for _ in 0..10 {+            if !ctx.has_requested_repaint() {+                break;+            }+            assert!(!draws_arabic());+        }+        assert!(!ctx.has_requested_repaint(), "the context settles");+        let woken = Arc::new(AtomicBool::new(false));+        let wake = Arc::clone(&woken);+        ctx.set_request_repaint_callback(move |_| wake.store(true, Ordering::Release));++        // A rescan on another thread, with no pass running.+        IDLE_FONTS.store(2, Ordering::Release);+        for callback in IDLE_CALLBACKS.lock().unwrap().iter() {+            callback();+        }+        let deadline = Instant::now() + Duration::from_secs(10);+        while !woken.load(Ordering::Acquire) {+            assert!(Instant::now() < deadline, "the context is asked to repaint once the index is rebuilt");+            std::thread::sleep(Duration::from_millis(5));+        }+        assert_eq!(IDLE_BUILDS.load(Ordering::Relaxed), 2);+        draws_arabic();+        assert!(draws_arabic(), "the repainted context reloads its fonts");+    }++    fn build_unless_first() -> Installed {+        let generation = FLAKY_FONTS.load(Ordering::Acquire);+        assert!(generation > 1, "stand-in index build failure");+        Installed(generation)+    }++    static FLAKY_FONTS: AtomicU64 = AtomicU64::new(1);++    fn flaky_generation() -> u64 {+        FLAKY_FONTS.load(Ordering::Acquire)+    }++    static FLAKY: Shared<Installed> = Shared::new(build_unless_first, Source { generation: flaky_generation, on_change: never_changes });++    #[test]+    fn a_failed_build_is_tried_again_when_the_fonts_change() {+        let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional };+        assert!(Lookup::new(&FLAKY, (), true).font_for(&request).is_none());+        assert!(Lookup::new(&FLAKY, (), true).font_for(&request).is_none(), "an unchanged generation doesn't retry");+        FLAKY_FONTS.store(2, Ordering::Release);+        assert!(Lookup::new(&FLAKY, (), true).font_for(&request).is_some(), "a new generation builds again");+    } }diff --git a/crates/ui/src/fonts/system_index.rs b/crates/ui/src/fonts/system_index.rs--- a/crates/ui/src/fonts/system_index.rs+++ b/crates/ui/src/fonts/system_index.rs@@ -1,21 +1,23 @@-//! The installed fonts, indexed once with fontique for every locale. The lookup is-//! egui_system_font_provider 0.37's, whose provider builds its own index per locale.+//! The installed fonts, indexed with fontique for every locale: craft-font-catalog's collection of+//! the system's fonts and the Craft font store's, indexed again when the catalog rescans them. The+//! lookup is egui_system_font_provider 0.37's, whose provider builds its own index per locale.  use std::sync::{Arc, LazyLock}; +use craft_font_catalog::system; use egui::{FallbackRequest, FontData, FontFamily, FontInsert, FontPriority, FontProvider, InsertFontFamily};-use fontique::{-    Collection, CollectionOptions, FallbackKey, GenericFamily, Language, QueryFamily, QueryFont, QueryStatus, Script, SourceCache, SourceCacheOptions,-};+use fontique::{Collection, FallbackKey, GenericFamily, Language, QueryFamily, QueryFont, QueryStatus, Script, SourceCache, SourceCacheOptions}; -use super::system::{FontIndex, Lookup, Shared};+use super::system::{FontIndex, Lookup, Shared, Source};  pub(super) struct Index {     collection: Collection,     source_cache: SourceCache,+    /// The catalog's [`system::generation`] of `collection`.+    generation: u64, } -static INDEX: Shared<Index> = Shared::new(build);+static INDEX: Shared<Index> = Shared::new(build, Source { generation: system::generation, on_change: system::on_change });  #[cfg(test)] pub(super) static BUILDS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);@@ -23,10 +25,12 @@ pub(super) static BUILDS: std::sync::atomic::AtomicUsize = std::sync::atomic::At fn build() -> Index {     #[cfg(test)]     BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);-    Index {-        collection: Collection::new(CollectionOptions { shared: false, system_fonts: true }),-        source_cache: SourceCache::new(SourceCacheOptions::default()),-    }+    // The first call enumerates the fonts if no one has yet. The generation read after it is+    // then no newer than the collection taken next: a rescan in between costs one more rebuild,+    // not a missed one.+    system::collection();+    let generation = system::generation();+    Index { collection: system::collection(), source_cache: SourceCache::new(SourceCacheOptions::default()), generation } }  /// A provider over the shared index that prefers `locale`'s fonts. With `wait`, a lookup before the@@ -46,7 +50,7 @@ pub(super) fn locale_language(locale: &str) -> Option<Language> { }  /// Start building the index in the background. `ctx` reloads its fonts once it is built if a-/// lookup found nothing meanwhile.+/// lookup found nothing meanwhile, and again after each rebuild for a catalog rescan. pub(super) fn start(ctx: &egui::Context) {     INDEX.start(Some(ctx)); }@@ -54,10 +58,14 @@ pub(super) fn start(ctx: &egui::Context) { impl FontIndex for Index {     type Locale = Option<Language>; +    fn generation(&self) -> u64 {+        self.generation+    }+     fn font_for(&mut self, request: &FallbackRequest<'_>, locale: &Option<Language>) -> Option<FontInsert> {         let FallbackRequest { cluster, family } = request;         let base_char = cluster.chars().next()?;-        let Self { collection, source_cache } = self;+        let Self { collection, source_cache, .. } = self;         let generic = match family {             FontFamily::Monospace => GenericFamily::Monospace,             FontFamily::Proportional | FontFamily::Name(_) => GenericFamily::SansSerif,