Skip to main content

romm_cli/tui/
app.rs

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