mod background;
pub(crate) mod event;
mod handlers;
mod render;
mod rom_load;
mod run;
mod update;
#[cfg(test)]
mod tests;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use romm_api::types::RomList;
use std::collections::{HashMap, HashSet, VecDeque};
use romm_api::client::RommClient;
use romm_api::config::{auth_for_persist_merge, Config};
use romm_api::core::cache::{RomCache, RomCacheKey};
use romm_api::core::download::DownloadManager;
use romm_api::core::library_scan::ScanCacheInvalidate;
use romm_api::endpoints::roms::GetRoms;
use romm_api::feature_compat::{
AchievementsCompatibility, MetadataEditCompatibility, SaveSyncCompatibility,
};
use romm_api::update::UpdateStatus;
use super::screens::connected_splash::StartupSplash;
use super::screens::{
DownloadScreen, GameDetailScreen, LibraryBrowseScreen, MetadataMatchScreen, SearchScreen,
SettingsScreen,
};
use super::theme::resolve_theme_or_default;
use ratatui_themekit::Theme;
use background::types::{
AchievementLoadDone, CollectionPrefetchDone, CoverLoadDone, DeferredLoadRoms, DeviceListDone,
LibraryMetadataRefreshDone, LibraryUploadComplete, MetadataApplyDone, MetadataSearchDone,
PlatformListDone, RomLoadDone, SaveDownloadDone, SaveListDone, SaveScreenshotLoadDone,
SaveUploadDone, SearchLoadDone, StartupUpdatePrompt, SyncPushPullDone,
};
pub enum AppScreen {
LibraryBrowse(Box<LibraryBrowseScreen>),
Search(SearchScreen),
Settings(Box<SettingsScreen>),
GameDetail(Box<GameDetailScreen>),
MetadataMatch(Box<MetadataMatchScreen>),
Download(DownloadScreen),
SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
}
pub struct App {
pub screen: AppScreen,
client: RommClient,
config: Config,
config_reset_pending_restart: bool,
server_version: Option<String>,
save_sync_compat: SaveSyncCompatibility,
metadata_edit_compat: MetadataEditCompatibility,
achievements_compat: AchievementsCompatibility,
rom_cache: RomCache,
rom_partials: HashMap<RomCacheKey, (u64, RomList)>,
downloads: DownloadManager,
screen_before_download: Option<AppScreen>,
screen_before_search: Option<AppScreen>,
screen_before_settings: 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,
keyboard_help_scroll: u16,
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_gen: u64,
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<()>>,
save_screenshot_rx: tokio::sync::mpsc::UnboundedReceiver<SaveScreenshotLoadDone>,
save_screenshot_tx: tokio::sync::mpsc::UnboundedSender<SaveScreenshotLoadDone>,
save_screenshot_task: Option<tokio::task::JoinHandle<()>>,
library_scan_rx:
Option<tokio::sync::mpsc::UnboundedReceiver<Result<(), romm_api::error::RommError>>>,
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, romm_api::error::RommError>,
>,
>,
save_list_rx: tokio::sync::mpsc::UnboundedReceiver<SaveListDone>,
save_list_tx: tokio::sync::mpsc::UnboundedSender<SaveListDone>,
achievement_load_gen: u64,
achievement_load_rx: tokio::sync::mpsc::UnboundedReceiver<AchievementLoadDone>,
achievement_load_tx: tokio::sync::mpsc::UnboundedSender<AchievementLoadDone>,
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>,
metadata_search_gen: u64,
metadata_search_rx: tokio::sync::mpsc::UnboundedReceiver<MetadataSearchDone>,
metadata_search_tx: tokio::sync::mpsc::UnboundedSender<MetadataSearchDone>,
metadata_apply_inflight_roms: HashSet<u64>,
metadata_apply_rx: tokio::sync::mpsc::UnboundedReceiver<MetadataApplyDone>,
metadata_apply_tx: tokio::sync::mpsc::UnboundedSender<MetadataApplyDone>,
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>,
startup_bootstrap_rx:
Option<tokio::sync::mpsc::UnboundedReceiver<background::types::StartupBootstrapDone>>,
theme: Box<dyn Theme>,
}
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()
|| self.typing_in_text_field()
}
fn typing_in_text_field(&self) -> bool {
match &self.screen {
AppScreen::Search(_) | AppScreen::SetupWizard(_) => true,
AppScreen::Settings(s) => {
s.editing
|| s.path_picker.is_some()
|| s.console_path_picker.is_some()
|| s.console_picker_open
|| s.device_picker_open
|| s.confirm.is_some()
}
AppScreen::LibraryBrowse(lib) => {
lib.any_upload_prompt_open() || lib.any_search_bar_open()
}
AppScreen::MetadataMatch(m) => m.is_query_input(),
_ => false,
}
}
fn blocks_global_d_shortcut(&self) -> bool {
self.typing_in_text_field()
|| self.library_upload_inflight
|| self.startup_splash.is_some()
|| self.startup_update_prompt.is_some()
|| self.global_error.is_some()
|| self.global_notice.is_some()
}
fn blocks_global_slash_shortcut(&self) -> bool {
self.typing_in_text_field()
|| self.startup_splash.is_some()
|| self.startup_update_prompt.is_some()
|| self.global_error.is_some()
|| self.global_notice.is_some()
}
fn blocks_global_comma_shortcut(&self) -> bool {
self.typing_in_text_field()
|| self.startup_splash.is_some()
|| self.startup_update_prompt.is_some()
|| self.global_error.is_some()
|| self.global_notice.is_some()
}
fn allows_global_question_help(&self) -> bool {
!self.typing_in_text_field()
&& self.startup_splash.is_none()
&& self.startup_update_prompt.is_none()
}
pub(crate) 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'))
}
#[allow(clippy::too_many_arguments)]
pub fn new(
client: RommClient,
config: Config,
save_sync_compat: SaveSyncCompatibility,
metadata_edit_compat: MetadataEditCompatibility,
achievements_compat: AchievementsCompatibility,
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_screenshot_tx, save_screenshot_rx) = tokio::sync::mpsc::unbounded_channel();
let (save_list_tx, save_list_rx) = tokio::sync::mpsc::unbounded_channel();
let (achievement_load_tx, achievement_load_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 (metadata_search_tx, metadata_search_rx) = tokio::sync::mpsc::unbounded_channel();
let (metadata_apply_tx, metadata_apply_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 theme = resolve_theme_or_default(&config.theme);
Self {
screen: AppScreen::LibraryBrowse(Box::new(LibraryBrowseScreen::new(
Vec::new(),
Vec::new(),
config.tui_layout.library_left_panel_percent,
))),
client,
config,
config_reset_pending_restart: false,
server_version,
save_sync_compat,
metadata_edit_compat,
achievements_compat,
rom_cache: RomCache::load(),
rom_partials: HashMap::new(),
downloads: DownloadManager::new(),
screen_before_download: None,
screen_before_search: None,
screen_before_settings: None,
deferred_load_roms: None,
startup_splash,
global_error: None,
global_notice: None,
show_keyboard_help: false,
keyboard_help_scroll: 0,
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_gen: 0,
search_load_rx,
search_load_tx,
search_load_task: None,
cover_load_rx,
cover_load_tx,
cover_load_task: None,
save_screenshot_rx,
save_screenshot_tx,
save_screenshot_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,
achievement_load_gen: 0,
achievement_load_rx,
achievement_load_tx,
save_upload_rx,
save_upload_tx,
save_download_rx,
save_download_tx,
metadata_search_gen: 0,
metadata_search_rx,
metadata_search_tx,
metadata_apply_inflight_roms: HashSet::new(),
metadata_apply_rx,
metadata_apply_tx,
device_list_rx,
device_list_tx,
platform_list_rx,
platform_list_tx,
sync_push_pull_rx,
sync_push_pull_tx,
startup_bootstrap_rx: None,
theme,
}
}
pub fn set_error(&mut self, err: romm_api::error::RommError) {
self.global_error = Some(romm_api::error::user_message(&err));
}
pub(in crate::tui::app) fn apply_saved_theme(&mut self) {
self.theme = resolve_theme_or_default(&self.config.theme);
}
pub(in crate::tui::app) fn persist_tui_layout(&self) {
if self.config_reset_pending_restart {
return;
}
let mut config = self.config.clone();
config.auth = auth_for_persist_merge(config.auth);
if let Err(e) = romm_api::config::persist_user_config(&config) {
tracing::warn!("failed to persist TUI panel layout: {e:#}");
}
}
pub(in crate::tui::app) fn persist_library_selection_if_changed(&mut self) {
let AppScreen::LibraryBrowse(ref lib) = self.screen else {
return;
};
let Some(next) = lib.selection_state() else {
return;
};
if self.config.tui_layout.library_selection.as_ref() == Some(&next) {
return;
}
self.config.tui_layout.library_selection = Some(next);
self.persist_tui_layout();
}
#[cfg(test)]
pub(crate) fn theme_id(&self) -> &str {
self.theme.id()
}
pub async fn handle_key_event(&mut self, key: &KeyEvent) -> Result<bool> {
for action in event::map_key_to_actions(self, key) {
if self.update(action).await? {
return Ok(true);
}
}
Ok(false)
}
}