use std::io::IsTerminal; use std::sync::OnceLock; use chrono::DateTime; use lipgloss::Style; use crate::core::time::{ms_to_iso, now_ms}; // ─── Color Mode ────────────────────────────────────────────────────────────── /// Color mode derived from CLI `--color` flag and `NO_COLOR` env. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ColorMode { Auto, Always, Never, } // ─── Glyph Mode ───────────────────────────────────────────────────────────── /// Icon tier: Nerd Font glyphs, Unicode symbols, or plain ASCII. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GlyphMode { Nerd, Unicode, Ascii, } impl GlyphMode { /// Detect glyph mode from CLI flag, env, or terminal heuristics. /// /// Precedence: /// 1. Explicit `--icons` CLI value (passed as `cli_flag`) /// 2. `LORE_ICONS` environment variable /// 3. Force ASCII fallback if `force_ascii` is true (robot mode) /// 4. Auto-detect: Nerd if `$TERM_PROGRAM` matches known Nerd-capable terminals /// or `$NERD_FONTS=1`; otherwise Unicode pub fn detect(cli_flag: Option<&str>, force_ascii: bool) -> Self { // 1. CLI flag if let Some(flag) = cli_flag { return Self::from_str_lossy(flag); } // 2. Env var if let Ok(val) = std::env::var("LORE_ICONS") { return Self::from_str_lossy(&val); } // 3. Robot-safe fallback if force_ascii { return Self::Ascii; } // 4. Auto-detect if Self::detect_nerd_capable() { Self::Nerd } else { Self::Unicode } } fn from_str_lossy(s: &str) -> Self { match s.to_ascii_lowercase().as_str() { "nerd" => Self::Nerd, "unicode" => Self::Unicode, "ascii" => Self::Ascii, _ => Self::Unicode, } } fn detect_nerd_capable() -> bool { if std::env::var("NERD_FONTS") .ok() .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) { return true; } std::env::var("TERM_PROGRAM").ok().is_some_and(|tp| { matches!( tp.as_str(), "WezTerm" | "kitty" | "Alacritty" | "iTerm2.app" | "iTerm.app" ) }) } } // ─── Icons ────────────────────────────────────────────────────────────────── /// Glyph catalog returning the right icon for the active `GlyphMode`. pub struct Icons; impl Icons { fn mode() -> GlyphMode { RENDERER.get().map_or(GlyphMode::Unicode, |r| r.glyphs) } // ── Status indicators ── pub fn success() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f058}", // nf-fa-check_circle GlyphMode::Unicode => "\u{2714}", // heavy check mark GlyphMode::Ascii => "[ok]", } } pub fn warning() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f421}", // nf-oct-alert GlyphMode::Unicode => "\u{26a0}", // warning sign GlyphMode::Ascii => "[!]", } } pub fn error() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f467}", // nf-oct-x_circle GlyphMode::Unicode => "\u{2716}", // heavy multiplication x GlyphMode::Ascii => "[X]", } } pub fn info() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f449}", // nf-oct-info GlyphMode::Unicode => "\u{2139}", // information source GlyphMode::Ascii => "[i]", } } // ── Entity state ── pub fn issue_opened() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f41b}", // nf-oct-issue_opened GlyphMode::Unicode => "\u{25cb}", // white circle GlyphMode::Ascii => "( )", } } pub fn issue_closed() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f41d}", // nf-oct-issue_closed GlyphMode::Unicode => "\u{25cf}", // black circle GlyphMode::Ascii => "(x)", } } pub fn mr_opened() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f407}", // nf-oct-git_pull_request GlyphMode::Unicode => "\u{21c4}", // rightwards arrow over leftwards GlyphMode::Ascii => "<->", } } pub fn mr_merged() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f402}", // nf-oct-git_merge GlyphMode::Unicode => "\u{2714}", // heavy check mark GlyphMode::Ascii => "[M]", } } pub fn mr_closed() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f430}", // nf-oct-git_pull_request_closed GlyphMode::Unicode => "\u{2716}", // heavy multiplication x GlyphMode::Ascii => "[X]", } } pub fn mr_draft() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f040}", // nf-fa-pencil GlyphMode::Unicode => "\u{270e}", // lower right pencil GlyphMode::Ascii => "[D]", } } // ── Miscellaneous ── pub fn note() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f3ed}", // nf-oct-comment GlyphMode::Unicode => "\u{25b8}", // black right-pointing small triangle GlyphMode::Ascii => ">", } } pub fn search() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f422}", // nf-oct-search GlyphMode::Unicode => "\u{1f50d}", // left-pointing magnifying glass GlyphMode::Ascii => "?", } } pub fn user() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f415}", // nf-oct-person GlyphMode::Unicode => "@", GlyphMode::Ascii => "@", } } pub fn sync() -> &'static str { match Self::mode() { GlyphMode::Nerd => "\u{f46a}", // nf-oct-sync GlyphMode::Unicode => "\u{21bb}", // clockwise open circle arrow GlyphMode::Ascii => "<>", } } /// Waiting stage indicator (dimmed dot). pub fn waiting() -> &'static str { match Self::mode() { GlyphMode::Nerd | GlyphMode::Unicode => "\u{00b7}", // middle dot GlyphMode::Ascii => ".", } } /// Progress bar characters: (filled, head, empty). pub fn progress_chars() -> &'static str { match Self::mode() { GlyphMode::Nerd | GlyphMode::Unicode => "\u{2501}\u{2578} ", GlyphMode::Ascii => "=> ", } } } // ─── Renderer ─────────────────────────────────────────────────────────────── /// Global renderer singleton, initialized once in `main.rs`. static RENDERER: OnceLock = OnceLock::new(); pub struct LoreRenderer { /// Resolved at init time so we don't re-check TTY + NO_COLOR on every call. colors: bool, /// Icon tier for the session. glyphs: GlyphMode, } impl LoreRenderer { /// Initialize the global renderer. Call once at startup. pub fn init(mode: ColorMode, glyphs: GlyphMode) { let colors = match mode { ColorMode::Always => true, ColorMode::Never => false, ColorMode::Auto => { std::io::stdout().is_terminal() && std::env::var("NO_COLOR").map_or(true, |v| v.is_empty()) } }; let _ = RENDERER.set(LoreRenderer { colors, glyphs }); } /// Get the global renderer. Panics if `init` hasn't been called. pub fn get() -> &'static LoreRenderer { RENDERER .get() .expect("LoreRenderer::init must be called before get") } /// Try to get the global renderer. Returns `None` if `init` hasn't been called. pub fn try_get() -> Option<&'static LoreRenderer> { RENDERER.get() } /// Whether color output is enabled. pub fn colors_enabled(&self) -> bool { self.colors } /// The active glyph mode. pub fn glyph_mode(&self) -> GlyphMode { self.glyphs } } /// Check if colors are enabled. Returns false if `LoreRenderer` hasn't been /// initialized (e.g. in tests), which is the safe default. fn colors_on() -> bool { RENDERER.get().is_some_and(LoreRenderer::colors_enabled) } // ─── Theme ─────────────────────────────────────────────────────────────────── /// Semantic style constants for the compact professional design language. /// /// When colors are disabled (`--color never`, `NO_COLOR`, non-TTY), all methods /// return a plain `Style::new()` that passes text through unchanged. This is /// necessary because lipgloss's `Style::render()` uses a hardcoded TrueColor /// renderer by default and does NOT auto-detect `NO_COLOR` or TTY state. pub struct Theme; impl Theme { // Text emphasis pub fn bold() -> Style { if colors_on() { Style::new().bold() } else { Style::new() } } pub fn dim() -> Style { if colors_on() { Style::new().faint() } else { Style::new() } } // Semantic colors pub fn success() -> Style { if colors_on() { Style::new().foreground("#10b981") } else { Style::new() } } pub fn warning() -> Style { if colors_on() { Style::new().foreground("#f59e0b") } else { Style::new() } } pub fn error() -> Style { if colors_on() { Style::new().foreground("#ef4444") } else { Style::new() } } pub fn info() -> Style { if colors_on() { Style::new().foreground("#06b6d4") } else { Style::new() } } pub fn accent() -> Style { if colors_on() { Style::new().foreground("#a855f7") } else { Style::new() } } // Entity styling pub fn issue_ref() -> Style { if colors_on() { Style::new().foreground("#06b6d4") } else { Style::new() } } pub fn mr_ref() -> Style { if colors_on() { Style::new().foreground("#a855f7") } else { Style::new() } } pub fn username() -> Style { if colors_on() { Style::new().foreground("#06b6d4") } else { Style::new() } } // State pub fn state_opened() -> Style { if colors_on() { Style::new().foreground("#10b981") } else { Style::new() } } pub fn state_closed() -> Style { if colors_on() { Style::new().faint() } else { Style::new() } } pub fn state_merged() -> Style { if colors_on() { Style::new().foreground("#a855f7") } else { Style::new() } } pub fn state_draft() -> Style { if colors_on() { Style::new().foreground("#6b7280") } else { Style::new() } } // Semantic additions pub fn muted() -> Style { if colors_on() { Style::new().foreground("#6b7280") } else { Style::new() } } pub fn highlight() -> Style { if colors_on() { Style::new().foreground("#fbbf24").bold() } else { Style::new() } } pub fn timing() -> Style { if colors_on() { Style::new().foreground("#94a3b8") } else { Style::new() } } // Structure pub fn section_title() -> Style { if colors_on() { Style::new().foreground("#06b6d4").bold() } else { Style::new() } } pub fn header() -> Style { if colors_on() { Style::new().bold() } else { Style::new() } } /// Apply semantic color to a stage-completion icon glyph. pub fn color_icon(icon: &str, has_errors: bool) -> String { if has_errors { Self::warning().render(icon) } else { Self::success().render(icon) } } } // ─── Shared Formatters ─────────────────────────────────────────────────────── /// Format an integer with thousands separators (commas). pub fn format_number(n: i64) -> String { let (prefix, abs) = if n < 0 { ("-", n.unsigned_abs()) } else { ("", n.unsigned_abs()) }; let s = abs.to_string(); let chars: Vec = s.chars().collect(); let mut result = String::from(prefix); for (i, c) in chars.iter().enumerate() { if i > 0 && (chars.len() - i).is_multiple_of(3) { result.push(','); } result.push(*c); } result } /// Format an epoch-ms timestamp as a human-friendly relative time string. pub fn format_relative_time(ms_epoch: i64) -> String { let now = now_ms(); let diff = now - ms_epoch; if diff < 0 { return "in the future".to_string(); } match diff { d if d < 60_000 => "just now".to_string(), d if d < 3_600_000 => format!("{} min ago", d / 60_000), d if d < 86_400_000 => { let n = d / 3_600_000; format!("{n} {} ago", if n == 1 { "hour" } else { "hours" }) } d if d < 604_800_000 => { let n = d / 86_400_000; format!("{n} {} ago", if n == 1 { "day" } else { "days" }) } d if d < 2_592_000_000 => { let n = d / 604_800_000; format!("{n} {} ago", if n == 1 { "week" } else { "weeks" }) } _ => { let n = diff / 2_592_000_000; format!("{n} {} ago", if n == 1 { "month" } else { "months" }) } } } /// Format an epoch-ms timestamp as YYYY-MM-DD. pub fn format_date(ms: i64) -> String { let iso = ms_to_iso(ms); iso.split('T').next().unwrap_or(&iso).to_string() } /// Format an epoch-ms timestamp as YYYY-MM-DD HH:MM. pub fn format_datetime(ms: i64) -> String { DateTime::from_timestamp_millis(ms) .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| "unknown".to_string()) } /// Detect terminal width. Checks `COLUMNS` env, then stderr ioctl, falls back to 80. pub fn terminal_width() -> usize { // 1. Explicit COLUMNS env (set by some shells, resized terminals) if let Ok(val) = std::env::var("COLUMNS") && let Ok(w) = val.parse::() && w > 0 { return w; } // 2. ioctl on stderr (works even when stdout is piped) #[cfg(unix)] { use std::mem::MaybeUninit; #[allow(non_camel_case_types)] #[repr(C)] struct winsize { ws_row: libc::c_ushort, ws_col: libc::c_ushort, ws_xpixel: libc::c_ushort, ws_ypixel: libc::c_ushort, } let mut ws = MaybeUninit::::uninit(); // SAFETY: ioctl with TIOCGWINSZ writes into the winsize struct. // stderr (fd 2) is used because stdout may be piped. if unsafe { libc::ioctl(2, libc::TIOCGWINSZ, ws.as_mut_ptr()) } == 0 { let ws = unsafe { ws.assume_init() }; let w = ws.ws_col as usize; if w > 0 { return w; } } } 80 } /// Strip ANSI escape codes (SGR sequences) from a string. pub fn strip_ansi(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut chars = s.chars(); while let Some(c) = chars.next() { if c == '\x1b' { // Consume `[`, then digits/semicolons, then the final letter if chars.next() == Some('[') { for c in chars.by_ref() { if c.is_ascii_alphabetic() { break; } } } } else { out.push(c); } } out } /// Compute the visible width of a string that may contain ANSI escape sequences. pub fn visible_width(s: &str) -> usize { strip_ansi(s).chars().count() } /// Truncate a string to `max` characters, appending "..." if truncated. pub fn truncate(s: &str, max: usize) -> String { if max < 4 { return s.chars().take(max).collect(); } if s.chars().count() <= max { s.to_owned() } else { let truncated: String = s.chars().take(max.saturating_sub(3)).collect(); format!("{truncated}...") } } /// Truncate and right-pad to exactly `width` visible characters. pub fn truncate_pad(s: &str, width: usize) -> String { let t = truncate(s, width); let count = t.chars().count(); if count < width { format!("{t}{}", " ".repeat(width - count)) } else { t } } /// Word-wrap text to `width`, prepending `indent` to continuation lines. /// Returns a single string with embedded newlines. pub fn wrap_indent(text: &str, width: usize, indent: &str) -> String { let mut result = String::new(); let mut current_line = String::new(); for word in text.split_whitespace() { if current_line.is_empty() { current_line = word.to_string(); } else if current_line.len() + 1 + word.len() <= width { current_line.push(' '); current_line.push_str(word); } else { if !result.is_empty() { result.push('\n'); result.push_str(indent); } result.push_str(¤t_line); current_line = word.to_string(); } } if !current_line.is_empty() { if !result.is_empty() { result.push('\n'); result.push_str(indent); } result.push_str(¤t_line); } result } /// Word-wrap text to `width`, returning a Vec of lines. pub fn wrap_lines(text: &str, width: usize) -> Vec { let mut lines = Vec::new(); let mut current = String::new(); for word in text.split_whitespace() { if current.is_empty() { current = word.to_string(); } else if current.len() + 1 + word.len() <= width { current.push(' '); current.push_str(word); } else { lines.push(current); current = word.to_string(); } } if !current.is_empty() { lines.push(current); } lines } /// Render a section divider: `── Title ──────────────────────` pub fn section_divider(title: &str) -> String { // prefix: 2 indent + 2 box-drawing + 1 space = 5 // suffix: 1 space + trailing box-drawing let used = 5 + title.len() + 1; let rule_len = terminal_width().saturating_sub(used); format!( "\n {} {} {}", Theme::dim().render("\u{2500}\u{2500}"), Theme::section_title().render(title), Theme::dim().render(&"\u{2500}".repeat(rule_len)), ) } /// Apply a hex color (e.g. `"#ff6b6b"`) to text via lipgloss. /// Returns styled text if hex is valid, or plain text otherwise. pub fn style_with_hex(text: &str, hex: Option<&str>) -> String { match hex { Some(h) if h.len() >= 4 && colors_on() => Style::new().foreground(h).render(text), _ => text.to_string(), } } /// Format a slice of labels with overflow indicator. /// e.g. `["bug", "urgent"]` with max 2 -> `"[bug, urgent]"` /// e.g. `["a", "b", "c", "d"]` with max 2 -> `"[a, b +2]"` pub fn format_labels(labels: &[String], max_shown: usize) -> String { if labels.is_empty() { return String::new(); } let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect(); let overflow = labels.len().saturating_sub(max_shown); if overflow > 0 { format!("[{} +{}]", shown.join(", "), overflow) } else { format!("[{}]", shown.join(", ")) } } /// Format a slice of labels without brackets. /// e.g. `["bug", "urgent"]` with max 2 -> `"bug, urgent"` /// e.g. `["a", "b", "c", "d"]` with max 2 -> `"a, b +2"` pub fn format_labels_bare(labels: &[String], max_shown: usize) -> String { if labels.is_empty() { return String::new(); } let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect(); let overflow = labels.len().saturating_sub(max_shown); if overflow > 0 { format!("{} +{}", shown.join(", "), overflow) } else { shown.join(", ") } } /// Format a duration in milliseconds as a human-friendly string. pub fn format_duration_ms(ms: u64) -> String { if ms < 1000 { format!("{ms}ms") } else { format!("{:.1}s", ms as f64 / 1000.0) } } /// Format an epoch-ms timestamp as a compact relative time string. /// Returns short forms like `3h`, `2d`, `1w`, `3mo` suitable for tight table columns. pub fn format_relative_time_compact(ms_epoch: i64) -> String { let now = now_ms(); let diff = now - ms_epoch; if diff < 0 { return "future".to_string(); } match diff { d if d < 60_000 => "now".to_string(), d if d < 3_600_000 => format!("{}m", d / 60_000), d if d < 86_400_000 => format!("{}h", d / 3_600_000), d if d < 604_800_000 => format!("{}d", d / 86_400_000), d if d < 2_592_000_000 => format!("{}w", d / 604_800_000), _ => format!("{}mo", diff / 2_592_000_000), } } // ─── Table Renderer ────────────────────────────────────────────────────────── /// Column alignment for the table renderer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Align { Left, Right, } /// A cell in a table row, optionally styled. pub struct StyledCell { pub text: String, pub style: Option