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:
@@ -44,6 +44,7 @@ const GLOBAL_FLAGS: &[&str] = &[
|
|||||||
"--robot",
|
"--robot",
|
||||||
"--json",
|
"--json",
|
||||||
"--color",
|
"--color",
|
||||||
|
"--icons",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
"--no-quiet",
|
"--no-quiet",
|
||||||
"--verbose",
|
"--verbose",
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ pub struct Cli {
|
|||||||
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")]
|
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")]
|
||||||
pub color: String,
|
pub color: String,
|
||||||
|
|
||||||
|
/// Icon set: nerd (Nerd Fonts), unicode, or ascii
|
||||||
|
#[arg(long, global = true, value_parser = ["nerd", "unicode", "ascii"], help = "Icon set: nerd (Nerd Fonts), unicode, or ascii")]
|
||||||
|
pub icons: Option<String>,
|
||||||
|
|
||||||
/// Suppress non-essential output
|
/// Suppress non-essential output
|
||||||
#[arg(
|
#[arg(
|
||||||
short = 'q',
|
short = 'q',
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Duration;
|
||||||
use tracing_subscriber::fmt::MakeWriter;
|
use tracing_subscriber::fmt::MakeWriter;
|
||||||
|
|
||||||
|
use crate::cli::render::Icons;
|
||||||
|
|
||||||
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
|
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
|
||||||
|
|
||||||
pub fn multi() -> &'static MultiProgress {
|
pub fn multi() -> &'static MultiProgress {
|
||||||
@@ -29,6 +32,71 @@ pub fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> Progr
|
|||||||
pb
|
pb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stage spinner with icon prefix and elapsed time on the right.
|
||||||
|
///
|
||||||
|
/// Template: `{spinner:.cyan} {prefix} {wide_msg} {elapsed_style:.dim}`
|
||||||
|
pub fn stage_spinner_v2(icon: &str, label: &str, msg: &str, robot_mode: bool) -> ProgressBar {
|
||||||
|
if robot_mode {
|
||||||
|
return ProgressBar::hidden();
|
||||||
|
}
|
||||||
|
let pb = multi().add(ProgressBar::new_spinner());
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_spinner()
|
||||||
|
.template(" {spinner:.cyan} {prefix} {wide_msg}")
|
||||||
|
.expect("valid template"),
|
||||||
|
);
|
||||||
|
pb.enable_steady_tick(Duration::from_millis(60));
|
||||||
|
pb.set_prefix(format!("{icon} {label}"));
|
||||||
|
pb.set_message(msg.to_string());
|
||||||
|
pb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nested progress bar with count, throughput, and ETA.
|
||||||
|
///
|
||||||
|
/// Template: ` {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}`
|
||||||
|
pub fn nested_progress(msg: &str, len: u64, robot_mode: bool) -> ProgressBar {
|
||||||
|
if robot_mode {
|
||||||
|
return ProgressBar::hidden();
|
||||||
|
}
|
||||||
|
let pb = multi().add(ProgressBar::new(len));
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template(
|
||||||
|
" {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}",
|
||||||
|
)
|
||||||
|
.expect("valid template")
|
||||||
|
.progress_chars(Icons::progress_chars()),
|
||||||
|
);
|
||||||
|
pb.enable_steady_tick(Duration::from_millis(60));
|
||||||
|
pb.set_message(msg.to_string());
|
||||||
|
pb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace a spinner with a static completion line showing icon, label, summary, and elapsed.
|
||||||
|
///
|
||||||
|
/// Output: ` ✓ Label summary elapsed`
|
||||||
|
pub fn finish_stage(pb: &ProgressBar, icon: &str, label: &str, summary: &str, elapsed: Duration) {
|
||||||
|
let elapsed_str = format_elapsed(elapsed);
|
||||||
|
let line = format!(" {icon} {label:<12}{summary:>40} {elapsed_str:>8}",);
|
||||||
|
pb.set_style(ProgressStyle::with_template("{msg}").expect("valid template"));
|
||||||
|
pb.finish_with_message(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a Duration as a compact human string (e.g. "1.2s", "42ms", "1m 5s").
|
||||||
|
fn format_elapsed(d: Duration) -> String {
|
||||||
|
let ms = d.as_millis();
|
||||||
|
if ms < 1000 {
|
||||||
|
format!("{ms}ms")
|
||||||
|
} else if ms < 60_000 {
|
||||||
|
format!("{:.1}s", ms as f64 / 1000.0)
|
||||||
|
} else {
|
||||||
|
let secs = d.as_secs();
|
||||||
|
let m = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
format!("{m}m {s}s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SuspendingWriter;
|
pub struct SuspendingWriter;
|
||||||
|
|
||||||
@@ -116,9 +184,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stage_spinner_human_mode_sets_properties() {
|
fn stage_spinner_human_mode_sets_properties() {
|
||||||
// In non-TTY test environments, MultiProgress may report bars as
|
|
||||||
// hidden. Verify the human-mode code path by checking that prefix
|
|
||||||
// and message are configured (robot-mode returns a bare hidden bar).
|
|
||||||
let pb = stage_spinner(1, 3, "Testing...", false);
|
let pb = stage_spinner(1, 3, "Testing...", false);
|
||||||
assert_eq!(pb.prefix(), "[1/3]");
|
assert_eq!(pb.prefix(), "[1/3]");
|
||||||
assert_eq!(pb.message(), "Testing...");
|
assert_eq!(pb.message(), "Testing...");
|
||||||
@@ -138,4 +203,52 @@ mod tests {
|
|||||||
assert_eq!(pb.message(), "Seeding timeline...");
|
assert_eq!(pb.message(), "Seeding timeline...");
|
||||||
pb.finish_and_clear();
|
pb.finish_and_clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── New progress API tests ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_spinner_v2_robot_mode_returns_hidden() {
|
||||||
|
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", true);
|
||||||
|
assert!(pb.is_hidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_spinner_v2_human_mode_sets_properties() {
|
||||||
|
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", false);
|
||||||
|
assert!(pb.prefix().contains("Issues"));
|
||||||
|
assert_eq!(pb.message(), "fetching...");
|
||||||
|
pb.finish_and_clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_progress_robot_mode_returns_hidden() {
|
||||||
|
let pb = nested_progress("Embedding...", 100, true);
|
||||||
|
assert!(pb.is_hidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_progress_human_mode_sets_length() {
|
||||||
|
let pb = nested_progress("Embedding...", 100, false);
|
||||||
|
assert_eq!(pb.length(), Some(100));
|
||||||
|
assert_eq!(pb.message(), "Embedding...");
|
||||||
|
pb.finish_and_clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_elapsed_sub_second() {
|
||||||
|
assert_eq!(format_elapsed(Duration::from_millis(42)), "42ms");
|
||||||
|
assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_elapsed_seconds() {
|
||||||
|
assert_eq!(format_elapsed(Duration::from_millis(1200)), "1.2s");
|
||||||
|
assert_eq!(format_elapsed(Duration::from_millis(5000)), "5.0s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_elapsed_minutes() {
|
||||||
|
assert_eq!(format_elapsed(Duration::from_secs(65)), "1m 5s");
|
||||||
|
assert_eq!(format_elapsed(Duration::from_secs(120)), "2m 0s");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,17 +16,234 @@ pub enum ColorMode {
|
|||||||
Never,
|
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`.
|
/// Global renderer singleton, initialized once in `main.rs`.
|
||||||
static RENDERER: OnceLock<LoreRenderer> = OnceLock::new();
|
static RENDERER: OnceLock<LoreRenderer> = OnceLock::new();
|
||||||
|
|
||||||
pub struct LoreRenderer {
|
pub struct LoreRenderer {
|
||||||
/// Resolved at init time so we don't re-check TTY + NO_COLOR on every call.
|
/// Resolved at init time so we don't re-check TTY + NO_COLOR on every call.
|
||||||
colors: bool,
|
colors: bool,
|
||||||
|
/// Icon tier for the session.
|
||||||
|
glyphs: GlyphMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoreRenderer {
|
impl LoreRenderer {
|
||||||
/// Initialize the global renderer. Call once at startup.
|
/// Initialize the global renderer. Call once at startup.
|
||||||
pub fn init(mode: ColorMode) {
|
pub fn init(mode: ColorMode, glyphs: GlyphMode) {
|
||||||
let colors = match mode {
|
let colors = match mode {
|
||||||
ColorMode::Always => true,
|
ColorMode::Always => true,
|
||||||
ColorMode::Never => false,
|
ColorMode::Never => false,
|
||||||
@@ -35,7 +252,7 @@ impl LoreRenderer {
|
|||||||
&& std::env::var("NO_COLOR").map_or(true, |v| v.is_empty())
|
&& 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.
|
/// Get the global renderer. Panics if `init` hasn't been called.
|
||||||
@@ -49,6 +266,11 @@ impl LoreRenderer {
|
|||||||
pub fn colors_enabled(&self) -> bool {
|
pub fn colors_enabled(&self) -> bool {
|
||||||
self.colors
|
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
|
/// 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
|
// Structure
|
||||||
pub fn section_title() -> Style {
|
pub fn section_title() -> Style {
|
||||||
if colors_on() {
|
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.
|
/// Format a duration in milliseconds as a human-friendly string.
|
||||||
pub fn format_duration_ms(ms: u64) -> String {
|
pub fn format_duration_ms(ms: u64) -> String {
|
||||||
if ms < 1000 {
|
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 ──────────────────────────────────────────────────────────
|
// ─── Table Renderer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Column alignment for the table renderer.
|
/// Column alignment for the table renderer.
|
||||||
@@ -946,6 +1239,121 @@ mod tests {
|
|||||||
assert!(plain.contains("1"), "got: {plain}");
|
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.
|
/// Strip ANSI escape codes (SGR sequences) for content assertions.
|
||||||
fn strip_ansi(s: &str) -> String {
|
fn strip_ansi(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
|
|||||||
Reference in New Issue
Block a user