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, MainMenuScreen,
37    SearchScreen, 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    MainMenu(MainMenuScreen),
55    LibraryBrowse(Box<LibraryBrowseScreen>),
56    Search(SearchScreen),
57    Settings(Box<SettingsScreen>),
58    GameDetail(Box<GameDetailScreen>),
59    ExtrasPicker(Box<ExtrasPickerScreen>),
60    Download(DownloadScreen),
61    SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
62}
63
64/// Root application object for the TUI.
65///
66/// Owns shared services (`RommClient`, `RomCache`, `DownloadManager`)
67/// as well as the currently active [`AppScreen`].
68pub struct App {
69    pub screen: AppScreen,
70    client: RommClient,
71    config: Config,
72    /// RomM server version from `GET /api/heartbeat` (`SYSTEM.VERSION`), if available.
73    server_version: Option<String>,
74    save_sync_compat: SaveSyncCompatibility,
75    rom_cache: RomCache,
76    downloads: DownloadManager,
77    /// Screen to restore when closing the Download overlay.
78    screen_before_download: Option<AppScreen>,
79    /// Deferred ROM load: (cache_key, api_request, expected_rom_count, context, start).
80    deferred_load_roms: Option<DeferredLoadRoms>,
81    /// Brief “connected” banner after setup or when the server responds to heartbeat.
82    startup_splash: Option<StartupSplash>,
83    pub global_error: Option<String>,
84    pub global_notice: Option<String>,
85    show_keyboard_help: bool,
86    startup_update_prompt: Option<StartupUpdatePrompt>,
87    /// Receives completed background metadata refreshes for the library screen.
88    library_metadata_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LibraryMetadataRefreshDone>>,
89    /// Incremented each time a new refresh is spawned; stale completions are ignored.
90    library_metadata_refresh_gen: u64,
91    collection_prefetch_rx: tokio::sync::mpsc::UnboundedReceiver<CollectionPrefetchDone>,
92    collection_prefetch_tx: tokio::sync::mpsc::UnboundedSender<CollectionPrefetchDone>,
93    collection_prefetch_queue: VecDeque<(RomCacheKey, GetRoms, u64)>,
94    collection_prefetch_queued_keys: HashSet<RomCacheKey>,
95    collection_prefetch_inflight_keys: HashSet<RomCacheKey>,
96    /// Latest generation for primary ROM loads; completions with a lower gen are ignored.
97    rom_load_gen: u64,
98    rom_load_rx: tokio::sync::mpsc::UnboundedReceiver<RomLoadDone>,
99    rom_load_tx: tokio::sync::mpsc::UnboundedSender<RomLoadDone>,
100    rom_load_task: Option<tokio::task::JoinHandle<()>>,
101    search_load_rx: tokio::sync::mpsc::UnboundedReceiver<SearchLoadDone>,
102    search_load_tx: tokio::sync::mpsc::UnboundedSender<SearchLoadDone>,
103    search_load_task: Option<tokio::task::JoinHandle<()>>,
104    cover_load_rx: tokio::sync::mpsc::UnboundedReceiver<CoverLoadDone>,
105    cover_load_tx: tokio::sync::mpsc::UnboundedSender<CoverLoadDone>,
106    cover_load_task: Option<tokio::task::JoinHandle<()>>,
107    /// Receives `Ok(())` when a background `scan_library` (with wait) finishes successfully.
108    library_scan_rx: Option<tokio::sync::mpsc::UnboundedReceiver<Result<(), String>>>,
109    library_scan_inflight: bool,
110    /// Cache policy applied when the current background scan completes successfully.
111    library_scan_pending_invalidate: Option<ScanCacheInvalidate>,
112    /// After a successful server scan, force ROM list reload once metadata refresh completes.
113    force_rom_reload_after_metadata: bool,
114    /// Background chunked ROM upload to the selected platform.
115    library_upload_inflight: bool,
116    library_upload_progress_rx: Option<tokio::sync::mpsc::UnboundedReceiver<(u64, u64)>>,
117    library_upload_done_rx:
118        Option<tokio::sync::mpsc::UnboundedReceiver<Result<LibraryUploadComplete, String>>>,
119    save_list_rx: tokio::sync::mpsc::UnboundedReceiver<SaveListDone>,
120    save_list_tx: tokio::sync::mpsc::UnboundedSender<SaveListDone>,
121    save_upload_rx: tokio::sync::mpsc::UnboundedReceiver<SaveUploadDone>,
122    save_upload_tx: tokio::sync::mpsc::UnboundedSender<SaveUploadDone>,
123    save_download_rx: tokio::sync::mpsc::UnboundedReceiver<SaveDownloadDone>,
124    save_download_tx: tokio::sync::mpsc::UnboundedSender<SaveDownloadDone>,
125    device_list_rx: tokio::sync::mpsc::UnboundedReceiver<DeviceListDone>,
126    device_list_tx: tokio::sync::mpsc::UnboundedSender<DeviceListDone>,
127    platform_list_rx: tokio::sync::mpsc::UnboundedReceiver<PlatformListDone>,
128    platform_list_tx: tokio::sync::mpsc::UnboundedSender<PlatformListDone>,
129    sync_push_pull_rx: tokio::sync::mpsc::UnboundedReceiver<SyncPushPullDone>,
130    sync_push_pull_tx: tokio::sync::mpsc::UnboundedSender<SyncPushPullDone>,
131    theme: Box<dyn Theme>,
132}
133
134impl App {
135    fn blocks_global_chord_shortcuts(&self) -> bool {
136        self.startup_splash.is_some()
137            || self.startup_update_prompt.is_some()
138            || self.global_error.is_some()
139            || self.global_notice.is_some()
140    }
141
142    fn blocks_global_d_shortcut(&self) -> bool {
143        let base = match &self.screen {
144            AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_) => true,
145            AppScreen::LibraryBrowse(lib) => {
146                lib.any_search_bar_open() || lib.any_upload_prompt_open()
147            }
148            _ => false,
149        };
150        base || self.library_upload_inflight || self.blocks_global_chord_shortcuts()
151    }
152
153    fn allows_global_question_help(&self) -> bool {
154        match &self.screen {
155            AppScreen::Search(_) | AppScreen::SetupWizard(_) => false,
156            AppScreen::LibraryBrowse(lib)
157                if lib.any_search_bar_open() || lib.any_upload_prompt_open() =>
158            {
159                false
160            }
161            AppScreen::Settings(s) if s.editing || s.path_picker.is_some() => false,
162            _ => true,
163        }
164    }
165
166    pub(crate) fn is_force_quit_key(key: &crossterm::event::KeyEvent) -> bool {
167        key.kind == KeyEventKind::Press
168            && key.modifiers.contains(KeyModifiers::CONTROL)
169            && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
170    }
171    /// Construct a new `App` with fresh cache and empty download list.
172    pub fn new(
173        client: RommClient,
174        config: Config,
175        save_sync_compat: SaveSyncCompatibility,
176        server_version: Option<String>,
177        startup_splash: Option<StartupSplash>,
178        startup_update: Option<UpdateStatus>,
179    ) -> Self {
180        let (prefetch_tx, prefetch_rx) = tokio::sync::mpsc::unbounded_channel();
181        let (rom_load_tx, rom_load_rx) = tokio::sync::mpsc::unbounded_channel();
182        let (search_load_tx, search_load_rx) = tokio::sync::mpsc::unbounded_channel();
183        let (cover_load_tx, cover_load_rx) = tokio::sync::mpsc::unbounded_channel();
184        let (save_list_tx, save_list_rx) = tokio::sync::mpsc::unbounded_channel();
185        let (save_upload_tx, save_upload_rx) = tokio::sync::mpsc::unbounded_channel();
186        let (save_download_tx, save_download_rx) = tokio::sync::mpsc::unbounded_channel();
187        let (device_list_tx, device_list_rx) = tokio::sync::mpsc::unbounded_channel();
188        let (platform_list_tx, platform_list_rx) = tokio::sync::mpsc::unbounded_channel();
189        let (sync_push_pull_tx, sync_push_pull_rx) = tokio::sync::mpsc::unbounded_channel();
190        let theme = resolve_theme_or_default(&config.theme);
191        Self {
192            screen: AppScreen::MainMenu(MainMenuScreen::new()),
193            client,
194            config,
195            server_version,
196            save_sync_compat,
197            rom_cache: RomCache::load(),
198            downloads: DownloadManager::new(),
199            screen_before_download: None,
200            deferred_load_roms: None,
201            startup_splash,
202            global_error: None,
203            global_notice: None,
204            show_keyboard_help: false,
205            startup_update_prompt: startup_update.map(|status| StartupUpdatePrompt {
206                status,
207                updating: false,
208            }),
209            library_metadata_rx: None,
210            library_metadata_refresh_gen: 0,
211            collection_prefetch_rx: prefetch_rx,
212            collection_prefetch_tx: prefetch_tx,
213            collection_prefetch_queue: VecDeque::new(),
214            collection_prefetch_queued_keys: HashSet::new(),
215            collection_prefetch_inflight_keys: HashSet::new(),
216            rom_load_gen: 0,
217            rom_load_rx,
218            rom_load_tx,
219            rom_load_task: None,
220            search_load_rx,
221            search_load_tx,
222            search_load_task: None,
223            cover_load_rx,
224            cover_load_tx,
225            cover_load_task: None,
226            library_scan_rx: None,
227            library_scan_inflight: false,
228            library_scan_pending_invalidate: None,
229            force_rom_reload_after_metadata: false,
230            library_upload_inflight: false,
231            library_upload_progress_rx: None,
232            library_upload_done_rx: None,
233            save_list_rx,
234            save_list_tx,
235            save_upload_rx,
236            save_upload_tx,
237            save_download_rx,
238            save_download_tx,
239            device_list_rx,
240            device_list_tx,
241            platform_list_rx,
242            platform_list_tx,
243            sync_push_pull_rx,
244            sync_push_pull_tx,
245            theme,
246        }
247    }
248    pub fn set_error(&mut self, err: crate::error::RommError) {
249        self.global_error = Some(crate::error::user_message(&err));
250    }
251
252    /// Reapply the theme from persisted in-memory config (discards unsaved preview).
253    pub(in crate::tui::app) fn apply_saved_theme(&mut self) {
254        self.theme = resolve_theme_or_default(&self.config.theme);
255    }
256
257    #[cfg(test)]
258    pub(crate) fn theme_id(&self) -> &str {
259        self.theme.id()
260    }
261    /// Legacy test/helper entry: map key → actions → update.
262    pub async fn handle_key_event(&mut self, key: &KeyEvent) -> Result<bool> {
263        for action in event::map_key_to_actions(self, key) {
264            if self.update(action).await? {
265                return Ok(true);
266            }
267        }
268        Ok(false)
269    }
270}