romm-cli 0.39.0

Rust-based CLI and TUI for the ROMM API
Documentation
//! Setup wizard rendering.

use ratatui::style::Modifier;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{List, ListItem, ListState, Paragraph};

use crate::config::normalize_romm_origin;
use crate::tui::theme::RommStyles;

use super::layout::{wizard_footer_text, wizard_layout};
use super::types::{AuthKind, SetupWizard, Step};

impl SetupWizard {
    pub fn render(
        &mut self,
        f: &mut ratatui::Frame,
        area: ratatui::layout::Rect,
        styles: &RommStyles,
    ) {
        let title = match self.step {
            Step::Url => "Step 1/6 — RomM server URL",
            Step::Https => "Step 2/6 — Secure connection",
            Step::Download => "Step 3/6 — ROMs directory",
            Step::CustomConsolePaths => "Step 4/6 — Custom console paths",
            Step::AuthMenu => "Step 5/6 — Authentication",
            Step::BasicUser | Step::BasicPass => "Step 6/6 — Basic auth",
            Step::Bearer => "Step 6/6 — API Token",
            Step::ApiHeader | Step::ApiKey => "Step 6/6 — API key",
            Step::PairingCode => "Step 6/6 — Pair with Web UI",
            Step::Summary => "Review & connect",
        };

        let main = wizard_layout(area, self.step);

        match self.step {
            Step::Url => {
                let intro = Text::from(vec![
                    Line::from("First-time setup: point the CLI at your RomM server."),
                    Line::from(Span::styled(
                        "Example: https://romm.example.com or http://192.168.1.10:8080",
                        styles.muted(),
                    )),
                    Line::from(Span::styled(
                        "Same origin as in your browser (no trailing /api).",
                        styles.muted(),
                    )),
                ]);
                f.render_widget(Paragraph::new(intro), main[0]);
            }
            step => {
                let hint_top = match step {
                    Step::Https => "HTTPS ensures your credentials are encrypted in transit. Only disable if necessary.",
                    Step::Download => "Choose a directory to save ROMs. Make sure you have write permissions.",
                    Step::CustomConsolePaths => "Consoles on other drives can use custom paths. Map them in Settings → ROMs → Console paths after setup.",
                    Step::AuthMenu => "Select how you authenticate with the RomM server.",
                    Step::BasicUser | Step::BasicPass => "Enter the exact same username and password you use to log into the RomM web UI.",
                    Step::Bearer => "To get an API token, go to the RomM web UI -> client API Tokens -> generate a new token.",
                    Step::PairingCode => "Login to RomM in your browser, go to your profile menu -> Client API Tokens, and create a new token.",
                    Step::ApiHeader | Step::ApiKey => "Use this only if you have a custom reverse proxy setup requiring specific headers (e.g., X-Api-Key). Otherwise, use API Token.",
                    Step::Summary => "Review your configuration before testing the connection.",
                    Step::Url => "",
                };
                let p = Paragraph::new(hint_top).style(styles.muted());
                f.render_widget(p, main[0]);
            }
        }

        match self.step {
            Step::Url => {
                let line = format!(
                    "{}",
                    self.url.chars().take(self.url_cursor).collect::<String>()
                );
                let rest: String = self.url.chars().skip(self.url_cursor).collect();
                let text = format!("{line}{rest}");
                let block = styles.panel_block(title);
                let p = Paragraph::new(text).style(styles.text()).block(block);
                f.render_widget(p, main[1]);
            }
            Step::Https => {
                let text = if self.use_https {
                    "[X] Use HTTPS (Recommended)"
                } else {
                    "[ ] Use HTTPS (Insecure)"
                };
                let block = styles.panel_block(title);
                let p = Paragraph::new(format!("\n  {}\n\n  Space: toggle   Enter: next", text))
                    .style(styles.text())
                    .block(block);
                f.render_widget(p, main[1]);
            }
            Step::Download => {
                self.download_picker.render(f, main[1], title, "", styles);
            }
            Step::CustomConsolePaths => {
                let body = "By default each console uses a subfolder under your ROMs directory.\n\nConsoles on other drives (e.g. Switch on D:, NES on E:) can use custom paths.\nMap them in Settings → ROMs → Console paths after setup.\n\nEnter: next";
                let block = styles.panel_block(title);
                f.render_widget(
                    Paragraph::new(body).style(styles.text()).block(block),
                    main[1],
                );
            }
            Step::AuthMenu => {
                let items: Vec<ListItem> = Self::auth_labels()
                    .iter()
                    .map(|s| ListItem::new(*s).style(styles.text()))
                    .collect();
                let mut state = ListState::default();
                state.select(Some(self.auth_menu_selected));
                let list = List::new(items)
                    .block(styles.panel_block(title))
                    .highlight_style(styles.selection().add_modifier(Modifier::BOLD))
                    .highlight_symbol(">> ");
                f.render_stateful_widget(list, main[1], &mut state);
            }
            Step::BasicUser | Step::BasicPass => {
                let user_line = if self.step == Step::BasicUser {
                    format!(
                        "{}{}",
                        self.username
                            .chars()
                            .take(self.user_cursor)
                            .collect::<String>(),
                        self.username
                            .chars()
                            .skip(self.user_cursor)
                            .collect::<String>()
                    )
                } else {
                    self.username.clone()
                };
                let pass_display: String = "".repeat(self.password.len());
                let kr_hint = if self.step == Step::BasicPass
                    && self.password.is_empty()
                    && self.reuse_keyring_password
                {
                    "\n\n(stored in OS keyring — leave blank to keep, or type a new password)"
                } else {
                    ""
                };
                let block = styles.panel_block(title);
                let body = format!(
                    "Username\n{user_line}\n\nPassword (hidden)\n{pass_display}{kr_hint}\n\nTab: switch field"
                );
                let p = Paragraph::new(body).style(styles.text()).block(block);
                f.render_widget(p, main[1]);
            }
            Step::Bearer => {
                let line = format!(
                    "{}{}",
                    self.bearer_token
                        .chars()
                        .take(self.bearer_cursor)
                        .collect::<String>(),
                    self.bearer_token
                        .chars()
                        .skip(self.bearer_cursor)
                        .collect::<String>()
                );
                let mut bearer_text = Text::from(vec![
                    Line::from("API Token"),
                    Line::from(""),
                    Line::from(line),
                ]);
                if self.bearer_token.is_empty() && self.reuse_keyring_bearer {
                    bearer_text.push_line(Line::from(""));
                    bearer_text.push_line(Line::from(Span::styled(
                        "Token stored in OS keyring — leave blank to keep, or type a new token.",
                        styles.muted(),
                    )));
                }
                let block = styles.panel_block(title);
                let p = Paragraph::new(bearer_text)
                    .style(styles.text())
                    .block(block);
                f.render_widget(p, main[1]);
            }
            Step::PairingCode => {
                let line = format!(
                    "{}{}",
                    self.pairing_code
                        .chars()
                        .take(self.pairing_cursor)
                        .collect::<String>(),
                    self.pairing_code
                        .chars()
                        .skip(self.pairing_cursor)
                        .collect::<String>()
                );
                let body = format!("Enter the 8-character code provided.\n\n{line}");
                let block = styles.panel_block(title);
                let p = Paragraph::new(body).style(styles.text()).block(block);
                f.render_widget(p, main[1]);
            }
            Step::ApiHeader | Step::ApiKey => {
                let header_line = if self.step == Step::ApiHeader {
                    format!(
                        "{}{}",
                        self.api_header
                            .chars()
                            .take(self.header_cursor)
                            .collect::<String>(),
                        self.api_header
                            .chars()
                            .skip(self.header_cursor)
                            .collect::<String>()
                    )
                } else {
                    self.api_header.clone()
                };
                let key_line = "".repeat(self.api_key.len());
                let kr_hint = if self.step == Step::ApiKey
                    && self.api_key.is_empty()
                    && self.reuse_keyring_api_key
                {
                    "\n\n(stored in OS keyring — leave blank to keep, or type a new key)"
                } else {
                    ""
                };
                let body = format!(
                    "Header name\n{header_line}\n\nKey (hidden)\n{key_line}{kr_hint}\n\nTab: switch field"
                );
                let block = styles.panel_block(title);
                let p = Paragraph::new(body).style(styles.text()).block(block);
                f.render_widget(p, main[1]);
            }
            Step::Summary => {
                let url_line = normalize_romm_origin(self.url.trim());
                let auth_desc = match self.auth_kind {
                    AuthKind::Basic => "Basic",
                    AuthKind::Bearer => "API Token",
                    AuthKind::ApiKey => "API key header",
                    AuthKind::Pairing => {
                        if self.pairing_code.trim().is_empty() {
                            "Pair with Web UI (no code yet)"
                        } else {
                            "Pair with Web UI (code entered)"
                        }
                    }
                };
                let mut lines = vec![
                    format!("Server: {url_line}"),
                    format!("ROMs Dir: {}", self.download_picker.path_trimmed()),
                    "Layout: base subfolder per console (custom paths in Settings → ROMs)"
                        .to_string(),
                    format!("Use HTTPS: {}", if self.use_https { "Yes" } else { "No" }),
                    format!("Auth: {auth_desc}"),
                    String::new(),
                ];
                if self.testing {
                    lines.push("Connecting to server…".to_string());
                } else if let Some(ref e) = self.error {
                    lines.push(format!("Last error: {e}"));
                } else {
                    lines.push("Enter: test connection and save   Esc: quit".to_string());
                }
                let block = styles.panel_block(title);
                let p = Paragraph::new(lines.join("\n"))
                    .style(styles.text())
                    .block(block);
                f.render_widget(p, main[1]);
            }
        }

        let footer_keys = match self.step {
            Step::Url => "Enter: next   Backspace: delete   Esc: quit",
            Step::Https => "Space: toggle   Enter: next   Esc: quit",
            Step::Download => "Ctrl+Enter: next (creates path)   ↑ list top: path bar   ↓/↑: list focus   Tab: path/list   Esc: quit",
            Step::CustomConsolePaths => "Enter: next   Esc: quit",
            Step::AuthMenu => "↑/↓: choose   Enter: next   Esc: quit",
            Step::BasicUser | Step::BasicPass => {
                "Type text   Tab: switch field   Enter: next step   Esc: quit"
            }
            Step::Bearer => "Enter: next step   Esc: quit",
            Step::PairingCode => "Enter: next step   Esc: quit",
            Step::ApiHeader | Step::ApiKey => "Tab: switch field   Enter: next step   Esc: quit",
            Step::Summary => {
                if self.testing {
                    "Please wait…"
                } else {
                    "Enter: connect & save"
                }
            }
        };
        let p = Paragraph::new(wizard_footer_text(footer_keys, styles))
            .block(styles.panel_block_untitled());
        f.render_widget(p, main[2]);
    }

    pub fn cursor_pos(&self, area: ratatui::layout::Rect) -> Option<(u16, u16)> {
        let main = wizard_layout(area, self.step);
        let inner = main[1];
        match self.step {
            Step::Url => {
                let x = inner.x + 1 + self.url_cursor.min(self.url.len()) as u16;
                Some((x, inner.y + 1))
            }
            Step::Download => self
                .download_picker
                .cursor_position(inner, "Step 3/6 — ROMs directory"),
            Step::Bearer => {
                let x = inner.x + 1 + self.bearer_cursor.min(self.bearer_token.len()) as u16;
                Some((x, inner.y + 1))
            }
            Step::PairingCode => {
                let x = inner.x + 1 + self.pairing_cursor.min(self.pairing_code.len()) as u16;
                Some((x, inner.y + 3))
            }
            Step::BasicUser => {
                let x = inner.x + 1 + self.user_cursor.min(self.username.len()) as u16;
                Some((x, inner.y + 2))
            }
            Step::BasicPass => {
                let x = inner.x + 1 + "".repeat(self.password.len()).len() as u16;
                Some((x, inner.y + 6))
            }
            Step::ApiHeader => {
                let x = inner.x + 1 + self.header_cursor.min(self.api_header.len()) as u16;
                Some((x, inner.y + 2))
            }
            Step::ApiKey => {
                let x = inner.x + 1 + self.api_key_cursor.min(self.api_key.len()) as u16;
                Some((x, inner.y + 6))
            }
            Step::Https | Step::CustomConsolePaths | Step::AuthMenu | Step::Summary => None,
        }
    }
}