summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 19:00:04 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 19:00:04 +0100
commitd251b54fe40f6f8f95261b4df65bced2fb728c63 (patch)
tree11a4ddffd68f1f497498d36096435a56d9f13c1b
parentv4.0.5: login and registration as core commands (diff)
downloadzagan-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.md2
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GUI-PLAN.md9
-rw-r--r--src/bin/client/repl.rs37
-rw-r--r--src/client/core.rs208
-rw-r--r--src/client/input.rs183
-rw-r--r--src/client/mod.rs1
-rw-r--r--tests/client_core.rs49
8 files changed, 261 insertions, 230 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 72def0d..efa8b74 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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]`.
diff --git a/Cargo.toml b/Cargo.toml
index bb7bfc2..ed5d306 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "4.0.5" 3version = "4.0.6"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "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.
31Done so far: every game plus `Lobby` and `History` are structured views; the 31Done so far: every game plus `Lobby` and `History` are structured views; the
32client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in 32client 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) 35editor and key mapping moved to `client::input`; `ClientCore` no longer knows
36and removing `Key` from the core. The TUI's interactive login still uses its 36keys, the prompt or its history. Remaining: the `Connect` and `Trust` adapter
37own prompt and should move to `Command::Login` when that prompt moves out. 37screens, and replacing the last TUI-shaped effects (`Interactive`, host
38listing, 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
14use sha2::{Digest, Sha256}; 14use sha2::{Digest, Sha256};
15 15
16use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; 16use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand};
17use zagan_bet::client::input::{game_input, GameInput, Input, Key, Editor};
17use zagan_bet::client::database; 18use zagan_bet::client::database;
18use zagan_bet::client::ui::{ 19use 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};
6use crate::client::games::roulette::Direction;
7use crate::client::view::{ 6use crate::client::view::{
8 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, 7 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View,
9}; 8};
10use crate::client::ui::{ 9use 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};
14use crate::crash::{CrashState, multiplier_at}; 10use crate::crash::{CrashState, multiplier_at};
15use crate::history::{PlayRecord, MAX_HISTORY}; 11use crate::history::{PlayRecord, MAX_HISTORY};
16use crate::roulette::{BetTarget, Bets}; 12use crate::roulette::{BetTarget, Bets};
17use std::time::{Duration, Instant}; 13use std::time::{Duration, Instant};
18 14
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub 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)]
34pub enum InteractiveCommand { 17pub 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
93pub struct ClientCore { 74pub 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 {
114impl ClientCore { 92impl 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
1134fn auth_result(reply: &str) -> Result<(), String> { 938fn 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
6use crate::client::games::roulette::Direction;
7use crate::client::ui::{
8 command_names, completion_lines, display_width, input_cursor_column, record_history,
9};
10use crate::client::view::Command;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub 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)]
28pub 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)]
42pub enum GameInput {
43 None,
44 Quit,
45 Command(Command),
46}
47
48/// The prompt buffer, its history and its cursor.
49pub struct Editor {
50 input: String,
51 history: Vec<String>,
52 history_index: usize,
53}
54
55impl Default for Editor {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61impl 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.
147pub 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 @@
1pub mod core; 1pub mod core;
2pub mod database; 2pub mod database;
3pub mod games; 3pub mod games;
4pub mod input;
4pub mod paths; 5pub mod paths;
5pub mod transport; 6pub mod transport;
6pub mod tui; 7pub 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
7use std::time::Duration; 7use std::time::Duration;
8use zagan_bet::blackjack::BlackjackState; 8use zagan_bet::blackjack::BlackjackState;
9use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; 9use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand};
10use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key};
10use zagan_bet::client::games::response::{ 11use 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
88struct Harness { 85struct 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]
471fn ctrl_c_quits_from_the_prompt() { 487fn 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]
477fn ctrl_c_quits_from_a_game() { 493fn 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]