1use anyhow::Result;
14use crossterm::event::{
15 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
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::collections::{HashSet, VecDeque};
25use std::path::{Path, PathBuf};
26use std::time::{Duration, Instant};
27
28use crate::client::RommClient;
29use crate::commands::library_scan::ScanCacheInvalidate;
30use crate::config::{auth_for_persist_merge, normalize_romm_origin, Config};
31use crate::core::cache::{RomCache, RomCacheKey};
32use crate::core::download::DownloadManager;
33use crate::core::startup_library_snapshot;
34use crate::endpoints::roms::GetRoms;
35use crate::types::{Collection, Platform, RomList};
36
37use super::keyboard_help;
38use super::openapi::{resolve_path_template, EndpointRegistry};
39use super::screens::connected_splash::{self, StartupSplash};
40use super::screens::setup_wizard::SetupWizard;
41use super::screens::{
42 BrowseScreen, DownloadScreen, ExecuteScreen, GameDetailPrevious, GameDetailScreen,
43 LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen, SearchScreen,
44 SettingsScreen,
45};
46
47struct LibraryMetadataRefreshDone {
49 gen: u64,
50 platforms: Vec<Platform>,
51 collections: Vec<Collection>,
52 collection_digest: Vec<startup_library_snapshot::CollectionDigestEntry>,
53 warnings: Vec<String>,
54}
55
56struct CollectionPrefetchDone {
57 key: RomCacheKey,
58 expected: u64,
59 roms: Option<RomList>,
60 warning: Option<String>,
61}
62
63struct RomLoadDone {
65 gen: u64,
66 key: Option<RomCacheKey>,
67 expected: u64,
68 result: Result<RomList, String>,
69 context: &'static str,
70 started: Instant,
71}
72
73struct SearchLoadDone {
74 result: Result<RomList, String>,
75}
76
77type DeferredLoadRoms = (
79 Option<RomCacheKey>,
80 Option<GetRoms>,
81 u64,
82 &'static str,
83 Instant,
84);
85
86#[inline]
87fn primary_rom_load_result_is_current(done_gen: u64, current_gen: u64) -> bool {
88 done_gen == current_gen
89}
90
91pub enum AppScreen {
100 MainMenu(MainMenuScreen),
101 LibraryBrowse(LibraryBrowseScreen),
102 Search(SearchScreen),
103 Settings(SettingsScreen),
104 Browse(BrowseScreen),
105 Execute(ExecuteScreen),
106 Result(ResultScreen),
107 ResultDetail(ResultDetailScreen),
108 GameDetail(Box<GameDetailScreen>),
109 Download(DownloadScreen),
110 SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
111}
112
113struct LibraryUploadComplete {
115 platform_id: u64,
116 scan_after: bool,
117}
118
119pub struct App {
128 pub screen: AppScreen,
129 client: RommClient,
130 config: Config,
131 registry: EndpointRegistry,
132 server_version: Option<String>,
134 rom_cache: RomCache,
135 downloads: DownloadManager,
136 screen_before_download: Option<AppScreen>,
138 deferred_load_roms: Option<DeferredLoadRoms>,
140 startup_splash: Option<StartupSplash>,
142 pub global_error: Option<String>,
143 show_keyboard_help: bool,
144 library_metadata_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LibraryMetadataRefreshDone>>,
146 library_metadata_refresh_gen: u64,
148 collection_prefetch_rx: tokio::sync::mpsc::UnboundedReceiver<CollectionPrefetchDone>,
149 collection_prefetch_tx: tokio::sync::mpsc::UnboundedSender<CollectionPrefetchDone>,
150 collection_prefetch_queue: VecDeque<(RomCacheKey, GetRoms, u64)>,
151 collection_prefetch_queued_keys: HashSet<RomCacheKey>,
152 collection_prefetch_inflight_keys: HashSet<RomCacheKey>,
153 rom_load_gen: u64,
155 rom_load_rx: tokio::sync::mpsc::UnboundedReceiver<RomLoadDone>,
156 rom_load_tx: tokio::sync::mpsc::UnboundedSender<RomLoadDone>,
157 rom_load_task: Option<tokio::task::JoinHandle<()>>,
158 search_load_rx: tokio::sync::mpsc::UnboundedReceiver<SearchLoadDone>,
159 search_load_tx: tokio::sync::mpsc::UnboundedSender<SearchLoadDone>,
160 search_load_task: Option<tokio::task::JoinHandle<()>>,
161 library_scan_rx: Option<tokio::sync::mpsc::UnboundedReceiver<Result<(), String>>>,
163 library_scan_inflight: bool,
164 library_scan_pending_invalidate: Option<ScanCacheInvalidate>,
166 force_rom_reload_after_metadata: bool,
168 library_upload_inflight: bool,
170 library_upload_progress_rx: Option<tokio::sync::mpsc::UnboundedReceiver<(u64, u64)>>,
171 library_upload_done_rx:
172 Option<tokio::sync::mpsc::UnboundedReceiver<Result<LibraryUploadComplete, String>>>,
173}
174
175impl App {
176 fn blocks_global_d_shortcut(&self) -> bool {
177 let base = match &self.screen {
178 AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_) => true,
179 AppScreen::LibraryBrowse(lib) => {
180 lib.any_search_bar_open() || lib.any_upload_prompt_open()
181 }
182 _ => false,
183 };
184 base || self.library_upload_inflight
185 }
186
187 fn allows_global_question_help(&self) -> bool {
188 match &self.screen {
189 AppScreen::Search(_) | AppScreen::SetupWizard(_) | AppScreen::Execute(_) => false,
190 AppScreen::LibraryBrowse(lib)
191 if lib.any_search_bar_open() || lib.any_upload_prompt_open() =>
192 {
193 false
194 }
195 AppScreen::Settings(s) if s.editing => false,
196 _ => true,
197 }
198 }
199
200 fn is_force_quit_key(key: &crossterm::event::KeyEvent) -> bool {
201 key.kind == KeyEventKind::Press
202 && key.modifiers.contains(KeyModifiers::CONTROL)
203 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
204 }
205
206 fn selected_rom_request_for_library(
207 lib: &super::screens::library_browse::LibraryBrowseScreen,
208 ) -> Option<GetRoms> {
209 match lib.subsection {
210 super::screens::library_browse::LibrarySubsection::ByConsole => {
211 lib.get_roms_request_platform()
212 }
213 super::screens::library_browse::LibrarySubsection::ByCollection => {
214 lib.get_roms_request_collection()
215 }
216 }
217 }
218
219 pub fn new(
221 client: RommClient,
222 config: Config,
223 registry: EndpointRegistry,
224 server_version: Option<String>,
225 startup_splash: Option<StartupSplash>,
226 ) -> Self {
227 let (prefetch_tx, prefetch_rx) = tokio::sync::mpsc::unbounded_channel();
228 let (rom_load_tx, rom_load_rx) = tokio::sync::mpsc::unbounded_channel();
229 let (search_load_tx, search_load_rx) = tokio::sync::mpsc::unbounded_channel();
230 Self {
231 screen: AppScreen::MainMenu(MainMenuScreen::new()),
232 client,
233 config,
234 registry,
235 server_version,
236 rom_cache: RomCache::load(),
237 downloads: DownloadManager::new(),
238 screen_before_download: None,
239 deferred_load_roms: None,
240 startup_splash,
241 global_error: None,
242 show_keyboard_help: false,
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 library_scan_rx: None,
258 library_scan_inflight: false,
259 library_scan_pending_invalidate: None,
260 force_rom_reload_after_metadata: false,
261 library_upload_inflight: false,
262 library_upload_progress_rx: None,
263 library_upload_done_rx: None,
264 }
265 }
266
267 fn spawn_library_metadata_refresh(&mut self) {
268 self.library_metadata_refresh_gen = self.library_metadata_refresh_gen.saturating_add(1);
269 let gen = self.library_metadata_refresh_gen;
270 let client = self.client.clone();
271 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
272 self.library_metadata_rx = Some(rx);
273 tokio::spawn(async move {
274 let fetch = startup_library_snapshot::fetch_merged_library_metadata(&client).await;
275 let _ = tx.send(LibraryMetadataRefreshDone {
276 gen,
277 platforms: fetch.platforms,
278 collections: fetch.collections,
279 collection_digest: fetch.collection_digest,
280 warnings: fetch.warnings,
281 });
282 });
283 }
284
285 pub fn poll_background_tasks(&mut self) {
287 self.poll_library_metadata_refresh();
288 self.poll_rom_load_results();
289 self.poll_collection_prefetch_results();
290 self.poll_search_load_results();
291 self.poll_library_upload();
292 self.poll_library_scan();
293 self.drive_collection_prefetch_scheduler();
294 }
295
296 fn spawn_library_rescan_worker(&mut self, cache_on_success: ScanCacheInvalidate) {
297 if self.library_scan_inflight {
298 return;
299 }
300 self.library_scan_inflight = true;
301 self.library_scan_pending_invalidate = Some(cache_on_success);
302 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
303 lib.set_metadata_footer(Some("Server library scan running…".into()));
304 }
305 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
306 self.library_scan_rx = Some(rx);
307 let client = self.client.clone();
308 tokio::spawn(async move {
309 let result = async {
310 let start = crate::commands::library_scan::start_scan_library(&client).await?;
311 crate::commands::library_scan::wait_for_task_terminal(
312 &client,
313 &start.task_id,
314 Duration::from_secs(3600),
315 |_| {},
316 )
317 .await?;
318 Ok::<(), anyhow::Error>(())
319 }
320 .await
321 .map_err(|e| e.to_string());
322 let _ = tx.send(result);
323 });
324 }
325
326 fn poll_library_scan(&mut self) {
327 let Some(rx) = &mut self.library_scan_rx else {
328 return;
329 };
330 match rx.try_recv() {
331 Ok(result) => {
332 self.library_scan_rx = None;
333 self.library_scan_inflight = false;
334 match result {
335 Ok(()) => self.on_library_scan_completed_success(),
336 Err(e) => {
337 self.library_scan_pending_invalidate = None;
338 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
339 lib.set_metadata_footer(Some(format!("Library scan failed: {e}")));
340 } else {
341 self.global_error = Some(format!("Library scan failed: {e}"));
342 }
343 }
344 }
345 }
346 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
347 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
348 self.library_scan_rx = None;
349 self.library_scan_inflight = false;
350 self.library_scan_pending_invalidate = None;
351 }
352 }
353 }
354
355 fn apply_library_scan_cache_invalidate(&mut self, inv: &ScanCacheInvalidate) {
356 match inv {
357 ScanCacheInvalidate::None => {}
358 ScanCacheInvalidate::Platform(pid) => {
359 self.rom_cache.remove(&RomCacheKey::Platform(*pid));
360 }
361 ScanCacheInvalidate::AllPlatforms => {
362 self.rom_cache.remove_all_platform_entries();
363 if let AppScreen::LibraryBrowse(lib) = &self.screen {
364 if let Some(ref k) = lib.cache_key() {
365 if !matches!(k, RomCacheKey::Platform(_)) {
366 self.rom_cache.remove(k);
367 }
368 }
369 }
370 }
371 }
372 }
373
374 fn on_library_scan_completed_success(&mut self) {
375 let inv = self
376 .library_scan_pending_invalidate
377 .take()
378 .unwrap_or(ScanCacheInvalidate::AllPlatforms);
379 self.apply_library_scan_cache_invalidate(&inv);
380 if matches!(self.screen, AppScreen::LibraryBrowse(_)) {
381 self.force_rom_reload_after_metadata = true;
382 self.spawn_library_metadata_refresh();
383 }
384 }
385
386 fn format_upload_bytes(n: u64) -> String {
387 const KB: u64 = 1024;
388 const MB: u64 = KB * 1024;
389 const GB: u64 = MB * 1024;
390 if n >= GB {
391 format!("{:.2} GiB", n as f64 / GB as f64)
392 } else if n >= MB {
393 format!("{:.2} MiB", n as f64 / MB as f64)
394 } else if n >= KB {
395 format!("{:.1} KiB", n as f64 / KB as f64)
396 } else {
397 format!("{n} B")
398 }
399 }
400
401 fn spawn_library_upload_worker(&mut self, platform_id: u64, path: PathBuf, scan_after: bool) {
402 if self.library_upload_inflight || self.library_scan_inflight {
403 return;
404 }
405 self.library_upload_inflight = true;
406 self.library_upload_progress_rx = None;
407 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
408 lib.set_metadata_footer(Some("Preparing upload…".into()));
409 }
410 let (prog_tx, prog_rx) = tokio::sync::mpsc::unbounded_channel();
411 let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
412 self.library_upload_progress_rx = Some(prog_rx);
413 self.library_upload_done_rx = Some(done_rx);
414 let client = self.client.clone();
415 tokio::spawn(async move {
416 let result: Result<LibraryUploadComplete, String> = async {
417 client
418 .upload_rom(platform_id, &path, move |uploaded, total| {
419 let _ = prog_tx.send((uploaded, total));
420 })
421 .await
422 .map_err(|e| e.to_string())?;
423 Ok(LibraryUploadComplete {
424 platform_id,
425 scan_after,
426 })
427 }
428 .await;
429 let _ = done_tx.send(result);
430 });
431 }
432
433 fn poll_library_upload(&mut self) {
434 if let Some(rx) = &mut self.library_upload_progress_rx {
435 while let Ok((up, tot)) = rx.try_recv() {
436 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
437 lib.set_metadata_footer(Some(format!(
438 "Uploading {} / {}…",
439 Self::format_upload_bytes(up),
440 Self::format_upload_bytes(tot)
441 )));
442 }
443 }
444 }
445
446 let Some(rx) = &mut self.library_upload_done_rx else {
447 return;
448 };
449 match rx.try_recv() {
450 Ok(result) => {
451 self.library_upload_done_rx = None;
452 self.library_upload_progress_rx = None;
453 self.library_upload_inflight = false;
454 match result {
455 Ok(done) => {
456 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
457 if done.scan_after {
458 lib.set_metadata_footer(Some(
459 "Upload complete. Starting library scan…".into(),
460 ));
461 self.spawn_library_rescan_worker(ScanCacheInvalidate::Platform(
462 done.platform_id,
463 ));
464 } else {
465 lib.set_metadata_footer(Some("Upload complete.".into()));
466 }
467 }
468 }
469 Err(e) => {
470 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
471 lib.set_metadata_footer(Some(format!("Upload failed: {e}")));
472 } else {
473 self.global_error = Some(format!("Upload failed: {e}"));
474 }
475 }
476 }
477 }
478 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
479 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
480 self.library_upload_done_rx = None;
481 self.library_upload_progress_rx = None;
482 self.library_upload_inflight = false;
483 }
484 }
485 }
486
487 fn poll_search_load_results(&mut self) {
488 loop {
489 match self.search_load_rx.try_recv() {
490 Ok(done) => {
491 if let AppScreen::Search(ref mut search) = self.screen {
492 search.loading = false;
493 if let Ok(roms) = done.result {
494 search.set_results(roms);
495 }
496 }
497 }
498 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
499 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
500 }
501 }
502 }
503
504 fn poll_rom_load_results(&mut self) {
505 loop {
506 match self.rom_load_rx.try_recv() {
507 Ok(done) => {
508 if !primary_rom_load_result_is_current(done.gen, self.rom_load_gen) {
509 continue;
510 }
511 let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
512 continue;
513 };
514 match done.result {
515 Ok(roms) => {
516 if let Some(ref k) = done.key {
517 self.rom_cache
518 .insert(k.clone(), roms.clone(), done.expected);
519 }
520 lib.set_roms(roms);
521 tracing::debug!(
522 "rom-list-render context={} latency_ms={}",
523 done.context,
524 done.started.elapsed().as_millis()
525 );
526 }
527 Err(e) => {
528 lib.set_metadata_footer(Some(format!("Could not load games: {e}")));
529 }
530 }
531 lib.set_rom_loading(false);
532 }
533 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
534 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
535 }
536 }
537 }
538
539 fn poll_library_metadata_refresh(&mut self) {
540 let mut batch = Vec::new();
541 let mut disconnected = false;
542 if let Some(rx) = &mut self.library_metadata_rx {
543 loop {
544 match rx.try_recv() {
545 Ok(msg) => batch.push(msg),
546 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
547 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
548 disconnected = true;
549 break;
550 }
551 }
552 }
553 }
554 if disconnected {
555 self.library_metadata_rx = None;
556 }
557 for msg in batch {
558 self.apply_library_metadata_refresh(msg);
559 }
560 }
561
562 fn apply_library_metadata_refresh(&mut self, msg: LibraryMetadataRefreshDone) {
563 if msg.gen != self.library_metadata_refresh_gen {
564 return;
565 }
566 let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
567 return;
568 };
569
570 let had_cached_lists = !lib.platforms.is_empty() || !lib.collections.is_empty();
571 let live_empty = msg.collections.is_empty();
572 if live_empty && had_cached_lists && !msg.warnings.is_empty() {
573 lib.set_metadata_footer(Some(
574 "Could not refresh library metadata (keeping cached list).".into(),
575 ));
576 self.force_rom_reload_after_metadata = false;
577 return;
578 }
579
580 let old_digest =
581 startup_library_snapshot::build_collection_digest_from_collections(&lib.collections);
582 let digest_changed = old_digest != msg.collection_digest;
583 let update_platforms = !msg.platforms.is_empty();
584 let selection_changed = lib.replace_metadata_preserving_selection(
585 msg.platforms,
586 msg.collections,
587 update_platforms,
588 true,
589 );
590 startup_library_snapshot::save_snapshot(&lib.platforms, &lib.collections);
591
592 let footer = if msg.warnings.is_empty() {
593 if digest_changed {
594 Some("Collection metadata updated.".into())
595 } else {
596 Some("Collection metadata already up to date.".into())
597 }
598 } else {
599 let w = msg.warnings.join(" | ");
600 let short: String = if w.chars().count() > 160 {
601 let prefix: String = w.chars().take(157).collect();
602 format!("{prefix}…")
603 } else {
604 w
605 };
606 Some(format!("Partial refresh: {}", short))
607 };
608 lib.set_metadata_footer(footer);
609
610 if selection_changed && lib.list_len() > 0 {
611 lib.clear_roms();
612 let key = lib.cache_key();
613 let expected = lib.expected_rom_count();
614 let req = Self::selected_rom_request_for_library(lib);
615 lib.set_rom_loading(expected > 0);
616 self.deferred_load_roms =
617 Some((key, req, expected, "refresh_selection", Instant::now()));
618 }
619
620 let force_reload = std::mem::take(&mut self.force_rom_reload_after_metadata);
621 if force_reload && lib.list_len() > 0 && !selection_changed {
622 lib.clear_roms();
623 let key = lib.cache_key();
624 let expected = lib.expected_rom_count();
625 let req = Self::selected_rom_request_for_library(lib);
626 lib.set_rom_loading(expected > 0);
627 self.deferred_load_roms =
628 Some((key, req, expected, "post_scan_reload", Instant::now()));
629 }
630
631 self.queue_collection_prefetches_from_screen(1, "refresh_warmup");
632 }
633
634 fn queue_collection_prefetches_from_screen(&mut self, radius: usize, _reason: &'static str) {
635 let AppScreen::LibraryBrowse(ref lib) = self.screen else {
636 return;
637 };
638 for (key, req, expected) in lib.collection_prefetch_candidates(radius) {
639 if self.rom_cache.get_valid(&key, expected).is_some() {
640 continue;
641 }
642 if self.collection_prefetch_queued_keys.contains(&key)
643 || self.collection_prefetch_inflight_keys.contains(&key)
644 {
645 continue;
646 }
647 self.collection_prefetch_queued_keys.insert(key.clone());
648 self.collection_prefetch_queue
649 .push_back((key, req, expected));
650 }
651 }
652
653 fn drive_collection_prefetch_scheduler(&mut self) {
654 const PREFETCH_MAX_INFLIGHT: usize = 2;
655 while self.collection_prefetch_inflight_keys.len() < PREFETCH_MAX_INFLIGHT {
656 let Some((key, req, expected)) = self.collection_prefetch_queue.pop_back() else {
657 break;
658 };
659 self.collection_prefetch_queued_keys.remove(&key);
660 self.collection_prefetch_inflight_keys.insert(key.clone());
661 let tx = self.collection_prefetch_tx.clone();
662 let client = self.client.clone();
663 tokio::spawn(async move {
664 let result = Self::fetch_roms_full(client, req).await;
665 let (roms, warning) = match result {
666 Ok(list) => (Some(list), None),
667 Err(e) => (None, Some(format!("Collection prefetch failed: {e:#}"))),
668 };
669 let _ = tx.send(CollectionPrefetchDone {
670 key,
671 expected,
672 roms,
673 warning,
674 });
675 });
676 }
677 }
678
679 fn poll_collection_prefetch_results(&mut self) {
680 loop {
681 match self.collection_prefetch_rx.try_recv() {
682 Ok(done) => {
683 self.collection_prefetch_inflight_keys.remove(&done.key);
684 if let Some(roms) = done.roms {
685 self.rom_cache.insert(done.key, roms, done.expected);
686 } else if let Some(warning) = done.warning {
687 tracing::debug!("{warning}");
688 }
689 }
690 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
691 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
692 }
693 }
694 }
695
696 pub fn set_error(&mut self, err: anyhow::Error) {
697 self.global_error = Some(format!("{:#}", err));
698 }
699
700 pub async fn run(&mut self) -> Result<()> {
710 enable_raw_mode()?;
711 let mut stdout = std::io::stdout();
712 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
713 let backend = CrosstermBackend::new(stdout);
714 let mut terminal = Terminal::new(backend)?;
715
716 loop {
717 self.poll_background_tasks();
718 if self
719 .startup_splash
720 .as_ref()
721 .is_some_and(|s| s.should_auto_dismiss())
722 {
723 self.startup_splash = None;
724 }
725 terminal.draw(|f| self.render(f))?;
728
729 if event::poll(Duration::from_millis(100))? {
732 if let Event::Key(key_event) = event::read()? {
733 if Self::is_force_quit_key(&key_event) {
734 break;
735 }
736 if key_event.kind == KeyEventKind::Press
737 && key_event.modifiers.contains(KeyModifiers::CONTROL)
738 && matches!(key_event.code, KeyCode::Char('r') | KeyCode::Char('R'))
739 {
740 if let AppScreen::LibraryBrowse(ref lib) = self.screen {
741 if !lib.any_search_bar_open()
742 && !lib.any_upload_prompt_open()
743 && !self.library_upload_inflight
744 && !self.library_scan_inflight
745 {
746 self.spawn_library_rescan_worker(ScanCacheInvalidate::AllPlatforms);
747 }
748 }
749 continue;
750 }
751 if key_event.kind == KeyEventKind::Press
752 && key_event.modifiers.contains(KeyModifiers::CONTROL)
753 && matches!(key_event.code, KeyCode::Char('u') | KeyCode::Char('U'))
754 {
755 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
756 if lib.any_upload_prompt_open() {
757 lib.close_upload_prompt();
758 } else if !lib.any_search_bar_open()
759 && !self.library_upload_inflight
760 && !self.library_scan_inflight
761 {
762 if lib.subsection
763 == super::screens::library_browse::LibrarySubsection::ByConsole
764 {
765 lib.open_upload_prompt();
766 } else {
767 lib.set_metadata_footer(Some(
768 "Upload requires Consoles view — press t".into(),
769 ));
770 }
771 }
772 }
773 continue;
774 }
775 if key_event.kind == KeyEventKind::Press
776 && self.handle_key(key_event.code).await?
777 {
778 break;
779 }
780 }
781 }
782
783 if let Some((key, req, expected, context, started)) = self.deferred_load_roms.take() {
787 if let Some(ref k) = key {
789 if let Some(cached) = self.rom_cache.get_valid(k, expected) {
790 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
791 lib.set_roms(cached.clone());
792 lib.set_rom_loading(false);
793 tracing::debug!(
794 "rom-list-render context={} latency_ms={} (cache_hit)",
795 context,
796 started.elapsed().as_millis()
797 );
798 }
799 continue;
800 }
801 }
802
803 if started.elapsed() < std::time::Duration::from_millis(250) {
805 self.deferred_load_roms = Some((key, req, expected, context, started));
807 continue;
808 }
809
810 self.rom_load_gen = self.rom_load_gen.saturating_add(1);
811 let gen = self.rom_load_gen;
812 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
813 lib.set_rom_loading(expected > 0);
814 }
815 if expected == 0 {
816 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
817 lib.set_rom_loading(false);
818 }
819 continue;
820 }
821
822 let Some(r) = req else {
823 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
824 lib.set_rom_loading(false);
825 }
826 continue;
827 };
828 let client = self.client.clone();
829 let tx = self.rom_load_tx.clone();
830
831 if let Some(task) = self.rom_load_task.take() {
832 task.abort();
833 }
834
835 self.rom_load_task = Some(tokio::spawn(async move {
836 let result = Self::fetch_roms_full(client, r)
837 .await
838 .map_err(|e| format!("{e:#}"));
839 let _ = tx.send(RomLoadDone {
840 gen,
841 key,
842 expected,
843 result,
844 context,
845 started,
846 });
847 }));
848 }
849 }
850
851 disable_raw_mode()?;
852 execute!(
853 terminal.backend_mut(),
854 LeaveAlternateScreen,
855 DisableMouseCapture
856 )?;
857 terminal.show_cursor()?;
858 Ok(())
859 }
860
861 async fn fetch_roms_full(client: RommClient, req: GetRoms) -> Result<RomList> {
866 let mut roms = client.call(&req).await?;
867 let total = roms.total;
868 let ceiling = 20000;
869 while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
870 let mut next_req = req.clone();
871 next_req.offset = Some(roms.items.len() as u32);
872 let next_batch = client.call(&next_req).await?;
873 if next_batch.items.is_empty() {
874 break;
875 }
876 roms.items.extend(next_batch.items);
877 }
878 Ok(roms)
879 }
880
881 pub async fn handle_key(&mut self, key: KeyCode) -> Result<bool> {
886 if self.global_error.is_some() {
887 if key == KeyCode::Esc || key == KeyCode::Enter {
888 self.global_error = None;
889 }
890 return Ok(false);
891 }
892
893 if self.startup_splash.is_some() {
894 self.startup_splash = None;
895 return Ok(false);
896 }
897
898 if self.show_keyboard_help {
899 if matches!(
900 key,
901 KeyCode::Esc | KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('?')
902 ) {
903 self.show_keyboard_help = false;
904 }
905 return Ok(false);
906 }
907
908 if key == KeyCode::F(1) {
909 self.show_keyboard_help = true;
910 return Ok(false);
911 }
912 if key == KeyCode::Char('?') && self.allows_global_question_help() {
913 self.show_keyboard_help = true;
914 return Ok(false);
915 }
916
917 if key == KeyCode::Char('d') && !self.blocks_global_d_shortcut() {
919 self.toggle_download_screen();
920 return Ok(false);
921 }
922
923 match &self.screen {
924 AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
925 AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
926 AppScreen::Search(_) => self.handle_search(key).await,
927 AppScreen::Settings(_) => self.handle_settings(key).await,
928 AppScreen::Browse(_) => self.handle_browse(key),
929 AppScreen::Execute(_) => self.handle_execute(key).await,
930 AppScreen::Result(_) => self.handle_result(key),
931 AppScreen::ResultDetail(_) => self.handle_result_detail(key),
932 AppScreen::GameDetail(_) => self.handle_game_detail(key),
933 AppScreen::Download(_) => self.handle_download(key),
934 AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
935 }
936 }
937
938 fn toggle_download_screen(&mut self) {
941 let current =
942 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
943 match current {
944 AppScreen::Download(_) => {
945 self.screen = self
946 .screen_before_download
947 .take()
948 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
949 }
950 other => {
951 self.screen_before_download = Some(other);
952 self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
953 }
954 }
955 }
956
957 fn handle_download(&mut self, key: KeyCode) -> Result<bool> {
958 if key == KeyCode::Esc || key == KeyCode::Char('d') {
959 self.screen = self
960 .screen_before_download
961 .take()
962 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
963 }
964 Ok(false)
965 }
966
967 async fn handle_main_menu(&mut self, key: KeyCode) -> Result<bool> {
970 let menu = match &mut self.screen {
971 AppScreen::MainMenu(m) => m,
972 _ => return Ok(false),
973 };
974 match key {
975 KeyCode::Up | KeyCode::Char('k') => menu.previous(),
976 KeyCode::Down | KeyCode::Char('j') => menu.next(),
977 KeyCode::Enter => match menu.selected {
978 0 => {
979 let start = Instant::now();
980 let snap = startup_library_snapshot::load_snapshot();
981 let (platforms, collections, from_disk) = match snap {
982 Some(s) => (s.platforms, s.collections, true),
983 None => (Vec::new(), Vec::new(), false),
984 };
985 let mut lib = LibraryBrowseScreen::new(platforms, collections);
986 if from_disk && lib.list_len() > 0 {
987 lib.set_metadata_footer(Some(
988 "Refreshing library metadata in background…".into(),
989 ));
990 } else if lib.list_len() == 0 {
991 lib.set_metadata_footer(Some("Loading library metadata…".into()));
992 }
993 if lib.list_len() > 0 {
994 let key = lib.cache_key();
995 let expected = lib.expected_rom_count();
996 let req = Self::selected_rom_request_for_library(&lib);
997 lib.set_rom_loading(expected > 0);
998 self.deferred_load_roms = Some((
999 key,
1000 req,
1001 expected,
1002 "startup_first_selection",
1003 Instant::now(),
1004 ));
1005 }
1006 self.screen = AppScreen::LibraryBrowse(lib);
1007 self.spawn_library_metadata_refresh();
1008 tracing::debug!(
1009 "library-open latency_ms={} snapshot_hit={}",
1010 start.elapsed().as_millis(),
1011 from_disk
1012 );
1013 }
1014 1 => self.screen = AppScreen::Search(SearchScreen::new()),
1015 2 => {
1016 self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
1017 self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
1018 }
1019 3 => {
1020 self.screen = AppScreen::Settings(SettingsScreen::new(
1021 &self.config,
1022 self.server_version.as_deref(),
1023 ))
1024 }
1025 4 => return Ok(true),
1026 _ => {}
1027 },
1028 KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
1029 _ => {}
1030 }
1031 Ok(false)
1032 }
1033
1034 async fn handle_library_browse(&mut self, key: KeyCode) -> Result<bool> {
1037 use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};
1038
1039 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1040 if lib.upload_prompt.is_some() {
1041 if let Some(up) = lib.upload_prompt.as_mut() {
1042 match key {
1043 KeyCode::Esc => lib.close_upload_prompt(),
1044 KeyCode::Tab => up.scan_after = !up.scan_after,
1045 KeyCode::Left => up.cursor_left(),
1046 KeyCode::Right => up.cursor_right(),
1047 KeyCode::Backspace => up.delete_char(),
1048 KeyCode::Char(c) => up.add_char(c),
1049 KeyCode::Enter => {
1050 let path_trim = up.path.trim().to_string();
1051 let scan_after = up.scan_after;
1052 if path_trim.is_empty() {
1053 return Ok(false);
1054 }
1055 let path = PathBuf::from(&path_trim);
1056 if !Path::new(&path).is_file() {
1057 lib.set_metadata_footer(Some(format!(
1058 "Not a file: {}",
1059 path.display()
1060 )));
1061 return Ok(false);
1062 }
1063 let Some(pid) = lib.selected_platform_id() else {
1064 lib.set_metadata_footer(Some(
1065 "Select a console before uploading.".into(),
1066 ));
1067 return Ok(false);
1068 };
1069 lib.close_upload_prompt();
1070 self.spawn_library_upload_worker(pid, path, scan_after);
1071 }
1072 _ => {}
1073 }
1074 }
1075 return Ok(false);
1076 }
1077 }
1078
1079 if self.library_upload_inflight {
1080 return Ok(false);
1081 }
1082
1083 let lib = match &mut self.screen {
1084 AppScreen::LibraryBrowse(l) => l,
1085 _ => return Ok(false),
1086 };
1087
1088 if lib.view_mode == LibraryViewMode::List {
1090 if let Some(mode) = lib.list_search.mode {
1091 let old_key = lib.cache_key();
1092 match key {
1093 KeyCode::Esc => lib.clear_list_search(),
1094 KeyCode::Backspace => lib.delete_list_search_char(),
1095 KeyCode::Char(c) => lib.add_list_search_char(c),
1096 KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.list_jump_match(true),
1097 KeyCode::Enter => lib.commit_list_filter_bar(),
1098 _ => {}
1099 }
1100 let new_key = lib.cache_key();
1101 if old_key != new_key && lib.list_len() > 0 {
1102 lib.clear_roms();
1103 let expected = lib.expected_rom_count();
1104 if expected > 0 {
1105 let req = Self::selected_rom_request_for_library(lib);
1106 lib.set_rom_loading(true);
1107 self.deferred_load_roms =
1108 Some((new_key, req, expected, "search_filter", Instant::now()));
1109 } else {
1110 lib.set_rom_loading(false);
1111 self.deferred_load_roms = None;
1112 }
1113 }
1114 return Ok(false);
1115 }
1116 }
1117
1118 if lib.view_mode == LibraryViewMode::Roms {
1120 if let Some(mode) = lib.rom_search.mode {
1121 match key {
1122 KeyCode::Esc => lib.clear_rom_search(),
1123 KeyCode::Backspace => lib.delete_rom_search_char(),
1124 KeyCode::Char(c) => lib.add_rom_search_char(c),
1125 KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_rom_match(true),
1126 KeyCode::Enter => lib.commit_rom_filter_bar(),
1127 _ => {}
1128 }
1129 return Ok(false);
1130 }
1131 }
1132
1133 match key {
1134 KeyCode::Up | KeyCode::Char('k') => {
1135 if lib.view_mode == LibraryViewMode::List {
1136 lib.list_previous();
1137 if lib.list_len() > 0 {
1138 lib.clear_roms(); let key = lib.cache_key();
1140 let expected = lib.expected_rom_count();
1141 if expected > 0 {
1142 let req = Self::selected_rom_request_for_library(lib);
1143 lib.set_rom_loading(true);
1144 self.deferred_load_roms =
1145 Some((key, req, expected, "list_move_up", Instant::now()));
1146 } else {
1147 lib.set_rom_loading(false);
1148 self.deferred_load_roms = None;
1149 }
1150 if lib.subsection
1151 == super::screens::library_browse::LibrarySubsection::ByCollection
1152 {
1153 tracing::debug!("collections-selection move=up expected={expected}");
1154 self.queue_collection_prefetches_from_screen(1, "move_up");
1155 }
1156 }
1157 } else {
1158 lib.rom_previous();
1159 }
1160 }
1161 KeyCode::Down | KeyCode::Char('j') => {
1162 if lib.view_mode == LibraryViewMode::List {
1163 lib.list_next();
1164 if lib.list_len() > 0 {
1165 lib.clear_roms(); let key = lib.cache_key();
1167 let expected = lib.expected_rom_count();
1168 if expected > 0 {
1169 let req = Self::selected_rom_request_for_library(lib);
1170 lib.set_rom_loading(true);
1171 self.deferred_load_roms =
1172 Some((key, req, expected, "list_move_down", Instant::now()));
1173 } else {
1174 lib.set_rom_loading(false);
1175 self.deferred_load_roms = None;
1176 }
1177 if lib.subsection
1178 == super::screens::library_browse::LibrarySubsection::ByCollection
1179 {
1180 tracing::debug!("collections-selection move=down expected={expected}");
1181 self.queue_collection_prefetches_from_screen(1, "move_down");
1182 }
1183 }
1184 } else {
1185 lib.rom_next();
1186 }
1187 }
1188 KeyCode::Left | KeyCode::Char('h') if lib.view_mode == LibraryViewMode::Roms => {
1189 lib.back_to_list();
1190 }
1191 KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
1192 KeyCode::Tab => {
1193 if lib.view_mode == LibraryViewMode::List {
1194 lib.switch_view();
1195 } else {
1196 lib.switch_view(); }
1198 }
1199 KeyCode::Char('/') => match lib.view_mode {
1200 LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Filter),
1201 LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Filter),
1202 },
1203 KeyCode::Char('f') => match lib.view_mode {
1204 LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Jump),
1205 LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Jump),
1206 },
1207 KeyCode::Enter => {
1208 if lib.view_mode == LibraryViewMode::List {
1209 lib.switch_view();
1210 } else if let Some((primary, others)) = lib.get_selected_group() {
1211 let lib_screen = std::mem::replace(
1212 &mut self.screen,
1213 AppScreen::MainMenu(MainMenuScreen::new()),
1214 );
1215 if let AppScreen::LibraryBrowse(l) = lib_screen {
1216 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
1217 primary,
1218 others,
1219 GameDetailPrevious::Library(l),
1220 self.downloads.shared(),
1221 )));
1222 }
1223 }
1224 }
1225 KeyCode::Char('t') => {
1226 lib.switch_subsection();
1227 if lib.view_mode == LibraryViewMode::List && lib.list_len() > 0 {
1230 let key = lib.cache_key();
1231 let expected = lib.expected_rom_count();
1232 if expected > 0 {
1233 let req = Self::selected_rom_request_for_library(lib);
1234 lib.set_rom_loading(true);
1235 self.deferred_load_roms =
1236 Some((key, req, expected, "switch_subsection", Instant::now()));
1237 } else {
1238 lib.set_rom_loading(false);
1239 self.deferred_load_roms = None;
1240 }
1241 }
1242 if lib.subsection == super::screens::library_browse::LibrarySubsection::ByCollection
1243 {
1244 tracing::debug!("collections-subsection entered");
1245 self.queue_collection_prefetches_from_screen(1, "enter_collections");
1246 }
1247 }
1248 KeyCode::Esc => {
1249 if lib.view_mode == LibraryViewMode::Roms {
1250 if lib.rom_search.filter_browsing {
1251 lib.clear_rom_search();
1252 } else {
1253 lib.back_to_list();
1254 }
1255 } else if lib.list_search.filter_browsing {
1256 lib.clear_list_search();
1257 } else {
1258 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
1259 }
1260 }
1261 KeyCode::Char('q') => return Ok(true),
1262 _ => {}
1263 }
1264 Ok(false)
1265 }
1266
1267 async fn handle_search(&mut self, key: KeyCode) -> Result<bool> {
1270 let search = match &mut self.screen {
1271 AppScreen::Search(s) => s,
1272 _ => return Ok(false),
1273 };
1274 match key {
1275 KeyCode::Backspace => search.delete_char(),
1276 KeyCode::Left => search.cursor_left(),
1277 KeyCode::Right => search.cursor_right(),
1278 KeyCode::Up => search.previous(),
1279 KeyCode::Down => search.next(),
1280 KeyCode::Char(c) => search.add_char(c),
1281 KeyCode::Enter => {
1282 if search.query.is_empty() {
1283 } else if search.result_groups.is_some() && search.results_match_current_query() {
1285 if let Some((primary, others)) = search.get_selected_group() {
1286 let prev = std::mem::replace(
1287 &mut self.screen,
1288 AppScreen::MainMenu(MainMenuScreen::new()),
1289 );
1290 if let AppScreen::Search(s) = prev {
1291 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
1292 primary,
1293 others,
1294 GameDetailPrevious::Search(s),
1295 self.downloads.shared(),
1296 )));
1297 }
1298 }
1299 } else {
1300 let req = GetRoms {
1301 search_term: Some(search.query.clone()),
1302 limit: Some(50),
1303 ..Default::default()
1304 };
1305 search.loading = true;
1306 if let Some(task) = self.search_load_task.take() {
1307 task.abort();
1308 }
1309 let client = self.client.clone();
1310 let tx = self.search_load_tx.clone();
1311 self.search_load_task = Some(tokio::spawn(async move {
1312 let result = client.call(&req).await.map_err(|e| format!("{e:#}"));
1313 let _ = tx.send(SearchLoadDone { result });
1314 }));
1315 }
1316 }
1317 KeyCode::Esc => {
1318 if search.results.is_some() {
1319 search.clear_results();
1320 } else {
1321 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
1322 }
1323 }
1324 _ => {}
1325 }
1326 Ok(false)
1327 }
1328
1329 async fn refresh_settings_server_version(&mut self) -> Result<()> {
1332 let (base_url, download_dir, use_https, verbose, auth) = {
1333 let settings = match &self.screen {
1334 AppScreen::Settings(s) => s,
1335 _ => return Ok(()),
1336 };
1337 let mut base_url = normalize_romm_origin(settings.base_url.trim());
1338 if settings.use_https && base_url.starts_with("http://") {
1339 base_url = base_url.replace("http://", "https://");
1340 }
1341 if !settings.use_https && base_url.starts_with("https://") {
1342 base_url = base_url.replace("https://", "http://");
1343 }
1344 (
1345 base_url,
1346 settings.download_dir.clone(),
1347 settings.use_https,
1348 self.client.verbose(),
1349 self.config.auth.clone(),
1350 )
1351 };
1352 let cfg = Config {
1353 base_url,
1354 download_dir,
1355 use_https,
1356 auth,
1357 };
1358 let client = match RommClient::new(&cfg, verbose) {
1359 Ok(c) => c,
1360 Err(_) => {
1361 if let AppScreen::Settings(s) = &mut self.screen {
1362 s.server_version = "unavailable (invalid URL or client error)".to_string();
1363 self.server_version = None;
1364 }
1365 return Ok(());
1366 }
1367 };
1368 let ver = client.rom_server_version_from_heartbeat().await;
1369 if let AppScreen::Settings(s) = &mut self.screen {
1370 match ver {
1371 Some(v) => {
1372 s.server_version = v.clone();
1373 self.server_version = Some(v);
1374 }
1375 None => {
1376 s.server_version = "unavailable (heartbeat failed)".to_string();
1377 self.server_version = None;
1378 }
1379 }
1380 }
1381 Ok(())
1382 }
1383
1384 async fn handle_settings(&mut self, key: KeyCode) -> Result<bool> {
1385 let settings = match &mut self.screen {
1386 AppScreen::Settings(s) => s,
1387 _ => return Ok(false),
1388 };
1389
1390 if settings.editing {
1391 match key {
1392 KeyCode::Enter => {
1393 let idx = settings.selected_index;
1394 settings.save_edit();
1395 if idx == 0 {
1396 self.refresh_settings_server_version().await?;
1397 }
1398 }
1399 KeyCode::Esc => settings.cancel_edit(),
1400 KeyCode::Backspace => settings.delete_char(),
1401 KeyCode::Left => settings.move_cursor_left(),
1402 KeyCode::Right => settings.move_cursor_right(),
1403 KeyCode::Char(c) => settings.add_char(c),
1404 _ => {}
1405 }
1406 return Ok(false);
1407 }
1408
1409 match key {
1410 KeyCode::Up | KeyCode::Char('k') => settings.previous(),
1411 KeyCode::Down | KeyCode::Char('j') => settings.next(),
1412 KeyCode::Enter => {
1413 if settings.selected_index == 3 {
1414 self.screen =
1415 AppScreen::SetupWizard(Box::new(SetupWizard::new_auth_only(&self.config)));
1416 } else {
1417 let toggle_https = settings.selected_index == 2;
1418 settings.enter_edit();
1419 if toggle_https {
1420 self.refresh_settings_server_version().await?;
1421 }
1422 }
1423 }
1424 KeyCode::Char('s' | 'S') => {
1425 use crate::config::persist_user_config;
1427 let auth = auth_for_persist_merge(self.config.auth.clone());
1428 if let Err(e) = persist_user_config(
1429 &settings.base_url,
1430 &settings.download_dir,
1431 settings.use_https,
1432 auth,
1433 ) {
1434 settings.message = Some((format!("Error saving: {e}"), Color::Red));
1435 } else {
1436 settings.message = Some(("Saved to config.json".to_string(), Color::Green));
1437 self.config.base_url = settings.base_url.clone();
1439 self.config.download_dir = settings.download_dir.clone();
1440 self.config.use_https = settings.use_https;
1441 if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
1443 self.client = new_client;
1444 }
1445 }
1446 }
1447 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
1448 KeyCode::Char('q') => return Ok(true),
1449 _ => {}
1450 }
1451 Ok(false)
1452 }
1453
1454 fn handle_browse(&mut self, key: KeyCode) -> Result<bool> {
1457 use super::screens::browse::ViewMode;
1458
1459 let browse = match &mut self.screen {
1460 AppScreen::Browse(b) => b,
1461 _ => return Ok(false),
1462 };
1463 match key {
1464 KeyCode::Up | KeyCode::Char('k') => browse.previous(),
1465 KeyCode::Down | KeyCode::Char('j') => browse.next(),
1466 KeyCode::Left | KeyCode::Char('h') if browse.view_mode == ViewMode::Endpoints => {
1467 browse.switch_view();
1468 }
1469 KeyCode::Right | KeyCode::Char('l') if browse.view_mode == ViewMode::Sections => {
1470 browse.switch_view();
1471 }
1472 KeyCode::Tab => browse.switch_view(),
1473 KeyCode::Enter => {
1474 if browse.view_mode == ViewMode::Endpoints {
1475 if let Some(ep) = browse.get_selected_endpoint() {
1476 self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
1477 }
1478 } else {
1479 browse.switch_view();
1480 }
1481 }
1482 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
1483 _ => {}
1484 }
1485 Ok(false)
1486 }
1487
1488 async fn handle_execute(&mut self, key: KeyCode) -> Result<bool> {
1491 let execute = match &mut self.screen {
1492 AppScreen::Execute(e) => e,
1493 _ => return Ok(false),
1494 };
1495 match key {
1496 KeyCode::Tab => execute.next_field(),
1497 KeyCode::BackTab => execute.previous_field(),
1498 KeyCode::Char(c) => execute.add_char_to_focused(c),
1499 KeyCode::Backspace => execute.delete_char_from_focused(),
1500 KeyCode::Enter => {
1501 let endpoint = execute.endpoint.clone();
1502 let query = execute.get_query_params();
1503 let body = if endpoint.has_body && !execute.body_text.is_empty() {
1504 Some(serde_json::from_str(&execute.body_text)?)
1505 } else {
1506 None
1507 };
1508 let resolved_path =
1509 match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
1510 Ok(p) => p,
1511 Err(e) => {
1512 self.screen = AppScreen::Result(ResultScreen::new(
1513 serde_json::json!({ "error": format!("{e}") }),
1514 None,
1515 None,
1516 ));
1517 return Ok(false);
1518 }
1519 };
1520 match self
1521 .client
1522 .request_json(&endpoint.method, &resolved_path, &query, body)
1523 .await
1524 {
1525 Ok(result) => {
1526 self.screen = AppScreen::Result(ResultScreen::new(
1527 result,
1528 Some(&endpoint.method),
1529 Some(resolved_path.as_str()),
1530 ));
1531 }
1532 Err(e) => {
1533 self.screen = AppScreen::Result(ResultScreen::new(
1534 serde_json::json!({ "error": format!("{e}") }),
1535 None,
1536 None,
1537 ));
1538 }
1539 }
1540 }
1541 KeyCode::Esc => {
1542 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
1543 }
1544 _ => {}
1545 }
1546 Ok(false)
1547 }
1548
1549 fn handle_result(&mut self, key: KeyCode) -> Result<bool> {
1552 use super::screens::result::ResultViewMode;
1553
1554 let result = match &mut self.screen {
1555 AppScreen::Result(r) => r,
1556 _ => return Ok(false),
1557 };
1558 match key {
1559 KeyCode::Up | KeyCode::Char('k') => {
1560 if result.view_mode == ResultViewMode::Json {
1561 result.scroll_up(1);
1562 } else {
1563 result.table_previous();
1564 }
1565 }
1566 KeyCode::Down => {
1567 if result.view_mode == ResultViewMode::Json {
1568 result.scroll_down(1);
1569 } else {
1570 result.table_next();
1571 }
1572 }
1573 KeyCode::Char('j') if result.view_mode == ResultViewMode::Json => {
1574 result.scroll_down(1);
1575 }
1576 KeyCode::PageUp => {
1577 if result.view_mode == ResultViewMode::Table {
1578 result.table_page_up();
1579 } else {
1580 result.scroll_up(10);
1581 }
1582 }
1583 KeyCode::PageDown => {
1584 if result.view_mode == ResultViewMode::Table {
1585 result.table_page_down();
1586 } else {
1587 result.scroll_down(10);
1588 }
1589 }
1590 KeyCode::Char('t') if result.table_row_count > 0 => {
1591 result.switch_view_mode();
1592 }
1593 KeyCode::Enter
1594 if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 =>
1595 {
1596 if let Some(item) = result.get_selected_item_value() {
1597 let prev = std::mem::replace(
1598 &mut self.screen,
1599 AppScreen::MainMenu(MainMenuScreen::new()),
1600 );
1601 if let AppScreen::Result(rs) = prev {
1602 self.screen = AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
1603 }
1604 }
1605 }
1606 KeyCode::Esc => {
1607 result.clear_message();
1608 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
1609 }
1610 KeyCode::Char('q') => return Ok(true),
1611 _ => {}
1612 }
1613 Ok(false)
1614 }
1615
1616 fn handle_result_detail(&mut self, key: KeyCode) -> Result<bool> {
1619 let detail = match &mut self.screen {
1620 AppScreen::ResultDetail(d) => d,
1621 _ => return Ok(false),
1622 };
1623 match key {
1624 KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
1625 KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
1626 KeyCode::PageUp => detail.scroll_up(10),
1627 KeyCode::PageDown => detail.scroll_down(10),
1628 KeyCode::Char('o') => detail.open_image_url(),
1629 KeyCode::Esc => {
1630 detail.clear_message();
1631 let prev =
1632 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1633 if let AppScreen::ResultDetail(d) = prev {
1634 self.screen = AppScreen::Result(d.parent);
1635 }
1636 }
1637 KeyCode::Char('q') => return Ok(true),
1638 _ => {}
1639 }
1640 Ok(false)
1641 }
1642
1643 fn handle_game_detail(&mut self, key: KeyCode) -> Result<bool> {
1646 let detail = match &mut self.screen {
1647 AppScreen::GameDetail(d) => d,
1648 _ => return Ok(false),
1649 };
1650
1651 if !detail.download_completion_acknowledged {
1654 if let Ok(list) = detail.downloads.lock() {
1655 let has_completed = list.iter().any(|j| {
1656 j.rom_id == detail.rom.id
1657 && matches!(
1658 j.status,
1659 crate::core::download::DownloadStatus::Done
1660 | crate::core::download::DownloadStatus::Error(_)
1661 )
1662 });
1663 let is_still_downloading = list.iter().any(|j| {
1664 j.rom_id == detail.rom.id
1665 && matches!(j.status, crate::core::download::DownloadStatus::Downloading)
1666 });
1667 if has_completed && !is_still_downloading {
1669 detail.download_completion_acknowledged = true;
1670 }
1671 }
1672 }
1673
1674 match key {
1675 KeyCode::Enter if !detail.has_started_download => {
1678 detail.has_started_download = true;
1679 self.downloads
1680 .start_download(&detail.rom, self.client.clone());
1681 }
1682 KeyCode::Char('o') => detail.open_cover(),
1683 KeyCode::Char('m') => detail.toggle_technical(),
1684 KeyCode::Esc => {
1685 detail.clear_message();
1686 let prev =
1687 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1688 if let AppScreen::GameDetail(g) = prev {
1689 self.screen = match g.previous {
1690 GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(l),
1691 GameDetailPrevious::Search(s) => AppScreen::Search(s),
1692 };
1693 }
1694 }
1695 KeyCode::Char('q') => return Ok(true),
1696 _ => {}
1697 }
1698 Ok(false)
1699 }
1700
1701 async fn handle_setup_wizard(&mut self, key: KeyCode) -> Result<bool> {
1704 let wizard = match &mut self.screen {
1705 AppScreen::SetupWizard(w) => w,
1706 _ => return Ok(false),
1707 };
1708
1709 let event = crossterm::event::KeyEvent::new(key, crossterm::event::KeyModifiers::empty());
1711 if wizard.handle_key(event)? {
1712 self.screen = AppScreen::Settings(SettingsScreen::new(
1714 &self.config,
1715 self.server_version.as_deref(),
1716 ));
1717 return Ok(false);
1718 }
1719
1720 if wizard.testing {
1721 let result = wizard.try_connect_and_persist(self.client.verbose()).await;
1722 wizard.testing = false;
1723 match result {
1724 Ok(cfg) => {
1725 let auth_ok = cfg.auth.is_some();
1726 self.config = cfg;
1727 if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
1728 self.client = new_client;
1729 }
1730 let mut settings =
1731 SettingsScreen::new(&self.config, self.server_version.as_deref());
1732 if auth_ok {
1733 settings.message = Some((
1734 "Authentication updated successfully".to_string(),
1735 Color::Green,
1736 ));
1737 } else {
1738 settings.message = Some((
1739 "Saved configuration but credentials could not be loaded from the OS keyring (see logs)."
1740 .to_string(),
1741 Color::Yellow,
1742 ));
1743 }
1744 self.screen = AppScreen::Settings(settings);
1745 }
1746 Err(e) => {
1747 wizard.error = Some(format!("{e:#}"));
1748 }
1749 }
1750 }
1751 Ok(false)
1752 }
1753
1754 fn render(&mut self, f: &mut ratatui::Frame) {
1759 let area = f.size();
1760 if let Some(ref splash) = self.startup_splash {
1761 connected_splash::render(f, area, splash);
1762 return;
1763 }
1764 match &mut self.screen {
1765 AppScreen::MainMenu(menu) => menu.render(f, area),
1766 AppScreen::LibraryBrowse(lib) => {
1767 lib.render(f, area);
1768 if let Some((x, y)) = lib.upload_prompt_cursor(area) {
1769 f.set_cursor(x, y);
1770 }
1771 }
1772 AppScreen::Search(search) => {
1773 search.render(f, area);
1774 if let Some((x, y)) = search.cursor_position(area) {
1775 f.set_cursor(x, y);
1776 }
1777 }
1778 AppScreen::Settings(settings) => {
1779 settings.render(f, area);
1780 if let Some((x, y)) = settings.cursor_position(area) {
1781 f.set_cursor(x, y);
1782 }
1783 }
1784 AppScreen::Browse(browse) => browse.render(f, area),
1785 AppScreen::Execute(execute) => {
1786 execute.render(f, area);
1787 if let Some((x, y)) = execute.cursor_position(area) {
1788 f.set_cursor(x, y);
1789 }
1790 }
1791 AppScreen::Result(result) => result.render(f, area),
1792 AppScreen::ResultDetail(detail) => detail.render(f, area),
1793 AppScreen::GameDetail(detail) => detail.render(f, area),
1794 AppScreen::Download(d) => d.render(f, area),
1795 AppScreen::SetupWizard(wizard) => {
1796 wizard.render(f, area);
1797 if let Some((x, y)) = wizard.cursor_pos(area) {
1798 f.set_cursor(x, y);
1799 }
1800 }
1801 }
1802
1803 if self.show_keyboard_help {
1804 keyboard_help::render_keyboard_help(f, area);
1805 }
1806
1807 if let Some(ref err) = self.global_error {
1808 let popup_area = ratatui::layout::Rect {
1809 x: area.width.saturating_sub(60) / 2,
1810 y: area.height.saturating_sub(10) / 2,
1811 width: 60.min(area.width),
1812 height: 10.min(area.height),
1813 };
1814 f.render_widget(ratatui::widgets::Clear, popup_area);
1815 let block = ratatui::widgets::Block::default()
1816 .title("Error")
1817 .borders(ratatui::widgets::Borders::ALL)
1818 .style(ratatui::style::Style::default().fg(ratatui::style::Color::Red));
1819 let text = format!("{}\n\nPress Esc to dismiss", err);
1820 let paragraph = ratatui::widgets::Paragraph::new(text)
1821 .block(block)
1822 .wrap(ratatui::widgets::Wrap { trim: true });
1823 f.render_widget(paragraph, popup_area);
1824 }
1825 }
1826}
1827
1828#[cfg(test)]
1829mod tests {
1830 use super::*;
1831 use crate::config::Config;
1832 use crate::tui::openapi::EndpointRegistry;
1833 use crate::tui::screens::library_browse::LibraryBrowseScreen;
1834 use crate::types::Platform;
1835 use crossterm::event::{KeyEvent, KeyModifiers};
1836 use serde_json::json;
1837
1838 fn platform(id: u64, name: &str, rom_count: u64) -> Platform {
1839 serde_json::from_value(json!({
1840 "id": id,
1841 "slug": format!("p{id}"),
1842 "fs_slug": format!("p{id}"),
1843 "rom_count": rom_count,
1844 "name": name,
1845 "igdb_slug": null,
1846 "moby_slug": null,
1847 "hltb_slug": null,
1848 "custom_name": null,
1849 "igdb_id": null,
1850 "sgdb_id": null,
1851 "moby_id": null,
1852 "launchbox_id": null,
1853 "ss_id": null,
1854 "ra_id": null,
1855 "hasheous_id": null,
1856 "tgdb_id": null,
1857 "flashpoint_id": null,
1858 "category": null,
1859 "generation": null,
1860 "family_name": null,
1861 "family_slug": null,
1862 "url": null,
1863 "url_logo": null,
1864 "firmware": [],
1865 "aspect_ratio": null,
1866 "created_at": "",
1867 "updated_at": "",
1868 "fs_size_bytes": 0,
1869 "is_unidentified": false,
1870 "is_identified": true,
1871 "missing_from_fs": false,
1872 "display_name": null
1873 }))
1874 .expect("valid platform fixture")
1875 }
1876
1877 fn app_with_library(platforms: Vec<Platform>) -> App {
1878 let config = Config {
1879 base_url: "http://127.0.0.1:9".into(),
1880 download_dir: "/tmp".into(),
1881 use_https: false,
1882 auth: None,
1883 };
1884 let client = RommClient::new(&config, false).expect("client");
1885 let mut app = App::new(client, config, EndpointRegistry::default(), None, None);
1886 app.screen = AppScreen::LibraryBrowse(LibraryBrowseScreen::new(platforms, vec![]));
1887 app
1888 }
1889
1890 #[tokio::test]
1891 async fn list_move_to_zero_rom_selection_does_not_queue_deferred_load() {
1892 let mut app = app_with_library(vec![platform(1, "HasRoms", 5), platform(2, "Empty", 0)]);
1893
1894 assert!(!app.handle_key(KeyCode::Down).await.expect("key handled"));
1895 assert!(
1896 app.deferred_load_roms.is_none(),
1897 "selection move to zero-rom platform should not queue deferred ROM load"
1898 );
1899 }
1900
1901 #[test]
1902 fn ctrl_c_is_treated_as_force_quit() {
1903 let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
1904 assert!(App::is_force_quit_key(&ctrl_c));
1905
1906 let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty());
1907 assert!(!App::is_force_quit_key(&plain_c));
1908 }
1909
1910 #[test]
1911 fn primary_rom_load_stale_gen_is_ignored() {
1912 assert!(!super::primary_rom_load_result_is_current(1, 2));
1913 assert!(super::primary_rom_load_result_is_current(3, 3));
1914 }
1915}