diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/client/connection.rs | 14 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 107 | ||||
| -rw-r--r-- | src/client/core.rs | 6 | ||||
| -rw-r--r-- | src/client/ui.rs | 93 |
4 files changed, 180 insertions, 40 deletions
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 @@ | |||
| 1 | use zagan_bet::client::database; | 1 | use zagan_bet::client::database; |
| 2 | use zagan_bet::client::ui::first_contact_lines; | ||
| 2 | use crate::tls::TlsConnector; | 3 | use crate::tls::TlsConnector; |
| 3 | use rusqlite::Connection; | 4 | use rusqlite::Connection; |
| 4 | use std::io::{Read, Write}; | 5 | use std::io::{Read, Write}; |
| @@ -57,10 +58,9 @@ pub fn connect( | |||
| 57 | if let Some(fp) = observed { | 58 | if let Some(fp) = observed { |
| 58 | if database::get_fingerprint(conn, addr).is_none() { | 59 | if database::get_fingerprint(conn, addr).is_none() { |
| 59 | database::save_fingerprint(conn, addr, &fp)?; | 60 | database::save_fingerprint(conn, addr, &fp)?; |
| 60 | println!( | 61 | for line in first_contact_lines(addr, &fp) { |
| 61 | "First contact with {addr}: trusting fingerprint {fp}\n\ | 62 | println!("{}", line); |
| 62 | Future connections must present the same certificate key." | 63 | } |
| 63 | ); | ||
| 64 | } | 64 | } |
| 65 | } | 65 | } |
| 66 | } | 66 | } |
| @@ -72,7 +72,11 @@ pub fn connect( | |||
| 72 | let mut response = String::new(); | 72 | let mut response = String::new(); |
| 73 | tls.read_line(&mut response)?; | 73 | tls.read_line(&mut response)?; |
| 74 | let elapsed = start.elapsed(); | 74 | let elapsed = start.elapsed(); |
| 75 | println!("\n\rConnected to {} with ping delay: {:?} (TLS)", addr, elapsed); | 75 | println!( |
| 76 | "\n\rConnected to {} with ping delay: {:.1}ms (TLS)", | ||
| 77 | addr, | ||
| 78 | elapsed.as_secs_f64() * 1000.0 | ||
| 79 | ); | ||
| 76 | 80 | ||
| 77 | if let Some((username, password)) = database::get_credentials(conn, addr) { | 81 | if let Some((username, password)) = database::get_credentials(conn, addr) { |
| 78 | let auth_msg = format!("AUTH {} {}", username, password); | 82 | 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 @@ | |||
| 1 | use rusqlite::Connection; | 1 | use rusqlite::Connection; |
| 2 | use std::io::{stdout, Write}; | 2 | use std::io::{stdout, Write}; |
| 3 | use std::sync::{Arc, Mutex}; | 3 | use std::sync::{Arc, Mutex}; |
| 4 | use std::time::Instant; | ||
| 4 | 5 | ||
| 5 | use crossterm::{ | 6 | use crossterm::{ |
| 6 | cursor::{MoveToColumn, MoveUp, Show}, | 7 | cursor::{MoveToColumn, MoveUp, Show}, |
| 7 | event::{read, Event, KeyCode, KeyEventKind, KeyModifiers}, | 8 | event::{read, Event, KeyCode, KeyEventKind, KeyModifiers}, |
| 8 | execute, | 9 | execute, |
| 10 | style::Stylize, | ||
| 9 | terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, | 11 | terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, |
| 10 | }; | 12 | }; |
| 11 | 13 | ||
| @@ -13,7 +15,9 @@ use sha2::{Digest, Sha256}; | |||
| 13 | 15 | ||
| 14 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; | 16 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; |
| 15 | use zagan_bet::client::database; | 17 | use zagan_bet::client::database; |
| 16 | use zagan_bet::client::ui::{input_cursor_column, lines_to_move_up}; | 18 | use zagan_bet::client::ui::{ |
| 19 | display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, | ||
| 20 | }; | ||
| 17 | 21 | ||
| 18 | use crate::connection::TlsStream; | 22 | use crate::connection::TlsStream; |
| 19 | 23 | ||
| @@ -96,18 +100,22 @@ impl ClientState { | |||
| 96 | 100 | ||
| 97 | loop { | 101 | loop { |
| 98 | let event = read().unwrap(); | 102 | let event = read().unwrap(); |
| 99 | if let Event::Key(key) = event { | 103 | match event { |
| 100 | if key.kind != KeyEventKind::Press { | 104 | Event::Key(key) => { |
| 101 | continue; | 105 | if key.kind != KeyEventKind::Press { |
| 102 | } | 106 | continue; |
| 103 | let Some(key) = map_key(key) else { | 107 | } |
| 104 | continue; | 108 | let Some(key) = map_key(key) else { |
| 105 | }; | 109 | continue; |
| 106 | let effects = self.core.handle_key(key); | 110 | }; |
| 107 | if self.apply_effects(effects) { | 111 | let effects = self.core.handle_key(key); |
| 108 | break; | 112 | if self.apply_effects(effects) { |
| 113 | break; | ||
| 114 | } | ||
| 115 | self.apply_prompt(); | ||
| 109 | } | 116 | } |
| 110 | self.apply_prompt(); | 117 | Event::Resize(_, _) => self.apply_prompt(), |
| 118 | _ => {} | ||
| 111 | } | 119 | } |
| 112 | } | 120 | } |
| 113 | 121 | ||
| @@ -120,15 +128,45 @@ impl ClientState { | |||
| 120 | Effect::Print(lines) => { | 128 | Effect::Print(lines) => { |
| 121 | self.commit_prompt_line(); | 129 | self.commit_prompt_line(); |
| 122 | for line in lines { | 130 | for line in lines { |
| 123 | println!("{}", line); | 131 | let rendered = if line.starts_with("ERROR") |
| 132 | || line.contains("failed") | ||
| 133 | { | ||
| 134 | line.as_str().red().to_string() | ||
| 135 | } else if line.starts_with("Logged in") | ||
| 136 | || line.starts_with("Successfully") | ||
| 137 | { | ||
| 138 | line.as_str().green().to_string() | ||
| 139 | } else if line.starts_with("Available commands:") | ||
| 140 | || line.starts_with("Available games:") | ||
| 141 | || line.starts_with("Remembered hosts:") | ||
| 142 | { | ||
| 143 | line.as_str().bold().cyan().to_string() | ||
| 144 | } else { | ||
| 145 | line | ||
| 146 | }; | ||
| 147 | println!("{}", rendered); | ||
| 124 | } | 148 | } |
| 125 | } | 149 | } |
| 126 | Effect::ReplaceRegion(lines) => self.draw_region(&lines), | 150 | Effect::ReplaceRegion(lines) => self.draw_region(&lines), |
| 127 | Effect::ClearRegion => self.clear_region(), | 151 | Effect::ClearRegion => self.clear_region(), |
| 128 | Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col), | 152 | Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col), |
| 129 | Effect::SendLine(line) => { | 153 | Effect::SendLine(line) => { |
| 154 | let is_ping = line == "PING"; | ||
| 155 | let started = Instant::now(); | ||
| 130 | let reply = self.send_and_read(&line); | 156 | let reply = self.send_and_read(&line); |
| 131 | let more = self.core.handle_reply(&reply); | 157 | let elapsed = started.elapsed(); |
| 158 | let mut more = self.core.handle_reply(&reply); | ||
| 159 | if is_ping && reply.trim() == "PONG" { | ||
| 160 | for effect in &mut more { | ||
| 161 | if let Effect::Print(lines) = effect { | ||
| 162 | for text in lines { | ||
| 163 | if text == "Pong" { | ||
| 164 | *text = format_pong(elapsed); | ||
| 165 | } | ||
| 166 | } | ||
| 167 | } | ||
| 168 | } | ||
| 169 | } | ||
| 132 | if self.stream.is_none() { | 170 | if self.stream.is_none() { |
| 133 | self.core.set_connected(false); | 171 | self.core.set_connected(false); |
| 134 | } | 172 | } |
| @@ -196,7 +234,21 @@ impl ClientState { | |||
| 196 | execute!(stdout(), MoveUp(lines_to_move_up(self.game_lines))).unwrap(); | 234 | execute!(stdout(), MoveUp(lines_to_move_up(self.game_lines))).unwrap(); |
| 197 | } | 235 | } |
| 198 | execute!(stdout(), MoveToColumn(0), Clear(ClearType::FromCursorDown)).unwrap(); | 236 | execute!(stdout(), MoveToColumn(0), Clear(ClearType::FromCursorDown)).unwrap(); |
| 199 | print!("{}", lines.join("\r\n")); | 237 | let rendered: Vec<String> = lines |
| 238 | .iter() | ||
| 239 | .map(|line| { | ||
| 240 | if line.starts_with("===") { | ||
| 241 | line.as_str().bold().cyan().to_string() | ||
| 242 | } else if line.contains("JACKPOT") || line.contains("Two of a kind") { | ||
| 243 | line.as_str().bold().green().to_string() | ||
| 244 | } else if line.starts_with("ERROR:") { | ||
| 245 | line.as_str().red().to_string() | ||
| 246 | } else { | ||
| 247 | line.clone() | ||
| 248 | } | ||
| 249 | }) | ||
| 250 | .collect(); | ||
| 251 | print!("{}", rendered.join("\r\n")); | ||
| 200 | stdout().flush().unwrap(); | 252 | stdout().flush().unwrap(); |
| 201 | self.game_lines = lines.len().max(1); | 253 | self.game_lines = lines.len().max(1); |
| 202 | } | 254 | } |
| @@ -217,7 +269,8 @@ impl ClientState { | |||
| 217 | 269 | ||
| 218 | fn draw_prompt(&mut self, text: &str, cursor_col: u16) { | 270 | fn draw_prompt(&mut self, text: &str, cursor_col: u16) { |
| 219 | execute!(stdout(), MoveToColumn(0), Clear(ClearType::CurrentLine)).unwrap(); | 271 | execute!(stdout(), MoveToColumn(0), Clear(ClearType::CurrentLine)).unwrap(); |
| 220 | print!("> {}", text); | 272 | print!("{}", "> ".cyan()); |
| 273 | print!("{}", text); | ||
| 221 | stdout().flush().unwrap(); | 274 | stdout().flush().unwrap(); |
| 222 | execute!(stdout(), MoveToColumn(cursor_col)).unwrap(); | 275 | execute!(stdout(), MoveToColumn(cursor_col)).unwrap(); |
| 223 | self.prompt_text = text.to_string(); | 276 | self.prompt_text = text.to_string(); |
| @@ -376,16 +429,8 @@ impl ClientState { | |||
| 376 | 429 | ||
| 377 | fn list_hosts(&mut self) { | 430 | fn list_hosts(&mut self) { |
| 378 | let hosts = database::get_all_hosts(&self.conn); | 431 | let hosts = database::get_all_hosts(&self.conn); |
| 379 | if hosts.is_empty() { | 432 | for line in host_lines(&hosts) { |
| 380 | println!("No remembered hosts."); | 433 | println!("{}", line); |
| 381 | return; | ||
| 382 | } | ||
| 383 | println!("Remembered hosts:"); | ||
| 384 | for (host, fingerprint) in hosts { | ||
| 385 | match fingerprint { | ||
| 386 | Some(fingerprint) => println!(" {} - {}", host, fingerprint), | ||
| 387 | None => println!(" {} - (no fingerprint)", host), | ||
| 388 | } | ||
| 389 | } | 434 | } |
| 390 | } | 435 | } |
| 391 | } | 436 | } |
| @@ -425,7 +470,6 @@ fn read_line(prompt: &str, cleanup_done: &Arc<Mutex<bool>>) -> String { | |||
| 425 | stdout().flush().unwrap(); | 470 | stdout().flush().unwrap(); |
| 426 | 471 | ||
| 427 | let mut input = String::new(); | 472 | let mut input = String::new(); |
| 428 | let prompt_len = prompt.len(); | ||
| 429 | 473 | ||
| 430 | loop { | 474 | loop { |
| 431 | match read().unwrap() { | 475 | match read().unwrap() { |
| @@ -442,8 +486,8 @@ fn read_line(prompt: &str, cleanup_done: &Arc<Mutex<bool>>) -> String { | |||
| 442 | execute!( | 486 | execute!( |
| 443 | stdout(), | 487 | stdout(), |
| 444 | MoveToColumn(input_cursor_column( | 488 | MoveToColumn(input_cursor_column( |
| 445 | prompt_len, | 489 | display_width(prompt), |
| 446 | input.len() | 490 | display_width(&input) |
| 447 | )), | 491 | )), |
| 448 | Clear(ClearType::UntilNewLine) | 492 | Clear(ClearType::UntilNewLine) |
| 449 | ) | 493 | ) |
| @@ -486,7 +530,6 @@ fn read_password(prompt: &str, cleanup_done: &Arc<Mutex<bool>>) -> String { | |||
| 486 | stdout().flush().unwrap(); | 530 | stdout().flush().unwrap(); |
| 487 | 531 | ||
| 488 | let mut input = String::new(); | 532 | let mut input = String::new(); |
| 489 | let prompt_len = prompt.len(); | ||
| 490 | 533 | ||
| 491 | loop { | 534 | loop { |
| 492 | match read().unwrap() { | 535 | match read().unwrap() { |
| @@ -503,8 +546,8 @@ fn read_password(prompt: &str, cleanup_done: &Arc<Mutex<bool>>) -> String { | |||
| 503 | execute!( | 546 | execute!( |
| 504 | stdout(), | 547 | stdout(), |
| 505 | MoveToColumn(input_cursor_column( | 548 | MoveToColumn(input_cursor_column( |
| 506 | prompt_len, | 549 | display_width(prompt), |
| 507 | input.len() | 550 | display_width(&input) |
| 508 | )), | 551 | )), |
| 509 | Clear(ClearType::UntilNewLine) | 552 | Clear(ClearType::UntilNewLine) |
| 510 | ) | 553 | ) |
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 @@ | |||
| 1 | use crate::client::games::manager::GameManager; | 1 | use crate::client::games::manager::GameManager; |
| 2 | use crate::client::games::response::parse_spin_response; | 2 | use crate::client::games::response::parse_spin_response; |
| 3 | use crate::client::ui::{ | 3 | use crate::client::ui::{ |
| 4 | command_names, completion_lines, help_lines, input_cursor_column, parse_bet_arg, | 4 | command_names, completion_lines, display_width, help_lines, input_cursor_column, |
| 5 | record_history, | 5 | parse_bet_arg, record_history, |
| 6 | }; | 6 | }; |
| 7 | 7 | ||
| 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| @@ -120,7 +120,7 @@ impl ClientCore { | |||
| 120 | } | 120 | } |
| 121 | Some(Effect::SetPrompt { | 121 | Some(Effect::SetPrompt { |
| 122 | text: self.input.clone(), | 122 | text: self.input.clone(), |
| 123 | cursor_col: input_cursor_column(2, self.input.chars().count()), | 123 | cursor_col: input_cursor_column(2, display_width(&self.input)), |
| 124 | }) | 124 | }) |
| 125 | } | 125 | } |
| 126 | 126 | ||
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<u64, String> { | |||
| 153 | Ok(bet) | 153 | Ok(bet) |
| 154 | } | 154 | } |
| 155 | 155 | ||
| 156 | /// Number of terminal columns used by `text`. | ||
| 157 | /// | ||
| 158 | /// This is a deliberately small approximation of `unicode-width`: it handles | ||
| 159 | /// ASCII, Latin diacritics, combining marks, CJK, and the common emoji ranges | ||
| 160 | /// without adding a dependency. | ||
| 161 | pub fn display_width(text: &str) -> usize { | ||
| 162 | text.chars().map(char_width).sum() | ||
| 163 | } | ||
| 164 | |||
| 165 | fn char_width(c: char) -> usize { | ||
| 166 | let cp = c as u32; | ||
| 167 | if cp < 0x20 || cp == 0x7F { | ||
| 168 | return 0; | ||
| 169 | } | ||
| 170 | if is_zero_width(cp) || is_combining(cp) { | ||
| 171 | return 0; | ||
| 172 | } | ||
| 173 | if is_wide(cp) { | ||
| 174 | 2 | ||
| 175 | } else { | ||
| 176 | 1 | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 180 | fn is_zero_width(cp: u32) -> bool { | ||
| 181 | matches!( | ||
| 182 | cp, | ||
| 183 | 0x200B..=0x200F | 0xFE00..=0xFE0F | 0xFEFF | 0x1F3FB..=0x1F3FF | ||
| 184 | ) | ||
| 185 | } | ||
| 186 | |||
| 187 | fn is_combining(cp: u32) -> bool { | ||
| 188 | matches!( | ||
| 189 | cp, | ||
| 190 | 0x0300..=0x036F | ||
| 191 | | 0x1AB0..=0x1AFF | ||
| 192 | | 0x1DC0..=0x1DFF | ||
| 193 | | 0x20D0..=0x20FF | ||
| 194 | | 0xFE20..=0xFE2F | ||
| 195 | ) | ||
| 196 | } | ||
| 197 | |||
| 198 | fn is_wide(cp: u32) -> bool { | ||
| 199 | matches!( | ||
| 200 | cp, | ||
| 201 | 0x1100..=0x115F | ||
| 202 | | 0x2329..=0x232A | ||
| 203 | | 0x2E80..=0xA4CF | ||
| 204 | | 0xAC00..=0xD7A3 | ||
| 205 | | 0xF900..=0xFAFF | ||
| 206 | | 0xFE10..=0xFE19 | ||
| 207 | | 0xFE30..=0xFE6F | ||
| 208 | | 0xFF00..=0xFF60 | ||
| 209 | | 0xFFE0..=0xFFE6 | ||
| 210 | | 0x1F300..=0x1F64F | ||
| 211 | | 0x1F680..=0x1F6FF | ||
| 212 | | 0x1F900..=0x1F9FF | ||
| 213 | | 0x20000..=0x3FFFD | ||
| 214 | ) | ||
| 215 | } | ||
| 216 | |||
| 217 | /// The three lines printed on first contact with a host. | ||
| 218 | pub fn first_contact_lines(addr: &str, fingerprint: &str) -> Vec<String> { | ||
| 219 | vec![ | ||
| 220 | format!("First contact with {addr}: trusting a new certificate."), | ||
| 221 | format!("Fingerprint: {fingerprint}"), | ||
| 222 | "Future connections must present the same certificate key.".to_string(), | ||
| 223 | ] | ||
| 224 | } | ||
| 225 | |||
| 226 | /// Format remembered hosts so a 64-character fingerprint does not wrap on an | ||
| 227 | /// 80-column terminal. | ||
| 228 | pub fn host_lines(hosts: &[(String, Option<String>)]) -> Vec<String> { | ||
| 229 | if hosts.is_empty() { | ||
| 230 | return vec!["No remembered hosts.".to_string()]; | ||
| 231 | } | ||
| 232 | |||
| 233 | let mut lines = vec!["Remembered hosts:".to_string()]; | ||
| 234 | for (host, fingerprint) in hosts { | ||
| 235 | lines.push(format!(" {host}")); | ||
| 236 | match fingerprint { | ||
| 237 | Some(fingerprint) => lines.push(format!(" fingerprint: {fingerprint}")), | ||
| 238 | None => lines.push(" (no fingerprint)".to_string()), | ||
| 239 | } | ||
| 240 | } | ||
| 241 | lines | ||
| 242 | } | ||
| 243 | |||
| 156 | /// Lines to move the cursor up to reach the first line of a region that ends | 244 | /// Lines to move the cursor up to reach the first line of a region that ends |
| 157 | /// on the current line. `render()` has no trailing newline, so a 7-line board | 245 | /// on the current line. `render()` has no trailing newline, so a 7-line board |
| 158 | /// needs `MoveUp(6)`. | 246 | /// needs `MoveUp(6)`. |
| @@ -168,3 +256,8 @@ pub fn input_cursor_column(prompt_len: usize, input_len: usize) -> u16 { | |||
| 168 | .saturating_add(input_len) | 256 | .saturating_add(input_len) |
| 169 | .min(u16::MAX as usize) as u16 | 257 | .min(u16::MAX as usize) as u16 |
| 170 | } | 258 | } |
| 259 | |||
| 260 | /// Format a ping round-trip time with one decimal place. | ||
| 261 | pub fn format_pong(elapsed: std::time::Duration) -> String { | ||
| 262 | format!("Pong ({:.1}ms)", elapsed.as_secs_f64() * 1000.0) | ||
| 263 | } | ||
