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, KeyEventKind, KeyModifiers,
16};
17use crossterm::execute;
18use crossterm::terminal::{
19    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
20};
21use ratatui::backend::CrosstermBackend;
22use ratatui::style::Color;
23use ratatui::Terminal;
24use std::collections::{HashSet, VecDeque};
25use std::time::{Duration, Instant};
26
27use crate::client::RommClient;
28use crate::config::{auth_for_persist_merge, normalize_romm_origin, Config};
29use crate::core::cache::{RomCache, RomCacheKey};
30use crate::core::download::DownloadManager;
31use crate::core::startup_library_snapshot;
32use crate::endpoints::roms::GetRoms;
33use crate::types::{Collection, RomList};
34
35use super::keyboard_help;
36use super::openapi::{resolve_path_template, EndpointRegistry};
37use super::screens::connected_splash::{self, StartupSplash};
38use super::screens::setup_wizard::SetupWizard;
39use super::screens::{
40    BrowseScreen, DownloadScreen, ExecuteScreen, GameDetailPrevious, GameDetailScreen,
41    LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen, SearchScreen,
42    SettingsScreen,
43};
44
45/// Result of a background library metadata refresh (generation-guarded).
46struct LibraryMetadataRefreshDone {
47    gen: u64,
48    collections: Vec<Collection>,
49    collection_digest: Vec<startup_library_snapshot::CollectionDigestEntry>,
50    warnings: Vec<String>,
51}
52
53struct CollectionPrefetchDone {
54    key: RomCacheKey,
55    expected: u64,
56    roms: Option<RomList>,
57    warning: Option<String>,
58}
59
60/// Background primary ROM list fetch (deferred load path). Generation-guarded against stale completions.
61struct RomLoadDone {
62    gen: u64,
63    key: Option<RomCacheKey>,
64    expected: u64,
65    result: Result<RomList, String>,
66    context: &'static str,
67    started: Instant,
68}
69
70struct SearchLoadDone {
71    result: Result<RomList, String>,
72}
73
74/// Deferred primary ROM load: cache key, API request, expected count, context label, start time.
75type DeferredLoadRoms = (
76    Option<RomCacheKey>,
77    Option<GetRoms>,
78    u64,
79    &'static str,
80    Instant,
81);
82
83#[inline]
84fn primary_rom_load_result_is_current(done_gen: u64, current_gen: u64) -> bool {
85    done_gen == current_gen
86}
87
88// ---------------------------------------------------------------------------
89// Screen enum
90// ---------------------------------------------------------------------------
91
92/// All possible high-level screens in the TUI.
93///
94/// `App` holds exactly one of these at a time and delegates both
95/// rendering and key handling based on the current variant.
96pub enum AppScreen {
97    MainMenu(MainMenuScreen),
98    LibraryBrowse(LibraryBrowseScreen),
99    Search(SearchScreen),
100    Settings(SettingsScreen),
101    Browse(BrowseScreen),
102    Execute(ExecuteScreen),
103    Result(ResultScreen),
104    ResultDetail(ResultDetailScreen),
105    GameDetail(Box<GameDetailScreen>),
106    Download(DownloadScreen),
107    SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
108}
109
110fn blocks_global_d_shortcut(screen: &AppScreen) -> bool {
111    match screen {
112        AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_) => true,
113        AppScreen::LibraryBrowse(lib) => lib.any_search_bar_open(),
114        _ => false,
115    }
116}
117
118fn allows_global_question_help(screen: &AppScreen) -> bool {
119    match screen {
120        AppScreen::Search(_) | AppScreen::SetupWizard(_) | AppScreen::Execute(_) => false,
121        AppScreen::LibraryBrowse(lib) if lib.any_search_bar_open() => false,
122        AppScreen::Settings(s) if s.editing => false,
123        _ => true,
124    }
125}
126
127// ---------------------------------------------------------------------------
128// App
129// ---------------------------------------------------------------------------
130
131/// Root application object for the TUI.
132///
133/// Owns shared services (`RommClient`, `RomCache`, `DownloadManager`)
134/// as well as the currently active [`AppScreen`].
135pub struct App {
136    pub screen: AppScreen,
137    client: RommClient,
138    config: Config,
139    registry: EndpointRegistry,
140    /// RomM server version from `GET /api/heartbeat` (`SYSTEM.VERSION`), if available.
141    server_version: Option<String>,
142    rom_cache: RomCache,
143    downloads: DownloadManager,
144    /// Screen to restore when closing the Download overlay.
145    screen_before_download: Option<AppScreen>,
146    /// Deferred ROM load: (cache_key, api_request, expected_rom_count, context, start).
147    deferred_load_roms: Option<DeferredLoadRoms>,
148    /// Brief “connected” banner after setup or when the server responds to heartbeat.
149    startup_splash: Option<StartupSplash>,
150    pub global_error: Option<String>,
151    show_keyboard_help: bool,
152    /// Receives completed background metadata refreshes for the library screen.
153    library_metadata_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LibraryMetadataRefreshDone>>,
154    /// Incremented each time a new refresh is spawned; stale completions are ignored.
155    library_metadata_refresh_gen: u64,
156    collection_prefetch_rx: tokio::sync::mpsc::UnboundedReceiver<CollectionPrefetchDone>,
157    collection_prefetch_tx: tokio::sync::mpsc::UnboundedSender<CollectionPrefetchDone>,
158    collection_prefetch_queue: VecDeque<(RomCacheKey, GetRoms, u64)>,
159    collection_prefetch_queued_keys: HashSet<RomCacheKey>,
160    collection_prefetch_inflight_keys: HashSet<RomCacheKey>,
161    /// Latest generation for primary ROM loads; completions with a lower gen are ignored.
162    rom_load_gen: u64,
163    rom_load_rx: tokio::sync::mpsc::UnboundedReceiver<RomLoadDone>,
164    rom_load_tx: tokio::sync::mpsc::UnboundedSender<RomLoadDone>,
165    rom_load_task: Option<tokio::task::JoinHandle<()>>,
166    search_load_rx: tokio::sync::mpsc::UnboundedReceiver<SearchLoadDone>,
167    search_load_tx: tokio::sync::mpsc::UnboundedSender<SearchLoadDone>,
168    search_load_task: Option<tokio::task::JoinHandle<()>>,
169}
170
171impl App {
172    fn is_force_quit_key(key: &crossterm::event::KeyEvent) -> bool {
173        key.kind == KeyEventKind::Press
174            && key.modifiers.contains(KeyModifiers::CONTROL)
175            && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
176    }
177
178    fn selected_rom_request_for_library(
179        lib: &super::screens::library_browse::LibraryBrowseScreen,
180    ) -> Option<GetRoms> {
181        match lib.subsection {
182            super::screens::library_browse::LibrarySubsection::ByConsole => {
183                lib.get_roms_request_platform()
184            }
185            super::screens::library_browse::LibrarySubsection::ByCollection => {
186                lib.get_roms_request_collection()
187            }
188        }
189    }
190
191    /// Construct a new `App` with fresh cache and empty download list.
192    pub fn new(
193        client: RommClient,
194        config: Config,
195        registry: EndpointRegistry,
196        server_version: Option<String>,
197        startup_splash: Option<StartupSplash>,
198    ) -> Self {
199        let (prefetch_tx, prefetch_rx) = tokio::sync::mpsc::unbounded_channel();
200        let (rom_load_tx, rom_load_rx) = tokio::sync::mpsc::unbounded_channel();
201        let (search_load_tx, search_load_rx) = tokio::sync::mpsc::unbounded_channel();
202        Self {
203            screen: AppScreen::MainMenu(MainMenuScreen::new()),
204            client,
205            config,
206            registry,
207            server_version,
208            rom_cache: RomCache::load(),
209            downloads: DownloadManager::new(),
210            screen_before_download: None,
211            deferred_load_roms: None,
212            startup_splash,
213            global_error: None,
214            show_keyboard_help: false,
215            library_metadata_rx: None,
216            library_metadata_refresh_gen: 0,
217            collection_prefetch_rx: prefetch_rx,
218            collection_prefetch_tx: prefetch_tx,
219            collection_prefetch_queue: VecDeque::new(),
220            collection_prefetch_queued_keys: HashSet::new(),
221            collection_prefetch_inflight_keys: HashSet::new(),
222            rom_load_gen: 0,
223            rom_load_rx,
224            rom_load_tx,
225            rom_load_task: None,
226            search_load_rx,
227            search_load_tx,
228            search_load_task: None,
229        }
230    }
231
232    fn spawn_library_metadata_refresh(&mut self) {
233        self.library_metadata_refresh_gen = self.library_metadata_refresh_gen.saturating_add(1);
234        let gen = self.library_metadata_refresh_gen;
235        let client = self.client.clone();
236        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
237        self.library_metadata_rx = Some(rx);
238        tokio::spawn(async move {
239            let fetch = startup_library_snapshot::fetch_collection_summaries(&client).await;
240            let _ = tx.send(LibraryMetadataRefreshDone {
241                gen,
242                collections: fetch.collections,
243                collection_digest: fetch.collection_digest,
244                warnings: fetch.warnings,
245            });
246        });
247    }
248
249    /// Drain background work (e.g. library metadata refresh). Safe to call each frame.
250    pub fn poll_background_tasks(&mut self) {
251        self.poll_library_metadata_refresh();
252        self.poll_rom_load_results();
253        self.poll_collection_prefetch_results();
254        self.poll_search_load_results();
255        self.drive_collection_prefetch_scheduler();
256    }
257
258    fn poll_search_load_results(&mut self) {
259        loop {
260            match self.search_load_rx.try_recv() {
261                Ok(done) => {
262                    if let AppScreen::Search(ref mut search) = self.screen {
263                        search.loading = false;
264                        if let Ok(roms) = done.result {
265                            search.set_results(roms);
266                        }
267                    }
268                }
269                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
270                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
271            }
272        }
273    }
274
275    fn poll_rom_load_results(&mut self) {
276        loop {
277            match self.rom_load_rx.try_recv() {
278                Ok(done) => {
279                    if !primary_rom_load_result_is_current(done.gen, self.rom_load_gen) {
280                        continue;
281                    }
282                    let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
283                        continue;
284                    };
285                    match done.result {
286                        Ok(roms) => {
287                            if let Some(ref k) = done.key {
288                                self.rom_cache
289                                    .insert(k.clone(), roms.clone(), done.expected);
290                            }
291                            lib.set_roms(roms);
292                            tracing::debug!(
293                                "rom-list-render context={} latency_ms={}",
294                                done.context,
295                                done.started.elapsed().as_millis()
296                            );
297                        }
298                        Err(e) => {
299                            lib.set_metadata_footer(Some(format!("Could not load games: {e}")));
300                        }
301                    }
302                    lib.set_rom_loading(false);
303                }
304                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
305                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
306            }
307        }
308    }
309
310    fn poll_library_metadata_refresh(&mut self) {
311        let mut batch = Vec::new();
312        let mut disconnected = false;
313        if let Some(rx) = &mut self.library_metadata_rx {
314            loop {
315                match rx.try_recv() {
316                    Ok(msg) => batch.push(msg),
317                    Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
318                    Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
319                        disconnected = true;
320                        break;
321                    }
322                }
323            }
324        }
325        if disconnected {
326            self.library_metadata_rx = None;
327        }
328        for msg in batch {
329            self.apply_library_metadata_refresh(msg);
330        }
331    }
332
333    fn apply_library_metadata_refresh(&mut self, msg: LibraryMetadataRefreshDone) {
334        if msg.gen != self.library_metadata_refresh_gen {
335            return;
336        }
337        let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
338            return;
339        };
340
341        let had_cached_lists = !lib.platforms.is_empty() || !lib.collections.is_empty();
342        let live_empty = msg.collections.is_empty();
343        if live_empty && had_cached_lists && !msg.warnings.is_empty() {
344            lib.set_metadata_footer(Some(
345                "Could not refresh library metadata (keeping cached list).".into(),
346            ));
347            return;
348        }
349
350        let old_digest =
351            startup_library_snapshot::build_collection_digest_from_collections(&lib.collections);
352        let digest_changed = old_digest != msg.collection_digest;
353        let selection_changed =
354            lib.replace_metadata_preserving_selection(Vec::new(), msg.collections, false, true);
355        startup_library_snapshot::save_snapshot(&lib.platforms, &lib.collections);
356
357        let footer = if msg.warnings.is_empty() {
358            if digest_changed {
359                Some("Collection metadata updated.".into())
360            } else {
361                Some("Collection metadata already up to date.".into())
362            }
363        } else {
364            let w = msg.warnings.join(" | ");
365            let short: String = if w.chars().count() > 160 {
366                let prefix: String = w.chars().take(157).collect();
367                format!("{prefix}…")
368            } else {
369                w
370            };
371            Some(format!("Partial refresh: {}", short))
372        };
373        lib.set_metadata_footer(footer);
374
375        if selection_changed && lib.list_len() > 0 {
376            lib.clear_roms();
377            let key = lib.cache_key();
378            let expected = lib.expected_rom_count();
379            let req = Self::selected_rom_request_for_library(lib);
380            lib.set_rom_loading(expected > 0);
381            self.deferred_load_roms =
382                Some((key, req, expected, "refresh_selection", Instant::now()));
383        }
384        self.queue_collection_prefetches_from_screen(1, "refresh_warmup");
385    }
386
387    fn queue_collection_prefetches_from_screen(&mut self, radius: usize, _reason: &'static str) {
388        let AppScreen::LibraryBrowse(ref lib) = self.screen else {
389            return;
390        };
391        for (key, req, expected) in lib.collection_prefetch_candidates(radius) {
392            if self.rom_cache.get_valid(&key, expected).is_some() {
393                continue;
394            }
395            if self.collection_prefetch_queued_keys.contains(&key)
396                || self.collection_prefetch_inflight_keys.contains(&key)
397            {
398                continue;
399            }
400            self.collection_prefetch_queued_keys.insert(key.clone());
401            self.collection_prefetch_queue
402                .push_back((key, req, expected));
403        }
404    }
405
406    fn drive_collection_prefetch_scheduler(&mut self) {
407        const PREFETCH_MAX_INFLIGHT: usize = 2;
408        while self.collection_prefetch_inflight_keys.len() < PREFETCH_MAX_INFLIGHT {
409            let Some((key, req, expected)) = self.collection_prefetch_queue.pop_back() else {
410                break;
411            };
412            self.collection_prefetch_queued_keys.remove(&key);
413            self.collection_prefetch_inflight_keys.insert(key.clone());
414            let tx = self.collection_prefetch_tx.clone();
415            let client = self.client.clone();
416            tokio::spawn(async move {
417                let result = Self::fetch_roms_full(client, req).await;
418                let (roms, warning) = match result {
419                    Ok(list) => (Some(list), None),
420                    Err(e) => (None, Some(format!("Collection prefetch failed: {e:#}"))),
421                };
422                let _ = tx.send(CollectionPrefetchDone {
423                    key,
424                    expected,
425                    roms,
426                    warning,
427                });
428            });
429        }
430    }
431
432    fn poll_collection_prefetch_results(&mut self) {
433        loop {
434            match self.collection_prefetch_rx.try_recv() {
435                Ok(done) => {
436                    self.collection_prefetch_inflight_keys.remove(&done.key);
437                    if let Some(roms) = done.roms {
438                        self.rom_cache.insert(done.key, roms, done.expected);
439                    } else if let Some(warning) = done.warning {
440                        tracing::debug!("{warning}");
441                    }
442                }
443                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
444                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
445            }
446        }
447    }
448
449    pub fn set_error(&mut self, err: anyhow::Error) {
450        self.global_error = Some(format!("{:#}", err));
451    }
452
453    // -----------------------------------------------------------------------
454    // Event loop
455    // -----------------------------------------------------------------------
456
457    /// Main TUI event loop.
458    ///
459    /// This method owns the terminal for the lifetime of the app,
460    /// repeatedly drawing the current screen and dispatching key
461    /// events until the user chooses to quit.
462    pub async fn run(&mut self) -> Result<()> {
463        enable_raw_mode()?;
464        let mut stdout = std::io::stdout();
465        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
466        let backend = CrosstermBackend::new(stdout);
467        let mut terminal = Terminal::new(backend)?;
468
469        loop {
470            self.poll_background_tasks();
471            if self
472                .startup_splash
473                .as_ref()
474                .is_some_and(|s| s.should_auto_dismiss())
475            {
476                self.startup_splash = None;
477            }
478            // Draw the current screen. `App::render` delegates to the
479            // appropriate screen type based on `self.screen`.
480            terminal.draw(|f| self.render(f))?;
481
482            // Poll with a short timeout so the UI refreshes during downloads
483            // even when the user is not pressing any keys.
484            if event::poll(Duration::from_millis(100))? {
485                if let Event::Key(key) = event::read()? {
486                    if Self::is_force_quit_key(&key) {
487                        break;
488                    }
489                    if key.kind == KeyEventKind::Press && self.handle_key(key.code).await? {
490                        break;
491                    }
492                }
493            }
494
495            // Process deferred ROM fetch (set during LibraryBrowse ↑/↓, subsection switch, refresh).
496            // Cache hits apply synchronously; network fetch runs in a background task so the loop
497            // never awaits HTTP and the UI stays responsive (see `poll_rom_load_results`).
498            if let Some((key, req, expected, context, started)) = self.deferred_load_roms.take() {
499                // Fast path: valid disk cache — no await, no spawn, load immediately.
500                if let Some(ref k) = key {
501                    if let Some(cached) = self.rom_cache.get_valid(k, expected) {
502                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
503                            lib.set_roms(cached.clone());
504                            lib.set_rom_loading(false);
505                            tracing::debug!(
506                                "rom-list-render context={} latency_ms={} (cache_hit)",
507                                context,
508                                started.elapsed().as_millis()
509                            );
510                        }
511                        continue;
512                    }
513                }
514
515                // Debounce network fetches
516                if started.elapsed() < std::time::Duration::from_millis(250) {
517                    // Put it back to keep waiting
518                    self.deferred_load_roms = Some((key, req, expected, context, started));
519                    continue;
520                }
521
522                self.rom_load_gen = self.rom_load_gen.saturating_add(1);
523                let gen = self.rom_load_gen;
524                if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
525                    lib.set_rom_loading(expected > 0);
526                }
527                if expected == 0 {
528                    if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
529                        lib.set_rom_loading(false);
530                    }
531                    continue;
532                }
533                
534                let Some(r) = req else {
535                    if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
536                        lib.set_rom_loading(false);
537                    }
538                    continue;
539                };
540                let client = self.client.clone();
541                let tx = self.rom_load_tx.clone();
542                
543                if let Some(task) = self.rom_load_task.take() {
544                    task.abort();
545                }
546                
547                self.rom_load_task = Some(tokio::spawn(async move {
548                    let result = Self::fetch_roms_full(client, r)
549                        .await
550                        .map_err(|e| format!("{e:#}"));
551                    let _ = tx.send(RomLoadDone {
552                        gen,
553                        key,
554                        expected,
555                        result,
556                        context,
557                        started,
558                    });
559                }));
560            }
561        }
562
563        disable_raw_mode()?;
564        execute!(
565            terminal.backend_mut(),
566            LeaveAlternateScreen,
567            DisableMouseCapture
568        )?;
569        terminal.show_cursor()?;
570        Ok(())
571    }
572
573    // -----------------------------------------------------------------------
574    // ROM fetch (used by background tasks and collection prefetch)
575    // -----------------------------------------------------------------------
576
577    async fn fetch_roms_full(client: RommClient, req: GetRoms) -> Result<RomList> {
578        let mut roms = client.call(&req).await?;
579        let total = roms.total;
580        let ceiling = 20000;
581        while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
582            let mut next_req = req.clone();
583            next_req.offset = Some(roms.items.len() as u32);
584            let next_batch = client.call(&next_req).await?;
585            if next_batch.items.is_empty() {
586                break;
587            }
588            roms.items.extend(next_batch.items);
589        }
590        Ok(roms)
591    }
592
593    // -----------------------------------------------------------------------
594    // Key dispatch — one small method per screen
595    // -----------------------------------------------------------------------
596
597    pub async fn handle_key(&mut self, key: KeyCode) -> Result<bool> {
598        if self.global_error.is_some() {
599            if key == KeyCode::Esc || key == KeyCode::Enter {
600                self.global_error = None;
601            }
602            return Ok(false);
603        }
604
605        if self.startup_splash.is_some() {
606            self.startup_splash = None;
607            return Ok(false);
608        }
609
610        if self.show_keyboard_help {
611            if matches!(
612                key,
613                KeyCode::Esc | KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('?')
614            ) {
615                self.show_keyboard_help = false;
616            }
617            return Ok(false);
618        }
619
620        if key == KeyCode::F(1) {
621            self.show_keyboard_help = true;
622            return Ok(false);
623        }
624        if key == KeyCode::Char('?') && allows_global_question_help(&self.screen) {
625            self.show_keyboard_help = true;
626            return Ok(false);
627        }
628
629        // Global shortcut: 'd' toggles Download overlay (not on screens that need free typing / menus).
630        if key == KeyCode::Char('d') && !blocks_global_d_shortcut(&self.screen) {
631            self.toggle_download_screen();
632            return Ok(false);
633        }
634
635        match &self.screen {
636            AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
637            AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
638            AppScreen::Search(_) => self.handle_search(key).await,
639            AppScreen::Settings(_) => self.handle_settings(key).await,
640            AppScreen::Browse(_) => self.handle_browse(key),
641            AppScreen::Execute(_) => self.handle_execute(key).await,
642            AppScreen::Result(_) => self.handle_result(key),
643            AppScreen::ResultDetail(_) => self.handle_result_detail(key),
644            AppScreen::GameDetail(_) => self.handle_game_detail(key),
645            AppScreen::Download(_) => self.handle_download(key),
646            AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
647        }
648    }
649
650    // -- Download overlay ---------------------------------------------------
651
652    fn toggle_download_screen(&mut self) {
653        let current =
654            std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
655        match current {
656            AppScreen::Download(_) => {
657                self.screen = self
658                    .screen_before_download
659                    .take()
660                    .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
661            }
662            other => {
663                self.screen_before_download = Some(other);
664                self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
665            }
666        }
667    }
668
669    fn handle_download(&mut self, key: KeyCode) -> Result<bool> {
670        if key == KeyCode::Esc || key == KeyCode::Char('d') {
671            self.screen = self
672                .screen_before_download
673                .take()
674                .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
675        }
676        Ok(false)
677    }
678
679    // -- Main menu ----------------------------------------------------------
680
681    async fn handle_main_menu(&mut self, key: KeyCode) -> Result<bool> {
682        let menu = match &mut self.screen {
683            AppScreen::MainMenu(m) => m,
684            _ => return Ok(false),
685        };
686        match key {
687            KeyCode::Up | KeyCode::Char('k') => menu.previous(),
688            KeyCode::Down | KeyCode::Char('j') => menu.next(),
689            KeyCode::Enter => match menu.selected {
690                0 => {
691                    let start = Instant::now();
692                    let snap = startup_library_snapshot::load_snapshot();
693                    let (platforms, collections, from_disk) = match snap {
694                        Some(s) => (s.platforms, s.collections, true),
695                        None => (Vec::new(), Vec::new(), false),
696                    };
697                    let mut lib = LibraryBrowseScreen::new(platforms, collections);
698                    if from_disk && lib.list_len() > 0 {
699                        lib.set_metadata_footer(Some(
700                            "Refreshing library metadata in background…".into(),
701                        ));
702                    } else if lib.list_len() == 0 {
703                        lib.set_metadata_footer(Some("Loading library metadata…".into()));
704                    }
705                    if lib.list_len() > 0 {
706                        let key = lib.cache_key();
707                        let expected = lib.expected_rom_count();
708                        let req = Self::selected_rom_request_for_library(&lib);
709                        lib.set_rom_loading(expected > 0);
710                        self.deferred_load_roms = Some((
711                            key,
712                            req,
713                            expected,
714                            "startup_first_selection",
715                            Instant::now(),
716                        ));
717                    }
718                    self.screen = AppScreen::LibraryBrowse(lib);
719                    self.spawn_library_metadata_refresh();
720                    tracing::debug!(
721                        "library-open latency_ms={} snapshot_hit={}",
722                        start.elapsed().as_millis(),
723                        from_disk
724                    );
725                }
726                1 => self.screen = AppScreen::Search(SearchScreen::new()),
727                2 => {
728                    self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
729                    self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
730                }
731                3 => {
732                    self.screen = AppScreen::Settings(SettingsScreen::new(
733                        &self.config,
734                        self.server_version.as_deref(),
735                    ))
736                }
737                4 => return Ok(true),
738                _ => {}
739            },
740            KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
741            _ => {}
742        }
743        Ok(false)
744    }
745
746    // -- Library browse -----------------------------------------------------
747
748    async fn handle_library_browse(&mut self, key: KeyCode) -> Result<bool> {
749        use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};
750
751        let lib = match &mut self.screen {
752            AppScreen::LibraryBrowse(l) => l,
753            _ => return Ok(false),
754        };
755
756        // List pane: search typing bar
757        if lib.view_mode == LibraryViewMode::List {
758            if let Some(mode) = lib.list_search.mode {
759                let old_key = lib.cache_key();
760                match key {
761                    KeyCode::Esc => lib.clear_list_search(),
762                    KeyCode::Backspace => lib.delete_list_search_char(),
763                    KeyCode::Char(c) => lib.add_list_search_char(c),
764                    KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.list_jump_match(true),
765                    KeyCode::Enter => lib.commit_list_filter_bar(),
766                    _ => {}
767                }
768                let new_key = lib.cache_key();
769                if old_key != new_key && lib.list_len() > 0 {
770                    lib.clear_roms();
771                    let expected = lib.expected_rom_count();
772                    if expected > 0 {
773                        let req = Self::selected_rom_request_for_library(lib);
774                        lib.set_rom_loading(true);
775                        self.deferred_load_roms =
776                            Some((new_key, req, expected, "search_filter", Instant::now()));
777                    } else {
778                        lib.set_rom_loading(false);
779                        self.deferred_load_roms = None;
780                    }
781                }
782                return Ok(false);
783            }
784        }
785
786        // Games pane: search typing bar
787        if lib.view_mode == LibraryViewMode::Roms {
788            if let Some(mode) = lib.rom_search.mode {
789                match key {
790                    KeyCode::Esc => lib.clear_rom_search(),
791                    KeyCode::Backspace => lib.delete_rom_search_char(),
792                    KeyCode::Char(c) => lib.add_rom_search_char(c),
793                    KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_rom_match(true),
794                    KeyCode::Enter => lib.commit_rom_filter_bar(),
795                    _ => {}
796                }
797                return Ok(false);
798            }
799        }
800
801        match key {
802            KeyCode::Up | KeyCode::Char('k') => {
803                if lib.view_mode == LibraryViewMode::List {
804                    lib.list_previous();
805                    if lib.list_len() > 0 {
806                        lib.clear_roms(); // avoid showing previous console's games
807                        let key = lib.cache_key();
808                        let expected = lib.expected_rom_count();
809                        if expected > 0 {
810                            let req = Self::selected_rom_request_for_library(lib);
811                            lib.set_rom_loading(true);
812                            self.deferred_load_roms =
813                                Some((key, req, expected, "list_move_up", Instant::now()));
814                        } else {
815                            lib.set_rom_loading(false);
816                            self.deferred_load_roms = None;
817                        }
818                        if lib.subsection
819                            == super::screens::library_browse::LibrarySubsection::ByCollection
820                        {
821                            tracing::debug!("collections-selection move=up expected={expected}");
822                            self.queue_collection_prefetches_from_screen(1, "move_up");
823                        }
824                    }
825                } else {
826                    lib.rom_previous();
827                }
828            }
829            KeyCode::Down | KeyCode::Char('j') => {
830                if lib.view_mode == LibraryViewMode::List {
831                    lib.list_next();
832                    if lib.list_len() > 0 {
833                        lib.clear_roms(); // avoid showing previous console's games
834                        let key = lib.cache_key();
835                        let expected = lib.expected_rom_count();
836                        if expected > 0 {
837                            let req = Self::selected_rom_request_for_library(lib);
838                            lib.set_rom_loading(true);
839                            self.deferred_load_roms =
840                                Some((key, req, expected, "list_move_down", Instant::now()));
841                        } else {
842                            lib.set_rom_loading(false);
843                            self.deferred_load_roms = None;
844                        }
845                        if lib.subsection
846                            == super::screens::library_browse::LibrarySubsection::ByCollection
847                        {
848                            tracing::debug!("collections-selection move=down expected={expected}");
849                            self.queue_collection_prefetches_from_screen(1, "move_down");
850                        }
851                    }
852                } else {
853                    lib.rom_next();
854                }
855            }
856            KeyCode::Left | KeyCode::Char('h') if lib.view_mode == LibraryViewMode::Roms => {
857                lib.back_to_list();
858            }
859            KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
860            KeyCode::Tab => {
861                if lib.view_mode == LibraryViewMode::List {
862                    lib.switch_view();
863                } else {
864                    lib.switch_view(); // Normal tab also switches panels
865                }
866            }
867            KeyCode::Char('/') => match lib.view_mode {
868                LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Filter),
869                LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Filter),
870            },
871            KeyCode::Char('f') => match lib.view_mode {
872                LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Jump),
873                LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Jump),
874            },
875            KeyCode::Enter => {
876                if lib.view_mode == LibraryViewMode::List {
877                    lib.switch_view();
878                } else if let Some((primary, others)) = lib.get_selected_group() {
879                    let lib_screen = std::mem::replace(
880                        &mut self.screen,
881                        AppScreen::MainMenu(MainMenuScreen::new()),
882                    );
883                    if let AppScreen::LibraryBrowse(l) = lib_screen {
884                        self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
885                            primary,
886                            others,
887                            GameDetailPrevious::Library(l),
888                            self.downloads.shared(),
889                        )));
890                    }
891                }
892            }
893            KeyCode::Char('t') => {
894                lib.switch_subsection();
895                // `switch_subsection` clears ROMs but does not queue a load; mirror list ↑/↓ so the
896                // first row in the new subsection (index 0) gets ROMs without an extra keypress.
897                if lib.view_mode == LibraryViewMode::List && lib.list_len() > 0 {
898                    let key = lib.cache_key();
899                    let expected = lib.expected_rom_count();
900                    if expected > 0 {
901                        let req = Self::selected_rom_request_for_library(lib);
902                        lib.set_rom_loading(true);
903                        self.deferred_load_roms =
904                            Some((key, req, expected, "switch_subsection", Instant::now()));
905                    } else {
906                        lib.set_rom_loading(false);
907                        self.deferred_load_roms = None;
908                    }
909                }
910                if lib.subsection == super::screens::library_browse::LibrarySubsection::ByCollection
911                {
912                    tracing::debug!("collections-subsection entered");
913                    self.queue_collection_prefetches_from_screen(1, "enter_collections");
914                }
915            }
916            KeyCode::Esc => {
917                if lib.view_mode == LibraryViewMode::Roms {
918                    if lib.rom_search.filter_browsing {
919                        lib.clear_rom_search();
920                    } else {
921                        lib.back_to_list();
922                    }
923                } else if lib.list_search.filter_browsing {
924                    lib.clear_list_search();
925                } else {
926                    self.screen = AppScreen::MainMenu(MainMenuScreen::new());
927                }
928            }
929            KeyCode::Char('q') => return Ok(true),
930            _ => {}
931        }
932        Ok(false)
933    }
934
935    // -- Search -------------------------------------------------------------
936
937    async fn handle_search(&mut self, key: KeyCode) -> Result<bool> {
938        let search = match &mut self.screen {
939            AppScreen::Search(s) => s,
940            _ => return Ok(false),
941        };
942        match key {
943            KeyCode::Backspace => search.delete_char(),
944            KeyCode::Left => search.cursor_left(),
945            KeyCode::Right => search.cursor_right(),
946            KeyCode::Up => search.previous(),
947            KeyCode::Down => search.next(),
948            KeyCode::Char(c) => search.add_char(c),
949            KeyCode::Enter => {
950                if search.query.is_empty() {
951                    // no-op (same as before: empty query does not search)
952                } else if search.result_groups.is_some() && search.results_match_current_query() {
953                    if let Some((primary, others)) = search.get_selected_group() {
954                        let prev = std::mem::replace(
955                            &mut self.screen,
956                            AppScreen::MainMenu(MainMenuScreen::new()),
957                        );
958                        if let AppScreen::Search(s) = prev {
959                            self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
960                                primary,
961                                others,
962                                GameDetailPrevious::Search(s),
963                                self.downloads.shared(),
964                            )));
965                        }
966                    }
967                } else {
968                    let req = GetRoms {
969                        search_term: Some(search.query.clone()),
970                        limit: Some(50),
971                        ..Default::default()
972                    };
973                    search.loading = true;
974                    if let Some(task) = self.search_load_task.take() {
975                        task.abort();
976                    }
977                    let client = self.client.clone();
978                    let tx = self.search_load_tx.clone();
979                    self.search_load_task = Some(tokio::spawn(async move {
980                        let result = client.call(&req).await.map_err(|e| format!("{e:#}"));
981                        let _ = tx.send(SearchLoadDone { result });
982                    }));
983                }
984            }
985            KeyCode::Esc => {
986                if search.results.is_some() {
987                    search.clear_results();
988                } else {
989                    self.screen = AppScreen::MainMenu(MainMenuScreen::new());
990                }
991            }
992            _ => {}
993        }
994        Ok(false)
995    }
996
997    // -- Settings -----------------------------------------------------------
998
999    async fn refresh_settings_server_version(&mut self) -> Result<()> {
1000        let (base_url, download_dir, use_https, verbose, auth) = {
1001            let settings = match &self.screen {
1002                AppScreen::Settings(s) => s,
1003                _ => return Ok(()),
1004            };
1005            let mut base_url = normalize_romm_origin(settings.base_url.trim());
1006            if settings.use_https && base_url.starts_with("http://") {
1007                base_url = base_url.replace("http://", "https://");
1008            }
1009            if !settings.use_https && base_url.starts_with("https://") {
1010                base_url = base_url.replace("https://", "http://");
1011            }
1012            (
1013                base_url,
1014                settings.download_dir.clone(),
1015                settings.use_https,
1016                self.client.verbose(),
1017                self.config.auth.clone(),
1018            )
1019        };
1020        let cfg = Config {
1021            base_url,
1022            download_dir,
1023            use_https,
1024            auth,
1025        };
1026        let client = match RommClient::new(&cfg, verbose) {
1027            Ok(c) => c,
1028            Err(_) => {
1029                if let AppScreen::Settings(s) = &mut self.screen {
1030                    s.server_version = "unavailable (invalid URL or client error)".to_string();
1031                    self.server_version = None;
1032                }
1033                return Ok(());
1034            }
1035        };
1036        let ver = client.rom_server_version_from_heartbeat().await;
1037        if let AppScreen::Settings(s) = &mut self.screen {
1038            match ver {
1039                Some(v) => {
1040                    s.server_version = v.clone();
1041                    self.server_version = Some(v);
1042                }
1043                None => {
1044                    s.server_version = "unavailable (heartbeat failed)".to_string();
1045                    self.server_version = None;
1046                }
1047            }
1048        }
1049        Ok(())
1050    }
1051
1052    async fn handle_settings(&mut self, key: KeyCode) -> Result<bool> {
1053        let settings = match &mut self.screen {
1054            AppScreen::Settings(s) => s,
1055            _ => return Ok(false),
1056        };
1057
1058        if settings.editing {
1059            match key {
1060                KeyCode::Enter => {
1061                    let idx = settings.selected_index;
1062                    settings.save_edit();
1063                    if idx == 0 {
1064                        self.refresh_settings_server_version().await?;
1065                    }
1066                }
1067                KeyCode::Esc => settings.cancel_edit(),
1068                KeyCode::Backspace => settings.delete_char(),
1069                KeyCode::Left => settings.move_cursor_left(),
1070                KeyCode::Right => settings.move_cursor_right(),
1071                KeyCode::Char(c) => settings.add_char(c),
1072                _ => {}
1073            }
1074            return Ok(false);
1075        }
1076
1077        match key {
1078            KeyCode::Up | KeyCode::Char('k') => settings.previous(),
1079            KeyCode::Down | KeyCode::Char('j') => settings.next(),
1080            KeyCode::Enter => {
1081                if settings.selected_index == 3 {
1082                    self.screen =
1083                        AppScreen::SetupWizard(Box::new(SetupWizard::new_auth_only(&self.config)));
1084                } else {
1085                    let toggle_https = settings.selected_index == 2;
1086                    settings.enter_edit();
1087                    if toggle_https {
1088                        self.refresh_settings_server_version().await?;
1089                    }
1090                }
1091            }
1092            KeyCode::Char('s' | 'S') => {
1093                // Save to disk (accept both cases; footer shows "S:")
1094                use crate::config::persist_user_config;
1095                let auth = auth_for_persist_merge(self.config.auth.clone());
1096                if let Err(e) = persist_user_config(
1097                    &settings.base_url,
1098                    &settings.download_dir,
1099                    settings.use_https,
1100                    auth,
1101                ) {
1102                    settings.message = Some((format!("Error saving: {e}"), Color::Red));
1103                } else {
1104                    settings.message = Some(("Saved to config.json".to_string(), Color::Green));
1105                    // Update app state
1106                    self.config.base_url = settings.base_url.clone();
1107                    self.config.download_dir = settings.download_dir.clone();
1108                    self.config.use_https = settings.use_https;
1109                    // Re-create client to pick up new base URL
1110                    if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
1111                        self.client = new_client;
1112                    }
1113                }
1114            }
1115            KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
1116            KeyCode::Char('q') => return Ok(true),
1117            _ => {}
1118        }
1119        Ok(false)
1120    }
1121
1122    // -- API Browse ---------------------------------------------------------
1123
1124    fn handle_browse(&mut self, key: KeyCode) -> Result<bool> {
1125        use super::screens::browse::ViewMode;
1126
1127        let browse = match &mut self.screen {
1128            AppScreen::Browse(b) => b,
1129            _ => return Ok(false),
1130        };
1131        match key {
1132            KeyCode::Up | KeyCode::Char('k') => browse.previous(),
1133            KeyCode::Down | KeyCode::Char('j') => browse.next(),
1134            KeyCode::Left | KeyCode::Char('h') if browse.view_mode == ViewMode::Endpoints => {
1135                browse.switch_view();
1136            }
1137            KeyCode::Right | KeyCode::Char('l') if browse.view_mode == ViewMode::Sections => {
1138                browse.switch_view();
1139            }
1140            KeyCode::Tab => browse.switch_view(),
1141            KeyCode::Enter => {
1142                if browse.view_mode == ViewMode::Endpoints {
1143                    if let Some(ep) = browse.get_selected_endpoint() {
1144                        self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
1145                    }
1146                } else {
1147                    browse.switch_view();
1148                }
1149            }
1150            KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
1151            _ => {}
1152        }
1153        Ok(false)
1154    }
1155
1156    // -- Execute endpoint ---------------------------------------------------
1157
1158    async fn handle_execute(&mut self, key: KeyCode) -> Result<bool> {
1159        let execute = match &mut self.screen {
1160            AppScreen::Execute(e) => e,
1161            _ => return Ok(false),
1162        };
1163        match key {
1164            KeyCode::Tab => execute.next_field(),
1165            KeyCode::BackTab => execute.previous_field(),
1166            KeyCode::Char(c) => execute.add_char_to_focused(c),
1167            KeyCode::Backspace => execute.delete_char_from_focused(),
1168            KeyCode::Enter => {
1169                let endpoint = execute.endpoint.clone();
1170                let query = execute.get_query_params();
1171                let body = if endpoint.has_body && !execute.body_text.is_empty() {
1172                    Some(serde_json::from_str(&execute.body_text)?)
1173                } else {
1174                    None
1175                };
1176                let resolved_path =
1177                    match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
1178                        Ok(p) => p,
1179                        Err(e) => {
1180                            self.screen = AppScreen::Result(ResultScreen::new(
1181                                serde_json::json!({ "error": format!("{e}") }),
1182                                None,
1183                                None,
1184                            ));
1185                            return Ok(false);
1186                        }
1187                    };
1188                match self
1189                    .client
1190                    .request_json(&endpoint.method, &resolved_path, &query, body)
1191                    .await
1192                {
1193                    Ok(result) => {
1194                        self.screen = AppScreen::Result(ResultScreen::new(
1195                            result,
1196                            Some(&endpoint.method),
1197                            Some(resolved_path.as_str()),
1198                        ));
1199                    }
1200                    Err(e) => {
1201                        self.screen = AppScreen::Result(ResultScreen::new(
1202                            serde_json::json!({ "error": format!("{e}") }),
1203                            None,
1204                            None,
1205                        ));
1206                    }
1207                }
1208            }
1209            KeyCode::Esc => {
1210                self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
1211            }
1212            _ => {}
1213        }
1214        Ok(false)
1215    }
1216
1217    // -- Result view --------------------------------------------------------
1218
1219    fn handle_result(&mut self, key: KeyCode) -> Result<bool> {
1220        use super::screens::result::ResultViewMode;
1221
1222        let result = match &mut self.screen {
1223            AppScreen::Result(r) => r,
1224            _ => return Ok(false),
1225        };
1226        match key {
1227            KeyCode::Up | KeyCode::Char('k') => {
1228                if result.view_mode == ResultViewMode::Json {
1229                    result.scroll_up(1);
1230                } else {
1231                    result.table_previous();
1232                }
1233            }
1234            KeyCode::Down => {
1235                if result.view_mode == ResultViewMode::Json {
1236                    result.scroll_down(1);
1237                } else {
1238                    result.table_next();
1239                }
1240            }
1241            KeyCode::Char('j') if result.view_mode == ResultViewMode::Json => {
1242                result.scroll_down(1);
1243            }
1244            KeyCode::PageUp => {
1245                if result.view_mode == ResultViewMode::Table {
1246                    result.table_page_up();
1247                } else {
1248                    result.scroll_up(10);
1249                }
1250            }
1251            KeyCode::PageDown => {
1252                if result.view_mode == ResultViewMode::Table {
1253                    result.table_page_down();
1254                } else {
1255                    result.scroll_down(10);
1256                }
1257            }
1258            KeyCode::Char('t') if result.table_row_count > 0 => {
1259                result.switch_view_mode();
1260            }
1261            KeyCode::Enter
1262                if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 =>
1263            {
1264                if let Some(item) = result.get_selected_item_value() {
1265                    let prev = std::mem::replace(
1266                        &mut self.screen,
1267                        AppScreen::MainMenu(MainMenuScreen::new()),
1268                    );
1269                    if let AppScreen::Result(rs) = prev {
1270                        self.screen = AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
1271                    }
1272                }
1273            }
1274            KeyCode::Esc => {
1275                result.clear_message();
1276                self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
1277            }
1278            KeyCode::Char('q') => return Ok(true),
1279            _ => {}
1280        }
1281        Ok(false)
1282    }
1283
1284    // -- Result detail ------------------------------------------------------
1285
1286    fn handle_result_detail(&mut self, key: KeyCode) -> Result<bool> {
1287        let detail = match &mut self.screen {
1288            AppScreen::ResultDetail(d) => d,
1289            _ => return Ok(false),
1290        };
1291        match key {
1292            KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
1293            KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
1294            KeyCode::PageUp => detail.scroll_up(10),
1295            KeyCode::PageDown => detail.scroll_down(10),
1296            KeyCode::Char('o') => detail.open_image_url(),
1297            KeyCode::Esc => {
1298                detail.clear_message();
1299                let prev =
1300                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1301                if let AppScreen::ResultDetail(d) = prev {
1302                    self.screen = AppScreen::Result(d.parent);
1303                }
1304            }
1305            KeyCode::Char('q') => return Ok(true),
1306            _ => {}
1307        }
1308        Ok(false)
1309    }
1310
1311    // -- Game detail --------------------------------------------------------
1312
1313    fn handle_game_detail(&mut self, key: KeyCode) -> Result<bool> {
1314        let detail = match &mut self.screen {
1315            AppScreen::GameDetail(d) => d,
1316            _ => return Ok(false),
1317        };
1318
1319        // Acknowledge download completion on any key press
1320        // (check if there's a completed/errored download for this ROM)
1321        if !detail.download_completion_acknowledged {
1322            if let Ok(list) = detail.downloads.lock() {
1323                let has_completed = list.iter().any(|j| {
1324                    j.rom_id == detail.rom.id
1325                        && matches!(
1326                            j.status,
1327                            crate::core::download::DownloadStatus::Done
1328                                | crate::core::download::DownloadStatus::Error(_)
1329                        )
1330                });
1331                let is_still_downloading = list.iter().any(|j| {
1332                    j.rom_id == detail.rom.id
1333                        && matches!(j.status, crate::core::download::DownloadStatus::Downloading)
1334                });
1335                // Only acknowledge if there's a completion and no active download
1336                if has_completed && !is_still_downloading {
1337                    detail.download_completion_acknowledged = true;
1338                }
1339            }
1340        }
1341
1342        match key {
1343            // Only start a download once per detail view and avoid
1344            // stacking multiple concurrent downloads for the same ROM.
1345            KeyCode::Enter if !detail.has_started_download => {
1346                detail.has_started_download = true;
1347                self.downloads
1348                    .start_download(&detail.rom, self.client.clone());
1349            }
1350            KeyCode::Char('o') => detail.open_cover(),
1351            KeyCode::Char('m') => detail.toggle_technical(),
1352            KeyCode::Esc => {
1353                detail.clear_message();
1354                let prev =
1355                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1356                if let AppScreen::GameDetail(g) = prev {
1357                    self.screen = match g.previous {
1358                        GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(l),
1359                        GameDetailPrevious::Search(s) => AppScreen::Search(s),
1360                    };
1361                }
1362            }
1363            KeyCode::Char('q') => return Ok(true),
1364            _ => {}
1365        }
1366        Ok(false)
1367    }
1368
1369    // -- Setup Wizard -------------------------------------------------------
1370
1371    async fn handle_setup_wizard(&mut self, key: KeyCode) -> Result<bool> {
1372        let wizard = match &mut self.screen {
1373            AppScreen::SetupWizard(w) => w,
1374            _ => return Ok(false),
1375        };
1376
1377        // Create a dummy event to pass to handle_key
1378        let event = crossterm::event::KeyEvent::new(key, crossterm::event::KeyModifiers::empty());
1379        if wizard.handle_key(event)? {
1380            // Esc pressed
1381            self.screen = AppScreen::Settings(SettingsScreen::new(
1382                &self.config,
1383                self.server_version.as_deref(),
1384            ));
1385            return Ok(false);
1386        }
1387
1388        if wizard.testing {
1389            let result = wizard.try_connect_and_persist(self.client.verbose()).await;
1390            wizard.testing = false;
1391            match result {
1392                Ok(cfg) => {
1393                    let auth_ok = cfg.auth.is_some();
1394                    self.config = cfg;
1395                    if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
1396                        self.client = new_client;
1397                    }
1398                    let mut settings =
1399                        SettingsScreen::new(&self.config, self.server_version.as_deref());
1400                    if auth_ok {
1401                        settings.message = Some((
1402                            "Authentication updated successfully".to_string(),
1403                            Color::Green,
1404                        ));
1405                    } else {
1406                        settings.message = Some((
1407                            "Saved configuration but credentials could not be loaded from the OS keyring (see logs)."
1408                                .to_string(),
1409                            Color::Yellow,
1410                        ));
1411                    }
1412                    self.screen = AppScreen::Settings(settings);
1413                }
1414                Err(e) => {
1415                    wizard.error = Some(format!("{e:#}"));
1416                }
1417            }
1418        }
1419        Ok(false)
1420    }
1421
1422    // -----------------------------------------------------------------------
1423    // Render
1424    // -----------------------------------------------------------------------
1425
1426    fn render(&mut self, f: &mut ratatui::Frame) {
1427        let area = f.size();
1428        if let Some(ref splash) = self.startup_splash {
1429            connected_splash::render(f, area, splash);
1430            return;
1431        }
1432        match &mut self.screen {
1433            AppScreen::MainMenu(menu) => menu.render(f, area),
1434            AppScreen::LibraryBrowse(lib) => lib.render(f, area),
1435            AppScreen::Search(search) => {
1436                search.render(f, area);
1437                if let Some((x, y)) = search.cursor_position(area) {
1438                    f.set_cursor(x, y);
1439                }
1440            }
1441            AppScreen::Settings(settings) => {
1442                settings.render(f, area);
1443                if let Some((x, y)) = settings.cursor_position(area) {
1444                    f.set_cursor(x, y);
1445                }
1446            }
1447            AppScreen::Browse(browse) => browse.render(f, area),
1448            AppScreen::Execute(execute) => {
1449                execute.render(f, area);
1450                if let Some((x, y)) = execute.cursor_position(area) {
1451                    f.set_cursor(x, y);
1452                }
1453            }
1454            AppScreen::Result(result) => result.render(f, area),
1455            AppScreen::ResultDetail(detail) => detail.render(f, area),
1456            AppScreen::GameDetail(detail) => detail.render(f, area),
1457            AppScreen::Download(d) => d.render(f, area),
1458            AppScreen::SetupWizard(wizard) => {
1459                wizard.render(f, area);
1460                if let Some((x, y)) = wizard.cursor_pos(area) {
1461                    f.set_cursor(x, y);
1462                }
1463            }
1464        }
1465
1466        if self.show_keyboard_help {
1467            keyboard_help::render_keyboard_help(f, area);
1468        }
1469
1470        if let Some(ref err) = self.global_error {
1471            let popup_area = ratatui::layout::Rect {
1472                x: area.width.saturating_sub(60) / 2,
1473                y: area.height.saturating_sub(10) / 2,
1474                width: 60.min(area.width),
1475                height: 10.min(area.height),
1476            };
1477            f.render_widget(ratatui::widgets::Clear, popup_area);
1478            let block = ratatui::widgets::Block::default()
1479                .title("Error")
1480                .borders(ratatui::widgets::Borders::ALL)
1481                .style(ratatui::style::Style::default().fg(ratatui::style::Color::Red));
1482            let text = format!("{}\n\nPress Esc to dismiss", err);
1483            let paragraph = ratatui::widgets::Paragraph::new(text)
1484                .block(block)
1485                .wrap(ratatui::widgets::Wrap { trim: true });
1486            f.render_widget(paragraph, popup_area);
1487        }
1488    }
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493    use super::*;
1494    use crate::config::Config;
1495    use crate::tui::openapi::EndpointRegistry;
1496    use crate::tui::screens::library_browse::LibraryBrowseScreen;
1497    use crate::types::Platform;
1498    use crossterm::event::{KeyEvent, KeyModifiers};
1499    use serde_json::json;
1500
1501    fn platform(id: u64, name: &str, rom_count: u64) -> Platform {
1502        serde_json::from_value(json!({
1503            "id": id,
1504            "slug": format!("p{id}"),
1505            "fs_slug": format!("p{id}"),
1506            "rom_count": rom_count,
1507            "name": name,
1508            "igdb_slug": null,
1509            "moby_slug": null,
1510            "hltb_slug": null,
1511            "custom_name": null,
1512            "igdb_id": null,
1513            "sgdb_id": null,
1514            "moby_id": null,
1515            "launchbox_id": null,
1516            "ss_id": null,
1517            "ra_id": null,
1518            "hasheous_id": null,
1519            "tgdb_id": null,
1520            "flashpoint_id": null,
1521            "category": null,
1522            "generation": null,
1523            "family_name": null,
1524            "family_slug": null,
1525            "url": null,
1526            "url_logo": null,
1527            "firmware": [],
1528            "aspect_ratio": null,
1529            "created_at": "",
1530            "updated_at": "",
1531            "fs_size_bytes": 0,
1532            "is_unidentified": false,
1533            "is_identified": true,
1534            "missing_from_fs": false,
1535            "display_name": null
1536        }))
1537        .expect("valid platform fixture")
1538    }
1539
1540    fn app_with_library(platforms: Vec<Platform>) -> App {
1541        let config = Config {
1542            base_url: "http://127.0.0.1:9".into(),
1543            download_dir: "/tmp".into(),
1544            use_https: false,
1545            auth: None,
1546        };
1547        let client = RommClient::new(&config, false).expect("client");
1548        let mut app = App::new(client, config, EndpointRegistry::default(), None, None);
1549        app.screen = AppScreen::LibraryBrowse(LibraryBrowseScreen::new(platforms, vec![]));
1550        app
1551    }
1552
1553    #[tokio::test]
1554    async fn list_move_to_zero_rom_selection_does_not_queue_deferred_load() {
1555        let mut app = app_with_library(vec![platform(1, "HasRoms", 5), platform(2, "Empty", 0)]);
1556
1557        assert!(!app.handle_key(KeyCode::Down).await.expect("key handled"));
1558        assert!(
1559            app.deferred_load_roms.is_none(),
1560            "selection move to zero-rom platform should not queue deferred ROM load"
1561        );
1562    }
1563
1564    #[test]
1565    fn ctrl_c_is_treated_as_force_quit() {
1566        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
1567        assert!(App::is_force_quit_key(&ctrl_c));
1568
1569        let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty());
1570        assert!(!App::is_force_quit_key(&plain_c));
1571    }
1572
1573    #[test]
1574    fn primary_rom_load_stale_gen_is_ignored() {
1575        assert!(!super::primary_rom_load_result_is_current(1, 2));
1576        assert!(super::primary_rom_load_result_is_current(3, 3));
1577    }
1578}