mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Fonts: the font menus leave out macOS's hidden "." families
macOS keeps faces for its own interface under family names that start with
"." (".SF NS", ".LastResort", ".Aqua Kana"…); Mac apps don't list them. Since
the font menus list every installed family (#36), they showed 74 of them on a
stock macOS, at the top of every list.
- FontDb::menu_family_list: family_list without names that start with ".",
cached like it. Only a leading dot hides a family.
- Type › Font, the family dropdowns (Character, Properties), text.fontList and
Refresh Font List's count use it.
- Lookups by name still see every family: documents and fallbacks that name
a hidden family still find it (face, has_family, find_family, families,
text.fontList {family}, Fonts in Document's missing check).
- Test: tests_sysfonts::font_lists_leave_out_hidden_system_families_which_still_resolve
(a temporary font folder); the engine and UI font-list tests compare with
the listed families.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
856e36c694
commit
fcc9414480
8 files changed
+68
-14
No files matched your search
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
d.revision += 1;
|
||||
}
|
||||
Ok(json!({ "families": db.family_list().len(), "faces": faces }))
|
||||
Ok(json!({ "families": db.menu_family_list().len(), "faces": faces }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -14,14 +14,14 @@ fn names(v: &Value) -> Vec<String> {
|
||||
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<String> = installed.families().into_iter().filter(|f| !fams.contains(f)).collect();
|
||||
let missing: Vec<String> = 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");
|
||||
|
||||
@@ -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<Option<Arc<[String]>>>,
|
||||
menu_cache: Mutex<Option<MenuFamilies>>,
|
||||
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<PathBuf>) -> 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<String> {
|
||||
let mut v: Vec<(bool, f32, String)> =
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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::<Vec<u8>>();
|
||||
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");
|
||||
|
||||
@@ -2485,7 +2485,7 @@ fn font_items() -> Vec<Item> {
|
||||
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
|
||||
|
||||
@@ -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<String> = installed.families().into_iter().filter(|f| !labels.contains(f)).collect();
|
||||
let missing: Vec<String> = 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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
+4
-2
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user