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, PartialEq, Eq)]
35pub enum FontLoadMode {
36 Embedded,
38 External,
40 ExternalThenEmbedded,
43 Mixed,
46}
47
48impl Default for FontLoadMode {
49 fn default() -> Self {
50 Self::ExternalThenEmbedded
51 }
52}
53
54#[derive(Clone, Debug)]
56pub struct EmbeddedFont {
57 pub name: SharedString,
59 pub bytes: Cow<'static, [u8]>,
61}
62
63impl EmbeddedFont {
64 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#[derive(Clone, Debug, Default)]
75pub struct FontLoadOptions {
76 pub mode: FontLoadMode,
78 pub external_dirs: Vec<PathBuf>,
80 pub asset_paths: Vec<SharedString>,
82 pub embedded_fonts: Vec<EmbeddedFont>,
84 pub required_families: Vec<SharedString>,
90}
91
92impl FontLoadOptions {
93 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 pub fn external_dir(mut self, dir: impl Into<PathBuf>) -> Self {
106 self.external_dirs.push(dir.into());
107 self
108 }
109
110 pub fn asset_path(mut self, path: impl Into<SharedString>) -> Self {
112 self.asset_paths.push(path.into());
113 self
114 }
115
116 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 pub fn require_family(mut self, family: impl Into<SharedString>) -> Self {
133 self.required_families.push(family.into());
134 self
135 }
136}
137
138#[derive(Clone, Debug, Default, PartialEq, Eq)]
140pub struct FontDiscoveryReport {
141 pub font_files: Vec<PathBuf>,
143 pub skipped_unsupported: usize,
146}
147
148#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct FontLoadFailure {
151 pub source: String,
153 pub error: String,
155}
156
157#[derive(Clone, Debug, Default, PartialEq, Eq)]
159pub struct FontLoadReport {
160 pub loaded: usize,
162 pub skipped_unsupported: usize,
164 pub missing_external_dirs: Vec<PathBuf>,
167 pub failures: Vec<FontLoadFailure>,
169 pub missing_required_families: Vec<SharedString>,
172}
173
174impl FontLoadReport {
175 pub fn loaded_any(&self) -> bool {
182 self.loaded > 0
183 }
184
185 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
209pub 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
222pub 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
248pub 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
263pub 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
282pub 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
305pub 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
329pub 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
375pub 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}