Skip to main content

romm_cli/tui/
mod.rs

1//! Terminal UI module.
2//!
3//! This module contains all ratatui / crossterm code and is responsible
4//! purely for presentation and interaction. It talks to the rest of the
5//! application through:
6//! - `RommClient` (HTTP / data access),
7//! - `core::cache::RomCache` (disk-backed ROM cache), and
8//! - `core::download::DownloadManager` (background ROM downloads).
9//!
10//! Keeping those \"service\" types UI-agnostic makes it easy to add other
11//! frontends (e.g. a GUI) reusing the same core logic.
12
13pub mod app;
14pub mod keyboard_help;
15pub mod openapi_sync;
16pub mod path_picker;
17pub mod runtime;
18pub mod screens;
19pub mod theme;
20pub mod utils;
21
22use anyhow::Result;
23use std::time::Duration;
24
25use crate::client::RommClient;
26use crate::config::{openapi_cache_path, should_check_updates, Config};
27use crate::feature_compat::save_sync_compatibility;
28
29use self::app::App;
30use self::openapi_sync::sync_openapi_registry;
31use self::screens::connected_splash::StartupSplash;
32use self::screens::setup_wizard::SetupWizard;
33
34fn install_panic_hook() {
35    let original_hook = std::panic::take_hook();
36    std::panic::set_hook(Box::new(move |panic| {
37        let _ = crossterm::terminal::disable_raw_mode();
38        let _ = crossterm::execute!(
39            std::io::stdout(),
40            crossterm::terminal::LeaveAlternateScreen,
41            crossterm::event::DisableMouseCapture
42        );
43        original_hook(panic);
44    }));
45}
46
47fn startup_splash_for(
48    from_setup_wizard: bool,
49    config: &Config,
50    server_version: &Option<String>,
51) -> Option<StartupSplash> {
52    if from_setup_wizard {
53        return Some(StartupSplash::new(
54            config.base_url.clone(),
55            server_version.clone(),
56        ));
57    }
58    if server_version.is_some() {
59        return Some(StartupSplash::new(
60            config.base_url.clone(),
61            server_version.clone(),
62        ));
63    }
64    None
65}
66
67/// Connected splash is skipped when an update prompt will show — avoids overlapping modals.
68fn startup_splash_for_launch(
69    from_setup_wizard: bool,
70    config: &Config,
71    server_version: &Option<String>,
72    update_pending: bool,
73) -> Option<StartupSplash> {
74    if update_pending {
75        return None;
76    }
77    startup_splash_for(from_setup_wizard, config, server_version)
78}
79
80async fn run_started(
81    client: RommClient,
82    config: Config,
83    from_setup_wizard: bool,
84    mock_update: bool,
85) -> Result<()> {
86    install_panic_hook();
87    let cache_path = openapi_cache_path()?;
88    let (registry, server_version) = sync_openapi_registry(&client, &cache_path).await?;
89
90    let startup_update = if mock_update {
91        Some(crate::update::UpdateStatus {
92            current_version: format!("{} (dev)", env!("CARGO_PKG_VERSION")),
93            latest_version: "9.9.9-mock".into(),
94            release_tag: "v9.9.9-mock".into(),
95            should_update: true,
96            release_url: "https://github.com/patricksmill/romm-cli".into(),
97            changelog_url: crate::update::changelog_url().to_string(),
98        })
99    } else if should_check_updates() {
100        match tokio::time::timeout(Duration::from_secs(2), crate::update::check_for_update()).await
101        {
102            Ok(Ok(status)) if status.should_update => Some(status),
103            _ => None,
104        }
105    } else {
106        None
107    };
108
109    let splash = startup_splash_for_launch(
110        from_setup_wizard,
111        &config,
112        &server_version,
113        startup_update.is_some(),
114    );
115    let save_sync_compat = save_sync_compatibility(&registry);
116    let mut app = App::new(
117        client,
118        config,
119        save_sync_compat,
120        server_version,
121        splash,
122        startup_update,
123    );
124    app.run().await
125}
126
127/// Launch the TUI when the caller already has a [`RommClient`] and [`Config`].
128pub async fn run(client: RommClient, config: Config, mock_update: bool) -> Result<()> {
129    run_started(client, config, false, mock_update).await
130}
131
132/// Load config, run first-time setup in the terminal if `API_BASE_URL` is missing, then start the TUI.
133pub async fn run_interactive(verbose: bool, mock_update: bool) -> Result<()> {
134    let (from_wizard, config) = match crate::config::load_config() {
135        Ok(c) => (false, c),
136        Err(_) => (true, SetupWizard::new().run(verbose).await?),
137    };
138    let client = RommClient::new(&config, verbose)?;
139    run_started(client, config, from_wizard, mock_update).await
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::config::{default_theme_id, Config, ExtrasDefaults};
146
147    fn test_config() -> Config {
148        Config {
149            base_url: "http://127.0.0.1:9".into(),
150            download_dir: "/tmp".into(),
151            use_https: false,
152            auth: None,
153            extras_defaults: ExtrasDefaults::default(),
154            save_sync: Default::default(),
155            roms_layout: Default::default(),
156            theme: default_theme_id(),
157            tui_layout: Default::default(),
158        }
159    }
160
161    #[test]
162    fn startup_splash_for_launch_skips_splash_when_update_pending() {
163        let config = test_config();
164        let version = Some("4.0.0".into());
165        assert!(startup_splash_for_launch(false, &config, &version, true).is_none());
166    }
167
168    #[test]
169    fn startup_splash_for_launch_shows_splash_when_no_update() {
170        let config = test_config();
171        let version = Some("4.0.0".into());
172        assert!(startup_splash_for_launch(false, &config, &version, false).is_some());
173    }
174}