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