helios-remote/crates/client/src/display.rs

149 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// 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);
}