use anyhow::{bail, Context, Result};
use crossterm::style::Stylize;
use std::io::Read;
use std::path::{Path, PathBuf};
use tracing::debug;
use crate::ui;
pub struct FontEntry {
pub name: &'static str,
pub description: &'static str,
pub license: &'static str,
pub url: &'static str,
}
pub const FONT_REGISTRY: &[FontEntry] = &[
FontEntry {
name: "JetBrainsMono Nerd Font",
description: "JetBrains Mono with Nerd Font icons, box-drawing, and Powerline glyphs",
license: "OFL-1.1 (SIL Open Font License)",
url: "https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip",
},
FontEntry {
name: "JetBrains Mono",
description: "JetBrains Mono typeface for developers",
license: "Apache-2.0",
url: "https://github.com/JetBrains/JetBrainsMono/releases/latest/download/JetBrainsMono-2.304.zip",
},
FontEntry {
name: "Monaspace Nerd Font",
description: "GitHub's Monaspace superfamily with Nerd Font icons and Powerline glyphs",
license: "OFL-1.1 (SIL Open Font License)",
url: "https://github.com/ryanoasis/nerd-fonts/releases/latest/download/Monaspace.zip",
},
];
pub fn system_font_dir() -> Result<PathBuf> {
let home = dirs_path()?;
#[cfg(target_os = "macos")]
{
Ok(home.join("Library/Fonts"))
}
#[cfg(target_os = "linux")]
{
Ok(home.join(".local/share/fonts"))
}
#[cfg(target_os = "windows")]
{
Ok(home.join("AppData/Local/Microsoft/Windows/Fonts"))
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
bail!("unsupported platform for font installation")
}
}
fn dirs_path() -> Result<PathBuf> {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(PathBuf::from)
.context("could not determine home directory")
}
pub fn check_font(family: &str) -> bool {
crate::term_render::check_font_available(family)
}
pub async fn install_font(name: &str) -> Result<()> {
let entry = FONT_REGISTRY
.iter()
.find(|e| e.name.eq_ignore_ascii_case(name))
.with_context(|| {
let available: Vec<&str> = FONT_REGISTRY.iter().map(|e| e.name).collect();
format!("unknown font: {name}. Available: {}", available.join(", "))
})?;
let pb = ui::spinner(&format!("installing: {} ({})", entry.name, entry.license));
debug!("downloading from: {}", entry.url);
let response = reqwest::get(entry.url)
.await
.context("failed to download font")?;
if !response.status().is_success() {
bail!("download failed: HTTP {}", response.status());
}
let bytes = response.bytes().await.context("failed to read response")?;
let font_dir = system_font_dir()?;
std::fs::create_dir_all(&font_dir)
.with_context(|| format!("failed to create font dir: {}", font_dir.display()))?;
extract_fonts(&bytes, &font_dir)?;
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("fc-cache").arg("-f").status();
}
ui::spinner_done(&pb, Some(&format!("installed to {}", font_dir.display())));
Ok(())
}
fn extract_fonts(zip_bytes: &[u8], dest: &Path) -> Result<()> {
let cursor = std::io::Cursor::new(zip_bytes);
let mut archive = zip::ZipArchive::new(cursor).context("failed to open zip archive")?;
let mut count = 0;
for i in 0..archive.len() {
let mut file = archive.by_index(i).context("failed to read zip entry")?;
let name = file.name().to_string();
let is_font = name.ends_with(".ttf") || name.ends_with(".otf");
if !is_font || file.is_dir() {
continue;
}
let filename = Path::new(&name)
.file_name()
.context("invalid font filename")?;
let dest_path = dest.join(filename);
let mut data = Vec::new();
file.read_to_end(&mut data)
.with_context(|| format!("failed to read {name}"))?;
std::fs::write(&dest_path, &data)
.with_context(|| format!("failed to write {}", dest_path.display()))?;
debug!(" extracted: {}", filename.to_string_lossy());
count += 1;
}
if count == 0 {
bail!("no font files found in archive");
}
ui::phase_ok(&format!("{count} font files extracted"), None);
Ok(())
}
pub fn list_fonts() {
ui::header("available fonts");
let total = FONT_REGISTRY.len();
for (i, entry) in FONT_REGISTRY.iter().enumerate() {
let is_last = i == total - 1;
let connector = if is_last { "└─" } else { "├─" };
eprintln!(" {} {}", connector.dim(), entry.name.bold());
let branch = if is_last { " " } else { "│ " };
eprintln!(" {} {}", branch.dim(), entry.description.dim());
eprintln!(
" {} {}",
branch.dim(),
format!("License: {}", entry.license).dim()
);
if !is_last {
eprintln!(" {}", "│".dim());
}
}
eprintln!();
}