elio 1.3.0

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
use super::PreviewContent;
use super::PreviewKind;
use super::appearance as theme;
use super::process::run_command_capture_stdout_cancellable;
use crate::core::Entry;
use anyhow::{Context, Result};
use ratatui::{
    style::Style,
    text::{Line, Span},
};
use std::{
    fs,
    io::Read,
    path::Path,
    process::{Command, Stdio},
    sync::OnceLock,
};

const FC_SCAN_FORMAT: &str =
    "%{family}\n%{style}\n%{fontformat}\n%{fontwrapper}\n%{spacing}\n%{variable}\n";
const WOFF_MAGIC: &[u8; 4] = b"wOFF";
const WOFF2_MAGIC: &[u8; 4] = b"wOF2";
const OTTO_MAGIC: &[u8; 4] = b"OTTO";
const TRUE_TYPE_MAGIC: [u8; 4] = [0x00, 0x01, 0x00, 0x00];
const APPLE_TRUE_TYPE_MAGIC: &[u8; 4] = b"true";
const TYPE_1_MAGIC: &[u8; 4] = b"typ1";

#[derive(Debug, Eq, PartialEq)]
struct FontMetadata {
    family: Option<String>,
    style: Option<String>,
    format: String,
    monospace: bool,
    variable: bool,
    file_size: String,
}

#[derive(Debug, Eq, PartialEq)]
struct FcScanMetadata {
    family: Option<String>,
    style: Option<String>,
    font_format: Option<String>,
    wrapper: Option<String>,
    monospace: bool,
    variable: bool,
}

pub(super) fn build_font_preview<F>(
    entry: &Entry,
    type_detail: Option<&'static str>,
    canceled: &F,
) -> Result<PreviewContent>
where
    F: Fn() -> bool,
{
    let detail = type_detail.unwrap_or("Font");
    let metadata = fs::metadata(&entry.path)
        .with_context(|| format!("failed to read metadata for {}", entry.path.display()))?;
    let byte_size = metadata.len();
    let header = read_font_header(&entry.path)?;
    let fallback_format = detect_font_format_from_header(&header, type_detail);
    let scan_metadata = (byte_size >= 12)
        .then(|| fc_scan_metadata(&entry.path, canceled))
        .flatten();
    let preview_metadata = FontMetadata {
        family: scan_metadata.as_ref().and_then(|scan| scan.family.clone()),
        style: scan_metadata.as_ref().and_then(|scan| scan.style.clone()),
        format: scan_metadata
            .as_ref()
            .and_then(|scan| {
                normalize_format(
                    scan.wrapper.as_deref(),
                    scan.font_format.as_deref(),
                    type_detail,
                )
            })
            .unwrap_or(fallback_format),
        monospace: scan_metadata.as_ref().is_some_and(|scan| scan.monospace),
        variable: scan_metadata.as_ref().is_some_and(|scan| scan.variable),
        file_size: crate::fs::format_size(byte_size),
    };

    Ok(render_font_preview(detail, preview_metadata))
}

fn fc_scan_metadata<F>(path: &Path, canceled: &F) -> Option<FcScanMetadata>
where
    F: Fn() -> bool,
{
    if canceled() || !fc_scan_available() {
        return None;
    }

    let mut command = Command::new("fc-scan");
    command.arg("--format").arg(FC_SCAN_FORMAT).arg(path);
    let output = run_command_capture_stdout_cancellable(command, "preview-font-fc-scan", canceled)?;
    parse_fc_scan_output(&String::from_utf8_lossy(&output))
}

fn fc_scan_available() -> bool {
    static FC_SCAN_AVAILABLE: OnceLock<bool> = OnceLock::new();
    *FC_SCAN_AVAILABLE.get_or_init(|| {
        Command::new("fc-scan")
            .arg("--version")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .is_ok_and(|status| status.success())
    })
}

fn parse_fc_scan_output(output: &str) -> Option<FcScanMetadata> {
    let mut lines = output.lines();
    let family = primary_name_value(lines.next().unwrap_or_default());
    let style = primary_name_value(lines.next().unwrap_or_default());
    let font_format = clean_fc_scan_value(lines.next().unwrap_or_default());
    let wrapper = clean_fc_scan_value(lines.next().unwrap_or_default());
    let monospace = is_monospace_spacing(lines.next().unwrap_or_default());
    let variable = clean_fc_scan_value(lines.next().unwrap_or_default())
        .is_some_and(|value| value.eq_ignore_ascii_case("true"));

    let metadata = FcScanMetadata {
        family,
        style,
        font_format,
        wrapper,
        monospace,
        variable,
    };
    if metadata.family.is_none()
        && metadata.style.is_none()
        && metadata.font_format.is_none()
        && metadata.wrapper.is_none()
        && !metadata.monospace
        && !metadata.variable
    {
        None
    } else {
        Some(metadata)
    }
}

fn clean_fc_scan_value(value: &str) -> Option<String> {
    let value = value.trim();
    if value.is_empty() || value == "(null)" {
        None
    } else {
        Some(value.to_string())
    }
}

fn primary_name_value(value: &str) -> Option<String> {
    value
        .split(',')
        .map(str::trim)
        .find(|family| !family.is_empty())
        .map(str::to_string)
}

fn is_monospace_spacing(value: &str) -> bool {
    matches!(value.trim(), "100" | "110")
}

fn read_font_header(path: &Path) -> Result<Vec<u8>> {
    let mut file =
        fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
    let mut header = [0u8; 8];
    let read = file
        .read(&mut header)
        .with_context(|| format!("failed to read {}", path.display()))?;
    Ok(header[..read].to_vec())
}

fn detect_font_format_from_header(header: &[u8], type_detail: Option<&'static str>) -> String {
    if header.starts_with(WOFF_MAGIC) {
        return woff_wrapper_format("WOFF", header.get(4..8));
    }
    if header.starts_with(WOFF2_MAGIC) {
        return woff_wrapper_format("WOFF2", header.get(4..8));
    }
    if header.starts_with(OTTO_MAGIC) {
        return "OpenType (CFF)".to_string();
    }
    if is_true_type_flavor(header) {
        return true_type_outline_format(type_detail).to_string();
    }
    format_from_type_detail(type_detail).to_string()
}

fn woff_wrapper_format(wrapper: &str, flavor: Option<&[u8]>) -> String {
    match flavor {
        Some(flavor) if flavor == OTTO_MAGIC => format!("{wrapper} (CFF)"),
        Some(flavor) if is_true_type_flavor(flavor) => format!("{wrapper} (TrueType)"),
        _ => wrapper.to_string(),
    }
}

fn normalize_format(
    wrapper: Option<&str>,
    font_format: Option<&str>,
    type_detail: Option<&'static str>,
) -> Option<String> {
    let wrapper = wrapper.map(str::trim).filter(|value| !value.is_empty());
    let font_format = font_format.map(str::trim).filter(|value| !value.is_empty());

    match (wrapper, font_format) {
        (Some("WOFF"), Some("CFF")) => Some("WOFF (CFF)".to_string()),
        (Some("WOFF"), Some("TrueType")) => Some("WOFF (TrueType)".to_string()),
        (Some("WOFF2"), Some("CFF")) => Some("WOFF2 (CFF)".to_string()),
        (Some("WOFF2"), Some("TrueType")) => Some("WOFF2 (TrueType)".to_string()),
        (Some("WOFF"), _) => Some("WOFF".to_string()),
        (Some("WOFF2"), _) => Some("WOFF2".to_string()),
        (Some("SFNT"), Some("CFF")) => Some("OpenType (CFF)".to_string()),
        (Some("SFNT"), Some("TrueType")) => Some(true_type_outline_format(type_detail).to_string()),
        (Some("SFNT"), _) => Some(format_from_type_detail(type_detail).to_string()),
        (Some(wrapper), Some(format)) if wrapper.eq_ignore_ascii_case(format) => {
            Some(wrapper.to_string())
        }
        (Some(wrapper), Some(format)) => Some(format!("{wrapper} ({format})")),
        (Some(wrapper), None) => Some(wrapper.to_string()),
        (None, Some("CFF")) => Some("OpenType (CFF)".to_string()),
        (None, Some("TrueType")) => Some(true_type_outline_format(type_detail).to_string()),
        (None, Some(format)) => Some(format.to_string()),
        (None, None) => None,
    }
}

fn format_from_type_detail(type_detail: Option<&'static str>) -> &'static str {
    match type_detail {
        Some("TrueType font") => "TrueType",
        Some("OpenType font") => "OpenType",
        Some("WOFF font") => "WOFF",
        Some("WOFF2 font") => "WOFF2",
        _ => "Font",
    }
}

fn true_type_outline_format(type_detail: Option<&'static str>) -> &'static str {
    match type_detail {
        Some("OpenType font") => "OpenType (TrueType)",
        _ => "TrueType",
    }
}

fn is_true_type_flavor(value: &[u8]) -> bool {
    value == TRUE_TYPE_MAGIC || value == APPLE_TRUE_TYPE_MAGIC || value == TYPE_1_MAGIC
}

fn render_font_preview(detail: &str, metadata: FontMetadata) -> PreviewContent {
    let palette = theme::palette();
    let mut fields = Vec::new();
    if let Some(family) = metadata.family {
        fields.push(("Family", family));
    }
    if let Some(style) = metadata.style {
        fields.push(("Style", style));
    }
    fields.push(("Format", metadata.format));
    if metadata.monospace {
        fields.push(("Monospace", "Yes".to_string()));
    }
    if metadata.variable {
        fields.push(("Variable", "Yes".to_string()));
    }
    fields.push(("File Size", metadata.file_size));

    let label_width = fields
        .iter()
        .map(|(label, _)| label.len())
        .max()
        .unwrap_or(8);
    let mut lines = vec![preview_section_line("Details", palette)];
    for (label, value) in fields {
        lines.push(preview_field_line(label, &value, label_width, palette));
    }

    PreviewContent::new(PreviewKind::Font, lines).with_detail(detail)
}

fn preview_section_line(title: &str, palette: theme::Palette) -> Line<'static> {
    Line::from(Span::styled(
        title.to_string(),
        Style::default().fg(palette.accent),
    ))
}

fn preview_field_line(
    label: &str,
    value: &str,
    label_width: usize,
    palette: theme::Palette,
) -> Line<'static> {
    Line::from(vec![
        Span::styled(
            format!("{label:<width$} ", width = label_width + 1),
            Style::default().fg(palette.muted),
        ),
        Span::styled(value.to_string(), Style::default().fg(palette.text)),
    ])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_fc_scan_output_extracts_clean_metadata() {
        let output = "\
JetBrainsMono Nerd Font,JetBrainsMono NF
Regular
TrueType
SFNT
100
True
";

        let metadata = parse_fc_scan_output(output).expect("expected parsed fc-scan metadata");

        assert_eq!(metadata.family.as_deref(), Some("JetBrainsMono Nerd Font"));
        assert_eq!(metadata.style.as_deref(), Some("Regular"));
        assert_eq!(metadata.font_format.as_deref(), Some("TrueType"));
        assert_eq!(metadata.wrapper.as_deref(), Some("SFNT"));
        assert!(metadata.monospace);
        assert!(metadata.variable);
    }

    #[test]
    fn normalize_format_prefers_wrapper_specific_labels() {
        assert_eq!(
            normalize_format(Some("WOFF2"), Some("TrueType"), Some("WOFF2 font")),
            Some("WOFF2 (TrueType)".to_string())
        );
        assert_eq!(
            normalize_format(Some("SFNT"), Some("CFF"), Some("OpenType font")),
            Some("OpenType (CFF)".to_string())
        );
        assert_eq!(
            normalize_format(Some("SFNT"), Some("TrueType"), Some("OpenType font")),
            Some("OpenType (TrueType)".to_string())
        );
    }

    #[test]
    fn parse_fc_scan_output_keeps_only_primary_style_name() {
        let output = "\
TypoGraphica
Regular ,Normal, obyéejné, Standard, Kavov ika, Normaali
TrueType
SFNT

False
";

        let metadata = parse_fc_scan_output(output).expect("expected parsed fc-scan metadata");

        assert_eq!(metadata.style.as_deref(), Some("Regular"));
    }

    #[test]
    fn detect_font_format_from_header_uses_magic_wrappers() {
        assert_eq!(
            detect_font_format_from_header(b"wOFF\x00\x01\x00\x00", Some("WOFF font")),
            "WOFF (TrueType)"
        );
        assert_eq!(
            detect_font_format_from_header(b"wOF2OTTO", Some("WOFF2 font")),
            "WOFF2 (CFF)"
        );
        assert_eq!(
            detect_font_format_from_header(b"OTTOrest", Some("OpenType font")),
            "OpenType (CFF)"
        );
        assert_eq!(
            detect_font_format_from_header(b"\x00\x01\x00\x00rest", Some("TrueType font")),
            "TrueType"
        );
    }
}