//! Pure text/history helpers for the client TUI. //! //! Nothing here touches the terminal, so it is unit-tested from `tests/` //! without a PTY. struct CommandHelp { name: &'static str, usage: &'static str, description: &'static str, } const COMMANDS: &[CommandHelp] = &[ CommandHelp { name: "connect", usage: "connect :", description: "connect to the server", }, CommandHelp { name: "send", usage: "send ", description: "send a message to the server", }, CommandHelp { name: "disconnect", usage: "disconnect", description: "disconnect from the server", }, CommandHelp { name: "ping", usage: "ping", description: "ping the server", }, CommandHelp { name: "login", usage: "login", description: "login to the server", }, CommandHelp { name: "register", usage: "register", description: "register a new user", }, CommandHelp { name: "setcred", usage: "setcred", description: "set credentials for a host", }, CommandHelp { name: "logout", usage: "logout", description: "logout from the server", }, CommandHelp { name: "info", usage: "info", description: "show account and connection info", }, CommandHelp { name: "play", usage: "play [bet]", description: "start a game (e.g. play slots 50)", }, CommandHelp { name: "games", usage: "games", description: "list available games", }, CommandHelp { name: "help", usage: "help", description: "show this message", }, CommandHelp { name: "forget-host", usage: "forget-host [ip:port]", description: "clear saved trust fingerprint and credentials", }, CommandHelp { name: "hosts", usage: "hosts", description: "list remembered hosts", }, CommandHelp { name: "quit", usage: "quit", description: "quit the program", }, ]; pub fn command_names() -> Vec<&'static str> { COMMANDS.iter().map(|command| command.name).collect() } pub fn help_lines() -> Vec { let mut lines = vec!["Available commands:".to_string()]; for command in COMMANDS { lines.push(format!( " {:<28} - {}", command.usage, command.description )); } lines } /// Wrap a completion list to `width` columns, one space between names. pub fn completion_lines(commands: &[&str], width: usize) -> Vec { let mut lines = vec!["Possible completions:".to_string()]; let mut current = String::new(); for command in commands { let needed = if current.is_empty() { command.len() } else { current.len() + 1 + command.len() }; if !current.is_empty() && needed > width { lines.push(current.clone()); current.clear(); } if !current.is_empty() { current.push(' '); } current.push_str(command); } if !current.is_empty() { lines.push(current); } lines } /// Add a command to the history, skipping blanks and consecutive duplicates. pub fn record_history(history: &mut Vec, line: &str) { let line = line.trim(); if line.is_empty() { return; } if history.last().is_some_and(|last| last == line) { return; } history.push(line.to_string()); } /// Parse the optional bet argument of `play [bet]`. pub fn parse_bet_arg(raw: &str) -> Result { let bet = raw .trim() .parse::() .map_err(|_| format!("Invalid bet: {raw}"))?; if bet < 10 { return Err("Bet must be at least 10 PLN".to_string()); } Ok(bet) } /// Number of terminal columns used by `text`. /// /// This is a deliberately small approximation of `unicode-width`: it handles /// ASCII, Latin diacritics, combining marks, CJK, and the common emoji ranges /// without adding a dependency. pub fn display_width(text: &str) -> usize { text.chars().map(char_width).sum() } fn char_width(c: char) -> usize { let cp = c as u32; if cp < 0x20 || cp == 0x7F { return 0; } if is_zero_width(cp) || is_combining(cp) { return 0; } if is_wide(cp) { 2 } else { 1 } } fn is_zero_width(cp: u32) -> bool { matches!( cp, 0x200B..=0x200F | 0xFE00..=0xFE0F | 0xFEFF | 0x1F3FB..=0x1F3FF ) } fn is_combining(cp: u32) -> bool { matches!( cp, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F ) } fn is_wide(cp: u32) -> bool { matches!( cp, 0x1100..=0x115F | 0x2329..=0x232A | 0x2E80..=0xA4CF | 0xAC00..=0xD7A3 | 0xF900..=0xFAFF | 0xFE10..=0xFE19 | 0xFE30..=0xFE6F | 0xFF00..=0xFF60 | 0xFFE0..=0xFFE6 | 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x20000..=0x3FFFD ) } /// The three lines printed on first contact with a host. pub fn first_contact_lines(addr: &str, fingerprint: &str) -> Vec { vec![ format!("First contact with {addr}: trusting a new certificate."), format!("Fingerprint: {fingerprint}"), "Future connections must present the same certificate key.".to_string(), ] } /// Format remembered hosts so a 64-character fingerprint does not wrap on an /// 80-column terminal. pub fn host_lines(hosts: &[(String, Option)]) -> Vec { if hosts.is_empty() { return vec!["No remembered hosts.".to_string()]; } let mut lines = vec!["Remembered hosts:".to_string()]; for (host, fingerprint) in hosts { lines.push(format!(" {host}")); match fingerprint { Some(fingerprint) => lines.push(format!(" fingerprint: {fingerprint}")), None => lines.push(" (no fingerprint)".to_string()), } } lines } /// Lines to move the cursor up to reach the first line of a region that ends /// on the current line. `render()` has no trailing newline, so a 7-line board /// needs `MoveUp(6)`. pub fn lines_to_move_up(previous_lines: usize) -> u16 { previous_lines .saturating_sub(1) .min(u16::MAX as usize) as u16 } /// Column (0-based) to move to after the given prompt and input text. pub fn input_cursor_column(prompt_len: usize, input_len: usize) -> u16 { prompt_len .saturating_add(input_len) .min(u16::MAX as usize) as u16 } /// Number of terminal rows a block of logical lines occupies after wrapping. /// Empty lines still occupy one row. pub fn visual_line_count(lines: &[String], width: usize) -> usize { let width = width.max(1); lines .iter() .map(|line| { let columns = display_width(line); if columns == 0 { 1 } else { columns.div_ceil(width) } }) .sum() } /// Format a ping round-trip time with one decimal place. pub fn format_pong(elapsed: std::time::Duration) -> String { format!("Pong ({:.1}ms)", elapsed.as_secs_f64() * 1000.0) }