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, KeyModifiers,
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::collections::{HashSet, VecDeque};
25use std::path::{Path, PathBuf};
26use std::time::{Duration, Instant};
27
28use crate::client::RommClient;
29use crate::commands::library_scan::ScanCacheInvalidate;
30use crate::config::{auth_for_persist_merge, normalize_romm_origin, Config};
31use crate::core::cache::{RomCache, RomCacheKey};
32use crate::core::download::DownloadManager;
33use crate::core::startup_library_snapshot;
34use crate::endpoints::roms::GetRoms;
35use crate::types::{Collection, Platform, RomList};
36
37use super::keyboard_help;
38use super::openapi::{resolve_path_template, EndpointRegistry};
39use super::screens::connected_splash::{self, StartupSplash};
40use super::screens::setup_wizard::SetupWizard;
41use super::screens::{
42    BrowseScreen, DownloadScreen, ExecuteScreen, GameDetailPrevious, GameDetailScreen,
43    LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen, SearchScreen,
44    SettingsScreen,
45};
46
47/// Result of a background library metadata refresh (generation-guarded).
48struct LibraryMetadataRefreshDone {
49    gen: u64,
50    platforms: Vec<Platform>,
51    collections: Vec<Collection>,
52    collection_digest: Vec<startup_library_snapshot::CollectionDigestEntry>,
53    warnings: Vec<String>,
54}
55
56struct CollectionPrefetchDone {
57    key: RomCacheKey,
58    expected: u64,
59    roms: Option<RomList>,
60    warning: Option<String>,
61}
62
63/// Background primary ROM list fetch (deferred load path). Generation-guarded against stale completions.
64struct RomLoadDone {
65    gen: u64,
66    key: Option<RomCacheKey>,
67    expected: u64,
68    result: Result<RomList, String>,
69    context: &'static str,
70    started: Instant,
71}
72
73struct SearchLoadDone {
74    result: Result<RomList, String>,
75}
76
77struct CoverLoadDone {
78    rom_id: u64,
79    result: Result<image::DynamicImage, String>,
80}
81
82/// Deferred primary ROM load: cache key, API request, expected count, context label, start time.
83type DeferredLoadRoms = (
84    Option<RomCacheKey>,
85    Option<GetRoms>,
86    u64,
87    &'static str,
88    Instant,
89);
90
91#[inline]
92fn primary_rom_load_result_is_current(done_gen: u64, current_gen: u64) -> bool {
93    done_gen == current_gen
94}
95
96// ---------------------------------------------------------------------------
97// Screen enum
98// ---------------------------------------------------------------------------
99
100/// All possible high-level screens in the TUI.
101///
102/// `App` holds exactly one of these at a time and delegates both
103/// rendering and key handling based on the current variant.
104pub enum AppScreen {
105    MainMenu(MainMenuScreen),
106    LibraryBrowse(LibraryBrowseScreen),
107    Search(SearchScreen),
108    Settings(SettingsScreen),
109    Browse(BrowseScreen),
110    Execute(ExecuteScreen),
111    Result(ResultScreen),
112    ResultDetail(ResultDetailScreen),
113    GameDetail(Box<GameDetailScreen>),
114    Download(DownloadScreen),
115    SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
116}
117
118/// Result of a background ROM upload (path validated before spawn).
119struct LibraryUploadComplete {
120    platform_id: u64,
121    scan_after: bool,
122}
123
124// ---------------------------------------------------------------------------
125// App
126// ---------------------------------------------------------------------------
127
128/// Root application object for the TUI.
129///
130/// Owns shared services (`RommClient`, `RomCache`, `DownloadManager`)
131/// as well as the currently active [`AppScreen`].
132pub struct App {
133    pub screen: AppScreen,
134    client: RommClient,
135    config: Config,
136    registry: EndpointRegistry,
137    /// RomM server version from `GET /api/heartbeat` (`SYSTEM.VERSION`), if available.
138    server_version: Option<String>,
139    rom_cache: RomCache,
140    downloads: DownloadManager,
141    /// Screen to restore when closing the Download overlay.
142    screen_before_download: Option<AppScreen>,
143    /// Deferred ROM load: (cache_key, api_request, expected_rom_count, context, start).
144    deferred_load_roms: Option<DeferredLoadRoms>,
145    /// Brief “connected” banner after setup or when the server responds to heartbeat.
146    startup_splash: Option<StartupSplash>,
147    pub global_error: Option<String>,
148    show_keyboard_help: bool,
149    /// Receives completed background metadata refreshes for the library screen.
150    library_metadata_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LibraryMetadataRefreshDone>>,
151    /// Incremented each time a new refresh is spawned; stale completions are ignored.
152    library_metadata_refresh_gen: u64,
153    collection_prefetch_rx: tokio::sync::mpsc::UnboundedReceiver<CollectionPrefetchDone>,
154    collection_prefetch_tx: tokio::sync::mpsc::UnboundedSender<CollectionPrefetchDone>,
155    collection_prefetch_queue: VecDeque<(RomCacheKey, GetRoms, u64)>,
156    collection_prefetch_queued_keys: HashSet<RomCacheKey>,
157    collection_prefetch_inflight_keys: HashSet<RomCacheKey>,
158    /// Latest generation for primary ROM loads; completions with a lower gen are ignored.
159    rom_load_gen: u64,
160    rom_load_rx: tokio::sync::mpsc::UnboundedReceiver<RomLoadDone>,
161    rom_load_tx: tokio::sync::mpsc::UnboundedSender<RomLoadDone>,
162    rom_load_task: Option<tokio::task::JoinHandle<()>>,
163    search_load_rx: tokio::sync::mpsc::UnboundedReceiver<SearchLoadDone>,
164    search_load_tx: tokio::sync::mpsc::UnboundedSender<SearchLoadDone>,
165    search_load_task: Option<tokio::task::JoinHandle<()>>,
166    cover_load_rx: tokio::sync::mpsc::UnboundedReceiver<CoverLoadDone>,
167    cover_load_tx: tokio::sync::mpsc::UnboundedSender<CoverLoadDone>,
168    cover_load_task: Option<tokio::task::JoinHandle<()>>,
169    /// Receives `Ok(())` when a background `scan_library` (with wait) finishes successfully.
170    library_scan_rx: Option<tokio::sync::mpsc::UnboundedReceiver<Result<(), String>>>,
171    library_scan_inflight: bool,
172    /// Cache policy applied when the current background scan completes successfully.
173    library_scan_pending_invalidate: Option<ScanCacheInvalidate>,
174    /// After a successful server scan, force ROM list reload once metadata refresh completes.
175    force_rom_reload_after_metadata: bool,
176    /// Background chunked ROM upload to the selected platform.
177    library_upload_inflight: bool,
178    library_upload_progress_rx: Option<tokio::sync::mpsc::UnboundedReceiver<(u64, u64)>>,
179    library_upload_done_rx:
180        Option<tokio::sync::mpsc::UnboundedReceiver<Result<LibraryUploadComplete, String>>>,
181}
182
183impl App {
184    fn blocks_global_d_shortcut(&self) -> bool {
185        let base = match &self.screen {
186            AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_) => true,
187            AppScreen::LibraryBrowse(lib) => {
188                lib.any_search_bar_open() || lib.any_upload_prompt_open()
189            }
190            _ => false,
191        };
192        base || self.library_upload_inflight
193    }
194
195    fn allows_global_question_help(&self) -> bool {
196        match &self.screen {
197            AppScreen::Search(_) | AppScreen::SetupWizard(_) | AppScreen::Execute(_) => false,
198            AppScreen::LibraryBrowse(lib)
199                if lib.any_search_bar_open() || lib.any_upload_prompt_open() =>
200            {
201                false
202            }
203            AppScreen::Settings(s) if s.editing => false,
204            _ => true,
205        }
206    }
207
208    fn is_force_quit_key(key: &crossterm::event::KeyEvent) -> bool {
209        key.kind == KeyEventKind::Press
210            && key.modifiers.contains(KeyModifiers::CONTROL)
211            && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
212    }
213
214    fn selected_rom_request_for_library(
215        lib: &super::screens::library_browse::LibraryBrowseScreen,
216    ) -> Option<GetRoms> {
217        match lib.subsection {
218            super::screens::library_browse::LibrarySubsection::ByConsole => {
219                lib.get_roms_request_platform()
220            }
221            super::screens::library_browse::LibrarySubsection::ByCollection => {
222                lib.get_roms_request_collection()
223            }
224        }
225    }
226
227    /// Construct a new `App` with fresh cache and empty download list.
228    pub fn new(
229        client: RommClient,
230        config: Config,
231        registry: EndpointRegistry,
232        server_version: Option<String>,
233        startup_splash: Option<StartupSplash>,
234    ) -> Self {
235        let (prefetch_tx, prefetch_rx) = tokio::sync::mpsc::unbounded_channel();
236        let (rom_load_tx, rom_load_rx) = tokio::sync::mpsc::unbounded_channel();
237        let (search_load_tx, search_load_rx) = tokio::sync::mpsc::unbounded_channel();
238        let (cover_load_tx, cover_load_rx) = tokio::sync::mpsc::unbounded_channel();
239        Self {
240            screen: AppScreen::MainMenu(MainMenuScreen::new()),
241            client,
242            config,
243            registry,
244            server_version,
245            rom_cache: RomCache::load(),
246            downloads: DownloadManager::new(),
247            screen_before_download: None,
248            deferred_load_roms: None,
249            startup_splash,
250            global_error: None,
251            show_keyboard_help: false,
252            library_metadata_rx: None,
253            library_metadata_refresh_gen: 0,
254            collection_prefetch_rx: prefetch_rx,
255            collection_prefetch_tx: prefetch_tx,
256            collection_prefetch_queue: VecDeque::new(),
257            collection_prefetch_queued_keys: HashSet::new(),
258            collection_prefetch_inflight_keys: HashSet::new(),
259            rom_load_gen: 0,
260            rom_load_rx,
261            rom_load_tx,
262            rom_load_task: None,
263            search_load_rx,
264            search_load_tx,
265            search_load_task: None,
266            cover_load_rx,
267            cover_load_tx,
268            cover_load_task: None,
269            library_scan_rx: None,
270            library_scan_inflight: false,
271            library_scan_pending_invalidate: None,
272            force_rom_reload_after_metadata: false,
273            library_upload_inflight: false,
274            library_upload_progress_rx: None,
275            library_upload_done_rx: None,
276        }
277    }
278
279    fn spawn_library_metadata_refresh(&mut self) {
280        self.library_metadata_refresh_gen = self.library_metadata_refresh_gen.saturating_add(1);
281        let gen = self.library_metadata_refresh_gen;
282        let client = self.client.clone();
283        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
284        self.library_metadata_rx = Some(rx);
285        tokio::spawn(async move {
286            let fetch = startup_library_snapshot::fetch_merged_library_metadata(&client).await;
287            let _ = tx.send(LibraryMetadataRefreshDone {
288                gen,
289                platforms: fetch.platforms,
290                collections: fetch.collections,
291                collection_digest: fetch.collection_digest,
292                warnings: fetch.warnings,
293            });
294        });
295    }
296
297    /// Drain background work (e.g. library metadata refresh). Safe to call each frame.
298    pub fn poll_background_tasks(&mut self) {
299        self.poll_library_metadata_refresh();
300        self.poll_rom_load_results();
301        self.poll_collection_prefetch_results();
302        self.poll_search_load_results();
303        self.poll_cover_load_results();
304        self.poll_library_upload();
305        self.poll_library_scan();
306        self.drive_collection_prefetch_scheduler();
307    }
308
309    fn spawn_library_rescan_worker(&mut self, cache_on_success: ScanCacheInvalidate) {
310        if self.library_scan_inflight {
311            return;
312        }
313        self.library_scan_inflight = true;
314        self.library_scan_pending_invalidate = Some(cache_on_success);
315        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
316            lib.set_metadata_footer(Some("Server library scan running…".into()));
317        }
318        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
319        self.library_scan_rx = Some(rx);
320        let client = self.client.clone();
321        tokio::spawn(async move {
322            let result = async {
323                let start = crate::commands::library_scan::start_scan_library(&client).await?;
324                crate::commands::library_scan::wait_for_task_terminal(
325                    &client,
326                    &start.task_id,
327                    Duration::from_secs(3600),
328                    |_| {},
329                )
330                .await?;
331                Ok::<(), anyhow::Error>(())
332            }
333            .await
334            .map_err(|e| e.to_string());
335            let _ = tx.send(result);
336        });
337    }
338
339    fn poll_library_scan(&mut self) {
340        let Some(rx) = &mut self.library_scan_rx else {
341            return;
342        };
343        match rx.try_recv() {
344            Ok(result) => {
345                self.library_scan_rx = None;
346                self.library_scan_inflight = false;
347                match result {
348                    Ok(()) => self.on_library_scan_completed_success(),
349                    Err(e) => {
350                        self.library_scan_pending_invalidate = None;
351                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
352                            lib.set_metadata_footer(Some(format!("Library scan failed: {e}")));
353                        } else {
354                            self.global_error = Some(format!("Library scan failed: {e}"));
355                        }
356                    }
357                }
358            }
359            Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
360            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
361                self.library_scan_rx = None;
362                self.library_scan_inflight = false;
363                self.library_scan_pending_invalidate = None;
364            }
365        }
366    }
367
368    fn apply_library_scan_cache_invalidate(&mut self, inv: &ScanCacheInvalidate) {
369        match inv {
370            ScanCacheInvalidate::None => {}
371            ScanCacheInvalidate::Platform(pid) => {
372                self.rom_cache.remove(&RomCacheKey::Platform(*pid));
373            }
374            ScanCacheInvalidate::AllPlatforms => {
375                self.rom_cache.remove_all_platform_entries();
376                if let AppScreen::LibraryBrowse(lib) = &self.screen {
377                    if let Some(ref k) = lib.cache_key() {
378                        if !matches!(k, RomCacheKey::Platform(_)) {
379                            self.rom_cache.remove(k);
380                        }
381                    }
382                }
383            }
384        }
385    }
386
387    fn on_library_scan_completed_success(&mut self) {
388        let inv = self
389            .library_scan_pending_invalidate
390            .take()
391            .unwrap_or(ScanCacheInvalidate::AllPlatforms);
392        self.apply_library_scan_cache_invalidate(&inv);
393        if matches!(self.screen, AppScreen::LibraryBrowse(_)) {
394            self.force_rom_reload_after_metadata = true;
395            self.spawn_library_metadata_refresh();
396        }
397    }
398
399    fn format_upload_bytes(n: u64) -> String {
400        const KB: u64 = 1024;
401        const MB: u64 = KB * 1024;
402        const GB: u64 = MB * 1024;
403        if n >= GB {
404            format!("{:.2} GiB", n as f64 / GB as f64)
405        } else if n >= MB {
406            format!("{:.2} MiB", n as f64 / MB as f64)
407        } else if n >= KB {
408            format!("{:.1} KiB", n as f64 / KB as f64)
409        } else {
410            format!("{n} B")
411        }
412    }
413
414    fn spawn_library_upload_worker(&mut self, platform_id: u64, path: PathBuf, scan_after: bool) {
415        if self.library_upload_inflight || self.library_scan_inflight {
416            return;
417        }
418        self.library_upload_inflight = true;
419        self.library_upload_progress_rx = None;
420        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
421            lib.set_metadata_footer(Some("Preparing upload…".into()));
422        }
423        let (prog_tx, prog_rx) = tokio::sync::mpsc::unbounded_channel();
424        let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
425        self.library_upload_progress_rx = Some(prog_rx);
426        self.library_upload_done_rx = Some(done_rx);
427        let client = self.client.clone();
428        tokio::spawn(async move {
429            let result: Result<LibraryUploadComplete, String> = async {
430                client
431                    .upload_rom(platform_id, &path, move |uploaded, total| {
432                        let _ = prog_tx.send((uploaded, total));
433                    })
434                    .await
435                    .map_err(|e| e.to_string())?;
436                Ok(LibraryUploadComplete {
437                    platform_id,
438                    scan_after,
439                })
440            }
441            .await;
442            let _ = done_tx.send(result);
443        });
444    }
445
446    fn poll_library_upload(&mut self) {
447        if let Some(rx) = &mut self.library_upload_progress_rx {
448            while let Ok((up, tot)) = rx.try_recv() {
449                if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
450                    lib.set_metadata_footer(Some(format!(
451                        "Uploading {} / {}…",
452                        Self::format_upload_bytes(up),
453                        Self::format_upload_bytes(tot)
454                    )));
455                }
456            }
457        }
458
459        let Some(rx) = &mut self.library_upload_done_rx else {
460            return;
461        };
462        match rx.try_recv() {
463            Ok(result) => {
464                self.library_upload_done_rx = None;
465                self.library_upload_progress_rx = None;
466                self.library_upload_inflight = false;
467                match result {
468                    Ok(done) => {
469                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
470                            if done.scan_after {
471                                lib.set_metadata_footer(Some(
472                                    "Upload complete. Starting library scan…".into(),
473                                ));
474                                self.spawn_library_rescan_worker(ScanCacheInvalidate::Platform(
475                                    done.platform_id,
476                                ));
477                            } else {
478                                lib.set_metadata_footer(Some("Upload complete.".into()));
479                            }
480                        }
481                    }
482                    Err(e) => {
483                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
484                            lib.set_metadata_footer(Some(format!("Upload failed: {e}")));
485                        } else {
486                            self.global_error = Some(format!("Upload failed: {e}"));
487                        }
488                    }
489                }
490            }
491            Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
492            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
493                self.library_upload_done_rx = None;
494                self.library_upload_progress_rx = None;
495                self.library_upload_inflight = false;
496            }
497        }
498    }
499
500    fn poll_search_load_results(&mut self) {
501        loop {
502            match self.search_load_rx.try_recv() {
503                Ok(done) => {
504                    if let AppScreen::Search(ref mut search) = self.screen {
505                        search.loading = false;
506                        if let Ok(roms) = done.result {
507                            search.set_results(roms);
508                        }
509                    }
510                }
511                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
512                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
513            }
514        }
515    }
516
517    fn spawn_cover_load_worker(&mut self, rom_id: u64, url: String) {
518        if let Some(task) = self.cover_load_task.take() {
519            task.abort();
520        }
521        let tx = self.cover_load_tx.clone();
522        self.cover_load_task = Some(tokio::spawn(async move {
523            let result = async {
524                let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
525                let status = response.status();
526                if !status.is_success() {
527                    return Err(format!("HTTP {}", status.as_u16()));
528                }
529                let bytes = response.bytes().await.map_err(|e| e.to_string())?;
530                image::load_from_memory(&bytes).map_err(|e| e.to_string())
531            }
532            .await;
533            let _ = tx.send(CoverLoadDone { rom_id, result });
534        }));
535    }
536
537    fn poll_cover_load_results(&mut self) {
538        loop {
539            match self.cover_load_rx.try_recv() {
540                Ok(done) => {
541                    if let AppScreen::GameDetail(detail) = &mut self.screen {
542                        if detail.rom.id != done.rom_id {
543                            continue;
544                        }
545                        match done.result {
546                            Ok(image) => detail.apply_cover_image(image),
547                            Err(err) => detail.apply_cover_error(format!(
548                                "Cover failed: {}",
549                                crate::tui::utils::truncate(&err, 120)
550                            )),
551                        }
552                    }
553                }
554                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
555                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
556            }
557        }
558    }
559
560    fn maybe_start_game_detail_cover_load(&mut self) {
561        let (rom_id, url) = match &mut self.screen {
562            AppScreen::GameDetail(detail) => {
563                if !detail.should_request_cover_load() {
564                    return;
565                }
566                detail.set_cover_loading();
567                let Some(url) = detail.cover_last_url.clone() else {
568                    return;
569                };
570                (detail.rom.id, url)
571            }
572            _ => return,
573        };
574        self.spawn_cover_load_worker(rom_id, url);
575    }
576
577    fn poll_rom_load_results(&mut self) {
578        loop {
579            match self.rom_load_rx.try_recv() {
580                Ok(done) => {
581                    if !primary_rom_load_result_is_current(done.gen, self.rom_load_gen) {
582                        continue;
583                    }
584                    let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
585                        continue;
586                    };
587                    match done.result {
588                        Ok(roms) => {
589                            if let Some(ref k) = done.key {
590                                self.rom_cache
591                                    .insert(k.clone(), roms.clone(), done.expected);
592                            }
593                            lib.set_roms(roms);
594                            tracing::debug!(
595                                "rom-list-render context={} latency_ms={}",
596                                done.context,
597                                done.started.elapsed().as_millis()
598                            );
599                        }
600                        Err(e) => {
601                            lib.set_metadata_footer(Some(format!("Could not load games: {e}")));
602                        }
603                    }
604                    lib.set_rom_loading(false);
605                }
606                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
607                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
608            }
609        }
610    }
611
612    fn poll_library_metadata_refresh(&mut self) {
613        let mut batch = Vec::new();
614        let mut disconnected = false;
615        if let Some(rx) = &mut self.library_metadata_rx {
616            loop {
617                match rx.try_recv() {
618                    Ok(msg) => batch.push(msg),
619                    Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
620                    Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
621                        disconnected = true;
622                        break;
623                    }
624                }
625            }
626        }
627        if disconnected {
628            self.library_metadata_rx = None;
629        }
630        for msg in batch {
631            self.apply_library_metadata_refresh(msg);
632        }
633    }
634
635    fn apply_library_metadata_refresh(&mut self, msg: LibraryMetadataRefreshDone) {
636        if msg.gen != self.library_metadata_refresh_gen {
637            return;
638        }
639        let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
640            return;
641        };
642
643        let had_cached_lists = !lib.platforms.is_empty() || !lib.collections.is_empty();
644        let live_empty = msg.collections.is_empty();
645        if live_empty && had_cached_lists && !msg.warnings.is_empty() {
646            lib.set_metadata_footer(Some(
647                "Could not refresh library metadata (keeping cached list).".into(),
648            ));
649            self.force_rom_reload_after_metadata = false;
650            return;
651        }
652
653        let old_digest =
654            startup_library_snapshot::build_collection_digest_from_collections(&lib.collections);
655        let digest_changed = old_digest != msg.collection_digest;
656        let update_platforms = !msg.platforms.is_empty();
657        let selection_changed = lib.replace_metadata_preserving_selection(
658            msg.platforms,
659            msg.collections,
660            update_platforms,
661            true,
662        );
663        startup_library_snapshot::save_snapshot(&lib.platforms, &lib.collections);
664
665        let footer = if msg.warnings.is_empty() {
666            if digest_changed {
667                Some("Collection metadata updated.".into())
668            } else {
669                Some("Collection metadata already up to date.".into())
670            }
671        } else {
672            let w = msg.warnings.join(" | ");
673            let short: String = if w.chars().count() > 160 {
674                let prefix: String = w.chars().take(157).collect();
675                format!("{prefix}…")
676            } else {
677                w
678            };
679            Some(format!("Partial refresh: {}", short))
680        };
681        lib.set_metadata_footer(footer);
682
683        if selection_changed && lib.list_len() > 0 {
684            lib.clear_roms();
685            let key = lib.cache_key();
686            let expected = lib.expected_rom_count();
687            let req = Self::selected_rom_request_for_library(lib);
688            lib.set_rom_loading(expected > 0);
689            self.deferred_load_roms =
690                Some((key, req, expected, "refresh_selection", Instant::now()));
691        }
692
693        let force_reload = std::mem::take(&mut self.force_rom_reload_after_metadata);
694        if force_reload && lib.list_len() > 0 && !selection_changed {
695            lib.clear_roms();
696            let key = lib.cache_key();
697            let expected = lib.expected_rom_count();
698            let req = Self::selected_rom_request_for_library(lib);
699            lib.set_rom_loading(expected > 0);
700            self.deferred_load_roms =
701                Some((key, req, expected, "post_scan_reload", Instant::now()));
702        }
703
704        self.queue_collection_prefetches_from_screen(1, "refresh_warmup");
705    }
706
707    fn queue_collection_prefetches_from_screen(&mut self, radius: usize, _reason: &'static str) {
708        let AppScreen::LibraryBrowse(ref lib) = self.screen else {
709            return;
710        };
711        for (key, req, expected) in lib.collection_prefetch_candidates(radius) {
712            if self.rom_cache.get_valid(&key, expected).is_some() {
713                continue;
714            }
715            if self.collection_prefetch_queued_keys.contains(&key)
716                || self.collection_prefetch_inflight_keys.contains(&key)
717            {
718                continue;
719            }
720            self.collection_prefetch_queued_keys.insert(key.clone());
721            self.collection_prefetch_queue
722                .push_back((key, req, expected));
723        }
724    }
725
726    fn drive_collection_prefetch_scheduler(&mut self) {
727        const PREFETCH_MAX_INFLIGHT: usize = 2;
728        while self.collection_prefetch_inflight_keys.len() < PREFETCH_MAX_INFLIGHT {
729            let Some((key, req, expected)) = self.collection_prefetch_queue.pop_back() else {
730                break;
731            };
732            self.collection_prefetch_queued_keys.remove(&key);
733            self.collection_prefetch_inflight_keys.insert(key.clone());
734            let tx = self.collection_prefetch_tx.clone();
735            let client = self.client.clone();
736            tokio::spawn(async move {
737                let result = Self::fetch_roms_full(client, req).await;
738                let (roms, warning) = match result {
739                    Ok(list) => (Some(list), None),
740                    Err(e) => (None, Some(format!("Collection prefetch failed: {e:#}"))),
741                };
742                let _ = tx.send(CollectionPrefetchDone {
743                    key,
744                    expected,
745                    roms,
746                    warning,
747                });
748            });
749        }
750    }
751
752    fn poll_collection_prefetch_results(&mut self) {
753        loop {
754            match self.collection_prefetch_rx.try_recv() {
755                Ok(done) => {
756                    self.collection_prefetch_inflight_keys.remove(&done.key);
757                    if let Some(roms) = done.roms {
758                        self.rom_cache.insert(done.key, roms, done.expected);
759                    } else if let Some(warning) = done.warning {
760                        tracing::debug!("{warning}");
761                    }
762                }
763                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
764                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
765            }
766        }
767    }
768
769    pub fn set_error(&mut self, err: anyhow::Error) {
770        self.global_error = Some(format!("{:#}", err));
771    }
772
773    // -----------------------------------------------------------------------
774    // Event loop
775    // -----------------------------------------------------------------------
776
777    /// Main TUI event loop.
778    ///
779    /// This method owns the terminal for the lifetime of the app,
780    /// repeatedly drawing the current screen and dispatching key
781    /// events until the user chooses to quit.
782    pub async fn run(&mut self) -> Result<()> {
783        enable_raw_mode()?;
784        let mut stdout = std::io::stdout();
785        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
786        let backend = CrosstermBackend::new(stdout);
787        let mut terminal = Terminal::new(backend)?;
788
789        loop {
790            self.poll_background_tasks();
791            if self
792                .startup_splash
793                .as_ref()
794                .is_some_and(|s| s.should_auto_dismiss())
795            {
796                self.startup_splash = None;
797            }
798            // Draw the current screen. `App::render` delegates to the
799            // appropriate screen type based on `self.screen`.
800            terminal.draw(|f| self.render(f))?;
801
802            // Poll with a short timeout so the UI refreshes during downloads
803            // even when the user is not pressing any keys.
804            if event::poll(Duration::from_millis(100))? {
805                if let Event::Key(key_event) = event::read()? {
806                    if Self::is_force_quit_key(&key_event) {
807                        break;
808                    }
809                    if key_event.kind == KeyEventKind::Press
810                        && key_event.modifiers.contains(KeyModifiers::CONTROL)
811                        && matches!(key_event.code, KeyCode::Char('r') | KeyCode::Char('R'))
812                    {
813                        if let AppScreen::LibraryBrowse(ref lib) = self.screen {
814                            if !lib.any_search_bar_open()
815                                && !lib.any_upload_prompt_open()
816                                && !self.library_upload_inflight
817                                && !self.library_scan_inflight
818                            {
819                                self.spawn_library_rescan_worker(ScanCacheInvalidate::AllPlatforms);
820                            }
821                        }
822                        continue;
823                    }
824                    if key_event.kind == KeyEventKind::Press
825                        && key_event.modifiers.contains(KeyModifiers::CONTROL)
826                        && matches!(key_event.code, KeyCode::Char('u') | KeyCode::Char('U'))
827                    {
828                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
829                            if lib.any_upload_prompt_open() {
830                                lib.close_upload_prompt();
831                            } else if !lib.any_search_bar_open()
832                                && !self.library_upload_inflight
833                                && !self.library_scan_inflight
834                            {
835                                if lib.subsection
836                                    == super::screens::library_browse::LibrarySubsection::ByConsole
837                                {
838                                    lib.open_upload_prompt();
839                                } else {
840                                    lib.set_metadata_footer(Some(
841                                        "Upload requires Consoles view — press t".into(),
842                                    ));
843                                }
844                            }
845                        }
846                        continue;
847                    }
848                    if key_event.kind == KeyEventKind::Press
849                        && self.handle_key(key_event.code).await?
850                    {
851                        break;
852                    }
853                }
854            }
855
856            // Process deferred ROM fetch (set during LibraryBrowse ↑/↓, subsection switch, refresh).
857            // Cache hits apply synchronously; network fetch runs in a background task so the loop
858            // never awaits HTTP and the UI stays responsive (see `poll_rom_load_results`).
859            if let Some((key, req, expected, context, started)) = self.deferred_load_roms.take() {
860                // Fast path: valid disk cache — no await, no spawn, load immediately.
861                if let Some(ref k) = key {
862                    if let Some(cached) = self.rom_cache.get_valid(k, expected) {
863                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
864                            lib.set_roms(cached.clone());
865                            lib.set_rom_loading(false);
866                            tracing::debug!(
867                                "rom-list-render context={} latency_ms={} (cache_hit)",
868                                context,
869                                started.elapsed().as_millis()
870                            );
871                        }
872                        continue;
873                    }
874                }
875
876                // Debounce network fetches
877                if started.elapsed() < std::time::Duration::from_millis(250) {
878                    // Put it back to keep waiting
879                    self.deferred_load_roms = Some((key, req, expected, context, started));
880                    continue;
881                }
882
883                self.rom_load_gen = self.rom_load_gen.saturating_add(1);
884                let gen = self.rom_load_gen;
885                if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
886                    lib.set_rom_loading(expected > 0);
887                }
888                if expected == 0 {
889                    if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
890                        lib.set_rom_loading(false);
891                    }
892                    continue;
893                }
894
895                let Some(r) = req else {
896                    if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
897                        lib.set_rom_loading(false);
898                    }
899                    continue;
900                };
901                let client = self.client.clone();
902                let tx = self.rom_load_tx.clone();
903
904                if let Some(task) = self.rom_load_task.take() {
905                    task.abort();
906                }
907
908                self.rom_load_task = Some(tokio::spawn(async move {
909                    let result = Self::fetch_roms_full(client, r)
910                        .await
911                        .map_err(|e| format!("{e:#}"));
912                    let _ = tx.send(RomLoadDone {
913                        gen,
914                        key,
915                        expected,
916                        result,
917                        context,
918                        started,
919                    });
920                }));
921            }
922        }
923
924        disable_raw_mode()?;
925        execute!(
926            terminal.backend_mut(),
927            LeaveAlternateScreen,
928            DisableMouseCapture
929        )?;
930        terminal.show_cursor()?;
931        Ok(())
932    }
933
934    // -----------------------------------------------------------------------
935    // ROM fetch (used by background tasks and collection prefetch)
936    // -----------------------------------------------------------------------
937
938    async fn fetch_roms_full(client: RommClient, req: GetRoms) -> Result<RomList> {
939        let mut roms = client.call(&req).await?;
940        let total = roms.total;
941        let ceiling = 20000;
942        while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
943            let mut next_req = req.clone();
944            next_req.offset = Some(roms.items.len() as u32);
945            let next_batch = client.call(&next_req).await?;
946            if next_batch.items.is_empty() {
947                break;
948            }
949            roms.items.extend(next_batch.items);
950        }
951        Ok(roms)
952    }
953
954    // -----------------------------------------------------------------------
955    // Key dispatch — one small method per screen
956    // -----------------------------------------------------------------------
957
958    pub async fn handle_key(&mut self, key: KeyCode) -> Result<bool> {
959        if self.global_error.is_some() {
960            if key == KeyCode::Esc || key == KeyCode::Enter {
961                self.global_error = None;
962            }
963            return Ok(false);
964        }
965
966        if self.startup_splash.is_some() {
967            self.startup_splash = None;
968            return Ok(false);
969        }
970
971        if self.show_keyboard_help {
972            if matches!(
973                key,
974                KeyCode::Esc | KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('?')
975            ) {
976                self.show_keyboard_help = false;
977            }
978            return Ok(false);
979        }
980
981        if key == KeyCode::F(1) {
982            self.show_keyboard_help = true;
983            return Ok(false);
984        }
985        if key == KeyCode::Char('?') && self.allows_global_question_help() {
986            self.show_keyboard_help = true;
987            return Ok(false);
988        }
989
990        // Global shortcut: 'd' toggles Download overlay (not on screens that need free typing / menus).
991        if key == KeyCode::Char('d') && !self.blocks_global_d_shortcut() {
992            self.toggle_download_screen();
993            return Ok(false);
994        }
995
996        match &self.screen {
997            AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
998            AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
999            AppScreen::Search(_) => self.handle_search(key).await,
1000            AppScreen::Settings(_) => self.handle_settings(key).await,
1001            AppScreen::Browse(_) => self.handle_browse(key),
1002            AppScreen::Execute(_) => self.handle_execute(key).await,
1003            AppScreen::Result(_) => self.handle_result(key),
1004            AppScreen::ResultDetail(_) => self.handle_result_detail(key),
1005            AppScreen::GameDetail(_) => self.handle_game_detail(key),
1006            AppScreen::Download(_) => self.handle_download(key),
1007            AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
1008        }
1009    }
1010
1011    // -- Download overlay ---------------------------------------------------
1012
1013    fn toggle_download_screen(&mut self) {
1014        let current =
1015            std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1016        match current {
1017            AppScreen::Download(_) => {
1018                self.screen = self
1019                    .screen_before_download
1020                    .take()
1021                    .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
1022            }
1023            other => {
1024                self.screen_before_download = Some(other);
1025                self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
1026            }
1027        }
1028    }
1029
1030    fn handle_download(&mut self, key: KeyCode) -> Result<bool> {
1031        if key == KeyCode::Esc || key == KeyCode::Char('d') {
1032            self.screen = self
1033                .screen_before_download
1034                .take()
1035                .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
1036        }
1037        Ok(false)
1038    }
1039
1040    // -- Main menu ----------------------------------------------------------
1041
1042    async fn handle_main_menu(&mut self, key: KeyCode) -> Result<bool> {
1043        let menu = match &mut self.screen {
1044            AppScreen::MainMenu(m) => m,
1045            _ => return Ok(false),
1046        };
1047        match key {
1048            KeyCode::Up | KeyCode::Char('k') => menu.previous(),
1049            KeyCode::Down | KeyCode::Char('j') => menu.next(),
1050            KeyCode::Enter => match menu.selected {
1051                0 => {
1052                    let start = Instant::now();
1053                    let snap = startup_library_snapshot::load_snapshot();
1054                    let (platforms, collections, from_disk) = match snap {
1055                        Some(s) => (s.platforms, s.collections, true),
1056                        None => (Vec::new(), Vec::new(), false),
1057                    };
1058                    let mut lib = LibraryBrowseScreen::new(platforms, collections);
1059                    if from_disk && lib.list_len() > 0 {
1060                        lib.set_metadata_footer(Some(
1061                            "Refreshing library metadata in background…".into(),
1062                        ));
1063                    } else if lib.list_len() == 0 {
1064                        lib.set_metadata_footer(Some("Loading library metadata…".into()));
1065                    }
1066                    if lib.list_len() > 0 {
1067                        let key = lib.cache_key();
1068                        let expected = lib.expected_rom_count();
1069                        let req = Self::selected_rom_request_for_library(&lib);
1070                        lib.set_rom_loading(expected > 0);
1071                        self.deferred_load_roms = Some((
1072                            key,
1073                            req,
1074                            expected,
1075                            "startup_first_selection",
1076                            Instant::now(),
1077                        ));
1078                    }
1079                    self.screen = AppScreen::LibraryBrowse(lib);
1080                    self.spawn_library_metadata_refresh();
1081                    tracing::debug!(
1082                        "library-open latency_ms={} snapshot_hit={}",
1083                        start.elapsed().as_millis(),
1084                        from_disk
1085                    );
1086                }
1087                1 => self.screen = AppScreen::Search(SearchScreen::new()),
1088                2 => {
1089                    self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
1090                    self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
1091                }
1092                3 => {
1093                    self.screen = AppScreen::Settings(SettingsScreen::new(
1094                        &self.config,
1095                        self.server_version.as_deref(),
1096                    ))
1097                }
1098                4 => return Ok(true),
1099                _ => {}
1100            },
1101            KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
1102            _ => {}
1103        }
1104        Ok(false)
1105    }
1106
1107    // -- Library browse -----------------------------------------------------
1108
1109    async fn handle_library_browse(&mut self, key: KeyCode) -> Result<bool> {
1110        use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};
1111
1112        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1113            if lib.upload_prompt.is_some() {
1114                if let Some(up) = lib.upload_prompt.as_mut() {
1115                    match key {
1116                        KeyCode::Esc => lib.close_upload_prompt(),
1117                        KeyCode::Tab => up.scan_after = !up.scan_after,
1118                        KeyCode::Left => up.cursor_left(),
1119                        KeyCode::Right => up.cursor_right(),
1120                        KeyCode::Backspace => up.delete_char(),
1121                        KeyCode::Char(c) => up.add_char(c),
1122                        KeyCode::Enter => {
1123                            let path_trim = up.path.trim().to_string();
1124                            let scan_after = up.scan_after;
1125                            if path_trim.is_empty() {
1126                                return Ok(false);
1127                            }
1128                            let path = PathBuf::from(&path_trim);
1129                            if !Path::new(&path).is_file() {
1130                                lib.set_metadata_footer(Some(format!(
1131                                    "Not a file: {}",
1132                                    path.display()
1133                                )));
1134                                return Ok(false);
1135                            }
1136                            let Some(pid) = lib.selected_platform_id() else {
1137                                lib.set_metadata_footer(Some(
1138                                    "Select a console before uploading.".into(),
1139                                ));
1140                                return Ok(false);
1141                            };
1142                            lib.close_upload_prompt();
1143                            self.spawn_library_upload_worker(pid, path, scan_after);
1144                        }
1145                        _ => {}
1146                    }
1147                }
1148                return Ok(false);
1149            }
1150        }
1151
1152        if self.library_upload_inflight {
1153            return Ok(false);
1154        }
1155
1156        let lib = match &mut self.screen {
1157            AppScreen::LibraryBrowse(l) => l,
1158            _ => return Ok(false),
1159        };
1160
1161        // List pane: search typing bar
1162        if lib.view_mode == LibraryViewMode::List {
1163            if let Some(mode) = lib.list_search.mode {
1164                let old_key = lib.cache_key();
1165                match key {
1166                    KeyCode::Esc => lib.clear_list_search(),
1167                    KeyCode::Backspace => lib.delete_list_search_char(),
1168                    KeyCode::Char(c) => lib.add_list_search_char(c),
1169                    KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.list_jump_match(true),
1170                    KeyCode::Enter => lib.commit_list_filter_bar(),
1171                    _ => {}
1172                }
1173                let new_key = lib.cache_key();
1174                if old_key != new_key && lib.list_len() > 0 {
1175                    lib.clear_roms();
1176                    let expected = lib.expected_rom_count();
1177                    if expected > 0 {
1178                        let req = Self::selected_rom_request_for_library(lib);
1179                        lib.set_rom_loading(true);
1180                        self.deferred_load_roms =
1181                            Some((new_key, req, expected, "search_filter", Instant::now()));
1182                    } else {
1183                        lib.set_rom_loading(false);
1184                        self.deferred_load_roms = None;
1185                    }
1186                }
1187                return Ok(false);
1188            }
1189        }
1190
1191        // Games pane: search typing bar
1192        if lib.view_mode == LibraryViewMode::Roms {
1193            if let Some(mode) = lib.rom_search.mode {
1194                match key {
1195                    KeyCode::Esc => lib.clear_rom_search(),
1196                    KeyCode::Backspace => lib.delete_rom_search_char(),
1197                    KeyCode::Char(c) => lib.add_rom_search_char(c),
1198                    KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_rom_match(true),
1199                    KeyCode::Enter => lib.commit_rom_filter_bar(),
1200                    _ => {}
1201                }
1202                return Ok(false);
1203            }
1204        }
1205
1206        match key {
1207            KeyCode::Up | KeyCode::Char('k') => {
1208                if lib.view_mode == LibraryViewMode::List {
1209                    lib.list_previous();
1210                    if lib.list_len() > 0 {
1211                        lib.clear_roms(); // avoid showing previous console's games
1212                        let key = lib.cache_key();
1213                        let expected = lib.expected_rom_count();
1214                        if expected > 0 {
1215                            let req = Self::selected_rom_request_for_library(lib);
1216                            lib.set_rom_loading(true);
1217                            self.deferred_load_roms =
1218                                Some((key, req, expected, "list_move_up", Instant::now()));
1219                        } else {
1220                            lib.set_rom_loading(false);
1221                            self.deferred_load_roms = None;
1222                        }
1223                        if lib.subsection
1224                            == super::screens::library_browse::LibrarySubsection::ByCollection
1225                        {
1226                            tracing::debug!("collections-selection move=up expected={expected}");
1227                            self.queue_collection_prefetches_from_screen(1, "move_up");
1228                        }
1229                    }
1230                } else {
1231                    lib.rom_previous();
1232                }
1233            }
1234            KeyCode::Down | KeyCode::Char('j') => {
1235                if lib.view_mode == LibraryViewMode::List {
1236                    lib.list_next();
1237                    if lib.list_len() > 0 {
1238                        lib.clear_roms(); // avoid showing previous console's games
1239                        let key = lib.cache_key();
1240                        let expected = lib.expected_rom_count();
1241                        if expected > 0 {
1242                            let req = Self::selected_rom_request_for_library(lib);
1243                            lib.set_rom_loading(true);
1244                            self.deferred_load_roms =
1245                                Some((key, req, expected, "list_move_down", Instant::now()));
1246                        } else {
1247                            lib.set_rom_loading(false);
1248                            self.deferred_load_roms = None;
1249                        }
1250                        if lib.subsection
1251                            == super::screens::library_browse::LibrarySubsection::ByCollection
1252                        {
1253                            tracing::debug!("collections-selection move=down expected={expected}");
1254                            self.queue_collection_prefetches_from_screen(1, "move_down");
1255                        }
1256                    }
1257                } else {
1258                    lib.rom_next();
1259                }
1260            }
1261            KeyCode::Left | KeyCode::Char('h') if lib.view_mode == LibraryViewMode::Roms => {
1262                lib.back_to_list();
1263            }
1264            KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
1265            KeyCode::Tab => {
1266                if lib.view_mode == LibraryViewMode::List {
1267                    lib.switch_view();
1268                } else {
1269                    lib.switch_view(); // Normal tab also switches panels
1270                }
1271            }
1272            KeyCode::Char('/') => match lib.view_mode {
1273                LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Filter),
1274                LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Filter),
1275            },
1276            KeyCode::Char('f') => match lib.view_mode {
1277                LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Jump),
1278                LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Jump),
1279            },
1280            KeyCode::Enter => {
1281                if lib.view_mode == LibraryViewMode::List {
1282                    lib.switch_view();
1283                } else if let Some((primary, others)) = lib.get_selected_group() {
1284                    let lib_screen = std::mem::replace(
1285                        &mut self.screen,
1286                        AppScreen::MainMenu(MainMenuScreen::new()),
1287                    );
1288                    if let AppScreen::LibraryBrowse(l) = lib_screen {
1289                        self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
1290                            primary,
1291                            others,
1292                            GameDetailPrevious::Library(l),
1293                            self.downloads.shared(),
1294                        )));
1295                        self.maybe_start_game_detail_cover_load();
1296                    }
1297                }
1298            }
1299            KeyCode::Char('t') => {
1300                lib.switch_subsection();
1301                // `switch_subsection` clears ROMs but does not queue a load; mirror list ↑/↓ so the
1302                // first row in the new subsection (index 0) gets ROMs without an extra keypress.
1303                if lib.view_mode == LibraryViewMode::List && lib.list_len() > 0 {
1304                    let key = lib.cache_key();
1305                    let expected = lib.expected_rom_count();
1306                    if expected > 0 {
1307                        let req = Self::selected_rom_request_for_library(lib);
1308                        lib.set_rom_loading(true);
1309                        self.deferred_load_roms =
1310                            Some((key, req, expected, "switch_subsection", Instant::now()));
1311                    } else {
1312                        lib.set_rom_loading(false);
1313                        self.deferred_load_roms = None;
1314                    }
1315                }
1316                if lib.subsection == super::screens::library_browse::LibrarySubsection::ByCollection
1317                {
1318                    tracing::debug!("collections-subsection entered");
1319                    self.queue_collection_prefetches_from_screen(1, "enter_collections");
1320                }
1321            }
1322            KeyCode::Esc => {
1323                if lib.view_mode == LibraryViewMode::Roms {
1324                    if lib.rom_search.filter_browsing {
1325                        lib.clear_rom_search();
1326                    } else {
1327                        lib.back_to_list();
1328                    }
1329                } else if lib.list_search.filter_browsing {
1330                    lib.clear_list_search();
1331                } else {
1332                    self.screen = AppScreen::MainMenu(MainMenuScreen::new());
1333                }
1334            }
1335            KeyCode::Char('q') => return Ok(true),
1336            _ => {}
1337        }
1338        Ok(false)
1339    }
1340
1341    // -- Search -------------------------------------------------------------
1342
1343    async fn handle_search(&mut self, key: KeyCode) -> Result<bool> {
1344        let search = match &mut self.screen {
1345            AppScreen::Search(s) => s,
1346            _ => return Ok(false),
1347        };
1348        match key {
1349            KeyCode::Backspace => search.delete_char(),
1350            KeyCode::Left => search.cursor_left(),
1351            KeyCode::Right => search.cursor_right(),
1352            KeyCode::Up => search.previous(),
1353            KeyCode::Down => search.next(),
1354            KeyCode::Char(c) => search.add_char(c),
1355            KeyCode::Enter => {
1356                if search.query.is_empty() {
1357                    // no-op (same as before: empty query does not search)
1358                } else if search.result_groups.is_some() && search.results_match_current_query() {
1359                    if let Some((primary, others)) = search.get_selected_group() {
1360                        let prev = std::mem::replace(
1361                            &mut self.screen,
1362                            AppScreen::MainMenu(MainMenuScreen::new()),
1363                        );
1364                        if let AppScreen::Search(s) = prev {
1365                            self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
1366                                primary,
1367                                others,
1368                                GameDetailPrevious::Search(s),
1369                                self.downloads.shared(),
1370                            )));
1371                            self.maybe_start_game_detail_cover_load();
1372                        }
1373                    }
1374                } else {
1375                    let req = GetRoms {
1376                        search_term: Some(search.query.clone()),
1377                        limit: Some(50),
1378                        ..Default::default()
1379                    };
1380                    search.loading = true;
1381                    if let Some(task) = self.search_load_task.take() {
1382                        task.abort();
1383                    }
1384                    let client = self.client.clone();
1385                    let tx = self.search_load_tx.clone();
1386                    self.search_load_task = Some(tokio::spawn(async move {
1387                        let result = client.call(&req).await.map_err(|e| format!("{e:#}"));
1388                        let _ = tx.send(SearchLoadDone { result });
1389                    }));
1390                }
1391            }
1392            KeyCode::Esc => {
1393                if search.results.is_some() {
1394                    search.clear_results();
1395                } else {
1396                    self.screen = AppScreen::MainMenu(MainMenuScreen::new());
1397                }
1398            }
1399            _ => {}
1400        }
1401        Ok(false)
1402    }
1403
1404    // -- Settings -----------------------------------------------------------
1405
1406    async fn refresh_settings_server_version(&mut self) -> Result<()> {
1407        let (base_url, download_dir, use_https, verbose, auth) = {
1408            let settings = match &self.screen {
1409                AppScreen::Settings(s) => s,
1410                _ => return Ok(()),
1411            };
1412            let mut base_url = normalize_romm_origin(settings.base_url.trim());
1413            if settings.use_https && base_url.starts_with("http://") {
1414                base_url = base_url.replace("http://", "https://");
1415            }
1416            if !settings.use_https && base_url.starts_with("https://") {
1417                base_url = base_url.replace("https://", "http://");
1418            }
1419            (
1420                base_url,
1421                settings.download_dir.clone(),
1422                settings.use_https,
1423                self.client.verbose(),
1424                self.config.auth.clone(),
1425            )
1426        };
1427        let cfg = Config {
1428            base_url,
1429            download_dir,
1430            use_https,
1431            auth,
1432        };
1433        let client = match RommClient::new(&cfg, verbose) {
1434            Ok(c) => c,
1435            Err(_) => {
1436                if let AppScreen::Settings(s) = &mut self.screen {
1437                    s.server_version = "unavailable (invalid URL or client error)".to_string();
1438                    self.server_version = None;
1439                }
1440                return Ok(());
1441            }
1442        };
1443        let ver = client.rom_server_version_from_heartbeat().await;
1444        if let AppScreen::Settings(s) = &mut self.screen {
1445            match ver {
1446                Some(v) => {
1447                    s.server_version = v.clone();
1448                    self.server_version = Some(v);
1449                }
1450                None => {
1451                    s.server_version = "unavailable (heartbeat failed)".to_string();
1452                    self.server_version = None;
1453                }
1454            }
1455        }
1456        Ok(())
1457    }
1458
1459    async fn handle_settings(&mut self, key: KeyCode) -> Result<bool> {
1460        let settings = match &mut self.screen {
1461            AppScreen::Settings(s) => s,
1462            _ => return Ok(false),
1463        };
1464
1465        if settings.editing {
1466            match key {
1467                KeyCode::Enter => {
1468                    let idx = settings.selected_index;
1469                    settings.save_edit();
1470                    if idx == 0 {
1471                        self.refresh_settings_server_version().await?;
1472                    }
1473                }
1474                KeyCode::Esc => settings.cancel_edit(),
1475                KeyCode::Backspace => settings.delete_char(),
1476                KeyCode::Left => settings.move_cursor_left(),
1477                KeyCode::Right => settings.move_cursor_right(),
1478                KeyCode::Char(c) => settings.add_char(c),
1479                _ => {}
1480            }
1481            return Ok(false);
1482        }
1483
1484        match key {
1485            KeyCode::Up | KeyCode::Char('k') => settings.previous(),
1486            KeyCode::Down | KeyCode::Char('j') => settings.next(),
1487            KeyCode::Enter => {
1488                if settings.selected_index == 3 {
1489                    self.screen =
1490                        AppScreen::SetupWizard(Box::new(SetupWizard::new_auth_only(&self.config)));
1491                } else {
1492                    let toggle_https = settings.selected_index == 2;
1493                    settings.enter_edit();
1494                    if toggle_https {
1495                        self.refresh_settings_server_version().await?;
1496                    }
1497                }
1498            }
1499            KeyCode::Char('s' | 'S') => {
1500                // Save to disk (accept both cases; footer shows "S:")
1501                use crate::config::persist_user_config;
1502                let auth = auth_for_persist_merge(self.config.auth.clone());
1503                if let Err(e) = persist_user_config(
1504                    &settings.base_url,
1505                    &settings.download_dir,
1506                    settings.use_https,
1507                    auth,
1508                ) {
1509                    settings.message = Some((format!("Error saving: {e}"), Color::Red));
1510                } else {
1511                    settings.message = Some(("Saved to config.json".to_string(), Color::Green));
1512                    // Update app state
1513                    self.config.base_url = settings.base_url.clone();
1514                    self.config.download_dir = settings.download_dir.clone();
1515                    self.config.use_https = settings.use_https;
1516                    // Re-create client to pick up new base URL
1517                    if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
1518                        self.client = new_client;
1519                    }
1520                }
1521            }
1522            KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
1523            KeyCode::Char('q') => return Ok(true),
1524            _ => {}
1525        }
1526        Ok(false)
1527    }
1528
1529    // -- API Browse ---------------------------------------------------------
1530
1531    fn handle_browse(&mut self, key: KeyCode) -> Result<bool> {
1532        use super::screens::browse::ViewMode;
1533
1534        let browse = match &mut self.screen {
1535            AppScreen::Browse(b) => b,
1536            _ => return Ok(false),
1537        };
1538        match key {
1539            KeyCode::Up | KeyCode::Char('k') => browse.previous(),
1540            KeyCode::Down | KeyCode::Char('j') => browse.next(),
1541            KeyCode::Left | KeyCode::Char('h') if browse.view_mode == ViewMode::Endpoints => {
1542                browse.switch_view();
1543            }
1544            KeyCode::Right | KeyCode::Char('l') if browse.view_mode == ViewMode::Sections => {
1545                browse.switch_view();
1546            }
1547            KeyCode::Tab => browse.switch_view(),
1548            KeyCode::Enter => {
1549                if browse.view_mode == ViewMode::Endpoints {
1550                    if let Some(ep) = browse.get_selected_endpoint() {
1551                        self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
1552                    }
1553                } else {
1554                    browse.switch_view();
1555                }
1556            }
1557            KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
1558            _ => {}
1559        }
1560        Ok(false)
1561    }
1562
1563    // -- Execute endpoint ---------------------------------------------------
1564
1565    async fn handle_execute(&mut self, key: KeyCode) -> Result<bool> {
1566        let execute = match &mut self.screen {
1567            AppScreen::Execute(e) => e,
1568            _ => return Ok(false),
1569        };
1570        match key {
1571            KeyCode::Tab => execute.next_field(),
1572            KeyCode::BackTab => execute.previous_field(),
1573            KeyCode::Char(c) => execute.add_char_to_focused(c),
1574            KeyCode::Backspace => execute.delete_char_from_focused(),
1575            KeyCode::Enter => {
1576                let endpoint = execute.endpoint.clone();
1577                let query = execute.get_query_params();
1578                let body = if endpoint.has_body && !execute.body_text.is_empty() {
1579                    Some(serde_json::from_str(&execute.body_text)?)
1580                } else {
1581                    None
1582                };
1583                let resolved_path =
1584                    match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
1585                        Ok(p) => p,
1586                        Err(e) => {
1587                            self.screen = AppScreen::Result(ResultScreen::new(
1588                                serde_json::json!({ "error": format!("{e}") }),
1589                                None,
1590                                None,
1591                            ));
1592                            return Ok(false);
1593                        }
1594                    };
1595                match self
1596                    .client
1597                    .request_json(&endpoint.method, &resolved_path, &query, body)
1598                    .await
1599                {
1600                    Ok(result) => {
1601                        self.screen = AppScreen::Result(ResultScreen::new(
1602                            result,
1603                            Some(&endpoint.method),
1604                            Some(resolved_path.as_str()),
1605                        ));
1606                    }
1607                    Err(e) => {
1608                        self.screen = AppScreen::Result(ResultScreen::new(
1609                            serde_json::json!({ "error": format!("{e}") }),
1610                            None,
1611                            None,
1612                        ));
1613                    }
1614                }
1615            }
1616            KeyCode::Esc => {
1617                self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
1618            }
1619            _ => {}
1620        }
1621        Ok(false)
1622    }
1623
1624    // -- Result view --------------------------------------------------------
1625
1626    fn handle_result(&mut self, key: KeyCode) -> Result<bool> {
1627        use super::screens::result::ResultViewMode;
1628
1629        let result = match &mut self.screen {
1630            AppScreen::Result(r) => r,
1631            _ => return Ok(false),
1632        };
1633        match key {
1634            KeyCode::Up | KeyCode::Char('k') => {
1635                if result.view_mode == ResultViewMode::Json {
1636                    result.scroll_up(1);
1637                } else {
1638                    result.table_previous();
1639                }
1640            }
1641            KeyCode::Down => {
1642                if result.view_mode == ResultViewMode::Json {
1643                    result.scroll_down(1);
1644                } else {
1645                    result.table_next();
1646                }
1647            }
1648            KeyCode::Char('j') if result.view_mode == ResultViewMode::Json => {
1649                result.scroll_down(1);
1650            }
1651            KeyCode::PageUp => {
1652                if result.view_mode == ResultViewMode::Table {
1653                    result.table_page_up();
1654                } else {
1655                    result.scroll_up(10);
1656                }
1657            }
1658            KeyCode::PageDown => {
1659                if result.view_mode == ResultViewMode::Table {
1660                    result.table_page_down();
1661                } else {
1662                    result.scroll_down(10);
1663                }
1664            }
1665            KeyCode::Char('t') if result.table_row_count > 0 => {
1666                result.switch_view_mode();
1667            }
1668            KeyCode::Enter
1669                if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 =>
1670            {
1671                if let Some(item) = result.get_selected_item_value() {
1672                    let prev = std::mem::replace(
1673                        &mut self.screen,
1674                        AppScreen::MainMenu(MainMenuScreen::new()),
1675                    );
1676                    if let AppScreen::Result(rs) = prev {
1677                        self.screen = AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
1678                    }
1679                }
1680            }
1681            KeyCode::Esc => {
1682                result.clear_message();
1683                self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
1684            }
1685            KeyCode::Char('q') => return Ok(true),
1686            _ => {}
1687        }
1688        Ok(false)
1689    }
1690
1691    // -- Result detail ------------------------------------------------------
1692
1693    fn handle_result_detail(&mut self, key: KeyCode) -> Result<bool> {
1694        let detail = match &mut self.screen {
1695            AppScreen::ResultDetail(d) => d,
1696            _ => return Ok(false),
1697        };
1698        match key {
1699            KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
1700            KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
1701            KeyCode::PageUp => detail.scroll_up(10),
1702            KeyCode::PageDown => detail.scroll_down(10),
1703            KeyCode::Char('o') => detail.open_image_url(),
1704            KeyCode::Esc => {
1705                detail.clear_message();
1706                let prev =
1707                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1708                if let AppScreen::ResultDetail(d) = prev {
1709                    self.screen = AppScreen::Result(d.parent);
1710                }
1711            }
1712            KeyCode::Char('q') => return Ok(true),
1713            _ => {}
1714        }
1715        Ok(false)
1716    }
1717
1718    // -- Game detail --------------------------------------------------------
1719
1720    fn handle_game_detail(&mut self, key: KeyCode) -> Result<bool> {
1721        let detail = match &mut self.screen {
1722            AppScreen::GameDetail(d) => d,
1723            _ => return Ok(false),
1724        };
1725
1726        // Acknowledge download completion on any key press
1727        // (check if there's a completed/errored download for this ROM)
1728        if !detail.download_completion_acknowledged {
1729            if let Ok(list) = detail.downloads.lock() {
1730                let has_completed = list.iter().any(|j| {
1731                    j.rom_id == detail.rom.id
1732                        && matches!(
1733                            j.status,
1734                            crate::core::download::DownloadStatus::Done
1735                                | crate::core::download::DownloadStatus::SkippedAlreadyExists
1736                                | crate::core::download::DownloadStatus::FinalizeFailed(_)
1737                                | crate::core::download::DownloadStatus::Error(_)
1738                        )
1739                });
1740                let is_still_downloading = list.iter().any(|j| {
1741                    j.rom_id == detail.rom.id
1742                        && matches!(j.status, crate::core::download::DownloadStatus::Downloading)
1743                });
1744                // Only acknowledge if there's a completion and no active download
1745                if has_completed && !is_still_downloading {
1746                    detail.download_completion_acknowledged = true;
1747                }
1748            }
1749        }
1750
1751        match key {
1752            // Only start a download once per detail view and avoid
1753            // stacking multiple concurrent downloads for the same ROM.
1754            KeyCode::Enter if !detail.has_started_download => {
1755                match self.downloads.start_download(
1756                    &detail.rom,
1757                    self.client.clone(),
1758                    Some(self.config.download_dir.as_str()),
1759                ) {
1760                    Ok(()) => detail.has_started_download = true,
1761                    Err(err) => {
1762                        detail.has_started_download = false;
1763                        detail.message = Some(format!(
1764                            "Download blocked: {err}. Fix ROMs directory in settings/setup."
1765                        ));
1766                    }
1767                }
1768            }
1769            KeyCode::Char('o') => detail.open_cover(),
1770            KeyCode::Char('m') => detail.toggle_technical(),
1771            KeyCode::Esc => {
1772                detail.clear_message();
1773                let prev =
1774                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1775                if let AppScreen::GameDetail(g) = prev {
1776                    self.screen = match g.previous {
1777                        GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(l),
1778                        GameDetailPrevious::Search(s) => AppScreen::Search(s),
1779                    };
1780                }
1781            }
1782            KeyCode::Char('q') => return Ok(true),
1783            _ => {}
1784        }
1785        Ok(false)
1786    }
1787
1788    // -- Setup Wizard -------------------------------------------------------
1789
1790    async fn handle_setup_wizard(&mut self, key: KeyCode) -> Result<bool> {
1791        let wizard = match &mut self.screen {
1792            AppScreen::SetupWizard(w) => w,
1793            _ => return Ok(false),
1794        };
1795
1796        // Create a dummy event to pass to handle_key
1797        let event = crossterm::event::KeyEvent::new(key, crossterm::event::KeyModifiers::empty());
1798        if wizard.handle_key(event)? {
1799            // Esc pressed
1800            self.screen = AppScreen::Settings(SettingsScreen::new(
1801                &self.config,
1802                self.server_version.as_deref(),
1803            ));
1804            return Ok(false);
1805        }
1806
1807        if wizard.testing {
1808            let result = wizard.try_connect_and_persist(self.client.verbose()).await;
1809            wizard.testing = false;
1810            match result {
1811                Ok(cfg) => {
1812                    let auth_ok = cfg.auth.is_some();
1813                    self.config = cfg;
1814                    if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
1815                        self.client = new_client;
1816                    }
1817                    let mut settings =
1818                        SettingsScreen::new(&self.config, self.server_version.as_deref());
1819                    if auth_ok {
1820                        settings.message = Some((
1821                            "Authentication updated successfully".to_string(),
1822                            Color::Green,
1823                        ));
1824                    } else {
1825                        settings.message = Some((
1826                            "Saved configuration but credentials could not be loaded from the OS keyring (see logs)."
1827                                .to_string(),
1828                            Color::Yellow,
1829                        ));
1830                    }
1831                    self.screen = AppScreen::Settings(settings);
1832                }
1833                Err(e) => {
1834                    wizard.error = Some(format!("{e:#}"));
1835                }
1836            }
1837        }
1838        Ok(false)
1839    }
1840
1841    // -----------------------------------------------------------------------
1842    // Render
1843    // -----------------------------------------------------------------------
1844
1845    fn render(&mut self, f: &mut ratatui::Frame) {
1846        let area = f.area();
1847        if let Some(ref splash) = self.startup_splash {
1848            connected_splash::render(f, area, splash);
1849            return;
1850        }
1851        match &mut self.screen {
1852            AppScreen::MainMenu(menu) => menu.render(f, area),
1853            AppScreen::LibraryBrowse(lib) => {
1854                lib.render(f, area);
1855                if let Some((x, y)) = lib.upload_prompt_cursor(area) {
1856                    f.set_cursor_position((x, y));
1857                }
1858            }
1859            AppScreen::Search(search) => {
1860                search.render(f, area);
1861                if let Some((x, y)) = search.cursor_position(area) {
1862                    f.set_cursor_position((x, y));
1863                }
1864            }
1865            AppScreen::Settings(settings) => {
1866                settings.render(f, area);
1867                if let Some((x, y)) = settings.cursor_position(area) {
1868                    f.set_cursor_position((x, y));
1869                }
1870            }
1871            AppScreen::Browse(browse) => browse.render(f, area),
1872            AppScreen::Execute(execute) => {
1873                execute.render(f, area);
1874                if let Some((x, y)) = execute.cursor_position(area) {
1875                    f.set_cursor_position((x, y));
1876                }
1877            }
1878            AppScreen::Result(result) => result.render(f, area),
1879            AppScreen::ResultDetail(detail) => detail.render(f, area),
1880            AppScreen::GameDetail(detail) => detail.render(f, area),
1881            AppScreen::Download(d) => d.render(f, area),
1882            AppScreen::SetupWizard(wizard) => {
1883                wizard.render(f, area);
1884                if let Some((x, y)) = wizard.cursor_pos(area) {
1885                    f.set_cursor_position((x, y));
1886                }
1887            }
1888        }
1889
1890        if self.show_keyboard_help {
1891            keyboard_help::render_keyboard_help(f, area);
1892        }
1893
1894        if let Some(ref err) = self.global_error {
1895            let popup_area = ratatui::layout::Rect {
1896                x: area.width.saturating_sub(60) / 2,
1897                y: area.height.saturating_sub(10) / 2,
1898                width: 60.min(area.width),
1899                height: 10.min(area.height),
1900            };
1901            f.render_widget(ratatui::widgets::Clear, popup_area);
1902            let block = ratatui::widgets::Block::default()
1903                .title("Error")
1904                .borders(ratatui::widgets::Borders::ALL)
1905                .style(ratatui::style::Style::default().fg(ratatui::style::Color::Red));
1906            let text = format!("{}\n\nPress Esc to dismiss", err);
1907            let paragraph = ratatui::widgets::Paragraph::new(text)
1908                .block(block)
1909                .wrap(ratatui::widgets::Wrap { trim: true });
1910            f.render_widget(paragraph, popup_area);
1911        }
1912    }
1913}
1914
1915#[cfg(test)]
1916mod tests {
1917    use super::*;
1918    use crate::config::Config;
1919    use crate::tui::openapi::EndpointRegistry;
1920    use crate::tui::screens::library_browse::LibraryBrowseScreen;
1921    use crate::tui::screens::{GameDetailPrevious, GameDetailScreen};
1922    use crate::types::Platform;
1923    use crossterm::event::{KeyEvent, KeyModifiers};
1924    use serde_json::json;
1925
1926    fn platform(id: u64, name: &str, rom_count: u64) -> Platform {
1927        serde_json::from_value(json!({
1928            "id": id,
1929            "slug": format!("p{id}"),
1930            "fs_slug": format!("p{id}"),
1931            "rom_count": rom_count,
1932            "name": name,
1933            "igdb_slug": null,
1934            "moby_slug": null,
1935            "hltb_slug": null,
1936            "custom_name": null,
1937            "igdb_id": null,
1938            "sgdb_id": null,
1939            "moby_id": null,
1940            "launchbox_id": null,
1941            "ss_id": null,
1942            "ra_id": null,
1943            "hasheous_id": null,
1944            "tgdb_id": null,
1945            "flashpoint_id": null,
1946            "category": null,
1947            "generation": null,
1948            "family_name": null,
1949            "family_slug": null,
1950            "url": null,
1951            "url_logo": null,
1952            "firmware": [],
1953            "aspect_ratio": null,
1954            "created_at": "",
1955            "updated_at": "",
1956            "fs_size_bytes": 0,
1957            "is_unidentified": false,
1958            "is_identified": true,
1959            "missing_from_fs": false,
1960            "display_name": null
1961        }))
1962        .expect("valid platform fixture")
1963    }
1964
1965    fn app_with_library(platforms: Vec<Platform>) -> App {
1966        let config = Config {
1967            base_url: "http://127.0.0.1:9".into(),
1968            download_dir: "/tmp".into(),
1969            use_https: false,
1970            auth: None,
1971        };
1972        let client = RommClient::new(&config, false).expect("client");
1973        let mut app = App::new(client, config, EndpointRegistry::default(), None, None);
1974        app.screen = AppScreen::LibraryBrowse(LibraryBrowseScreen::new(platforms, vec![]));
1975        app
1976    }
1977
1978    fn rom_fixture() -> crate::types::Rom {
1979        serde_json::from_value(json!({
1980            "id": 10,
1981            "platform_id": 1,
1982            "platform_slug": null,
1983            "platform_fs_slug": null,
1984            "platform_custom_name": null,
1985            "platform_display_name": null,
1986            "fs_name": "sample.zip",
1987            "fs_name_no_tags": "sample",
1988            "fs_name_no_ext": "sample",
1989            "fs_extension": "zip",
1990            "fs_path": "/sample.zip",
1991            "fs_size_bytes": 100,
1992            "name": "Sample",
1993            "slug": null,
1994            "summary": null,
1995            "path_cover_small": null,
1996            "path_cover_large": null,
1997            "url_cover": null,
1998            "is_unidentified": false,
1999            "is_identified": true
2000        }))
2001        .expect("valid rom fixture")
2002    }
2003
2004    #[tokio::test]
2005    async fn list_move_to_zero_rom_selection_does_not_queue_deferred_load() {
2006        let mut app = app_with_library(vec![platform(1, "HasRoms", 5), platform(2, "Empty", 0)]);
2007
2008        assert!(!app.handle_key(KeyCode::Down).await.expect("key handled"));
2009        assert!(
2010            app.deferred_load_roms.is_none(),
2011            "selection move to zero-rom platform should not queue deferred ROM load"
2012        );
2013    }
2014
2015    #[test]
2016    fn ctrl_c_is_treated_as_force_quit() {
2017        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
2018        assert!(App::is_force_quit_key(&ctrl_c));
2019
2020        let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty());
2021        assert!(!App::is_force_quit_key(&plain_c));
2022    }
2023
2024    #[test]
2025    fn primary_rom_load_stale_gen_is_ignored() {
2026        assert!(!super::primary_rom_load_result_is_current(1, 2));
2027        assert!(super::primary_rom_load_result_is_current(3, 3));
2028    }
2029
2030    #[tokio::test]
2031    async fn game_detail_esc_returns_to_previous_library_screen() {
2032        let mut app = app_with_library(vec![platform(1, "NES", 1)]);
2033        let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
2034        let detail = GameDetailScreen::new(
2035            rom_fixture(),
2036            Vec::new(),
2037            GameDetailPrevious::Library(previous),
2038            app.downloads.shared(),
2039        );
2040        app.screen = AppScreen::GameDetail(Box::new(detail));
2041
2042        let quit = app.handle_key(KeyCode::Esc).await.expect("esc handled");
2043        assert!(!quit);
2044        assert!(matches!(app.screen, AppScreen::LibraryBrowse(_)));
2045    }
2046}