149 lines
5.8 KiB
Rust
149 lines
5.8 KiB
Rust
/// Terminal display helpers — table-style command rows with live spinner.
|
||
///
|
||
/// Layout (no borders, aligned columns):
|
||
/// {2 spaces}{action_emoji (2 display cols)}{2 spaces}{name:<NAME_W}{2 spaces}{payload:<payload_w}{2 spaces}{status_emoji}{2 spaces}{result}
|
||
use std::io::Write;
|
||
|
||
use colored::Colorize;
|
||
use unicode_width::UnicodeWidthStr;
|
||
|
||
/// Pad an emoji/symbol string to exactly 2 terminal display columns.
|
||
/// Some symbols (ℹ, ☀, ⚠, # …) render as 1-wide; we add a space so columns align.
|
||
fn emoji_cell(s: &str) -> String {
|
||
let w = UnicodeWidthStr::width(s);
|
||
if w < 2 {
|
||
format!("{s} ")
|
||
} else {
|
||
s.to_string()
|
||
}
|
||
}
|
||
|
||
// Fixed column widths (in terminal display columns, ASCII-only content assumed for name/payload/result)
|
||
const NAME_W: usize = 14;
|
||
const MIN_PAYLOAD_W: usize = 10;
|
||
const MIN_RESULT_W: usize = 10;
|
||
// Overhead: 2 (indent) + 2 (action emoji) + 2 (gap) + NAME_W + 2 (gap) + 2 (gap) + 2 (status emoji) + 2 (gap)
|
||
const FIXED_OVERHEAD: usize = 2 + 2 + 2 + NAME_W + 2 + 2 + 2 + 2;
|
||
|
||
pub fn terminal_width() -> usize {
|
||
terminal_size::terminal_size()
|
||
.map(|(w, _)| w.0 as usize)
|
||
.unwrap_or(120)
|
||
.max(60)
|
||
}
|
||
|
||
/// Split remaining space between payload and result columns.
|
||
/// payload gets ~55%, result gets the rest; both have minimums.
|
||
fn col_widths() -> (usize, usize) {
|
||
let tw = terminal_width();
|
||
let remaining = tw.saturating_sub(FIXED_OVERHEAD).max(MIN_PAYLOAD_W + MIN_RESULT_W);
|
||
let payload_w = (remaining * 55 / 100).max(MIN_PAYLOAD_W);
|
||
let result_w = remaining.saturating_sub(payload_w).max(MIN_RESULT_W);
|
||
(payload_w, result_w)
|
||
}
|
||
|
||
/// Truncate a string to at most `max` Unicode chars, appending `…` if cut.
|
||
/// Must be called on PLAIN (uncolored) text — ANSI codes confuse char counting.
|
||
pub fn trunc(s: &str, max: usize) -> String {
|
||
if max == 0 {
|
||
return String::new();
|
||
}
|
||
let mut chars = s.chars();
|
||
let truncated: String = chars.by_ref().take(max).collect();
|
||
if chars.next().is_some() {
|
||
let mut t: String = truncated.chars().take(max.saturating_sub(1)).collect();
|
||
t.push('…');
|
||
t
|
||
} else {
|
||
truncated
|
||
}
|
||
}
|
||
|
||
/// Format one table row into a String (no trailing newline).
|
||
/// Truncation happens on plain `result` BEFORE colorizing, so ANSI reset codes are never cut off.
|
||
fn format_row(action: &str, name: &str, payload: &str, status: &str, result: &str, err: bool) -> String {
|
||
let (payload_w, result_w) = col_widths();
|
||
let p = trunc(payload, payload_w);
|
||
// Truncate BEFORE colorizing — avoids dangling ANSI escape sequences
|
||
let r_plain = trunc(result, result_w);
|
||
let r = if err { r_plain.red().to_string() } else { r_plain };
|
||
format!(
|
||
" {} {:<name_w$} {:<payload_w$} {} {}",
|
||
emoji_cell(action),
|
||
name,
|
||
p,
|
||
emoji_cell(status),
|
||
r,
|
||
name_w = NAME_W,
|
||
payload_w = payload_w,
|
||
)
|
||
}
|
||
|
||
/// Print the "running" row (🔄 spinner).
|
||
pub fn cmd_start(action: &str, name: &str, payload: &str) {
|
||
let line = format_row(action, name, payload, "🔄", "", false);
|
||
println!("{}", line);
|
||
let _ = std::io::stdout().flush();
|
||
}
|
||
|
||
/// Overwrite the previous line (cursor up + clear) with the completed row.
|
||
pub fn cmd_done(action: &str, name: &str, payload: &str, success: bool, result: &str) {
|
||
let status = if success { "✅" } else { "❌" };
|
||
let line = format_row(action, name, payload, status, result, !success);
|
||
// \x1b[1A = cursor up 1, \r = go to col 0, \x1b[2K = clear line
|
||
print!("\x1b[1A\r\x1b[2K{}\n", line);
|
||
let _ = std::io::stdout().flush();
|
||
crate::logger::write_line(if success { "OK" } else { "ERROR" }, &format!("{name} {payload} → {result}"));
|
||
}
|
||
|
||
/// Info line for the startup header — uses same column alignment as table rows.
|
||
pub fn info_line(emoji: &str, key: &str, value: &str) {
|
||
// Match table layout: 2 spaces + 2-wide emoji + 2 spaces + name (NAME_W) + 2 spaces + value
|
||
println!(" {} {:<name_w$} {}", emoji_cell(emoji), key, value, name_w = NAME_W);
|
||
}
|
||
|
||
/// Print the prompt "awaiting input" row + the 🎤 answer input prefix.
|
||
/// The caller should read stdin immediately after this returns.
|
||
pub fn prompt_waiting(message: &str) {
|
||
let (payload_w, _result_w) = col_widths();
|
||
let p = trunc(message, payload_w);
|
||
// 🌀 row: show message + 🔄 + "awaiting input"
|
||
println!(
|
||
" {} {:<name_w$} {:<payload_w$} {} awaiting input",
|
||
emoji_cell("🌀"), "prompt", p, emoji_cell("🔄"),
|
||
name_w = NAME_W, payload_w = payload_w,
|
||
);
|
||
// 🎤 answer input prefix — no newline, user types here
|
||
print!(" {} {:<name_w$} › ", emoji_cell("🎤"), "answer", name_w = NAME_W);
|
||
let _ = std::io::stdout().flush();
|
||
}
|
||
|
||
/// Overwrite the 🌀 and 🎤 lines with the final state after input was received.
|
||
/// Must be called after the user pressed Enter (cursor is on the line after 🎤).
|
||
pub fn prompt_done(message: &str, answer: &str) {
|
||
let (payload_w, result_w) = col_widths();
|
||
let p = trunc(message, payload_w);
|
||
// Go up 2 lines (🎤 line + 🌀 line), rewrite both
|
||
print!("\x1b[2A\r\x1b[2K");
|
||
// Rewrite 🌀 row as done
|
||
println!(
|
||
" {} {:<name_w$} {:<payload_w$} {} done",
|
||
emoji_cell("🌀"), "prompt", p, emoji_cell("✅"),
|
||
name_w = NAME_W, payload_w = payload_w,
|
||
);
|
||
// Clear 🎤 line + rewrite with purple answer
|
||
print!("\r\x1b[2K");
|
||
let a = trunc(answer, payload_w + result_w + 4); // answer spans both columns
|
||
println!(
|
||
" {} {:<name_w$} {}",
|
||
emoji_cell("🎤"), "answer", a.purple(),
|
||
name_w = NAME_W,
|
||
);
|
||
let _ = std::io::stdout().flush();
|
||
crate::logger::write_line("OK", &format!("prompt → {answer}"));
|
||
}
|
||
|
||
pub fn err(emoji: &str, msg: &str) {
|
||
println!(" {} {}", emoji_cell(emoji), msg.red());
|
||
crate::logger::write_line("ERROR", msg);
|
||
}
|