Skip to main content

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}