Skip to main content

romm_cli/tui/
app.rs

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