Skip to main content

liora_core/
fonts.rs

1//! Application-level font loading and typography helpers.
2//!
3//! GPUI resolves text in two separate steps: first an application registers any
4//! private font bytes with `App::text_system().add_fonts`, then elements refer to
5//! a family name such as `"PingFang SC"`, `"Inter"`, or an already installed
6//! system family such as `"Segoe UI"`. This module keeps those steps explicit so
7//! a packaged application can mount large font files next to the executable while
8//! a bare executable can still fall back to small embedded font bytes.
9//!
10//! Important: GPUI's `add_fonts` API reports transport/registration errors, but
11//! some platform backends silently ignore font bytes they cannot parse. Use
12//! [`FontLoadOptions::require_family`] when the application must know that a
13//! selected family, such as `"PingFang SC"`, is actually visible after loading.
14
15use gpui::{App, SharedString};
16use std::{
17    borrow::Cow,
18    fs,
19    path::{Path, PathBuf},
20};
21
22/// Font file extensions that Liora will try to register with GPUI.
23///
24/// GPUI accepts raw font bytes and delegates parsing to its platform text
25/// backend: font-kit/CoreText on macOS, DirectWrite on Windows, and
26/// cosmic-text/fontdb on Linux. The exact parser support can vary by backend.
27/// For maximum native compatibility prefer `ttf`, `otf`, `ttc`, or `otc`; web
28/// formats are accepted as inputs but must be verified with
29/// [`FontLoadOptions::require_family`] because a backend can ignore bytes it
30/// cannot parse without returning an error.
31pub const SUPPORTED_FONT_EXTENSIONS: &[&str] = &["ttf", "otf", "ttc", "otc", "woff", "woff2"];
32
33/// Controls which resource location is used when loading app fonts.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum FontLoadMode {
36    /// Only load fonts that were embedded in the executable with `include_bytes!`.
37    Embedded,
38    /// Only load fonts from external filesystem directories or GPUI assets.
39    External,
40    /// Prefer external mounted files/assets and fall back to embedded bytes when
41    /// no external font could be registered.
42    ExternalThenEmbedded,
43    /// Always register both external and embedded fonts. Use this when the app
44    /// intentionally ships different families in different locations.
45    Mixed,
46}
47
48impl Default for FontLoadMode {
49    fn default() -> Self {
50        Self::ExternalThenEmbedded
51    }
52}
53
54/// One embedded font included in the executable.
55#[derive(Clone, Debug)]
56pub struct EmbeddedFont {
57    /// Human-readable source name used in reports and logs.
58    pub name: SharedString,
59    /// Raw font bytes, commonly produced by `include_bytes!`.
60    pub bytes: Cow<'static, [u8]>,
61}
62
63impl EmbeddedFont {
64    /// Creates an embedded font from static or owned bytes.
65    pub fn new(name: impl Into<SharedString>, bytes: impl Into<Cow<'static, [u8]>>) -> Self {
66        Self {
67            name: name.into(),
68            bytes: bytes.into(),
69        }
70    }
71}
72
73/// Options used by [`load_app_fonts`] to combine embedded and mounted fonts.
74#[derive(Clone, Debug, Default)]
75pub struct FontLoadOptions {
76    /// Resource selection policy.
77    pub mode: FontLoadMode,
78    /// External directories scanned recursively for supported font files.
79    pub external_dirs: Vec<PathBuf>,
80    /// Font asset paths resolved through the current GPUI [`gpui::AssetSource`].
81    pub asset_paths: Vec<SharedString>,
82    /// Embedded fallback fonts bundled into the executable.
83    pub embedded_fonts: Vec<EmbeddedFont>,
84    /// Family names that must be visible after loading.
85    ///
86    /// This is stronger than checking `FontLoadReport.loaded`: a GPUI backend can
87    /// accept bytes and still fail to expose a family if the file format is not
88    /// supported on that platform.
89    pub required_families: Vec<SharedString>,
90}
91
92impl FontLoadOptions {
93    /// Creates an option set for the supplied resource mode.
94    pub fn new(mode: FontLoadMode) -> Self {
95        Self {
96            mode,
97            external_dirs: Vec::new(),
98            asset_paths: Vec::new(),
99            embedded_fonts: Vec::new(),
100            required_families: Vec::new(),
101        }
102    }
103
104    /// Adds a recursively scanned external font directory.
105    pub fn external_dir(mut self, dir: impl Into<PathBuf>) -> Self {
106        self.external_dirs.push(dir.into());
107        self
108    }
109
110    /// Adds a GPUI asset path such as `"fonts/PingFangSC-Regular.ttf"`.
111    pub fn asset_path(mut self, path: impl Into<SharedString>) -> Self {
112        self.asset_paths.push(path.into());
113        self
114    }
115
116    /// Adds one embedded font file to the fallback set.
117    pub fn embedded(
118        mut self,
119        name: impl Into<SharedString>,
120        bytes: impl Into<Cow<'static, [u8]>>,
121    ) -> Self {
122        self.embedded_fonts.push(EmbeddedFont::new(name, bytes));
123        self
124    }
125
126    /// Requires a family name to be visible after loading completes.
127    ///
128    /// `ExternalThenEmbedded` uses this list to decide whether external files
129    /// really satisfied the selected typography. If an external source returns
130    /// `Ok(())` but the family is still absent, embedded fallback fonts are tried
131    /// before the final report is produced.
132    pub fn require_family(mut self, family: impl Into<SharedString>) -> Self {
133        self.required_families.push(family.into());
134        self
135    }
136}
137
138/// Files discovered under an external font directory before GPUI registration.
139#[derive(Clone, Debug, Default, PartialEq, Eq)]
140pub struct FontDiscoveryReport {
141    /// Supported font files found recursively, sorted for deterministic loading.
142    pub font_files: Vec<PathBuf>,
143    /// Regular files skipped because their extension is not in
144    /// [`SUPPORTED_FONT_EXTENSIONS`].
145    pub skipped_unsupported: usize,
146}
147
148/// One font source that could not be read or registered.
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct FontLoadFailure {
151    /// File path, asset path, or embedded source label.
152    pub source: String,
153    /// Error message returned by the filesystem, asset source, or GPUI backend.
154    pub error: String,
155}
156
157/// Summary returned after attempting to load app fonts.
158#[derive(Clone, Debug, Default, PartialEq, Eq)]
159pub struct FontLoadReport {
160    /// Number of font faces or font files passed to GPUI successfully.
161    pub loaded: usize,
162    /// External files skipped because their extension is not supported.
163    pub skipped_unsupported: usize,
164    /// External directories that do not exist. Missing directories are not fatal
165    /// because packaged and source-tree layouts often differ.
166    pub missing_external_dirs: Vec<PathBuf>,
167    /// Sources that existed but failed to read, resolve, or register.
168    pub failures: Vec<FontLoadFailure>,
169    /// Required families that are still not visible to GPUI after all selected
170    /// sources and fallbacks have been attempted.
171    pub missing_required_families: Vec<SharedString>,
172}
173
174impl FontLoadReport {
175    /// Returns `true` when at least one font source was passed to GPUI without
176    /// a transport-level error.
177    ///
178    /// This does not prove a specific family became available. Use
179    /// [`FontLoadOptions::require_family`] and inspect
180    /// [`FontLoadReport::missing_required_families`] for that stronger check.
181    pub fn loaded_any(&self) -> bool {
182        self.loaded > 0
183    }
184
185    /// Returns `true` when all families listed with
186    /// [`FontLoadOptions::require_family`] were visible after loading.
187    pub fn required_families_available(&self) -> bool {
188        self.missing_required_families.is_empty()
189    }
190
191    fn extend(&mut self, other: Self) {
192        self.loaded += other.loaded;
193        self.skipped_unsupported += other.skipped_unsupported;
194        self.missing_external_dirs
195            .extend(other.missing_external_dirs);
196        self.failures.extend(other.failures);
197        self.missing_required_families
198            .extend(other.missing_required_families);
199    }
200
201    fn failure(&mut self, source: impl Into<String>, error: impl ToString) {
202        self.failures.push(FontLoadFailure {
203            source: source.into(),
204            error: error.to_string(),
205        });
206    }
207}
208
209/// Returns whether the path has a font extension Liora should try to register.
210pub fn is_supported_font_path(path: impl AsRef<Path>) -> bool {
211    path.as_ref()
212        .extension()
213        .and_then(|extension| extension.to_str())
214        .map(|extension| {
215            SUPPORTED_FONT_EXTENSIONS
216                .iter()
217                .any(|supported| extension.eq_ignore_ascii_case(supported))
218        })
219        .unwrap_or(false)
220}
221
222/// Recursively discovers supported font files under `dir`.
223pub fn discover_font_files(dir: impl AsRef<Path>) -> std::io::Result<FontDiscoveryReport> {
224    let mut report = FontDiscoveryReport::default();
225    discover_font_files_inner(dir.as_ref(), &mut report)?;
226    report.font_files.sort();
227    Ok(report)
228}
229
230fn discover_font_files_inner(dir: &Path, report: &mut FontDiscoveryReport) -> std::io::Result<()> {
231    for entry in fs::read_dir(dir)? {
232        let entry = entry?;
233        let path = entry.path();
234        let file_type = entry.file_type()?;
235        if file_type.is_dir() {
236            discover_font_files_inner(&path, report)?;
237        } else if file_type.is_file() {
238            if is_supported_font_path(&path) {
239                report.font_files.push(path);
240            } else {
241                report.skipped_unsupported += 1;
242            }
243        }
244    }
245    Ok(())
246}
247
248/// Registers embedded font bytes with GPUI.
249pub fn load_embedded_fonts(
250    cx: &mut App,
251    fonts: impl IntoIterator<Item = EmbeddedFont>,
252) -> FontLoadReport {
253    let mut report = FontLoadReport::default();
254    for font in fonts {
255        match cx.text_system().add_fonts(vec![font.bytes]) {
256            Ok(()) => report.loaded += 1,
257            Err(error) => report.failure(font.name.to_string(), error),
258        }
259    }
260    report
261}
262
263/// Reads and registers explicit font files.
264pub fn load_font_files(cx: &mut App, paths: impl IntoIterator<Item = PathBuf>) -> FontLoadReport {
265    let mut report = FontLoadReport::default();
266    for path in paths {
267        if !is_supported_font_path(&path) {
268            report.skipped_unsupported += 1;
269            continue;
270        }
271        match fs::read(&path) {
272            Ok(bytes) => match cx.text_system().add_fonts(vec![Cow::Owned(bytes)]) {
273                Ok(()) => report.loaded += 1,
274                Err(error) => report.failure(path.display().to_string(), error),
275            },
276            Err(error) => report.failure(path.display().to_string(), error),
277        }
278    }
279    report
280}
281
282/// Recursively reads and registers fonts from an external directory.
283pub fn load_fonts_from_dir(cx: &mut App, dir: impl AsRef<Path>) -> FontLoadReport {
284    let dir = dir.as_ref();
285    if !dir.exists() {
286        return FontLoadReport {
287            missing_external_dirs: vec![dir.to_path_buf()],
288            ..Default::default()
289        };
290    }
291    match discover_font_files(dir) {
292        Ok(discovery) => {
293            let mut report = load_font_files(cx, discovery.font_files);
294            report.skipped_unsupported += discovery.skipped_unsupported;
295            report
296        }
297        Err(error) => {
298            let mut report = FontLoadReport::default();
299            report.failure(dir.display().to_string(), error);
300            report
301        }
302    }
303}
304
305/// Resolves and registers font bytes from GPUI's configured asset source.
306pub fn load_font_assets(
307    cx: &mut App,
308    paths: impl IntoIterator<Item = SharedString>,
309) -> FontLoadReport {
310    let mut report = FontLoadReport::default();
311    let asset_source = cx.asset_source().clone();
312    for path in paths {
313        if !is_supported_font_path(path.as_ref()) {
314            report.skipped_unsupported += 1;
315            continue;
316        }
317        match asset_source.load(path.as_ref()) {
318            Ok(Some(bytes)) => match cx.text_system().add_fonts(vec![bytes]) {
319                Ok(()) => report.loaded += 1,
320                Err(error) => report.failure(path.to_string(), error),
321            },
322            Ok(None) => report.failure(path.to_string(), "asset not found"),
323            Err(error) => report.failure(path.to_string(), error),
324        }
325    }
326    report
327}
328
329/// Loads application fonts according to `options`.
330pub fn load_app_fonts(cx: &mut App, options: FontLoadOptions) -> FontLoadReport {
331    let required_families = options.required_families.clone();
332    let mut report = match options.mode {
333        FontLoadMode::Embedded => load_embedded_fonts(cx, options.embedded_fonts),
334        FontLoadMode::External => {
335            load_external_fonts(cx, options.external_dirs, options.asset_paths)
336        }
337        FontLoadMode::Mixed => {
338            let mut report = load_external_fonts(cx, options.external_dirs, options.asset_paths);
339            report.extend(load_embedded_fonts(cx, options.embedded_fonts));
340            report
341        }
342        FontLoadMode::ExternalThenEmbedded => {
343            let mut report = load_external_fonts(cx, options.external_dirs, options.asset_paths);
344            let missing_after_external = missing_required_families(cx, &required_families);
345            let should_try_embedded = if required_families.is_empty() {
346                !report.loaded_any()
347            } else {
348                !missing_after_external.is_empty()
349            };
350
351            if should_try_embedded {
352                report.extend(load_embedded_fonts(cx, options.embedded_fonts));
353            }
354            report
355        }
356    };
357
358    report.missing_required_families = missing_required_families(cx, &required_families);
359    report
360}
361
362fn load_external_fonts(
363    cx: &mut App,
364    external_dirs: Vec<PathBuf>,
365    asset_paths: Vec<SharedString>,
366) -> FontLoadReport {
367    let mut report = FontLoadReport::default();
368    for dir in external_dirs {
369        report.extend(load_fonts_from_dir(cx, dir));
370    }
371    report.extend(load_font_assets(cx, asset_paths));
372    report
373}
374
375/// Returns whether the named family is currently visible to GPUI.
376///
377/// This works for system-installed families and for memory fonts after they are
378/// registered. It is a diagnostic helper; applications may still set a family
379/// optimistically and rely on GPUI's fallback stack if a platform reports names
380/// differently.
381pub fn is_font_family_available(cx: &App, family: &str) -> bool {
382    cx.text_system()
383        .all_font_names()
384        .iter()
385        .any(|name| name == family)
386}
387
388fn missing_required_families(cx: &App, families: &[SharedString]) -> Vec<SharedString> {
389    families
390        .iter()
391        .filter(|family| !is_font_family_available(cx, family.as_ref()))
392        .cloned()
393        .collect()
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use std::{
400        fs,
401        time::{SystemTime, UNIX_EPOCH},
402    };
403
404    #[test]
405    fn supported_font_extensions_cover_native_and_web_font_files() {
406        for path in [
407            "Inter.ttf",
408            "Inter.otf",
409            "PingFang.ttc",
410            "PingFang.otc",
411            "Brand.woff",
412            "Brand.woff2",
413            "UpperCase.TTF",
414        ] {
415            assert!(is_supported_font_path(path), "{path} should be accepted");
416        }
417
418        for path in ["README.md", "font.txt", "no-extension"] {
419            assert!(!is_supported_font_path(path), "{path} should be rejected");
420        }
421    }
422
423    #[test]
424    fn discover_font_files_recurses_and_skips_unsupported_files() {
425        let root = temp_dir("liora-font-discovery");
426        let nested = root.join("nested");
427        fs::create_dir_all(&nested).unwrap();
428        fs::write(root.join("PingFangSC-Regular.ttf"), b"fake").unwrap();
429        fs::write(nested.join("PingFangSC-Regular.woff2"), b"fake").unwrap();
430        fs::write(nested.join("README.md"), b"ignore").unwrap();
431
432        let report = discover_font_files(&root).unwrap();
433        let names = report
434            .font_files
435            .iter()
436            .map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
437            .collect::<Vec<_>>();
438
439        assert_eq!(
440            names,
441            vec!["PingFangSC-Regular.ttf", "PingFangSC-Regular.woff2"]
442        );
443        assert_eq!(report.skipped_unsupported, 1);
444
445        fs::remove_dir_all(root).unwrap();
446    }
447
448    #[test]
449    fn load_options_default_to_external_then_embedded_for_package_friendly_apps() {
450        let options = FontLoadOptions::new(FontLoadMode::ExternalThenEmbedded)
451            .external_dir("assets/fonts")
452            .embedded("Inter-Regular.ttf", b"font-bytes" as &'static [u8])
453            .require_family("Inter");
454
455        assert_eq!(options.mode, FontLoadMode::ExternalThenEmbedded);
456        assert_eq!(options.external_dirs.len(), 1);
457        assert_eq!(options.embedded_fonts.len(), 1);
458        assert_eq!(
459            options.required_families.as_slice(),
460            [SharedString::from("Inter")]
461        );
462    }
463
464    #[test]
465    fn report_tracks_missing_required_families() {
466        let report = FontLoadReport {
467            missing_required_families: vec![SharedString::from("PingFang SC")],
468            ..Default::default()
469        };
470
471        assert!(!report.required_families_available());
472    }
473
474    fn temp_dir(label: &str) -> std::path::PathBuf {
475        let unique = SystemTime::now()
476            .duration_since(UNIX_EPOCH)
477            .unwrap()
478            .as_nanos();
479        std::env::temp_dir().join(format!("{label}-{unique}"))
480    }
481}