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