Skip to main content

native_theme/
fonts.rs

1//! Font matching, and the platform's own typeface for a family.
2//!
3//! [`select_face`](crate::fonts::select_face) is the one matcher — CSS Fonts Module Level 4 §5.1
4//! "Localized name matching" and §5.2 "Matching font styles" over a list of
5//! face descriptions — and needs no font database, so it is compiled
6//! unconditionally. Behind the `system-fonts` feature, `system_face` runs
7//! it over the system font database, loaded once per process, and returns
8//! the chosen face's bytes; on macOS the system UI font is found by its file
9//! through Core Text, because no font database files it under the name the
10//! platform documents. `system_face` substitutes no family; `substitute_family`,
11//! behind the same feature, names the family the platform draws in place of
12//! one it has no face of — on Linux and the other fontconfig platforms,
13//! fontconfig's best match, which `system_face` then finds by name.
14
15use crate::theme::FontStyle;
16
17/// What [`select_face`] knows about one candidate face.
18#[derive(Clone, Copy, Debug)]
19pub struct FaceTraits<'a> {
20    /// Every family name the face records.
21    pub families: &'a [&'a str],
22    /// OS/2 `usWidthClass`, `1..=9`; `5` is normal.
23    pub width: u16,
24    /// The face's style.
25    pub style: FontStyle,
26    /// OS/2 `usWeightClass`, the CSS weight.
27    pub weight: u16,
28}
29
30/// The index in `faces` of the face CSS Fonts Module Level 4 selects for
31/// `family`, `weight` and `style`, or `None` where no face records `family`.
32///
33/// Only faces one of whose family names equals `family` under Unicode
34/// default caseless matching (§5.1 "Localized name matching"; `unicase`)
35/// are candidates, and **no other family is ever substituted**: an empty
36/// candidate set is `None`. Among the candidates the three steps of §5.2
37/// "Matching font styles" narrow the set in order, with the model's
38/// normal width — `font-width`: normal first, then narrower widths
39/// descending, then wider ascending; `font-style`: `Italic` takes italic,
40/// then oblique, then normal, `Oblique` oblique, italic, normal, and
41/// `Normal` normal, oblique, italic; `font-weight`: for a desired weight
42/// inclusively between 400 and 500, weights at or above it ascending up to
43/// and including 500, then below it descending, then above 500; below 400,
44/// weights at or below it descending, then above it ascending; above 500,
45/// weights at or above it ascending, then below it descending — and the
46/// first face in `faces` order among those left is returned.
47///
48/// fontdb's own `query` is deliberately not used: it compares family names
49/// case-sensitively, and its weight step follows Level 3 with a `450`
50/// cut-off, which picks `300` where Level 4 picks `450` for a desired `400`.
51#[must_use]
52pub fn select_face(
53    faces: &[FaceTraits<'_>],
54    family: &str,
55    weight: u16,
56    style: FontStyle,
57) -> Option<usize> {
58    let wanted = unicase::UniCase::new(family);
59    let mut candidates: Vec<(usize, &FaceTraits<'_>)> = faces
60        .iter()
61        .enumerate()
62        .filter(|(_, face)| {
63            face.families
64                .iter()
65                .any(|name| unicase::UniCase::new(*name) == wanted)
66        })
67        .collect();
68    if candidates.is_empty() {
69        return None;
70    }
71    keep_best(&mut candidates, |face| width_rank(face.width));
72    keep_best(&mut candidates, |face| style_rank(face.style, style));
73    keep_best(&mut candidates, |face| weight_rank(face.weight, weight));
74    candidates.first().map(|(index, _)| *index)
75}
76
77/// Keep the candidates of the lowest rank.
78fn keep_best(
79    candidates: &mut Vec<(usize, &FaceTraits<'_>)>,
80    rank: impl Fn(&FaceTraits<'_>) -> (u8, u16),
81) {
82    if let Some(best) = candidates.iter().map(|(_, face)| rank(face)).min() {
83        candidates.retain(|(_, face)| rank(face) == best);
84    }
85}
86
87/// §5.2 `font-width` for the normal width: `(group, distance)`, lower first.
88fn width_rank(width: u16) -> (u8, u16) {
89    const NORMAL: u16 = 5;
90    match width.cmp(&NORMAL) {
91        std::cmp::Ordering::Equal => (0, 0),
92        std::cmp::Ordering::Less => (1, NORMAL.saturating_sub(width)),
93        std::cmp::Ordering::Greater => (2, width),
94    }
95}
96
97/// §5.2 `font-style`: the position of `face` in the order `wanted` sets.
98fn style_rank(face: FontStyle, wanted: FontStyle) -> (u8, u16) {
99    let rank = match (wanted, face) {
100        (w, f) if w == f => 0,
101        (FontStyle::Italic, FontStyle::Oblique)
102        | (FontStyle::Oblique, FontStyle::Italic)
103        | (FontStyle::Normal, FontStyle::Oblique) => 1,
104        _ => 2,
105    };
106    (rank, 0)
107}
108
109/// §5.2 `font-weight`: the three quoted rules as `(group, distance)`.
110fn weight_rank(face: u16, wanted: u16) -> (u8, u16) {
111    let below = wanted.saturating_sub(face);
112    match wanted {
113        400..=500 if face >= wanted && face <= 500 => (0, face),
114        400..=500 if face < wanted => (1, below),
115        400..=500 => (2, face),
116        0..=399 if face <= wanted => (0, below),
117        0..=399 => (1, face),
118        _ if face >= wanted => (0, face),
119        _ => (1, below),
120    }
121}
122
123/// Whether `family` names the macOS system UI font: caselessly equal to
124/// "SF Pro", the name the platform documents for it
125/// (`docs/platform-facts.md:59-67`) and `macos-sonoma` states. A pure
126/// predicate, on every platform, with no font database: `system_face`'s
127/// macOS branch asks it beside its Core Text family comparison, and the
128/// gpui connector asks it alone to map the family to gpui's own alias.
129#[must_use]
130pub fn is_macos_system_ui_family(family: &str) -> bool {
131    unicase::UniCase::new(family) == unicase::UniCase::new("SF Pro")
132}
133
134/// A face [`system_face`] chose: its bytes, and what fontdb records for it.
135#[cfg(feature = "system-fonts")]
136#[derive(Clone, Debug, PartialEq, Eq)]
137pub struct SystemFace {
138    /// The face's file, whole: for a face of a collection (`.ttc`), the
139    /// collection, addressed by `index`.
140    pub data: std::sync::Arc<[u8]>,
141    /// The face's index in `data`.
142    pub index: u32,
143    /// The face's own family name as fontdb records it: the first of its
144    /// `FaceInfo::families`, "always English US, unless it's missing from
145    /// the font" (fontdb 0.23.0 `src/lib.rs` lines 826–827). A toolkit that draws
146    /// by name draws this one.
147    pub family: std::sync::Arc<str>,
148    /// The face's own weight, OS/2 `usWeightClass`.
149    pub weight: u16,
150    /// The face's own style.
151    pub style: FontStyle,
152}
153
154/// The system font database, loaded once per process and shared by every
155/// [`system_face`] call. A font installed while the process runs is seen
156/// after a restart, as with iced's own font system.
157#[cfg(feature = "system-fonts")]
158fn database() -> &'static fontdb::Database {
159    static DB: std::sync::OnceLock<fontdb::Database> = std::sync::OnceLock::new();
160    DB.get_or_init(|| {
161        let mut db = fontdb::Database::new();
162        db.load_system_fonts();
163        db
164    })
165}
166
167#[cfg(feature = "system-fonts")]
168fn style_of(style: fontdb::Style) -> FontStyle {
169    match style {
170        fontdb::Style::Normal => FontStyle::Normal,
171        fontdb::Style::Italic => FontStyle::Italic,
172        fontdb::Style::Oblique => FontStyle::Oblique,
173    }
174}
175
176/// [`select_face`] over `db`'s faces; the chosen face's bytes copied once.
177#[cfg(feature = "system-fonts")]
178fn face_in(
179    db: &fontdb::Database,
180    family: &str,
181    weight: u16,
182    style: FontStyle,
183) -> Option<SystemFace> {
184    let infos: Vec<&fontdb::FaceInfo> = db.faces().collect();
185    let names: Vec<Vec<&str>> = infos
186        .iter()
187        .map(|info| {
188            info.families
189                .iter()
190                .map(|(name, _)| name.as_str())
191                .collect()
192        })
193        .collect();
194    let traits: Vec<FaceTraits<'_>> = infos
195        .iter()
196        .zip(&names)
197        .map(|(info, names)| FaceTraits {
198            families: names,
199            width: info.stretch.to_number(),
200            style: style_of(info.style),
201            weight: info.weight.0,
202        })
203        .collect();
204    let chosen = select_face(&traits, family, weight, style)?;
205    let info = infos.get(chosen)?;
206    let (data, index) = db.with_face_data(info.id, |data, index| {
207        (std::sync::Arc::<[u8]>::from(data), index)
208    })?;
209    let (recorded, _) = info.families.first()?;
210    Some(SystemFace {
211        data,
212        index,
213        family: std::sync::Arc::from(recorded.as_str()),
214        weight: info.weight.0,
215        style: style_of(info.style),
216    })
217}
218
219/// The system's own face for `family`, `weight` and `style`, or `None`.
220///
221/// The first call in a process loads the system font database
222/// (`fontdb::Database::load_system_fonts`), which every later call shares;
223/// each call runs [`select_face`] over its faces and copies the chosen
224/// face's file into the returned [`SystemFace`], so call it when a theme
225/// changes, not per frame. A family the system has no face of is `None`:
226/// nothing is substituted here; [`substitute_family`] names the family the
227/// platform draws in its place, to ask this function for next. On macOS a
228/// `family` that names the system UI
229/// font — [`is_macos_system_ui_family`], or caselessly Core Text's own
230/// family name for it — is found by its file instead: fontdb files that
231/// font under `.SF NS`, not under the platform's name, so Core Text is
232/// asked which file its system font is (upright, or its italic for an
233/// `Italic` or `Oblique` style), that file alone is loaded, and
234/// [`select_face`] picks among its faces by width, style and weight under
235/// the family the file itself records; a `None` from Core Text, or a file
236/// that yields no face, is `None` here too. Every other family, on macOS as
237/// elsewhere, goes through the name lookup.
238#[cfg(feature = "system-fonts")]
239#[must_use]
240pub fn system_face(family: &str, weight: u16, style: FontStyle) -> Option<SystemFace> {
241    #[cfg(target_os = "macos")]
242    {
243        let italic = matches!(style, FontStyle::Italic | FontStyle::Oblique);
244        let ui = crate::macos::system_ui_font(italic);
245        let names_it = is_macos_system_ui_family(family)
246            || ui.as_ref().is_some_and(|font| {
247                unicase::UniCase::new(family) == unicase::UniCase::new(font.family.as_str())
248            });
249        if names_it {
250            return ui.and_then(|font| face_in_file(&font.path, weight, style));
251        }
252    }
253    face_in(database(), family, weight, style)
254}
255
256/// The face of one font file — the macOS system UI font's — selected by
257/// width, style and weight under the family the file's first face records.
258#[cfg(all(feature = "system-fonts", target_os = "macos"))]
259fn face_in_file(path: &std::path::Path, weight: u16, style: FontStyle) -> Option<SystemFace> {
260    let mut db = fontdb::Database::new();
261    db.load_font_file(path).ok()?;
262    let recorded = db.faces().next()?.families.first()?.0.clone();
263    face_in(&db, &recorded, weight, style)
264}
265
266/// The family the platform draws in place of `family`, or `None` where the
267/// platform has that family, gives no answer, or its fallback is not
268/// implemented here.
269///
270/// On Linux and the other fontconfig platforms — Unix other than macOS, iOS
271/// and Android — fontconfig is asked through its own `fc-match` tool, run as
272/// `fc-match --format=%{family[0]} <pattern>`: `fc-match` parses the pattern
273/// with `FcNameParse`, runs `FcConfigSubstitute` and `FcDefaultSubstitute`
274/// on it and prints `FcFontMatch`'s best match (fontconfig 2.15.0
275/// `fc-match/fc-match.c` lines 169, 188–189 and 218), which is the font
276/// every native application of the system gets for that family, and
277/// `%{family[0]}` is that match's first family name (`FcPatternFormat(3)`).
278/// The pattern is `family` with the characters fontconfig's name syntax
279/// reserves in a family — `\`, `-`, `:` and `,` — each preceded by a `\`
280/// (fontconfig user's guide, "Font Names"). `None` when `fc-match` cannot be run or fails,
281/// prints nothing, or prints a family caselessly equal to `family`: the
282/// system has that family, and [`system_face`] finds it by name. Each call
283/// starts a process, so call it when a theme changes, not per frame.
284///
285/// macOS, Windows and every other platform: `None`. Their own fallback for a
286/// missing family is not implemented: no code path of this crate reads it.
287#[cfg(feature = "system-fonts")]
288#[must_use]
289pub fn substitute_family(family: &str) -> Option<String> {
290    platform_substitute(family)
291}
292
293#[cfg(all(
294    feature = "system-fonts",
295    unix,
296    not(target_os = "macos"),
297    not(target_os = "ios"),
298    not(target_os = "android")
299))]
300fn platform_substitute(family: &str) -> Option<String> {
301    let output = std::process::Command::new("fc-match")
302        .arg("--format=%{family[0]}")
303        .arg(fontconfig_family(family))
304        .output()
305        .ok()?;
306    if !output.status.success() {
307        return None;
308    }
309    let printed = String::from_utf8(output.stdout).ok()?;
310    let matched = printed.trim();
311    if matched.is_empty() || unicase::UniCase::new(matched) == unicase::UniCase::new(family) {
312        return None;
313    }
314    Some(matched.to_owned())
315}
316
317#[cfg(all(
318    feature = "system-fonts",
319    not(all(
320        unix,
321        not(target_os = "macos"),
322        not(target_os = "ios"),
323        not(target_os = "android")
324    ))
325))]
326fn platform_substitute(_family: &str) -> Option<String> {
327    None
328}
329
330/// `family` as a fontconfig pattern's family: "The '\\', '-', ':' and ','
331/// characters in family names must be preceded by a '\\' character to avoid
332/// having them misinterpreted" (fontconfig user's guide, "Font Names";
333/// `FcNameParse` reads the families up to an unescaped `-`, `,` or `:`, and
334/// takes the character after a `\` as it is, fontconfig 2.15.0
335/// `src/fcname.c` lines 415–443 and 467).
336#[cfg(all(
337    feature = "system-fonts",
338    unix,
339    not(target_os = "macos"),
340    not(target_os = "ios"),
341    not(target_os = "android")
342))]
343fn fontconfig_family(family: &str) -> String {
344    let mut pattern = String::with_capacity(family.len());
345    for c in family.chars() {
346        if matches!(c, '\\' | '-' | ':' | ',') {
347            pattern.push('\\');
348        }
349        pattern.push(c);
350    }
351    pattern
352}
353
354#[cfg(test)]
355#[allow(
356    clippy::unwrap_used,
357    clippy::expect_used,
358    clippy::panic,
359    clippy::indexing_slicing,
360    reason = "a test fails by panicking"
361)]
362mod tests {
363    use super::*;
364
365    const N: FontStyle = FontStyle::Normal;
366    const I: FontStyle = FontStyle::Italic;
367    const O: FontStyle = FontStyle::Oblique;
368
369    fn face(
370        families: &'static [&'static str],
371        width: u16,
372        style: FontStyle,
373        weight: u16,
374    ) -> FaceTraits<'static> {
375        FaceTraits {
376            families,
377            width,
378            style,
379            weight,
380        }
381    }
382
383    /// CSS Fonts Level 4 §5.1: names match case-insensitively by Unicode
384    /// default caseless matching; a family with no face is `None`, never
385    /// another family.
386    #[test]
387    fn the_family_is_matched_caselessly_and_never_substituted() {
388        let faces = [face(&["Inter"], 5, N, 400), face(&["Roboto"], 5, N, 400)];
389        assert_eq!(select_face(&faces, "inter", 400, N), Some(0));
390        assert_eq!(select_face(&faces, "INTER", 400, N), Some(0));
391        assert_eq!(select_face(&faces, "Helvetica", 400, N), None);
392        assert_eq!(select_face(&[], "Inter", 400, N), None);
393    }
394
395    /// Any of the names a face records matches, not only the first.
396    #[test]
397    fn any_recorded_family_name_matches() {
398        let faces = [face(&["Segoe UI", "Segoe UI Regular"], 5, N, 400)];
399        assert_eq!(select_face(&faces, "segoe ui regular", 400, N), Some(0));
400    }
401
402    /// §5.2 `font-width`, with the model's normal width `5`: normal first,
403    /// then narrower widths descending, then wider ascending.
404    #[test]
405    fn width_prefers_normal_then_narrower_descending_then_wider_ascending() {
406        let normal = [
407            face(&["F"], 3, N, 400),
408            face(&["F"], 7, N, 400),
409            face(&["F"], 5, N, 400),
410        ];
411        assert_eq!(select_face(&normal, "F", 400, N), Some(2));
412        let narrow = [
413            face(&["F"], 2, N, 400),
414            face(&["F"], 7, N, 400),
415            face(&["F"], 3, N, 400),
416        ];
417        assert_eq!(select_face(&narrow, "F", 400, N), Some(2));
418        let wide = [face(&["F"], 8, N, 400), face(&["F"], 6, N, 400)];
419        assert_eq!(select_face(&wide, "F", 400, N), Some(1));
420    }
421
422    /// §5.2 `font-style`: italic → italic, oblique, normal; oblique →
423    /// oblique, italic, normal; normal → normal, oblique, italic.
424    #[test]
425    fn style_falls_back_in_the_css_order() {
426        let no_italic = [face(&["F"], 5, N, 400), face(&["F"], 5, O, 400)];
427        assert_eq!(select_face(&no_italic, "F", 400, I), Some(1));
428        let no_oblique = [face(&["F"], 5, N, 400), face(&["F"], 5, I, 400)];
429        assert_eq!(select_face(&no_oblique, "F", 400, O), Some(1));
430        let no_normal = [face(&["F"], 5, I, 400), face(&["F"], 5, O, 400)];
431        assert_eq!(select_face(&no_normal, "F", 400, N), Some(1));
432        let exact = [face(&["F"], 5, O, 400), face(&["F"], 5, I, 400)];
433        assert_eq!(select_face(&exact, "F", 400, I), Some(1));
434    }
435
436    /// §5.2 `font-weight`, desired weight inclusively between 400 and 500:
437    /// weights at or above the target ascending up to and including 500,
438    /// then below descending, then above 500 ascending. Level 4 picks 450
439    /// for 400 from {300, 450}, where fontdb's Level 3 rule picks 300
440    /// (fontdb 0.23.0 `src/lib.rs` lines 1207–1208).
441    #[test]
442    fn weight_between_400_and_500_climbs_to_500_first() {
443        let faces = [face(&["F"], 5, N, 300), face(&["F"], 5, N, 450)];
444        assert_eq!(select_face(&faces, "F", 400, N), Some(1));
445        let faces = [face(&["F"], 5, N, 600), face(&["F"], 5, N, 300)];
446        assert_eq!(select_face(&faces, "F", 400, N), Some(1));
447        let faces = [face(&["F"], 5, N, 400), face(&["F"], 5, N, 500)];
448        assert_eq!(select_face(&faces, "F", 450, N), Some(1));
449        let faces = [face(&["F"], 5, N, 700), face(&["F"], 5, N, 400)];
450        assert_eq!(select_face(&faces, "F", 500, N), Some(1));
451    }
452
453    /// Desired weight below 400: at or below the target descending, then
454    /// above ascending.
455    #[test]
456    fn weight_below_400_descends_first() {
457        let faces = [face(&["F"], 5, N, 350), face(&["F"], 5, N, 200)];
458        assert_eq!(select_face(&faces, "F", 300, N), Some(1));
459        let faces = [face(&["F"], 5, N, 700), face(&["F"], 5, N, 350)];
460        assert_eq!(select_face(&faces, "F", 300, N), Some(1));
461    }
462
463    /// Desired weight above 500: at or above the target ascending, then
464    /// below descending.
465    #[test]
466    fn weight_above_500_ascends_first() {
467        let faces = [face(&["F"], 5, N, 600), face(&["F"], 5, N, 800)];
468        assert_eq!(select_face(&faces, "F", 700, N), Some(1));
469        let faces = [face(&["F"], 5, N, 500), face(&["F"], 5, N, 600)];
470        assert_eq!(select_face(&faces, "F", 700, N), Some(1));
471    }
472
473    /// The three steps narrow in order, and a tie keeps the first face.
474    #[test]
475    fn width_outranks_style_which_outranks_weight() {
476        let faces = [
477            face(&["F"], 3, N, 400),
478            face(&["F"], 5, I, 700),
479            face(&["F"], 5, I, 400),
480        ];
481        assert_eq!(select_face(&faces, "F", 400, N), Some(2));
482        let tie = [face(&["F"], 5, N, 400), face(&["F"], 5, N, 400)];
483        assert_eq!(select_face(&tie, "F", 400, N), Some(0));
484    }
485
486    /// The macOS system UI font's documented name, caselessly, and nothing
487    /// else (`docs/platform-facts.md:59-67`).
488    #[test]
489    fn the_macos_system_ui_family_is_sf_pro_caselessly() {
490        assert!(is_macos_system_ui_family("SF Pro"));
491        assert!(is_macos_system_ui_family("sf pro"));
492        assert!(!is_macos_system_ui_family("SF Mono"));
493        assert!(!is_macos_system_ui_family("SF Pro Text"));
494        assert!(!is_macos_system_ui_family("Inter"));
495    }
496
497    /// fontconfig's name-syntax specials in a family — `\`, `-`, `:` and `,` —
498    /// are each preceded by a `\`; every other character, a space included,
499    /// is kept as it is (fontconfig user's guide, "Font Names").
500    #[cfg(all(
501        feature = "system-fonts",
502        unix,
503        not(target_os = "macos"),
504        not(target_os = "ios"),
505        not(target_os = "android")
506    ))]
507    #[test]
508    fn a_fontconfig_family_escapes_the_name_syntax_specials() {
509        assert_eq!(fontconfig_family("JetBrains Mono"), "JetBrains Mono");
510        assert_eq!(fontconfig_family("sans-serif"), r"sans\-serif");
511        assert_eq!(fontconfig_family(r"a\b:c,d-e"), r"a\\b\:c\,d\-e");
512        assert_eq!(fontconfig_family(r"\\"), r"\\\\");
513        assert_eq!(fontconfig_family(""), "");
514        assert_eq!(
515            fontconfig_family("Noto Sans CJK 日本"),
516            "Noto Sans CJK 日本"
517        );
518    }
519
520    #[cfg(feature = "system-fonts")]
521    mod system {
522        use super::super::*;
523
524        /// A family no system has is drawn in fontconfig's best match, a
525        /// family the system has, so its substitute is itself `None`. On a
526        /// fontconfig platform where `fc-match` cannot be run, and on every
527        /// other platform, there is no substitute.
528        #[test]
529        fn a_missing_family_has_a_substitute_the_system_has() {
530            let missing = format!("native-theme-no-such-family-{}", std::process::id());
531            let substitute = substitute_family(&missing);
532            let fontconfig_platform = cfg!(all(
533                unix,
534                not(target_os = "macos"),
535                not(target_os = "ios"),
536                not(target_os = "android")
537            ));
538            let fc_match_runs = std::process::Command::new("fc-match")
539                .arg("--version")
540                .output()
541                .is_ok_and(|output| output.status.success());
542            if !(fontconfig_platform && fc_match_runs) {
543                println!(
544                    "a_missing_family_has_a_substitute_the_system_has: fc-match not run here, \
545                     substitute {substitute:?}"
546                );
547                assert_eq!(substitute, None);
548                return;
549            }
550            let substitute = substitute.expect("fc-match names a substitute");
551            assert!(
552                system_face(&substitute, 400, FontStyle::Normal).is_some(),
553                "the substitute {substitute:?} has a system face"
554            );
555            assert_eq!(substitute_family(&substitute), None);
556        }
557
558        /// Never substituted: a family no system has is `None`.
559        #[test]
560        fn a_family_no_system_has_is_none() {
561            assert_eq!(
562                system_face("native-theme-no-such-family-7f3c1a", 400, FontStyle::Normal),
563                None
564            );
565        }
566
567        /// A name that differs only in case matches, and the face reports
568        /// the family as fontdb records it, not as asked. The family is
569        /// taken from the loaded database itself, so the clause holds on
570        /// every runner that has a font with a lower-case letter in its
571        /// family name; a runner with none fails here, and says so.
572        #[test]
573        fn a_family_matches_caselessly_and_reports_its_recorded_name() {
574            let db = database();
575            let (recorded, weight, style) = db
576                .faces()
577                .find_map(|info| {
578                    let (name, _) = info.families.first()?;
579                    name.chars()
580                        .any(char::is_lowercase)
581                        .then(|| (name.clone(), info.weight.0, style_of(info.style)))
582                })
583                .expect("the font database holds a face whose family has a lower-case letter");
584            let asked = recorded.to_uppercase();
585            assert_ne!(asked, recorded);
586            let face = system_face(&asked, weight, style)
587                .expect("the upper-cased family name finds the face");
588            assert_ne!(
589                face.family.as_ref(),
590                asked.as_str(),
591                "not the spelling asked for"
592            );
593            let recorded_by_a_face_of_that_family = db.faces().any(|info| {
594                info.families
595                    .first()
596                    .is_some_and(|(first, _)| **first == *face.family)
597                    && info.families.iter().any(|(name, _)| {
598                        unicase::UniCase::new(name.as_str())
599                            == unicase::UniCase::new(recorded.as_str())
600                    })
601            });
602            assert!(
603                recorded_by_a_face_of_that_family,
604                "the family fontdb records for the face"
605            );
606        }
607
608        /// Two calls with the same arguments return the same face.
609        #[test]
610        fn the_same_query_gives_the_same_face() {
611            let info = database()
612                .faces()
613                .next()
614                .expect("the font database holds a face");
615            let (name, _) = info.families.first().expect("a face records a family");
616            let a = system_face(name, info.weight.0, style_of(info.style));
617            let b = system_face(name, info.weight.0, style_of(info.style));
618            assert!(a.is_some());
619            assert_eq!(a, b);
620        }
621
622        /// The file route: "SF Pro" and Core Text's family name both give
623        /// the face whose family is the first family the Core Text file's
624        /// first face records. The repository holds no font file a unit test
625        /// could load in the Core Text file's place, so this is the route's
626        /// only check; the screenshot workflow (`.github/workflows/screenshots.yml`)
627        /// runs the egui connector's `system_faces_resolve` on the macOS
628        /// runner too.
629        #[cfg(target_os = "macos")]
630        #[test]
631        fn the_macos_system_ui_font_is_found_by_its_file() {
632            let ui = crate::macos::system_ui_font(false)
633                .expect("Core Text names the system UI font's file");
634            let expected = {
635                let mut db = fontdb::Database::new();
636                db.load_font_file(&ui.path)
637                    .expect("the system UI font's file loads");
638                db.faces()
639                    .next()
640                    .and_then(|face| face.families.first().map(|(name, _)| name.clone()))
641                    .expect("the file records a family")
642            };
643            for asked in ["SF Pro", ui.family.as_str()] {
644                let face = system_face(asked, 400, FontStyle::Normal)
645                    .unwrap_or_else(|| panic!("{asked} resolves through Core Text"));
646                assert_eq!(face.family.as_ref(), expected.as_str());
647            }
648        }
649    }
650}