Skip to main content

khora_editor/
fonts.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Editor brand fonts — Geist & Geist Mono loader.
16//!
17//! Loads Khora's brand fonts at startup through the same I/O layer as project
18//! assets ([`khora_sdk::AssetIo`] / [`khora_sdk::FileLoader`]). Today the
19//! loader hits the disk; once the editor ships as a packed binary we can swap
20//! `FileLoader` for `PackLoader` without touching the call sites.
21//!
22//! If the font files are missing, returns an empty [`FontPack`] — the shell
23//! will silently keep its built-in defaults. No build-time check, no recompile
24//! required when adding the fonts later.
25//!
26//! Expected layout (relative to the loader root):
27//! ```text
28//! fonts/
29//!   Geist-Regular.ttf
30//!   Geist-Medium.ttf
31//!   Geist-SemiBold.ttf
32//!   GeistMono-Regular.ttf
33//!   GeistMono-Medium.ttf
34//! ```
35//!
36//! The fonts are distributed under the SIL Open Font License — see
37//! `https://github.com/vercel/geist-font` for the source.
38
39use std::path::{Path, PathBuf};
40
41use khora_sdk::editor_ui::{FontHandle, FontPack, NamedFont};
42use khora_sdk::{AssetIo, AssetSource, FileLoader};
43
44/// File names looked up under the loader root. Listed in the order the shell
45/// should install them — the first proportional / monospace face becomes the
46/// primary one for that family.
47const PROPORTIONAL_FILES: &[(&str, &str)] = &[
48    ("geist-regular", "fonts/Geist-Regular.ttf"),
49    ("geist-medium", "fonts/Geist-Medium.ttf"),
50    ("geist-semibold", "fonts/Geist-SemiBold.ttf"),
51];
52
53const MONOSPACE_FILES: &[(&str, &str)] = &[
54    ("geist-mono-regular", "fonts/GeistMono-Regular.ttf"),
55    ("geist-mono-medium", "fonts/GeistMono-Medium.ttf"),
56];
57
58/// Display / serif faces (Fraunces 72pt optical, SIL OFL). Listed
59/// regular-then-semibold so the heavier face becomes the primary display face
60/// (see `install_named`). Optional — absent files leave the display family
61/// aliased to proportional.
62const DISPLAY_FILES: &[(&str, &str)] = &[
63    ("fraunces-regular", "fonts/Fraunces-Regular.ttf"),
64    ("fraunces-semibold", "fonts/Fraunces-SemiBold.ttf"),
65];
66
67const ICON_FILES: &[(&str, &str)] = &[("lucide", "fonts/Lucide.ttf")];
68
69/// Attempts to load the Khora brand font pack via the asset I/O layer.
70///
71/// Tries every candidate root in order (next to the binary, then the crate's
72/// `assets/` directory), and the first root containing at least one of the
73/// expected files becomes the active loader. Returns an empty pack if none of
74/// the roots have any of the fonts — callers should treat that as a no-op.
75pub fn load_pack() -> FontPack {
76    let candidates = candidate_roots();
77
78    for root in &candidates {
79        let mut loader = FileLoader::new(root);
80        let proportional = collect(&mut loader, PROPORTIONAL_FILES);
81        let monospace = collect(&mut loader, MONOSPACE_FILES);
82        let display = collect(&mut loader, DISPLAY_FILES);
83        let icons = collect(&mut loader, ICON_FILES);
84
85        if !proportional.is_empty()
86            || !monospace.is_empty()
87            || !display.is_empty()
88            || !icons.is_empty()
89        {
90            log::info!(
91                "Editor fonts: loaded {} proportional + {} monospace + {} display + {} icon face(s) from '{}'.",
92                proportional.len(),
93                monospace.len(),
94                display.len(),
95                icons.len(),
96                root.display()
97            );
98            return FontPack {
99                proportional,
100                monospace,
101                display,
102                icons,
103            };
104        }
105    }
106
107    log::info!(
108        "Editor fonts: no Geist files found under any of {:?} \u{2014} \
109         keeping default fonts. Drop the .ttf files into 'assets/fonts/' to \
110         enable the brand typography.",
111        candidates
112            .iter()
113            .map(|p| p.display().to_string())
114            .collect::<Vec<_>>()
115    );
116    FontPack::default()
117}
118
119/// Pulls bytes for each `(name, relative_path)` pair through `loader`,
120/// silently skipping any that aren't there.
121fn collect(loader: &mut dyn AssetIo, files: &[(&str, &str)]) -> Vec<NamedFont> {
122    let mut out = Vec::new();
123    for (name, rel) in files {
124        let source = AssetSource::Path(PathBuf::from(rel));
125        match loader.load_bytes(&source) {
126            Ok(bytes) => out.push(NamedFont {
127                name: (*name).to_owned(),
128                data: FontHandle::Owned(bytes),
129            }),
130            Err(_) => {
131                // FileLoader returns Err on missing file. We treat that as
132                // "this root doesn't have this asset" — no warning, the
133                // outer loop will move on.
134            }
135        }
136    }
137    out
138}
139
140/// Builds the list of root directories to try, in priority order.
141fn candidate_roots() -> Vec<PathBuf> {
142    let mut out = Vec::new();
143
144    // 1. Deployed layout: `<exe-dir>/assets/`.
145    if let Ok(exe) = std::env::current_exe() {
146        if let Some(parent) = exe.parent() {
147            out.push(parent.join("assets"));
148        }
149    }
150
151    // 2. Dev layout: `<crate>/assets/` for `cargo run -p khora-editor`.
152    out.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("assets"));
153
154    out
155}