chess_tui/handler.rs
1use crate::constants::Popups;
2use crate::game_logic::coord::Coord;
3use crate::game_logic::game::GameState;
4use crate::utils::{flip_square_if_needed, get_coord_from_square};
5use crate::{
6 app::{App, AppResult},
7 constants::Pages,
8};
9use ratatui::crossterm::event::{
10 KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
11};
12use shakmaty::san::San;
13use shakmaty::{Role, Square};
14
15/// Handles keyboard input events and updates the application state accordingly.
16///
17/// This is the main entry point for all keyboard interactions. It filters out
18/// non-press events, handles mouse-to-keyboard transitions, and routes events
19/// to either popup handlers or page handlers based on the current application state.
20pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> {
21 // Only process key press events, ignore release and repeat events
22 // (crossterm on Windows sends Release and Repeat events as well)
23 if key_event.kind != KeyEventKind::Press {
24 return Ok(());
25 }
26
27 // Reset cursor position after mouse interaction when keyboard is used
28 // This ensures the cursor is in a valid position for keyboard navigation
29 if app.game.ui.mouse_used {
30 app.game.ui.mouse_used = false;
31 if let Some(selected_square) = app.game.ui.selected_square {
32 // If a piece was selected via mouse, move cursor to that square
33 app.game.ui.cursor_coordinates =
34 get_coord_from_square(Some(selected_square), app.game.logic.game_board.is_flipped);
35 app.game.ui.selected_square = None;
36 } else {
37 // Otherwise, reset cursor to center of board (e4/e5)
38 app.game.ui.cursor_coordinates.col = 4;
39 app.game.ui.cursor_coordinates.row = 4;
40 }
41 }
42
43 // If a popup is active, the key should affect the popup and not the page,
44 // therefore, if there is some popup active, handle it, if not, handle the page event
45 match app.current_popup {
46 Some(popup) => handle_popup_input(app, key_event, popup),
47 None => handle_page_input(app, key_event),
48 }
49
50 Ok(())
51}
52
53/// Handles keyboard input when a popup is active.
54///
55/// Popups take priority over page input, so all keyboard events are routed here
56/// when a popup is displayed. Each popup type has its own set of key bindings.
57fn handle_popup_input(app: &mut App, key_event: KeyEvent, popup: Popups) {
58 match popup {
59 // Popup for entering the host IP address when joining a multiplayer game
60 Popups::EnterHostIP => match key_event.code {
61 KeyCode::Enter => {
62 // Submit the entered IP address and store it
63 app.game.ui.prompt.submit_message();
64 if app.current_page == Pages::Multiplayer {
65 app.host_ip = Some(app.game.ui.prompt.message.clone());
66 }
67 app.current_popup = None;
68 }
69 KeyCode::Char(to_insert) => app.game.ui.prompt.enter_char(to_insert),
70 KeyCode::Backspace => app.game.ui.prompt.delete_char(),
71 KeyCode::Left => app.game.ui.prompt.move_cursor_left(),
72 KeyCode::Right => app.game.ui.prompt.move_cursor_right(),
73 KeyCode::Esc => {
74 // Cancel IP entry and return to home menu, resetting multiplayer state
75 app.current_popup = None;
76 if app.current_page == Pages::Multiplayer {
77 app.hosting = None;
78 app.selected_color = None;
79 app.menu_cursor = 0;
80 }
81 app.current_page = Pages::Home;
82 }
83 _ => fallback_key_handler(app, key_event),
84 },
85 // Help popup - shows game controls and key bindings
86 Popups::Help => match key_event.code {
87 KeyCode::Char('?') => app.toggle_help_popup(),
88 KeyCode::Esc => app.toggle_help_popup(),
89 _ => fallback_key_handler(app, key_event),
90 },
91 // Multiplayer selection popup - choose to host or join a game
92 Popups::MultiplayerSelection => match key_event.code {
93 KeyCode::Esc => app.close_popup_and_go_home(),
94 KeyCode::Right | KeyCode::Char('l') => app.menu_cursor_right(2),
95 KeyCode::Left | KeyCode::Char('h') => app.menu_cursor_left(2),
96 KeyCode::Char(' ') | KeyCode::Enter => app.hosting_selection(),
97 _ => fallback_key_handler(app, key_event),
98 },
99 Popups::EnginePathError => match key_event.code {
100 KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' ') => app.close_popup_and_go_home(),
101 _ => fallback_key_handler(app, key_event),
102 },
103 Popups::WaitingForOpponentToJoin => match key_event.code {
104 KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' ') => {
105 app.close_popup_and_go_home();
106 app.cancel_hosting_cleanup();
107 }
108 _ => fallback_key_handler(app, key_event),
109 },
110 // End screen popup - shown when game ends (checkmate or draw)
111 Popups::EndScreen => match key_event.code {
112 KeyCode::Char('h' | 'H') => {
113 // Hide the end screen (can be toggled back with H when not in popup)
114 app.current_popup = None;
115 app.end_screen_dismissed = true;
116 }
117 KeyCode::Esc => {
118 // Also allow Esc to hide the end screen and mark as dismissed
119 app.current_popup = None;
120 app.end_screen_dismissed = true;
121 }
122 KeyCode::Char('r' | 'R') => {
123 // Restart the game (only for non-multiplayer games)
124 if app.game.logic.opponent.is_none() {
125 app.restart();
126 app.current_popup = None;
127 }
128 }
129 KeyCode::Char('b' | 'B') => {
130 // Go back to home menu - completely reset all game state
131 app.reset_home();
132 }
133 _ => fallback_key_handler(app, key_event),
134 },
135 // Puzzle end screen popup - shown when puzzle is completed
136 Popups::PuzzleEndScreen => match key_event.code {
137 KeyCode::Char('h' | 'H') => {
138 // Hide the puzzle end screen
139 app.current_popup = None;
140 }
141 KeyCode::Esc => {
142 // Also allow Esc to hide the puzzle end screen
143 app.current_popup = None;
144 }
145 KeyCode::Char('n' | 'N') => {
146 // Start a new puzzle
147 app.current_popup = None;
148 app.start_puzzle_mode();
149 }
150 KeyCode::Char('b' | 'B') => {
151 // Go back to home menu - completely reset all game state
152 app.reset_home();
153 }
154 _ => fallback_key_handler(app, key_event),
155 },
156 // Error popup - displays error messages
157 Popups::Error => match key_event.code {
158 KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' ') => {
159 app.current_popup = None;
160 app.error_message = None;
161 // Navigate back to an appropriate page based on current context
162 match app.current_page {
163 Pages::Lichess | Pages::OngoingGames => {
164 // If we're on Lichess-related pages, go back to Lichess menu
165 app.current_page = Pages::LichessMenu;
166 }
167 Pages::Multiplayer | Pages::Bot => {
168 // If we're on multiplayer or bot page, go back to home
169 app.current_page = Pages::Home;
170 }
171 _ => {
172 // For other pages, stay on current page or go to home
173 // Only change if we're in a weird state
174 if app.current_page == Pages::Solo && app.game.logic.opponent.is_some() {
175 // If we're in solo but have an opponent (shouldn't happen), reset
176 app.current_page = Pages::Home;
177 }
178 }
179 }
180 }
181 _ => fallback_key_handler(app, key_event),
182 },
183 // Success popup - displays success messages
184 Popups::Success => match key_event.code {
185 KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' ') => {
186 app.current_popup = None;
187 app.error_message = None;
188 // Navigate back to an appropriate page based on current context
189 match app.current_page {
190 Pages::Lichess | Pages::OngoingGames => {
191 // If we're on Lichess-related pages, go back to Lichess menu
192 app.current_page = Pages::LichessMenu;
193 }
194 Pages::Multiplayer | Pages::Bot => {
195 // If we're on multiplayer or bot page, go back to home
196 app.current_page = Pages::Home;
197 }
198 _ => {
199 // For other pages, stay on current page or go to home
200 // Only change if we're in a weird state
201 if app.current_page == Pages::Solo && app.game.logic.opponent.is_some() {
202 // If we're in solo but have an opponent (shouldn't happen), reset
203 app.current_page = Pages::Home;
204 }
205 }
206 }
207 }
208 _ => fallback_key_handler(app, key_event),
209 },
210 Popups::EnterGameCode => match key_event.code {
211 KeyCode::Enter => {
212 // Submit the entered game code
213 app.game.ui.prompt.submit_message();
214 let game_code = app.game.ui.prompt.message.clone();
215
216 if !game_code.is_empty() {
217 // Join the game with the entered code
218 app.current_page = Pages::Lichess;
219 app.join_lichess_game_by_code(game_code);
220 } else {
221 // No code entered, return to menu
222 app.current_popup = None;
223 app.current_page = Pages::LichessMenu;
224 }
225 }
226 KeyCode::Char(to_insert) => app.game.ui.prompt.enter_char(to_insert),
227 KeyCode::Backspace => app.game.ui.prompt.delete_char(),
228 KeyCode::Left => app.game.ui.prompt.move_cursor_left(),
229 KeyCode::Right => app.game.ui.prompt.move_cursor_right(),
230 KeyCode::Esc => {
231 // Cancel game code entry and return to Lichess menu
232 app.current_popup = None;
233 app.current_page = Pages::LichessMenu;
234 }
235 _ => fallback_key_handler(app, key_event),
236 },
237 Popups::EnterLichessToken => match key_event.code {
238 KeyCode::Enter => {
239 // Submit the entered token
240 app.game.ui.prompt.submit_message();
241 let token = app.game.ui.prompt.message.clone().trim().to_string();
242
243 if !token.is_empty() {
244 // Save and validate the token
245 app.save_and_validate_lichess_token(token);
246 } else {
247 // No token entered, return to previous page
248 app.current_popup = None;
249 }
250 }
251 KeyCode::Char(to_insert) => app.game.ui.prompt.enter_char(to_insert),
252 KeyCode::Backspace => app.game.ui.prompt.delete_char(),
253 KeyCode::Left => app.game.ui.prompt.move_cursor_left(),
254 KeyCode::Right => app.game.ui.prompt.move_cursor_right(),
255 KeyCode::Esc => {
256 // Cancel token entry
257 app.current_popup = None;
258 }
259 _ => fallback_key_handler(app, key_event),
260 },
261 Popups::SeekingLichessGame => match key_event.code {
262 KeyCode::Esc => {
263 if let Some(token) = &app.lichess_cancellation_token {
264 token.store(true, std::sync::atomic::Ordering::Relaxed);
265 }
266 app.lichess_seek_receiver = None; // Cancel the receiver (thread continues but result ignored)
267 app.lichess_cancellation_token = None;
268 app.current_popup = None;
269 app.current_page = Pages::Home; // Go back to home
270 }
271 _ => fallback_key_handler(app, key_event),
272 },
273 Popups::ResignConfirmation => match key_event.code {
274 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
275 app.confirm_resign_game();
276 // fetch_ongoing_games() is already called in confirm_resign_game()
277 }
278 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
279 app.current_popup = None;
280 }
281 _ => fallback_key_handler(app, key_event),
282 },
283 Popups::MoveInputSelection => match key_event.code {
284 KeyCode::Enter => {
285 // Submit the entered move
286 app.game.ui.prompt.submit_message();
287 let player_move = app.game.ui.prompt.message.clone().trim().to_string();
288
289 if player_move.is_empty() {
290 app.current_popup = None;
291 return;
292 }
293
294 let san = match San::from_ascii(player_move.as_bytes()) {
295 Ok(san) => san,
296 Err(_) => {
297 app.current_popup = None;
298 return;
299 }
300 };
301
302 let position = app.game.logic.game_board.position_ref().clone();
303
304 let chess_move = match san.to_move(&position) {
305 Ok(chess_move) => chess_move,
306 Err(_) => {
307 app.current_popup = None;
308 return;
309 }
310 };
311
312 let from = match chess_move.from() {
313 Some(from) => from,
314 None => {
315 app.current_popup = None;
316 return;
317 }
318 };
319
320 let to = chess_move.to();
321 let promotion = chess_move.promotion();
322
323 app.game.apply_player_move(from, to, promotion);
324 app.current_popup = None;
325 }
326 KeyCode::Char(to_insert) => app.game.ui.prompt.enter_char(to_insert),
327 KeyCode::Backspace => app.game.ui.prompt.delete_char(),
328 KeyCode::Left => app.game.ui.prompt.move_cursor_left(),
329 KeyCode::Right => app.game.ui.prompt.move_cursor_right(),
330 KeyCode::Esc => {
331 app.current_popup = None;
332 }
333 _ => fallback_key_handler(app, key_event),
334 },
335 };
336}
337
338/// Routes keyboard input to the appropriate page handler based on current page.
339fn handle_page_input(app: &mut App, key_event: KeyEvent) {
340 match &app.current_page {
341 Pages::Home => handle_home_page_events(app, key_event),
342 Pages::Solo => handle_solo_page_events(app, key_event),
343 Pages::Multiplayer => handle_multiplayer_page_events(app, key_event),
344 Pages::Lichess => handle_multiplayer_page_events(app, key_event),
345 Pages::LichessMenu => handle_lichess_menu_page_events(app, key_event),
346 Pages::GameModeMenu => handle_game_mode_menu_page_events(app, key_event),
347 Pages::OngoingGames => handle_ongoing_games_page_events(app, key_event),
348 Pages::Bot => handle_bot_page_events(app, key_event),
349 Pages::Credit => handle_credit_page_events(app, key_event),
350 }
351}
352
353/// Handles keyboard input on the home/menu page.
354/// Supports navigation through menu items and selection.
355fn handle_home_page_events(app: &mut App, key_event: KeyEvent) {
356 // Number of menu items depends on whether sound feature is enabled
357 const MENU_ITEMS: u8 = {
358 #[cfg(feature = "sound")]
359 {
360 6 // Play Game, Lichess, Skin, Sound, Help, About
361 }
362 #[cfg(not(feature = "sound"))]
363 {
364 5 // Play Game, Lichess, Skin, Help, About
365 }
366 };
367
368 match key_event.code {
369 KeyCode::Up | KeyCode::Char('k') => app.menu_cursor_up(MENU_ITEMS),
370 KeyCode::Down | KeyCode::Char('j') => app.menu_cursor_down(MENU_ITEMS),
371 // If on skin selection menu item (index 2), use left/right to cycle skins
372 KeyCode::Left | KeyCode::Char('h') if app.menu_cursor == 2 => {
373 app.cycle_skin_backward();
374 app.update_config();
375 }
376 KeyCode::Right | KeyCode::Char('l') if app.menu_cursor == 2 => {
377 app.cycle_skin();
378 app.update_config();
379 }
380 KeyCode::Char(' ') | KeyCode::Enter => app.menu_select(),
381 KeyCode::Char('?') => app.toggle_help_popup(),
382 _ => fallback_key_handler(app, key_event),
383 }
384}
385
386/// Handles keyboard input during solo (two-player) game mode.
387fn handle_solo_page_events(app: &mut App, key_event: KeyEvent) {
388 match key_event.code {
389 KeyCode::Char('r') => app.restart(), // Restart current game
390 KeyCode::Char('b') => {
391 // Return to home menu - reset all game state
392 app.reset_home();
393 }
394 KeyCode::Char('n' | 'N') => {
395 // In puzzle mode, 'n' is used for new puzzle (handled in popup)
396 // Otherwise, navigate to next position in history
397 if app.puzzle_game.is_none()
398 && app.game.logic.game_state != GameState::Checkmate
399 && app.game.logic.game_state != GameState::Draw
400 {
401 app.navigate_history_next();
402 }
403 }
404 KeyCode::Char('p' | 'P') => {
405 // Navigate to previous position in history (only if game hasn't ended and not in puzzle mode)
406 if app.puzzle_game.is_none()
407 && app.game.logic.game_state != GameState::Checkmate
408 && app.game.logic.game_state != GameState::Draw
409 {
410 app.navigate_history_previous();
411 }
412 }
413 KeyCode::Char('t' | 'T') if app.puzzle_game.is_some() && app.current_popup.is_none() => {
414 // Show hint in puzzle mode (only when no popup is active)
415 app.show_puzzle_hint();
416 }
417 _ => chess_inputs(app, key_event), // Delegate chess-specific inputs
418 }
419}
420
421/// Handles chess-specific keyboard inputs (cursor movement, piece selection, etc.).
422///
423/// This function processes inputs that are common across all game modes (solo, bot, multiplayer).
424/// The behavior varies based on the current game state (Playing, Promotion, Checkmate, Draw).
425fn chess_inputs(app: &mut App, key_event: KeyEvent) {
426 let is_playing = app.game.logic.game_state == GameState::Playing;
427
428 match key_event.code {
429 // Vertical cursor movement (only during active play)
430 KeyCode::Up | KeyCode::Char('k') if is_playing => app.go_up_in_game(),
431 KeyCode::Down | KeyCode::Char('j') if is_playing => app.go_down_in_game(),
432
433 // Horizontal cursor movement - behavior depends on game state
434 KeyCode::Right | KeyCode::Char('l') => match app.game.logic.game_state {
435 GameState::Promotion => {
436 // Always allow promotion cursor movement, regardless of turn or page
437 app.game.ui.cursor_right_promotion();
438 }
439 GameState::Playing => {
440 // In Lichess mode, only allow board cursor movement if it's our turn
441 if app.current_page == Pages::Lichess {
442 if let Some(my_color) = app.selected_color {
443 if app.game.logic.player_turn == my_color {
444 app.go_right_in_game();
445 }
446 }
447 } else {
448 app.go_right_in_game();
449 }
450 }
451 _ => (),
452 },
453 KeyCode::Left | KeyCode::Char('h') => match app.game.logic.game_state {
454 GameState::Promotion => {
455 // Always allow promotion cursor movement, regardless of turn or page
456 app.game.ui.cursor_left_promotion();
457 }
458 GameState::Playing => {
459 // In Lichess mode, only allow board cursor movement if it's our turn
460 if app.current_page == Pages::Lichess {
461 if let Some(my_color) = app.selected_color {
462 if app.game.logic.player_turn == my_color {
463 app.go_left_in_game();
464 }
465 }
466 } else {
467 app.go_left_in_game();
468 }
469 }
470 GameState::Checkmate | GameState::Draw => {
471 // Toggle end screen visibility when game is over
472 // If popup is shown, hide it; if hidden and dismissed, show it again
473 if app.current_popup == Some(Popups::EndScreen) {
474 // Hide the end screen
475 app.current_popup = None;
476 app.end_screen_dismissed = true;
477 } else if app.end_screen_dismissed {
478 // Show the end screen again if it was dismissed (toggle back)
479 app.end_screen_dismissed = false;
480 app.show_end_screen();
481 } else {
482 // Show the end screen if it hasn't been shown yet
483 app.show_end_screen();
484 }
485 }
486 },
487 // Select/move piece or confirm action
488 KeyCode::Char(' ') | KeyCode::Enter => {
489 // In Lichess mode, only allow input if it's our turn
490 app.process_cell_click();
491 }
492 KeyCode::Char('?') => app.toggle_help_popup(), // Toggle help popup
493 KeyCode::Char('s' | 'S') => {
494 app.cycle_skin(); // Cycle through available skins
495 app.update_config();
496 }
497 KeyCode::Char('m') => {
498 app.game.ui.prompt.reset();
499 app.current_popup = Some(Popups::MoveInputSelection)
500 }
501 KeyCode::Esc => app.game.ui.unselect_cell(), // Deselect piece
502 _ => fallback_key_handler(app, key_event),
503 }
504}
505
506/// Handles keyboard input during multiplayer game mode.
507/// Similar to solo mode but includes cleanup of network connections.
508fn handle_multiplayer_page_events(app: &mut App, key_event: KeyEvent) {
509 match key_event.code {
510 KeyCode::Char('b') => {
511 // Return to home menu - disconnect from opponent and reset state
512 app.reset_home();
513 }
514
515 _ => chess_inputs(app, key_event), // Delegate chess-specific inputs
516 }
517}
518
519/// Handles keyboard input when playing against a bot.
520/// Includes restart functionality and bot state cleanup.
521fn handle_bot_page_events(app: &mut App, key_event: KeyEvent) {
522 match key_event.code {
523 KeyCode::Char('r') => app.restart(), // Restart current game
524 KeyCode::Char('b') => {
525 // Return to home menu - clean up bot and reset state
526 app.reset_home();
527 }
528 _ => chess_inputs(app, key_event), // Delegate chess-specific inputs
529 }
530}
531
532/// Handles keyboard input on the credits page.
533fn handle_credit_page_events(app: &mut App, key_event: KeyEvent) {
534 match key_event.code {
535 KeyCode::Char(' ') | KeyCode::Esc | KeyCode::Enter => app.toggle_credit_popup(),
536 _ => fallback_key_handler(app, key_event),
537 }
538}
539
540/// Fallback handler for keys that aren't handled by specific page/popup handlers.
541/// Provides global shortcuts like quit that work from anywhere.
542fn fallback_key_handler(app: &mut App, key_event: KeyEvent) {
543 match key_event.code {
544 KeyCode::Char('q') => app.quit(), // Quit application
545 KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => app.quit(), // Ctrl+C to quit
546 KeyCode::Char('s') => app.cycle_skin(), // Cycle through available skins
547 _ => (), // Ignore other keys
548 }
549}
550
551/// Handles mouse click events for piece selection and movement.
552///
553/// Mouse input is only active during game pages (Solo, Bot, Multiplayer).
554/// Handles both board clicks and promotion selection clicks.
555pub fn handle_mouse_events(mouse_event: MouseEvent, app: &mut App) -> AppResult<()> {
556 // Handle clicks on GameModeMenu page for documentation links
557 if app.current_page == Pages::GameModeMenu {
558 // Documentation links are opened via keyboard shortcut ('d')
559 return Ok(());
560 }
561
562 // Mouse control only implemented for game pages, not home or credits
563 if app.current_page == Pages::Home || app.current_page == Pages::Credit {
564 return Ok(());
565 }
566
567 // Only process left mouse button clicks
568 if mouse_event.kind == MouseEventKind::Down(MouseButton::Left) {
569 // Ignore clicks when game has ended
570 if app.game.logic.game_state == GameState::Checkmate
571 || app.game.logic.game_state == GameState::Draw
572 {
573 return Ok(());
574 }
575 // Ignore clicks when a popup is active
576 if app.current_popup.is_some() {
577 return Ok(());
578 }
579
580 // Handle promotion piece selection via mouse
581 // Note: Promotion state should always allow input, even if turn has switched
582 // because the player needs to select the promotion piece after making the move
583 if app.game.logic.game_state == GameState::Promotion {
584 // Calculate which promotion option was clicked (0-3 for Queen, Rook, Bishop, Knight)
585 let x = (mouse_event.column - app.game.ui.top_x) / app.game.ui.width;
586 let y = (mouse_event.row - app.game.ui.top_y) / app.game.ui.height;
587 if x > 3 || y > 0 {
588 return Ok(()); // Click outside promotion area
589 }
590 app.game.ui.promotion_cursor = x as i8;
591
592 // Track if the move was correct (for puzzle mode)
593 let mut move_was_correct = true;
594
595 // If we have a pending promotion move, validate it now with the selected promotion piece
596 if let Some((from, to)) = app.pending_promotion_move.take() {
597 // Get the promotion piece from the cursor
598 let promotion_char = match app.game.ui.promotion_cursor {
599 0 => 'q', // Queen
600 1 => 'r', // Rook
601 2 => 'b', // Bishop
602 3 => 'n', // Knight
603 _ => 'q', // Default to queen
604 };
605
606 // Construct full UCI move with promotion piece
607 let move_uci = format!("{}{}{}", from, to, promotion_char);
608
609 // Validate the puzzle move with the complete UCI
610 if app.puzzle_game.is_some() {
611 if let Some(mut puzzle_game) = app.puzzle_game.take() {
612 let (is_correct, message) = puzzle_game.validate_move(
613 move_uci,
614 &mut app.game,
615 app.lichess_token.clone(),
616 );
617
618 move_was_correct = is_correct;
619 app.puzzle_game = Some(puzzle_game);
620
621 if let Some(msg) = message {
622 if is_correct {
623 app.error_message = Some(msg);
624 app.current_popup = Some(Popups::PuzzleEndScreen);
625 } else {
626 app.error_message = Some(msg);
627 app.current_popup = Some(Popups::Error);
628 }
629 }
630 }
631 }
632 }
633
634 // Only handle promotion if the move was correct (or not in puzzle mode)
635 // If incorrect, reset_last_move already removed the move and reset the state
636 if move_was_correct || app.puzzle_game.is_none() {
637 // Don't flip board in puzzle mode
638 let should_flip = app.puzzle_game.is_none();
639 app.game.handle_promotion(should_flip);
640 } else {
641 // Move was incorrect in puzzle mode - ensure game state is reset
642 // reset_last_move should have already handled this, but make sure
643 if app.game.logic.game_state == GameState::Promotion {
644 app.game.logic.game_state = GameState::Playing;
645 }
646 }
647 // Notify opponent in multiplayer games
648 if app.game.logic.opponent.is_some() {
649 app.game.logic.handle_multiplayer_promotion();
650 }
651 return Ok(());
652 }
653
654 // In Lichess mode, only allow input if it's our turn (but not for promotion, handled above)
655 if app.current_page == Pages::Lichess {
656 if let Some(my_color) = app.selected_color {
657 if app.game.logic.player_turn != my_color {
658 return Ok(());
659 }
660 }
661 }
662
663 // Validate click is within board boundaries
664 if mouse_event.column < app.game.ui.top_x || mouse_event.row < app.game.ui.top_y {
665 return Ok(());
666 }
667 if app.game.ui.width == 0 || app.game.ui.height == 0 {
668 return Ok(());
669 }
670
671 // Calculate which board square was clicked (0-7 for both x and y)
672 let x = (mouse_event.column - app.game.ui.top_x) / app.game.ui.width;
673 let y = (mouse_event.row - app.game.ui.top_y) / app.game.ui.height;
674 if x > 7 || y > 7 {
675 return Ok(()); // Click outside board
676 }
677
678 // Mark that mouse was used (affects keyboard cursor positioning)
679 app.game.ui.mouse_used = true;
680 let coords: Coord = Coord::new(y as u8, x as u8);
681
682 // Convert coordinates to board square, handling board flip if needed
683 let square = match coords.try_to_square() {
684 Some(s) => s,
685 None => return Ok(()), // Invalid coordinates, ignore click
686 };
687
688 // Get piece color at clicked square (accounting for board flip)
689 let piece_color =
690 app.game
691 .logic
692 .game_board
693 .get_piece_color_at_square(&flip_square_if_needed(
694 square,
695 app.game.logic.game_board.is_flipped,
696 ));
697
698 // Handle click on empty square
699 if piece_color.is_none() {
700 // If no piece was previously selected, ignore the click
701 if app.game.ui.selected_square.is_none() {
702 return Ok(());
703 } else {
704 // Piece was selected - try to execute move to empty square
705 app.try_mouse_move(square, coords);
706 }
707 }
708 // Handle click on square with a piece
709 else if piece_color == Some(app.game.logic.player_turn) {
710 // Clicked on own piece
711 // First check if we have a piece selected and this square is a valid move destination
712 if let Some(selected_square) = app.game.ui.selected_square {
713 // Check if this is a castling attempt: king selected, rook clicked
714 let actual_selected =
715 flip_square_if_needed(selected_square, app.game.logic.game_board.is_flipped);
716 let actual_clicked =
717 flip_square_if_needed(square, app.game.logic.game_board.is_flipped);
718
719 let selected_role = app
720 .game
721 .logic
722 .game_board
723 .get_role_at_square(&actual_selected);
724 let clicked_role = app
725 .game
726 .logic
727 .game_board
728 .get_role_at_square(&actual_clicked);
729
730 // Check if king is selected and rook is clicked
731 if selected_role == Some(Role::King) && clicked_role == Some(Role::Rook) {
732 // Determine castling destination based on rook position
733 let castling_dest = match actual_clicked {
734 Square::H1 => Square::G1, // Kingside for white
735 Square::A1 => Square::C1, // Queenside for white
736 Square::H8 => Square::G8, // Kingside for black
737 Square::A8 => Square::C8, // Queenside for black
738 _ => {
739 // Not a castling rook, try normal move
740 if app.try_mouse_move(square, coords) {
741 return Ok(());
742 }
743 app.game.ui.selected_square = Some(square);
744 return Ok(());
745 }
746 };
747
748 // Check if castling is legal by checking if destination is in authorized positions
749 let authorized_positions = app
750 .game
751 .logic
752 .game_board
753 .get_authorized_positions(app.game.logic.player_turn, &actual_selected);
754
755 if authorized_positions.contains(&castling_dest) {
756 // Castling is legal, execute it
757 let castling_coords = Coord::from_square(flip_square_if_needed(
758 castling_dest,
759 app.game.logic.game_board.is_flipped,
760 ));
761 if app.try_mouse_move(
762 flip_square_if_needed(
763 castling_dest,
764 app.game.logic.game_board.is_flipped,
765 ),
766 castling_coords,
767 ) {
768 return Ok(());
769 }
770 }
771 }
772
773 // Try normal move first
774 if app.try_mouse_move(square, coords) {
775 // Move was executed successfully
776 return Ok(());
777 }
778 }
779 // Otherwise, select the clicked piece
780 app.game.ui.selected_square = Some(square);
781 } else {
782 // Clicked on opponent's piece - try to capture if valid
783 if app.game.ui.selected_square.is_some() {
784 app.try_mouse_move(square, coords);
785 }
786 // No piece selected and clicked opponent piece - ignore (try_execute_move handles this)
787 }
788 }
789 Ok(())
790}
791
792/// Handles keyboard input on the Lichess menu page.
793/// Supports navigation through menu items and selection.
794fn handle_lichess_menu_page_events(app: &mut App, key_event: KeyEvent) {
795 match key_event.code {
796 KeyCode::Up | KeyCode::Char('k') => app.menu_cursor_up(5), // 5 menu options
797 KeyCode::Down | KeyCode::Char('j') => app.menu_cursor_down(5),
798 KeyCode::Char(' ') | KeyCode::Enter => {
799 // Handle menu selection
800 match app.menu_cursor {
801 0 => {
802 // Seek Game
803 if app.lichess_token.is_none()
804 || app
805 .lichess_token
806 .as_ref()
807 .map(|t| t.is_empty())
808 .unwrap_or(true)
809 {
810 // Open interactive token entry popup
811 app.current_popup = Some(Popups::EnterLichessToken);
812 app.game.ui.prompt.reset();
813 app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
814 return;
815 }
816 app.menu_cursor = 0;
817 app.current_page = Pages::Lichess;
818 app.create_lichess_opponent();
819 }
820 1 => {
821 // Puzzle
822 if app.lichess_token.is_none()
823 || app
824 .lichess_token
825 .as_ref()
826 .map(|t| t.is_empty())
827 .unwrap_or(true)
828 {
829 // Open interactive token entry popup
830 app.current_popup = Some(Popups::EnterLichessToken);
831 app.game.ui.prompt.reset();
832 app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
833 return;
834 }
835 app.start_puzzle_mode();
836 }
837 2 => {
838 // My Ongoing Games
839 if app.lichess_token.is_none()
840 || app
841 .lichess_token
842 .as_ref()
843 .map(|t| t.is_empty())
844 .unwrap_or(true)
845 {
846 // Open interactive token entry popup
847 app.current_popup = Some(Popups::EnterLichessToken);
848 app.game.ui.prompt.reset();
849 app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
850 return;
851 }
852 app.fetch_ongoing_games();
853 }
854 3 => {
855 // Join by Code
856 if app.lichess_token.is_none()
857 || app
858 .lichess_token
859 .as_ref()
860 .map(|t| t.is_empty())
861 .unwrap_or(true)
862 {
863 // Open interactive token entry popup
864 app.current_popup = Some(Popups::EnterLichessToken);
865 app.game.ui.prompt.reset();
866 app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
867 return;
868 }
869 app.current_popup = Some(Popups::EnterGameCode);
870 app.game.ui.prompt.reset();
871 }
872 4 => {
873 // Disconnect
874 app.disconnect_lichess();
875 }
876 _ => {}
877 }
878 }
879 KeyCode::Esc | KeyCode::Char('b') => {
880 // Return to home menu
881 app.menu_cursor = 0;
882 app.current_page = Pages::Home;
883 }
884 KeyCode::Char('?') => app.toggle_help_popup(),
885 _ => fallback_key_handler(app, key_event),
886 }
887}
888
889/// Handles keyboard input on the Game Mode menu page.
890/// Supports navigation through menu items, form fields, and selection.
891fn handle_game_mode_menu_page_events(app: &mut App, key_event: KeyEvent) {
892 // Ensure cursor is valid (0-2)
893 if app.menu_cursor > 2 {
894 app.menu_cursor = 0;
895 }
896
897 let game_mode = app.menu_cursor;
898
899 // If form is active, handle form navigation
900 if app.game_mode_form_active {
901 match key_event.code {
902 KeyCode::Esc => {
903 // Deactivate form and go back to menu
904 app.game_mode_form_active = false;
905 app.game_mode_form_cursor = 0;
906 }
907 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
908 // Up/Down navigation disabled in form mode
909 // Use Left/Right to toggle options and Enter/Space to move to next field
910 }
911 KeyCode::Left | KeyCode::Char('h') => {
912 // Navigate left - go to first option (Host/White)
913 match game_mode {
914 0 => {
915 // Local: time control selection
916 match app.game_mode_form_cursor {
917 0 => {
918 // Time control - previous option (0-6)
919 if app.clock_form_cursor > 0 {
920 app.clock_form_cursor -= 1;
921 }
922 }
923 1 => {
924 // Custom time - decrease (only if Custom is selected)
925 if app.clock_form_cursor
926 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
927 && app.custom_time_minutes > 1
928 {
929 app.custom_time_minutes -= 1;
930 }
931 }
932 _ => {}
933 }
934 }
935 1 => {
936 // Multiplayer
937 match app.game_mode_form_cursor {
938 0 => {
939 // Set to Host
940 app.hosting = Some(true);
941 }
942 1 => {
943 // Set to White (only if hosting)
944 if app.hosting == Some(true) {
945 app.selected_color = Some(shakmaty::Color::White);
946 }
947 }
948 _ => {}
949 }
950 }
951 2 => {
952 // Bot
953 match app.game_mode_form_cursor {
954 0 => {
955 // Time control - previous option (0-6)
956 if app.clock_form_cursor > 0 {
957 app.clock_form_cursor -= 1;
958 }
959 }
960 1 => {
961 // Custom time or Color
962 if app.clock_form_cursor
963 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
964 {
965 // Custom time - decrease (only if Custom is selected)
966 if app.custom_time_minutes > 1 {
967 app.custom_time_minutes -= 1;
968 }
969 } else {
970 // Color - set to White
971 app.selected_color = Some(shakmaty::Color::White);
972 }
973 }
974 2 => {
975 // Color (if Custom selected) or Bot depth
976 if app.clock_form_cursor
977 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
978 {
979 // Color - set to White
980 app.selected_color = Some(shakmaty::Color::White);
981 } else {
982 // Bot depth - decrease
983 if app.bot_depth > 1 {
984 app.bot_depth -= 1;
985 }
986 }
987 }
988 3 => {
989 // Bot depth (if Custom selected)
990 if app.clock_form_cursor
991 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
992 && app.bot_depth > 1
993 {
994 app.bot_depth -= 1;
995 }
996 }
997 _ => {}
998 }
999 }
1000 _ => {}
1001 }
1002 }
1003 KeyCode::Right | KeyCode::Char('l') => {
1004 // Navigate right - go to second option (Join/Black)
1005 match game_mode {
1006 0 => {
1007 // Local: time control selection
1008 match app.game_mode_form_cursor {
1009 0 => {
1010 // Time control - next option (0-6: UltraBullet, Bullet, Blitz, Rapid, Classical, No clock, Custom)
1011 if app.clock_form_cursor
1012 < crate::constants::TIME_CONTROL_CUSTOM_INDEX
1013 {
1014 app.clock_form_cursor += 1;
1015 }
1016 }
1017 1 => {
1018 // Custom time - increase (only if Custom is selected)
1019 if app.clock_form_cursor
1020 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
1021 && app.custom_time_minutes < 120
1022 {
1023 app.custom_time_minutes += 1;
1024 }
1025 }
1026 _ => {}
1027 }
1028 }
1029 1 => {
1030 // Multiplayer
1031 match app.game_mode_form_cursor {
1032 0 => {
1033 // Set to Join
1034 app.hosting = Some(false);
1035 }
1036 1 => {
1037 // Set to Black (only if hosting)
1038 if app.hosting == Some(true) {
1039 app.selected_color = Some(shakmaty::Color::Black);
1040 }
1041 }
1042 _ => {}
1043 }
1044 }
1045 2 => {
1046 // Bot
1047 match app.game_mode_form_cursor {
1048 0 => {
1049 // Time control - next option (0-6: UltraBullet, Bullet, Blitz, Rapid, Classical, No clock, Custom)
1050 if app.clock_form_cursor
1051 < crate::constants::TIME_CONTROL_CUSTOM_INDEX
1052 {
1053 app.clock_form_cursor += 1;
1054 }
1055 }
1056 1 => {
1057 // Custom time or Color
1058 if app.clock_form_cursor
1059 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
1060 {
1061 // Custom time - increase (only if Custom is selected)
1062 if app.custom_time_minutes < 120 {
1063 app.custom_time_minutes += 1;
1064 }
1065 } else {
1066 // Color - set to Black
1067 app.selected_color = Some(shakmaty::Color::Black);
1068 }
1069 }
1070 2 => {
1071 // Color (if Custom selected) or Bot depth
1072 if app.clock_form_cursor
1073 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
1074 {
1075 // Color - set to Black
1076 app.selected_color = Some(shakmaty::Color::Black);
1077 } else {
1078 // Bot depth - increase
1079 if app.bot_depth < 20 {
1080 app.bot_depth += 1;
1081 }
1082 }
1083 }
1084 3 => {
1085 // Bot depth (if Custom selected)
1086 if app.clock_form_cursor
1087 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
1088 && app.bot_depth < 20
1089 {
1090 app.bot_depth += 1;
1091 }
1092 }
1093 _ => {}
1094 }
1095 }
1096 _ => {}
1097 }
1098 }
1099 KeyCode::Char(' ') | KeyCode::Enter => {
1100 // Confirm current field and move to next, or start game if all fields filled
1101 match game_mode {
1102 0 => {
1103 // Local game - handle form navigation or start game
1104 match app.game_mode_form_cursor {
1105 0 => {
1106 // On time control field
1107 if app.clock_form_cursor
1108 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
1109 {
1110 // Custom selected - move to custom time field
1111 app.game_mode_form_cursor = 1;
1112 } else {
1113 // Other time control - start game directly
1114 if let Some(seconds) = app.get_time_control_seconds() {
1115 use crate::game_logic::clock::Clock;
1116 app.game.logic.clock = Some(Clock::new(seconds));
1117 }
1118 app.current_page = Pages::Solo;
1119 app.game_mode_selection = None;
1120 app.game_mode_form_cursor = 0;
1121 app.game_mode_form_active = false;
1122 }
1123 }
1124 1 => {
1125 // On custom time field - start game
1126 if let Some(seconds) = app.get_time_control_seconds() {
1127 use crate::game_logic::clock::Clock;
1128 app.game.logic.clock = Some(Clock::new(seconds));
1129 }
1130 app.current_page = Pages::Solo;
1131 app.game_mode_selection = None;
1132 app.game_mode_form_cursor = 0;
1133 app.game_mode_form_active = false;
1134 }
1135 _ => {}
1136 }
1137 }
1138 1 => {
1139 // Multiplayer - step by step
1140 match app.game_mode_form_cursor {
1141 0 => {
1142 // On Host/Join field - select default (Host) if nothing selected, then move to next
1143 if app.hosting.is_none() {
1144 app.hosting = Some(true); // Default to Host
1145 }
1146 if app.hosting == Some(true) {
1147 // Hosting: move to color selection
1148 app.game_mode_form_cursor = 1;
1149 } else {
1150 // Joining: can start game immediately
1151 app.current_page = Pages::Multiplayer;
1152 app.game_mode_selection = None;
1153 app.game_mode_form_cursor = 0;
1154 app.game_mode_form_active = false;
1155 }
1156 }
1157 1 => {
1158 // On Color field - select default (White) if nothing selected, then start game
1159 if app.selected_color.is_none() {
1160 app.selected_color = Some(shakmaty::Color::White);
1161 // Default to White
1162 }
1163 // Hosting: start game (color selected)
1164 app.current_page = Pages::Multiplayer;
1165 app.game_mode_selection = None;
1166 app.game_mode_form_cursor = 0;
1167 app.game_mode_form_active = false;
1168 }
1169 _ => {}
1170 }
1171 }
1172 2 => {
1173 // Bot - step by step
1174 match app.game_mode_form_cursor {
1175 0 => {
1176 // On time control field
1177 if app.clock_form_cursor
1178 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
1179 {
1180 // Custom selected - move to custom time field
1181 app.game_mode_form_cursor = 1;
1182 } else {
1183 // Other time control - move to color field
1184 app.game_mode_form_cursor = 1;
1185 }
1186 }
1187 1 => {
1188 // On custom time field (if Custom selected) or color field
1189 if app.clock_form_cursor
1190 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
1191 {
1192 // Custom time field - move to color field
1193 app.game_mode_form_cursor = 2;
1194 } else {
1195 // Color field - select default (White) if nothing selected, then move to depth
1196 if app.selected_color.is_none() {
1197 app.selected_color = Some(shakmaty::Color::White);
1198 // Default to White
1199 }
1200 app.game_mode_form_cursor = 2;
1201 }
1202 }
1203 2 => {
1204 // On color field (if Custom selected) or depth field
1205 if app.clock_form_cursor
1206 == crate::constants::TIME_CONTROL_CUSTOM_INDEX
1207 {
1208 // Color field - select default (White) if nothing selected, then move to depth
1209 if app.selected_color.is_none() {
1210 app.selected_color = Some(shakmaty::Color::White);
1211 // Default to White
1212 }
1213 app.game_mode_form_cursor = 3;
1214 } else {
1215 // Depth field - start game (time control, color and depth selected)
1216 app.current_page = Pages::Bot;
1217 app.game_mode_selection = None;
1218 app.game_mode_form_cursor = 0;
1219 app.game_mode_form_active = false;
1220 }
1221 }
1222 3 => {
1223 // On depth field (if Custom selected) - start game
1224 app.current_page = Pages::Bot;
1225 app.game_mode_selection = None;
1226 app.game_mode_form_cursor = 0;
1227 app.game_mode_form_active = false;
1228 }
1229 _ => {}
1230 }
1231 }
1232 _ => {}
1233 }
1234 }
1235 _ => fallback_key_handler(app, key_event),
1236 }
1237 } else {
1238 // Menu navigation mode (form not active)
1239 match key_event.code {
1240 KeyCode::Up | KeyCode::Char('k') => {
1241 app.menu_cursor_up(3);
1242 }
1243 KeyCode::Down | KeyCode::Char('j') => {
1244 app.menu_cursor_down(3);
1245 }
1246 KeyCode::Left | KeyCode::Char('h') => {
1247 // Change game mode selection
1248 if app.menu_cursor > 0 {
1249 app.menu_cursor -= 1;
1250 }
1251 }
1252 KeyCode::Right | KeyCode::Char('l') => {
1253 // Change game mode selection
1254 if app.menu_cursor < 2 {
1255 app.menu_cursor += 1;
1256 }
1257 }
1258 KeyCode::Char(' ') | KeyCode::Enter => {
1259 // Activate the form for all modes
1260 app.game_mode_form_active = true;
1261 app.game_mode_form_cursor = 0;
1262 app.game_mode_selection = Some(game_mode);
1263 // Reset form state
1264 if game_mode == 0 {
1265 // Local game: reset clock time to default if needed
1266 if app.clock_form_cursor > crate::constants::TIME_CONTROL_CUSTOM_INDEX {
1267 app.clock_form_cursor = 3; // Default: Rapid
1268 }
1269 } else {
1270 // Activate the form for modes with configuration
1271 app.game_mode_form_active = true;
1272 app.game_mode_form_cursor = 0; // Start at first form field
1273 app.game_mode_selection = Some(game_mode);
1274 // Reset form state
1275 app.hosting = None;
1276 app.selected_color = None;
1277 }
1278 }
1279 KeyCode::Esc | KeyCode::Char('b') => {
1280 // Return to home menu
1281 app.menu_cursor = 0;
1282 app.game_mode_selection = None;
1283 app.game_mode_form_cursor = 0;
1284 app.game_mode_form_active = false;
1285 app.clock_form_cursor = 3; // Reset to default (Rapid)
1286 app.custom_time_minutes = 10; // Reset custom time
1287 app.current_page = Pages::Home;
1288 }
1289 KeyCode::Char('?') => app.toggle_help_popup(),
1290 _ => fallback_key_handler(app, key_event),
1291 }
1292 }
1293}
1294
1295fn handle_ongoing_games_page_events(app: &mut App, key_event: KeyEvent) {
1296 match key_event.code {
1297 KeyCode::Up | KeyCode::Char('k') => {
1298 if app.menu_cursor > 0 {
1299 app.menu_cursor -= 1;
1300 }
1301 }
1302 KeyCode::Down | KeyCode::Char('j') => {
1303 if (app.menu_cursor as usize) < app.ongoing_games.len().saturating_sub(1) {
1304 app.menu_cursor += 1;
1305 }
1306 }
1307 KeyCode::Enter | KeyCode::Char(' ') => {
1308 app.select_ongoing_game();
1309 }
1310 KeyCode::Char('r') | KeyCode::Char('R') => {
1311 // Resign game
1312 app.show_resign_confirmation();
1313 }
1314 KeyCode::Esc | KeyCode::Char('b') => {
1315 app.menu_cursor = 0;
1316 app.current_page = Pages::LichessMenu;
1317 }
1318 KeyCode::Char('?') => app.toggle_help_popup(),
1319 _ => fallback_key_handler(app, key_event),
1320 }
1321}