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    match key_event.code {
311        KeyCode::Up | KeyCode::Char('k') => app.menu_cursor_up(8), // 8 menu items: Local game, Multiplayer, Lichess, Bot, Skin, Sound, Help, About
312        KeyCode::Down | KeyCode::Char('j') => app.menu_cursor_down(8), // 8 menu items
313        // If on skin selection menu item (index 3), use left/right to cycle skins
314        KeyCode::Left | KeyCode::Char('h') if app.menu_cursor == 3 => {
315            app.cycle_skin_backward();
316            app.update_config();
317        }
318        KeyCode::Right | KeyCode::Char('l') if app.menu_cursor == 3 => {
319            app.cycle_skin();
320            app.update_config();
321        }
322        KeyCode::Char(' ') | KeyCode::Enter => app.menu_select(),
323        KeyCode::Char('?') => app.toggle_help_popup(),
324        _ => fallback_key_handler(app, key_event),
325    }
326}
327
328/// Handles keyboard input during solo (two-player) game mode.
329fn handle_solo_page_events(app: &mut App, key_event: KeyEvent) {
330    match key_event.code {
331        KeyCode::Char('r') => app.restart(), // Restart current game
332        KeyCode::Char('b') => {
333            // Return to home menu - reset all game state
334            app.reset_home();
335        }
336        KeyCode::Char('n' | 'N') => {
337            // In puzzle mode, 'n' is used for new puzzle (handled in popup)
338            // Otherwise, navigate to next position in history
339            if app.puzzle_game.is_none()
340                && app.game.logic.game_state != GameState::Checkmate
341                && app.game.logic.game_state != GameState::Draw
342            {
343                app.navigate_history_next();
344            }
345        }
346        KeyCode::Char('p' | 'P') => {
347            // Navigate to previous position in history (only if game hasn't ended and not in puzzle mode)
348            if app.puzzle_game.is_none()
349                && app.game.logic.game_state != GameState::Checkmate
350                && app.game.logic.game_state != GameState::Draw
351            {
352                app.navigate_history_previous();
353            }
354        }
355        KeyCode::Char('t' | 'T') if app.puzzle_game.is_some() && app.current_popup.is_none() => {
356            // Show hint in puzzle mode (only when no popup is active)
357            app.show_puzzle_hint();
358        }
359        _ => chess_inputs(app, key_event), // Delegate chess-specific inputs
360    }
361}
362
363/// Handles chess-specific keyboard inputs (cursor movement, piece selection, etc.).
364///
365/// This function processes inputs that are common across all game modes (solo, bot, multiplayer).
366/// The behavior varies based on the current game state (Playing, Promotion, Checkmate, Draw).
367fn chess_inputs(app: &mut App, key_event: KeyEvent) {
368    let is_playing = app.game.logic.game_state == GameState::Playing;
369
370    match key_event.code {
371        // Vertical cursor movement (only during active play)
372        KeyCode::Up | KeyCode::Char('k') if is_playing => app.go_up_in_game(),
373        KeyCode::Down | KeyCode::Char('j') if is_playing => app.go_down_in_game(),
374
375        // Horizontal cursor movement - behavior depends on game state
376        KeyCode::Right | KeyCode::Char('l') => match app.game.logic.game_state {
377            GameState::Promotion => {
378                // Always allow promotion cursor movement, regardless of turn or page
379                app.game.ui.cursor_right_promotion();
380            }
381            GameState::Playing => {
382                // In Lichess mode, only allow board cursor movement if it's our turn
383                if app.current_page == Pages::Lichess {
384                    if let Some(my_color) = app.selected_color {
385                        if app.game.logic.player_turn == my_color {
386                            app.go_right_in_game();
387                        }
388                    }
389                } else {
390                    app.go_right_in_game();
391                }
392            }
393            _ => (),
394        },
395        KeyCode::Left | KeyCode::Char('h') => match app.game.logic.game_state {
396            GameState::Promotion => {
397                // Always allow promotion cursor movement, regardless of turn or page
398                app.game.ui.cursor_left_promotion();
399            }
400            GameState::Playing => {
401                // In Lichess mode, only allow board cursor movement if it's our turn
402                if app.current_page == Pages::Lichess {
403                    if let Some(my_color) = app.selected_color {
404                        if app.game.logic.player_turn == my_color {
405                            app.go_left_in_game();
406                        }
407                    }
408                } else {
409                    app.go_left_in_game();
410                }
411            }
412            GameState::Checkmate | GameState::Draw => {
413                // Toggle end screen visibility when game is over
414                // If popup is shown, hide it; if hidden and dismissed, show it again
415                if app.current_popup == Some(Popups::EndScreen) {
416                    // Hide the end screen
417                    app.current_popup = None;
418                    app.end_screen_dismissed = true;
419                } else if app.end_screen_dismissed {
420                    // Show the end screen again if it was dismissed (toggle back)
421                    app.end_screen_dismissed = false;
422                    app.show_end_screen();
423                } else {
424                    // Show the end screen if it hasn't been shown yet
425                    app.show_end_screen();
426                }
427            }
428        },
429        // Select/move piece or confirm action
430        KeyCode::Char(' ') | KeyCode::Enter => {
431            // In Lichess mode, only allow input if it's our turn
432            app.process_cell_click();
433        }
434        KeyCode::Char('?') => app.toggle_help_popup(), // Toggle help popup
435        KeyCode::Char('s' | 'S') => {
436            app.cycle_skin(); // Cycle through available skins
437            app.update_config();
438        }
439        KeyCode::Esc => app.game.ui.unselect_cell(), // Deselect piece
440        _ => fallback_key_handler(app, key_event),
441    }
442}
443
444/// Handles keyboard input during multiplayer game mode.
445/// Similar to solo mode but includes cleanup of network connections.
446fn handle_multiplayer_page_events(app: &mut App, key_event: KeyEvent) {
447    match key_event.code {
448        KeyCode::Char('b') => {
449            // Return to home menu - disconnect from opponent and reset state
450            app.reset_home();
451        }
452
453        _ => chess_inputs(app, key_event), // Delegate chess-specific inputs
454    }
455}
456
457/// Handles keyboard input when playing against a bot.
458/// Includes restart functionality and bot state cleanup.
459fn handle_bot_page_events(app: &mut App, key_event: KeyEvent) {
460    match key_event.code {
461        KeyCode::Char('r') => app.restart(), // Restart current game
462        KeyCode::Char('b') => {
463            // Return to home menu - clean up bot and reset state
464            app.reset_home();
465        }
466        _ => chess_inputs(app, key_event), // Delegate chess-specific inputs
467    }
468}
469
470/// Handles keyboard input on the credits page.
471fn handle_credit_page_events(app: &mut App, key_event: KeyEvent) {
472    match key_event.code {
473        KeyCode::Char(' ') | KeyCode::Esc | KeyCode::Enter => app.toggle_credit_popup(),
474        _ => fallback_key_handler(app, key_event),
475    }
476}
477
478/// Fallback handler for keys that aren't handled by specific page/popup handlers.
479/// Provides global shortcuts like quit that work from anywhere.
480fn fallback_key_handler(app: &mut App, key_event: KeyEvent) {
481    match key_event.code {
482        KeyCode::Char('q') => app.quit(), // Quit application
483        KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => app.quit(), // Ctrl+C to quit
484        KeyCode::Char('s') => app.cycle_skin(), // Cycle through available skins
485        _ => (),                                // Ignore other keys
486    }
487}
488
489/// Handles mouse click events for piece selection and movement.
490///
491/// Mouse input is only active during game pages (Solo, Bot, Multiplayer).
492/// Handles both board clicks and promotion selection clicks.
493pub fn handle_mouse_events(mouse_event: MouseEvent, app: &mut App) -> AppResult<()> {
494    // Mouse control only implemented for game pages, not home or credits
495    if app.current_page == Pages::Home || app.current_page == Pages::Credit {
496        return Ok(());
497    }
498
499    // Only process left mouse button clicks
500    if mouse_event.kind == MouseEventKind::Down(MouseButton::Left) {
501        // Ignore clicks when game has ended
502        if app.game.logic.game_state == GameState::Checkmate
503            || app.game.logic.game_state == GameState::Draw
504        {
505            return Ok(());
506        }
507        // Ignore clicks when a popup is active
508        if app.current_popup.is_some() {
509            return Ok(());
510        }
511
512        // Handle promotion piece selection via mouse
513        // Note: Promotion state should always allow input, even if turn has switched
514        // because the player needs to select the promotion piece after making the move
515        if app.game.logic.game_state == GameState::Promotion {
516            // Calculate which promotion option was clicked (0-3 for Queen, Rook, Bishop, Knight)
517            let x = (mouse_event.column - app.game.ui.top_x) / app.game.ui.width;
518            let y = (mouse_event.row - app.game.ui.top_y) / app.game.ui.height;
519            if x > 3 || y > 0 {
520                return Ok(()); // Click outside promotion area
521            }
522            app.game.ui.promotion_cursor = x as i8;
523
524            // Track if the move was correct (for puzzle mode)
525            let mut move_was_correct = true;
526
527            // If we have a pending promotion move, validate it now with the selected promotion piece
528            if let Some((from, to)) = app.pending_promotion_move.take() {
529                // Get the promotion piece from the cursor
530                let promotion_char = match app.game.ui.promotion_cursor {
531                    0 => 'q', // Queen
532                    1 => 'r', // Rook
533                    2 => 'b', // Bishop
534                    3 => 'n', // Knight
535                    _ => 'q', // Default to queen
536                };
537
538                // Construct full UCI move with promotion piece
539                let move_uci = format!("{}{}{}", from, to, promotion_char);
540
541                // Validate the puzzle move with the complete UCI
542                if app.puzzle_game.is_some() {
543                    if let Some(mut puzzle_game) = app.puzzle_game.take() {
544                        let (is_correct, message) = puzzle_game.validate_move(
545                            move_uci,
546                            &mut app.game,
547                            app.lichess_token.clone(),
548                        );
549
550                        move_was_correct = is_correct;
551                        app.puzzle_game = Some(puzzle_game);
552
553                        if let Some(msg) = message {
554                            if is_correct {
555                                app.error_message = Some(msg);
556                                app.current_popup = Some(Popups::PuzzleEndScreen);
557                            } else {
558                                app.error_message = Some(msg);
559                                app.current_popup = Some(Popups::Error);
560                            }
561                        }
562                    }
563                }
564            }
565
566            // Only handle promotion if the move was correct (or not in puzzle mode)
567            // If incorrect, reset_last_move already removed the move and reset the state
568            if move_was_correct || app.puzzle_game.is_none() {
569                // Don't flip board in puzzle mode
570                let should_flip = app.puzzle_game.is_none();
571                app.game.handle_promotion(should_flip);
572            } else {
573                // Move was incorrect in puzzle mode - ensure game state is reset
574                // reset_last_move should have already handled this, but make sure
575                if app.game.logic.game_state == GameState::Promotion {
576                    app.game.logic.game_state = GameState::Playing;
577                }
578            }
579            // Notify opponent in multiplayer games
580            if app.game.logic.opponent.is_some() {
581                app.game.logic.handle_multiplayer_promotion();
582            }
583            return Ok(());
584        }
585
586        // In Lichess mode, only allow input if it's our turn (but not for promotion, handled above)
587        if app.current_page == Pages::Lichess {
588            if let Some(my_color) = app.selected_color {
589                if app.game.logic.player_turn != my_color {
590                    return Ok(());
591                }
592            }
593        }
594
595        // Validate click is within board boundaries
596        if mouse_event.column < app.game.ui.top_x || mouse_event.row < app.game.ui.top_y {
597            return Ok(());
598        }
599        if app.game.ui.width == 0 || app.game.ui.height == 0 {
600            return Ok(());
601        }
602
603        // Calculate which board square was clicked (0-7 for both x and y)
604        let x = (mouse_event.column - app.game.ui.top_x) / app.game.ui.width;
605        let y = (mouse_event.row - app.game.ui.top_y) / app.game.ui.height;
606        if x > 7 || y > 7 {
607            return Ok(()); // Click outside board
608        }
609
610        // Mark that mouse was used (affects keyboard cursor positioning)
611        app.game.ui.mouse_used = true;
612        let coords: Coord = Coord::new(y as u8, x as u8);
613
614        // Convert coordinates to board square, handling board flip if needed
615        let square = match coords.try_to_square() {
616            Some(s) => s,
617            None => return Ok(()), // Invalid coordinates, ignore click
618        };
619
620        // Get piece color at clicked square (accounting for board flip)
621        let piece_color =
622            app.game
623                .logic
624                .game_board
625                .get_piece_color_at_square(&flip_square_if_needed(
626                    square,
627                    app.game.logic.game_board.is_flipped,
628                ));
629
630        // Handle click on empty square
631        if piece_color.is_none() {
632            // If no piece was previously selected, ignore the click
633            if app.game.ui.selected_square.is_none() {
634                return Ok(());
635            } else {
636                // Piece was selected - try to execute move to empty square
637                app.try_mouse_move(square, coords);
638            }
639        }
640        // Handle click on square with a piece
641        else if piece_color == Some(app.game.logic.player_turn) {
642            // Clicked on own piece
643            // First check if we have a piece selected and this square is a valid move destination
644            if let Some(selected_square) = app.game.ui.selected_square {
645                // Check if this is a castling attempt: king selected, rook clicked
646                let actual_selected =
647                    flip_square_if_needed(selected_square, app.game.logic.game_board.is_flipped);
648                let actual_clicked =
649                    flip_square_if_needed(square, app.game.logic.game_board.is_flipped);
650
651                let selected_role = app
652                    .game
653                    .logic
654                    .game_board
655                    .get_role_at_square(&actual_selected);
656                let clicked_role = app
657                    .game
658                    .logic
659                    .game_board
660                    .get_role_at_square(&actual_clicked);
661
662                // Check if king is selected and rook is clicked
663                if selected_role == Some(Role::King) && clicked_role == Some(Role::Rook) {
664                    // Determine castling destination based on rook position
665                    let castling_dest = match actual_clicked {
666                        Square::H1 => Square::G1, // Kingside for white
667                        Square::A1 => Square::C1, // Queenside for white
668                        Square::H8 => Square::G8, // Kingside for black
669                        Square::A8 => Square::C8, // Queenside for black
670                        _ => {
671                            // Not a castling rook, try normal move
672                            if app.try_mouse_move(square, coords) {
673                                return Ok(());
674                            }
675                            app.game.ui.selected_square = Some(square);
676                            return Ok(());
677                        }
678                    };
679
680                    // Check if castling is legal by checking if destination is in authorized positions
681                    let authorized_positions = app
682                        .game
683                        .logic
684                        .game_board
685                        .get_authorized_positions(app.game.logic.player_turn, &actual_selected);
686
687                    if authorized_positions.contains(&castling_dest) {
688                        // Castling is legal, execute it
689                        let castling_coords = Coord::from_square(flip_square_if_needed(
690                            castling_dest,
691                            app.game.logic.game_board.is_flipped,
692                        ));
693                        if app.try_mouse_move(
694                            flip_square_if_needed(
695                                castling_dest,
696                                app.game.logic.game_board.is_flipped,
697                            ),
698                            castling_coords,
699                        ) {
700                            return Ok(());
701                        }
702                    }
703                }
704
705                // Try normal move first
706                if app.try_mouse_move(square, coords) {
707                    // Move was executed successfully
708                    return Ok(());
709                }
710            }
711            // Otherwise, select the clicked piece
712            app.game.ui.selected_square = Some(square);
713        } else {
714            // Clicked on opponent's piece - try to capture if valid
715            if app.game.ui.selected_square.is_some() {
716                app.try_mouse_move(square, coords);
717            }
718            // No piece selected and clicked opponent piece - ignore (try_execute_move handles this)
719        }
720    }
721    Ok(())
722}
723
724/// Handles keyboard input on the Lichess menu page.
725/// Supports navigation through menu items and selection.
726fn handle_lichess_menu_page_events(app: &mut App, key_event: KeyEvent) {
727    match key_event.code {
728        KeyCode::Up | KeyCode::Char('k') => app.menu_cursor_up(5), // 5 menu options
729        KeyCode::Down | KeyCode::Char('j') => app.menu_cursor_down(5),
730        KeyCode::Char(' ') | KeyCode::Enter => {
731            // Handle menu selection
732            match app.menu_cursor {
733                0 => {
734                    // Seek Game
735                    if app.lichess_token.is_none()
736                        || app
737                            .lichess_token
738                            .as_ref()
739                            .map(|t| t.is_empty())
740                            .unwrap_or(true)
741                    {
742                        // Open interactive token entry popup
743                        app.current_popup = Some(Popups::EnterLichessToken);
744                        app.game.ui.prompt.reset();
745                        app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
746                        return;
747                    }
748                    app.menu_cursor = 0;
749                    app.current_page = Pages::Lichess;
750                    app.create_lichess_opponent();
751                }
752                1 => {
753                    // Puzzle
754                    if app.lichess_token.is_none()
755                        || app
756                            .lichess_token
757                            .as_ref()
758                            .map(|t| t.is_empty())
759                            .unwrap_or(true)
760                    {
761                        // Open interactive token entry popup
762                        app.current_popup = Some(Popups::EnterLichessToken);
763                        app.game.ui.prompt.reset();
764                        app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
765                        return;
766                    }
767                    app.start_puzzle_mode();
768                }
769                2 => {
770                    // My Ongoing Games
771                    if app.lichess_token.is_none()
772                        || app
773                            .lichess_token
774                            .as_ref()
775                            .map(|t| t.is_empty())
776                            .unwrap_or(true)
777                    {
778                        // Open interactive token entry popup
779                        app.current_popup = Some(Popups::EnterLichessToken);
780                        app.game.ui.prompt.reset();
781                        app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
782                        return;
783                    }
784                    app.fetch_ongoing_games();
785                }
786                3 => {
787                    // Join by Code
788                    if app.lichess_token.is_none()
789                        || app
790                            .lichess_token
791                            .as_ref()
792                            .map(|t| t.is_empty())
793                            .unwrap_or(true)
794                    {
795                        // Open interactive token entry popup
796                        app.current_popup = Some(Popups::EnterLichessToken);
797                        app.game.ui.prompt.reset();
798                        app.game.ui.prompt.message = "Enter your Lichess API token:".to_string();
799                        return;
800                    }
801                    app.current_popup = Some(Popups::EnterGameCode);
802                    app.game.ui.prompt.reset();
803                }
804                4 => {
805                    // Disconnect
806                    app.disconnect_lichess();
807                }
808                _ => {}
809            }
810        }
811        KeyCode::Esc | KeyCode::Char('b') => {
812            // Return to home menu
813            app.menu_cursor = 0;
814            app.current_page = Pages::Home;
815        }
816        KeyCode::Char('?') => app.toggle_help_popup(),
817        _ => fallback_key_handler(app, key_event),
818    }
819}
820
821fn handle_ongoing_games_page_events(app: &mut App, key_event: KeyEvent) {
822    match key_event.code {
823        KeyCode::Up | KeyCode::Char('k') => {
824            if app.menu_cursor > 0 {
825                app.menu_cursor -= 1;
826            }
827        }
828        KeyCode::Down | KeyCode::Char('j') => {
829            if (app.menu_cursor as usize) < app.ongoing_games.len().saturating_sub(1) {
830                app.menu_cursor += 1;
831            }
832        }
833        KeyCode::Enter | KeyCode::Char(' ') => {
834            app.select_ongoing_game();
835        }
836        KeyCode::Char('r') | KeyCode::Char('R') => {
837            // Resign game
838            app.show_resign_confirmation();
839        }
840        KeyCode::Esc | KeyCode::Char('b') => {
841            app.menu_cursor = 0;
842            app.current_page = Pages::LichessMenu;
843        }
844        KeyCode::Char('?') => app.toggle_help_popup(),
845        _ => fallback_key_handler(app, key_event),
846    }
847}