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