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