Skip to main content

romm_cli/tui/app/
run.rs

1//! Main TUI event loop.
2
3use anyhow::Result;
4use crossterm::event::{
5    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
6};
7use crossterm::execute;
8use crossterm::terminal::{
9    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
10};
11use ratatui::backend::CrosstermBackend;
12use ratatui::Terminal;
13use std::time::Duration;
14
15use crate::commands::library_scan::ScanCacheInvalidate;
16use crate::types::RomList;
17
18use super::background::types::{RomLoadDone, RomLoadEvent};
19use super::AppScreen;
20
21impl super::App {
22    pub async fn run(&mut self) -> Result<()> {
23        enable_raw_mode()?;
24        let mut stdout = std::io::stdout();
25        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
26        let backend = CrosstermBackend::new(stdout);
27        let mut terminal = Terminal::new(backend)?;
28
29        loop {
30            self.poll_background_tasks();
31            if self
32                .startup_splash
33                .as_ref()
34                .is_some_and(|s| s.should_auto_dismiss())
35            {
36                self.startup_splash = None;
37            }
38            // Draw the current screen. `App::render` delegates to the
39            // appropriate screen type based on `self.screen`.
40            terminal.draw(|f| self.render(f))?;
41
42            // If an update was triggered, execute it now (this will block the loop and show the "Updating..." message)
43            if let Some(ref mut prompt) = self.startup_update_prompt {
44                if prompt.updating {
45                    // Safety: Don't actually run self_update if this is a mock
46                    if prompt.status.latest_version == "9.9.9-mock" {
47                        tokio::time::sleep(std::time::Duration::from_secs(2)).await; // Simulate some work
48                        self.global_notice =
49                            Some("Mock update successful! (No files were changed)".into());
50                        self.startup_update_prompt = None;
51                    } else {
52                        let options = crate::update::ApplyUpdateOptions {
53                            show_progress: false,
54                            show_output: false,
55                            no_confirm: true,
56                            target_version_tag: Some(prompt.status.release_tag.clone()),
57                        };
58                        match crate::update::apply_update(None, options).await {
59                            Ok(crate::update::ApplyUpdateOutcome::Updated(version)) => {
60                                self.global_notice = Some(format!(
61                                    "Updated to {version}. Restart romm-cli to use the new version."
62                                ));
63                            }
64                            Ok(crate::update::ApplyUpdateOutcome::UpToDate(version)) => {
65                                self.global_notice =
66                                    Some(format!("Already up to date (`{version}`)."));
67                            }
68                            Err(err) => {
69                                self.global_error = Some(format!("Update failed: {err:#}"));
70                            }
71                        }
72                        self.startup_update_prompt = None;
73                    }
74                    continue;
75                }
76            }
77
78            // Poll with a short timeout so the UI refreshes during downloads
79            // even when the user is not pressing any keys.
80            if event::poll(Duration::from_millis(100))? {
81                if let Event::Key(key_event) = event::read()? {
82                    if Self::is_force_quit_key(&key_event) {
83                        break;
84                    }
85                    if key_event.kind == KeyEventKind::Press
86                        && key_event.modifiers.contains(KeyModifiers::CONTROL)
87                        && matches!(key_event.code, KeyCode::Char('r') | KeyCode::Char('R'))
88                        && !self.blocks_global_chord_shortcuts()
89                    {
90                        if let AppScreen::LibraryBrowse(ref lib) = self.screen {
91                            if !lib.any_search_bar_open()
92                                && !lib.any_upload_prompt_open()
93                                && !self.library_upload_inflight
94                                && !self.library_scan_inflight
95                            {
96                                self.spawn_library_rescan_worker(ScanCacheInvalidate::AllPlatforms);
97                            }
98                        }
99                        continue;
100                    }
101                    if key_event.kind == KeyEventKind::Press
102                        && key_event.modifiers.contains(KeyModifiers::CONTROL)
103                        && matches!(key_event.code, KeyCode::Char('u') | KeyCode::Char('U'))
104                        && !self.blocks_global_chord_shortcuts()
105                    {
106                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
107                            if lib.any_upload_prompt_open() {
108                                lib.close_upload_prompt();
109                            } else if !lib.any_search_bar_open()
110                                && !self.library_upload_inflight
111                                && !self.library_scan_inflight
112                            {
113                                if lib.subsection
114                                    == crate::tui::screens::library_browse::LibrarySubsection::ByConsole
115                                {
116                                    lib.open_upload_prompt();
117                                } else {
118                                    lib.set_metadata_footer(Some(
119                                        "Upload requires Consoles view — press t".into(),
120                                    ));
121                                }
122                            }
123                        }
124                        continue;
125                    }
126                    if key_event.kind == KeyEventKind::Press
127                        && self.handle_key_event(&key_event).await?
128                    {
129                        break;
130                    }
131                }
132            }
133
134            // Process deferred ROM fetch (set during LibraryBrowse ↑/↓, subsection switch, refresh).
135            // Cache hits apply synchronously; network fetch runs in a background task so the loop
136            // never awaits HTTP and the UI stays responsive (see `poll_rom_load_results`).
137            if let Some((key, req, expected, context, started)) = self.deferred_load_roms.take() {
138                // Fast path: valid disk cache — no await, no spawn, load immediately.
139                if let Some(ref k) = key {
140                    if let Some(cached) = self.rom_cache.get_valid(k, expected) {
141                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
142                            if crate::tui::app::rom_load::primary_rom_load_result_matches_selection(
143                                lib, &key,
144                            ) {
145                                lib.set_roms(cached.clone());
146                                lib.set_rom_loading(false);
147                                tracing::debug!(
148                                    "rom-list-render context={} latency_ms={} (cache_hit)",
149                                    context,
150                                    started.elapsed().as_millis()
151                                );
152                            } else {
153                                lib.set_rom_loading(false);
154                                tracing::debug!(
155                                    "rom-list-render context={} skipped stale cache hit",
156                                    context
157                                );
158                            }
159                        }
160                        continue;
161                    }
162                }
163
164                // Debounce network fetches
165                if started.elapsed() < std::time::Duration::from_millis(250) {
166                    // Put it back to keep waiting
167                    self.deferred_load_roms = Some((key, req, expected, context, started));
168                    continue;
169                }
170
171                let gen = self.rom_load_gen;
172                if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
173                    lib.set_rom_loading(expected > 0);
174                }
175                if expected == 0 {
176                    if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
177                        lib.set_rom_loading(false);
178                    }
179                    continue;
180                }
181
182                let Some(r) = req else {
183                    if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
184                        lib.set_rom_loading(false);
185                    }
186                    continue;
187                };
188                let client = self.client.clone();
189                let tx = self.rom_load_tx.clone();
190
191                self.rom_load_task = Some(tokio::spawn(async move {
192                    let mut req = r;
193                    let mut aggregated: Option<RomList> = None;
194
195                    loop {
196                        match client.call(&req).await {
197                            Ok(mut batch) => {
198                                if let Some(ref mut all) = aggregated {
199                                    if batch.items.is_empty() {
200                                        break;
201                                    }
202                                    all.items.append(&mut batch.items);
203                                    let _ = tx.send(RomLoadDone {
204                                        gen,
205                                        key: key.clone(),
206                                        expected,
207                                        event: RomLoadEvent::Batch(all.clone()),
208                                        context,
209                                        started,
210                                    });
211                                    if all.items.len() as u64 >= all.total {
212                                        break;
213                                    }
214                                    req.offset = Some(all.items.len() as u32);
215                                } else {
216                                    let loaded = batch.items.len() as u64;
217                                    let total = batch.total;
218                                    let _ = tx.send(RomLoadDone {
219                                        gen,
220                                        key: key.clone(),
221                                        expected,
222                                        event: RomLoadEvent::Batch(batch.clone()),
223                                        context,
224                                        started,
225                                    });
226                                    req.offset = Some(loaded as u32);
227                                    aggregated = Some(batch);
228                                    if loaded >= total {
229                                        break;
230                                    }
231                                }
232                            }
233                            Err(e) => {
234                                let _ = tx.send(RomLoadDone {
235                                    gen,
236                                    key: key.clone(),
237                                    expected,
238                                    event: RomLoadEvent::Failed(format!("{e:#}")),
239                                    context,
240                                    started,
241                                });
242                                return;
243                            }
244                        }
245                        // Cap at 20k
246                        if let Some(ref all) = aggregated {
247                            if all.items.len() >= 20000 {
248                                break;
249                            }
250                        }
251                    }
252
253                    let _ = tx.send(RomLoadDone {
254                        gen,
255                        key,
256                        expected,
257                        event: RomLoadEvent::Complete,
258                        context,
259                        started,
260                    });
261                }));
262            }
263        }
264
265        disable_raw_mode()?;
266        execute!(
267            terminal.backend_mut(),
268            LeaveAlternateScreen,
269            DisableMouseCapture
270        )?;
271        terminal.show_cursor()?;
272        Ok(())
273    }
274}