use gpui::{App, SharedString};
use std::{
borrow::Cow,
fs,
path::{Path, PathBuf},
};
pub const SUPPORTED_FONT_EXTENSIONS: &[&str] = &["ttf", "otf", "ttc", "otc", "woff", "woff2"];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FontLoadMode {
Embedded,
External,
ExternalThenEmbedded,
Mixed,
}
impl Default for FontLoadMode {
fn default() -> Self {
Self::ExternalThenEmbedded
}
}
#[derive(Clone, Debug)]
pub struct EmbeddedFont {
pub name: SharedString,
pub bytes: Cow<'static, [u8]>,
}
impl EmbeddedFont {
pub fn new(name: impl Into<SharedString>, bytes: impl Into<Cow<'static, [u8]>>) -> Self {
Self {
name: name.into(),
bytes: bytes.into(),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct FontLoadOptions {
pub mode: FontLoadMode,
pub external_dirs: Vec<PathBuf>,
pub asset_paths: Vec<SharedString>,
pub embedded_fonts: Vec<EmbeddedFont>,
pub required_families: Vec<SharedString>,
}
impl FontLoadOptions {
pub fn new(mode: FontLoadMode) -> Self {
Self {
mode,
external_dirs: Vec::new(),
asset_paths: Vec::new(),
embedded_fonts: Vec::new(),
required_families: Vec::new(),
}
}
pub fn external_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.external_dirs.push(dir.into());
self
}
pub fn asset_path(mut self, path: impl Into<SharedString>) -> Self {
self.asset_paths.push(path.into());
self
}
pub fn embedded(
mut self,
name: impl Into<SharedString>,
bytes: impl Into<Cow<'static, [u8]>>,
) -> Self {
self.embedded_fonts.push(EmbeddedFont::new(name, bytes));
self
}
pub fn require_family(mut self, family: impl Into<SharedString>) -> Self {
self.required_families.push(family.into());
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FontDiscoveryReport {
pub font_files: Vec<PathBuf>,
pub skipped_unsupported: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FontLoadFailure {
pub source: String,
pub error: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FontLoadReport {
pub loaded: usize,
pub skipped_unsupported: usize,
pub missing_external_dirs: Vec<PathBuf>,
pub failures: Vec<FontLoadFailure>,
pub missing_required_families: Vec<SharedString>,
}
impl FontLoadReport {
pub fn loaded_any(&self) -> bool {
self.loaded > 0
}
pub fn required_families_available(&self) -> bool {
self.missing_required_families.is_empty()
}
fn extend(&mut self, other: Self) {
self.loaded += other.loaded;
self.skipped_unsupported += other.skipped_unsupported;
self.missing_external_dirs
.extend(other.missing_external_dirs);
self.failures.extend(other.failures);
self.missing_required_families
.extend(other.missing_required_families);
}
fn failure(&mut self, source: impl Into<String>, error: impl ToString) {
self.failures.push(FontLoadFailure {
source: source.into(),
error: error.to_string(),
});
}
}
pub fn is_supported_font_path(path: impl AsRef<Path>) -> bool {
path.as_ref()
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| {
SUPPORTED_FONT_EXTENSIONS
.iter()
.any(|supported| extension.eq_ignore_ascii_case(supported))
})
.unwrap_or(false)
}
pub fn discover_font_files(dir: impl AsRef<Path>) -> std::io::Result<FontDiscoveryReport> {
let mut report = FontDiscoveryReport::default();
discover_font_files_inner(dir.as_ref(), &mut report)?;
report.font_files.sort();
Ok(report)
}
fn discover_font_files_inner(dir: &Path, report: &mut FontDiscoveryReport) -> std::io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
discover_font_files_inner(&path, report)?;
} else if file_type.is_file() {
if is_supported_font_path(&path) {
report.font_files.push(path);
} else {
report.skipped_unsupported += 1;
}
}
}
Ok(())
}
pub fn load_embedded_fonts(
cx: &mut App,
fonts: impl IntoIterator<Item = EmbeddedFont>,
) -> FontLoadReport {
let mut report = FontLoadReport::default();
for font in fonts {
match cx.text_system().add_fonts(vec![font.bytes]) {
Ok(()) => report.loaded += 1,
Err(error) => report.failure(font.name.to_string(), error),
}
}
report
}
pub fn load_font_files(cx: &mut App, paths: impl IntoIterator<Item = PathBuf>) -> FontLoadReport {
let mut report = FontLoadReport::default();
for path in paths {
if !is_supported_font_path(&path) {
report.skipped_unsupported += 1;
continue;
}
match fs::read(&path) {
Ok(bytes) => match cx.text_system().add_fonts(vec![Cow::Owned(bytes)]) {
Ok(()) => report.loaded += 1,
Err(error) => report.failure(path.display().to_string(), error),
},
Err(error) => report.failure(path.display().to_string(), error),
}
}
report
}
pub fn load_fonts_from_dir(cx: &mut App, dir: impl AsRef<Path>) -> FontLoadReport {
let dir = dir.as_ref();
if !dir.exists() {
return FontLoadReport {
missing_external_dirs: vec![dir.to_path_buf()],
..Default::default()
};
}
match discover_font_files(dir) {
Ok(discovery) => {
let mut report = load_font_files(cx, discovery.font_files);
report.skipped_unsupported += discovery.skipped_unsupported;
report
}
Err(error) => {
let mut report = FontLoadReport::default();
report.failure(dir.display().to_string(), error);
report
}
}
}
pub fn load_font_assets(
cx: &mut App,
paths: impl IntoIterator<Item = SharedString>,
) -> FontLoadReport {
let mut report = FontLoadReport::default();
let asset_source = cx.asset_source().clone();
for path in paths {
if !is_supported_font_path(path.as_ref()) {
report.skipped_unsupported += 1;
continue;
}
match asset_source.load(path.as_ref()) {
Ok(Some(bytes)) => match cx.text_system().add_fonts(vec![bytes]) {
Ok(()) => report.loaded += 1,
Err(error) => report.failure(path.to_string(), error),
},
Ok(None) => report.failure(path.to_string(), "asset not found"),
Err(error) => report.failure(path.to_string(), error),
}
}
report
}
pub fn load_app_fonts(cx: &mut App, options: FontLoadOptions) -> FontLoadReport {
let required_families = options.required_families.clone();
let mut report = match options.mode {
FontLoadMode::Embedded => load_embedded_fonts(cx, options.embedded_fonts),
FontLoadMode::External => {
load_external_fonts(cx, options.external_dirs, options.asset_paths)
}
FontLoadMode::Mixed => {
let mut report = load_external_fonts(cx, options.external_dirs, options.asset_paths);
report.extend(load_embedded_fonts(cx, options.embedded_fonts));
report
}
FontLoadMode::ExternalThenEmbedded => {
let mut report = load_external_fonts(cx, options.external_dirs, options.asset_paths);
let missing_after_external = missing_required_families(cx, &required_families);
let should_try_embedded = if required_families.is_empty() {
!report.loaded_any()
} else {
!missing_after_external.is_empty()
};
if should_try_embedded {
report.extend(load_embedded_fonts(cx, options.embedded_fonts));
}
report
}
};
report.missing_required_families = missing_required_families(cx, &required_families);
report
}
fn load_external_fonts(
cx: &mut App,
external_dirs: Vec<PathBuf>,
asset_paths: Vec<SharedString>,
) -> FontLoadReport {
let mut report = FontLoadReport::default();
for dir in external_dirs {
report.extend(load_fonts_from_dir(cx, dir));
}
report.extend(load_font_assets(cx, asset_paths));
report
}
pub fn is_font_family_available(cx: &App, family: &str) -> bool {
cx.text_system()
.all_font_names()
.iter()
.any(|name| name == family)
}
fn missing_required_families(cx: &App, families: &[SharedString]) -> Vec<SharedString> {
families
.iter()
.filter(|family| !is_font_family_available(cx, family.as_ref()))
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
fs,
time::{SystemTime, UNIX_EPOCH},
};
#[test]
fn supported_font_extensions_cover_native_and_web_font_files() {
for path in [
"Inter.ttf",
"Inter.otf",
"PingFang.ttc",
"PingFang.otc",
"Brand.woff",
"Brand.woff2",
"UpperCase.TTF",
] {
assert!(is_supported_font_path(path), "{path} should be accepted");
}
for path in ["README.md", "font.txt", "no-extension"] {
assert!(!is_supported_font_path(path), "{path} should be rejected");
}
}
#[test]
fn discover_font_files_recurses_and_skips_unsupported_files() {
let root = temp_dir("liora-font-discovery");
let nested = root.join("nested");
fs::create_dir_all(&nested).unwrap();
fs::write(root.join("PingFangSC-Regular.ttf"), b"fake").unwrap();
fs::write(nested.join("PingFangSC-Regular.woff2"), b"fake").unwrap();
fs::write(nested.join("README.md"), b"ignore").unwrap();
let report = discover_font_files(&root).unwrap();
let names = report
.font_files
.iter()
.map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(
names,
vec!["PingFangSC-Regular.ttf", "PingFangSC-Regular.woff2"]
);
assert_eq!(report.skipped_unsupported, 1);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn load_options_default_to_external_then_embedded_for_package_friendly_apps() {
let options = FontLoadOptions::new(FontLoadMode::ExternalThenEmbedded)
.external_dir("assets/fonts")
.embedded("Inter-Regular.ttf", b"font-bytes" as &'static [u8])
.require_family("Inter");
assert_eq!(options.mode, FontLoadMode::ExternalThenEmbedded);
assert_eq!(options.external_dirs.len(), 1);
assert_eq!(options.embedded_fonts.len(), 1);
assert_eq!(
options.required_families.as_slice(),
[SharedString::from("Inter")]
);
}
#[test]
fn report_tracks_missing_required_families() {
let report = FontLoadReport {
missing_required_families: vec![SharedString::from("PingFang SC")],
..Default::default()
};
assert!(!report.required_families_available());
}
fn temp_dir(label: &str) -> std::path::PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("{label}-{unique}"))
}
}