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