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