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