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::{Role, Square};
13
14/// Handles keyboard input events and updates the application state accordingly.
15///
16/// This is the main entry point for all keyboard interactions. It filters out
17/// non-press events, handles mouse-to-keyboard transitions, and routes events
18/// to either popup handlers or page handlers based on the current application state.
19pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> {
20    // Only process key press events, ignore release and repeat events
21    // (crossterm on Windows sends Release and Repeat events as well)
22    if key_event.kind != KeyEventKind::Press {
23        return Ok(());
24    }
25
26    // Reset cursor position after mouse interaction when keyboard is used
27    // This ensures the cursor is in a valid position for keyboard navigation
28    if app.game.ui.mouse_used {
29        app.game.ui.mouse_used = false;
30        if let Some(selected_square) = app.game.ui.selected_square {
31            // If a piece was selected via mouse, move cursor to that square
32            app.game.ui.cursor_coordinates =
33                get_coord_from_square(Some(selected_square), app.game.logic.game_board.is_flipped);
34            app.game.ui.selected_square = None;
35        } else {
36            // Otherwise, reset cursor to center of board (e4/e5)
37            app.game.ui.cursor_coordinates.col = 4;
38            app.game.ui.cursor_coordinates.row = 4;
39        }
40    }
41
42    // If a popup is active, the key should affect the popup and not the page,
43    // therefore, if there is some popup active, handle it, if not, handle the page event
44    match app.current_popup {
45        Some(popup) => handle_popup_input(app, key_event, popup),
46        None => handle_page_input(app, key_event),
47    }
48
49    Ok(())
50}
51
52/// Handles keyboard input when a popup is active.
53///
54/// Popups take priority over page input, so all keyboard events are routed here
55/// when a popup is displayed. Each popup type has its own set of key bindings.
56fn handle_popup_input(app: &mut App, key_event: KeyEvent, popup: Popups) {
57    match popup {
58        // Popup for entering the host IP address when joining a multiplayer game
59        Popups::EnterHostIP => match key_event.code {
60            KeyCode::Enter => {
61                // Submit the entered IP address and store it
62                app.game.ui.prompt.submit_message();
63                if app.current_page == Pages::Multiplayer {
64                    app.host_ip = Some(app.game.ui.prompt.message.clone());
65                }
66                app.current_popup = None;
67            }
68            KeyCode::Char(to_insert) => app.game.ui.prompt.enter_char(to_insert),
69            KeyCode::Backspace => app.game.ui.prompt.delete_char(),
70            KeyCode::Left => app.game.ui.prompt.move_cursor_left(),
71            KeyCode::Right => app.game.ui.prompt.move_cursor_right(),
72            KeyCode::Esc => {
73                // Cancel IP entry and return to home menu, resetting multiplayer state
74                app.current_popup = None;
75                if app.current_page == Pages::Multiplayer {
76                    app.hosting = None;
77                    app.selected_color = None;
78                    app.menu_cursor = 0;
79                }
80                app.current_page = Pages::Home;
81            }
82            _ => fallback_key_handler(app, key_event),
83        },
84        // Help popup - shows game controls and key bindings
85        Popups::Help => match key_event.code {
86            KeyCode::Char('?') => app.toggle_help_popup(),
87            KeyCode::Esc => app.toggle_help_popup(),
88            _ => fallback_key_handler(app, key_event),
89        },
90        // Color selection popup - choose white or black when playing against bot
91        Popups::ColorSelection => match key_event.code {
92            KeyCode::Esc => app.close_popup_and_go_home(),
93            KeyCode::Right | KeyCode::Char('l') => app.menu_cursor_right(2),
94            KeyCode::Left | KeyCode::Char('h') => app.menu_cursor_left(2),
95            KeyCode::Char(' ') | KeyCode::Enter => app.color_selection(),
96            _ => fallback_key_handler(app, key_event),
97        },
98        // Multiplayer selection popup - choose to host or join a game
99        Popups::MultiplayerSelection => match key_event.code {
100            KeyCode::Esc => app.close_popup_and_go_home(),
101            KeyCode::Right | KeyCode::Char('l') => app.menu_cursor_right(2),
102            KeyCode::Left | KeyCode::Char('h') => app.menu_cursor_left(2),
103            KeyCode::Char(' ') | KeyCode::Enter => app.hosting_selection(),
104            _ => fallback_key_handler(app, key_event),
105        },
106        Popups::EnginePathError => match key_event.code {
107            KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' ') => app.close_popup_and_go_home(),
108            _ => fallback_key_handler(app, key_event),
109        },
110        Popups::WaitingForOpponentToJoin => match key_event.code {
111            KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' ') => {
112                app.close_popup_and_go_home();
113                app.cancel_hosting_cleanup();
114            }
115            _ => fallback_key_handler(app, key_event),
116        },
117        // End screen popup - shown when game ends (checkmate or draw)
118        Popups::EndScreen => match key_event.code {
119            KeyCode::Char('h') | KeyCode::Char('H') => {
120                // Hide the end screen (can be toggled back with H when not in popup)
121                app.current_popup = None;
122                app.end_screen_dismissed = true;
123            }
124            KeyCode::Esc => {
125                // Also allow Esc to hide the end screen and mark as dismissed
126                app.current_popup = None;
127                app.end_screen_dismissed = true;
128            }
129            KeyCode::Char('r') | KeyCode::Char('R') => {
130                // Restart the game (only for non-multiplayer games)
131                if app.game.logic.opponent.is_none() {
132                    app.restart();
133                    app.current_popup = None;
134                }
135            }
136            KeyCode::Char('b') | KeyCode::Char('B') => {
137                // Go back to home menu - completely reset all game state
138                app.reset_home();
139            }
140            _ => fallback_key_handler(app, key_event),
141        },
142        // Puzzle end screen popup - shown when puzzle is completed
143        Popups::PuzzleEndScreen => match key_event.code {
144            KeyCode::Char('h') | KeyCode::Char('H') => {
145                // Hide the puzzle end screen
146                app.current_popup = None;
147            }
148            KeyCode::Esc => {
149                // Also allow Esc to hide the puzzle end screen
150                app.current_popup = None;
151            }
152            KeyCode::Char('n') | KeyCode::Char('N') => {
153                // Start a new puzzle
154                app.current_popup = None;
155                app.start_puzzle_mode();
156            }
157            KeyCode::Char('b') | KeyCode::Char('B') => {
158                // Go back to home menu - completely reset all game state
159                app.reset_home();
160            }
161            _ => fallback_key_handler(app, key_event),
162        },
163        // Error popup - displays error messages
164        Popups::Error => match key_event.code {
165            KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' ') => {
166                app.current_popup = None;
167                app.error_message = None;
168                // Navigate back to an appropriate page based on current context
169                match app.current_page {
170                    Pages::Lichess | Pages::OngoingGames => {
171                        // If we're on Lichess-related pages, go back to Lichess menu
172                        app.current_page = Pages::LichessMenu;
173                    }
174                    Pages::Multiplayer | Pages::Bot => {
175                        // If we're on multiplayer or bot page, go back to home
176                        app.current_page = Pages::Home;
177                    }
178                    _ => {
179                        // For other pages, stay on current page or go to home
180                        // Only change if we're in a weird state
181                        if app.current_page == Pages::Solo && app.game.logic.opponent.is_some() {
182                            // If we're in solo but have an opponent (shouldn't happen), reset
183                            app.current_page = Pages::Home;
184                        }
185                    }
186                }
187            }
188            _ => fallback_key_handler(app, key_event),
189        },
190        // Success popup - displays success messages
191        Popups::Success => match key_event.code {
192            KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' ') => {
193                app.current_popup = None;
194                app.error_message = None;
195                // Navigate back to an appropriate page based on current context
196                match app.current_page {
197                    Pages::Lichess | Pages::OngoingGames => {
198                        // If we're on Lichess-related pages, go back to Lichess menu
199                        app.current_page = Pages::LichessMenu;
200                    }
201                    Pages::Multiplayer | Pages::Bot => {
202                        // If we're on multiplayer or bot page, go back to home
203                        app.current_page = Pages::Home;
204                    }
205                    _ => {
206                        // For other pages, stay on current page or go to home
207                        // Only change if we're in a weird state
208                        if app.current_page == Pages::Solo && app.game.logic.opponent.is_some() {
209                            // If we're in solo but have an opponent (shouldn't happen), reset
210                            app.current_page = Pages::Home;
211                        }
212                    }
213                }
214            }
215            _ => fallback_key_handler(app, key_event),
216        },
217        Popups::EnterGameCode => match key_event.code {
218            KeyCode::Enter => {
219                // Submit the entered game code
220                app.game.ui.prompt.submit_message();
221                let game_code = app.game.ui.prompt.message.clone();
222
223                if !game_code.is_empty() {
224                    // Join the game with the entered code
225                    app.current_page = Pages::Lichess;
226                    app.join_lichess_game_by_code(game_code);
227                } else {
228                    // No code entered, return to menu
229                    app.current_popup = None;
230                    app.current_page = Pages::LichessMenu;
231                }
232            }
233            KeyCode::Char(to_insert) => app.game.ui.prompt.enter_char(to_insert),
234            KeyCode::Backspace => app.game.ui.prompt.delete_char(),
235            KeyCode::Left => app.game.ui.prompt.move_cursor_left(),
236            KeyCode::Right => app.game.ui.prompt.move_cursor_right(),
237            KeyCode::Esc => {
238                // Cancel game code entry and return to Lichess menu
239                app.current_popup = None;
240                app.current_page = Pages::LichessMenu;
241            }
242            _ => fallback_key_handler(app, key_event),
243        },
244        Popups::EnterLichessToken => match key_event.code {
245            KeyCode::Enter => {
246                // Submit the entered token
247                app.game.ui.prompt.submit_message();
248                let token = app.game.ui.prompt.message.clone().trim().to_string();
249
250                if !token.is_empty() {
251                    // Save and validate the token
252                    app.save_and_validate_lichess_token(token);
253                } else {
254                    // No token entered, return to previous page
255                    app.current_popup = None;
256                }
257            }
258            KeyCode::Char(to_insert) => app.game.ui.prompt.enter_char(to_insert),
259            KeyCode::Backspace => app.game.ui.prompt.delete_char(),
260            KeyCode::Left => app.game.ui.prompt.move_cursor_left(),
261            KeyCode::Right => app.game.ui.prompt.move_cursor_right(),
262            KeyCode::Esc => {
263                // Cancel token entry
264                app.current_popup = None;
265            }
266            _ => fallback_key_handler(app, key_event),
267        },
268        Popups::SeekingLichessGame => match key_event.code {
269            KeyCode::Esc => {
270                if let Some(token) = &app.lichess_cancellation_token {
271                    token.store(true, std::sync::atomic::Ordering::Relaxed);
272                }
273                app.lichess_seek_receiver = None; // Cancel the receiver (thread continues but result ignored)
274                app.lichess_cancellation_token = None;
275                app.current_popup = None;
276                app.current_page = Pages::Home; // Go back to home
277            }
278            _ => fallback_key_handler(app, key_event),
279        },
280        Popups::ResignConfirmation => match key_event.code {
281            KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
282                app.confirm_resign_game();
283                // fetch_ongoing_games() is already called in confirm_resign_game()
284            }
285            KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
286                app.current_popup = None;
287            }
288            _ => fallback_key_handler(app, key_event),
289        },
290    };
291}
292
293/// Routes keyboard input to the appropriate page handler based on current page.
294fn handle_page_input(app: &mut App, key_event: KeyEvent) {
295    match &app.current_page {
296        Pages::Home => handle_home_page_events(app, key_event),
297        Pages::Solo => handle_solo_page_events(app, key_event),
298        Pages::Multiplayer => handle_multiplayer_page_events(app, key_event),
299        Pages::Lichess => handle_multiplayer_page_events(app, key_event),
300        Pages::LichessMenu => handle_lichess_menu_page_events(app, key_event),
301        Pages::OngoingGames => handle_ongoing_games_page_events(app, key_event),
302        Pages::Bot => handle_bot_page_events(app, key_event),
303        Pages::Credit => handle_credit_page_events(app, key_event),
304    }
305}
306
307/// Handles keyboard input on the home/menu page.
308/// Supports navigation through menu items and selection.
309fn handle_home_page_events(app: &mut App, key_event: KeyEvent) {
310    // Number of menu items depends on whether sound feature is enabled
311    const MENU_ITEMS: u8 = {
312        #[cfg(feature = "sound")]
313        {
314            8 // Local game, Multiplayer, Lichess, Bot, Skin, Sound, Help, About
315        }
316        #[cfg(not(feature = "sound"))]
317        {
318            7 // Local game, Multiplayer, Lichess, Bot, Skin, Help, About
319        }
320    };
321
322    match key_event.code {
323        KeyCode::Up | KeyCode::Char('k') => app.menu_cursor_up(MENU_ITEMS),
324        KeyCode::Down | KeyCode::Char('j') => app.menu_cursor_down(MENU_ITEMS),
325        // If on skin selection menu item (index 3), use left/right to cycle skins
326        KeyCode::Left | KeyCode::Char('h') if app.menu_cursor == 3 => {
327            app.cycle_skin_backward();
328            app.update_config();
329        }
330        KeyCode::Right | KeyCode::Char('l') if app.menu_cursor == 3 => {
331            app.cycle_skin();
332            app.update_config();
333        }
334        KeyCode::Char(' ') | KeyCode::Enter => app.menu_select(),
335        KeyCode::Char('?') => app.toggle_help_popup(),
336        _ => fallback_key_handler(app, key_event),
337    }
338}
339
340/// Handles keyboard input during solo (two-player) game mode.
341fn handle_solo_page_events(app: &mut App, key_event: KeyEvent) {
342    match key_event.code {
343        KeyCode::Char('r') => app.restart(), // Restart current game
344        KeyCode::Char('b') => {
345            // Return to home menu - reset all game state
346            app.reset_home();
347        }
348        KeyCode::Char('n' | 'N') => {
349            // In puzzle mode, 'n' is used for new puzzle (handled in popup)
350            // Otherwise, navigate to next position in history
351            if app.puzzle_game.is_none()
352                && app.game.logic.game_state != GameState::Checkmate
353                && app.game.logic.game_state != GameState::Draw
354            {
355                app.navigate_history_next();
356            }
357        }
358        KeyCode::Char('p' | 'P') => {
359            // Navigate to previous position in history (only if game hasn't ended and not in puzzle mode)
360            if app.puzzle_game.is_none()
361                && app.game.logic.game_state != GameState::Checkmate
362                && app.game.logic.game_state != GameState::Draw
363            {
364                app.navigate_history_previous();
365            }
366        }
367        KeyCode::Char('t' | 'T') if app.puzzle_game.is_some() && app.current_popup.is_none() => {
368            // Show hint in puzzle mode (only when no popup is active)
369            app.show_puzzle_hint();
370        }
371        _ => chess_inputs(app, key_event), // Delegate chess-specific inputs
372    }
373}
374
375/// Handles chess-specific keyboard inputs (cursor movement, piece selection, etc.).
376///
377/// This function processes inputs that are common across all game modes (solo, bot, multiplayer).
378/// The behavior varies based on the current game state (Playing, Promotion, Checkmate, Draw).
379fn chess_inputs(app: &mut App, key_event: KeyEvent) {
380    let is_playing = app.game.logic.game_state == GameState::Playing;
381
382    match key_event.code {
383        // Vertical cursor movement (only during active play)
384        KeyCode::Up | KeyCode::Char('k') if is_playing => app.go_up_in_game(),
385        KeyCode::Down | KeyCode::Char('j') if is_playing => app.go_down_in_game(),
386
387        // Horizontal cursor movement - behavior depends on game state
388        KeyCode::Right | KeyCode::Char('l') => match app.game.logic.game_state {
389            GameState::Promotion => {
390                // Always allow promotion cursor movement, regardless of turn or page
391                app.game.ui.cursor_right_promotion();
392            }
393            GameState::Playing => {
394                // In Lichess mode, only allow board cursor movement if it's our turn
395                if app.current_page == Pages::Lichess {
396                    if let Some(my_color) = app.selected_color {
397                        if app.game.logic.player_turn == my_color {
398                            app.go_right_in_game();
399                        }
400                    }
401                } else {
402                    app.go_right_in_game();
403                }
404            }
405            _ => (),
406        },
407        KeyCode::Left | KeyCode::Char('h') => match app.game.logic.game_state {
408            GameState::Promotion => {
409                // Always allow promotion cursor movement, regardless of turn or page
410                app.game.ui.cursor_left_promotion();
411            }
412            GameState::Playing => {
413                // In Lichess mode, only allow board cursor movement if it's our turn
414                if app.current_page == Pages::Lichess {
415                    if let Some(my_color) = app.selected_color {
416                        if app.game.logic.player_turn == my_color {
417                            app.go_left_in_game();
418                        }
419                    }
420                } else {
421                    app.go_left_in_game();
422                }
423            }
424            GameState::Checkmate | GameState::Draw => {
425                // Toggle end screen visibility when game is over
426                // If popup is shown, hide it; if hidden and dismissed, show it again
427                if app.current_popup == Some(Popups::EndScreen) {
428                    // Hide the end screen
429                    app.current_popup = None;
430                    app.end_screen_dismissed = true;
431                } else if app.end_screen_dismissed {
432                    // Show the end screen again if it was dismissed (toggle back)
433                    app.end_screen_dismissed = false;
434                    app.show_end_screen();
435                } else {
436                    // Show the end screen if it hasn't been shown yet
437                    app.show_end_screen();
438                }
439            }
440        },
441        // Select/move piece or confirm action
442        KeyCode::Char(' ') | KeyCode::Enter => {
443            // In Lichess mode, only allow input if it's our turn
444            app.process_cell_click();
445        }
446        KeyCode::Char('?') => app.toggle_help_popup(), // Toggle help popup
447        KeyCode::Char('s' | 'S') => {
448            app.cycle_skin(); // Cycle through available skins
449            app.update_config();
450        }
451        KeyCode::Esc => app.game.ui.unselect_cell(), // Deselect piece
452        _ => fallback_key_handler(app, key_event),
453    }
454}
455
456/// Handles keyboard input during multiplayer game mode.
457/// Similar to solo mode but includes cleanup of network connections.
458fn handle_multiplayer_page_events(app: &mut App, key_event: KeyEvent) {
459    match key_event.code {
460        KeyCode::Char('b') => {
461            // Return to home menu - disconnect from opponent and reset state
462            app.reset_home();
463        }
464
465        _ => chess_inputs(app, key_event), // Delegate chess-specific inputs
466    }
467}
468
469/// Handles keyboard input when playing against a bot.
470/// Includes restart functionality and bot state cleanup.
471fn handle_bot_page_events(app: &mut App, key_event: KeyEvent) {
472    match key_event.code {
473        KeyCode::Char('r') => app.restart(), // Restart current game
474        KeyCode::Char('b') => {
475            // Return to home menu - clean up bot and reset state
476            app.reset_home();
477        }
478        _ => chess_inputs(app, key_event), // Delegate chess-specific inputs
479    }
480}
481
482/// Handles keyboard input on the credits page.
483fn handle_credit_page_events(app: &mut App, key_event: KeyEvent) {
484    match key_event.code {
485        KeyCode::Char(' ') | KeyCode::Esc | KeyCode::Enter => app.toggle_credit_popup(),
486        _ => fallback_key_handler(app, key_event),
487    }
488}
489
490/// Fallback handler for keys that aren't handled by specific page/popup handlers.
491/// Provides global shortcuts like quit that work from anywhere.
492fn fallback_key_handler(app: &mut App, key_event: KeyEvent) {
493    match key_event.code {
494        KeyCode::Char('q') => app.quit(), // Quit application
495        KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => app.quit(), // Ctrl+C to quit
496        KeyCode::Char('s') => app.cycle_skin(), // Cycle through available skins
497        _ => (),                                // Ignore other keys
498    }
499}
500
501/// Handles mouse click events for piece selection and movement.
502///
503/// Mouse input is only active during game pages (Solo, Bot, Multiplayer).
504/// Handles both board clicks and promotion selection clicks.
505pub fn handle_mouse_events(mouse_event: MouseEvent, app: &mut App) -> AppResult<()> {
506    // Mouse control only implemented for game pages, not home or credits
507    if app.current_page == Pages::Home || app.current_page == Pages::Credit {
508        return Ok(());
509    }
510
511    // Only process left mouse button clicks
512    if mouse_event.kind == MouseEventKind::Down(MouseButton::Left) {
513        // Ignore clicks when game has ended
514        if app.game.logic.game_state == GameState::Checkmate
515            || app.game.logic.game_state == GameState::Draw
516        {
517            return Ok(());
518        }
519        // Ignore clicks when a popup is active
520        if app.current_popup.is_some() {
521            return Ok(());
522        }
523
524        // Handle promotion piece selection via mouse
525        // Note: Promotion state should always allow input, even if turn has switched
526        // because the player needs to select the promotion piece after making the move
527        if app.game.logic.game_state == GameState::Promotion {
528            // Calculate which promotion option was clicked (0-3 for Queen, Rook, Bishop, Knight)
529            let x = (mouse_event.column - app.game.ui.top_x) / app.game.ui.width;
530            let y = (mouse_event.row - app.game.ui.top_y) / app.game.ui.height;
531            if x > 3 || y > 0 {
532                return Ok(()); // Click outside promotion area
533            }
534            app.game.ui.promotion_cursor = x as i8;
535
536            // Track if the move was correct (for puzzle mode)
537            let mut move_was_correct = true;
538
539            // If we have a pending promotion move, validate it now with the selected promotion piece
540            if let Some((from, to)) = app.pending_promotion_move.take() {
541                // Get the promotion piece from the cursor
542                let promotion_char = match app.game.ui.promotion_cursor {
543                    0 => 'q', // Queen
544                    1 => 'r', // Rook
545                    2 => 'b', // Bishop
546                    3 => 'n', // Knight
547                    _ => 'q', // Default to queen
548                };
549
550                // Construct full UCI move with promotion piece
551                let move_uci = format!("{}{}{}", from, to, promotion_char);
552
553                // Validate the puzzle move with the complete UCI
554                if app.puzzle_game.is_some() {
555                    if let Some(mut puzzle_game) = app.puzzle_game.take() {
556                        let (is_correct, message) = puzzle_game.validate_move(
557                            move_uci,
558                            &mut app.game,
559                            app.lichess_token.clone(),
560                        );
561
562                        move_was_correct = is_correct;
563                        app.puzzle_game = Some(puzzle_game);
564
565                        if let Some(msg) = message {
566                            if is_correct {
567                                app.error_message = Some(msg);
568                                app.current_popup = Some(Popups::PuzzleEndScreen);
569                            } else {
570                                app.error_message = Some(msg);
571                                app.current_popup = Some(Popups::Error);
572                            }
573                        }
574                    }
575                }
576            }
577
578            // Only handle promotion if the move was correct (or not in puzzle mode)
579            // If incorrect, reset_last_move already removed the move and reset the state
580            if move_was_correct || app.puzzle_game.is_none() {
581                // Don't flip board in puzzle mode
582                let should_flip = app.puzzle_game.is_none();
583                app.game.handle_promotion(should_flip);
584            } else {
585                // Move was incorrect in puzzle mode - ensure game state is reset
586                // reset_last_move should have already handled this, but make sure
587                if app.game.logic.game_state == GameState::Promotion {
588                    app.game.logic.game_state = GameState::Playing;
589                }
590            }
591            // Notify opponent in multiplayer games
592            if app.game.logic.opponent.is_some() {
593                app.game.logic.handle_multiplayer_promotion();
594            }
595            return Ok(());
596        }
597
598        // In Lichess mode, only allow input if it's our turn (but not for promotion, handled above)
599        if app.current_page == Pages::Lichess {
600            if let Some(my_color) = app.selected_color {
601                if app.game.logic.player_turn != my_color {
602                    return Ok(());
603                }
604            }
605        }
606
607        // Validate click is within board boundaries
608        if mouse_event.column < app.game.ui.top_x || mouse_event.row < app.game.ui.top_y {
609            return Ok(());
610        }
611        if app.game.ui.width == 0 || app.game.ui.height == 0 {
612            return Ok(());
613        }
614
615        // Calculate which board square was clicked (0-7 for both x and y)
616        let x = (mouse_event.column - app.game.ui.top_x) / app.game.ui.width;
617        let y = (mouse_event.row - app.game.ui.top_y) / app.game.ui.height;
618        if x > 7 || y > 7 {
619            return Ok(()); // Click outside board
620        }
621
622        // Mark that mouse was used (affects keyboard cursor positioning)
623        app.game.ui.mouse_used = true;
624        let coords: Coord = Coord::new(y as u8, x as u8);
625
626        // Convert coordinates to board square, handling board flip if needed
627        let square = match coords.try_to_square() {
628            Some(s) => s,
629            None => return Ok(()), // Invalid coordinates, ignore click
630        };
631
632        // Get piece color at clicked square (accounting for board flip)
633        let piece_color =
634            app.game
635                .logic
636                .game_board
637                .get_piece_color_at_square(&flip_square_if_needed(
638                    square,
639                    app.game.logic.game_board.is_flipped,
640                ));
641
642        // Handle click on empty square
643        if piece_color.is_none() {
644            // If no piece was previously selected, ignore the click
645            if app.game.ui.selected_square.is_none() {
646                return Ok(());
647            } else {
648                // Piece was selected - try to execute move to empty square
649                app.try_mouse_move(square, coords);
650            }
651        }
652        // Handle click on square with a piece
653        else if piece_color == Some(app.game.logic.player_turn) {
654            // Clicked on own piece
655            // First check if we have a piece selected and this square is a valid move destination
656            if let Some(selected_square) = app.game.ui.selected_square {
657                // Check if this is a castling attempt: king selected, rook clicked
658                let actual_selected =
659                    flip_square_if_needed(selected_square, app.game.logic.game_board.is_flipped);
660                let actual_clicked =
661                    flip_square_if_needed(square, app.game.logic.game_board.is_flipped);
662
663                let selected_role = app
664                    .game
665                    .logic
666                    .game_board
667                    .get_role_at_square(&actual_selected);
668                let clicked_role = app
669                    .game
670                    .logic
671                    .game_board
672                    .get_role_at_square(&actual_clicked);
673
674                // Check if king is selected and rook is clicked
675                if selected_role == Some(Role::King) && clicked_role == Some(Role::Rook) {
676                    // Determine castling destination based on rook position
677                    let castling_dest = match actual_clicked {
678                        Square::H1 => Square::G1, // Kingside for white
679                        Square::A1 => Square::C1, // Queenside for white
680                        Square::H8 => Square::G8, // Kingside for black
681                        Square::A8 => Square::C8, // Queenside for black
682                        _ => {
683                            // Not a castling rook, try normal move
684                            if app.try_mouse_move(square, coords) {
685                                return Ok(());
686                            }
687                            app.game.ui.selected_square = Some(square);
688                            return Ok(());
689                        }
690                    };
691
692                    // Check if castling is legal by checking if destination is in authorized positions
693                    let authorized_positions = app
694                        .game
695                        .logic
696                        .game_board
697                        .get_authorized_positions(app.game.logic.player_turn, &actual_selected);
698
699                    if authorized_positions.contains(&castling_dest) {
700                        // Castling is legal, execute it
701                        let castling_coords = Coord::from_square(flip_square_if_needed(
702                            castling_dest,
703                            app.game.logic.game_board.is_flipped,
704                        ));
705                        if app.try_mouse_move(
706                            flip_square_if_needed(
707                                castling_dest,
708                                app.game.logic.game_board.is_flipped,
709                            ),
710                            castling_coords,
711                        ) {
712                            return Ok(());
713                        }
714                    }
715                }
716
717                // Try normal move first
718                if app.try_mouse_move(square, coords) {
719                    // Move was executed successfully
720                    return Ok(());
721                }
722            }
723            // Otherwise, select the clicked piece
724            app.game.ui.selected_square = Some(square);
725        } else {
726            // Clicked on opponent's piece - try to capture if valid
727            if app.game.ui.selected_square.is_some() {
728                app.try_mouse_move(square, coords);
729            }
730            // No piece selected and clicked opponent piece - ignore (try_execute_move handles this)
731        }
732    }
733    Ok(())
734}
735
736/// Handles keyboard input on the Lichess menu page.
737/// Supports navigation through menu items and selection.
738fn handle_lichess_menu_page_events(app: &mut App, key_event: KeyEvent) {
739    match key_event.code {
740        KeyCode::Up | KeyCode::Char('k') => app.menu_cursor_up(5), // 5 menu options
741        KeyCode::Down | KeyCode::Char('j') => app.menu_cursor_down(5),
742        KeyCode::Char(' ') | KeyCode::Enter => {
743            // Handle menu selection
744            match app.menu_cursor {
745                0 => {
746                    // Seek Game
747                    if app.lichess_token.is_none()
748                        || app
749                            .lichess_token
750                            .as_ref()
751                            .map(|t| t.is_empty())
752                            .unwrap_or(true)
753                    {
754                        // Open interactive token entry popup
755                        app.current_popup = Some(Popups::EnterLichessToken);
756                        app.game.ui.prompt.reset();
757                        app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
758                        return;
759                    }
760                    app.menu_cursor = 0;
761                    app.current_page = Pages::Lichess;
762                    app.create_lichess_opponent();
763                }
764                1 => {
765                    // Puzzle
766                    if app.lichess_token.is_none()
767                        || app
768                            .lichess_token
769                            .as_ref()
770                            .map(|t| t.is_empty())
771                            .unwrap_or(true)
772                    {
773                        // Open interactive token entry popup
774                        app.current_popup = Some(Popups::EnterLichessToken);
775                        app.game.ui.prompt.reset();
776                        app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
777                        return;
778                    }
779                    app.start_puzzle_mode();
780                }
781                2 => {
782                    // My Ongoing Games
783                    if app.lichess_token.is_none()
784                        || app
785                            .lichess_token
786                            .as_ref()
787                            .map(|t| t.is_empty())
788                            .unwrap_or(true)
789                    {
790                        // Open interactive token entry popup
791                        app.current_popup = Some(Popups::EnterLichessToken);
792                        app.game.ui.prompt.reset();
793                        app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
794                        return;
795                    }
796                    app.fetch_ongoing_games();
797                }
798                3 => {
799                    // Join by Code
800                    if app.lichess_token.is_none()
801                        || app
802                            .lichess_token
803                            .as_ref()
804                            .map(|t| t.is_empty())
805                            .unwrap_or(true)
806                    {
807                        // Open interactive token entry popup
808                        app.current_popup = Some(Popups::EnterLichessToken);
809                        app.game.ui.prompt.reset();
810                        app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
811                        return;
812                    }
813                    app.current_popup = Some(Popups::EnterGameCode);
814                    app.game.ui.prompt.reset();
815                }
816                4 => {
817                    // Disconnect
818                    app.disconnect_lichess();
819                }
820                _ => {}
821            }
822        }
823        KeyCode::Esc | KeyCode::Char('b') => {
824            // Return to home menu
825            app.menu_cursor = 0;
826            app.current_page = Pages::Home;
827        }
828        KeyCode::Char('?') => app.toggle_help_popup(),
829        _ => fallback_key_handler(app, key_event),
830    }
831}
832
833fn handle_ongoing_games_page_events(app: &mut App, key_event: KeyEvent) {
834    match key_event.code {
835        KeyCode::Up | KeyCode::Char('k') => {
836            if app.menu_cursor > 0 {
837                app.menu_cursor -= 1;
838            }
839        }
840        KeyCode::Down | KeyCode::Char('j') => {
841            if (app.menu_cursor as usize) < app.ongoing_games.len().saturating_sub(1) {
842                app.menu_cursor += 1;
843            }
844        }
845        KeyCode::Enter | KeyCode::Char(' ') => {
846            app.select_ongoing_game();
847        }
848        KeyCode::Char('r') | KeyCode::Char('R') => {
849            // Resign game
850            app.show_resign_confirmation();
851        }
852        KeyCode::Esc | KeyCode::Char('b') => {
853            app.menu_cursor = 0;
854            app.current_page = Pages::LichessMenu;
855        }
856        KeyCode::Char('?') => app.toggle_help_popup(),
857        _ => fallback_key_handler(app, key_event),
858    }
859}