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 text_search;
20pub mod theme;
21pub mod utils;
22
23use anyhow::Result;
24use std::time::Duration;
25
26use crate::client::RommClient;
27use crate::config::{openapi_cache_path, should_check_updates, Config};
28use crate::feature_compat::save_sync_compatibility;
29
30use self::app::App;
31use self::openapi_sync::sync_openapi_registry;
32use self::screens::connected_splash::StartupSplash;
33use self::screens::setup_wizard::SetupWizard;
34
35fn install_panic_hook() {
36    let original_hook = std::panic::take_hook();
37    std::panic::set_hook(Box::new(move |panic| {
38        let _ = crossterm::terminal::disable_raw_mode();
39        let _ = crossterm::execute!(
40            std::io::stdout(),
41            crossterm::terminal::LeaveAlternateScreen,
42            crossterm::event::DisableMouseCapture
43        );
44        original_hook(panic);
45    }));
46}
47
48fn startup_splash_for(
49    from_setup_wizard: bool,
50    config: &Config,
51    server_version: &Option<String>,
52) -> Option<StartupSplash> {
53    if from_setup_wizard {
54        return Some(StartupSplash::new(
55            config.base_url.clone(),
56            server_version.clone(),
57        ));
58    }
59    if server_version.is_some() {
60        return Some(StartupSplash::new(
61            config.base_url.clone(),
62            server_version.clone(),
63        ));
64    }
65    None
66}
67
68/// Connected splash is skipped when an update prompt will show — avoids overlapping modals.
69fn startup_splash_for_launch(
70    from_setup_wizard: bool,
71    config: &Config,
72    server_version: &Option<String>,
73    update_pending: bool,
74) -> Option<StartupSplash> {
75    if update_pending {
76        return None;
77    }
78    startup_splash_for(from_setup_wizard, config, server_version)
79}
80
81async fn run_started(
82    client: RommClient,
83    config: Config,
84    from_setup_wizard: bool,
85    mock_update: bool,
86) -> Result<()> {
87    install_panic_hook();
88    let cache_path = openapi_cache_path()?;
89    let (registry, server_version) = sync_openapi_registry(&client, &cache_path).await?;
90
91    let startup_update = if mock_update {
92        Some(crate::update::UpdateStatus {
93            current_version: format!("{} (dev)", env!("CARGO_PKG_VERSION")),
94            latest_version: "9.9.9-mock".into(),
95            release_tag: "v9.9.9-mock".into(),
96            should_update: true,
97            release_url: "https://github.com/patricksmill/romm-cli".into(),
98            changelog_url: crate::update::changelog_url().to_string(),
99        })
100    } else if should_check_updates() {
101        match tokio::time::timeout(Duration::from_secs(2), crate::update::check_for_update()).await
102        {
103            Ok(Ok(status)) if status.should_update => Some(status),
104            _ => None,
105        }
106    } else {
107        None
108    };
109
110    let splash = startup_splash_for_launch(
111        from_setup_wizard,
112        &config,
113        &server_version,
114        startup_update.is_some(),
115    );
116    let save_sync_compat = save_sync_compatibility(&registry);
117    let mut app = App::new(
118        client,
119        config,
120        save_sync_compat,
121        server_version,
122        splash,
123        startup_update,
124    );
125    app.run().await
126}
127
128/// Launch the TUI when the caller already has a [`RommClient`] and [`Config`].
129pub async fn run(client: RommClient, config: Config, mock_update: bool) -> Result<()> {
130    run_started(client, config, false, mock_update).await
131}
132
133/// Load config, run first-time setup in the terminal if `API_BASE_URL` is missing, then start the TUI.
134pub async fn run_interactive(verbose: bool, mock_update: bool) -> Result<()> {
135    let (from_wizard, config) = match crate::config::load_config() {
136        Ok(c) => (false, c),
137        Err(_) => (true, SetupWizard::new().run(verbose).await?),
138    };
139    let client = RommClient::new(&config, verbose)?;
140    run_started(client, config, from_wizard, mock_update).await
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::config::{default_theme_id, Config, ExtrasDefaults};
147
148    fn test_config() -> Config {
149        Config {
150            base_url: "http://127.0.0.1:9".into(),
151            download_dir: "/tmp".into(),
152            use_https: false,
153            auth: None,
154            extras_defaults: ExtrasDefaults::default(),
155            save_sync: Default::default(),
156            roms_layout: Default::default(),
157            theme: default_theme_id(),
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}