From 7b3d3455cb7bb66ddf30ac2c4af0edfdfbf883b4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 02:35:07 +1100 Subject: [PATCH 1/2] UI: rescan system fonts from the font menu; Linux scan smoke test (#1089) Fonts installed after EffectCraft launched (dnf, GNOME Font Viewer, a drop into ~/.local/share/fonts) stayed invisible until restart: only the startup background scan ran, and the report in #1089 could not tell discovery gaps apart from a stale scan. - The font menu's filter row gains a Rescan Fonts button (Character and Properties panels share the popup) that runs the existing text.fonts {rescan} command and reports the number added in the status line. The press doesn't flip the Variable-fonts filter (the button is above the row's click area and closes the popup on top of it). - system_scan_finds_fonts now asserts, on a native fontconfig desktop (any machine with /etc/fonts, which includes the Fedora reporter's and the CI runner), that the scan lists installed families rather than silently returning only the bundled fonts: 870 faces in ~10 ms on an Arch box. Tests: ui_font_search 4 passed (new rescan test drives the real popup); effectcraft-text lib system scan green; fmt and clippy --all-targets -D warnings clean. Translations for 'Rescan Fonts' in all six catalogs. --- crates/text/src/fonts.rs | 5 +++++ crates/ui-egui/src/i18n/ui.rs | 7 +++++++ crates/ui-egui/src/panels/text_panels.rs | 11 +++++++++++ crates/ui-egui/tests/ui_font_search.rs | 21 +++++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d555954c9..98ed85493 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -943,6 +943,11 @@ mod tests { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); + if cfg!(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))) && std::path::Path::new("/etc/fonts").exists() { + // #1089: a fontconfig desktop (Fedora, Arch, Ubuntu, the CI runner): the scan must + // list the installed system families, not just the bundled three. + assert!(n > 20, "fontconfig system fonts missing: {n}"); + } if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 58cbe8349..d554c45fa 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -351,6 +351,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), ("No matching fonts", "一致するフォントはありません"), + ("Rescan Fonts", "フォントを再スキャン"), ("Variable fonts only", "可変フォントのみ"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), @@ -893,6 +894,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), ("No matching fonts", "没有匹配的字体"), + ("Rescan Fonts", "重新扫描字体"), ("Variable fonts only", "仅可变字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), @@ -1626,6 +1628,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), ("No matching fonts", "沒有符合的字體"), + ("Rescan Fonts", "重新掃描字型"), ("Variable fonts only", "僅可變字型"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), @@ -2368,6 +2371,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), ("No matching fonts", "Не знайдено відповідних шрифтів"), + ("Rescan Fonts", "Повторно сканувати шрифти"), ("Variable fonts only", "Лише змінні шрифти"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), @@ -2895,6 +2899,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("New", "Novo"), ("New Comp from Selection", "Nova composição a partir da seleção"), ("Replace with Precomposition", "Substituir por pré-composição"), + ("Rescan Fonts", "Reanalisar fontes"), ("used once", "usado uma vez"), ("used {} times", "usado {} vezes"), ("Compositions that use this item", "Composições que usam este item"), @@ -3349,6 +3354,7 @@ const CZECH: &[(&str, &str)] = &[ ("New", "Nový"), ("New Comp from Selection", "Nová kompozice z výběru"), ("Replace with Precomposition", "Nahradit předkompozicí"), + ("Rescan Fonts", "Znovu nalézt písma"), ("used once", "použito jednou"), ("used {} times", "použito {}×"), ("Compositions that use this item", "Kompozice, které tuto položku používají"), @@ -3926,6 +3932,7 @@ const SPANISH: &[(&str, &str)] = &[ ("No layer selected", "No hay ninguna capa seleccionada"), ("No matching effects or presets", "No hay efectos ni ajustes preestablecidos que coincidan"), ("No matching fonts", "No hay fuentes coincidentes"), + ("Rescan Fonts", "Volver a analizar fuentes"), ("Variable fonts only", "Solo fuentes variables"), ("No model credits were built into this copy.", "No se incluyeron créditos de modelos en esta copia."), ("No nested compositions", "No hay composiciones anidadas"), diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index ae2294f1f..631a75eba 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -642,6 +642,17 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: // Typing goes on into the search field. ui.memory_mut(|m| m.request_focus(search_id)); } + // #1089: fonts installed after launch (dnf, GNOME Fonts, a drop into ~/.fonts) appear + // without restarting EffectCraft: the system scan also runs in the background at + // startup, this re-runs it on demand. + let resc = Rect::from_min_size(pos2(row.rect.max.x - 124.0, row.rect.min.y + 2.0), vec2(120.0, filter_h - 4.0)); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() { + match app.session.execute("text.fonts", json!({"rescan": true})) { + Ok(v) => app.ui.status = format!("{} +{}", tr("Rescan Fonts"), v.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0)), + Err(e) => app.ui.status = e.to_string(), + } + } + app.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let query = state.query.trim().to_lowercase(); let mut seen = std::collections::HashSet::new(); let filtered: Vec<_> = rows diff --git a/crates/ui-egui/tests/ui_font_search.rs b/crates/ui-egui/tests/ui_font_search.rs index 6be69cb7a..0d9c7b79a 100644 --- a/crates/ui-egui/tests/ui_font_search.rs +++ b/crates/ui-egui/tests/ui_font_search.rs @@ -176,3 +176,24 @@ fn font_menu_filters_variable_fonts() { key(&mut h, Key::Escape); assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "filtering changes nothing"); } + +/// #1089: the font menu can re-run the system scan without restarting the app, and the press +/// does not also flip the "Variable fonts only" filter. +#[test] +fn font_menu_rescan_button_adds_fonts_installed_later() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + h.run_steps(3); + assert!(h.state().auto.find("character.font.rescan").is_some(), "the filter row offers a rescan"); + click(&mut h, "character.font.rescan"); + assert!(h.state().ui.status.contains("Rescan Fonts"), "the status reports the rescan: {}", h.state().ui.status); + // The popup stays open, the variable filter did not flip, and the list still has the bundled families. + assert!(h.state().auto.find("character.font.search").is_some(), "the popup stays open"); + let rows = h.state().auto.query("character.font.option."); + assert!(rows.iter().any(|r| r.label == "Inter"), "{:?}", rows.iter().map(|r| &r.label).collect::>()); +} From f676bf08204dc261e9b8f8d978307a0d538e8022 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 22:20:10 +1100 Subject: [PATCH 2/2] Keep the font rescan off the filter label and off the UI thread. The 180 px font menu pinned Rescan Fonts over "Variable fonts only", and the click walked every fontconfig directory on the frame thread. The button is now its own row, and the scan runs on a background thread that the frame polls. The Linux face-count assert is left to the native-fonts gate. --- crates/text/src/fonts.rs | 64 +++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/text_panels.rs | 29 +++++++---- 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 98ed85493..4f6dceb13 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -360,7 +360,8 @@ pub fn scan_system() -> usize { } /// Scan the system font folders again, adding fonts installed since the last scan (faces already -/// known are kept, so font ids stay valid). Returns the number of faces added. +/// known are kept, so font ids stay valid). Returns the number of faces added. This walks every +/// fontconfig directory, so the UI calls [`start_rescan`] instead of running it on the frame thread. pub fn rescan_system() -> usize { let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner()); let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) }; @@ -368,6 +369,60 @@ pub fn rescan_system() -> usize { n } +/// A rescan started by [`start_rescan`], if one is in flight or finished and not yet polled. +#[cfg(not(target_arch = "wasm32"))] +static RESCAN: std::sync::Mutex>> = std::sync::Mutex::new(None); + +/// Start [`rescan_system`] on a background thread. Returns false when a rescan is already +/// running, the thread could not be spawned, or this build has no system font folders (wasm). +pub fn start_rescan() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(|e| e.into_inner()); + if slot.as_ref().is_some_and(|h| !h.is_finished()) { + return false; + } + match std::thread::Builder::new().name("font-rescan".into()).spawn(rescan_system) { + Ok(handle) => { + *slot = Some(handle); + true + } + Err(_) => false, + } + } +} + +/// Whether a background rescan is still walking the font folders. +pub fn rescan_busy() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + RESCAN.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|h| !h.is_finished()) + } +} + +/// Faces added by the latest background rescan, once that thread has finished. `None` while it +/// is still running or when nothing was started. +pub fn poll_rescan() -> Option { + #[cfg(target_arch = "wasm32")] + { + None + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(|e| e.into_inner()); + let finished = slot.as_ref().is_some_and(std::thread::JoinHandle::is_finished); + if finished { slot.take().and_then(|h| h.join().ok()) } else { None } + } +} + /// Start the system font scan on a background thread, so the font menus are complete by the /// time someone opens them without the first open waiting for the scan. Native only; on wasm /// (no threads, no font folders) this does nothing. @@ -943,11 +998,8 @@ mod tests { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); - if cfg!(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))) && std::path::Path::new("/etc/fonts").exists() { - // #1089: a fontconfig desktop (Fedora, Arch, Ubuntu, the CI runner): the scan must - // list the installed system families, not just the bundled three. - assert!(n > 20, "fontconfig system fonts missing: {n}"); - } + // A minimum face count on Linux belongs with the native-fonts gate in #1591. A container + // that has fontconfig and only a handful of faces must still pass this portable test. if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..108c6db23 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1871,6 +1871,7 @@ impl eframe::App for EffectcraftApp { // Settings ▸ General ▸ Language instead of only the first frame. let language = crate::i18n::language(self); crate::i18n::set_current(language); + crate::panels::text_panels::poll_font_rescan(self, ctx); if !self.styled || self.styled_language != language { theme::install(ctx, &self.tokens, language); self.styled_language = language; diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 631a75eba..c6ade5634 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -568,6 +568,17 @@ struct FontSearch { selected: usize, } +/// Finish a font rescan started from the font menu and report how many faces it added. +/// While the scan is still walking font folders, ask for another frame so the menu updates. +pub(crate) fn poll_font_rescan(app: &mut EffectcraftApp, ctx: &egui::Context) { + if effectcraft_engine::text::fonts::rescan_busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + if let Some(n) = effectcraft_engine::text::fonts::poll_rescan() { + app.ui.status = format!("{} +{n}", tr("Rescan Fonts")); + } +} + /// Shared font menu for Character and Properties: search, recent fonts and previews. pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option { let state_id = id.with("search-state"); @@ -642,15 +653,13 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: // Typing goes on into the search field. ui.memory_mut(|m| m.request_focus(search_id)); } - // #1089: fonts installed after launch (dnf, GNOME Fonts, a drop into ~/.fonts) appear - // without restarting EffectCraft: the system scan also runs in the background at - // startup, this re-runs it on demand. - let resc = Rect::from_min_size(pos2(row.rect.max.x - 124.0, row.rect.min.y + 2.0), vec2(120.0, filter_h - 4.0)); - if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() { - match app.session.execute("text.fonts", json!({"rescan": true})) { - Ok(v) => app.ui.status = format!("{} +{}", tr("Rescan Fonts"), v.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0)), - Err(e) => app.ui.status = e.to_string(), - } + // #1089: fonts installed after launch appear without restarting. The button is its own + // row: the popup is only 180 px wide, so a control pinned to the filter row covers + // "Variable fonts only". The scan itself runs off the UI thread (`poll_font_rescan`). + let rescan_h = 22.0; + let (resc, _) = ui.allocate_exact_size(vec2(ui.available_width(), rescan_h), egui::Sense::hover()); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() && effectcraft_engine::text::fonts::start_rescan() { + app.ui.status = format!("{}…", tr("Rescan Fonts")); } app.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let query = state.query.trim().to_lowercase(); @@ -683,7 +692,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: } // Give the result list its actual height even when the previous query had // only one row. A cached tiny Area must not collapse a reopened long list. - let filter_room = filter_h + ui.spacing().item_spacing.y; + let filter_room = filter_h + rescan_h + ui.spacing().item_spacing.y * 2.0; let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, (max_height - filter_room).max(28.0)); // A minimum, from below the filter row (a maximum would move the cursor back up). ui.set_min_height(height);