Skip to main content

romm_cli/tui/app/
mod.rs

1//! Application state and TUI event loop.
2//!
3//! Submodules:
4//! - [`background`] — async task spawn/poll
5//! - [`handlers`] — per-screen key handlers
6//! - [`run`] — terminal event loop
7//! - [`render`] — frame drawing
8//! - [`rom_load`] — ROM fetch and prefetch scheduling
9
10mod background;
11mod handlers;
12mod render;
13mod rom_load;
14mod run;
15
16#[cfg(test)]
17mod tests;
18
19use anyhow::Result;
20use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
21use std::collections::{HashSet, VecDeque};
22
23use crate::client::RommClient;
24use crate::commands::library_scan::ScanCacheInvalidate;
25use crate::config::Config;
26use crate::core::cache::{RomCache, RomCacheKey};
27use crate::core::download::DownloadManager;
28use crate::endpoints::roms::GetRoms;
29use crate::feature_compat::SaveSyncCompatibility;
30use crate::update::UpdateStatus;
31
32use super::screens::connected_splash::StartupSplash;
33use super::screens::{
34    DownloadScreen, ExtrasPickerScreen, GameDetailScreen, LibraryBrowseScreen, MainMenuScreen,
35    SearchScreen, SettingsScreen,
36};
37use super::theme::resolve_theme_or_default;
38use ratatui_themekit::Theme;
39
40use background::types::{
41    CollectionPrefetchDone, CoverLoadDone, DeferredLoadRoms, DeviceListDone,
42    LibraryMetadataRefreshDone, LibraryUploadComplete, PlatformListDone, RomLoadDone,
43    SaveDownloadDone, SaveListDone, SaveUploadDone, SearchLoadDone, StartupUpdatePrompt,
44    SyncPushPullDone,
45};
46
47/// All possible high-level screens in the TUI.
48///
49/// `App` holds exactly one of these at a time and delegates both
50/// rendering and key handling based on the current variant.
51pub enum AppScreen {
52    MainMenu(MainMenuScreen),
53    LibraryBrowse(Box<LibraryBrowseScreen>),
54    Search(SearchScreen),
55    Settings(Box<SettingsScreen>),
56    GameDetail(Box<GameDetailScreen>),
57    ExtrasPicker(Box<ExtrasPickerScreen>),
58    Download(DownloadScreen),
59    SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
60}
61
62/// Root application object for the TUI.
63///
64/// Owns shared services (`RommClient`, `RomCache`, `DownloadManager`)
65/// as well as the currently active [`AppScreen`].
66pub struct App {
67    pub screen: AppScreen,
68    client: RommClient,
69    config: Config,
70    /// RomM server version from `GET /api/heartbeat` (`SYSTEM.VERSION`), if available.
71    server_version: Option<String>,
72    save_sync_compat: SaveSyncCompatibility,
73    rom_cache: RomCache,
74    downloads: DownloadManager,
75    /// Screen to restore when closing the Download overlay.
76    screen_before_download: Option<AppScreen>,
77    /// Deferred ROM load: (cache_key, api_request, expected_rom_count, context, start).
78    deferred_load_roms: Option<DeferredLoadRoms>,
79    /// Brief “connected” banner after setup or when the server responds to heartbeat.
80    startup_splash: Option<StartupSplash>,
81    pub global_error: Option<String>,
82    pub global_notice: Option<String>,
83    show_keyboard_help: bool,
84    startup_update_prompt: Option<StartupUpdatePrompt>,
85    /// Receives completed background metadata refreshes for the library screen.
86    library_metadata_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LibraryMetadataRefreshDone>>,
87    /// Incremented each time a new refresh is spawned; stale completions are ignored.
88    library_metadata_refresh_gen: u64,
89    collection_prefetch_rx: tokio::sync::mpsc::UnboundedReceiver<CollectionPrefetchDone>,
90    collection_prefetch_tx: tokio::sync::mpsc::UnboundedSender<CollectionPrefetchDone>,
91    collection_prefetch_queue: VecDeque<(RomCacheKey, GetRoms, u64)>,
92    collection_prefetch_queued_keys: HashSet<RomCacheKey>,
93    collection_prefetch_inflight_keys: HashSet<RomCacheKey>,
94    /// Latest generation for primary ROM loads; completions with a lower gen are ignored.
95    rom_load_gen: u64,
96    rom_load_rx: tokio::sync::mpsc::UnboundedReceiver<RomLoadDone>,
97    rom_load_tx: tokio::sync::mpsc::UnboundedSender<RomLoadDone>,
98    rom_load_task: Option<tokio::task::JoinHandle<()>>,
99    search_load_rx: tokio::sync::mpsc::UnboundedReceiver<SearchLoadDone>,
100    search_load_tx: tokio::sync::mpsc::UnboundedSender<SearchLoadDone>,
101    search_load_task: Option<tokio::task::JoinHandle<()>>,
102    cover_load_rx: tokio::sync::mpsc::UnboundedReceiver<CoverLoadDone>,
103    cover_load_tx: tokio::sync::mpsc::UnboundedSender<CoverLoadDone>,
104    cover_load_task: Option<tokio::task::JoinHandle<()>>,
105    /// Receives `Ok(())` when a background `scan_library` (with wait) finishes successfully.
106    library_scan_rx: Option<tokio::sync::mpsc::UnboundedReceiver<Result<(), String>>>,
107    library_scan_inflight: bool,
108    /// Cache policy applied when the current background scan completes successfully.
109    library_scan_pending_invalidate: Option<ScanCacheInvalidate>,
110    /// After a successful server scan, force ROM list reload once metadata refresh completes.
111    force_rom_reload_after_metadata: bool,
112    /// Background chunked ROM upload to the selected platform.
113    library_upload_inflight: bool,
114    library_upload_progress_rx: Option<tokio::sync::mpsc::UnboundedReceiver<(u64, u64)>>,
115    library_upload_done_rx:
116        Option<tokio::sync::mpsc::UnboundedReceiver<Result<LibraryUploadComplete, String>>>,
117    save_list_rx: tokio::sync::mpsc::UnboundedReceiver<SaveListDone>,
118    save_list_tx: tokio::sync::mpsc::UnboundedSender<SaveListDone>,
119    save_upload_rx: tokio::sync::mpsc::UnboundedReceiver<SaveUploadDone>,
120    save_upload_tx: tokio::sync::mpsc::UnboundedSender<SaveUploadDone>,
121    save_download_rx: tokio::sync::mpsc::UnboundedReceiver<SaveDownloadDone>,
122    save_download_tx: tokio::sync::mpsc::UnboundedSender<SaveDownloadDone>,
123    device_list_rx: tokio::sync::mpsc::UnboundedReceiver<DeviceListDone>,
124    device_list_tx: tokio::sync::mpsc::UnboundedSender<DeviceListDone>,
125    platform_list_rx: tokio::sync::mpsc::UnboundedReceiver<PlatformListDone>,
126    platform_list_tx: tokio::sync::mpsc::UnboundedSender<PlatformListDone>,
127    sync_push_pull_rx: tokio::sync::mpsc::UnboundedReceiver<SyncPushPullDone>,
128    sync_push_pull_tx: tokio::sync::mpsc::UnboundedSender<SyncPushPullDone>,
129    theme: Box<dyn Theme>,
130}
131
132impl App {
133    fn blocks_global_chord_shortcuts(&self) -> bool {
134        self.startup_splash.is_some()
135            || self.startup_update_prompt.is_some()
136            || self.global_error.is_some()
137            || self.global_notice.is_some()
138    }
139
140    fn blocks_global_d_shortcut(&self) -> bool {
141        let base = match &self.screen {
142            AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_) => true,
143            AppScreen::LibraryBrowse(lib) => {
144                lib.any_search_bar_open() || lib.any_upload_prompt_open()
145            }
146            _ => false,
147        };
148        base || self.library_upload_inflight || self.blocks_global_chord_shortcuts()
149    }
150
151    fn allows_global_question_help(&self) -> bool {
152        match &self.screen {
153            AppScreen::Search(_) | AppScreen::SetupWizard(_) => false,
154            AppScreen::LibraryBrowse(lib)
155                if lib.any_search_bar_open() || lib.any_upload_prompt_open() =>
156            {
157                false
158            }
159            AppScreen::Settings(s) if s.editing || s.path_picker.is_some() => false,
160            _ => true,
161        }
162    }
163
164    pub(crate) fn is_force_quit_key(key: &crossterm::event::KeyEvent) -> bool {
165        key.kind == KeyEventKind::Press
166            && key.modifiers.contains(KeyModifiers::CONTROL)
167            && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
168    }
169    /// Construct a new `App` with fresh cache and empty download list.
170    pub fn new(
171        client: RommClient,
172        config: Config,
173        save_sync_compat: SaveSyncCompatibility,
174        server_version: Option<String>,
175        startup_splash: Option<StartupSplash>,
176        startup_update: Option<UpdateStatus>,
177    ) -> Self {
178        let (prefetch_tx, prefetch_rx) = tokio::sync::mpsc::unbounded_channel();
179        let (rom_load_tx, rom_load_rx) = tokio::sync::mpsc::unbounded_channel();
180        let (search_load_tx, search_load_rx) = tokio::sync::mpsc::unbounded_channel();
181        let (cover_load_tx, cover_load_rx) = tokio::sync::mpsc::unbounded_channel();
182        let (save_list_tx, save_list_rx) = tokio::sync::mpsc::unbounded_channel();
183        let (save_upload_tx, save_upload_rx) = tokio::sync::mpsc::unbounded_channel();
184        let (save_download_tx, save_download_rx) = tokio::sync::mpsc::unbounded_channel();
185        let (device_list_tx, device_list_rx) = tokio::sync::mpsc::unbounded_channel();
186        let (platform_list_tx, platform_list_rx) = tokio::sync::mpsc::unbounded_channel();
187        let (sync_push_pull_tx, sync_push_pull_rx) = tokio::sync::mpsc::unbounded_channel();
188        let theme = resolve_theme_or_default(&config.theme);
189        Self {
190            screen: AppScreen::MainMenu(MainMenuScreen::new()),
191            client,
192            config,
193            server_version,
194            save_sync_compat,
195            rom_cache: RomCache::load(),
196            downloads: DownloadManager::new(),
197            screen_before_download: None,
198            deferred_load_roms: None,
199            startup_splash,
200            global_error: None,
201            global_notice: None,
202            show_keyboard_help: false,
203            startup_update_prompt: startup_update.map(|status| StartupUpdatePrompt {
204                status,
205                updating: false,
206            }),
207            library_metadata_rx: None,
208            library_metadata_refresh_gen: 0,
209            collection_prefetch_rx: prefetch_rx,
210            collection_prefetch_tx: prefetch_tx,
211            collection_prefetch_queue: VecDeque::new(),
212            collection_prefetch_queued_keys: HashSet::new(),
213            collection_prefetch_inflight_keys: HashSet::new(),
214            rom_load_gen: 0,
215            rom_load_rx,
216            rom_load_tx,
217            rom_load_task: None,
218            search_load_rx,
219            search_load_tx,
220            search_load_task: None,
221            cover_load_rx,
222            cover_load_tx,
223            cover_load_task: None,
224            library_scan_rx: None,
225            library_scan_inflight: false,
226            library_scan_pending_invalidate: None,
227            force_rom_reload_after_metadata: false,
228            library_upload_inflight: false,
229            library_upload_progress_rx: None,
230            library_upload_done_rx: None,
231            save_list_rx,
232            save_list_tx,
233            save_upload_rx,
234            save_upload_tx,
235            save_download_rx,
236            save_download_tx,
237            device_list_rx,
238            device_list_tx,
239            platform_list_rx,
240            platform_list_tx,
241            sync_push_pull_rx,
242            sync_push_pull_tx,
243            theme,
244        }
245    }
246    pub fn set_error(&mut self, err: anyhow::Error) {
247        self.global_error = Some(format!("{:#}", err));
248    }
249
250    /// Reapply the theme from persisted in-memory config (discards unsaved preview).
251    pub(in crate::tui::app) fn apply_saved_theme(&mut self) {
252        self.theme = resolve_theme_or_default(&self.config.theme);
253    }
254
255    #[cfg(test)]
256    pub(crate) fn theme_id(&self) -> &str {
257        self.theme.id()
258    }
259    pub async fn handle_key_event(&mut self, key: &KeyEvent) -> Result<bool> {
260        if key.kind != KeyEventKind::Press {
261            return Ok(false);
262        }
263
264        if self.global_error.is_some() || self.global_notice.is_some() {
265            if key.code == KeyCode::Esc || key.code == KeyCode::Enter {
266                self.global_error = None;
267                self.global_notice = None;
268            }
269            return Ok(false);
270        }
271
272        // Dismiss the connected splash before the update prompt: the splash is drawn on top,
273        // so Enter/Esc here must not be routed to the hidden update dialog (which would quit).
274        if self.startup_splash.is_some() {
275            self.startup_splash = None;
276            return Ok(false);
277        }
278
279        if self.startup_update_prompt.is_some() {
280            return self.handle_startup_update_prompt(key).await;
281        }
282
283        if self.show_keyboard_help {
284            if matches!(
285                key.code,
286                KeyCode::Esc | KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('?')
287            ) {
288                self.show_keyboard_help = false;
289            }
290            return Ok(false);
291        }
292
293        if key.code == KeyCode::F(1) {
294            self.show_keyboard_help = true;
295            return Ok(false);
296        }
297        if key.code == KeyCode::Char('?') && self.allows_global_question_help() {
298            self.show_keyboard_help = true;
299            return Ok(false);
300        }
301
302        // Global shortcut: 'd' toggles Download overlay (not on screens that need free typing / menus).
303        if key.code == KeyCode::Char('d') && !self.blocks_global_d_shortcut() {
304            self.toggle_download_screen();
305            return Ok(false);
306        }
307
308        match &self.screen {
309            AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
310            AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
311            AppScreen::Search(_) => self.handle_search(key).await,
312            AppScreen::Settings(_) => self.handle_settings(key).await,
313            AppScreen::GameDetail(_) => self.handle_game_detail(key),
314            AppScreen::ExtrasPicker(_) => self.handle_extras_picker(key),
315            AppScreen::Download(_) => self.handle_download(key),
316            AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
317        }
318    }
319}