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