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