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,
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::time::Duration;
25
26use crate::client::RommClient;
27use crate::config::{auth_for_persist_merge, Config};
28use crate::core::cache::{RomCache, RomCacheKey};
29use crate::core::download::DownloadManager;
30use crate::endpoints::{collections::ListCollections, platforms::ListPlatforms, roms::GetRoms};
31use crate::types::RomList;
32
33use super::openapi::{resolve_path_template, EndpointRegistry};
34use super::screens::connected_splash::{self, StartupSplash};
35use super::screens::setup_wizard::SetupWizard;
36use super::screens::{
37    BrowseScreen, DownloadScreen, ExecuteScreen, GameDetailPrevious, GameDetailScreen,
38    LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen, SearchScreen,
39    SettingsScreen,
40};
41
42// ---------------------------------------------------------------------------
43// Screen enum
44// ---------------------------------------------------------------------------
45
46/// All possible high-level screens in the TUI.
47///
48/// `App` holds exactly one of these at a time and delegates both
49/// rendering and key handling based on the current variant.
50pub enum AppScreen {
51    MainMenu(MainMenuScreen),
52    LibraryBrowse(LibraryBrowseScreen),
53    Search(SearchScreen),
54    Settings(SettingsScreen),
55    Browse(BrowseScreen),
56    Execute(ExecuteScreen),
57    Result(ResultScreen),
58    ResultDetail(ResultDetailScreen),
59    GameDetail(Box<GameDetailScreen>),
60    Download(DownloadScreen),
61    SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
62}
63
64// ---------------------------------------------------------------------------
65// App
66// ---------------------------------------------------------------------------
67
68/// Root application object for the TUI.
69///
70/// Owns shared services (`RommClient`, `RomCache`, `DownloadManager`)
71/// as well as the currently active [`AppScreen`].
72pub struct App {
73    pub screen: AppScreen,
74    client: RommClient,
75    config: Config,
76    registry: EndpointRegistry,
77    /// RomM server version from `GET /api/heartbeat` (`SYSTEM.VERSION`), if available.
78    server_version: Option<String>,
79    rom_cache: RomCache,
80    downloads: DownloadManager,
81    /// Screen to restore when closing the Download overlay.
82    screen_before_download: Option<AppScreen>,
83    /// Deferred ROM load: (cache_key, api_request, expected_rom_count).
84    deferred_load_roms: Option<(Option<RomCacheKey>, Option<GetRoms>, u64)>,
85    /// Brief “connected” banner after setup or when the server responds to heartbeat.
86    startup_splash: Option<StartupSplash>,
87    pub global_error: Option<String>,
88}
89
90impl App {
91    /// Construct a new `App` with fresh cache and empty download list.
92    pub fn new(
93        client: RommClient,
94        config: Config,
95        registry: EndpointRegistry,
96        server_version: Option<String>,
97        startup_splash: Option<StartupSplash>,
98    ) -> Self {
99        Self {
100            screen: AppScreen::MainMenu(MainMenuScreen::new()),
101            client,
102            config,
103            registry,
104            server_version,
105            rom_cache: RomCache::load(),
106            downloads: DownloadManager::new(),
107            screen_before_download: None,
108            deferred_load_roms: None,
109            startup_splash,
110            global_error: None,
111        }
112    }
113
114    pub fn set_error(&mut self, err: anyhow::Error) {
115        self.global_error = Some(format!("{:#}", err));
116    }
117
118    // -----------------------------------------------------------------------
119    // Event loop
120    // -----------------------------------------------------------------------
121
122    /// Main TUI event loop.
123    ///
124    /// This method owns the terminal for the lifetime of the app,
125    /// repeatedly drawing the current screen and dispatching key
126    /// events until the user chooses to quit.
127    pub async fn run(&mut self) -> Result<()> {
128        enable_raw_mode()?;
129        let mut stdout = std::io::stdout();
130        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
131        let backend = CrosstermBackend::new(stdout);
132        let mut terminal = Terminal::new(backend)?;
133
134        loop {
135            if self
136                .startup_splash
137                .as_ref()
138                .is_some_and(|s| s.should_auto_dismiss())
139            {
140                self.startup_splash = None;
141            }
142            // Draw the current screen. `App::render` delegates to the
143            // appropriate screen type based on `self.screen`.
144            terminal.draw(|f| self.render(f))?;
145
146            // Poll with a short timeout so the UI refreshes during downloads
147            // even when the user is not pressing any keys.
148            if event::poll(Duration::from_millis(100))? {
149                if let Event::Key(key) = event::read()? {
150                    if key.kind == KeyEventKind::Press && self.handle_key(key.code).await? {
151                        break;
152                    }
153                }
154            }
155
156            // Process deferred ROM fetch (set during LibraryBrowse ↑/↓).
157            // This avoids borrowing `self` mutably in two places at once:
158            // the screen handler only *records* the intent to load ROMs,
159            // and the actual HTTP call happens here after rendering.
160            if let Some((key, req, expected)) = self.deferred_load_roms.take() {
161                if let Ok(Some(roms)) = self.load_roms_cached(key, req, expected).await {
162                    if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
163                        lib.set_roms(roms);
164                    }
165                }
166            }
167        }
168
169        disable_raw_mode()?;
170        execute!(
171            terminal.backend_mut(),
172            LeaveAlternateScreen,
173            DisableMouseCapture
174        )?;
175        terminal.show_cursor()?;
176        Ok(())
177    }
178
179    // -----------------------------------------------------------------------
180    // Cache helper
181    // -----------------------------------------------------------------------
182
183    /// Fetch ROMs from the persistent cache if the count still matches,
184    /// otherwise hit the API and update the cache on disk.
185    async fn load_roms_cached(
186        &mut self,
187        key: Option<RomCacheKey>,
188        req: Option<GetRoms>,
189        expected_count: u64,
190    ) -> Result<Option<RomList>> {
191        // Try the disk-backed cache first.
192        if let Some(k) = key {
193            if let Some(cached) = self.rom_cache.get_valid(&k, expected_count) {
194                return Ok(Some(cached.clone()));
195            }
196        }
197        // Cache miss or stale — fetch fresh data from the API.
198        if let Some(r) = req {
199            let mut roms = self.client.call(&r).await?;
200            let total = roms.total;
201            let ceiling = 20000;
202
203            // The RomM API has a default limit (often 500) even if we request more.
204            // Loop until the items list is complete or we hit the ceiling.
205            while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
206                let mut next_req = r.clone();
207                next_req.offset = Some(roms.items.len() as u32);
208
209                let next_batch = self.client.call(&next_req).await?;
210                if next_batch.items.is_empty() {
211                    break;
212                }
213                roms.items.extend(next_batch.items);
214            }
215
216            if let Some(k) = key {
217                self.rom_cache.insert(k, roms.clone(), expected_count); // also persists to disk
218            }
219            return Ok(Some(roms));
220        }
221        Ok(None)
222    }
223
224    // -----------------------------------------------------------------------
225    // Key dispatch — one small method per screen
226    // -----------------------------------------------------------------------
227
228    pub async fn handle_key(&mut self, key: KeyCode) -> Result<bool> {
229        if self.global_error.is_some() {
230            if key == KeyCode::Esc || key == KeyCode::Enter {
231                self.global_error = None;
232            }
233            return Ok(false);
234        }
235
236        if self.startup_splash.is_some() {
237            self.startup_splash = None;
238            return Ok(false);
239        }
240
241        // Global shortcut: 'd' toggles Download overlay (not on screens that need free typing / menus).
242        if key == KeyCode::Char('d')
243            && !matches!(
244                &self.screen,
245                AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_)
246            )
247        {
248            self.toggle_download_screen();
249            return Ok(false);
250        }
251
252        match &self.screen {
253            AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
254            AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
255            AppScreen::Search(_) => self.handle_search(key).await,
256            AppScreen::Settings(_) => self.handle_settings(key),
257            AppScreen::Browse(_) => self.handle_browse(key),
258            AppScreen::Execute(_) => self.handle_execute(key).await,
259            AppScreen::Result(_) => self.handle_result(key),
260            AppScreen::ResultDetail(_) => self.handle_result_detail(key),
261            AppScreen::GameDetail(_) => self.handle_game_detail(key),
262            AppScreen::Download(_) => self.handle_download(key),
263            AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
264        }
265    }
266
267    // -- Download overlay ---------------------------------------------------
268
269    fn toggle_download_screen(&mut self) {
270        let current =
271            std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
272        match current {
273            AppScreen::Download(_) => {
274                self.screen = self
275                    .screen_before_download
276                    .take()
277                    .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
278            }
279            other => {
280                self.screen_before_download = Some(other);
281                self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
282            }
283        }
284    }
285
286    fn handle_download(&mut self, key: KeyCode) -> Result<bool> {
287        if key == KeyCode::Esc || key == KeyCode::Char('d') {
288            self.screen = self
289                .screen_before_download
290                .take()
291                .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
292        }
293        Ok(false)
294    }
295
296    // -- Main menu ----------------------------------------------------------
297
298    async fn handle_main_menu(&mut self, key: KeyCode) -> Result<bool> {
299        let menu = match &mut self.screen {
300            AppScreen::MainMenu(m) => m,
301            _ => return Ok(false),
302        };
303        match key {
304            KeyCode::Up | KeyCode::Char('k') => menu.previous(),
305            KeyCode::Down | KeyCode::Char('j') => menu.next(),
306            KeyCode::Enter => match menu.selected {
307                0 => {
308                    let platforms = match self.client.call(&ListPlatforms).await {
309                        Ok(p) => p,
310                        Err(e) => {
311                            self.set_error(e);
312                            return Ok(false);
313                        }
314                    };
315                    let collections = self.client.call(&ListCollections).await.unwrap_or_default();
316                    let mut lib = LibraryBrowseScreen::new(platforms, collections);
317                    if lib.list_len() > 0 {
318                        let key = lib.cache_key();
319                        let expected = lib.expected_rom_count();
320                        let req = lib
321                            .get_roms_request_platform()
322                            .or_else(|| lib.get_roms_request_collection());
323                        if let Ok(Some(roms)) = self.load_roms_cached(key, req, expected).await {
324                            lib.set_roms(roms);
325                        }
326                    }
327                    self.screen = AppScreen::LibraryBrowse(lib);
328                }
329                1 => self.screen = AppScreen::Search(SearchScreen::new()),
330                2 => {
331                    self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
332                    self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
333                }
334                3 => {
335                    self.screen = AppScreen::Settings(SettingsScreen::new(
336                        &self.config,
337                        self.server_version.as_deref(),
338                    ))
339                }
340                4 => self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone())),
341                5 => return Ok(true),
342                _ => {}
343            },
344            KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
345            _ => {}
346        }
347        Ok(false)
348    }
349
350    // -- Library browse -----------------------------------------------------
351
352    async fn handle_library_browse(&mut self, key: KeyCode) -> Result<bool> {
353        use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};
354
355        let lib = match &mut self.screen {
356            AppScreen::LibraryBrowse(l) => l,
357            _ => return Ok(false),
358        };
359
360        // If in search mode, intercept typing keys.
361        if let Some(mode) = lib.search_mode {
362            match key {
363                KeyCode::Esc => lib.clear_search(),
364                KeyCode::Backspace => lib.delete_search_char(),
365                KeyCode::Char(c) => lib.add_search_char(c),
366                KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_to_match(true),
367                KeyCode::Enter => lib.search_mode = None, // Commit search (keep filtered/position)
368                _ => {}
369            }
370            return Ok(false);
371        }
372
373        match key {
374            KeyCode::Up | KeyCode::Char('k') => {
375                if lib.view_mode == LibraryViewMode::List {
376                    lib.list_previous();
377                    if lib.list_len() > 0 {
378                        lib.clear_roms(); // avoid showing previous console's games
379                        let key = lib.cache_key();
380                        let expected = lib.expected_rom_count();
381                        let req = lib
382                            .get_roms_request_platform()
383                            .or_else(|| lib.get_roms_request_collection());
384                        self.deferred_load_roms = Some((key, req, expected));
385                    }
386                } else {
387                    lib.rom_previous();
388                }
389            }
390            KeyCode::Down | KeyCode::Char('j') => {
391                if lib.view_mode == LibraryViewMode::List {
392                    lib.list_next();
393                    if lib.list_len() > 0 {
394                        lib.clear_roms(); // avoid showing previous console's games
395                        let key = lib.cache_key();
396                        let expected = lib.expected_rom_count();
397                        let req = lib
398                            .get_roms_request_platform()
399                            .or_else(|| lib.get_roms_request_collection());
400                        self.deferred_load_roms = Some((key, req, expected));
401                    }
402                } else {
403                    lib.rom_next();
404                }
405            }
406            KeyCode::Left | KeyCode::Char('h') => {
407                if lib.view_mode == LibraryViewMode::Roms {
408                    lib.back_to_list();
409                }
410            }
411            KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
412            KeyCode::Tab => {
413                if lib.view_mode == LibraryViewMode::List {
414                    lib.switch_view();
415                } else {
416                    lib.switch_view(); // Normal tab also switches panels
417                }
418            }
419            KeyCode::Char('/') if lib.view_mode == LibraryViewMode::Roms => {
420                lib.enter_search(LibrarySearchMode::Filter);
421            }
422            KeyCode::Char('f') if lib.view_mode == LibraryViewMode::Roms => {
423                lib.enter_search(LibrarySearchMode::Jump);
424            }
425            KeyCode::Enter => {
426                if lib.view_mode == LibraryViewMode::List {
427                    lib.switch_view();
428                } else if let Some((primary, others)) = lib.get_selected_group() {
429                    let lib_screen = std::mem::replace(
430                        &mut self.screen,
431                        AppScreen::MainMenu(MainMenuScreen::new()),
432                    );
433                    if let AppScreen::LibraryBrowse(l) = lib_screen {
434                        self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
435                            primary,
436                            others,
437                            GameDetailPrevious::Library(l),
438                            self.downloads.shared(),
439                        )));
440                    }
441                }
442            }
443            KeyCode::Char('t') => lib.switch_subsection(),
444            KeyCode::Esc => {
445                if lib.view_mode == LibraryViewMode::Roms {
446                    lib.back_to_list();
447                } else {
448                    self.screen = AppScreen::MainMenu(MainMenuScreen::new());
449                }
450            }
451            KeyCode::Char('q') => return Ok(true),
452            _ => {}
453        }
454        Ok(false)
455    }
456
457    // -- Search -------------------------------------------------------------
458
459    async fn handle_search(&mut self, key: KeyCode) -> Result<bool> {
460        let search = match &mut self.screen {
461            AppScreen::Search(s) => s,
462            _ => return Ok(false),
463        };
464        match key {
465            KeyCode::Backspace => search.delete_char(),
466            KeyCode::Left => search.cursor_left(),
467            KeyCode::Right => search.cursor_right(),
468            KeyCode::Up => search.previous(),
469            KeyCode::Down => search.next(),
470            KeyCode::Char(c) => search.add_char(c),
471            KeyCode::Enter => {
472                if search.result_groups.is_some() {
473                    if let Some((primary, others)) = search.get_selected_group() {
474                        let prev = std::mem::replace(
475                            &mut self.screen,
476                            AppScreen::MainMenu(MainMenuScreen::new()),
477                        );
478                        if let AppScreen::Search(s) = prev {
479                            self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
480                                primary,
481                                others,
482                                GameDetailPrevious::Search(s),
483                                self.downloads.shared(),
484                            )));
485                        }
486                    }
487                } else if !search.query.is_empty() {
488                    let req = GetRoms {
489                        search_term: Some(search.query.clone()),
490                        limit: Some(50),
491                        ..Default::default()
492                    };
493                    if let Ok(roms) = self.client.call(&req).await {
494                        search.set_results(roms);
495                    }
496                }
497            }
498            KeyCode::Esc => {
499                if search.results.is_some() {
500                    search.clear_results();
501                } else {
502                    self.screen = AppScreen::MainMenu(MainMenuScreen::new());
503                }
504            }
505            _ => {}
506        }
507        Ok(false)
508    }
509
510    // -- Settings -----------------------------------------------------------
511
512    fn handle_settings(&mut self, key: KeyCode) -> Result<bool> {
513        let settings = match &mut self.screen {
514            AppScreen::Settings(s) => s,
515            _ => return Ok(false),
516        };
517
518        if settings.editing {
519            match key {
520                KeyCode::Enter => {
521                    settings.save_edit();
522                }
523                KeyCode::Esc => settings.cancel_edit(),
524                KeyCode::Backspace => settings.delete_char(),
525                KeyCode::Left => settings.move_cursor_left(),
526                KeyCode::Right => settings.move_cursor_right(),
527                KeyCode::Char(c) => settings.add_char(c),
528                _ => {}
529            }
530            return Ok(false);
531        }
532
533        match key {
534            KeyCode::Up | KeyCode::Char('k') => settings.previous(),
535            KeyCode::Down | KeyCode::Char('j') => settings.next(),
536            KeyCode::Enter => {
537                if settings.selected_index == 3 {
538                    self.screen =
539                        AppScreen::SetupWizard(Box::new(SetupWizard::new_auth_only(&self.config)));
540                } else {
541                    settings.enter_edit();
542                }
543            }
544            KeyCode::Char('s' | 'S') => {
545                // Save to disk (accept both cases; footer shows "S:")
546                use crate::config::persist_user_config;
547                let auth = auth_for_persist_merge(self.config.auth.clone());
548                if let Err(e) = persist_user_config(
549                    &settings.base_url,
550                    &settings.download_dir,
551                    settings.use_https,
552                    auth,
553                ) {
554                    settings.message = Some((format!("Error saving: {e}"), Color::Red));
555                } else {
556                    settings.message = Some(("Saved to config.json".to_string(), Color::Green));
557                    // Update app state
558                    self.config.base_url = settings.base_url.clone();
559                    self.config.download_dir = settings.download_dir.clone();
560                    self.config.use_https = settings.use_https;
561                    // Re-create client to pick up new base URL
562                    if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
563                        self.client = new_client;
564                    }
565                }
566            }
567            KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
568            KeyCode::Char('q') => return Ok(true),
569            _ => {}
570        }
571        Ok(false)
572    }
573
574    // -- API Browse ---------------------------------------------------------
575
576    fn handle_browse(&mut self, key: KeyCode) -> Result<bool> {
577        use super::screens::browse::ViewMode;
578
579        let browse = match &mut self.screen {
580            AppScreen::Browse(b) => b,
581            _ => return Ok(false),
582        };
583        match key {
584            KeyCode::Up | KeyCode::Char('k') => browse.previous(),
585            KeyCode::Down | KeyCode::Char('j') => browse.next(),
586            KeyCode::Left | KeyCode::Char('h') => {
587                if browse.view_mode == ViewMode::Endpoints {
588                    browse.switch_view();
589                }
590            }
591            KeyCode::Right | KeyCode::Char('l') => {
592                if browse.view_mode == ViewMode::Sections {
593                    browse.switch_view();
594                }
595            }
596            KeyCode::Tab => browse.switch_view(),
597            KeyCode::Enter => {
598                if browse.view_mode == ViewMode::Endpoints {
599                    if let Some(ep) = browse.get_selected_endpoint() {
600                        self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
601                    }
602                } else {
603                    browse.switch_view();
604                }
605            }
606            KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
607            _ => {}
608        }
609        Ok(false)
610    }
611
612    // -- Execute endpoint ---------------------------------------------------
613
614    async fn handle_execute(&mut self, key: KeyCode) -> Result<bool> {
615        let execute = match &mut self.screen {
616            AppScreen::Execute(e) => e,
617            _ => return Ok(false),
618        };
619        match key {
620            KeyCode::Tab => execute.next_field(),
621            KeyCode::BackTab => execute.previous_field(),
622            KeyCode::Char(c) => execute.add_char_to_focused(c),
623            KeyCode::Backspace => execute.delete_char_from_focused(),
624            KeyCode::Enter => {
625                let endpoint = execute.endpoint.clone();
626                let query = execute.get_query_params();
627                let body = if endpoint.has_body && !execute.body_text.is_empty() {
628                    Some(serde_json::from_str(&execute.body_text)?)
629                } else {
630                    None
631                };
632                let resolved_path =
633                    match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
634                        Ok(p) => p,
635                        Err(e) => {
636                            self.screen = AppScreen::Result(ResultScreen::new(
637                                serde_json::json!({ "error": format!("{e}") }),
638                                None,
639                                None,
640                            ));
641                            return Ok(false);
642                        }
643                    };
644                match self
645                    .client
646                    .request_json(&endpoint.method, &resolved_path, &query, body)
647                    .await
648                {
649                    Ok(result) => {
650                        self.screen = AppScreen::Result(ResultScreen::new(
651                            result,
652                            Some(&endpoint.method),
653                            Some(resolved_path.as_str()),
654                        ));
655                    }
656                    Err(e) => {
657                        self.screen = AppScreen::Result(ResultScreen::new(
658                            serde_json::json!({ "error": format!("{e}") }),
659                            None,
660                            None,
661                        ));
662                    }
663                }
664            }
665            KeyCode::Esc => {
666                self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
667            }
668            _ => {}
669        }
670        Ok(false)
671    }
672
673    // -- Result view --------------------------------------------------------
674
675    fn handle_result(&mut self, key: KeyCode) -> Result<bool> {
676        use super::screens::result::ResultViewMode;
677
678        let result = match &mut self.screen {
679            AppScreen::Result(r) => r,
680            _ => return Ok(false),
681        };
682        match key {
683            KeyCode::Up | KeyCode::Char('k') => {
684                if result.view_mode == ResultViewMode::Json {
685                    result.scroll_up(1);
686                } else {
687                    result.table_previous();
688                }
689            }
690            KeyCode::Down => {
691                if result.view_mode == ResultViewMode::Json {
692                    result.scroll_down(1);
693                } else {
694                    result.table_next();
695                }
696            }
697            KeyCode::Char('j') => {
698                if result.view_mode == ResultViewMode::Json {
699                    result.scroll_down(1);
700                }
701            }
702            KeyCode::PageUp => {
703                if result.view_mode == ResultViewMode::Table {
704                    result.table_page_up();
705                } else {
706                    result.scroll_up(10);
707                }
708            }
709            KeyCode::PageDown => {
710                if result.view_mode == ResultViewMode::Table {
711                    result.table_page_down();
712                } else {
713                    result.scroll_down(10);
714                }
715            }
716            KeyCode::Char('t') => {
717                if result.table_row_count > 0 {
718                    result.switch_view_mode();
719                }
720            }
721            KeyCode::Enter => {
722                if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 {
723                    if let Some(item) = result.get_selected_item_value() {
724                        let prev = std::mem::replace(
725                            &mut self.screen,
726                            AppScreen::MainMenu(MainMenuScreen::new()),
727                        );
728                        if let AppScreen::Result(rs) = prev {
729                            self.screen =
730                                AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
731                        }
732                    }
733                }
734            }
735            KeyCode::Esc => {
736                result.clear_message();
737                self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
738            }
739            KeyCode::Char('q') => return Ok(true),
740            _ => {}
741        }
742        Ok(false)
743    }
744
745    // -- Result detail ------------------------------------------------------
746
747    fn handle_result_detail(&mut self, key: KeyCode) -> Result<bool> {
748        let detail = match &mut self.screen {
749            AppScreen::ResultDetail(d) => d,
750            _ => return Ok(false),
751        };
752        match key {
753            KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
754            KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
755            KeyCode::PageUp => detail.scroll_up(10),
756            KeyCode::PageDown => detail.scroll_down(10),
757            KeyCode::Char('o') => detail.open_image_url(),
758            KeyCode::Esc => {
759                detail.clear_message();
760                let prev =
761                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
762                if let AppScreen::ResultDetail(d) = prev {
763                    self.screen = AppScreen::Result(d.parent);
764                }
765            }
766            KeyCode::Char('q') => return Ok(true),
767            _ => {}
768        }
769        Ok(false)
770    }
771
772    // -- Game detail --------------------------------------------------------
773
774    fn handle_game_detail(&mut self, key: KeyCode) -> Result<bool> {
775        let detail = match &mut self.screen {
776            AppScreen::GameDetail(d) => d,
777            _ => return Ok(false),
778        };
779
780        // Acknowledge download completion on any key press
781        // (check if there's a completed/errored download for this ROM)
782        if !detail.download_completion_acknowledged {
783            if let Ok(list) = detail.downloads.lock() {
784                let has_completed = list.iter().any(|j| {
785                    j.rom_id == detail.rom.id
786                        && matches!(
787                            j.status,
788                            crate::core::download::DownloadStatus::Done
789                                | crate::core::download::DownloadStatus::Error(_)
790                        )
791                });
792                let is_still_downloading = list.iter().any(|j| {
793                    j.rom_id == detail.rom.id
794                        && matches!(j.status, crate::core::download::DownloadStatus::Downloading)
795                });
796                // Only acknowledge if there's a completion and no active download
797                if has_completed && !is_still_downloading {
798                    detail.download_completion_acknowledged = true;
799                }
800            }
801        }
802
803        match key {
804            KeyCode::Enter => {
805                // Only start a download once per detail view and avoid
806                // stacking multiple concurrent downloads for the same ROM.
807                if !detail.has_started_download {
808                    detail.has_started_download = true;
809                    self.downloads
810                        .start_download(&detail.rom, self.client.clone());
811                }
812            }
813            KeyCode::Char('o') => detail.open_cover(),
814            KeyCode::Char('m') => detail.toggle_technical(),
815            KeyCode::Esc => {
816                detail.clear_message();
817                let prev =
818                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
819                if let AppScreen::GameDetail(g) = prev {
820                    self.screen = match g.previous {
821                        GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(l),
822                        GameDetailPrevious::Search(s) => AppScreen::Search(s),
823                    };
824                }
825            }
826            KeyCode::Char('q') => return Ok(true),
827            _ => {}
828        }
829        Ok(false)
830    }
831
832    // -- Setup Wizard -------------------------------------------------------
833
834    async fn handle_setup_wizard(&mut self, key: KeyCode) -> Result<bool> {
835        let wizard = match &mut self.screen {
836            AppScreen::SetupWizard(w) => w,
837            _ => return Ok(false),
838        };
839
840        // Create a dummy event to pass to handle_key
841        let event = crossterm::event::KeyEvent::new(key, crossterm::event::KeyModifiers::empty());
842        if wizard.handle_key(event)? {
843            // Esc pressed
844            self.screen = AppScreen::Settings(SettingsScreen::new(
845                &self.config,
846                self.server_version.as_deref(),
847            ));
848            return Ok(false);
849        }
850
851        if wizard.testing {
852            let result = wizard.try_connect_and_persist(self.client.verbose()).await;
853            wizard.testing = false;
854            match result {
855                Ok(cfg) => {
856                    let auth_ok = cfg.auth.is_some();
857                    self.config = cfg;
858                    if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
859                        self.client = new_client;
860                    }
861                    let mut settings =
862                        SettingsScreen::new(&self.config, self.server_version.as_deref());
863                    if auth_ok {
864                        settings.message = Some((
865                            "Authentication updated successfully".to_string(),
866                            Color::Green,
867                        ));
868                    } else {
869                        settings.message = Some((
870                            "Saved configuration but credentials could not be loaded from the OS keyring (see logs)."
871                                .to_string(),
872                            Color::Yellow,
873                        ));
874                    }
875                    self.screen = AppScreen::Settings(settings);
876                }
877                Err(e) => {
878                    wizard.error = Some(format!("{e:#}"));
879                }
880            }
881        }
882        Ok(false)
883    }
884
885    // -----------------------------------------------------------------------
886    // Render
887    // -----------------------------------------------------------------------
888
889    fn render(&mut self, f: &mut ratatui::Frame) {
890        let area = f.size();
891        if let Some(ref splash) = self.startup_splash {
892            connected_splash::render(f, area, splash);
893            return;
894        }
895        match &mut self.screen {
896            AppScreen::MainMenu(menu) => menu.render(f, area),
897            AppScreen::LibraryBrowse(lib) => lib.render(f, area),
898            AppScreen::Search(search) => {
899                search.render(f, area);
900                if let Some((x, y)) = search.cursor_position(area) {
901                    f.set_cursor(x, y);
902                }
903            }
904            AppScreen::Settings(settings) => {
905                settings.render(f, area);
906                if let Some((x, y)) = settings.cursor_position(area) {
907                    f.set_cursor(x, y);
908                }
909            }
910            AppScreen::Browse(browse) => browse.render(f, area),
911            AppScreen::Execute(execute) => {
912                execute.render(f, area);
913                if let Some((x, y)) = execute.cursor_position(area) {
914                    f.set_cursor(x, y);
915                }
916            }
917            AppScreen::Result(result) => result.render(f, area),
918            AppScreen::ResultDetail(detail) => detail.render(f, area),
919            AppScreen::GameDetail(detail) => detail.render(f, area),
920            AppScreen::Download(d) => d.render(f, area),
921            AppScreen::SetupWizard(wizard) => {
922                wizard.render(f, area);
923                if let Some((x, y)) = wizard.cursor_pos(area) {
924                    f.set_cursor(x, y);
925                }
926            }
927        }
928
929        if let Some(ref err) = self.global_error {
930            let popup_area = ratatui::layout::Rect {
931                x: area.width.saturating_sub(60) / 2,
932                y: area.height.saturating_sub(10) / 2,
933                width: 60.min(area.width),
934                height: 10.min(area.height),
935            };
936            f.render_widget(ratatui::widgets::Clear, popup_area);
937            let block = ratatui::widgets::Block::default()
938                .title("Error")
939                .borders(ratatui::widgets::Borders::ALL)
940                .style(ratatui::style::Style::default().fg(ratatui::style::Color::Red));
941            let text = format!("{}\n\nPress Esc to dismiss", err);
942            let paragraph = ratatui::widgets::Paragraph::new(text)
943                .block(block)
944                .wrap(ratatui::widgets::Wrap { trim: true });
945            f.render_widget(paragraph, popup_area);
946        }
947    }
948}