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