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