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