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