use anyhow::Result;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::style::Color;
use ratatui::Terminal;
use std::collections::{HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::client::RommClient;
use crate::commands::library_scan::ScanCacheInvalidate;
use crate::config::{
auth_for_persist_merge, normalize_romm_origin, resolve_game_save_dir, Config, ExtrasDefaults,
};
use crate::core::cache::{RomCache, RomCacheKey};
use crate::core::download::DownloadManager;
use crate::core::extras::has_update_or_dlc_extras;
use crate::core::startup_library_snapshot;
use crate::endpoints::device::{DeviceSchema, ListDevices};
use crate::endpoints::platforms::ListPlatforms;
use crate::endpoints::roms::GetRoms;
use crate::endpoints::sync::{SyncSessionSchema, TriggerPushPull};
use crate::types::{Collection, Platform, RomList, SaveMetadata};
use crate::update::UpdateStatus;
use super::keyboard_help;
use super::screens::connected_splash::{self, StartupSplash};
use super::screens::settings::{ConsolePathKind, SettingsRow};
use super::screens::setup_wizard::SetupWizard;
use super::screens::{
BrowseScreen, DownloadScreen, ExecuteScreen, ExtrasPickerScreen, GameDetailPrevious,
GameDetailScreen, LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen,
SearchScreen, SettingsScreen,
};
use crate::feature_compat::{save_sync_compatibility, SaveSyncCompatibility};
use crate::openapi::{resolve_path_template, EndpointRegistry};
struct LibraryMetadataRefreshDone {
gen: u64,
platforms: Vec<Platform>,
collections: Vec<Collection>,
collection_digest: Vec<startup_library_snapshot::CollectionDigestEntry>,
warnings: Vec<String>,
}
struct CollectionPrefetchDone {
key: RomCacheKey,
expected: u64,
roms: Option<RomList>,
warning: Option<String>,
}
enum RomLoadEvent {
Batch(RomList),
Failed(String),
Complete,
}
struct RomLoadDone {
gen: u64,
key: Option<RomCacheKey>,
expected: u64,
event: RomLoadEvent,
context: &'static str,
started: Instant,
}
enum SearchLoadEvent {
Batch(RomList),
Failed(String),
Complete,
}
struct SearchLoadDone {
query: String,
event: SearchLoadEvent,
}
struct CoverLoadDone {
rom_id: u64,
result: Result<image::DynamicImage, String>,
}
struct SaveListDone {
rom_id: u64,
result: Result<Vec<SaveMetadata>, String>,
}
struct SaveUploadDone {
rom_id: u64,
result: Result<(), String>,
}
struct SaveDownloadDone {
rom_id: u64,
result: Result<PathBuf, String>,
}
struct DeviceListDone {
result: Result<Vec<DeviceSchema>, String>,
}
struct PlatformListDone {
result: Result<Vec<crate::types::Platform>, String>,
}
struct SyncPushPullDone {
result: Result<SyncSessionSchema, String>,
}
struct StartupUpdatePrompt {
status: UpdateStatus,
updating: bool,
}
type DeferredLoadRoms = (
Option<RomCacheKey>,
Option<GetRoms>,
u64,
&'static str,
Instant,
);
#[inline]
fn primary_rom_load_result_is_current(done_gen: u64, current_gen: u64) -> bool {
done_gen == current_gen
}
fn safe_path_segment(input: &str) -> String {
let cleaned: String = input
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') {
c
} else {
'_'
}
})
.collect();
let trimmed = cleaned.trim().trim_matches('.').trim();
if trimmed.is_empty() {
"game".to_string()
} else {
trimmed.to_string()
}
}
fn unique_save_path(dir: &Path, file_name: &str) -> PathBuf {
let safe_name = safe_path_segment(file_name);
let base = Path::new(&safe_name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("save");
let ext = Path::new(&safe_name).extension().and_then(|s| s.to_str());
let mut candidate = dir.join(&safe_name);
let mut n = 1u32;
while candidate.exists() {
let name = match ext {
Some(ext) if !ext.is_empty() => format!("{base}-{n}.{ext}"),
_ => format!("{base}-{n}"),
};
candidate = dir.join(name);
n += 1;
}
candidate
}
pub enum AppScreen {
MainMenu(MainMenuScreen),
LibraryBrowse(LibraryBrowseScreen),
Search(SearchScreen),
Settings(Box<SettingsScreen>),
Browse(BrowseScreen),
Execute(ExecuteScreen),
Result(ResultScreen),
ResultDetail(ResultDetailScreen),
GameDetail(Box<GameDetailScreen>),
ExtrasPicker(Box<ExtrasPickerScreen>),
Download(DownloadScreen),
SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
}
struct LibraryUploadComplete {
platform_id: u64,
scan_after: bool,
}
pub struct App {
pub screen: AppScreen,
client: RommClient,
config: Config,
registry: EndpointRegistry,
server_version: Option<String>,
save_sync_compat: SaveSyncCompatibility,
rom_cache: RomCache,
downloads: DownloadManager,
screen_before_download: Option<AppScreen>,
deferred_load_roms: Option<DeferredLoadRoms>,
startup_splash: Option<StartupSplash>,
pub global_error: Option<String>,
pub global_notice: Option<String>,
show_keyboard_help: bool,
startup_update_prompt: Option<StartupUpdatePrompt>,
library_metadata_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LibraryMetadataRefreshDone>>,
library_metadata_refresh_gen: u64,
collection_prefetch_rx: tokio::sync::mpsc::UnboundedReceiver<CollectionPrefetchDone>,
collection_prefetch_tx: tokio::sync::mpsc::UnboundedSender<CollectionPrefetchDone>,
collection_prefetch_queue: VecDeque<(RomCacheKey, GetRoms, u64)>,
collection_prefetch_queued_keys: HashSet<RomCacheKey>,
collection_prefetch_inflight_keys: HashSet<RomCacheKey>,
rom_load_gen: u64,
rom_load_rx: tokio::sync::mpsc::UnboundedReceiver<RomLoadDone>,
rom_load_tx: tokio::sync::mpsc::UnboundedSender<RomLoadDone>,
rom_load_task: Option<tokio::task::JoinHandle<()>>,
search_load_rx: tokio::sync::mpsc::UnboundedReceiver<SearchLoadDone>,
search_load_tx: tokio::sync::mpsc::UnboundedSender<SearchLoadDone>,
search_load_task: Option<tokio::task::JoinHandle<()>>,
cover_load_rx: tokio::sync::mpsc::UnboundedReceiver<CoverLoadDone>,
cover_load_tx: tokio::sync::mpsc::UnboundedSender<CoverLoadDone>,
cover_load_task: Option<tokio::task::JoinHandle<()>>,
library_scan_rx: Option<tokio::sync::mpsc::UnboundedReceiver<Result<(), String>>>,
library_scan_inflight: bool,
library_scan_pending_invalidate: Option<ScanCacheInvalidate>,
force_rom_reload_after_metadata: bool,
library_upload_inflight: bool,
library_upload_progress_rx: Option<tokio::sync::mpsc::UnboundedReceiver<(u64, u64)>>,
library_upload_done_rx:
Option<tokio::sync::mpsc::UnboundedReceiver<Result<LibraryUploadComplete, String>>>,
save_list_rx: tokio::sync::mpsc::UnboundedReceiver<SaveListDone>,
save_list_tx: tokio::sync::mpsc::UnboundedSender<SaveListDone>,
save_upload_rx: tokio::sync::mpsc::UnboundedReceiver<SaveUploadDone>,
save_upload_tx: tokio::sync::mpsc::UnboundedSender<SaveUploadDone>,
save_download_rx: tokio::sync::mpsc::UnboundedReceiver<SaveDownloadDone>,
save_download_tx: tokio::sync::mpsc::UnboundedSender<SaveDownloadDone>,
device_list_rx: tokio::sync::mpsc::UnboundedReceiver<DeviceListDone>,
device_list_tx: tokio::sync::mpsc::UnboundedSender<DeviceListDone>,
platform_list_rx: tokio::sync::mpsc::UnboundedReceiver<PlatformListDone>,
platform_list_tx: tokio::sync::mpsc::UnboundedSender<PlatformListDone>,
sync_push_pull_rx: tokio::sync::mpsc::UnboundedReceiver<SyncPushPullDone>,
sync_push_pull_tx: tokio::sync::mpsc::UnboundedSender<SyncPushPullDone>,
}
impl App {
fn blocks_global_chord_shortcuts(&self) -> bool {
self.startup_splash.is_some()
|| self.startup_update_prompt.is_some()
|| self.global_error.is_some()
|| self.global_notice.is_some()
}
fn blocks_global_d_shortcut(&self) -> bool {
let base = match &self.screen {
AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_) => true,
AppScreen::LibraryBrowse(lib) => {
lib.any_search_bar_open() || lib.any_upload_prompt_open()
}
_ => false,
};
base || self.library_upload_inflight || self.blocks_global_chord_shortcuts()
}
fn allows_global_question_help(&self) -> bool {
match &self.screen {
AppScreen::Search(_) | AppScreen::SetupWizard(_) | AppScreen::Execute(_) => false,
AppScreen::LibraryBrowse(lib)
if lib.any_search_bar_open() || lib.any_upload_prompt_open() =>
{
false
}
AppScreen::Settings(s) if s.editing || s.path_picker.is_some() => false,
_ => true,
}
}
fn is_force_quit_key(key: &crossterm::event::KeyEvent) -> bool {
key.kind == KeyEventKind::Press
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
}
fn selected_rom_request_for_library(
lib: &super::screens::library_browse::LibraryBrowseScreen,
) -> Option<GetRoms> {
match lib.subsection {
super::screens::library_browse::LibrarySubsection::ByConsole => {
lib.get_roms_request_platform()
}
super::screens::library_browse::LibrarySubsection::ByCollection => {
lib.get_roms_request_collection()
}
}
}
pub fn new(
client: RommClient,
config: Config,
registry: EndpointRegistry,
server_version: Option<String>,
startup_splash: Option<StartupSplash>,
startup_update: Option<UpdateStatus>,
) -> Self {
let (prefetch_tx, prefetch_rx) = tokio::sync::mpsc::unbounded_channel();
let (rom_load_tx, rom_load_rx) = tokio::sync::mpsc::unbounded_channel();
let (search_load_tx, search_load_rx) = tokio::sync::mpsc::unbounded_channel();
let (cover_load_tx, cover_load_rx) = tokio::sync::mpsc::unbounded_channel();
let (save_list_tx, save_list_rx) = tokio::sync::mpsc::unbounded_channel();
let (save_upload_tx, save_upload_rx) = tokio::sync::mpsc::unbounded_channel();
let (save_download_tx, save_download_rx) = tokio::sync::mpsc::unbounded_channel();
let (device_list_tx, device_list_rx) = tokio::sync::mpsc::unbounded_channel();
let (platform_list_tx, platform_list_rx) = tokio::sync::mpsc::unbounded_channel();
let (sync_push_pull_tx, sync_push_pull_rx) = tokio::sync::mpsc::unbounded_channel();
let save_sync_compat = save_sync_compatibility(®istry);
Self {
screen: AppScreen::MainMenu(MainMenuScreen::new()),
client,
config,
registry,
server_version,
save_sync_compat,
rom_cache: RomCache::load(),
downloads: DownloadManager::new(),
screen_before_download: None,
deferred_load_roms: None,
startup_splash,
global_error: None,
global_notice: None,
show_keyboard_help: false,
startup_update_prompt: startup_update.map(|status| StartupUpdatePrompt {
status,
updating: false,
}),
library_metadata_rx: None,
library_metadata_refresh_gen: 0,
collection_prefetch_rx: prefetch_rx,
collection_prefetch_tx: prefetch_tx,
collection_prefetch_queue: VecDeque::new(),
collection_prefetch_queued_keys: HashSet::new(),
collection_prefetch_inflight_keys: HashSet::new(),
rom_load_gen: 0,
rom_load_rx,
rom_load_tx,
rom_load_task: None,
search_load_rx,
search_load_tx,
search_load_task: None,
cover_load_rx,
cover_load_tx,
cover_load_task: None,
library_scan_rx: None,
library_scan_inflight: false,
library_scan_pending_invalidate: None,
force_rom_reload_after_metadata: false,
library_upload_inflight: false,
library_upload_progress_rx: None,
library_upload_done_rx: None,
save_list_rx,
save_list_tx,
save_upload_rx,
save_upload_tx,
save_download_rx,
save_download_tx,
device_list_rx,
device_list_tx,
platform_list_rx,
platform_list_tx,
sync_push_pull_rx,
sync_push_pull_tx,
}
}
fn spawn_library_metadata_refresh(&mut self) {
self.library_metadata_refresh_gen = self.library_metadata_refresh_gen.saturating_add(1);
let gen = self.library_metadata_refresh_gen;
let client = self.client.clone();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
self.library_metadata_rx = Some(rx);
tokio::spawn(async move {
let fetch = startup_library_snapshot::fetch_merged_library_metadata(&client).await;
let _ = tx.send(LibraryMetadataRefreshDone {
gen,
platforms: fetch.platforms,
collections: fetch.collections,
collection_digest: fetch.collection_digest,
warnings: fetch.warnings,
});
});
}
pub fn poll_background_tasks(&mut self) {
self.poll_library_metadata_refresh();
self.poll_rom_load_results();
self.poll_collection_prefetch_results();
self.poll_search_load_results();
self.poll_cover_load_results();
self.poll_save_results();
self.poll_settings_results();
self.poll_library_upload();
self.poll_library_scan();
self.drive_collection_prefetch_scheduler();
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
lib.poll_footer_clear();
}
}
fn spawn_library_rescan_worker(&mut self, cache_on_success: ScanCacheInvalidate) {
if self.library_scan_inflight {
return;
}
self.library_scan_inflight = true;
self.library_scan_pending_invalidate = Some(cache_on_success);
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
lib.set_metadata_footer(Some("Server library scan running…".into()));
}
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
self.library_scan_rx = Some(rx);
let client = self.client.clone();
tokio::spawn(async move {
let result = async {
let start =
crate::commands::library_scan::start_scan_library(&client, None).await?;
crate::commands::library_scan::wait_for_task_terminal(
&client,
&start.task_id,
Duration::from_secs(3600),
None,
|_| {},
)
.await?;
Ok::<(), anyhow::Error>(())
}
.await
.map_err(|e| e.to_string());
let _ = tx.send(result);
});
}
fn poll_library_scan(&mut self) {
let Some(rx) = &mut self.library_scan_rx else {
return;
};
match rx.try_recv() {
Ok(result) => {
self.library_scan_rx = None;
self.library_scan_inflight = false;
match result {
Ok(()) => self.on_library_scan_completed_success(),
Err(e) => {
self.library_scan_pending_invalidate = None;
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
lib.set_metadata_footer(Some(format!("Library scan failed: {e}")));
} else {
self.global_error = Some(format!("Library scan failed: {e}"));
}
}
}
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
self.library_scan_rx = None;
self.library_scan_inflight = false;
self.library_scan_pending_invalidate = None;
}
}
}
fn apply_library_scan_cache_invalidate(&mut self, inv: &ScanCacheInvalidate) {
match inv {
ScanCacheInvalidate::None => {}
ScanCacheInvalidate::Platform(pid) => {
self.rom_cache.remove(&RomCacheKey::Platform(*pid));
}
ScanCacheInvalidate::AllPlatforms => {
self.rom_cache.remove_all_platform_entries();
if let AppScreen::LibraryBrowse(lib) = &self.screen {
if let Some(ref k) = lib.cache_key() {
if !matches!(k, RomCacheKey::Platform(_)) {
self.rom_cache.remove(k);
}
}
}
}
}
}
fn on_library_scan_completed_success(&mut self) {
let inv = self
.library_scan_pending_invalidate
.take()
.unwrap_or(ScanCacheInvalidate::AllPlatforms);
self.apply_library_scan_cache_invalidate(&inv);
if matches!(self.screen, AppScreen::LibraryBrowse(_)) {
self.force_rom_reload_after_metadata = true;
self.spawn_library_metadata_refresh();
}
}
fn format_upload_bytes(n: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if n >= GB {
format!("{:.2} GiB", n as f64 / GB as f64)
} else if n >= MB {
format!("{:.2} MiB", n as f64 / MB as f64)
} else if n >= KB {
format!("{:.1} KiB", n as f64 / KB as f64)
} else {
format!("{n} B")
}
}
fn spawn_library_upload_worker(&mut self, platform_id: u64, path: PathBuf, scan_after: bool) {
if self.library_upload_inflight || self.library_scan_inflight {
return;
}
self.library_upload_inflight = true;
self.library_upload_progress_rx = None;
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
lib.set_metadata_footer(Some("Preparing upload…".into()));
}
let (prog_tx, prog_rx) = tokio::sync::mpsc::unbounded_channel();
let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
self.library_upload_progress_rx = Some(prog_rx);
self.library_upload_done_rx = Some(done_rx);
let client = self.client.clone();
tokio::spawn(async move {
let result: Result<LibraryUploadComplete, String> = async {
client
.upload_rom(platform_id, &path, move |uploaded, total| {
let _ = prog_tx.send((uploaded, total));
})
.await
.map_err(|e| e.to_string())?;
Ok(LibraryUploadComplete {
platform_id,
scan_after,
})
}
.await;
let _ = done_tx.send(result);
});
}
fn poll_library_upload(&mut self) {
if let Some(rx) = &mut self.library_upload_progress_rx {
while let Ok((up, tot)) = rx.try_recv() {
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
lib.set_metadata_footer(Some(format!(
"Uploading {} / {}…",
Self::format_upload_bytes(up),
Self::format_upload_bytes(tot)
)));
}
}
}
let Some(rx) = &mut self.library_upload_done_rx else {
return;
};
match rx.try_recv() {
Ok(result) => {
self.library_upload_done_rx = None;
self.library_upload_progress_rx = None;
self.library_upload_inflight = false;
match result {
Ok(done) => {
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
if done.scan_after {
lib.set_metadata_footer(Some(
"Upload complete. Starting library scan…".into(),
));
self.spawn_library_rescan_worker(ScanCacheInvalidate::Platform(
done.platform_id,
));
} else {
lib.set_metadata_footer(Some("Upload complete.".into()));
}
}
}
Err(e) => {
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
lib.set_metadata_footer(Some(format!("Upload failed: {e}")));
} else {
self.global_error = Some(format!("Upload failed: {e}"));
}
}
}
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
self.library_upload_done_rx = None;
self.library_upload_progress_rx = None;
self.library_upload_inflight = false;
}
}
}
fn poll_search_load_results(&mut self) {
loop {
match self.search_load_rx.try_recv() {
Ok(done) => {
if let AppScreen::Search(ref mut search) = self.screen {
match done.event {
SearchLoadEvent::Batch(roms) => {
search.set_results_for_query(done.query, roms);
}
SearchLoadEvent::Failed(err) => {
search.loading = false;
self.global_error = Some(err);
}
SearchLoadEvent::Complete => {
search.loading = false;
}
}
}
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
}
}
}
fn spawn_cover_load_worker(&mut self, rom_id: u64, url: String) {
if let Some(task) = self.cover_load_task.take() {
task.abort();
}
let tx = self.cover_load_tx.clone();
self.cover_load_task = Some(tokio::spawn(async move {
let result = async {
let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
let status = response.status();
if !status.is_success() {
return Err(format!("HTTP {}", status.as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
image::load_from_memory(&bytes).map_err(|e| e.to_string())
}
.await;
let _ = tx.send(CoverLoadDone { rom_id, result });
}));
}
fn poll_cover_load_results(&mut self) {
loop {
match self.cover_load_rx.try_recv() {
Ok(done) => {
if let AppScreen::GameDetail(detail) = &mut self.screen {
if detail.rom.id != done.rom_id {
continue;
}
match done.result {
Ok(image) => detail.apply_cover_image(image),
Err(err) => detail.apply_cover_error(format!(
"Cover failed: {}",
crate::tui::utils::truncate(&err, 120)
)),
}
}
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
}
}
}
fn maybe_start_game_detail_cover_load(&mut self) {
let (rom_id, url) = match &mut self.screen {
AppScreen::GameDetail(detail) => {
if !detail.should_request_cover_load() {
return;
}
detail.set_cover_loading();
let Some(url) = detail.cover_last_url.clone() else {
return;
};
(detail.rom.id, url)
}
_ => return,
};
self.spawn_cover_load_worker(rom_id, url);
}
fn spawn_save_list_worker(&mut self, rom_id: u64) {
if let AppScreen::GameDetail(detail) = &mut self.screen {
detail.set_saves_loading();
}
let client = self.client.clone();
let tx = self.save_list_tx.clone();
tokio::spawn(async move {
let result = async {
let value = client
.request_json(
"GET",
"/api/saves",
&[("rom_id".to_string(), rom_id.to_string())],
None,
)
.await?;
SaveMetadata::from_api_value(value)
}
.await
.map_err(|e| format!("{e:#}"));
let _ = tx.send(SaveListDone { rom_id, result });
});
}
fn refresh_current_game_saves(&mut self) {
if let AppScreen::GameDetail(detail) = &self.screen {
self.spawn_save_list_worker(detail.rom.id);
}
}
fn poll_save_results(&mut self) {
while let Ok(done) = self.save_list_rx.try_recv() {
if let AppScreen::GameDetail(detail) = &mut self.screen {
if detail.rom.id == done.rom_id {
match done.result {
Ok(rows) => detail.apply_saves(rows),
Err(e) => detail.apply_saves_error(e),
}
}
}
}
while let Ok(done) = self.save_upload_rx.try_recv() {
if let AppScreen::GameDetail(detail) = &mut self.screen {
if detail.rom.id == done.rom_id {
match done.result {
Ok(()) => {
detail.message = Some("Save uploaded. Refreshing saves...".into());
detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
self.spawn_save_list_worker(done.rom_id);
}
Err(e) => {
detail.message = Some(format!("Save upload failed: {e}"));
detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
}
}
}
}
}
while let Ok(done) = self.save_download_rx.try_recv() {
if let AppScreen::GameDetail(detail) = &mut self.screen {
if detail.rom.id == done.rom_id {
match done.result {
Ok(path) => {
detail.message = Some(format!("Save downloaded: {}", path.display()));
detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
self.spawn_save_list_worker(done.rom_id);
}
Err(e) => {
detail.message = Some(format!("Save download failed: {e}"));
detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
}
}
}
}
}
}
fn poll_settings_results(&mut self) {
while let Ok(done) = self.device_list_rx.try_recv() {
if let AppScreen::Settings(settings) = &mut self.screen {
match done.result {
Ok(devices) => {
settings.set_devices(devices);
settings.message = None;
}
Err(e) => {
settings.set_device_error(e.clone());
settings.message = Some((format!("Device load failed: {e}"), Color::Red));
}
}
}
}
while let Ok(done) = self.platform_list_rx.try_recv() {
if let AppScreen::Settings(settings) = &mut self.screen {
match done.result {
Ok(platforms) => {
settings.set_console_platforms(platforms);
settings.message = None;
}
Err(e) => {
settings.set_console_platform_error(e.clone());
settings.message = Some((format!("Platform load failed: {e}"), Color::Red));
}
}
}
}
while let Ok(done) = self.sync_push_pull_rx.try_recv() {
if let AppScreen::Settings(settings) = &mut self.screen {
settings.sync_inflight = false;
match done.result {
Ok(session) => {
settings.message = Some((
format!("Sync session #{}: {}", session.id, session.status),
Color::Green,
));
}
Err(e) => {
settings.message = Some((format!("Sync failed: {e}"), Color::Red));
}
}
}
}
}
fn poll_rom_load_results(&mut self) {
loop {
match self.rom_load_rx.try_recv() {
Ok(done) => {
if !primary_rom_load_result_is_current(done.gen, self.rom_load_gen) {
continue;
}
let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
continue;
};
match done.event {
RomLoadEvent::Batch(roms) => {
if let Some(ref k) = done.key {
self.rom_cache
.insert(k.clone(), roms.clone(), done.expected);
}
lib.set_roms(roms);
tracing::debug!(
"rom-list-render batch context={} latency_ms={}",
done.context,
done.started.elapsed().as_millis()
);
}
RomLoadEvent::Failed(e) => {
lib.set_metadata_footer(Some(format!("Could not load games: {e}")));
lib.set_rom_loading(false);
}
RomLoadEvent::Complete => {
lib.set_rom_loading(false);
}
}
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
}
}
}
fn poll_library_metadata_refresh(&mut self) {
let mut batch = Vec::new();
let mut disconnected = false;
if let Some(rx) = &mut self.library_metadata_rx {
loop {
match rx.try_recv() {
Ok(msg) => batch.push(msg),
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
}
if disconnected {
self.library_metadata_rx = None;
}
for msg in batch {
self.apply_library_metadata_refresh(msg);
}
}
fn apply_library_metadata_refresh(&mut self, msg: LibraryMetadataRefreshDone) {
if msg.gen != self.library_metadata_refresh_gen {
return;
}
let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
return;
};
let had_cached_lists = !lib.platforms.is_empty() || !lib.collections.is_empty();
let live_empty = msg.collections.is_empty();
if live_empty && had_cached_lists && !msg.warnings.is_empty() {
lib.set_temporary_metadata_footer(
"Could not refresh library metadata (keeping cached list).".into(),
std::time::Duration::from_secs(3),
);
self.force_rom_reload_after_metadata = false;
return;
}
let old_digest =
startup_library_snapshot::build_collection_digest_from_collections(&lib.collections);
let digest_changed = old_digest != msg.collection_digest;
let update_platforms = !msg.platforms.is_empty();
let selection_changed = lib.replace_metadata_preserving_selection(
msg.platforms,
msg.collections,
update_platforms,
true,
);
startup_library_snapshot::save_snapshot(&lib.platforms, &lib.collections);
let footer = if msg.warnings.is_empty() {
if digest_changed {
Some("Collection metadata updated.".into())
} else {
None
}
} else {
let w = msg.warnings.join(" | ");
let short: String = if w.chars().count() > 160 {
let prefix: String = w.chars().take(157).collect();
format!("{prefix}…")
} else {
w
};
Some(format!("Partial refresh: {}", short))
};
lib.set_metadata_footer(footer);
if selection_changed && lib.list_len() > 0 {
lib.clear_roms();
let key = lib.cache_key();
let expected = lib.expected_rom_count();
let req = Self::selected_rom_request_for_library(lib);
lib.set_rom_loading(expected > 0);
self.deferred_load_roms =
Some((key, req, expected, "refresh_selection", Instant::now()));
}
let force_reload = std::mem::take(&mut self.force_rom_reload_after_metadata);
if force_reload && lib.list_len() > 0 && !selection_changed {
lib.clear_roms();
let key = lib.cache_key();
let expected = lib.expected_rom_count();
let req = Self::selected_rom_request_for_library(lib);
lib.set_rom_loading(expected > 0);
self.deferred_load_roms =
Some((key, req, expected, "post_scan_reload", Instant::now()));
}
self.queue_collection_prefetches_from_screen(1, "refresh_warmup");
}
fn queue_collection_prefetches_from_screen(&mut self, radius: usize, _reason: &'static str) {
let AppScreen::LibraryBrowse(ref lib) = self.screen else {
return;
};
for (key, req, expected) in lib.collection_prefetch_candidates(radius) {
if self.rom_cache.get_valid(&key, expected).is_some() {
continue;
}
if self.collection_prefetch_queued_keys.contains(&key)
|| self.collection_prefetch_inflight_keys.contains(&key)
{
continue;
}
self.collection_prefetch_queued_keys.insert(key.clone());
self.collection_prefetch_queue
.push_back((key, req, expected));
}
}
fn drive_collection_prefetch_scheduler(&mut self) {
const PREFETCH_MAX_INFLIGHT: usize = 2;
while self.collection_prefetch_inflight_keys.len() < PREFETCH_MAX_INFLIGHT {
let Some((key, req, expected)) = self.collection_prefetch_queue.pop_back() else {
break;
};
self.collection_prefetch_queued_keys.remove(&key);
self.collection_prefetch_inflight_keys.insert(key.clone());
let tx = self.collection_prefetch_tx.clone();
let client = self.client.clone();
tokio::spawn(async move {
let result = Self::fetch_roms_full(client, req).await;
let (roms, warning) = match result {
Ok(list) => (Some(list), None),
Err(e) => (None, Some(format!("Collection prefetch failed: {e:#}"))),
};
let _ = tx.send(CollectionPrefetchDone {
key,
expected,
roms,
warning,
});
});
}
}
fn poll_collection_prefetch_results(&mut self) {
loop {
match self.collection_prefetch_rx.try_recv() {
Ok(done) => {
self.collection_prefetch_inflight_keys.remove(&done.key);
if let Some(roms) = done.roms {
self.rom_cache.insert(done.key, roms, done.expected);
} else if let Some(warning) = done.warning {
tracing::debug!("{warning}");
}
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
}
}
}
pub fn set_error(&mut self, err: anyhow::Error) {
self.global_error = Some(format!("{:#}", err));
}
pub async fn run(&mut self) -> Result<()> {
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
loop {
self.poll_background_tasks();
if self
.startup_splash
.as_ref()
.is_some_and(|s| s.should_auto_dismiss())
{
self.startup_splash = None;
}
terminal.draw(|f| self.render(f))?;
if let Some(ref mut prompt) = self.startup_update_prompt {
if prompt.updating {
if prompt.status.latest_version == "9.9.9-mock" {
tokio::time::sleep(std::time::Duration::from_secs(2)).await; self.global_notice =
Some("Mock update successful! (No files were changed)".into());
self.startup_update_prompt = None;
} else {
let options = crate::update::ApplyUpdateOptions {
show_progress: false,
show_output: false,
no_confirm: true,
target_version_tag: Some(prompt.status.release_tag.clone()),
};
match crate::update::apply_update(None, options).await {
Ok(crate::update::ApplyUpdateOutcome::Updated(version)) => {
self.global_notice = Some(format!(
"Updated to {version}. Restart romm-cli to use the new version."
));
}
Ok(crate::update::ApplyUpdateOutcome::UpToDate(version)) => {
self.global_notice =
Some(format!("Already up to date (`{version}`)."));
}
Err(err) => {
self.global_error = Some(format!("Update failed: {err:#}"));
}
}
self.startup_update_prompt = None;
}
continue;
}
}
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key_event) = event::read()? {
if Self::is_force_quit_key(&key_event) {
break;
}
if key_event.kind == KeyEventKind::Press
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('r') | KeyCode::Char('R'))
&& !self.blocks_global_chord_shortcuts()
{
if let AppScreen::LibraryBrowse(ref lib) = self.screen {
if !lib.any_search_bar_open()
&& !lib.any_upload_prompt_open()
&& !self.library_upload_inflight
&& !self.library_scan_inflight
{
self.spawn_library_rescan_worker(ScanCacheInvalidate::AllPlatforms);
}
}
continue;
}
if key_event.kind == KeyEventKind::Press
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('u') | KeyCode::Char('U'))
&& !self.blocks_global_chord_shortcuts()
{
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
if lib.any_upload_prompt_open() {
lib.close_upload_prompt();
} else if !lib.any_search_bar_open()
&& !self.library_upload_inflight
&& !self.library_scan_inflight
{
if lib.subsection
== super::screens::library_browse::LibrarySubsection::ByConsole
{
lib.open_upload_prompt();
} else {
lib.set_metadata_footer(Some(
"Upload requires Consoles view — press t".into(),
));
}
}
}
continue;
}
if key_event.kind == KeyEventKind::Press
&& self.handle_key_event(&key_event).await?
{
break;
}
}
}
if let Some((key, req, expected, context, started)) = self.deferred_load_roms.take() {
if let Some(ref k) = key {
if let Some(cached) = self.rom_cache.get_valid(k, expected) {
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
lib.set_roms(cached.clone());
lib.set_rom_loading(false);
tracing::debug!(
"rom-list-render context={} latency_ms={} (cache_hit)",
context,
started.elapsed().as_millis()
);
}
continue;
}
}
if started.elapsed() < std::time::Duration::from_millis(250) {
self.deferred_load_roms = Some((key, req, expected, context, started));
continue;
}
self.rom_load_gen = self.rom_load_gen.saturating_add(1);
let gen = self.rom_load_gen;
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
lib.set_rom_loading(expected > 0);
}
if expected == 0 {
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
lib.set_rom_loading(false);
}
continue;
}
let Some(r) = req else {
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
lib.set_rom_loading(false);
}
continue;
};
let client = self.client.clone();
let tx = self.rom_load_tx.clone();
if let Some(task) = self.rom_load_task.take() {
task.abort();
}
self.rom_load_task = Some(tokio::spawn(async move {
let mut req = r;
let mut aggregated: Option<RomList> = None;
loop {
match client.call(&req).await {
Ok(mut batch) => {
if let Some(ref mut all) = aggregated {
if batch.items.is_empty() {
break;
}
all.items.append(&mut batch.items);
let _ = tx.send(RomLoadDone {
gen,
key: key.clone(),
expected,
event: RomLoadEvent::Batch(all.clone()),
context,
started,
});
if all.items.len() as u64 >= all.total {
break;
}
req.offset = Some(all.items.len() as u32);
} else {
let loaded = batch.items.len() as u64;
let total = batch.total;
let _ = tx.send(RomLoadDone {
gen,
key: key.clone(),
expected,
event: RomLoadEvent::Batch(batch.clone()),
context,
started,
});
req.offset = Some(loaded as u32);
aggregated = Some(batch);
if loaded >= total {
break;
}
}
}
Err(e) => {
let _ = tx.send(RomLoadDone {
gen,
key: key.clone(),
expected,
event: RomLoadEvent::Failed(format!("{e:#}")),
context,
started,
});
return;
}
}
if let Some(ref all) = aggregated {
if all.items.len() >= 20000 {
break;
}
}
}
let _ = tx.send(RomLoadDone {
gen,
key,
expected,
event: RomLoadEvent::Complete,
context,
started,
});
}));
}
}
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
async fn fetch_roms_full(client: RommClient, req: GetRoms) -> Result<RomList> {
let mut roms = client.call(&req).await?;
let total = roms.total;
let ceiling = 20000;
while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
let mut next_req = req.clone();
next_req.offset = Some(roms.items.len() as u32);
let next_batch = client.call(&next_req).await?;
if next_batch.items.is_empty() {
break;
}
roms.items.extend(next_batch.items);
}
Ok(roms)
}
pub async fn handle_key_event(&mut self, key: &KeyEvent) -> Result<bool> {
if key.kind != KeyEventKind::Press {
return Ok(false);
}
if self.global_error.is_some() || self.global_notice.is_some() {
if key.code == KeyCode::Esc || key.code == KeyCode::Enter {
self.global_error = None;
self.global_notice = None;
}
return Ok(false);
}
if self.startup_splash.is_some() {
self.startup_splash = None;
return Ok(false);
}
if self.startup_update_prompt.is_some() {
return self.handle_startup_update_prompt(key).await;
}
if self.show_keyboard_help {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('?')
) {
self.show_keyboard_help = false;
}
return Ok(false);
}
if key.code == KeyCode::F(1) {
self.show_keyboard_help = true;
return Ok(false);
}
if key.code == KeyCode::Char('?') && self.allows_global_question_help() {
self.show_keyboard_help = true;
return Ok(false);
}
if key.code == KeyCode::Char('d') && !self.blocks_global_d_shortcut() {
self.toggle_download_screen();
return Ok(false);
}
match &self.screen {
AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
AppScreen::Search(_) => self.handle_search(key).await,
AppScreen::Settings(_) => self.handle_settings(key).await,
AppScreen::Browse(_) => self.handle_browse(key),
AppScreen::Execute(_) => self.handle_execute(key).await,
AppScreen::Result(_) => self.handle_result(key),
AppScreen::ResultDetail(_) => self.handle_result_detail(key),
AppScreen::GameDetail(_) => self.handle_game_detail(key),
AppScreen::ExtrasPicker(_) => self.handle_extras_picker(key),
AppScreen::Download(_) => self.handle_download(key),
AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
}
}
async fn handle_startup_update_prompt(&mut self, key: &KeyEvent) -> Result<bool> {
let Some(ref mut prompt) = self.startup_update_prompt else {
return Ok(false);
};
if prompt.updating {
return Ok(false); }
match key.code {
KeyCode::Char('u')
| KeyCode::Char('U')
| KeyCode::Char('y')
| KeyCode::Char('Y')
| KeyCode::Enter => {
prompt.updating = true;
Ok(false)
}
KeyCode::Char('c') | KeyCode::Char('C') => {
if let Err(err) = crate::update::open_changelog_in_browser() {
self.global_error = Some(format!("Could not open changelog: {err:#}"));
} else {
self.global_notice =
Some(format!("Opened changelog: {}", prompt.status.changelog_url));
}
Ok(false)
}
KeyCode::Esc
| KeyCode::Char('s')
| KeyCode::Char('S')
| KeyCode::Char('n')
| KeyCode::Char('N')
| KeyCode::Char('q')
| KeyCode::Char('Q') => {
self.startup_update_prompt = None;
Ok(false)
}
_ => Ok(false),
}
}
fn toggle_download_screen(&mut self) {
let current =
std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
match current {
AppScreen::Download(_) => {
self.screen = self
.screen_before_download
.take()
.unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
}
other => {
self.screen_before_download = Some(other);
self.screen = AppScreen::Download(DownloadScreen::new(
self.downloads.shared(),
self.downloads.shared_extras(),
));
}
}
}
fn handle_download(&mut self, key: &KeyEvent) -> Result<bool> {
if key.code == KeyCode::Esc || key.code == KeyCode::Char('d') {
self.screen = self
.screen_before_download
.take()
.unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
}
Ok(false)
}
async fn handle_main_menu(&mut self, key: &KeyEvent) -> Result<bool> {
let menu = match &mut self.screen {
AppScreen::MainMenu(m) => m,
_ => return Ok(false),
};
match key.code {
KeyCode::Up | KeyCode::Char('k') => menu.previous(),
KeyCode::Down | KeyCode::Char('j') => menu.next(),
KeyCode::Enter => match menu.selected {
0 => {
let start = Instant::now();
let snap = startup_library_snapshot::load_snapshot();
let (platforms, collections, from_disk) = match snap {
Some(s) => (s.platforms, s.collections, true),
None => (Vec::new(), Vec::new(), false),
};
let mut lib = LibraryBrowseScreen::new(platforms, collections);
if from_disk && lib.list_len() > 0 {
lib.set_metadata_footer(Some(
"Refreshing library metadata in background…".into(),
));
} else if lib.list_len() == 0 {
lib.set_metadata_footer(Some("Loading library metadata…".into()));
}
if lib.list_len() > 0 {
let key = lib.cache_key();
let expected = lib.expected_rom_count();
let req = Self::selected_rom_request_for_library(&lib);
lib.set_rom_loading(expected > 0);
self.deferred_load_roms = Some((
key,
req,
expected,
"startup_first_selection",
Instant::now(),
));
}
self.screen = AppScreen::LibraryBrowse(lib);
self.spawn_library_metadata_refresh();
tracing::debug!(
"library-open latency_ms={} snapshot_hit={}",
start.elapsed().as_millis(),
from_disk
);
}
1 => self.screen = AppScreen::Search(SearchScreen::new()),
2 => {
self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
self.screen = AppScreen::Download(DownloadScreen::new(
self.downloads.shared(),
self.downloads.shared_extras(),
));
}
3 => {
self.screen = AppScreen::Settings(Box::new(SettingsScreen::new(
&self.config,
self.server_version.as_deref(),
self.save_sync_compat.clone(),
)))
}
4 => return Ok(true),
_ => {}
},
KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
_ => {}
}
Ok(false)
}
async fn handle_library_browse(&mut self, key: &KeyEvent) -> Result<bool> {
use super::path_picker::PathPickerEvent;
use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};
if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
if lib.upload_prompt.is_some() {
if let Some(up) = lib.upload_prompt.as_mut() {
if key.code == KeyCode::Esc {
lib.close_upload_prompt();
return Ok(false);
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('s') | KeyCode::Char('S'))
{
up.scan_after = !up.scan_after;
return Ok(false);
}
match up.picker.handle_key(key) {
PathPickerEvent::Confirmed(path) => {
let scan_after = up.scan_after;
if !Path::new(&path).is_file() {
lib.set_metadata_footer(Some(format!(
"Not a file: {}",
path.display()
)));
return Ok(false);
}
let Some(pid) = lib.selected_platform_id() else {
lib.set_metadata_footer(Some(
"Select a console before uploading.".into(),
));
return Ok(false);
};
lib.close_upload_prompt();
self.spawn_library_upload_worker(pid, path, scan_after);
}
PathPickerEvent::None => {}
}
}
return Ok(false);
}
}
if self.library_upload_inflight {
return Ok(false);
}
let lib = match &mut self.screen {
AppScreen::LibraryBrowse(l) => l,
_ => return Ok(false),
};
if lib.view_mode == LibraryViewMode::List {
if let Some(mode) = lib.list_search.mode {
let old_key = lib.cache_key();
match key.code {
KeyCode::Esc => lib.clear_list_search(),
KeyCode::Backspace => lib.delete_list_search_char(),
KeyCode::Char(c) => lib.add_list_search_char(c),
KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.list_jump_match(true),
KeyCode::Enter => lib.commit_list_filter_bar(),
_ => {}
}
let new_key = lib.cache_key();
if old_key != new_key && lib.list_len() > 0 {
lib.clear_roms();
let expected = lib.expected_rom_count();
if expected > 0 {
let req = Self::selected_rom_request_for_library(lib);
lib.set_rom_loading(true);
self.deferred_load_roms =
Some((new_key, req, expected, "search_filter", Instant::now()));
} else {
lib.set_rom_loading(false);
self.deferred_load_roms = None;
}
}
return Ok(false);
}
}
if lib.view_mode == LibraryViewMode::Roms {
if let Some(mode) = lib.rom_search.mode {
match key.code {
KeyCode::Esc => lib.clear_rom_search(),
KeyCode::Backspace => lib.delete_rom_search_char(),
KeyCode::Char(c) => lib.add_rom_search_char(c),
KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_rom_match(true),
KeyCode::Enter => lib.commit_rom_filter_bar(),
_ => {}
}
return Ok(false);
}
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if lib.view_mode == LibraryViewMode::List {
lib.list_previous();
if lib.list_len() > 0 {
lib.clear_roms(); let key = lib.cache_key();
let expected = lib.expected_rom_count();
if expected > 0 {
let req = Self::selected_rom_request_for_library(lib);
lib.set_rom_loading(true);
self.deferred_load_roms =
Some((key, req, expected, "list_move_up", Instant::now()));
} else {
lib.set_rom_loading(false);
self.deferred_load_roms = None;
}
if lib.subsection
== super::screens::library_browse::LibrarySubsection::ByCollection
{
tracing::debug!("collections-selection move=up expected={expected}");
self.queue_collection_prefetches_from_screen(1, "move_up");
}
}
} else {
lib.rom_previous();
}
}
KeyCode::Down | KeyCode::Char('j') => {
if lib.view_mode == LibraryViewMode::List {
lib.list_next();
if lib.list_len() > 0 {
lib.clear_roms(); let key = lib.cache_key();
let expected = lib.expected_rom_count();
if expected > 0 {
let req = Self::selected_rom_request_for_library(lib);
lib.set_rom_loading(true);
self.deferred_load_roms =
Some((key, req, expected, "list_move_down", Instant::now()));
} else {
lib.set_rom_loading(false);
self.deferred_load_roms = None;
}
if lib.subsection
== super::screens::library_browse::LibrarySubsection::ByCollection
{
tracing::debug!("collections-selection move=down expected={expected}");
self.queue_collection_prefetches_from_screen(1, "move_down");
}
}
} else {
lib.rom_next();
}
}
KeyCode::Left | KeyCode::Char('h') if lib.view_mode == LibraryViewMode::Roms => {
lib.back_to_list();
}
KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
KeyCode::Tab => {
if lib.view_mode == LibraryViewMode::List {
lib.switch_view();
} else {
lib.switch_view(); }
}
KeyCode::Char('/') => match lib.view_mode {
LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Filter),
LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Filter),
},
KeyCode::Char('f') => match lib.view_mode {
LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Jump),
LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Jump),
},
KeyCode::Enter => {
if lib.view_mode == LibraryViewMode::List {
lib.switch_view();
} else if let Some((primary, others)) = lib.get_selected_group() {
let lib_screen = std::mem::replace(
&mut self.screen,
AppScreen::MainMenu(MainMenuScreen::new()),
);
if let AppScreen::LibraryBrowse(l) = lib_screen {
self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
primary,
others,
GameDetailPrevious::Library(Box::new(l)),
self.downloads.shared(),
)));
self.maybe_start_game_detail_cover_load();
self.refresh_current_game_saves();
}
}
}
KeyCode::Char('t') => {
lib.switch_subsection();
if lib.view_mode == LibraryViewMode::List && lib.list_len() > 0 {
let key = lib.cache_key();
let expected = lib.expected_rom_count();
if expected > 0 {
let req = Self::selected_rom_request_for_library(lib);
lib.set_rom_loading(true);
self.deferred_load_roms =
Some((key, req, expected, "switch_subsection", Instant::now()));
} else {
lib.set_rom_loading(false);
self.deferred_load_roms = None;
}
}
if lib.subsection == super::screens::library_browse::LibrarySubsection::ByCollection
{
tracing::debug!("collections-subsection entered");
self.queue_collection_prefetches_from_screen(1, "enter_collections");
}
}
KeyCode::Esc => {
if lib.view_mode == LibraryViewMode::Roms {
if lib.rom_search.filter_browsing {
lib.clear_rom_search();
} else {
lib.back_to_list();
}
} else if lib.list_search.filter_browsing {
lib.clear_list_search();
} else {
self.screen = AppScreen::MainMenu(MainMenuScreen::new());
}
}
KeyCode::Char('q') => return Ok(true),
_ => {}
}
Ok(false)
}
async fn handle_search(&mut self, key: &KeyEvent) -> Result<bool> {
let search = match &mut self.screen {
AppScreen::Search(s) => s,
_ => return Ok(false),
};
match key.code {
KeyCode::Backspace => search.delete_char(),
KeyCode::Left => search.cursor_left(),
KeyCode::Right => search.cursor_right(),
KeyCode::Up => search.previous(),
KeyCode::Down => search.next(),
KeyCode::Char(c) => search.add_char(c),
KeyCode::Enter => {
if search.query.is_empty() {
} else if search.result_groups.is_some() && search.results_match_current_query() {
if let Some((primary, others)) = search.get_selected_group() {
let prev = std::mem::replace(
&mut self.screen,
AppScreen::MainMenu(MainMenuScreen::new()),
);
if let AppScreen::Search(s) = prev {
self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
primary,
others,
GameDetailPrevious::Search(s),
self.downloads.shared(),
)));
self.maybe_start_game_detail_cover_load();
self.refresh_current_game_saves();
}
}
} else {
let query = search.query.clone();
let req = GetRoms {
search_term: Some(query.clone()),
limit: Some(50),
..Default::default()
};
search.loading = true;
if let Some(task) = self.search_load_task.take() {
task.abort();
}
let client = self.client.clone();
let tx = self.search_load_tx.clone();
self.search_load_task = Some(tokio::spawn(async move {
let mut req = req;
let mut aggregated: Option<RomList> = None;
loop {
match client.call(&req).await {
Ok(mut batch) => {
if let Some(ref mut all) = aggregated {
if batch.items.is_empty() {
break;
}
all.items.append(&mut batch.items);
let _ = tx.send(SearchLoadDone {
query: query.clone(),
event: SearchLoadEvent::Batch(all.clone()),
});
if all.items.len() as u64 >= all.total {
break;
}
req.offset = Some(all.items.len() as u32);
} else {
let loaded = batch.items.len() as u64;
let total = batch.total;
let _ = tx.send(SearchLoadDone {
query: query.clone(),
event: SearchLoadEvent::Batch(batch.clone()),
});
req.offset = Some(loaded as u32);
aggregated = Some(batch);
if loaded >= total {
break;
}
}
}
Err(e) => {
let _ = tx.send(SearchLoadDone {
query: query.clone(),
event: SearchLoadEvent::Failed(format!("{e:#}")),
});
return;
}
}
}
let _ = tx.send(SearchLoadDone {
query,
event: SearchLoadEvent::Complete,
});
}));
}
}
KeyCode::Esc => {
if search.results.is_some() {
search.clear_results();
} else {
self.screen = AppScreen::MainMenu(MainMenuScreen::new());
}
}
_ => {}
}
Ok(false)
}
async fn refresh_settings_server_version(&mut self) -> Result<()> {
let (base_url, download_dir, use_https, verbose, auth) = {
let settings = match &self.screen {
AppScreen::Settings(s) => s,
_ => return Ok(()),
};
let mut base_url = normalize_romm_origin(settings.base_url.trim());
if settings.use_https && base_url.starts_with("http://") {
base_url = base_url.replace("http://", "https://");
}
if !settings.use_https && base_url.starts_with("https://") {
base_url = base_url.replace("https://", "http://");
}
(
base_url,
settings.download_dir.clone(),
settings.use_https,
self.client.verbose(),
self.config.auth.clone(),
)
};
let cfg = Config {
base_url,
download_dir,
use_https,
auth,
extras_defaults: self.config.extras_defaults.clone(),
save_sync: self.config.save_sync.clone(),
roms_layout: self.config.roms_layout.clone(),
};
let client = match RommClient::new(&cfg, verbose) {
Ok(c) => c,
Err(_) => {
if let AppScreen::Settings(s) = &mut self.screen {
s.server_version = "unavailable (invalid URL or client error)".to_string();
self.server_version = None;
}
return Ok(());
}
};
let ver = client.rom_server_version_from_heartbeat().await;
if let AppScreen::Settings(s) = &mut self.screen {
match ver {
Some(v) => {
s.server_version = v.clone();
self.server_version = Some(v);
}
None => {
s.server_version = "unavailable (heartbeat failed)".to_string();
self.server_version = None;
}
}
}
Ok(())
}
async fn handle_settings(&mut self, key: &KeyEvent) -> Result<bool> {
use super::path_picker::PathPickerEvent;
use crate::core::download::validate_configured_download_directory;
let settings = match &mut self.screen {
AppScreen::Settings(s) => s,
_ => return Ok(false),
};
if let Some((kind, ref mut picker)) = settings.path_picker {
if key.code == KeyCode::Esc {
settings.path_picker = None;
return Ok(false);
}
match picker.handle_key(key) {
PathPickerEvent::Confirmed(p) => {
match validate_configured_download_directory(p.to_string_lossy().as_ref()) {
Ok(canonical) => {
if kind == super::screens::settings::SettingsPickerKind::RomsDir {
settings.download_dir = canonical.display().to_string();
settings.message = Some((
"ROMs directory updated (press S to save)".to_string(),
Color::Green,
));
} else {
settings.save_dir = canonical.display().to_string();
settings.message = Some((
"Save directory updated (press S to save)".to_string(),
Color::Green,
));
}
settings.path_picker = None;
}
Err(e) => {
settings.message =
Some((format!("Invalid ROMs directory: {e:#}"), Color::Red));
}
}
}
PathPickerEvent::None => {}
}
return Ok(false);
}
if let Some((platform_id, ref mut picker)) = settings.console_path_picker {
if key.code == KeyCode::Esc {
settings.console_path_picker = None;
return Ok(false);
}
match picker.handle_key(key) {
PathPickerEvent::Confirmed(p) => {
match validate_configured_download_directory(p.to_string_lossy().as_ref()) {
Ok(canonical) => {
settings
.confirm_console_path(platform_id, canonical.display().to_string());
}
Err(e) => {
settings.message =
Some((format!("Invalid console directory: {e:#}"), Color::Red));
}
}
}
PathPickerEvent::None => {}
}
return Ok(false);
}
if settings.console_picker_open {
match key.code {
KeyCode::Esc => {
settings.console_picker_open = false;
settings.active_console_kind = None;
}
KeyCode::Up | KeyCode::Char('k') => settings.console_previous(),
KeyCode::Down | KeyCode::Char('j') => settings.console_next(),
KeyCode::Enter => settings.open_console_path_picker(),
KeyCode::Delete | KeyCode::Backspace => {
if let Some(platform) = settings
.console_platforms
.get(settings.console_selected_index)
{
settings.clear_console_path(platform.id);
}
}
_ => {}
}
return Ok(false);
}
if settings.device_picker_open {
match key.code {
KeyCode::Esc => {
settings.device_picker_open = false;
settings.device_picker_loading = false;
}
KeyCode::Up | KeyCode::Char('k') => settings.device_previous(),
KeyCode::Down | KeyCode::Char('j') => settings.device_next(),
KeyCode::Enter => settings.confirm_device(),
_ => {}
}
return Ok(false);
}
if settings.confirm.is_some() {
match key.code {
KeyCode::Enter => match settings.confirm.take().unwrap() {
super::screens::settings::SettingsConfirm::Reset => {
let _ = crate::config::reset_all_settings();
settings.message = Some((
"Settings deleted. Please restart romm-cli.".to_string(),
Color::Yellow,
));
}
super::screens::settings::SettingsConfirm::ClearCache => {
match crate::core::cache::RomCache::clear_file() {
Ok(true) => {
self.rom_cache = crate::core::cache::RomCache::load();
settings.message =
Some(("ROM cache cleared.".to_string(), Color::Green));
}
Ok(false) => {
settings.message = Some((
"ROM cache file does not exist.".to_string(),
Color::Yellow,
));
}
Err(e) => {
settings.message =
Some((format!("Failed to clear cache: {e}"), Color::Red));
}
}
}
},
KeyCode::Esc => {
settings.confirm = None;
}
_ => {}
}
return Ok(false);
}
if settings.editing {
match key.code {
KeyCode::Enter => {
let row = settings.selected_row();
settings.save_edit();
if row == SettingsRow::BaseUrl {
self.refresh_settings_server_version().await?;
}
}
KeyCode::Esc => settings.cancel_edit(),
KeyCode::Backspace => settings.delete_char(),
KeyCode::Left => settings.move_cursor_left(),
KeyCode::Right => settings.move_cursor_right(),
KeyCode::Char(c) => settings.add_char(c),
_ => {}
}
return Ok(false);
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => settings.previous(),
KeyCode::Down | KeyCode::Char('j') => settings.next(),
KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => settings.next_tab(),
KeyCode::Left | KeyCode::Char('h') | KeyCode::BackTab => settings.previous_tab(),
KeyCode::Enter => {
let row = settings.selected_row();
if row == SettingsRow::Auth {
self.screen =
AppScreen::SetupWizard(Box::new(SetupWizard::new_auth_only(&self.config)));
} else if row == SettingsRow::ConsolePaths {
settings.open_console_picker(ConsolePathKind::Roms);
let client = self.client.clone();
let tx = self.platform_list_tx.clone();
tokio::spawn(async move {
let result = client
.call(&ListPlatforms)
.await
.map_err(|e| format!("{e:#}"));
let _ = tx.send(PlatformListDone { result });
});
} else if row == SettingsRow::SaveConsolePaths {
settings.open_console_picker(ConsolePathKind::Saves);
let client = self.client.clone();
let tx = self.platform_list_tx.clone();
tokio::spawn(async move {
let result = client
.call(&ListPlatforms)
.await
.map_err(|e| format!("{e:#}"));
let _ = tx.send(PlatformListDone { result });
});
} else if row == SettingsRow::SyncDevice {
if !settings.save_sync_supported() {
settings.set_save_sync_unsupported_message();
return Ok(false);
}
settings.enter_edit();
let client = self.client.clone();
let tx = self.device_list_tx.clone();
tokio::spawn(async move {
let result = client
.call(&ListDevices)
.await
.map_err(|e| format!("{e:#}"));
let _ = tx.send(DeviceListDone { result });
});
} else if row == SettingsRow::SyncNow {
if !settings.save_sync_supported() {
settings.set_save_sync_unsupported_message();
return Ok(false);
}
if settings.sync_inflight {
return Ok(false);
}
let Some(device_id) = settings.sync_device_id.clone() else {
settings.message =
Some(("Choose a Sync Device first".to_string(), Color::Yellow));
return Ok(false);
};
settings.sync_inflight = true;
settings.message =
Some(("Sync Saves Now running...".to_string(), Color::Yellow));
let client = self.client.clone();
let tx = self.sync_push_pull_tx.clone();
tokio::spawn(async move {
let result = client
.call(&TriggerPushPull { device_id })
.await
.map_err(|e| format!("{e:#}"));
let _ = tx.send(SyncPushPullDone { result });
});
} else {
let toggle_https = row == SettingsRow::UseHttps;
settings.enter_edit();
if toggle_https {
self.refresh_settings_server_version().await?;
}
}
}
KeyCode::Char('s' | 'S') => {
use crate::config::persist_user_config;
let auth = auth_for_persist_merge(self.config.auth.clone());
let cfg = Config {
base_url: settings.base_url.clone(),
download_dir: settings.download_dir.clone(),
use_https: settings.use_https,
auth,
extras_defaults: ExtrasDefaults {
include_related_roms: settings.extras_include_related_roms,
include_cover: settings.extras_include_cover,
include_manual: settings.extras_include_manual,
},
save_sync: settings.save_sync_config(),
roms_layout: settings.roms_layout_config(),
};
if let Err(e) = persist_user_config(&cfg) {
settings.message = Some((format!("Error saving: {e}"), Color::Red));
} else {
settings.message = Some(("Saved to config.json".to_string(), Color::Green));
self.config.base_url = cfg.base_url.clone();
self.config.download_dir = cfg.download_dir.clone();
self.config.use_https = cfg.use_https;
self.config.extras_defaults = cfg.extras_defaults.clone();
self.config.save_sync = cfg.save_sync.clone();
self.config.roms_layout = cfg.roms_layout.clone();
if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
self.client = new_client;
}
}
}
KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
KeyCode::Char('q') => return Ok(true),
_ => {}
}
Ok(false)
}
fn handle_browse(&mut self, key: &KeyEvent) -> Result<bool> {
use super::screens::browse::ViewMode;
let browse = match &mut self.screen {
AppScreen::Browse(b) => b,
_ => return Ok(false),
};
match key.code {
KeyCode::Up | KeyCode::Char('k') => browse.previous(),
KeyCode::Down | KeyCode::Char('j') => browse.next(),
KeyCode::Left | KeyCode::Char('h') if browse.view_mode == ViewMode::Endpoints => {
browse.switch_view();
}
KeyCode::Right | KeyCode::Char('l') if browse.view_mode == ViewMode::Sections => {
browse.switch_view();
}
KeyCode::Tab => browse.switch_view(),
KeyCode::Enter => {
if browse.view_mode == ViewMode::Endpoints {
if let Some(ep) = browse.get_selected_endpoint() {
self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
}
} else {
browse.switch_view();
}
}
KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
_ => {}
}
Ok(false)
}
async fn handle_execute(&mut self, key: &KeyEvent) -> Result<bool> {
let execute = match &mut self.screen {
AppScreen::Execute(e) => e,
_ => return Ok(false),
};
match key.code {
KeyCode::Tab => execute.next_field(),
KeyCode::BackTab => execute.previous_field(),
KeyCode::Char(c) => execute.add_char_to_focused(c),
KeyCode::Backspace => execute.delete_char_from_focused(),
KeyCode::Enter => {
let endpoint = execute.endpoint.clone();
let query = execute.get_query_params();
let body = if endpoint.has_body && !execute.body_text.is_empty() {
Some(serde_json::from_str(&execute.body_text)?)
} else {
None
};
let resolved_path =
match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
Ok(p) => p,
Err(e) => {
self.screen = AppScreen::Result(ResultScreen::new(
serde_json::json!({ "error": format!("{e}") }),
None,
None,
));
return Ok(false);
}
};
match self
.client
.request_json(&endpoint.method, &resolved_path, &query, body)
.await
{
Ok(result) => {
self.screen = AppScreen::Result(ResultScreen::new(
result,
Some(&endpoint.method),
Some(resolved_path.as_str()),
));
}
Err(e) => {
self.screen = AppScreen::Result(ResultScreen::new(
serde_json::json!({ "error": format!("{e}") }),
None,
None,
));
}
}
}
KeyCode::Esc => {
self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
}
_ => {}
}
Ok(false)
}
fn handle_result(&mut self, key: &KeyEvent) -> Result<bool> {
use super::screens::result::ResultViewMode;
let result = match &mut self.screen {
AppScreen::Result(r) => r,
_ => return Ok(false),
};
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if result.view_mode == ResultViewMode::Json {
result.scroll_up(1);
} else {
result.table_previous();
}
}
KeyCode::Down => {
if result.view_mode == ResultViewMode::Json {
result.scroll_down(1);
} else {
result.table_next();
}
}
KeyCode::Char('j') if result.view_mode == ResultViewMode::Json => {
result.scroll_down(1);
}
KeyCode::PageUp => {
if result.view_mode == ResultViewMode::Table {
result.table_page_up();
} else {
result.scroll_up(10);
}
}
KeyCode::PageDown => {
if result.view_mode == ResultViewMode::Table {
result.table_page_down();
} else {
result.scroll_down(10);
}
}
KeyCode::Char('t') if result.table_row_count > 0 => {
result.switch_view_mode();
}
KeyCode::Enter
if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 =>
{
if let Some(item) = result.get_selected_item_value() {
let prev = std::mem::replace(
&mut self.screen,
AppScreen::MainMenu(MainMenuScreen::new()),
);
if let AppScreen::Result(rs) = prev {
self.screen = AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
}
}
}
KeyCode::Esc => {
result.clear_message();
self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
}
KeyCode::Char('q') => return Ok(true),
_ => {}
}
Ok(false)
}
fn handle_result_detail(&mut self, key: &KeyEvent) -> Result<bool> {
let detail = match &mut self.screen {
AppScreen::ResultDetail(d) => d,
_ => return Ok(false),
};
match key.code {
KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
KeyCode::PageUp => detail.scroll_up(10),
KeyCode::PageDown => detail.scroll_down(10),
KeyCode::Char('o') => detail.open_image_url(),
KeyCode::Esc => {
detail.clear_message();
let prev =
std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
if let AppScreen::ResultDetail(d) = prev {
self.screen = AppScreen::Result(d.parent);
}
}
KeyCode::Char('q') => return Ok(true),
_ => {}
}
Ok(false)
}
fn handle_game_detail(&mut self, key: &KeyEvent) -> Result<bool> {
use super::path_picker::PathPickerEvent;
let detail = match &mut self.screen {
AppScreen::GameDetail(d) => d,
_ => return Ok(false),
};
if let Some(picker) = detail.save_upload_picker.as_mut() {
if key.code == KeyCode::Esc {
detail.save_upload_picker = None;
detail.clear_message();
return Ok(false);
}
match picker.handle_key(key) {
PathPickerEvent::Confirmed(path) => {
let rom_id = detail.rom.id;
detail.save_upload_picker = None;
detail.message = Some("Uploading save...".into());
detail.message_clear_at = None;
let client = self.client.clone();
let tx = self.save_upload_tx.clone();
tokio::spawn(async move {
let result = client
.upload_save_file(rom_id, None, &path)
.await
.map(|_| ())
.map_err(|e| format!("{e:#}"));
let _ = tx.send(SaveUploadDone { rom_id, result });
});
}
PathPickerEvent::None => {}
}
return Ok(false);
}
if !detail.download_completion_acknowledged {
if let Ok(list) = detail.downloads.lock() {
let has_completed = list.iter().any(|j| {
j.rom_id == detail.rom.id
&& matches!(
j.status,
crate::core::download::DownloadStatus::Done
| crate::core::download::DownloadStatus::SkippedAlreadyExists
| crate::core::download::DownloadStatus::Cancelled
| crate::core::download::DownloadStatus::FinalizeFailed(_)
| crate::core::download::DownloadStatus::Error(_)
)
});
let is_still_downloading = list.iter().any(|j| {
j.rom_id == detail.rom.id
&& matches!(j.status, crate::core::download::DownloadStatus::Downloading)
});
if has_completed && !is_still_downloading {
detail.download_completion_acknowledged = true;
}
}
}
let wants_extras = matches!(key.code, KeyCode::Char('e') | KeyCode::Char('E'))
|| (key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::SHIFT));
if wants_extras {
if !detail.has_any_extras() {
detail.message = Some("No extras available for this ROM".to_string());
detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
return Ok(false);
}
let prev =
std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
if let AppScreen::GameDetail(g) = prev {
self.screen = AppScreen::ExtrasPicker(Box::new(ExtrasPickerScreen::new(
g,
&self.config.extras_defaults,
)));
}
return Ok(false);
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => detail.save_selection_previous(),
KeyCode::Down | KeyCode::Char('j') => detail.save_selection_next(),
KeyCode::Char('u') => detail.open_save_upload_picker(),
KeyCode::Char('D') => {
let Some(save) = detail.selected_save().cloned() else {
detail.message = Some("No save selected".into());
detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
return Ok(false);
};
let rom_id = detail.rom.id;
let rom = detail.rom.clone();
let target_dir = match resolve_game_save_dir(&self.config, &rom) {
Ok(path) => path,
Err(err) => {
detail.message = Some(format!(
"Save download blocked: {err:#}. Fix save paths in Settings."
));
detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
return Ok(false);
}
};
detail.message = Some("Downloading save...".into());
detail.message_clear_at = None;
let client = self.client.clone();
let tx = self.save_download_tx.clone();
tokio::spawn(async move {
let result = async {
let bytes = client.download_save_content(save.id, None, None).await?;
tokio::fs::create_dir_all(&target_dir).await?;
let filename = if save.file_name.trim().is_empty() {
format!("save-{}.sav", save.id)
} else {
save.file_name.clone()
};
let target = unique_save_path(&target_dir, &filename);
tokio::fs::write(&target, bytes).await?;
Ok::<PathBuf, anyhow::Error>(target)
}
.await
.map_err(|e| format!("{e:#}"));
let _ = tx.send(SaveDownloadDone { rom_id, result });
});
}
KeyCode::Enter if !detail.has_started_download => {
match self.downloads.start_download(
&detail.rom,
self.client.clone(),
&self.config.roms_layout,
Some(self.config.download_dir.as_str()),
) {
Ok(()) => {
detail.has_started_download = true;
if has_update_or_dlc_extras(&detail.rom, &detail.other_files) {
detail.message = Some(
"Updates/DLC available. Press e to download extras.".to_string(),
);
detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
}
}
Err(err) => {
detail.has_started_download = false;
detail.message = Some(format!(
"Download blocked: {err}. Fix ROMs directory in settings/setup."
));
}
}
}
KeyCode::Char('o') => detail.open_cover(),
KeyCode::Char('m') => detail.toggle_technical(),
KeyCode::Esc => {
detail.clear_message();
let prev =
std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
if let AppScreen::GameDetail(g) = prev {
self.screen = match g.previous {
GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(*l),
GameDetailPrevious::Search(s) => AppScreen::Search(s),
};
}
}
KeyCode::Char('q') => return Ok(true),
_ => {}
}
Ok(false)
}
fn handle_extras_picker(&mut self, key: &KeyEvent) -> Result<bool> {
let picker = match &mut self.screen {
AppScreen::ExtrasPicker(p) => p,
_ => return Ok(false),
};
picker.tick_message();
match key.code {
KeyCode::Esc => {
let prev =
std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
if let AppScreen::ExtrasPicker(p) = prev {
self.screen = AppScreen::GameDetail(p.previous);
}
}
KeyCode::Up | KeyCode::Char('k') => picker.move_up(),
KeyCode::Down | KeyCode::Char('j') => picker.move_down(),
KeyCode::Char(' ') => picker.toggle_current(),
KeyCode::Char('a') | KeyCode::Char('A') => picker.toggle_all(),
KeyCode::Enter => {
if picker.selected_count() == 0 {
picker.show_message(
"Select at least one item (Space to toggle)",
Duration::from_secs(2),
);
return Ok(false);
}
let targets = match picker.build_selected_targets(
&self.config.roms_layout,
Some(self.config.download_dir.as_str()),
) {
Ok(t) => t,
Err(e) => {
picker.show_message(format!("{e:#}"), Duration::from_secs(4));
return Ok(false);
}
};
let rom = picker.rom.clone();
let prev =
std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
if let AppScreen::ExtrasPicker(p) = prev {
match self.downloads.start_extras_download(
&rom,
targets,
self.client.clone(),
Some(self.config.download_dir.as_str()),
) {
Ok(()) => {
self.screen = AppScreen::GameDetail(p.previous);
}
Err(e) => {
let mut detail = *p.previous;
detail.message = Some(format!("Extras: {e:#}"));
detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
self.screen = AppScreen::GameDetail(Box::new(detail));
}
}
}
}
KeyCode::Char('q') => return Ok(true),
_ => {}
}
Ok(false)
}
async fn handle_setup_wizard(&mut self, key: &KeyEvent) -> Result<bool> {
let wizard = match &mut self.screen {
AppScreen::SetupWizard(w) => w,
_ => return Ok(false),
};
if wizard.handle_key(key)? {
self.screen = AppScreen::Settings(Box::new(SettingsScreen::new(
&self.config,
self.server_version.as_deref(),
self.save_sync_compat.clone(),
)));
return Ok(false);
}
if wizard.testing {
let result = wizard.try_connect_and_persist(self.client.verbose()).await;
wizard.testing = false;
match result {
Ok(cfg) => {
let auth_ok = cfg.auth.is_some();
self.config = cfg;
if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
self.client = new_client;
}
let mut settings = SettingsScreen::new(
&self.config,
self.server_version.as_deref(),
self.save_sync_compat.clone(),
);
if auth_ok {
settings.message = Some((
"Authentication updated successfully".to_string(),
Color::Green,
));
} else {
settings.message = Some((
"Saved configuration but credentials could not be loaded from the OS keyring (see logs)."
.to_string(),
Color::Yellow,
));
}
self.screen = AppScreen::Settings(Box::new(settings));
}
Err(e) => {
wizard.error = Some(format!("{e:#}"));
}
}
}
Ok(false)
}
fn render(&mut self, f: &mut ratatui::Frame) {
let area = f.area();
if let Some(ref splash) = self.startup_splash {
connected_splash::render(f, area, splash);
} else {
match &mut self.screen {
AppScreen::MainMenu(menu) => menu.render(f, area),
AppScreen::LibraryBrowse(lib) => {
lib.render(f, area);
if let Some((x, y)) = lib.upload_prompt_cursor(area) {
f.set_cursor_position((x, y));
}
}
AppScreen::Search(search) => {
search.render(f, area);
if let Some((x, y)) = search.cursor_position(area) {
f.set_cursor_position((x, y));
}
}
AppScreen::Settings(settings) => {
settings.render(f, area);
if let Some((x, y)) = settings.cursor_position(area) {
f.set_cursor_position((x, y));
}
}
AppScreen::Browse(browse) => browse.render(f, area),
AppScreen::Execute(execute) => {
execute.render(f, area);
if let Some((x, y)) = execute.cursor_position(area) {
f.set_cursor_position((x, y));
}
}
AppScreen::Result(result) => result.render(f, area),
AppScreen::ResultDetail(detail) => detail.render(f, area),
AppScreen::GameDetail(detail) => detail.render(f, area),
AppScreen::ExtrasPicker(picker) => picker.render(f, area),
AppScreen::Download(d) => d.render(f, area),
AppScreen::SetupWizard(wizard) => {
wizard.render(f, area);
if let Some((x, y)) = wizard.cursor_pos(area) {
f.set_cursor_position((x, y));
}
}
}
if self.show_keyboard_help {
keyboard_help::render_keyboard_help(f, area);
}
}
if let Some(prompt) = &self.startup_update_prompt {
let popup_w = 44;
let popup_h = 10;
let popup_area = ratatui::layout::Rect {
x: area.width.saturating_sub(popup_w) / 2,
y: area.height.saturating_sub(popup_h) / 2,
width: popup_w.min(area.width),
height: popup_h.min(area.height),
};
f.render_widget(ratatui::widgets::Clear, popup_area);
let block = ratatui::widgets::Block::default()
.title(" Update Available ")
.title_alignment(ratatui::layout::Alignment::Center)
.borders(ratatui::widgets::Borders::ALL)
.border_style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan));
if prompt.updating {
let text = vec![
ratatui::text::Line::from(""),
ratatui::text::Line::from("Downloading and installing...")
.alignment(ratatui::layout::Alignment::Center),
ratatui::text::Line::from("Please wait.")
.alignment(ratatui::layout::Alignment::Center),
ratatui::text::Line::from(""),
ratatui::text::Line::from("This may take a few moments.")
.alignment(ratatui::layout::Alignment::Center)
.style(
ratatui::style::Style::default().fg(ratatui::style::Color::DarkGray),
),
];
let paragraph = ratatui::widgets::Paragraph::new(text).block(block);
f.render_widget(paragraph, popup_area);
} else {
let text = vec![
ratatui::text::Line::from(vec![
ratatui::text::Span::raw("Current: "),
ratatui::text::Span::styled(
&prompt.status.current_version,
ratatui::style::Style::default().fg(ratatui::style::Color::DarkGray),
),
])
.alignment(ratatui::layout::Alignment::Center),
ratatui::text::Line::from(vec![
ratatui::text::Span::raw("Latest: "),
ratatui::text::Span::styled(
&prompt.status.latest_version,
ratatui::style::Style::default()
.fg(ratatui::style::Color::Green)
.add_modifier(ratatui::style::Modifier::BOLD),
),
])
.alignment(ratatui::layout::Alignment::Center),
ratatui::text::Line::from(""),
ratatui::text::Line::from("Would you like to update?")
.alignment(ratatui::layout::Alignment::Center),
ratatui::text::Line::from(""),
ratatui::text::Line::from(vec![
ratatui::text::Span::styled(
"Y/Enter",
ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
),
ratatui::text::Span::raw(": Yes (update) "),
ratatui::text::Span::styled(
"N/Esc",
ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
),
ratatui::text::Span::raw(": No (skip)"),
])
.alignment(ratatui::layout::Alignment::Center),
ratatui::text::Line::from(vec![
ratatui::text::Span::styled(
"C",
ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
),
ratatui::text::Span::raw(": View changelog"),
])
.alignment(ratatui::layout::Alignment::Center),
];
let paragraph = ratatui::widgets::Paragraph::new(text).block(block);
f.render_widget(paragraph, popup_area);
}
}
if let Some(ref err) = self.global_error {
let popup_area = ratatui::layout::Rect {
x: area.width.saturating_sub(60) / 2,
y: area.height.saturating_sub(10) / 2,
width: 60.min(area.width),
height: 10.min(area.height),
};
f.render_widget(ratatui::widgets::Clear, popup_area);
let block = ratatui::widgets::Block::default()
.title("Error")
.borders(ratatui::widgets::Borders::ALL)
.style(ratatui::style::Style::default().fg(ratatui::style::Color::Red));
let text = format!("{}\n\nPress Esc to dismiss", err);
let paragraph = ratatui::widgets::Paragraph::new(text)
.block(block)
.wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(paragraph, popup_area);
}
if let Some(ref notice) = self.global_notice {
let popup_area = ratatui::layout::Rect {
x: area.width.saturating_sub(60) / 2,
y: area.height.saturating_sub(10) / 2,
width: 60.min(area.width),
height: 10.min(area.height),
};
f.render_widget(ratatui::widgets::Clear, popup_area);
let block = ratatui::widgets::Block::default()
.title("Notice")
.borders(ratatui::widgets::Borders::ALL)
.style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan));
let text = format!("{notice}\n\nPress Esc to dismiss");
let paragraph = ratatui::widgets::Paragraph::new(text)
.block(block)
.wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(paragraph, popup_area);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, ExtrasDefaults};
use crate::openapi::EndpointRegistry;
use crate::tui::screens::connected_splash::StartupSplash;
use crate::tui::screens::library_browse::LibraryBrowseScreen;
use crate::tui::screens::{GameDetailPrevious, GameDetailScreen, SearchScreen};
use crate::types::Platform;
use crate::update::UpdateStatus;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde_json::json;
fn platform(id: u64, name: &str, rom_count: u64) -> Platform {
serde_json::from_value(json!({
"id": id,
"slug": format!("p{id}"),
"fs_slug": format!("p{id}"),
"rom_count": rom_count,
"name": name,
"igdb_slug": null,
"moby_slug": null,
"hltb_slug": null,
"custom_name": null,
"igdb_id": null,
"sgdb_id": null,
"moby_id": null,
"launchbox_id": null,
"ss_id": null,
"ra_id": null,
"hasheous_id": null,
"tgdb_id": null,
"flashpoint_id": null,
"category": null,
"generation": null,
"family_name": null,
"family_slug": null,
"url": null,
"url_logo": null,
"firmware": [],
"aspect_ratio": null,
"created_at": "",
"updated_at": "",
"fs_size_bytes": 0,
"is_unidentified": false,
"is_identified": true,
"missing_from_fs": false,
"display_name": null
}))
.expect("valid platform fixture")
}
fn app_with_library(platforms: Vec<Platform>) -> App {
let config = Config {
base_url: "http://127.0.0.1:9".into(),
download_dir: "/tmp".into(),
use_https: false,
auth: None,
extras_defaults: ExtrasDefaults::default(),
save_sync: Default::default(),
roms_layout: Default::default(),
};
let client = RommClient::new(&config, false).expect("client");
let mut app = App::new(
client,
config,
EndpointRegistry::default(),
None,
None,
None,
);
app.screen = AppScreen::LibraryBrowse(LibraryBrowseScreen::new(platforms, vec![]));
app
}
fn update_status_fixture() -> UpdateStatus {
UpdateStatus {
current_version: "0.25.0".into(),
latest_version: "0.26.0".into(),
release_tag: "v0.26.0".into(),
should_update: true,
release_url: "https://github.com/patricksmill/romm-cli/releases/tag/v0.26.0".into(),
changelog_url: "https://github.com/patricksmill/romm-cli/blob/main/CHANGELOG.md".into(),
}
}
fn rom_fixture() -> crate::types::Rom {
serde_json::from_value(json!({
"id": 10,
"platform_id": 1,
"platform_slug": null,
"platform_fs_slug": null,
"platform_custom_name": null,
"platform_display_name": null,
"fs_name": "sample.zip",
"fs_name_no_tags": "sample",
"fs_name_no_ext": "sample",
"fs_extension": "zip",
"fs_path": "/sample.zip",
"fs_size_bytes": 100,
"name": "Sample",
"slug": null,
"summary": null,
"path_cover_small": null,
"path_cover_large": null,
"url_cover": null,
"has_manual": false,
"path_manual": null,
"url_manual": null,
"is_unidentified": false,
"is_identified": true
}))
.expect("valid rom fixture")
}
fn empty_rom_list_with_total(total: u64) -> RomList {
RomList {
items: vec![],
total,
limit: 50,
offset: 0,
}
}
#[tokio::test]
async fn list_move_to_zero_rom_selection_does_not_queue_deferred_load() {
let mut app = app_with_library(vec![platform(1, "HasRoms", 5), platform(2, "Empty", 0)]);
assert!(!app
.handle_key_event(&KeyEvent::new(KeyCode::Down, KeyModifiers::empty()))
.await
.expect("key handled"));
assert!(
app.deferred_load_roms.is_none(),
"selection move to zero-rom platform should not queue deferred ROM load"
);
}
#[test]
fn ctrl_c_is_treated_as_force_quit() {
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(App::is_force_quit_key(&ctrl_c));
let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty());
assert!(!App::is_force_quit_key(&plain_c));
}
#[test]
fn primary_rom_load_stale_gen_is_ignored() {
assert!(!super::primary_rom_load_result_is_current(1, 2));
assert!(super::primary_rom_load_result_is_current(3, 3));
}
#[tokio::test]
async fn game_detail_esc_returns_to_previous_library_screen() {
let mut app = app_with_library(vec![platform(1, "NES", 1)]);
let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
let detail = GameDetailScreen::new(
rom_fixture(),
Vec::new(),
GameDetailPrevious::Library(Box::new(previous)),
app.downloads.shared(),
);
app.screen = AppScreen::GameDetail(Box::new(detail));
let quit = app
.handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
.await
.expect("esc handled");
assert!(!quit);
assert!(matches!(app.screen, AppScreen::LibraryBrowse(_)));
}
#[tokio::test]
async fn startup_splash_enter_dismisses_without_quitting_when_update_pending() {
let config = Config {
base_url: "http://127.0.0.1:9".into(),
download_dir: "/tmp".into(),
use_https: false,
auth: None,
extras_defaults: ExtrasDefaults::default(),
save_sync: Default::default(),
roms_layout: Default::default(),
};
let client = RommClient::new(&config, false).expect("client");
let splash = Some(StartupSplash::new(
config.base_url.clone(),
Some("4.0.0".into()),
));
let mut app = App::new(
client,
config,
EndpointRegistry::default(),
Some("4.0.0".into()),
splash,
Some(update_status_fixture()),
);
assert!(app.startup_splash.is_some());
assert!(app.startup_update_prompt.is_some());
let quit = app
.handle_key_event(&KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await
.expect("enter handled");
assert!(!quit, "Enter on connected splash should not quit the app");
assert!(app.startup_splash.is_none(), "splash should be dismissed");
assert!(
app.startup_update_prompt.is_some(),
"update prompt should remain after splash dismiss"
);
}
#[tokio::test]
async fn startup_update_prompt_enter_starts_update_without_quitting() {
let config = Config {
base_url: "http://127.0.0.1:9".into(),
download_dir: "/tmp".into(),
use_https: false,
auth: None,
extras_defaults: ExtrasDefaults::default(),
save_sync: Default::default(),
roms_layout: Default::default(),
};
let client = RommClient::new(&config, false).expect("client");
let mut app = App::new(
client,
config,
EndpointRegistry::default(),
None,
None,
Some(update_status_fixture()),
);
let quit = app
.handle_key_event(&KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await
.expect("enter handled");
assert!(!quit, "Enter to confirm update should not quit the app");
assert!(
app.startup_update_prompt
.as_ref()
.is_some_and(|p| p.updating),
"update should be in progress"
);
}
#[tokio::test]
async fn startup_update_prompt_esc_skips_without_quitting() {
let config = Config {
base_url: "http://127.0.0.1:9".into(),
download_dir: "/tmp".into(),
use_https: false,
auth: None,
extras_defaults: ExtrasDefaults::default(),
save_sync: Default::default(),
roms_layout: Default::default(),
};
let client = RommClient::new(&config, false).expect("client");
let mut app = App::new(
client,
config,
EndpointRegistry::default(),
None,
None,
Some(update_status_fixture()),
);
let quit = app
.handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
.await
.expect("esc handled");
assert!(!quit);
assert!(app.startup_update_prompt.is_none());
}
#[tokio::test]
async fn startup_update_prompt_blocks_global_d_shortcut() {
let config = Config {
base_url: "http://127.0.0.1:9".into(),
download_dir: "/tmp".into(),
use_https: false,
auth: None,
extras_defaults: ExtrasDefaults::default(),
save_sync: Default::default(),
roms_layout: Default::default(),
};
let client = RommClient::new(&config, false).expect("client");
let app = App::new(
client,
config,
EndpointRegistry::default(),
None,
None,
Some(update_status_fixture()),
);
assert!(app.blocks_global_d_shortcut());
assert!(app.blocks_global_chord_shortcuts());
}
#[tokio::test]
async fn startup_update_prompt_skip_closes_prompt() {
let config = Config {
base_url: "http://127.0.0.1:9".into(),
download_dir: "/tmp".into(),
use_https: false,
auth: None,
extras_defaults: ExtrasDefaults::default(),
save_sync: Default::default(),
roms_layout: Default::default(),
};
let client = RommClient::new(&config, false).expect("client");
let mut app = App::new(
client,
config,
EndpointRegistry::default(),
None,
None,
Some(update_status_fixture()),
);
assert!(app.startup_update_prompt.is_some());
let quit = app
.handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
.await
.expect("esc handled");
assert!(!quit);
assert!(app.startup_update_prompt.is_none());
}
#[test]
fn search_batch_updates_results_without_stopping_loading() {
let config = Config {
base_url: "http://127.0.0.1:9".into(),
download_dir: "/tmp".into(),
use_https: false,
auth: None,
extras_defaults: ExtrasDefaults::default(),
save_sync: Default::default(),
roms_layout: Default::default(),
};
let client = RommClient::new(&config, false).expect("client");
let mut app = App::new(
client,
config,
EndpointRegistry::default(),
None,
None,
None,
);
let mut search = SearchScreen::new();
search.loading = true;
app.screen = AppScreen::Search(search);
app.search_load_tx
.send(SearchLoadDone {
query: "zelda".to_string(),
event: SearchLoadEvent::Batch(empty_rom_list_with_total(120)),
})
.expect("send batch");
app.poll_search_load_results();
match &app.screen {
AppScreen::Search(search) => {
assert!(search.loading, "loading should continue after batch");
assert!(search.results.is_some(), "batch should populate results");
assert_eq!(search.last_searched_query.as_deref(), Some("zelda"));
}
_ => panic!("expected search screen"),
}
}
#[test]
fn search_complete_event_stops_loading() {
let config = Config {
base_url: "http://127.0.0.1:9".into(),
download_dir: "/tmp".into(),
use_https: false,
auth: None,
extras_defaults: ExtrasDefaults::default(),
save_sync: Default::default(),
roms_layout: Default::default(),
};
let client = RommClient::new(&config, false).expect("client");
let mut app = App::new(
client,
config,
EndpointRegistry::default(),
None,
None,
None,
);
let mut search = SearchScreen::new();
search.loading = true;
app.screen = AppScreen::Search(search);
app.search_load_tx
.send(SearchLoadDone {
query: "zelda".to_string(),
event: SearchLoadEvent::Complete,
})
.expect("send complete");
app.poll_search_load_results();
match &app.screen {
AppScreen::Search(search) => {
assert!(!search.loading, "loading should stop after completion");
}
_ => panic!("expected search screen"),
}
}
#[tokio::test]
async fn pressing_e_with_no_extras_shows_toast_not_picker() {
let mut app = app_with_library(vec![platform(1, "NES", 1)]);
let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
let detail = GameDetailScreen::new(
rom_fixture(),
Vec::new(),
GameDetailPrevious::Library(Box::new(previous)),
app.downloads.shared(),
);
app.screen = AppScreen::GameDetail(Box::new(detail));
app.handle_key_event(&KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()))
.await
.expect("handled");
match &app.screen {
AppScreen::GameDetail(d) => {
assert!(
d.message
.as_deref()
.is_some_and(|m| m.contains("No extras")),
"expected toast, got {:?}",
d.message
);
}
_ => panic!("expected game detail"),
}
}
#[tokio::test]
async fn pressing_e_with_extras_opens_picker() {
let mut rom = rom_fixture();
rom.url_cover = Some("https://example.com/c.png".into());
let mut app = app_with_library(vec![platform(1, "NES", 1)]);
let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
let detail = GameDetailScreen::new(
rom,
Vec::new(),
GameDetailPrevious::Library(Box::new(previous)),
app.downloads.shared(),
);
app.screen = AppScreen::GameDetail(Box::new(detail));
app.handle_key_event(&KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()))
.await
.expect("handled");
assert!(
matches!(app.screen, AppScreen::ExtrasPicker(_)),
"expected extras picker"
);
}
}