refactor: table-style display with live spinner (🔄→✅/❌)
- Remove \t-based alignment (fixes emoji spacing inconsistencies)
- New display.rs module: table rows with dynamic terminal-width columns
- Columns: action_emoji | name (14ch) | payload (55%) | status_emoji | result (45%)
- cmd_start() prints 🔄 spinner, cmd_done() overwrites line in-place via ANSI cursor-up
- Payload and result truncated to column width with ellipsis
- Consistent 2-space gaps after every emoji (no tab stops)
- Add terminal_size crate for dynamic width (fallback: 120)
This commit is contained in:
parent
7c0341a5f3
commit
959a00ff8a
3 changed files with 212 additions and 154 deletions
105
crates/client/src/display.rs
Normal file
105
crates/client/src/display.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/// Terminal display helpers — table-style command rows with live spinner.
|
||||
///
|
||||
/// Layout (no borders, aligned columns):
|
||||
/// {2 spaces}{action_emoji}{2 spaces}{name:<NAME_W}{2 spaces}{payload:<payload_w}{2 spaces}{status_emoji}{2 spaces}{result}
|
||||
use std::io::Write;
|
||||
|
||||
use colored::Colorize;
|
||||
|
||||
// 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.
|
||||
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() {
|
||||
// Replace last char with ellipsis
|
||||
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).
|
||||
fn format_row(action: &str, name: &str, payload: &str, status: &str, result: &str) -> String {
|
||||
let (payload_w, result_w) = col_widths();
|
||||
let p = trunc(payload, payload_w);
|
||||
let r = trunc(result, result_w);
|
||||
format!(
|
||||
" {} {:<name_w$} {:<payload_w$} {} {}",
|
||||
action,
|
||||
name,
|
||||
p,
|
||||
status,
|
||||
r,
|
||||
name_w = NAME_W,
|
||||
payload_w = payload_w,
|
||||
)
|
||||
}
|
||||
|
||||
/// Print the "running" row (🔄 spinner) without extra newline logic.
|
||||
/// Returns the row string so callers can reuse it for the done line.
|
||||
pub fn cmd_start(action: &str, name: &str, payload: &str) {
|
||||
let line = format_row(action, name, payload, "🔄", "");
|
||||
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 result_colored = if success {
|
||||
result.to_string()
|
||||
} else {
|
||||
result.red().to_string()
|
||||
};
|
||||
let line = format_row(action, name, payload, status, &result_colored);
|
||||
// \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}"));
|
||||
}
|
||||
|
||||
/// Plain status line (banner / connection info), not in table format.
|
||||
/// Uses 2 leading spaces + emoji + 2 spaces + message (no tab).
|
||||
pub fn status(emoji: &str, msg: &str) {
|
||||
println!(" {} {}", emoji, msg);
|
||||
}
|
||||
|
||||
/// Same as status() but also logs to file.
|
||||
pub fn ok(emoji: &str, msg: &str) {
|
||||
println!(" {} {}", emoji, msg);
|
||||
crate::logger::write_line("OK", msg);
|
||||
}
|
||||
|
||||
pub fn err(emoji: &str, msg: &str) {
|
||||
println!(" {} {}", emoji, msg.red());
|
||||
crate::logger::write_line("ERROR", msg);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue