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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
pub mod autocorrect;
|
pub mod autocorrect;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
|
pub mod render;
|
||||||
pub mod robot;
|
pub mod robot;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
@@ -810,7 +811,8 @@ pub struct EmbedArgs {
|
|||||||
lore timeline i:42 # Shorthand for issue:42
|
lore timeline i:42 # Shorthand for issue:42
|
||||||
lore timeline mr:99 # Direct: MR !99 and related entities
|
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 '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 {
|
pub struct TimelineArgs {
|
||||||
/// Search text or entity reference (issue:N, i:N, mr:N, m:N)
|
/// Search text or entity reference (issue:N, i:N, mr:N, m:N)
|
||||||
pub query: String,
|
pub query: String,
|
||||||
@@ -827,9 +829,9 @@ pub struct TimelineArgs {
|
|||||||
#[arg(long, default_value = "1", help_heading = "Expansion")]
|
#[arg(long, default_value = "1", help_heading = "Expansion")]
|
||||||
pub depth: u32,
|
pub depth: u32,
|
||||||
|
|
||||||
/// Also follow 'mentioned' edges during expansion (high fan-out)
|
/// Skip 'mentioned' edges during expansion (only follow 'closes' and 'related')
|
||||||
#[arg(long = "expand-mentions", help_heading = "Expansion")]
|
#[arg(long = "no-mentions", help_heading = "Expansion")]
|
||||||
pub expand_mentions: bool,
|
pub no_mentions: bool,
|
||||||
|
|
||||||
/// Maximum number of events to display
|
/// Maximum number of events to display
|
||||||
#[arg(
|
#[arg(
|
||||||
|
|||||||
969
src/cli/render.rs
Normal file
969
src/cli/render.rs
Normal file
@@ -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<LoreRenderer> = 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<char> = 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<String> {
|
||||||
|
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<Style>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StyledCell {
|
||||||
|
/// Create a plain (unstyled) cell.
|
||||||
|
pub fn plain(text: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
text: text.into(),
|
||||||
|
style: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a styled cell.
|
||||||
|
pub fn styled(text: impl Into<String>, style: Style) -> Self {
|
||||||
|
Self {
|
||||||
|
text: text.into(),
|
||||||
|
style: Some(style),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The visible width of the cell text (ANSI-unaware character count).
|
||||||
|
fn visible_width(&self) -> usize {
|
||||||
|
// Use char count since our text doesn't contain ANSI before rendering
|
||||||
|
self.text.chars().count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compact table renderer built on lipgloss.
|
||||||
|
///
|
||||||
|
/// Design: no borders, bold headers, thin `─` separator, 2-space column gaps.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Table {
|
||||||
|
headers: Vec<String>,
|
||||||
|
rows: Vec<Vec<StyledCell>>,
|
||||||
|
alignments: Vec<Align>,
|
||||||
|
max_widths: Vec<Option<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Table {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set column headers.
|
||||||
|
pub fn headers(mut self, h: &[&str]) -> Self {
|
||||||
|
self.headers = h.iter().map(|s| (*s).to_string()).collect();
|
||||||
|
// Initialize alignments and max_widths to match column count
|
||||||
|
self.alignments.resize(self.headers.len(), Align::Left);
|
||||||
|
self.max_widths.resize(self.headers.len(), None);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a data row.
|
||||||
|
pub fn add_row(&mut self, cells: Vec<StyledCell>) {
|
||||||
|
self.rows.push(cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set alignment for a column.
|
||||||
|
pub fn align(mut self, col: usize, a: Align) -> Self {
|
||||||
|
if col < self.alignments.len() {
|
||||||
|
self.alignments[col] = a;
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set maximum width for a column (content will be truncated).
|
||||||
|
pub fn max_width(mut self, col: usize, w: usize) -> Self {
|
||||||
|
if col < self.max_widths.len() {
|
||||||
|
self.max_widths[col] = Some(w);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the table to a string.
|
||||||
|
pub fn render(&self) -> String {
|
||||||
|
if self.headers.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let col_count = self.headers.len();
|
||||||
|
let gap = " "; // 2-space gap between columns
|
||||||
|
|
||||||
|
// Compute column widths from content
|
||||||
|
let mut widths: Vec<usize> = self.headers.iter().map(|h| h.chars().count()).collect();
|
||||||
|
|
||||||
|
for row in &self.rows {
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i < col_count {
|
||||||
|
widths[i] = widths[i].max(cell.visible_width());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply max_width constraints
|
||||||
|
for (i, max_w) in self.max_widths.iter().enumerate() {
|
||||||
|
if let Some(max) = max_w
|
||||||
|
&& i < widths.len()
|
||||||
|
{
|
||||||
|
widths[i] = widths[i].min(*max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
|
||||||
|
// Header row (bold)
|
||||||
|
let header_parts: Vec<String> = self
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, h)| {
|
||||||
|
let w = widths.get(i).copied().unwrap_or(0);
|
||||||
|
let text = truncate(h, w);
|
||||||
|
pad_cell(
|
||||||
|
&text,
|
||||||
|
w,
|
||||||
|
self.alignments.get(i).copied().unwrap_or(Align::Left),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.push_str(&Theme::header().render(&header_parts.join(gap)));
|
||||||
|
out.push('\n');
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
let total_width: usize =
|
||||||
|
widths.iter().sum::<usize>() + gap.len() * col_count.saturating_sub(1);
|
||||||
|
out.push_str(&Theme::dim().render(&"\u{2500}".repeat(total_width)));
|
||||||
|
out.push('\n');
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
for row in &self.rows {
|
||||||
|
let mut parts: Vec<String> = Vec::with_capacity(col_count);
|
||||||
|
for i in 0..col_count {
|
||||||
|
let w = widths.get(i).copied().unwrap_or(0);
|
||||||
|
let align = self.alignments.get(i).copied().unwrap_or(Align::Left);
|
||||||
|
|
||||||
|
if let Some(cell) = row.get(i) {
|
||||||
|
// Truncate the raw text first, then style it
|
||||||
|
let truncated = truncate(&cell.text, w);
|
||||||
|
let styled = match &cell.style {
|
||||||
|
Some(s) => s.render(&truncated),
|
||||||
|
None => truncated.clone(),
|
||||||
|
};
|
||||||
|
// Pad based on visible (unstyled) width
|
||||||
|
let visible_w = truncated.chars().count();
|
||||||
|
let padding = w.saturating_sub(visible_w);
|
||||||
|
match align {
|
||||||
|
Align::Left => {
|
||||||
|
parts.push(format!("{}{}", styled, " ".repeat(padding)));
|
||||||
|
}
|
||||||
|
Align::Right => {
|
||||||
|
parts.push(format!("{}{}", " ".repeat(padding), styled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Missing cell - empty padding
|
||||||
|
parts.push(" ".repeat(w));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(&parts.join(gap));
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pad a plain string to a target width with the given alignment.
|
||||||
|
fn pad_cell(text: &str, width: usize, align: Align) -> String {
|
||||||
|
let visible = text.chars().count();
|
||||||
|
let padding = width.saturating_sub(visible);
|
||||||
|
match align {
|
||||||
|
Align::Left => format!("{}{}", text, " ".repeat(padding)),
|
||||||
|
Align::Right => format!("{}{}", " ".repeat(padding), text),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── format_number ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_number_small_values() {
|
||||||
|
assert_eq!(format_number(0), "0");
|
||||||
|
assert_eq!(format_number(1), "1");
|
||||||
|
assert_eq!(format_number(100), "100");
|
||||||
|
assert_eq!(format_number(999), "999");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_number_thousands_separators() {
|
||||||
|
assert_eq!(format_number(1000), "1,000");
|
||||||
|
assert_eq!(format_number(12345), "12,345");
|
||||||
|
assert_eq!(format_number(1_234_567), "1,234,567");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_number_negative() {
|
||||||
|
assert_eq!(format_number(-1), "-1");
|
||||||
|
assert_eq!(format_number(-1000), "-1,000");
|
||||||
|
assert_eq!(format_number(-1_234_567), "-1,234,567");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── format_date ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_date_extracts_date_part() {
|
||||||
|
// 2024-01-15T00:00:00Z in ms
|
||||||
|
let ms = 1_705_276_800_000;
|
||||||
|
let date = format_date(ms);
|
||||||
|
assert!(date.starts_with("2024-01-15"), "got: {date}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_date_epoch_zero() {
|
||||||
|
assert_eq!(format_date(0), "1970-01-01");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── format_datetime ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_datetime_basic() {
|
||||||
|
let ms = 1_705_312_800_000; // 2024-01-15T10:00:00Z
|
||||||
|
let dt = format_datetime(ms);
|
||||||
|
assert!(dt.starts_with("2024-01-15"), "got: {dt}");
|
||||||
|
assert!(dt.contains("10:00"), "got: {dt}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── truncate ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_within_limit() {
|
||||||
|
assert_eq!(truncate("hello", 10), "hello");
|
||||||
|
assert_eq!(truncate("hello", 5), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_over_limit() {
|
||||||
|
assert_eq!(truncate("hello world", 8), "hello...");
|
||||||
|
assert_eq!(truncate("abcdefghij", 7), "abcd...");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_very_short_max() {
|
||||||
|
assert_eq!(truncate("hello", 3), "hel");
|
||||||
|
assert_eq!(truncate("hello", 1), "h");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_unicode() {
|
||||||
|
// Multi-byte chars should not panic
|
||||||
|
let s = "caf\u{e9} latt\u{e9}";
|
||||||
|
let result = truncate(s, 6);
|
||||||
|
assert!(result.chars().count() <= 6, "got: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── wrap_indent ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrap_indent_single_line() {
|
||||||
|
assert_eq!(wrap_indent("hello world", 80, " "), "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrap_indent_wraps_long_text() {
|
||||||
|
let result = wrap_indent("one two three four five", 10, " ");
|
||||||
|
assert!(result.contains('\n'), "expected wrapping, got: {result}");
|
||||||
|
// Continuation lines should have indent
|
||||||
|
let lines: Vec<&str> = result.lines().collect();
|
||||||
|
assert!(lines.len() >= 2);
|
||||||
|
assert!(
|
||||||
|
lines[1].starts_with(" "),
|
||||||
|
"expected indent on line 2: {:?}",
|
||||||
|
lines[1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrap_indent_empty() {
|
||||||
|
assert_eq!(wrap_indent("", 80, " "), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── wrap_lines ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrap_lines_single_line() {
|
||||||
|
assert_eq!(wrap_lines("hello world", 80), vec!["hello world"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrap_lines_wraps() {
|
||||||
|
let lines = wrap_lines("one two three four five", 10);
|
||||||
|
assert!(lines.len() >= 2, "expected wrapping, got: {lines:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrap_lines_empty() {
|
||||||
|
let lines = wrap_lines("", 80);
|
||||||
|
assert!(lines.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── section_divider ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn section_divider_contains_title() {
|
||||||
|
let result = section_divider("Documents");
|
||||||
|
// Strip ANSI to check content
|
||||||
|
let plain = strip_ansi(&result);
|
||||||
|
assert!(plain.contains("Documents"), "got: {plain}");
|
||||||
|
assert!(
|
||||||
|
plain.contains("\u{2500}"),
|
||||||
|
"expected box-drawing chars in: {plain}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── style_with_hex ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn style_with_hex_none_returns_plain() {
|
||||||
|
assert_eq!(style_with_hex("hello", None), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn style_with_hex_invalid_returns_plain() {
|
||||||
|
assert_eq!(style_with_hex("hello", Some("")), "hello");
|
||||||
|
assert_eq!(style_with_hex("hello", Some("x")), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn style_with_hex_valid_contains_text() {
|
||||||
|
let result = style_with_hex("hello", Some("#ff0000"));
|
||||||
|
assert!(
|
||||||
|
result.contains("hello"),
|
||||||
|
"styled text should contain original: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── format_labels ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_labels_empty() {
|
||||||
|
assert_eq!(format_labels(&[], 2), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_labels_single() {
|
||||||
|
assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_labels_multiple() {
|
||||||
|
let labels = vec!["bug".to_string(), "urgent".to_string()];
|
||||||
|
assert_eq!(format_labels(&labels, 2), "[bug, urgent]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_labels_overflow() {
|
||||||
|
let labels = vec![
|
||||||
|
"bug".to_string(),
|
||||||
|
"urgent".to_string(),
|
||||||
|
"wip".to_string(),
|
||||||
|
"blocked".to_string(),
|
||||||
|
];
|
||||||
|
assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── format_duration_ms ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_duration_ms_sub_second() {
|
||||||
|
assert_eq!(format_duration_ms(42), "42ms");
|
||||||
|
assert_eq!(format_duration_ms(999), "999ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_duration_ms_seconds() {
|
||||||
|
assert_eq!(format_duration_ms(1000), "1.0s");
|
||||||
|
assert_eq!(format_duration_ms(5200), "5.2s");
|
||||||
|
assert_eq!(format_duration_ms(12500), "12.5s");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── format_relative_time ──
|
||||||
|
// Note: these are harder to test deterministically since they depend on now_ms().
|
||||||
|
// We test the boundary behavior by checking that the function doesn't panic
|
||||||
|
// and returns reasonable-looking strings.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_time_future() {
|
||||||
|
let future = now_ms() + 60_000;
|
||||||
|
assert_eq!(format_relative_time(future), "in the future");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_time_just_now() {
|
||||||
|
let recent = now_ms() - 5_000;
|
||||||
|
assert_eq!(format_relative_time(recent), "just now");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_time_minutes() {
|
||||||
|
let mins_ago = now_ms() - 300_000; // 5 minutes
|
||||||
|
let result = format_relative_time(mins_ago);
|
||||||
|
assert!(result.contains("min ago"), "got: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_time_hours() {
|
||||||
|
let hours_ago = now_ms() - 7_200_000; // 2 hours
|
||||||
|
let result = format_relative_time(hours_ago);
|
||||||
|
assert!(result.contains("hours ago"), "got: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_time_days() {
|
||||||
|
let days_ago = now_ms() - 172_800_000; // 2 days
|
||||||
|
let result = format_relative_time(days_ago);
|
||||||
|
assert!(result.contains("days ago"), "got: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──
|
||||||
|
|
||||||
|
// ── Table ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_empty_headers_returns_empty() {
|
||||||
|
let table = Table::new();
|
||||||
|
assert_eq!(table.render(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_headers_only() {
|
||||||
|
let table = Table::new().headers(&["ID", "Name", "Status"]);
|
||||||
|
let result = table.render();
|
||||||
|
let plain = strip_ansi(&result);
|
||||||
|
assert!(plain.contains("ID"), "got: {plain}");
|
||||||
|
assert!(plain.contains("Name"), "got: {plain}");
|
||||||
|
assert!(plain.contains("Status"), "got: {plain}");
|
||||||
|
assert!(plain.contains("\u{2500}"), "expected separator in: {plain}");
|
||||||
|
// Should have header line + separator line
|
||||||
|
let lines: Vec<&str> = result.lines().collect();
|
||||||
|
assert_eq!(
|
||||||
|
lines.len(),
|
||||||
|
2,
|
||||||
|
"expected 2 lines (header + separator), got {}",
|
||||||
|
lines.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_single_row() {
|
||||||
|
let mut table = Table::new().headers(&["ID", "Title", "State"]);
|
||||||
|
table.add_row(vec![
|
||||||
|
StyledCell::plain("1"),
|
||||||
|
StyledCell::plain("Fix bug"),
|
||||||
|
StyledCell::plain("open"),
|
||||||
|
]);
|
||||||
|
let result = table.render();
|
||||||
|
let plain = strip_ansi(&result);
|
||||||
|
// 3 lines: header, separator, data
|
||||||
|
let lines: Vec<&str> = plain.lines().collect();
|
||||||
|
assert_eq!(
|
||||||
|
lines.len(),
|
||||||
|
3,
|
||||||
|
"expected 3 lines, got {}: {plain}",
|
||||||
|
lines.len()
|
||||||
|
);
|
||||||
|
assert!(lines[2].contains("Fix bug"), "data row missing: {plain}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_multi_row() {
|
||||||
|
let mut table = Table::new().headers(&["ID", "Name"]);
|
||||||
|
table.add_row(vec![StyledCell::plain("1"), StyledCell::plain("Alice")]);
|
||||||
|
table.add_row(vec![StyledCell::plain("2"), StyledCell::plain("Bob")]);
|
||||||
|
table.add_row(vec![StyledCell::plain("3"), StyledCell::plain("Charlie")]);
|
||||||
|
let result = table.render();
|
||||||
|
let plain = strip_ansi(&result);
|
||||||
|
let lines: Vec<&str> = plain.lines().collect();
|
||||||
|
assert_eq!(
|
||||||
|
lines.len(),
|
||||||
|
5,
|
||||||
|
"expected 5 lines (1 header + 1 sep + 3 data): {plain}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_right_align() {
|
||||||
|
let mut table = Table::new()
|
||||||
|
.headers(&["Name", "Score"])
|
||||||
|
.align(1, Align::Right);
|
||||||
|
table.add_row(vec![StyledCell::plain("Alice"), StyledCell::plain("95")]);
|
||||||
|
table.add_row(vec![StyledCell::plain("Bob"), StyledCell::plain("8")]);
|
||||||
|
let result = table.render();
|
||||||
|
let plain = strip_ansi(&result);
|
||||||
|
// The "8" should be right-aligned (padded with spaces on the left)
|
||||||
|
let data_lines: Vec<&str> = plain.lines().skip(2).collect();
|
||||||
|
let score_line = data_lines[1]; // Bob's line
|
||||||
|
// Score column should have leading space before "8"
|
||||||
|
assert!(
|
||||||
|
score_line.contains(" 8") || score_line.ends_with(" 8"),
|
||||||
|
"expected right-aligned '8': {score_line}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_max_width_truncates() {
|
||||||
|
let mut table = Table::new().headers(&["ID", "Title"]).max_width(1, 10);
|
||||||
|
table.add_row(vec![
|
||||||
|
StyledCell::plain("1"),
|
||||||
|
StyledCell::plain("This is a very long title that should be truncated"),
|
||||||
|
]);
|
||||||
|
let result = table.render();
|
||||||
|
let plain = strip_ansi(&result);
|
||||||
|
// The title should be truncated to 10 chars (7 + "...")
|
||||||
|
assert!(plain.contains("..."), "expected truncation in: {plain}");
|
||||||
|
// The full title should NOT appear
|
||||||
|
assert!(
|
||||||
|
!plain.contains("should be truncated"),
|
||||||
|
"title not truncated: {plain}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_styled_cells_dont_break_alignment() {
|
||||||
|
let mut table = Table::new().headers(&["Name", "Status"]);
|
||||||
|
table.add_row(vec![
|
||||||
|
StyledCell::plain("Alice"),
|
||||||
|
StyledCell::styled("open", Theme::success()),
|
||||||
|
]);
|
||||||
|
table.add_row(vec![
|
||||||
|
StyledCell::plain("Bob"),
|
||||||
|
StyledCell::styled("closed", Theme::dim()),
|
||||||
|
]);
|
||||||
|
let result = table.render();
|
||||||
|
let plain = strip_ansi(&result);
|
||||||
|
let lines: Vec<&str> = plain.lines().collect();
|
||||||
|
assert_eq!(lines.len(), 4); // header + sep + 2 rows
|
||||||
|
// Both data rows should exist
|
||||||
|
assert!(plain.contains("Alice"), "missing Alice: {plain}");
|
||||||
|
assert!(plain.contains("Bob"), "missing Bob: {plain}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_missing_cells_padded() {
|
||||||
|
let mut table = Table::new().headers(&["A", "B", "C"]);
|
||||||
|
// Row with fewer cells than columns
|
||||||
|
table.add_row(vec![StyledCell::plain("1")]);
|
||||||
|
let result = table.render();
|
||||||
|
// Should not panic
|
||||||
|
let plain = strip_ansi(&result);
|
||||||
|
assert!(plain.contains("1"), "got: {plain}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip ANSI escape codes (SGR sequences) for content assertions.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user