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