diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 19:00:04 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 19:00:04 +0100 |
| commit | d251b54fe40f6f8f95261b4df65bced2fb728c63 (patch) | |
| tree | 11a4ddffd68f1f497498d36096435a56d9f13c1b | |
| parent | v4.0.5: login and registration as core commands (diff) | |
| download | zagan-bet-d251b54fe40f6f8f95261b4df65bced2fb728c63.tar.gz zagan-bet-d251b54fe40f6f8f95261b4df65bced2fb728c63.zip | |
v4.0.6: move the prompt editor and key mapping out of the core
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GUI-PLAN.md | 9 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 37 | ||||
| -rw-r--r-- | src/client/core.rs | 208 | ||||
| -rw-r--r-- | src/client/input.rs | 183 | ||||
| -rw-r--r-- | src/client/mod.rs | 1 | ||||
| -rw-r--r-- | tests/client_core.rs | 49 |
8 files changed, 261 insertions, 230 deletions
| @@ -55,6 +55,8 @@ | |||
| 55 | - The GUI is a second frontend over `ClientCore`, not a second client. | 55 | - The GUI is a second frontend over `ClientCore`, not a second client. |
| 56 | Frontends send `Command` and render `View`; the core owns navigation and | 56 | Frontends send `Command` and render `View`; the core owns navigation and |
| 57 | state and contains no I/O or terminal types. | 57 | state and contains no I/O or terminal types. |
| 58 | - The command-line editor and key mapping live in `client::input`. The core | ||
| 59 | receives `Command` or a finished line through `submit_line`, never a key. | ||
| 58 | - egui lives in the `gui/` workspace member. Its dependencies must never | 60 | - egui lives in the `gui/` workspace member. Its dependencies must never |
| 59 | enter the TUI or server build graph: no GUI crate in the root package's | 61 | enter the TUI or server build graph: no GUI crate in the root package's |
| 60 | `[dependencies]`. | 62 | `[dependencies]`. |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "4.0.5" | 3 | version = "4.0.6" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 5 | license = "GPL-3.0-or-later" |
| 6 | 6 | ||
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index f8ac3d5..cb0db91 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md | |||
| @@ -31,10 +31,11 @@ core builds a typed view, frontends send `Command`, and the TUI renders it. | |||
| 31 | Done so far: every game plus `Lobby` and `History` are structured views; the | 31 | Done so far: every game plus `Lobby` and `History` are structured views; the |
| 32 | client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in | 32 | client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in |
| 33 | `client::transport` behind a trust callback; login and registration are | 33 | `client::transport` behind a trust callback; login and registration are |
| 34 | `Command`s, so a GUI form never needs a terminal prompt. Remaining: the | 34 | `Command`s, so a GUI form never needs a terminal prompt. The command-line |
| 35 | `Connect` and `Trust` screens (adapter-owned; the transport callback is ready) | 35 | editor and key mapping moved to `client::input`; `ClientCore` no longer knows |
| 36 | and removing `Key` from the core. The TUI's interactive login still uses its | 36 | keys, the prompt or its history. Remaining: the `Connect` and `Trust` adapter |
| 37 | own prompt and should move to `Command::Login` when that prompt moves out. | 37 | screens, and replacing the last TUI-shaped effects (`Interactive`, host |
| 38 | listing, roulette animation) with commands. | ||
| 38 | 39 | ||
| 39 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, | 40 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, |
| 40 | wheel animation, interactive prompts) into the TUI adapter. | 41 | wheel animation, interactive prompts) into the TUI adapter. |
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index c13fc58..bc4238d 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs | |||
| @@ -13,7 +13,8 @@ use crossterm::{ | |||
| 13 | 13 | ||
| 14 | use sha2::{Digest, Sha256}; | 14 | use sha2::{Digest, Sha256}; |
| 15 | 15 | ||
| 16 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; | 16 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand}; |
| 17 | use zagan_bet::client::input::{game_input, GameInput, Input, Key, Editor}; | ||
| 17 | use zagan_bet::client::database; | 18 | use zagan_bet::client::database; |
| 18 | use zagan_bet::client::ui::{ | 19 | use zagan_bet::client::ui::{ |
| 19 | display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, | 20 | display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, |
| @@ -30,6 +31,7 @@ pub struct ClientState { | |||
| 30 | pub addr: Option<String>, | 31 | pub addr: Option<String>, |
| 31 | pub default_port: u16, | 32 | pub default_port: u16, |
| 32 | core: ClientCore, | 33 | core: ClientCore, |
| 34 | editor: Editor, | ||
| 33 | cleanup_done: Arc<Mutex<bool>>, | 35 | cleanup_done: Arc<Mutex<bool>>, |
| 34 | /// Number of visual terminal lines currently occupied by the game board. | 36 | /// Number of visual terminal lines currently occupied by the game board. |
| 35 | game_lines: usize, | 37 | game_lines: usize, |
| @@ -59,6 +61,7 @@ impl ClientState { | |||
| 59 | addr, | 61 | addr, |
| 60 | default_port, | 62 | default_port, |
| 61 | core: ClientCore::new(), | 63 | core: ClientCore::new(), |
| 64 | editor: Editor::new(), | ||
| 62 | cleanup_done: Arc::new(Mutex::new(false)), | 65 | cleanup_done: Arc::new(Mutex::new(false)), |
| 63 | game_lines: 0, | 66 | game_lines: 0, |
| 64 | prompt_text: String::new(), | 67 | prompt_text: String::new(), |
| @@ -112,9 +115,29 @@ impl ClientState { | |||
| 112 | Event::Key(key) => { | 115 | Event::Key(key) => { |
| 113 | if key.kind == KeyEventKind::Press { | 116 | if key.kind == KeyEventKind::Press { |
| 114 | if let Some(key) = map_key(key) { | 117 | if let Some(key) = map_key(key) { |
| 115 | let effects = self.core.handle_key(key); | 118 | let effects = if self.core.is_in_game() { |
| 119 | match game_input( | ||
| 120 | self.core.current_game_type().unwrap_or(""), | ||
| 121 | key, | ||
| 122 | ) { | ||
| 123 | GameInput::Quit => vec![Effect::Quit], | ||
| 124 | GameInput::Command(command) => { | ||
| 125 | self.core.command(command) | ||
| 126 | } | ||
| 127 | GameInput::None => Vec::new(), | ||
| 128 | } | ||
| 129 | } else { | ||
| 130 | match self.editor.handle(key) { | ||
| 131 | Input::Quit => vec![Effect::Quit], | ||
| 132 | Input::Submit(line) => self.core.submit_line(&line), | ||
| 133 | Input::Print(lines) => vec![Effect::Print(lines)], | ||
| 134 | Input::Prompt | Input::None => Vec::new(), | ||
| 135 | } | ||
| 136 | }; | ||
| 116 | quit = self.apply_effects(effects); | 137 | quit = self.apply_effects(effects); |
| 117 | self.apply_prompt(); | 138 | if !quit { |
| 139 | self.apply_prompt(); | ||
| 140 | } | ||
| 118 | } | 141 | } |
| 119 | } | 142 | } |
| 120 | } | 143 | } |
| @@ -222,7 +245,6 @@ impl ClientState { | |||
| 222 | } | 245 | } |
| 223 | } | 246 | } |
| 224 | Effect::ClearRegion => self.clear_region(), | 247 | Effect::ClearRegion => self.clear_region(), |
| 225 | Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col), | ||
| 226 | Effect::SendLine(line) => { | 248 | Effect::SendLine(line) => { |
| 227 | let is_ping = line == "PING"; | 249 | let is_ping = line == "PING"; |
| 228 | let started = Instant::now(); | 250 | let started = Instant::now(); |
| @@ -293,9 +315,12 @@ impl ClientState { | |||
| 293 | } | 315 | } |
| 294 | 316 | ||
| 295 | fn apply_prompt(&mut self) { | 317 | fn apply_prompt(&mut self) { |
| 296 | if let Some(prompt) = self.core.prompt_effect() { | 318 | if self.core.is_in_game() { |
| 297 | let _ = self.apply_effects(vec![prompt]); | 319 | return; |
| 298 | } | 320 | } |
| 321 | let text = self.editor.input().to_string(); | ||
| 322 | let column = self.editor.cursor_column(); | ||
| 323 | self.draw_prompt(&text, column); | ||
| 299 | } | 324 | } |
| 300 | 325 | ||
| 301 | /// Leave the command currently on the prompt line visible in the | 326 | /// Leave the command currently on the prompt line visible in the |
diff --git a/src/client/core.rs b/src/client/core.rs index 98f109f..280eb77 100644 --- a/src/client/core.rs +++ b/src/client/core.rs | |||
| @@ -3,32 +3,15 @@ use crate::client::games::response::{ | |||
| 3 | parse_blackjack_response, parse_crash_response, parse_history_response, | 3 | parse_blackjack_response, parse_crash_response, parse_history_response, |
| 4 | parse_roulette_response, parse_spin_response, | 4 | parse_roulette_response, parse_spin_response, |
| 5 | }; | 5 | }; |
| 6 | use crate::client::games::roulette::Direction; | ||
| 7 | use crate::client::view::{ | 6 | use crate::client::view::{ |
| 8 | BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, | 7 | BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, |
| 9 | }; | 8 | }; |
| 10 | use crate::client::ui::{ | 9 | use crate::client::ui::{help_lines, history_lines, parse_bet_arg}; |
| 11 | command_names, completion_lines, display_width, help_lines, history_lines, | ||
| 12 | input_cursor_column, parse_bet_arg, record_history, | ||
| 13 | }; | ||
| 14 | use crate::crash::{CrashState, multiplier_at}; | 10 | use crate::crash::{CrashState, multiplier_at}; |
| 15 | use crate::history::{PlayRecord, MAX_HISTORY}; | 11 | use crate::history::{PlayRecord, MAX_HISTORY}; |
| 16 | use crate::roulette::{BetTarget, Bets}; | 12 | use crate::roulette::{BetTarget, Bets}; |
| 17 | use std::time::{Duration, Instant}; | 13 | use std::time::{Duration, Instant}; |
| 18 | 14 | ||
| 19 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 20 | pub enum Key { | ||
| 21 | Char(char), | ||
| 22 | Enter, | ||
| 23 | Backspace, | ||
| 24 | Up, | ||
| 25 | Down, | ||
| 26 | Left, | ||
| 27 | Right, | ||
| 28 | Tab, | ||
| 29 | Esc, | ||
| 30 | CtrlC, | ||
| 31 | } | ||
| 32 | 15 | ||
| 33 | #[derive(Debug, Clone, PartialEq, Eq)] | 16 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 34 | pub enum InteractiveCommand { | 17 | pub enum InteractiveCommand { |
| @@ -50,8 +33,6 @@ pub enum Effect { | |||
| 50 | AnimateRoulette { winning: u8, view: View }, | 33 | AnimateRoulette { winning: u8, view: View }, |
| 51 | /// Remove the game board region. | 34 | /// Remove the game board region. |
| 52 | ClearRegion, | 35 | ClearRegion, |
| 53 | /// Redraw the command prompt. `text` is the input without the `> ` prefix. | ||
| 54 | SetPrompt { text: String, cursor_col: u16 }, | ||
| 55 | /// Send one protocol line and feed the reply to `handle_reply`. | 36 | /// Send one protocol line and feed the reply to `handle_reply`. |
| 56 | SendLine(String), | 37 | SendLine(String), |
| 57 | /// Delegate an interactive prompt flow to the terminal adapter. | 38 | /// Delegate an interactive prompt flow to the terminal adapter. |
| @@ -91,9 +72,6 @@ enum Screen { | |||
| 91 | } | 72 | } |
| 92 | 73 | ||
| 93 | pub struct ClientCore { | 74 | pub struct ClientCore { |
| 94 | input: String, | ||
| 95 | history: Vec<String>, | ||
| 96 | history_index: usize, | ||
| 97 | game: GameManager, | 75 | game: GameManager, |
| 98 | connected: bool, | 76 | connected: bool, |
| 99 | logged_in: Option<String>, | 77 | logged_in: Option<String>, |
| @@ -114,9 +92,6 @@ pub struct ClientCore { | |||
| 114 | impl ClientCore { | 92 | impl ClientCore { |
| 115 | pub fn new() -> Self { | 93 | pub fn new() -> Self { |
| 116 | Self { | 94 | Self { |
| 117 | input: String::new(), | ||
| 118 | history: Vec::new(), | ||
| 119 | history_index: 0, | ||
| 120 | game: GameManager::new(), | 95 | game: GameManager::new(), |
| 121 | connected: false, | 96 | connected: false, |
| 122 | logged_in: None, | 97 | logged_in: None, |
| @@ -184,40 +159,6 @@ impl ClientCore { | |||
| 184 | self.board_effects() | 159 | self.board_effects() |
| 185 | } | 160 | } |
| 186 | 161 | ||
| 187 | pub fn prompt_effect(&self) -> Option<Effect> { | ||
| 188 | if self.is_in_game() { | ||
| 189 | return None; | ||
| 190 | } | ||
| 191 | Some(Effect::SetPrompt { | ||
| 192 | text: self.input.clone(), | ||
| 193 | cursor_col: input_cursor_column(2, display_width(&self.input)), | ||
| 194 | }) | ||
| 195 | } | ||
| 196 | |||
| 197 | pub fn handle_key(&mut self, key: Key) -> Vec<Effect> { | ||
| 198 | if self.game.is_in_game() { | ||
| 199 | return self.handle_game_key(key); | ||
| 200 | } | ||
| 201 | |||
| 202 | match key { | ||
| 203 | Key::CtrlC => vec![Effect::Quit], | ||
| 204 | Key::Enter => self.submit(), | ||
| 205 | Key::Backspace => { | ||
| 206 | self.input.pop(); | ||
| 207 | self.prompt_effects() | ||
| 208 | } | ||
| 209 | Key::Char(c) => { | ||
| 210 | self.input.push(c); | ||
| 211 | self.prompt_effects() | ||
| 212 | } | ||
| 213 | Key::Up => self.history_previous(), | ||
| 214 | Key::Down => self.history_next(), | ||
| 215 | Key::Left | Key::Right => Vec::new(), | ||
| 216 | Key::Tab => self.complete(), | ||
| 217 | Key::Esc => Vec::new(), | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 221 | pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> { | 162 | pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> { |
| 222 | match self.pending.take() { | 163 | match self.pending.take() { |
| 223 | Some(Pending::Spin) => match parse_spin_response(reply) { | 164 | Some(Pending::Spin) => match parse_spin_response(reply) { |
| @@ -370,31 +311,6 @@ impl ClientCore { | |||
| 370 | } | 311 | } |
| 371 | } | 312 | } |
| 372 | 313 | ||
| 373 | fn handle_game_key(&mut self, key: Key) -> Vec<Effect> { | ||
| 374 | let game_type = self | ||
| 375 | .game | ||
| 376 | .get_current_state() | ||
| 377 | .map(|state| state.game_type.as_str()); | ||
| 378 | if game_type == Some("roulette") { | ||
| 379 | return self.handle_roulette_key(key); | ||
| 380 | } | ||
| 381 | if game_type == Some("blackjack") { | ||
| 382 | return self.handle_blackjack_key(key); | ||
| 383 | } | ||
| 384 | if game_type == Some("crash") { | ||
| 385 | return self.handle_crash_key(key); | ||
| 386 | } | ||
| 387 | |||
| 388 | match key { | ||
| 389 | Key::CtrlC => vec![Effect::Quit], | ||
| 390 | Key::Esc | Key::Char('q') => self.command(Command::Leave), | ||
| 391 | Key::Char(' ') => self.command(Command::Spin), | ||
| 392 | Key::Char('+') | Key::Char('=') => self.command(Command::AdjustBet(10)), | ||
| 393 | Key::Char('-') => self.command(Command::AdjustBet(-10)), | ||
| 394 | _ => Vec::new(), | ||
| 395 | } | ||
| 396 | } | ||
| 397 | |||
| 398 | fn spin(&mut self) -> Vec<Effect> { | 314 | fn spin(&mut self) -> Vec<Effect> { |
| 399 | let Some(request) = self.game.spin_request() else { | 315 | let Some(request) = self.game.spin_request() else { |
| 400 | return Vec::new(); | 316 | return Vec::new(); |
| @@ -409,23 +325,6 @@ impl ClientCore { | |||
| 409 | effects | 325 | effects |
| 410 | } | 326 | } |
| 411 | 327 | ||
| 412 | fn handle_roulette_key(&mut self, key: Key) -> Vec<Effect> { | ||
| 413 | match key { | ||
| 414 | Key::CtrlC => vec![Effect::Quit], | ||
| 415 | Key::Up => self.command(Command::MoveCursor(Direction::Up)), | ||
| 416 | Key::Down => self.command(Command::MoveCursor(Direction::Down)), | ||
| 417 | Key::Left => self.command(Command::MoveCursor(Direction::Left)), | ||
| 418 | Key::Right => self.command(Command::MoveCursor(Direction::Right)), | ||
| 419 | Key::Enter => self.command(Command::PlaceBet), | ||
| 420 | Key::Char(' ') => self.command(Command::Spin), | ||
| 421 | Key::Char('c') => self.command(Command::ClearBets), | ||
| 422 | Key::Char('+') | Key::Char('=') => self.command(Command::AdjustChip(10)), | ||
| 423 | Key::Char('-') => self.command(Command::AdjustChip(-10)), | ||
| 424 | Key::Char('q') => self.command(Command::Leave), | ||
| 425 | _ => Vec::new(), | ||
| 426 | } | ||
| 427 | } | ||
| 428 | |||
| 429 | /// Apply a semantic command. Both frontends drive the core through this. | 328 | /// Apply a semantic command. Both frontends drive the core through this. |
| 430 | pub fn command(&mut self, command: Command) -> Vec<Effect> { | 329 | pub fn command(&mut self, command: Command) -> Vec<Effect> { |
| 431 | let game = self.current_game_type().map(str::to_string); | 330 | let game = self.current_game_type().map(str::to_string); |
| @@ -523,7 +422,7 @@ impl ClientCore { | |||
| 523 | } | 422 | } |
| 524 | } | 423 | } |
| 525 | 424 | ||
| 526 | fn current_game_type(&self) -> Option<&str> { | 425 | pub fn current_game_type(&self) -> Option<&str> { |
| 527 | self.game | 426 | self.game |
| 528 | .get_current_state() | 427 | .get_current_state() |
| 529 | .map(|state| state.game_type.as_str()) | 428 | .map(|state| state.game_type.as_str()) |
| @@ -628,36 +527,6 @@ impl ClientCore { | |||
| 628 | effects | 527 | effects |
| 629 | } | 528 | } |
| 630 | 529 | ||
| 631 | fn handle_blackjack_key(&mut self, key: Key) -> Vec<Effect> { | ||
| 632 | let status = self | ||
| 633 | .game | ||
| 634 | .get_current_state() | ||
| 635 | .and_then(|state| state.data.get("status")) | ||
| 636 | .cloned() | ||
| 637 | .unwrap_or_else(|| "ready".to_string()); | ||
| 638 | let hand = self.game.blackjack_state(); | ||
| 639 | let player = hand.as_ref().is_some_and(|hand| hand.phase == "player"); | ||
| 640 | let can_double = hand.as_ref().is_some_and(|hand| { | ||
| 641 | hand.phase == "player" && hand.actions.iter().any(|action| action == "double") | ||
| 642 | }); | ||
| 643 | |||
| 644 | match key { | ||
| 645 | Key::CtrlC => vec![Effect::Quit], | ||
| 646 | Key::Char('q') => self.command(Command::Leave), | ||
| 647 | Key::Char(' ') => self.command(Command::Deal), | ||
| 648 | Key::Char('h') if player => self.command(Command::Hit), | ||
| 649 | Key::Char('s') if player => self.command(Command::Stand), | ||
| 650 | Key::Char('d') if can_double => self.command(Command::Double), | ||
| 651 | Key::Char('+') | Key::Char('=') if matches!(status.as_str(), "ready" | "done") => { | ||
| 652 | self.command(Command::AdjustBet(10)) | ||
| 653 | } | ||
| 654 | Key::Char('-') if matches!(status.as_str(), "ready" | "done") => { | ||
| 655 | self.command(Command::AdjustBet(-10)) | ||
| 656 | } | ||
| 657 | _ => Vec::new(), | ||
| 658 | } | ||
| 659 | } | ||
| 660 | |||
| 661 | fn start_blackjack(&mut self) -> Vec<Effect> { | 530 | fn start_blackjack(&mut self) -> Vec<Effect> { |
| 662 | let status = self | 531 | let status = self |
| 663 | .game | 532 | .game |
| @@ -768,25 +637,6 @@ impl ClientCore { | |||
| 768 | self.board_effects() | 637 | self.board_effects() |
| 769 | } | 638 | } |
| 770 | 639 | ||
| 771 | fn handle_crash_key(&mut self, key: Key) -> Vec<Effect> { | ||
| 772 | let status = self.game.crash_status(); | ||
| 773 | let running = status == "running"; | ||
| 774 | |||
| 775 | match key { | ||
| 776 | Key::CtrlC => vec![Effect::Quit], | ||
| 777 | Key::Char('q') => self.command(Command::Leave), | ||
| 778 | Key::Char(' ') if !running && self.pending.is_none() => self.command(Command::Start), | ||
| 779 | Key::Char('c') if running && self.pending.is_none() => { | ||
| 780 | self.command(Command::CashOut) | ||
| 781 | } | ||
| 782 | Key::Char('+') | Key::Char('=') if !running => { | ||
| 783 | self.command(Command::AdjustBet(10)) | ||
| 784 | } | ||
| 785 | Key::Char('-') if !running => self.command(Command::AdjustBet(-10)), | ||
| 786 | _ => Vec::new(), | ||
| 787 | } | ||
| 788 | } | ||
| 789 | |||
| 790 | fn start_crash(&mut self) -> Vec<Effect> { | 640 | fn start_crash(&mut self) -> Vec<Effect> { |
| 791 | let status = self.game.crash_status(); | 641 | let status = self.game.crash_status(); |
| 792 | if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { | 642 | if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { |
| @@ -822,16 +672,14 @@ impl ClientCore { | |||
| 822 | vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] | 672 | vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] |
| 823 | } | 673 | } |
| 824 | 674 | ||
| 825 | fn submit(&mut self) -> Vec<Effect> { | 675 | /// Run one command line. The interactive frontend owns the buffer and the |
| 826 | let line = self.input.trim().to_string(); | 676 | /// history; the core only sees the finished line. |
| 827 | self.input.clear(); | 677 | pub fn submit_line(&mut self, line: &str) -> Vec<Effect> { |
| 678 | let line = line.trim().to_string(); | ||
| 828 | if line.is_empty() { | 679 | if line.is_empty() { |
| 829 | return Vec::new(); | 680 | return Vec::new(); |
| 830 | } | 681 | } |
| 831 | 682 | ||
| 832 | record_history(&mut self.history, &line); | ||
| 833 | self.history_index = self.history.len(); | ||
| 834 | |||
| 835 | let parts: Vec<&str> = line.splitn(3, ' ').collect(); | 683 | let parts: Vec<&str> = line.splitn(3, ' ').collect(); |
| 836 | match parts[0] { | 684 | match parts[0] { |
| 837 | "play" => self.cmd_play(&parts), | 685 | "play" => self.cmd_play(&parts), |
| @@ -1047,47 +895,6 @@ impl ClientCore { | |||
| 1047 | effects | 895 | effects |
| 1048 | } | 896 | } |
| 1049 | 897 | ||
| 1050 | fn history_previous(&mut self) -> Vec<Effect> { | ||
| 1051 | if self.history_index == 0 { | ||
| 1052 | return Vec::new(); | ||
| 1053 | } | ||
| 1054 | self.history_index -= 1; | ||
| 1055 | self.input = self.history[self.history_index].clone(); | ||
| 1056 | self.prompt_effects() | ||
| 1057 | } | ||
| 1058 | |||
| 1059 | fn history_next(&mut self) -> Vec<Effect> { | ||
| 1060 | if self.history_index >= self.history.len() { | ||
| 1061 | return Vec::new(); | ||
| 1062 | } | ||
| 1063 | self.history_index += 1; | ||
| 1064 | self.input = if self.history_index == self.history.len() { | ||
| 1065 | String::new() | ||
| 1066 | } else { | ||
| 1067 | self.history[self.history_index].clone() | ||
| 1068 | }; | ||
| 1069 | self.prompt_effects() | ||
| 1070 | } | ||
| 1071 | |||
| 1072 | fn complete(&mut self) -> Vec<Effect> { | ||
| 1073 | let prefix = self.input.trim(); | ||
| 1074 | let matches: Vec<&'static str> = command_names() | ||
| 1075 | .into_iter() | ||
| 1076 | .filter(|command| command.starts_with(prefix)) | ||
| 1077 | .collect(); | ||
| 1078 | |||
| 1079 | let mut effects = Vec::new(); | ||
| 1080 | if matches.len() == 1 { | ||
| 1081 | self.input = matches[0].to_string(); | ||
| 1082 | } else if matches.len() > 1 { | ||
| 1083 | effects.push(Effect::Print(completion_lines(&matches, 76))); | ||
| 1084 | } | ||
| 1085 | if let Some(prompt) = self.prompt_effect() { | ||
| 1086 | effects.push(prompt); | ||
| 1087 | } | ||
| 1088 | effects | ||
| 1089 | } | ||
| 1090 | |||
| 1091 | /// Snapshot of the current screen for a frontend to draw. | 898 | /// Snapshot of the current screen for a frontend to draw. |
| 1092 | pub fn view(&self) -> Option<View> { | 899 | pub fn view(&self) -> Option<View> { |
| 1093 | if let Some(state) = self.game.get_current_state() { | 900 | if let Some(state) = self.game.get_current_state() { |
| @@ -1126,9 +933,6 @@ impl ClientCore { | |||
| 1126 | self.view().map(Effect::Render).into_iter().collect() | 933 | self.view().map(Effect::Render).into_iter().collect() |
| 1127 | } | 934 | } |
| 1128 | 935 | ||
| 1129 | fn prompt_effects(&self) -> Vec<Effect> { | ||
| 1130 | self.prompt_effect().into_iter().collect() | ||
| 1131 | } | ||
| 1132 | } | 936 | } |
| 1133 | 937 | ||
| 1134 | fn auth_result(reply: &str) -> Result<(), String> { | 938 | fn auth_result(reply: &str) -> Result<(), String> { |
diff --git a/src/client/input.rs b/src/client/input.rs new file mode 100644 index 0000000..0c9d99d --- /dev/null +++ b/src/client/input.rs | |||
| @@ -0,0 +1,183 @@ | |||
| 1 | //! Terminal input for the adapter: the command-line editor and key mapping. | ||
| 2 | //! | ||
| 3 | //! `ClientCore` knows nothing about keys or the prompt; this module turns | ||
| 4 | //! keystrokes into `Command`s or a submitted line. | ||
| 5 | |||
| 6 | use crate::client::games::roulette::Direction; | ||
| 7 | use crate::client::ui::{ | ||
| 8 | command_names, completion_lines, display_width, input_cursor_column, record_history, | ||
| 9 | }; | ||
| 10 | use crate::client::view::Command; | ||
| 11 | |||
| 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 13 | pub enum Key { | ||
| 14 | Char(char), | ||
| 15 | Enter, | ||
| 16 | Backspace, | ||
| 17 | Up, | ||
| 18 | Down, | ||
| 19 | Left, | ||
| 20 | Right, | ||
| 21 | Tab, | ||
| 22 | Esc, | ||
| 23 | CtrlC, | ||
| 24 | } | ||
| 25 | |||
| 26 | /// What the prompt wants the adapter to do after a key. | ||
| 27 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 28 | pub enum Input { | ||
| 29 | /// Nothing changed. | ||
| 30 | None, | ||
| 31 | /// Redraw the prompt. | ||
| 32 | Prompt, | ||
| 33 | /// Run this command line. | ||
| 34 | Submit(String), | ||
| 35 | /// Print lines above the prompt. | ||
| 36 | Print(Vec<String>), | ||
| 37 | Quit, | ||
| 38 | } | ||
| 39 | |||
| 40 | /// What a key means inside a running game. | ||
| 41 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 42 | pub enum GameInput { | ||
| 43 | None, | ||
| 44 | Quit, | ||
| 45 | Command(Command), | ||
| 46 | } | ||
| 47 | |||
| 48 | /// The prompt buffer, its history and its cursor. | ||
| 49 | pub struct Editor { | ||
| 50 | input: String, | ||
| 51 | history: Vec<String>, | ||
| 52 | history_index: usize, | ||
| 53 | } | ||
| 54 | |||
| 55 | impl Default for Editor { | ||
| 56 | fn default() -> Self { | ||
| 57 | Self::new() | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | impl Editor { | ||
| 62 | pub fn new() -> Self { | ||
| 63 | Self { | ||
| 64 | input: String::new(), | ||
| 65 | history: Vec::new(), | ||
| 66 | history_index: 0, | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | pub fn input(&self) -> &str { | ||
| 71 | &self.input | ||
| 72 | } | ||
| 73 | |||
| 74 | pub fn clear(&mut self) { | ||
| 75 | self.input.clear(); | ||
| 76 | } | ||
| 77 | |||
| 78 | /// Column of the cursor after the `> ` prompt. | ||
| 79 | pub fn cursor_column(&self) -> u16 { | ||
| 80 | input_cursor_column(2, display_width(&self.input)) | ||
| 81 | } | ||
| 82 | |||
| 83 | pub fn handle(&mut self, key: Key) -> Input { | ||
| 84 | match key { | ||
| 85 | Key::CtrlC => Input::Quit, | ||
| 86 | Key::Enter => { | ||
| 87 | let line = self.input.trim().to_string(); | ||
| 88 | if line.is_empty() { | ||
| 89 | return Input::None; | ||
| 90 | } | ||
| 91 | record_history(&mut self.history, &line); | ||
| 92 | self.history_index = self.history.len(); | ||
| 93 | self.input.clear(); | ||
| 94 | Input::Submit(line) | ||
| 95 | } | ||
| 96 | Key::Backspace => { | ||
| 97 | self.input.pop(); | ||
| 98 | Input::Prompt | ||
| 99 | } | ||
| 100 | Key::Char(c) => { | ||
| 101 | self.input.push(c); | ||
| 102 | Input::Prompt | ||
| 103 | } | ||
| 104 | Key::Up => { | ||
| 105 | if self.history_index > 0 { | ||
| 106 | self.history_index -= 1; | ||
| 107 | self.input = self.history[self.history_index].clone(); | ||
| 108 | } | ||
| 109 | Input::Prompt | ||
| 110 | } | ||
| 111 | Key::Down => { | ||
| 112 | if self.history_index < self.history.len() { | ||
| 113 | self.history_index += 1; | ||
| 114 | self.input = if self.history_index == self.history.len() { | ||
| 115 | String::new() | ||
| 116 | } else { | ||
| 117 | self.history[self.history_index].clone() | ||
| 118 | }; | ||
| 119 | } | ||
| 120 | Input::Prompt | ||
| 121 | } | ||
| 122 | Key::Tab => { | ||
| 123 | let prefix = self.input.trim(); | ||
| 124 | let matches: Vec<&'static str> = command_names() | ||
| 125 | .into_iter() | ||
| 126 | .filter(|command| command.starts_with(prefix)) | ||
| 127 | .collect(); | ||
| 128 | match matches.len() { | ||
| 129 | 1 => { | ||
| 130 | self.input = matches[0].to_string(); | ||
| 131 | Input::Prompt | ||
| 132 | } | ||
| 133 | 0 => Input::None, | ||
| 134 | _ => Input::Print(completion_lines(&matches, 76)), | ||
| 135 | } | ||
| 136 | } | ||
| 137 | // Esc, Left and Right do nothing on the prompt line. | ||
| 138 | Key::Esc | Key::Left | Key::Right => Input::None, | ||
| 139 | } | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | /// Map a key to a command inside `game`. | ||
| 144 | /// | ||
| 145 | /// Invalid combinations simply produce a command the core ignores (for | ||
| 146 | /// example `h` when it is not the player's turn), so this stays stateless. | ||
| 147 | pub fn game_input(game: &str, key: Key) -> GameInput { | ||
| 148 | match key { | ||
| 149 | Key::CtrlC => GameInput::Quit, | ||
| 150 | Key::Char('q') => GameInput::Command(Command::Leave), | ||
| 151 | Key::Char('+') | Key::Char('=') => GameInput::Command(Command::AdjustBet(10)), | ||
| 152 | Key::Char('-') => GameInput::Command(Command::AdjustBet(-10)), | ||
| 153 | _ => match game { | ||
| 154 | "roulette" => match key { | ||
| 155 | Key::Up => GameInput::Command(Command::MoveCursor(Direction::Up)), | ||
| 156 | Key::Down => GameInput::Command(Command::MoveCursor(Direction::Down)), | ||
| 157 | Key::Left => GameInput::Command(Command::MoveCursor(Direction::Left)), | ||
| 158 | Key::Right => GameInput::Command(Command::MoveCursor(Direction::Right)), | ||
| 159 | Key::Enter => GameInput::Command(Command::PlaceBet), | ||
| 160 | Key::Char(' ') => GameInput::Command(Command::Spin), | ||
| 161 | Key::Char('c') => GameInput::Command(Command::ClearBets), | ||
| 162 | _ => GameInput::None, | ||
| 163 | }, | ||
| 164 | "blackjack" => match key { | ||
| 165 | Key::Char(' ') => GameInput::Command(Command::Deal), | ||
| 166 | Key::Char('h') => GameInput::Command(Command::Hit), | ||
| 167 | Key::Char('s') => GameInput::Command(Command::Stand), | ||
| 168 | Key::Char('d') => GameInput::Command(Command::Double), | ||
| 169 | _ => GameInput::None, | ||
| 170 | }, | ||
| 171 | "crash" => match key { | ||
| 172 | Key::Char(' ') => GameInput::Command(Command::Start), | ||
| 173 | Key::Char('c') => GameInput::Command(Command::CashOut), | ||
| 174 | _ => GameInput::None, | ||
| 175 | }, | ||
| 176 | _ => match key { | ||
| 177 | Key::Char(' ') => GameInput::Command(Command::Spin), | ||
| 178 | Key::Esc => GameInput::Command(Command::Leave), | ||
| 179 | _ => GameInput::None, | ||
| 180 | }, | ||
| 181 | }, | ||
| 182 | } | ||
| 183 | } | ||
diff --git a/src/client/mod.rs b/src/client/mod.rs index 456faa0..1d79d97 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | pub mod core; | 1 | pub mod core; |
| 2 | pub mod database; | 2 | pub mod database; |
| 3 | pub mod games; | 3 | pub mod games; |
| 4 | pub mod input; | ||
| 4 | pub mod paths; | 5 | pub mod paths; |
| 5 | pub mod transport; | 6 | pub mod transport; |
| 6 | pub mod tui; | 7 | pub mod tui; |
diff --git a/tests/client_core.rs b/tests/client_core.rs index 53a024c..722cf0d 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs | |||
| @@ -6,7 +6,8 @@ | |||
| 6 | 6 | ||
| 7 | use std::time::Duration; | 7 | use std::time::Duration; |
| 8 | use zagan_bet::blackjack::BlackjackState; | 8 | use zagan_bet::blackjack::BlackjackState; |
| 9 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; | 9 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand}; |
| 10 | use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; | ||
| 10 | use zagan_bet::client::games::response::{ | 11 | use zagan_bet::client::games::response::{ |
| 11 | parse_blackjack_response, parse_crash_response, parse_spin_response, | 12 | parse_blackjack_response, parse_crash_response, parse_spin_response, |
| 12 | }; | 13 | }; |
| @@ -66,10 +67,6 @@ impl Screen { | |||
| 66 | self.lines.extend(lines.iter().cloned()); | 67 | self.lines.extend(lines.iter().cloned()); |
| 67 | self.region_len = lines.len(); | 68 | self.region_len = lines.len(); |
| 68 | } | 69 | } |
| 69 | Effect::SetPrompt { text, cursor_col } => { | ||
| 70 | self.prompt = text.clone(); | ||
| 71 | self.prompt_cursor = *cursor_col; | ||
| 72 | } | ||
| 73 | Effect::SendLine(line) => sent.push(line.clone()), | 70 | Effect::SendLine(line) => sent.push(line.clone()), |
| 74 | Effect::Interactive(command) => interactive.push(command.clone()), | 71 | Effect::Interactive(command) => interactive.push(command.clone()), |
| 75 | Effect::Quit | Effect::Disconnect | Effect::ListHosts | Effect::ForgetHost(_) => {} | 72 | Effect::Quit | Effect::Disconnect | Effect::ListHosts | Effect::ForgetHost(_) => {} |
| @@ -87,6 +84,7 @@ impl Screen { | |||
| 87 | 84 | ||
| 88 | struct Harness { | 85 | struct Harness { |
| 89 | core: ClientCore, | 86 | core: ClientCore, |
| 87 | editor: Editor, | ||
| 90 | screen: Screen, | 88 | screen: Screen, |
| 91 | sent: Vec<String>, | 89 | sent: Vec<String>, |
| 92 | interactive: Vec<InteractiveCommand>, | 90 | interactive: Vec<InteractiveCommand>, |
| @@ -99,6 +97,7 @@ impl Harness { | |||
| 99 | core.set_logged_in(Some("alice".to_string())); | 97 | core.set_logged_in(Some("alice".to_string())); |
| 100 | Self { | 98 | Self { |
| 101 | core, | 99 | core, |
| 100 | editor: Editor::new(), | ||
| 102 | screen: Screen::default(), | 101 | screen: Screen::default(), |
| 103 | sent: Vec::new(), | 102 | sent: Vec::new(), |
| 104 | interactive: Vec::new(), | 103 | interactive: Vec::new(), |
| @@ -111,11 +110,30 @@ impl Harness { | |||
| 111 | } | 110 | } |
| 112 | 111 | ||
| 113 | fn key(&mut self, key: Key) { | 112 | fn key(&mut self, key: Key) { |
| 114 | let effects = self.core.handle_key(key); | 113 | let effects = if self.core.is_in_game() { |
| 114 | match game_input(self.core.current_game_type().unwrap_or(""), key) { | ||
| 115 | GameInput::Quit => vec![Effect::Quit], | ||
| 116 | GameInput::Command(command) => self.core.command(command), | ||
| 117 | GameInput::None => Vec::new(), | ||
| 118 | } | ||
| 119 | } else { | ||
| 120 | match self.editor.handle(key) { | ||
| 121 | Input::Quit => vec![Effect::Quit], | ||
| 122 | Input::Submit(line) => self.core.submit_line(&line), | ||
| 123 | Input::Print(lines) => vec![Effect::Print(lines)], | ||
| 124 | Input::Prompt | Input::None => Vec::new(), | ||
| 125 | } | ||
| 126 | }; | ||
| 115 | self.apply(effects); | 127 | self.apply(effects); |
| 116 | if let Some(prompt) = self.core.prompt_effect() { | 128 | self.apply_prompt(); |
| 117 | self.apply(vec![prompt]); | 129 | } |
| 130 | |||
| 131 | fn apply_prompt(&mut self) { | ||
| 132 | if self.core.is_in_game() { | ||
| 133 | return; | ||
| 118 | } | 134 | } |
| 135 | self.screen.prompt = self.editor.input().to_string(); | ||
| 136 | self.screen.prompt_cursor = self.editor.cursor_column(); | ||
| 119 | } | 137 | } |
| 120 | 138 | ||
| 121 | fn line(&mut self, line: &str) { | 139 | fn line(&mut self, line: &str) { |
| @@ -128,9 +146,7 @@ impl Harness { | |||
| 128 | fn reply(&mut self, reply: &str) { | 146 | fn reply(&mut self, reply: &str) { |
| 129 | let effects = self.core.handle_reply(reply); | 147 | let effects = self.core.handle_reply(reply); |
| 130 | self.apply(effects); | 148 | self.apply(effects); |
| 131 | if let Some(prompt) = self.core.prompt_effect() { | 149 | self.apply_prompt(); |
| 132 | self.apply(vec![prompt]); | ||
| 133 | } | ||
| 134 | } | 150 | } |
| 135 | 151 | ||
| 136 | fn board(&self) -> &[String] { | 152 | fn board(&self) -> &[String] { |
| @@ -469,16 +485,15 @@ fn play_requires_a_connection_and_a_login() { | |||
| 469 | 485 | ||
| 470 | #[test] | 486 | #[test] |
| 471 | fn ctrl_c_quits_from_the_prompt() { | 487 | fn ctrl_c_quits_from_the_prompt() { |
| 472 | let mut core = ClientCore::new(); | 488 | let mut editor = Editor::new(); |
| 473 | assert_eq!(core.handle_key(Key::CtrlC), vec![Effect::Quit]); | 489 | assert_eq!(editor.handle(Key::CtrlC), Input::Quit); |
| 474 | } | 490 | } |
| 475 | 491 | ||
| 476 | #[test] | 492 | #[test] |
| 477 | fn ctrl_c_quits_from_a_game() { | 493 | fn ctrl_c_quits_from_a_game() { |
| 478 | let mut h = Harness::new(); | 494 | assert_eq!(game_input("slots", Key::CtrlC), GameInput::Quit); |
| 479 | h.enter_slots("50"); | 495 | assert_eq!(game_input("roulette", Key::CtrlC), GameInput::Quit); |
| 480 | 496 | assert_eq!(game_input("blackjack", Key::CtrlC), GameInput::Quit); | |
| 481 | assert_eq!(h.core.handle_key(Key::CtrlC), vec![Effect::Quit]); | ||
| 482 | } | 497 | } |
| 483 | 498 | ||
| 484 | #[test] | 499 | #[test] |
