tree-tui 0.1.3

An interactive terminal UI for visualizing directories: code, size, and git stats.
//! Colors, glyphs, and small formatting helpers shared across the UI.

use ratatui::style::{Color, Style};
use ratatui::text::Span;
use tokei::LanguageType;

use crate::model::{SubKey, Tint};

// Tree glyphs (Unicode, renders everywhere).
pub const GLYPH_EXPANDED: &str = "";
pub const GLYPH_COLLAPSED: &str = "";
pub const GLYPH_FILE: &str = "";

/// Braille spinner frames for the loading screen.
pub const SPINNER: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];

// Semantic palette (Catppuccin-ish).
pub const ACCENT: Color = Color::Rgb(137, 180, 250);
pub const DIR: Color = Color::Rgb(137, 180, 250);
pub const MUTED: Color = Color::Rgb(127, 132, 156);
pub const SELECTION_BG: Color = Color::Rgb(49, 50, 68);
pub const WARN: Color = Color::Rgb(249, 226, 175);
pub const BADGE_FG: Color = Color::Rgb(17, 17, 27);

// Composition-bar segment colors.
pub const CODE: Color = Color::Rgb(166, 209, 137);
pub const COMMENTS: Color = Color::Rgb(125, 166, 255);
pub const BLANKS: Color = Color::Rgb(88, 91, 112);

// Lens-metric colors (size, git add/delete, status).
pub const ADD: Color = Color::Rgb(166, 209, 137); // green
pub const DEL: Color = Color::Rgb(243, 139, 168); // red
pub const SIZE: Color = Color::Rgb(250, 179, 135); // peach
pub const STATUS: Color = Color::Rgb(249, 226, 175); // amber

/// A recognizable color for popular languages; a stable hashed color otherwise.
pub fn language_color(lang: LanguageType) -> Color {
    use LanguageType as L;
    match lang {
        L::Rust => Color::Rgb(222, 165, 132),
        L::Python => Color::Rgb(75, 139, 190),
        L::JavaScript | L::Jsx => Color::Rgb(240, 219, 79),
        L::TypeScript | L::Tsx => Color::Rgb(49, 120, 198),
        L::Go => Color::Rgb(0, 173, 216),
        L::C | L::CHeader => Color::Rgb(120, 140, 165),
        L::Cpp | L::CppHeader => Color::Rgb(243, 75, 125),
        L::CSharp => Color::Rgb(150, 90, 190),
        L::Java => Color::Rgb(176, 114, 25),
        L::Kotlin => Color::Rgb(169, 123, 255),
        L::Ruby => Color::Rgb(204, 52, 45),
        L::Php => Color::Rgb(119, 123, 180),
        L::Html => Color::Rgb(228, 77, 38),
        L::Css | L::Sass | L::Less => Color::Rgb(150, 120, 200),
        L::Swift => Color::Rgb(240, 81, 56),
        L::Scala => Color::Rgb(199, 42, 32),
        L::Haskell => Color::Rgb(140, 120, 180),
        L::OCaml => Color::Rgb(238, 106, 26),
        L::Elixir => Color::Rgb(150, 116, 170),
        L::Erlang => Color::Rgb(168, 47, 55),
        L::Clojure => Color::Rgb(99, 179, 60),
        L::Lua => Color::Rgb(120, 120, 220),
        L::Markdown => Color::Rgb(150, 150, 150),
        L::Json | L::Yaml | L::Toml | L::Xml => Color::Rgb(180, 142, 173),
        L::Sql => Color::Rgb(227, 131, 38),
        L::Sh | L::Bash | L::Zsh => Color::Rgb(137, 224, 81),
        L::Dockerfile => Color::Rgb(60, 150, 220),
        L::Vue => Color::Rgb(65, 184, 131),
        L::Svelte => Color::Rgb(255, 62, 0),
        L::Zig => Color::Rgb(247, 164, 29),
        L::Dart => Color::Rgb(0, 180, 171),
        L::R => Color::Rgb(100, 150, 220),
        L::Julia => Color::Rgb(150, 110, 200),
        _ => hashed_color(lang.name()),
    }
}

fn hashed_color(name: &str) -> Color {
    // FNV-1a over the language name → a cell in the 6×6×6 color cube, skipping
    // the darkest rows so text stays legible.
    let mut hash: u32 = 2_166_136_261;
    for byte in name.bytes() {
        hash ^= u32::from(byte);
        hash = hash.wrapping_mul(16_777_619);
    }
    let index = 17 + (hash % (231 - 17));
    Color::Indexed(index as u8)
}

/// A human-friendly display name (tokei's `name()` is SCREAMING-cased).
pub fn language_label(lang: LanguageType) -> String {
    use LanguageType as L;
    let curated = match lang {
        L::Rust => "Rust",
        L::Python => "Python",
        L::JavaScript => "JavaScript",
        L::Jsx => "JSX",
        L::TypeScript => "TypeScript",
        L::Tsx => "TSX",
        L::Go => "Go",
        L::C => "C",
        L::CHeader => "C Header",
        L::Cpp => "C++",
        L::CppHeader => "C++ Header",
        L::CSharp => "C#",
        L::Java => "Java",
        L::Kotlin => "Kotlin",
        L::Ruby => "Ruby",
        L::Php => "PHP",
        L::Html => "HTML",
        L::Css => "CSS",
        L::Sass => "Sass",
        L::Less => "Less",
        L::Swift => "Swift",
        L::Scala => "Scala",
        L::Haskell => "Haskell",
        L::OCaml => "OCaml",
        L::Elixir => "Elixir",
        L::Erlang => "Erlang",
        L::Clojure => "Clojure",
        L::Lua => "Lua",
        L::Markdown => "Markdown",
        L::Json => "JSON",
        L::Yaml => "YAML",
        L::Toml => "TOML",
        L::Xml => "XML",
        L::Sql => "SQL",
        L::Sh => "Shell",
        L::Bash => "Bash",
        L::Zsh => "Zsh",
        L::Dockerfile => "Dockerfile",
        L::Vue => "Vue",
        L::Svelte => "Svelte",
        L::Zig => "Zig",
        L::Dart => "Dart",
        _ => return prettify(lang.name()),
    };
    curated.to_string()
}

/// Title-case a SCREAMING name like `"AWK"` → `"Awk"` for uncurated languages.
fn prettify(name: &str) -> String {
    let mut chars = name.chars();
    match chars.next() {
        Some(first) => first
            .to_uppercase()
            .chain(chars.flat_map(char::to_lowercase))
            .collect(),
        None => String::new(),
    }
}

/// Group an integer with thousands separators: `12345` → `"12,345"`.
pub fn group_thousands(n: usize) -> String {
    let digits = n.to_string();
    let bytes = digits.as_bytes();
    let len = bytes.len();
    let mut out = String::with_capacity(len + len / 3);
    for (i, byte) in bytes.iter().enumerate() {
        if i > 0 && (len - i).is_multiple_of(3) {
            out.push(',');
        }
        out.push(*byte as char);
    }
    out
}

/// Format `value / total` as a percentage with one decimal: `"58.4%"`.
pub fn percent(value: usize, total: usize) -> String {
    if total == 0 {
        return "0.0%".to_string();
    }
    format!("{:.1}%", value as f64 / total as f64 * 100.0)
}

/// A `width`-cell horizontal bar split across colored segments (e.g. code /
/// comments / blanks, or added / deleted). The last segment absorbs rounding.
pub fn segments_bar(segments: &[(usize, Color)], width: usize) -> Vec<Span<'static>> {
    let total: usize = segments.iter().map(|(value, _)| *value).sum();
    if total == 0 || width == 0 {
        return vec![Span::styled("".repeat(width), Style::default().fg(MUTED))];
    }
    let mut spans = Vec::new();
    let mut used = 0;
    for (i, (value, color)) in segments.iter().enumerate() {
        let cells = if i + 1 == segments.len() {
            width - used
        } else {
            (value * width / total).min(width - used)
        };
        if cells > 0 {
            spans.push(Span::styled("".repeat(cells), Style::default().fg(*color)));
            used += cells;
        }
    }
    spans
}

/// A `width`-cell bar filled to `value / total` in `color`, padded with `░`.
pub fn ratio_bar(value: usize, total: usize, width: usize, color: Color) -> Vec<Span<'static>> {
    if total == 0 || width == 0 {
        return vec![Span::styled("".repeat(width), Style::default().fg(MUTED))];
    }
    let filled = (value * width / total).min(width);
    let mut spans = Vec::new();
    if filled > 0 {
        spans.push(Span::styled("".repeat(filled), Style::default().fg(color)));
    }
    if filled < width {
        spans.push(Span::styled(
            "".repeat(width - filled),
            Style::default().fg(BLANKS),
        ));
    }
    spans
}

/// Human-readable byte size (binary units, one decimal): `1536` → `"1.5 KB"`.
pub fn human_bytes(bytes: u64) -> String {
    const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
    if bytes < 1024 {
        return format!("{bytes} B");
    }
    let mut value = bytes as f64;
    let mut unit = 0;
    while value >= 1024.0 && unit < UNITS.len() - 1 {
        value /= 1024.0;
        unit += 1;
    }
    format!("{value:.1} {}", UNITS[unit])
}

/// Format a metric value for display: sizes are human-readable bytes, everything
/// else is a thousands-grouped count.
pub fn format_value(key: SubKey, value: u128) -> String {
    if key.is_bytes() {
        human_bytes(value as u64)
    } else {
        group_thousands(value as usize)
    }
}

/// The color for a column [`Tint`]. `Plain` has no color (rendered bold/default).
pub fn tint_color(tint: Tint) -> Option<Color> {
    match tint {
        Tint::Code => Some(CODE),
        Tint::Comments => Some(COMMENTS),
        Tint::Blanks => Some(BLANKS),
        Tint::Size => Some(SIZE),
        Tint::Add => Some(ADD),
        Tint::Del => Some(DEL),
        Tint::Status => Some(STATUS),
        Tint::Plain => None,
    }
}