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}