summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Cargo.toml2
-rw-r--r--src/bin/client/connection.rs14
-rw-r--r--src/bin/client/repl.rs107
-rw-r--r--src/client/core.rs6
-rw-r--r--src/client/ui.rs93
-rw-r--r--tests/client_core.rs89
-rw-r--r--tests/client_ui.rs65
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 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "0.2.4" 3version = "0.2.5"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[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 @@
1use zagan_bet::client::database; 1use zagan_bet::client::database;
2use zagan_bet::client::ui::first_contact_lines;
2use crate::tls::TlsConnector; 3use crate::tls::TlsConnector;
3use rusqlite::Connection; 4use rusqlite::Connection;
4use std::io::{Read, Write}; 5use 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 @@
1use rusqlite::Connection; 1use rusqlite::Connection;
2use std::io::{stdout, Write}; 2use std::io::{stdout, Write};
3use std::sync::{Arc, Mutex}; 3use std::sync::{Arc, Mutex};
4use std::time::Instant;
4 5
5use crossterm::{ 6use 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
14use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; 16use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key};
15use zagan_bet::client::database; 17use zagan_bet::client::database;
16use zagan_bet::client::ui::{input_cursor_column, lines_to_move_up}; 18use zagan_bet::client::ui::{
19 display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up,
20};
17 21
18use crate::connection::TlsStream; 22use 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 @@
1use crate::client::games::manager::GameManager; 1use crate::client::games::manager::GameManager;
2use crate::client::games::response::parse_spin_response; 2use crate::client::games::response::parse_spin_response;
3use crate::client::ui::{ 3use 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.
161pub fn display_width(text: &str) -> usize {
162 text.chars().map(char_width).sum()
163}
164
165fn 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
180fn is_zero_width(cp: u32) -> bool {
181 matches!(
182 cp,
183 0x200B..=0x200F | 0xFE00..=0xFE0F | 0xFEFF | 0x1F3FB..=0x1F3FF
184 )
185}
186
187fn 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
198fn 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.
218pub 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.
228pub 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.
261pub fn format_pong(elapsed: std::time::Duration) -> String {
262 format!("Pong ({:.1}ms)", elapsed.as_secs_f64() * 1000.0)
263}
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() {
386 assert_eq!(h.screen.prompt, ""); 386 assert_eq!(h.screen.prompt, "");
387 assert_eq!(h.screen.prompt_cursor, 2); 387 assert_eq!(h.screen.prompt_cursor, 2);
388} 388}
389
390#[test]
391fn info_shows_the_host_and_the_server_fields() {
392 let mut h = Harness::new();
393 h.core.set_host_info(
394 Some("127.0.0.1:11525".to_string()),
395 Some("deadbeef".to_string()),
396 );
397
398 h.line("info");
399 assert!(h
400 .screen
401 .lines
402 .iter()
403 .any(|line| line.contains("Connected to: 127.0.0.1:11525")));
404 assert!(h
405 .screen
406 .lines
407 .iter()
408 .any(|line| line.contains("Fingerprint: deadbeef")));
409
410 h.reply("INFO alice 42 1234");
411
412 assert!(h.screen.lines.iter().any(|line| line == "User: alice"));
413 assert!(h.screen.lines.iter().any(|line| line == "Account: 42"));
414 assert!(h
415 .screen
416 .lines
417 .iter()
418 .any(|line| line == "Balance: 1234 PLN"));
419}
420
421#[test]
422fn info_without_a_connection_does_not_send() {
423 let mut h = Harness::new();
424 h.core.set_connected(false);
425
426 h.line("info");
427
428 assert!(h
429 .screen
430 .lines
431 .iter()
432 .any(|line| line.contains("Not connected to any host")));
433 assert!(h.sent.is_empty());
434}
435
436#[test]
437fn ping_prints_pong() {
438 let mut h = Harness::new();
439 h.line("ping");
440 assert_eq!(h.sent.last().map(String::as_str), Some("PING"));
441
442 h.reply("PONG");
443
444 assert!(h.screen.lines.iter().any(|line| line == "Pong"));
445}
446
447#[test]
448fn send_prints_the_echo() {
449 let mut h = Harness::new();
450 h.line("send hello");
451 h.reply("hello");
452
453 assert!(h
454 .screen
455 .lines
456 .iter()
457 .any(|line| line == "Echo: hello"));
458}
459
460#[test]
461fn disconnect_clears_connection_state() {
462 let mut h = Harness::new();
463 h.line("disconnect");
464
465 assert!(h
466 .screen
467 .lines
468 .iter()
469 .any(|line| line == "Disconnected"));
470
471 h.line("play slots");
472 assert!(h
473 .screen
474 .lines
475 .iter()
476 .any(|line| line.contains("Not connected")));
477}
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 @@
1use zagan_bet::client::ui::{ 1use zagan_bet::client::ui::{
2 command_names, completion_lines, help_lines, input_cursor_column, lines_to_move_up, 2 command_names, completion_lines, display_width, first_contact_lines, format_pong,
3 parse_bet_arg, record_history, 3 help_lines, host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history,
4}; 4};
5use std::time::Duration;
5 6
6#[test] 7#[test]
7fn help_has_no_source_indentation() { 8fn help_has_no_source_indentation() {
@@ -129,3 +130,63 @@ fn completions_wrap_within_the_requested_width() {
129 ); 130 );
130 } 131 }
131} 132}
133
134#[test]
135fn display_width_counts_terminal_columns_not_bytes() {
136 assert_eq!(display_width("abc"), 3);
137 assert_eq!(display_width("ąćęłńóśźż"), 9);
138 assert_eq!(display_width("中"), 2);
139 assert_eq!(display_width("🎉"), 2);
140 assert_eq!(display_width("e\u{0301}"), 1);
141}
142
143#[test]
144fn cursor_column_uses_display_width() {
145 assert_eq!(input_cursor_column(2, display_width("ąć")), 4);
146 assert_eq!(input_cursor_column(2, display_width("中")), 4);
147}
148
149#[test]
150fn first_contact_message_stays_within_80_columns() {
151 let fingerprint = "a4d6f80784154c2cb739a1df61ab3e9038ed731ee2e05c7611457ac741b3834c";
152 let lines = first_contact_lines("127.0.0.1:11525", fingerprint);
153
154 assert!(lines.iter().any(|line| line.contains("127.0.0.1:11525")));
155 assert!(lines.iter().any(|line| line.contains(fingerprint)));
156 for line in lines {
157 assert!(
158 line.len() <= 80,
159 "first-contact line is too wide ({}): {line:?}",
160 line.len()
161 );
162 }
163}
164
165#[test]
166fn pong_is_formatted_to_one_decimal() {
167 assert_eq!(format_pong(Duration::from_micros(144)), "Pong (0.1ms)");
168 assert_eq!(format_pong(Duration::from_millis(41)), "Pong (41.0ms)");
169}
170
171#[test]
172fn host_lines_fit_an_80_column_terminal() {
173 let fingerprint = "a4d6f80784154c2cb739a1df61ab3e9038ed731ee2e05c7611457ac741b3834c";
174 let lines = host_lines(&[
175 (
176 "127.0.0.1:11525".to_string(),
177 Some(fingerprint.to_string()),
178 ),
179 ("unknown.example:443".to_string(), None),
180 ]);
181
182 assert!(lines.iter().any(|line| line.contains("127.0.0.1:11525")));
183 assert!(lines.iter().any(|line| line.contains(fingerprint)));
184 assert!(lines.iter().any(|line| line.contains("no fingerprint")));
185 for line in lines {
186 assert!(
187 line.len() <= 80,
188 "host line is too wide ({}): {line:?}",
189 line.len()
190 );
191 }
192}