feat(cli): add GlyphMode icon system, Theme extensions, and progress API
Phase 1 of UX skin overhaul: foundation layer that all subsequent phases build upon. Icons: 3-tier glyph system (Nerd Font / Unicode / ASCII) with auto-detection from TERM_PROGRAM, LORE_ICONS env, or --icons flag. 16 semantic icon methods on Icons struct (success, warning, error, issue states, MR states, note, search, user, sync, waiting). Theme: 4 new semantic styles — muted (#6b7280), highlight (#fbbf24), timing (#94a3b8), state_draft (#6b7280). Progress: stage_spinner_v2 with icon prefix, nested_progress with bounded bar/throughput/ETA, finish_stage for static completion lines, format_elapsed for compact duration strings. Utilities: format_relative_time_compact (3h, 2d, 1w, 3mo), format_labels_bare (comma-separated without brackets). CLI: --icons global flag, GLOBAL_FLAGS registry updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,17 +16,234 @@ pub enum ColorMode {
|
||||
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. Auto-detect: Nerd if `$TERM_PROGRAM` matches known Nerd-capable terminals
|
||||
/// or `$NERD_FONTS=1`; otherwise Unicode
|
||||
/// 4. Force ASCII if `force_ascii` is true (robot mode or `--color never`)
|
||||
pub fn detect(cli_flag: Option<&str>, force_ascii: bool) -> Self {
|
||||
if force_ascii {
|
||||
return Self::Ascii;
|
||||
}
|
||||
|
||||
// 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. 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<LoreRenderer> = 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) {
|
||||
pub fn init(mode: ColorMode, glyphs: GlyphMode) {
|
||||
let colors = match mode {
|
||||
ColorMode::Always => true,
|
||||
ColorMode::Never => false,
|
||||
@@ -35,7 +252,7 @@ impl LoreRenderer {
|
||||
&& std::env::var("NO_COLOR").map_or(true, |v| v.is_empty())
|
||||
}
|
||||
};
|
||||
let _ = RENDERER.set(LoreRenderer { colors });
|
||||
let _ = RENDERER.set(LoreRenderer { colors, glyphs });
|
||||
}
|
||||
|
||||
/// Get the global renderer. Panics if `init` hasn't been called.
|
||||
@@ -49,6 +266,11 @@ impl LoreRenderer {
|
||||
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
|
||||
@@ -176,6 +398,39 @@ impl Theme {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -369,6 +624,24 @@ pub fn format_labels(labels: &[String], max_shown: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -378,6 +651,26 @@ pub fn format_duration_ms(ms: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -946,6 +1239,121 @@ mod tests {
|
||||
assert!(plain.contains("1"), "got: {plain}");
|
||||
}
|
||||
|
||||
// ── GlyphMode ──
|
||||
|
||||
#[test]
|
||||
fn glyph_mode_cli_flag_overrides_all() {
|
||||
assert_eq!(GlyphMode::detect(Some("ascii"), false), GlyphMode::Ascii);
|
||||
assert_eq!(GlyphMode::detect(Some("nerd"), false), GlyphMode::Nerd);
|
||||
assert_eq!(
|
||||
GlyphMode::detect(Some("unicode"), false),
|
||||
GlyphMode::Unicode
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_mode_force_ascii_overrides_cli_flag() {
|
||||
assert_eq!(GlyphMode::detect(Some("nerd"), true), GlyphMode::Ascii);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_mode_unknown_falls_back_to_unicode() {
|
||||
assert_eq!(GlyphMode::detect(Some("bogus"), false), GlyphMode::Unicode);
|
||||
}
|
||||
|
||||
// ── Icons ──
|
||||
|
||||
#[test]
|
||||
fn icons_return_nonempty_strings() {
|
||||
// Without RENDERER initialized, Icons falls back to Unicode mode
|
||||
assert!(!Icons::success().is_empty());
|
||||
assert!(!Icons::warning().is_empty());
|
||||
assert!(!Icons::error().is_empty());
|
||||
assert!(!Icons::info().is_empty());
|
||||
assert!(!Icons::issue_opened().is_empty());
|
||||
assert!(!Icons::issue_closed().is_empty());
|
||||
assert!(!Icons::mr_opened().is_empty());
|
||||
assert!(!Icons::mr_merged().is_empty());
|
||||
assert!(!Icons::mr_closed().is_empty());
|
||||
assert!(!Icons::mr_draft().is_empty());
|
||||
assert!(!Icons::note().is_empty());
|
||||
assert!(!Icons::search().is_empty());
|
||||
assert!(!Icons::user().is_empty());
|
||||
assert!(!Icons::sync().is_empty());
|
||||
assert!(!Icons::waiting().is_empty());
|
||||
assert!(!Icons::progress_chars().is_empty());
|
||||
}
|
||||
|
||||
// ── format_labels_bare ──
|
||||
|
||||
#[test]
|
||||
fn format_labels_bare_empty() {
|
||||
assert_eq!(format_labels_bare(&[], 2), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_bare_no_brackets() {
|
||||
let labels = vec!["bug".to_string(), "urgent".to_string()];
|
||||
assert_eq!(format_labels_bare(&labels, 2), "bug, urgent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_bare_overflow() {
|
||||
let labels = vec![
|
||||
"a".to_string(),
|
||||
"b".to_string(),
|
||||
"c".to_string(),
|
||||
"d".to_string(),
|
||||
];
|
||||
assert_eq!(format_labels_bare(&labels, 2), "a, b +2");
|
||||
}
|
||||
|
||||
// ── format_relative_time_compact ──
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_now() {
|
||||
let recent = now_ms() - 5_000;
|
||||
assert_eq!(format_relative_time_compact(recent), "now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_minutes() {
|
||||
let mins_ago = now_ms() - 300_000; // 5 minutes
|
||||
assert_eq!(format_relative_time_compact(mins_ago), "5m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_hours() {
|
||||
let hours_ago = now_ms() - 7_200_000; // 2 hours
|
||||
assert_eq!(format_relative_time_compact(hours_ago), "2h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_days() {
|
||||
let days_ago = now_ms() - 172_800_000; // 2 days
|
||||
assert_eq!(format_relative_time_compact(days_ago), "2d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_weeks() {
|
||||
let weeks_ago = now_ms() - 1_209_600_000; // 2 weeks
|
||||
assert_eq!(format_relative_time_compact(weeks_ago), "2w");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_months() {
|
||||
let months_ago = now_ms() - 5_184_000_000; // ~2 months
|
||||
assert_eq!(format_relative_time_compact(months_ago), "2mo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_future() {
|
||||
let future = now_ms() + 60_000;
|
||||
assert_eq!(format_relative_time_compact(future), "future");
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
/// Strip ANSI escape codes (SGR sequences) for content assertions.
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
|
||||
Reference in New Issue
Block a user