onboarding: Fix font loading frame delay (#37668)

Ben Kunkle , Mikayla Maki , Anthony Eid , and Anthony created

Closes #ISSUE

Fixed an issue where the first frame of the `Editing` page in onboarding
would have a slight delay before rendering the first time it was
navigated to. This was caused by listing the OS fonts on the main
thread, blocking rendering. This PR fixes the issue by adding a new
method to the font family cache to prefill the cache on a background
thread.

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Anthony <anthony@zed.dev>

Change summary

crates/onboarding/src/editing_page.rs   | 32 ++++++++++----------
crates/onboarding/src/onboarding.rs     | 25 ++++++++++++---
crates/settings/src/settings_ui_core.rs |  1 
crates/theme/src/font_family_cache.rs   | 42 ++++++++++++++++++++++++++
4 files changed, 77 insertions(+), 23 deletions(-)

Detailed changes

crates/onboarding/src/editing_page.rs 🔗

@@ -449,28 +449,28 @@ impl FontPickerDelegate {
     ) -> Self {
         let font_family_cache = FontFamilyCache::global(cx);
 
-        let fonts: Vec<SharedString> = font_family_cache
-            .list_font_families(cx)
-            .into_iter()
-            .collect();
-
+        let fonts = font_family_cache
+            .try_list_font_families()
+            .unwrap_or_else(|| vec![current_font.clone()]);
         let selected_index = fonts
             .iter()
             .position(|font| *font == current_font)
             .unwrap_or(0);
 
+        let filtered_fonts = fonts
+            .iter()
+            .enumerate()
+            .map(|(index, font)| StringMatch {
+                candidate_id: index,
+                string: font.to_string(),
+                positions: Vec::new(),
+                score: 0.0,
+            })
+            .collect();
+
         Self {
-            fonts: fonts.clone(),
-            filtered_fonts: fonts
-                .iter()
-                .enumerate()
-                .map(|(index, font)| StringMatch {
-                    candidate_id: index,
-                    string: font.to_string(),
-                    positions: Vec::new(),
-                    score: 0.0,
-                })
-                .collect(),
+            fonts,
+            filtered_fonts,
             selected_index,
             current_font,
             on_font_changed: Arc::new(on_font_changed),

crates/onboarding/src/onboarding.rs 🔗

@@ -242,12 +242,25 @@ struct Onboarding {
 
 impl Onboarding {
     fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
-        cx.new(|cx| Self {
-            workspace: workspace.weak_handle(),
-            focus_handle: cx.focus_handle(),
-            selected_page: SelectedPage::Basics,
-            user_store: workspace.user_store().clone(),
-            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
+        let font_family_cache = theme::FontFamilyCache::global(cx);
+
+        cx.new(|cx| {
+            cx.spawn(async move |this, cx| {
+                font_family_cache.prefetch(cx).await;
+                this.update(cx, |_, cx| {
+                    cx.notify();
+                })
+            })
+            .detach();
+
+            Self {
+                workspace: workspace.weak_handle(),
+                focus_handle: cx.focus_handle(),
+                selected_page: SelectedPage::Basics,
+                user_store: workspace.user_store().clone(),
+                _settings_subscription: cx
+                    .observe_global::<SettingsStore>(move |_, cx| cx.notify()),
+            }
         })
     }
 

crates/settings/src/settings_ui_core.rs 🔗

@@ -78,6 +78,7 @@ impl SettingsValue<serde_json::Value> {
         let fs = <dyn Fs>::global(cx);
 
         let rx = settings_store.update_settings_file_at_path(fs.clone(), path.as_slice(), value);
+
         let path = path.clone();
         cx.background_spawn(async move {
             rx.await?

crates/theme/src/font_family_cache.rs 🔗

@@ -16,7 +16,7 @@ struct FontFamilyCacheState {
 /// so we do it once and then use the cached values each render.
 #[derive(Default)]
 pub struct FontFamilyCache {
-    state: RwLock<FontFamilyCacheState>,
+    state: Arc<RwLock<FontFamilyCacheState>>,
 }
 
 #[derive(Default)]
@@ -52,4 +52,44 @@ impl FontFamilyCache {
 
         lock.font_families.clone()
     }
+
+    /// Returns the list of font families if they have been loaded
+    pub fn try_list_font_families(&self) -> Option<Vec<SharedString>> {
+        self.state
+            .try_read()
+            .filter(|state| state.loaded_at.is_some())
+            .map(|state| state.font_families.clone())
+    }
+
+    /// Prefetch all font names in the background
+    pub async fn prefetch(&self, cx: &gpui::AsyncApp) {
+        if self
+            .state
+            .try_read()
+            .is_none_or(|state| state.loaded_at.is_some())
+        {
+            return;
+        }
+
+        let Ok(text_system) = cx.update(|cx| App::text_system(cx).clone()) else {
+            return;
+        };
+
+        let state = self.state.clone();
+
+        cx.background_executor()
+            .spawn(async move {
+                // We take this lock in the background executor to ensure that synchronous calls to `list_font_families` are blocked while we are prefetching,
+                // while not blocking the main thread and risking deadlocks
+                let mut lock = state.write();
+                let all_font_names = text_system
+                    .all_font_names()
+                    .into_iter()
+                    .map(SharedString::from)
+                    .collect();
+                lock.font_families = all_font_names;
+                lock.loaded_at = Some(Instant::now());
+            })
+            .await;
+    }
 }