diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 424c274f..9c5f1235 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { "Font List", [], None, - "{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)", + "{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), as the font menus list them (without the system's hidden families, whose names start with a dot; they still resolve by name); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)", always, font_list ), @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Refresh Font List", [], None, - "{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families available, installed faces found)", + "{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families listed, installed faces found)", always, rescan ), @@ -196,7 +196,7 @@ fn font_list(_: &mut Session, p: &Value) -> Result { let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string()); Ok(json!({ "family": name, "styles": db.styles(family) })) } - None => Ok(json!({ "families": *db.family_list() })), + None => Ok(json!({ "families": *db.menu_family_list() })), } } @@ -229,7 +229,7 @@ fn rescan(s: &mut Session, _: &Value) -> Result { } d.revision += 1; } - Ok(json!({ "families": db.family_list().len(), "faces": faces })) + Ok(json!({ "families": db.menu_family_list().len(), "faces": faces })) } #[cfg(test)] diff --git a/crates/engine/src/tests_fontlist.rs b/crates/engine/src/tests_fontlist.rs index 01aaecfd..7991e73a 100644 --- a/crates/engine/src/tests_fontlist.rs +++ b/crates/engine/src/tests_fontlist.rs @@ -14,14 +14,14 @@ fn names(v: &Value) -> Vec { fn the_font_list_names_every_available_family_with_its_styles() { let mut s = Session::new(); let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]); - assert_eq!(fams, FontDb::global().families()); + assert_eq!(fams, *FontDb::global().menu_family_list()); for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] { assert!(fams.iter().any(|x| x == f), "{f}"); } // Every installed family, though nothing asked for system fonts. let installed = FontDb::with_font_dirs(system_font_dirs()); installed.load_system_fonts(); - let missing: Vec = installed.families().into_iter().filter(|f| !fams.contains(f)).collect(); + let missing: Vec = installed.menu_family_list().iter().filter(|f| !fams.contains(f)).cloned().collect(); assert!(missing.is_empty(), "{missing:?}"); let r = s.execute("text.fontList", &json!({"family": "source sans 3"})).unwrap(); assert_eq!(r["family"], "Source Sans 3"); @@ -38,7 +38,7 @@ fn refreshing_the_font_list_redraws_type_without_editing_the_document() { s.doc_mut().unwrap().mark_saved(); let revision = s.doc().unwrap().revision; let r = s.execute("text.rescanFonts", &json!({})).unwrap(); - assert_eq!(r["families"], json!(FontDb::global().families().len())); + assert_eq!(r["families"], json!(FontDb::global().menu_family_list().len())); assert!(r["faces"].is_u64()); let d = s.doc().unwrap(); assert!(d.revision > revision, "the canvas redraws"); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index ff5fa7a1..a0df4bfd 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -167,6 +167,7 @@ pub struct FontDb { cataloged: std::sync::OnceLock<()>, /// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned. family_cache: Mutex>>, + menu_cache: Mutex>, generation: AtomicU64, /// System fallback state: characters no system font covers. #[cfg(not(target_arch = "wasm32"))] @@ -379,6 +380,9 @@ fn style_italic(style: &str) -> bool { s.contains("italic") || s.contains("oblique") || s == "it" } +/// [`FontDb::menu_family_list`], with the [`FontDb::family_list`] it was built from. +type MenuFamilies = (Arc<[String]>, Arc<[String]>); + impl FontDb { /// A database holding the bundled fonts, whose system font scan reads `font_dirs`. pub fn with_font_dirs(font_dirs: Vec) -> Self { @@ -401,6 +405,7 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock::new(), family_cache: Mutex::new(None), + menu_cache: Mutex::new(None), generation: AtomicU64::new(0), #[cfg(not(target_arch = "wasm32"))] sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), @@ -468,6 +473,23 @@ impl FontDb { list } + /// The families font menus and lists show: [`FontDb::family_list`] without the system's hidden + /// families, whose names start with "." (macOS keeps ".SF NS", ".LastResort" and others for its + /// own interface, and Mac apps don't list them). Those still resolve by name, for documents and + /// fallbacks that name them. Cheap to call every frame. + pub fn menu_family_list(&self) -> Arc<[String]> { + let all = self.family_list(); + let mut cache = self.menu_cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((from, list)) = cache.as_ref() + && Arc::ptr_eq(from, &all) + { + return list.clone(); + } + let list: Arc<[String]> = all.iter().filter(|f| !f.starts_with('.')).cloned().collect(); + *cache = Some((all, list.clone())); + list + } + /// Style names available for `family`: upright styles by weight, then italics. pub fn styles(&self, family: &str) -> Vec { let mut v: Vec<(bool, f32, String)> = diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index 5b9c96e1..d4008fd0 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -9,9 +9,15 @@ const FAMILY: &str = "Sysfont Sans3"; /// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name). fn renamed(file: &str) -> Vec { + renamed_to(file, FAMILY) +} + +/// A bundled Source Sans 3 file renamed `family`, which is as long as the original name. +fn renamed_to(file: &str, family: &str) -> Vec { + assert_eq!(family.len(), "Source Sans 3".len()); let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); - for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] { + for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] { let mut i = 0; while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { data[i + at..i + at + to.len()].copy_from_slice(&to); @@ -132,6 +138,30 @@ fn the_family_list_is_shared_until_fonts_change() { assert!(!Arc::ptr_eq(&a, &b) && has(&b, FAMILY) && !has(&a, FAMILY)); } +#[test] +fn font_lists_leave_out_hidden_system_families_which_still_resolve() { + // macOS names the faces it keeps for its own interface with a leading "." (".SF NS"). + const HIDDEN: &str = ".Sysfont Sans"; + const DOTTED: &str = "Sysfont.Sans3"; + let dir = font_dir("hidden"); + std::fs::write(dir.join("Hidden.ttf"), renamed_to("SourceSans3-Regular.ttf", HIDDEN)).unwrap(); + std::fs::write(dir.join("Dotted.ttf"), renamed_to("SourceSans3-Regular.ttf", DOTTED)).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + let listed = db.menu_family_list(); + assert!(has(&listed, FAMILY) && has(&listed, FALLBACK_FAMILY)); + assert!(!has(&listed, HIDDEN), "a leading dot hides a family: {listed:?}"); + assert!(has(&listed, DOTTED), "a dot elsewhere doesn't"); + assert_eq!(listed.len() + 1, db.family_list().len(), "only the hidden family is left out"); + assert!(Arc::ptr_eq(&listed, &db.menu_family_list()), "shared until the fonts change"); + // Documents and fallbacks that name it still find it. + assert!(has(&db.families(), HIDDEN) && db.has_family(HIDDEN)); + assert_eq!(db.find_family(HIDDEN).as_deref(), Some(HIDDEN)); + assert_eq!(db.face(HIDDEN, "Regular").unwrap().family, HIDDEN); + // Loading it changed the fonts: the list is built again, still without it. + let again = db.menu_family_list(); + assert!(!Arc::ptr_eq(&listed, &again) && *listed == *again); +} + #[test] fn installed_styles_of_a_loaded_family_load_when_asked_for() { let dir = font_dir("styles"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e00ff340..f4b77734 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2485,7 +2485,7 @@ fn font_items() -> Vec { let db = vectorcraft_text::FontDb::global(); // Read first: fonts that load meanwhile make the next frame build the list again. let generation = db.generation(); - let fams = db.family_list(); + let fams = db.menu_family_list(); let (Ok(mut names), Ok(mut items)) = (NAMES.lock(), ITEMS.lock()) else { return vec![] }; if items.0 != generation { let list = fams diff --git a/crates/ui-egui/src/tests_fonts.rs b/crates/ui-egui/src/tests_fonts.rs index aa59143f..bbca4b1c 100644 --- a/crates/ui-egui/src/tests_fonts.rs +++ b/crates/ui-egui/src/tests_fonts.rs @@ -16,10 +16,10 @@ fn type_font_menu_lists_every_available_family() { menus::menu_entries(app).into_iter().filter(|e| e.path == ["Type", "Font"]).map(|e| e.label).collect() }; let labels = fonts(&app); - assert_eq!(labels, FontDb::global().families()); + assert_eq!(labels, *FontDb::global().menu_family_list()); let installed = FontDb::with_font_dirs(system_font_dirs()); installed.load_system_fonts(); - let missing: Vec = installed.families().into_iter().filter(|f| !labels.contains(f)).collect(); + let missing: Vec = installed.menu_family_list().iter().filter(|f| !labels.contains(f)).cloned().collect(); assert!(missing.is_empty(), "installed but not in Type › Font: {missing:?}"); // The menu is built every frame, from a list built once. assert_eq!(fonts(&app), labels); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4e7e1d49..d3ede10f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -475,7 +475,7 @@ pub fn font_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, cu } let query = search_field(ui, field, "Search").to_lowercase(); let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); - let families = vectorcraft_text::FontDb::global().family_list(); + let families = vectorcraft_text::FontDb::global().menu_family_list(); let (mut chosen, mut first) = (None, None); // The search field stays put over the list as it scrolls. menu_scroll(ui, |ui| { diff --git a/docs/mcp.md b/docs/mcp.md index a7d7d456..58815107 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1741,8 +1741,10 @@ tone (mono, or CMYK screens multiplied over each other), clipped to the art's ou Type can use the bundled fonts, fonts added to the session and the fonts installed on the system (none on the web). The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup by family name), so opening, placing, pasting and importing files find them whatever ran before. `text.fontList` -lists every family available, the installed ones included; with `family` it gives that family's styles (upright -by weight, then italics) and fails when the family isn't available. `text.rescanFonts` (Character panel menu › +lists every family available, the installed ones included, as the font menus do: without the system's hidden +families, whose names start with "." (macOS's ".SF NS", ".LastResort"), which still resolve when a document names +them. With `family` it gives that family's styles (upright by weight, then italics) and fails when the family isn't +available. `text.rescanFonts` (Character panel menu › Refresh Font List) scans the font folders again, for fonts installed or removed since the app started: type set in a font that became available redraws in it, without editing the document.