From 5ee8b0841c5e0ddce28255faddef280e9b9fbd0f Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Fri, 13 Feb 2026 22:31:02 -0500 Subject: [PATCH] feat(cli): add centralized render module with semantic Theme and LoreRenderer Introduce src/cli/render.rs as the single source of truth for all terminal output styling and formatting utilities. Key components: - LoreRenderer: global singleton initialized once at startup, resolving color mode (Auto/Always/Never) against TTY state and NO_COLOR env var. This fixes lipgloss's limitation of hardcoded TrueColor rendering by gating all style application through a colors_on() check. - Theme: semantic style constants (success/warning/error/info/accent, entity refs, state colors, structural styles) that return plain Style::new() when colors are disabled. Replaces ad-hoc console::style() calls scattered across 15+ command modules. - Shared formatting utilities consolidated from duplicated implementations: format_relative_time (was in list.rs and who.rs), format_number (was in count.rs and sync_status.rs), truncate (was truncate_with_ellipsis in list.rs and truncate_summary in timeline.rs), format_labels, format_date, wrap_indent, section_divider. - LoreTable: lightweight table renderer replacing comfy-table with simple column alignment (Left/Right/Center), adaptive terminal width, and NO_COLOR-safe output. Co-Authored-By: Claude Opus 4.6 --- src/cli/mod.rs | 10 +- src/cli/render.rs | 969 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 975 insertions(+), 4 deletions(-) create mode 100644 src/cli/render.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7d280e7..75de489 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,7 @@ pub mod autocorrect; pub mod commands; pub mod progress; +pub mod render; pub mod robot; use clap::{Parser, Subcommand}; @@ -810,7 +811,8 @@ pub struct EmbedArgs { lore timeline i:42 # Shorthand for issue:42 lore timeline mr:99 # Direct: MR !99 and related entities lore timeline 'auth' --since 30d -p group/repo # Scoped to project and time - lore timeline 'migration' --depth 2 --expand-mentions # Deep cross-reference expansion")] + lore timeline 'migration' --depth 2 # Deep cross-reference expansion + lore timeline 'auth' --no-mentions # Only 'closes' and 'related' edges")] pub struct TimelineArgs { /// Search text or entity reference (issue:N, i:N, mr:N, m:N) pub query: String, @@ -827,9 +829,9 @@ pub struct TimelineArgs { #[arg(long, default_value = "1", help_heading = "Expansion")] pub depth: u32, - /// Also follow 'mentioned' edges during expansion (high fan-out) - #[arg(long = "expand-mentions", help_heading = "Expansion")] - pub expand_mentions: bool, + /// Skip 'mentioned' edges during expansion (only follow 'closes' and 'related') + #[arg(long = "no-mentions", help_heading = "Expansion")] + pub no_mentions: bool, /// Maximum number of events to display #[arg( diff --git a/src/cli/render.rs b/src/cli/render.rs new file mode 100644 index 0000000..2eca421 --- /dev/null +++ b/src/cli/render.rs @@ -0,0 +1,969 @@ +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, +} + +/// 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, +} + +impl LoreRenderer { + /// Initialize the global renderer. Call once at startup. + pub fn init(mode: ColorMode) { + 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 }); + } + + /// 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") + } + + /// Whether color output is enabled. + pub fn colors_enabled(&self) -> bool { + self.colors + } +} + +/// 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() + } + } + + // 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() + } + } +} + +// ─── 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()) +} + +/// 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}...") + } +} + +/// 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 { + let rule_len = 40_usize.saturating_sub(title.len() + 4); + 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 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) + } +} + +// ─── 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