From 25f9abeb4c69024f8d9dceb472ba33c03e00daa0 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 22:36:07 +0100 Subject: v0.2.5: fix terminal width, ping timing and output formatting --- Cargo.toml | 2 +- src/bin/client/connection.rs | 14 ++++-- src/bin/client/repl.rs | 107 ++++++++++++++++++++++++++++++------------- src/client/core.rs | 6 +-- src/client/ui.rs | 93 +++++++++++++++++++++++++++++++++++++ tests/client_core.rs | 89 +++++++++++++++++++++++++++++++++++ tests/client_ui.rs | 65 +++++++++++++++++++++++++- 7 files changed, 333 insertions(+), 43 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f61b963..8228349 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "0.2.4" +version = "0.2.5" edition = "2024" [dependencies] diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index e54b646..93a7829 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs @@ -1,4 +1,5 @@ use zagan_bet::client::database; +use zagan_bet::client::ui::first_contact_lines; use crate::tls::TlsConnector; use rusqlite::Connection; use std::io::{Read, Write}; @@ -57,10 +58,9 @@ pub fn connect( if let Some(fp) = observed { if database::get_fingerprint(conn, addr).is_none() { database::save_fingerprint(conn, addr, &fp)?; - println!( - "First contact with {addr}: trusting fingerprint {fp}\n\ - Future connections must present the same certificate key." - ); + for line in first_contact_lines(addr, &fp) { + println!("{}", line); + } } } } @@ -72,7 +72,11 @@ pub fn connect( let mut response = String::new(); tls.read_line(&mut response)?; let elapsed = start.elapsed(); - println!("\n\rConnected to {} with ping delay: {:?} (TLS)", addr, elapsed); + println!( + "\n\rConnected to {} with ping delay: {:.1}ms (TLS)", + addr, + elapsed.as_secs_f64() * 1000.0 + ); if let Some((username, password)) = database::get_credentials(conn, addr) { let auth_msg = format!("AUTH {} {}", username, password); diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index 3fa49b8..76b149b 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs @@ -1,11 +1,13 @@ use rusqlite::Connection; use std::io::{stdout, Write}; use std::sync::{Arc, Mutex}; +use std::time::Instant; use crossterm::{ cursor::{MoveToColumn, MoveUp, Show}, event::{read, Event, KeyCode, KeyEventKind, KeyModifiers}, execute, + style::Stylize, terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, }; @@ -13,7 +15,9 @@ use sha2::{Digest, Sha256}; use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; use zagan_bet::client::database; -use zagan_bet::client::ui::{input_cursor_column, lines_to_move_up}; +use zagan_bet::client::ui::{ + display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, +}; use crate::connection::TlsStream; @@ -96,18 +100,22 @@ impl ClientState { loop { let event = read().unwrap(); - if let Event::Key(key) = event { - if key.kind != KeyEventKind::Press { - continue; - } - let Some(key) = map_key(key) else { - continue; - }; - let effects = self.core.handle_key(key); - if self.apply_effects(effects) { - break; + match event { + Event::Key(key) => { + if key.kind != KeyEventKind::Press { + continue; + } + let Some(key) = map_key(key) else { + continue; + }; + let effects = self.core.handle_key(key); + if self.apply_effects(effects) { + break; + } + self.apply_prompt(); } - self.apply_prompt(); + Event::Resize(_, _) => self.apply_prompt(), + _ => {} } } @@ -120,15 +128,45 @@ impl ClientState { Effect::Print(lines) => { self.commit_prompt_line(); for line in lines { - println!("{}", line); + let rendered = if line.starts_with("ERROR") + || line.contains("failed") + { + line.as_str().red().to_string() + } else if line.starts_with("Logged in") + || line.starts_with("Successfully") + { + line.as_str().green().to_string() + } else if line.starts_with("Available commands:") + || line.starts_with("Available games:") + || line.starts_with("Remembered hosts:") + { + line.as_str().bold().cyan().to_string() + } else { + line + }; + println!("{}", rendered); } } Effect::ReplaceRegion(lines) => self.draw_region(&lines), Effect::ClearRegion => self.clear_region(), Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col), Effect::SendLine(line) => { + let is_ping = line == "PING"; + let started = Instant::now(); let reply = self.send_and_read(&line); - let more = self.core.handle_reply(&reply); + let elapsed = started.elapsed(); + let mut more = self.core.handle_reply(&reply); + if is_ping && reply.trim() == "PONG" { + for effect in &mut more { + if let Effect::Print(lines) = effect { + for text in lines { + if text == "Pong" { + *text = format_pong(elapsed); + } + } + } + } + } if self.stream.is_none() { self.core.set_connected(false); } @@ -196,7 +234,21 @@ impl ClientState { execute!(stdout(), MoveUp(lines_to_move_up(self.game_lines))).unwrap(); } execute!(stdout(), MoveToColumn(0), Clear(ClearType::FromCursorDown)).unwrap(); - print!("{}", lines.join("\r\n")); + let rendered: Vec = lines + .iter() + .map(|line| { + if line.starts_with("===") { + line.as_str().bold().cyan().to_string() + } else if line.contains("JACKPOT") || line.contains("Two of a kind") { + line.as_str().bold().green().to_string() + } else if line.starts_with("ERROR:") { + line.as_str().red().to_string() + } else { + line.clone() + } + }) + .collect(); + print!("{}", rendered.join("\r\n")); stdout().flush().unwrap(); self.game_lines = lines.len().max(1); } @@ -217,7 +269,8 @@ impl ClientState { fn draw_prompt(&mut self, text: &str, cursor_col: u16) { execute!(stdout(), MoveToColumn(0), Clear(ClearType::CurrentLine)).unwrap(); - print!("> {}", text); + print!("{}", "> ".cyan()); + print!("{}", text); stdout().flush().unwrap(); execute!(stdout(), MoveToColumn(cursor_col)).unwrap(); self.prompt_text = text.to_string(); @@ -376,16 +429,8 @@ impl ClientState { fn list_hosts(&mut self) { let hosts = database::get_all_hosts(&self.conn); - if hosts.is_empty() { - println!("No remembered hosts."); - return; - } - println!("Remembered hosts:"); - for (host, fingerprint) in hosts { - match fingerprint { - Some(fingerprint) => println!(" {} - {}", host, fingerprint), - None => println!(" {} - (no fingerprint)", host), - } + for line in host_lines(&hosts) { + println!("{}", line); } } } @@ -425,7 +470,6 @@ fn read_line(prompt: &str, cleanup_done: &Arc>) -> String { stdout().flush().unwrap(); let mut input = String::new(); - let prompt_len = prompt.len(); loop { match read().unwrap() { @@ -442,8 +486,8 @@ fn read_line(prompt: &str, cleanup_done: &Arc>) -> String { execute!( stdout(), MoveToColumn(input_cursor_column( - prompt_len, - input.len() + display_width(prompt), + display_width(&input) )), Clear(ClearType::UntilNewLine) ) @@ -486,7 +530,6 @@ fn read_password(prompt: &str, cleanup_done: &Arc>) -> String { stdout().flush().unwrap(); let mut input = String::new(); - let prompt_len = prompt.len(); loop { match read().unwrap() { @@ -503,8 +546,8 @@ fn read_password(prompt: &str, cleanup_done: &Arc>) -> String { execute!( stdout(), MoveToColumn(input_cursor_column( - prompt_len, - input.len() + display_width(prompt), + display_width(&input) )), Clear(ClearType::UntilNewLine) ) diff --git a/src/client/core.rs b/src/client/core.rs index a910a68..c204cb2 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -1,8 +1,8 @@ use crate::client::games::manager::GameManager; use crate::client::games::response::parse_spin_response; use crate::client::ui::{ - command_names, completion_lines, help_lines, input_cursor_column, parse_bet_arg, - record_history, + command_names, completion_lines, display_width, help_lines, input_cursor_column, + parse_bet_arg, record_history, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -120,7 +120,7 @@ impl ClientCore { } Some(Effect::SetPrompt { text: self.input.clone(), - cursor_col: input_cursor_column(2, self.input.chars().count()), + cursor_col: input_cursor_column(2, display_width(&self.input)), }) } diff --git a/src/client/ui.rs b/src/client/ui.rs index 6fc2f62..493f660 100644 --- a/src/client/ui.rs +++ b/src/client/ui.rs @@ -153,6 +153,94 @@ pub fn parse_bet_arg(raw: &str) -> Result { 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)`. @@ -168,3 +256,8 @@ pub fn input_cursor_column(prompt_len: usize, input_len: usize) -> u16 { .saturating_add(input_len) .min(u16::MAX as usize) as u16 } + +/// 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) +} diff --git a/tests/client_core.rs b/tests/client_core.rs index 79b3dd8..c298ab7 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -386,3 +386,92 @@ fn prompt_cursor_tracks_the_input() { assert_eq!(h.screen.prompt, ""); assert_eq!(h.screen.prompt_cursor, 2); } + +#[test] +fn info_shows_the_host_and_the_server_fields() { + let mut h = Harness::new(); + h.core.set_host_info( + Some("127.0.0.1:11525".to_string()), + Some("deadbeef".to_string()), + ); + + h.line("info"); + assert!(h + .screen + .lines + .iter() + .any(|line| line.contains("Connected to: 127.0.0.1:11525"))); + assert!(h + .screen + .lines + .iter() + .any(|line| line.contains("Fingerprint: deadbeef"))); + + h.reply("INFO alice 42 1234"); + + assert!(h.screen.lines.iter().any(|line| line == "User: alice")); + assert!(h.screen.lines.iter().any(|line| line == "Account: 42")); + assert!(h + .screen + .lines + .iter() + .any(|line| line == "Balance: 1234 PLN")); +} + +#[test] +fn info_without_a_connection_does_not_send() { + let mut h = Harness::new(); + h.core.set_connected(false); + + h.line("info"); + + assert!(h + .screen + .lines + .iter() + .any(|line| line.contains("Not connected to any host"))); + assert!(h.sent.is_empty()); +} + +#[test] +fn ping_prints_pong() { + let mut h = Harness::new(); + h.line("ping"); + assert_eq!(h.sent.last().map(String::as_str), Some("PING")); + + h.reply("PONG"); + + assert!(h.screen.lines.iter().any(|line| line == "Pong")); +} + +#[test] +fn send_prints_the_echo() { + let mut h = Harness::new(); + h.line("send hello"); + h.reply("hello"); + + assert!(h + .screen + .lines + .iter() + .any(|line| line == "Echo: hello")); +} + +#[test] +fn disconnect_clears_connection_state() { + let mut h = Harness::new(); + h.line("disconnect"); + + assert!(h + .screen + .lines + .iter() + .any(|line| line == "Disconnected")); + + h.line("play slots"); + assert!(h + .screen + .lines + .iter() + .any(|line| line.contains("Not connected"))); +} diff --git a/tests/client_ui.rs b/tests/client_ui.rs index 78faed0..a22fbe7 100644 --- a/tests/client_ui.rs +++ b/tests/client_ui.rs @@ -1,7 +1,8 @@ use zagan_bet::client::ui::{ - command_names, completion_lines, help_lines, input_cursor_column, lines_to_move_up, - parse_bet_arg, record_history, + command_names, completion_lines, display_width, first_contact_lines, format_pong, + help_lines, host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history, }; +use std::time::Duration; #[test] fn help_has_no_source_indentation() { @@ -129,3 +130,63 @@ fn completions_wrap_within_the_requested_width() { ); } } + +#[test] +fn display_width_counts_terminal_columns_not_bytes() { + assert_eq!(display_width("abc"), 3); + assert_eq!(display_width("ąćęłńóśźż"), 9); + assert_eq!(display_width("中"), 2); + assert_eq!(display_width("🎉"), 2); + assert_eq!(display_width("e\u{0301}"), 1); +} + +#[test] +fn cursor_column_uses_display_width() { + assert_eq!(input_cursor_column(2, display_width("ąć")), 4); + assert_eq!(input_cursor_column(2, display_width("中")), 4); +} + +#[test] +fn first_contact_message_stays_within_80_columns() { + let fingerprint = "a4d6f80784154c2cb739a1df61ab3e9038ed731ee2e05c7611457ac741b3834c"; + let lines = first_contact_lines("127.0.0.1:11525", fingerprint); + + assert!(lines.iter().any(|line| line.contains("127.0.0.1:11525"))); + assert!(lines.iter().any(|line| line.contains(fingerprint))); + for line in lines { + assert!( + line.len() <= 80, + "first-contact line is too wide ({}): {line:?}", + line.len() + ); + } +} + +#[test] +fn pong_is_formatted_to_one_decimal() { + assert_eq!(format_pong(Duration::from_micros(144)), "Pong (0.1ms)"); + assert_eq!(format_pong(Duration::from_millis(41)), "Pong (41.0ms)"); +} + +#[test] +fn host_lines_fit_an_80_column_terminal() { + let fingerprint = "a4d6f80784154c2cb739a1df61ab3e9038ed731ee2e05c7611457ac741b3834c"; + let lines = host_lines(&[ + ( + "127.0.0.1:11525".to_string(), + Some(fingerprint.to_string()), + ), + ("unknown.example:443".to_string(), None), + ]); + + assert!(lines.iter().any(|line| line.contains("127.0.0.1:11525"))); + assert!(lines.iter().any(|line| line.contains(fingerprint))); + assert!(lines.iter().any(|line| line.contains("no fingerprint"))); + for line in lines { + assert!( + line.len() <= 80, + "host line is too wide ({}): {line:?}", + line.len() + ); + } +} -- cgit v1.2.3