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