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