Skip to main content

romm_cli/tui/
app.rs

1//! Application state and TUI event loop.
2//!
3//! The `App` struct owns long-lived state (config, HTTP client, cache,
4//! downloads, and the currently active `AppScreen`). It drives a simple
5//! state machine:
6//! - render the current screen,
7//! - wait for input,
8//! - dispatch the key to a small handler per screen.
9//!
10//! This is intentionally separated from the drawing code in `screens/`
11//! so that alternative frontends can reuse the same \"backend\" services.
12
13use anyhow::Result;
14use crossterm::event::{
15    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
16};
17use crossterm::execute;
18use crossterm::terminal::{
19    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
20};
21use ratatui::backend::CrosstermBackend;
22use ratatui::style::Color;
23use ratatui::Terminal;
24use std::time::Duration;
25
26use crate::client::RommClient;
27use crate::config::Config;
28use crate::core::cache::{RomCache, RomCacheKey};
29use crate::core::download::DownloadManager;
30use crate::endpoints::{collections::ListCollections, platforms::ListPlatforms, roms::GetRoms};
31use crate::types::RomList;
32
33use super::openapi::{resolve_path_template, EndpointRegistry};
34use super::screens::connected_splash::{self, StartupSplash};
35use super::screens::setup_wizard::SetupWizard;
36use super::screens::{
37    BrowseScreen, DownloadScreen, ExecuteScreen, GameDetailPrevious, GameDetailScreen,
38    LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen, SearchScreen,
39    SettingsScreen,
40};
41
42// ---------------------------------------------------------------------------
43// Screen enum
44// ---------------------------------------------------------------------------
45
46/// All possible high-level screens in the TUI.
47///
48/// `App` holds exactly one of these at a time and delegates both
49/// rendering and key handling based on the current variant.
50pub enum AppScreen {
51    MainMenu(MainMenuScreen),
52    LibraryBrowse(LibraryBrowseScreen),
53    Search(SearchScreen),
54    Settings(SettingsScreen),
55    Browse(BrowseScreen),
56    Execute(ExecuteScreen),
57    Result(ResultScreen),
58    ResultDetail(ResultDetailScreen),
59    GameDetail(Box<GameDetailScreen>),
60    Download(DownloadScreen),
61    SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
62}
63
64// ---------------------------------------------------------------------------
65// App
66// ---------------------------------------------------------------------------
67
68/// Root application object for the TUI.
69///
70/// Owns shared services (`RommClient`, `RomCache`, `DownloadManager`)
71/// as well as the currently active [`AppScreen`].
72pub struct App {
73    screen: AppScreen,
74    client: RommClient,
75    config: Config,
76    registry: EndpointRegistry,
77    /// RomM server version from `GET /api/heartbeat` (`SYSTEM.VERSION`), if available.
78    server_version: Option<String>,
79    rom_cache: RomCache,
80    downloads: DownloadManager,
81    /// Screen to restore when closing the Download overlay.
82    screen_before_download: Option<AppScreen>,
83    /// Deferred ROM load: (cache_key, api_request, expected_rom_count).
84    deferred_load_roms: Option<(Option<RomCacheKey>, Option<GetRoms>, u64)>,
85    /// Brief “connected” banner after setup or when the server responds to heartbeat.
86    startup_splash: Option<StartupSplash>,
87}
88
89impl App {
90    /// Construct a new `App` with fresh cache and empty download list.
91    pub fn new(
92        client: RommClient,
93        config: Config,
94        registry: EndpointRegistry,
95        server_version: Option<String>,
96        startup_splash: Option<StartupSplash>,
97    ) -> Self {
98        Self {
99            screen: AppScreen::MainMenu(MainMenuScreen::new()),
100            client,
101            config,
102            registry,
103            server_version,
104            rom_cache: RomCache::load(),
105            downloads: DownloadManager::new(),
106            screen_before_download: None,
107            deferred_load_roms: None,
108            startup_splash,
109        }
110    }
111
112    // -----------------------------------------------------------------------
113    // Event loop
114    // -----------------------------------------------------------------------
115
116    /// Main TUI event loop.
117    ///
118    /// This method owns the terminal for the lifetime of the app,
119    /// repeatedly drawing the current screen and dispatching key
120    /// events until the user chooses to quit.
121    pub async fn run(&mut self) -> Result<()> {
122        enable_raw_mode()?;
123        let mut stdout = std::io::stdout();
124        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
125        let backend = CrosstermBackend::new(stdout);
126        let mut terminal = Terminal::new(backend)?;
127
128        loop {
129            if self
130                .startup_splash
131                .as_ref()
132                .is_some_and(|s| s.should_auto_dismiss())
133            {
134                self.startup_splash = None;
135            }
136            // Draw the current screen. `App::render` delegates to the
137            // appropriate screen type based on `self.screen`.
138            terminal.draw(|f| self.render(f))?;
139
140            // Poll with a short timeout so the UI refreshes during downloads
141            // even when the user is not pressing any keys.
142            if event::poll(Duration::from_millis(100))? {
143                if let Event::Key(key) = event::read()? {
144                    if key.kind == KeyEventKind::Press && self.handle_key(key.code).await? {
145                        break;
146                    }
147                }
148            }
149
150            // Process deferred ROM fetch (set during LibraryBrowse ↑/↓).
151            // This avoids borrowing `self` mutably in two places at once:
152            // the screen handler only *records* the intent to load ROMs,
153            // and the actual HTTP call happens here after rendering.
154            if let Some((key, req, expected)) = self.deferred_load_roms.take() {
155                if let Ok(Some(roms)) = self.load_roms_cached(key, req, expected).await {
156                    if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
157                        lib.set_roms(roms);
158                    }
159                }
160            }
161        }
162
163        disable_raw_mode()?;
164        execute!(
165            terminal.backend_mut(),
166            LeaveAlternateScreen,
167            DisableMouseCapture
168        )?;
169        terminal.show_cursor()?;
170        Ok(())
171    }
172
173    // -----------------------------------------------------------------------
174    // Cache helper
175    // -----------------------------------------------------------------------
176
177    /// Fetch ROMs from the persistent cache if the count still matches,
178    /// otherwise hit the API and update the cache on disk.
179    async fn load_roms_cached(
180        &mut self,
181        key: Option<RomCacheKey>,
182        req: Option<GetRoms>,
183        expected_count: u64,
184    ) -> Result<Option<RomList>> {
185        // Try the disk-backed cache first.
186        if let Some(k) = key {
187            if let Some(cached) = self.rom_cache.get_valid(&k, expected_count) {
188                return Ok(Some(cached.clone()));
189            }
190        }
191        // Cache miss or stale — fetch fresh data from the API.
192        if let Some(r) = req {
193            let mut roms = self.client.call(&r).await?;
194            let total = roms.total;
195            let ceiling = 20000;
196
197            // The RomM API has a default limit (often 500) even if we request more.
198            // Loop until the items list is complete or we hit the ceiling.
199            while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
200                let mut next_req = r.clone();
201                next_req.offset = Some(roms.items.len() as u32);
202
203                let next_batch = self.client.call(&next_req).await?;
204                if next_batch.items.is_empty() {
205                    break;
206                }
207                roms.items.extend(next_batch.items);
208            }
209
210            if let Some(k) = key {
211                self.rom_cache.insert(k, roms.clone(), expected_count); // also persists to disk
212            }
213            return Ok(Some(roms));
214        }
215        Ok(None)
216    }
217
218    // -----------------------------------------------------------------------
219    // Key dispatch — one small method per screen
220    // -----------------------------------------------------------------------
221
222    async fn handle_key(&mut self, key: KeyCode) -> Result<bool> {
223        if self.startup_splash.is_some() {
224            self.startup_splash = None;
225            return Ok(false);
226        }
227
228        // Global shortcut: 'd' toggles Download overlay (except on Search).
229        if key == KeyCode::Char('d') && !matches!(&self.screen, AppScreen::Search(_)) {
230            self.toggle_download_screen();
231            return Ok(false);
232        }
233
234        match &self.screen {
235            AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
236            AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
237            AppScreen::Search(_) => self.handle_search(key).await,
238            AppScreen::Settings(_) => self.handle_settings(key),
239            AppScreen::Browse(_) => self.handle_browse(key),
240            AppScreen::Execute(_) => self.handle_execute(key).await,
241            AppScreen::Result(_) => self.handle_result(key),
242            AppScreen::ResultDetail(_) => self.handle_result_detail(key),
243            AppScreen::GameDetail(_) => self.handle_game_detail(key),
244            AppScreen::Download(_) => self.handle_download(key),
245            AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
246        }
247    }
248
249    // -- Download overlay ---------------------------------------------------
250
251    fn toggle_download_screen(&mut self) {
252        let current =
253            std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
254        match current {
255            AppScreen::Download(_) => {
256                self.screen = self
257                    .screen_before_download
258                    .take()
259                    .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
260            }
261            other => {
262                self.screen_before_download = Some(other);
263                self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
264            }
265        }
266    }
267
268    fn handle_download(&mut self, key: KeyCode) -> Result<bool> {
269        if key == KeyCode::Esc || key == KeyCode::Char('d') {
270            self.screen = self
271                .screen_before_download
272                .take()
273                .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
274        }
275        Ok(false)
276    }
277
278    // -- Main menu ----------------------------------------------------------
279
280    async fn handle_main_menu(&mut self, key: KeyCode) -> Result<bool> {
281        let menu = match &mut self.screen {
282            AppScreen::MainMenu(m) => m,
283            _ => return Ok(false),
284        };
285        match key {
286            KeyCode::Up | KeyCode::Char('k') => menu.previous(),
287            KeyCode::Down | KeyCode::Char('j') => menu.next(),
288            KeyCode::Enter => match menu.selected {
289                0 => {
290                    let platforms = self.client.call(&ListPlatforms).await?;
291                    let collections = self.client.call(&ListCollections).await.unwrap_or_default();
292                    let mut lib = LibraryBrowseScreen::new(platforms, collections);
293                    if lib.list_len() > 0 {
294                        let key = lib.cache_key();
295                        let expected = lib.expected_rom_count();
296                        let req = lib
297                            .get_roms_request_platform()
298                            .or_else(|| lib.get_roms_request_collection());
299                        if let Ok(Some(roms)) = self.load_roms_cached(key, req, expected).await {
300                            lib.set_roms(roms);
301                        }
302                    }
303                    self.screen = AppScreen::LibraryBrowse(lib);
304                }
305                1 => self.screen = AppScreen::Search(SearchScreen::new()),
306                2 => {
307                    self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
308                    self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
309                }
310                3 => {
311                    self.screen = AppScreen::Settings(SettingsScreen::new(
312                        &self.config,
313                        self.server_version.as_deref(),
314                    ))
315                }
316                4 => self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone())),
317                5 => return Ok(true),
318                _ => {}
319            },
320            KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
321            _ => {}
322        }
323        Ok(false)
324    }
325
326    // -- Library browse -----------------------------------------------------
327
328    async fn handle_library_browse(&mut self, key: KeyCode) -> Result<bool> {
329        use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};
330
331        let lib = match &mut self.screen {
332            AppScreen::LibraryBrowse(l) => l,
333            _ => return Ok(false),
334        };
335
336        // If in search mode, intercept typing keys.
337        if let Some(mode) = lib.search_mode {
338            match key {
339                KeyCode::Esc => lib.clear_search(),
340                KeyCode::Backspace => lib.delete_search_char(),
341                KeyCode::Char(c) => lib.add_search_char(c),
342                KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_to_match(true),
343                KeyCode::Enter => lib.search_mode = None, // Commit search (keep filtered/position)
344                _ => {}
345            }
346            return Ok(false);
347        }
348
349        match key {
350            KeyCode::Up | KeyCode::Char('k') => {
351                if lib.view_mode == LibraryViewMode::List {
352                    lib.list_previous();
353                    if lib.list_len() > 0 {
354                        lib.clear_roms(); // avoid showing previous console's games
355                        let key = lib.cache_key();
356                        let expected = lib.expected_rom_count();
357                        let req = lib
358                            .get_roms_request_platform()
359                            .or_else(|| lib.get_roms_request_collection());
360                        self.deferred_load_roms = Some((key, req, expected));
361                    }
362                } else {
363                    lib.rom_previous();
364                }
365            }
366            KeyCode::Down | KeyCode::Char('j') => {
367                if lib.view_mode == LibraryViewMode::List {
368                    lib.list_next();
369                    if lib.list_len() > 0 {
370                        lib.clear_roms(); // avoid showing previous console's games
371                        let key = lib.cache_key();
372                        let expected = lib.expected_rom_count();
373                        let req = lib
374                            .get_roms_request_platform()
375                            .or_else(|| lib.get_roms_request_collection());
376                        self.deferred_load_roms = Some((key, req, expected));
377                    }
378                } else {
379                    lib.rom_next();
380                }
381            }
382            KeyCode::Left | KeyCode::Char('h') => {
383                if lib.view_mode == LibraryViewMode::Roms {
384                    lib.back_to_list();
385                }
386            }
387            KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
388            KeyCode::Tab => {
389                if lib.view_mode == LibraryViewMode::List {
390                    lib.switch_view();
391                } else {
392                    lib.switch_view(); // Normal tab also switches panels
393                }
394            }
395            KeyCode::Char('/') if lib.view_mode == LibraryViewMode::Roms => {
396                lib.enter_search(LibrarySearchMode::Filter);
397            }
398            KeyCode::Char('f') if lib.view_mode == LibraryViewMode::Roms => {
399                lib.enter_search(LibrarySearchMode::Jump);
400            }
401            KeyCode::Enter => {
402                if lib.view_mode == LibraryViewMode::List {
403                    lib.switch_view();
404                } else if let Some((primary, others)) = lib.get_selected_group() {
405                    let lib_screen = std::mem::replace(
406                        &mut self.screen,
407                        AppScreen::MainMenu(MainMenuScreen::new()),
408                    );
409                    if let AppScreen::LibraryBrowse(l) = lib_screen {
410                        self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
411                            primary,
412                            others,
413                            GameDetailPrevious::Library(l),
414                            self.downloads.shared(),
415                        )));
416                    }
417                }
418            }
419            KeyCode::Char('t') => lib.switch_subsection(),
420            KeyCode::Esc => {
421                if lib.view_mode == LibraryViewMode::Roms {
422                    lib.back_to_list();
423                } else {
424                    self.screen = AppScreen::MainMenu(MainMenuScreen::new());
425                }
426            }
427            KeyCode::Char('q') => return Ok(true),
428            _ => {}
429        }
430        Ok(false)
431    }
432
433    // -- Search -------------------------------------------------------------
434
435    async fn handle_search(&mut self, key: KeyCode) -> Result<bool> {
436        let search = match &mut self.screen {
437            AppScreen::Search(s) => s,
438            _ => return Ok(false),
439        };
440        match key {
441            KeyCode::Backspace => search.delete_char(),
442            KeyCode::Left => search.cursor_left(),
443            KeyCode::Right => search.cursor_right(),
444            KeyCode::Up => search.previous(),
445            KeyCode::Down => search.next(),
446            KeyCode::Char(c) => search.add_char(c),
447            KeyCode::Enter => {
448                if search.result_groups.is_some() {
449                    if let Some((primary, others)) = search.get_selected_group() {
450                        let prev = std::mem::replace(
451                            &mut self.screen,
452                            AppScreen::MainMenu(MainMenuScreen::new()),
453                        );
454                        if let AppScreen::Search(s) = prev {
455                            self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
456                                primary,
457                                others,
458                                GameDetailPrevious::Search(s),
459                                self.downloads.shared(),
460                            )));
461                        }
462                    }
463                } else if !search.query.is_empty() {
464                    let req = GetRoms {
465                        search_term: Some(search.query.clone()),
466                        limit: Some(50),
467                        ..Default::default()
468                    };
469                    if let Ok(roms) = self.client.call(&req).await {
470                        search.set_results(roms);
471                    }
472                }
473            }
474            KeyCode::Esc => {
475                if search.results.is_some() {
476                    search.clear_results();
477                } else {
478                    self.screen = AppScreen::MainMenu(MainMenuScreen::new());
479                }
480            }
481            _ => {}
482        }
483        Ok(false)
484    }
485
486    // -- Settings -----------------------------------------------------------
487
488    fn handle_settings(&mut self, key: KeyCode) -> Result<bool> {
489        let settings = match &mut self.screen {
490            AppScreen::Settings(s) => s,
491            _ => return Ok(false),
492        };
493
494        if settings.editing {
495            match key {
496                KeyCode::Enter => {
497                    settings.save_edit();
498                }
499                KeyCode::Esc => settings.cancel_edit(),
500                KeyCode::Backspace => settings.delete_char(),
501                KeyCode::Left => settings.move_cursor_left(),
502                KeyCode::Right => settings.move_cursor_right(),
503                KeyCode::Char(c) => settings.add_char(c),
504                _ => {}
505            }
506            return Ok(false);
507        }
508
509        match key {
510            KeyCode::Up | KeyCode::Char('k') => settings.previous(),
511            KeyCode::Down | KeyCode::Char('j') => settings.next(),
512            KeyCode::Enter => {
513                if settings.selected_index == 3 {
514                    self.screen =
515                        AppScreen::SetupWizard(Box::new(SetupWizard::new_auth_only(&self.config)));
516                } else {
517                    settings.enter_edit();
518                }
519            }
520            KeyCode::Char('s') => {
521                // Save to disk
522                use crate::config::persist_user_config;
523                if let Err(e) = persist_user_config(
524                    &settings.base_url,
525                    &settings.download_dir,
526                    settings.use_https,
527                    self.config.auth.clone(),
528                ) {
529                    settings.message = Some((format!("Error saving: {e}"), Color::Red));
530                } else {
531                    settings.message = Some(("Saved to .env".to_string(), Color::Green));
532                    // Update app state
533                    self.config.base_url = settings.base_url.clone();
534                    self.config.download_dir = settings.download_dir.clone();
535                    self.config.use_https = settings.use_https;
536                    // Re-create client to pick up new base URL
537                    if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
538                        self.client = new_client;
539                    }
540                }
541            }
542            KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
543            KeyCode::Char('q') => return Ok(true),
544            _ => {}
545        }
546        Ok(false)
547    }
548
549    // -- API Browse ---------------------------------------------------------
550
551    fn handle_browse(&mut self, key: KeyCode) -> Result<bool> {
552        use super::screens::browse::ViewMode;
553
554        let browse = match &mut self.screen {
555            AppScreen::Browse(b) => b,
556            _ => return Ok(false),
557        };
558        match key {
559            KeyCode::Up | KeyCode::Char('k') => browse.previous(),
560            KeyCode::Down | KeyCode::Char('j') => browse.next(),
561            KeyCode::Left | KeyCode::Char('h') => {
562                if browse.view_mode == ViewMode::Endpoints {
563                    browse.switch_view();
564                }
565            }
566            KeyCode::Right | KeyCode::Char('l') => {
567                if browse.view_mode == ViewMode::Sections {
568                    browse.switch_view();
569                }
570            }
571            KeyCode::Tab => browse.switch_view(),
572            KeyCode::Enter => {
573                if browse.view_mode == ViewMode::Endpoints {
574                    if let Some(ep) = browse.get_selected_endpoint() {
575                        self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
576                    }
577                } else {
578                    browse.switch_view();
579                }
580            }
581            KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
582            _ => {}
583        }
584        Ok(false)
585    }
586
587    // -- Execute endpoint ---------------------------------------------------
588
589    async fn handle_execute(&mut self, key: KeyCode) -> Result<bool> {
590        let execute = match &mut self.screen {
591            AppScreen::Execute(e) => e,
592            _ => return Ok(false),
593        };
594        match key {
595            KeyCode::Tab => execute.next_field(),
596            KeyCode::BackTab => execute.previous_field(),
597            KeyCode::Char(c) => execute.add_char_to_focused(c),
598            KeyCode::Backspace => execute.delete_char_from_focused(),
599            KeyCode::Enter => {
600                let endpoint = execute.endpoint.clone();
601                let query = execute.get_query_params();
602                let body = if endpoint.has_body && !execute.body_text.is_empty() {
603                    Some(serde_json::from_str(&execute.body_text)?)
604                } else {
605                    None
606                };
607                let resolved_path =
608                    match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
609                        Ok(p) => p,
610                        Err(e) => {
611                            self.screen = AppScreen::Result(ResultScreen::new(
612                                serde_json::json!({ "error": format!("{e}") }),
613                                None,
614                                None,
615                            ));
616                            return Ok(false);
617                        }
618                    };
619                match self
620                    .client
621                    .request_json(&endpoint.method, &resolved_path, &query, body)
622                    .await
623                {
624                    Ok(result) => {
625                        self.screen = AppScreen::Result(ResultScreen::new(
626                            result,
627                            Some(&endpoint.method),
628                            Some(resolved_path.as_str()),
629                        ));
630                    }
631                    Err(e) => {
632                        self.screen = AppScreen::Result(ResultScreen::new(
633                            serde_json::json!({ "error": format!("{e}") }),
634                            None,
635                            None,
636                        ));
637                    }
638                }
639            }
640            KeyCode::Esc => {
641                self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
642            }
643            _ => {}
644        }
645        Ok(false)
646    }
647
648    // -- Result view --------------------------------------------------------
649
650    fn handle_result(&mut self, key: KeyCode) -> Result<bool> {
651        use super::screens::result::ResultViewMode;
652
653        let result = match &mut self.screen {
654            AppScreen::Result(r) => r,
655            _ => return Ok(false),
656        };
657        match key {
658            KeyCode::Up | KeyCode::Char('k') => {
659                if result.view_mode == ResultViewMode::Json {
660                    result.scroll_up(1);
661                } else {
662                    result.table_previous();
663                }
664            }
665            KeyCode::Down => {
666                if result.view_mode == ResultViewMode::Json {
667                    result.scroll_down(1);
668                } else {
669                    result.table_next();
670                }
671            }
672            KeyCode::Char('j') => {
673                if result.view_mode == ResultViewMode::Json {
674                    result.scroll_down(1);
675                }
676            }
677            KeyCode::PageUp => {
678                if result.view_mode == ResultViewMode::Table {
679                    result.table_page_up();
680                } else {
681                    result.scroll_up(10);
682                }
683            }
684            KeyCode::PageDown => {
685                if result.view_mode == ResultViewMode::Table {
686                    result.table_page_down();
687                } else {
688                    result.scroll_down(10);
689                }
690            }
691            KeyCode::Char('t') => {
692                if result.table_row_count > 0 {
693                    result.switch_view_mode();
694                }
695            }
696            KeyCode::Enter => {
697                if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 {
698                    if let Some(item) = result.get_selected_item_value() {
699                        let prev = std::mem::replace(
700                            &mut self.screen,
701                            AppScreen::MainMenu(MainMenuScreen::new()),
702                        );
703                        if let AppScreen::Result(rs) = prev {
704                            self.screen =
705                                AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
706                        }
707                    }
708                }
709            }
710            KeyCode::Esc => {
711                result.clear_message();
712                self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
713            }
714            KeyCode::Char('q') => return Ok(true),
715            _ => {}
716        }
717        Ok(false)
718    }
719
720    // -- Result detail ------------------------------------------------------
721
722    fn handle_result_detail(&mut self, key: KeyCode) -> Result<bool> {
723        let detail = match &mut self.screen {
724            AppScreen::ResultDetail(d) => d,
725            _ => return Ok(false),
726        };
727        match key {
728            KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
729            KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
730            KeyCode::PageUp => detail.scroll_up(10),
731            KeyCode::PageDown => detail.scroll_down(10),
732            KeyCode::Char('o') => detail.open_image_url(),
733            KeyCode::Esc => {
734                detail.clear_message();
735                let prev =
736                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
737                if let AppScreen::ResultDetail(d) = prev {
738                    self.screen = AppScreen::Result(d.parent);
739                }
740            }
741            KeyCode::Char('q') => return Ok(true),
742            _ => {}
743        }
744        Ok(false)
745    }
746
747    // -- Game detail --------------------------------------------------------
748
749    fn handle_game_detail(&mut self, key: KeyCode) -> Result<bool> {
750        let detail = match &mut self.screen {
751            AppScreen::GameDetail(d) => d,
752            _ => return Ok(false),
753        };
754
755        // Acknowledge download completion on any key press
756        // (check if there's a completed/errored download for this ROM)
757        if !detail.download_completion_acknowledged {
758            if let Ok(list) = detail.downloads.lock() {
759                let has_completed = list.iter().any(|j| {
760                    j.rom_id == detail.rom.id
761                        && matches!(
762                            j.status,
763                            crate::core::download::DownloadStatus::Done
764                                | crate::core::download::DownloadStatus::Error(_)
765                        )
766                });
767                let is_still_downloading = list.iter().any(|j| {
768                    j.rom_id == detail.rom.id
769                        && matches!(j.status, crate::core::download::DownloadStatus::Downloading)
770                });
771                // Only acknowledge if there's a completion and no active download
772                if has_completed && !is_still_downloading {
773                    detail.download_completion_acknowledged = true;
774                }
775            }
776        }
777
778        match key {
779            KeyCode::Enter => {
780                // Only start a download once per detail view and avoid
781                // stacking multiple concurrent downloads for the same ROM.
782                if !detail.has_started_download {
783                    detail.has_started_download = true;
784                    self.downloads
785                        .start_download(&detail.rom, self.client.clone());
786                }
787            }
788            KeyCode::Char('o') => detail.open_cover(),
789            KeyCode::Char('m') => detail.toggle_technical(),
790            KeyCode::Esc => {
791                detail.clear_message();
792                let prev =
793                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
794                if let AppScreen::GameDetail(g) = prev {
795                    self.screen = match g.previous {
796                        GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(l),
797                        GameDetailPrevious::Search(s) => AppScreen::Search(s),
798                    };
799                }
800            }
801            KeyCode::Char('q') => return Ok(true),
802            _ => {}
803        }
804        Ok(false)
805    }
806
807    // -- Setup Wizard -------------------------------------------------------
808
809    async fn handle_setup_wizard(&mut self, key: KeyCode) -> Result<bool> {
810        let wizard = match &mut self.screen {
811            AppScreen::SetupWizard(w) => w,
812            _ => return Ok(false),
813        };
814
815        // Create a dummy event to pass to handle_key
816        let event = crossterm::event::KeyEvent::new(key, crossterm::event::KeyModifiers::empty());
817        if wizard.handle_key(event)? {
818            // Esc pressed
819            self.screen = AppScreen::Settings(SettingsScreen::new(
820                &self.config,
821                self.server_version.as_deref(),
822            ));
823            return Ok(false);
824        }
825
826        if wizard.testing {
827            let result = wizard.try_connect_and_persist(self.client.verbose()).await;
828            wizard.testing = false;
829            match result {
830                Ok(cfg) => {
831                    self.config = cfg;
832                    if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
833                        self.client = new_client;
834                    }
835                    let mut settings =
836                        SettingsScreen::new(&self.config, self.server_version.as_deref());
837                    settings.message = Some((
838                        "Authentication updated successfully".to_string(),
839                        Color::Green,
840                    ));
841                    self.screen = AppScreen::Settings(settings);
842                }
843                Err(e) => {
844                    wizard.error = Some(format!("{e:#}"));
845                }
846            }
847        }
848        Ok(false)
849    }
850
851    // -----------------------------------------------------------------------
852    // Render
853    // -----------------------------------------------------------------------
854
855    fn render(&mut self, f: &mut ratatui::Frame) {
856        let area = f.size();
857        if let Some(ref splash) = self.startup_splash {
858            connected_splash::render(f, area, splash);
859            return;
860        }
861        match &mut self.screen {
862            AppScreen::MainMenu(menu) => menu.render(f, area),
863            AppScreen::LibraryBrowse(lib) => lib.render(f, area),
864            AppScreen::Search(search) => {
865                search.render(f, area);
866                if let Some((x, y)) = search.cursor_position(area) {
867                    f.set_cursor(x, y);
868                }
869            }
870            AppScreen::Settings(settings) => {
871                settings.render(f, area);
872                if let Some((x, y)) = settings.cursor_position(area) {
873                    f.set_cursor(x, y);
874                }
875            }
876            AppScreen::Browse(browse) => browse.render(f, area),
877            AppScreen::Execute(execute) => {
878                execute.render(f, area);
879                if let Some((x, y)) = execute.cursor_position(area) {
880                    f.set_cursor(x, y);
881                }
882            }
883            AppScreen::Result(result) => result.render(f, area),
884            AppScreen::ResultDetail(detail) => detail.render(f, area),
885            AppScreen::GameDetail(detail) => detail.render(f, area),
886            AppScreen::Download(d) => d.render(f, area),
887            AppScreen::SetupWizard(wizard) => {
888                wizard.render(f, area);
889                if let Some((x, y)) = wizard.cursor_pos(area) {
890                    f.set_cursor(x, y);
891                }
892            }
893        }
894    }
895}