//! 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 the server's certificate key", }, 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 trust for one address (credentials stay while the key is in use)", }, 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 Kr".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 { let mut width = 0; let mut chars = text.chars().peekable(); while let Some(character) = chars.next() { if character == '\x1b' { if chars.peek() == Some(&'[') { chars.next(); // Skip CSI parameters until the final byte (@ through ~). for character in chars.by_ref() { if ('@'..='~').contains(&character) { break; } } } else { // Two-character escape sequence. chars.next(); } continue; } width += char_width(character); } width } 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. /// Smallest terminal every game fits in. Roulette's felt is 12 lines and the /// controls are ~72 columns, so anything smaller would wrap or hide the board. pub const MIN_COLUMNS: u16 = 80; pub const MIN_ROWS: u16 = 24; /// Refuse to start when the terminal cannot show every game. pub fn terminal_too_small(columns: u16, rows: u16) -> Option { if columns >= MIN_COLUMNS && rows >= MIN_ROWS { return None; } Some(format!( "zagan.bet needs a terminal of at least {MIN_COLUMNS}x{MIN_ROWS} cells, \ but this one is {columns}x{rows}.\n\ Resize it and start again." )) } /// One-line warning for a terminal that shrank while the client is running. pub fn terminal_size_warning(columns: u16, rows: u16) -> Option { if columns >= MIN_COLUMNS && rows >= MIN_ROWS { return None; } Some(format!( "WARNING: terminal is {columns}x{rows}; zagan.bet needs at least {MIN_COLUMNS}x{MIN_ROWS}. Resize to fit." )) } /// Render remembered hosts. /// /// Two addresses that present the same certificate key are the same server, so /// they are grouped under one fingerprint: `127.0.0.1:11525` and /// `10.0.0.100:11525` reached through different networks still show as one /// entry. Hosts without a fingerprint are never merged, since nothing proves /// they are related. pub fn host_lines(hosts: &[(String, Option)]) -> Vec { if hosts.is_empty() { return vec!["No remembered hosts.".to_string()]; } let mut groups: Vec<(Option<&str>, Vec<&str>)> = Vec::new(); for (host, fingerprint) in hosts { match fingerprint { Some(fingerprint) => { match groups .iter_mut() .find(|(key, _)| *key == Some(fingerprint.as_str())) { Some((_, addresses)) => addresses.push(host), None => groups.push((Some(fingerprint), vec![host])), } } // An unknown key proves nothing about identity. None => groups.push((None, vec![host])), } } let mut lines = vec!["Remembered hosts:".to_string()]; for (fingerprint, addresses) in groups { lines.push(format!(" {}", addresses.join(", "))); 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) }