1use gpui::{App, SharedString};
16use std::{
17 borrow::Cow,
18 fs,
19 path::{Path, PathBuf},
20};
21
22pub const SUPPORTED_FONT_EXTENSIONS: &[&str] = &["ttf", "otf", "ttc", "otc", "woff", "woff2"];
32
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35pub enum FontLoadMode {
36 Embedded,
38 External,
40 #[default]
43 ExternalThenEmbedded,
44 Mixed,
47}
48
49#[derive(Clone, Debug)]
51pub struct EmbeddedFont {
52 pub name: SharedString,
54 pub bytes: Cow<'static, [u8]>,
56}
57
58impl EmbeddedFont {
59 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#[derive(Clone, Debug, Default)]
70pub struct FontLoadOptions {
71 pub mode: FontLoadMode,
73 pub external_dirs: Vec<PathBuf>,
75 pub asset_paths: Vec<SharedString>,
77 pub embedded_fonts: Vec<EmbeddedFont>,
79 pub required_families: Vec<SharedString>,
85}
86
87impl FontLoadOptions {
88 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 pub fn external_dir(mut self, dir: impl Into<PathBuf>) -> Self {
101 self.external_dirs.push(dir.into());
102 self
103 }
104
105 pub fn asset_path(mut self, path: impl Into<SharedString>) -> Self {
107 self.asset_paths.push(path.into());
108 self
109 }
110
111 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 pub fn require_family(mut self, family: impl Into<SharedString>) -> Self {
128 self.required_families.push(family.into());
129 self
130 }
131}
132
133#[derive(Clone, Debug, Default, PartialEq, Eq)]
135pub struct FontDiscoveryReport {
136 pub font_files: Vec<PathBuf>,
138 pub skipped_unsupported: usize,
141}
142
143#[derive(Clone, Debug, PartialEq, Eq)]
145pub struct FontLoadFailure {
146 pub source: String,
148 pub error: String,
150}
151
152#[derive(Clone, Debug, Default, PartialEq, Eq)]
154pub struct FontLoadReport {
155 pub loaded: usize,
157 pub skipped_unsupported: usize,
159 pub missing_external_dirs: Vec<PathBuf>,
162 pub failures: Vec<FontLoadFailure>,
164 pub missing_required_families: Vec<SharedString>,
167}
168
169impl FontLoadReport {
170 pub fn loaded_any(&self) -> bool {
177 self.loaded > 0
178 }
179
180 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
204pub 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
217pub 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
243pub 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
258pub 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
277pub 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
300pub 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
324pub 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
370pub 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}