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