Skip to main content

romm_cli/tui/screens/setup_wizard/
render.rs

1//! Setup wizard rendering.
2
3use ratatui::style::Modifier;
4use ratatui::text::{Line, Span, Text};
5use ratatui::widgets::{List, ListItem, ListState, Paragraph};
6
7use crate::config::normalize_romm_origin;
8use crate::tui::theme::RommStyles;
9
10use super::layout::{wizard_footer_text, wizard_layout};
11use super::types::{AuthKind, SetupWizard, Step};
12
13impl SetupWizard {
14    pub fn render(
15        &mut self,
16        f: &mut ratatui::Frame,
17        area: ratatui::layout::Rect,
18        styles: &RommStyles,
19    ) {
20        let title = match self.step {
21            Step::Url => "Step 1/6 — RomM server URL",
22            Step::Https => "Step 2/6 — Secure connection",
23            Step::Download => "Step 3/6 — ROMs directory",
24            Step::CustomConsolePaths => "Step 4/6 — Custom console paths",
25            Step::AuthMenu => "Step 5/6 — Authentication",
26            Step::BasicUser | Step::BasicPass => "Step 6/6 — Basic auth",
27            Step::Bearer => "Step 6/6 — API Token",
28            Step::ApiHeader | Step::ApiKey => "Step 6/6 — API key",
29            Step::PairingCode => "Step 6/6 — Pair with Web UI",
30            Step::Summary => "Review & connect",
31        };
32
33        let main = wizard_layout(area, self.step);
34
35        match self.step {
36            Step::Url => {
37                let intro = Text::from(vec![
38                    Line::from("First-time setup: point the CLI at your RomM server."),
39                    Line::from(Span::styled(
40                        "Example: https://romm.example.com or http://192.168.1.10:8080",
41                        styles.muted(),
42                    )),
43                    Line::from(Span::styled(
44                        "Same origin as in your browser (no trailing /api).",
45                        styles.muted(),
46                    )),
47                ]);
48                f.render_widget(Paragraph::new(intro), main[0]);
49            }
50            step => {
51                let hint_top = match step {
52                    Step::Https => "HTTPS ensures your credentials are encrypted in transit. Only disable if necessary.",
53                    Step::Download => "Choose a directory to save ROMs. Make sure you have write permissions.",
54                    Step::CustomConsolePaths => "Consoles on other drives can use custom paths. Map them in Settings → ROMs → Console paths after setup.",
55                    Step::AuthMenu => "Select how you authenticate with the RomM server.",
56                    Step::BasicUser | Step::BasicPass => "Enter the exact same username and password you use to log into the RomM web UI.",
57                    Step::Bearer => "To get an API token, go to the RomM web UI -> client API Tokens -> generate a new token.",
58                    Step::PairingCode => "Login to RomM in your browser, go to your profile menu -> Client API Tokens, and create a new token.",
59                    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.",
60                    Step::Summary => "Review your configuration before testing the connection.",
61                    Step::Url => "",
62                };
63                let p = Paragraph::new(hint_top).style(styles.muted());
64                f.render_widget(p, main[0]);
65            }
66        }
67
68        match self.step {
69            Step::Url => {
70                let line = format!(
71                    "{}▏",
72                    self.url.chars().take(self.url_cursor).collect::<String>()
73                );
74                let rest: String = self.url.chars().skip(self.url_cursor).collect();
75                let text = format!("{line}{rest}");
76                let block = styles.panel_block(title);
77                let p = Paragraph::new(text).style(styles.text()).block(block);
78                f.render_widget(p, main[1]);
79            }
80            Step::Https => {
81                let text = if self.use_https {
82                    "[X] Use HTTPS (Recommended)"
83                } else {
84                    "[ ] Use HTTPS (Insecure)"
85                };
86                let block = styles.panel_block(title);
87                let p = Paragraph::new(format!("\n  {}\n\n  Space: toggle   Enter: next", text))
88                    .style(styles.text())
89                    .block(block);
90                f.render_widget(p, main[1]);
91            }
92            Step::Download => {
93                self.download_picker.render(f, main[1], title, "", styles);
94            }
95            Step::CustomConsolePaths => {
96                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";
97                let block = styles.panel_block(title);
98                f.render_widget(
99                    Paragraph::new(body).style(styles.text()).block(block),
100                    main[1],
101                );
102            }
103            Step::AuthMenu => {
104                let items: Vec<ListItem> = Self::auth_labels()
105                    .iter()
106                    .map(|s| ListItem::new(*s).style(styles.text()))
107                    .collect();
108                let mut state = ListState::default();
109                state.select(Some(self.auth_menu_selected));
110                let list = List::new(items)
111                    .block(styles.panel_block(title))
112                    .highlight_style(styles.selection().add_modifier(Modifier::BOLD))
113                    .highlight_symbol(">> ");
114                f.render_stateful_widget(list, main[1], &mut state);
115            }
116            Step::BasicUser | Step::BasicPass => {
117                let user_line = if self.step == Step::BasicUser {
118                    format!(
119                        "{}▏{}",
120                        self.username
121                            .chars()
122                            .take(self.user_cursor)
123                            .collect::<String>(),
124                        self.username
125                            .chars()
126                            .skip(self.user_cursor)
127                            .collect::<String>()
128                    )
129                } else {
130                    self.username.clone()
131                };
132                let pass_display: String = "•".repeat(self.password.len());
133                let kr_hint = if self.step == Step::BasicPass
134                    && self.password.is_empty()
135                    && self.reuse_keyring_password
136                {
137                    "\n\n(stored in OS keyring — leave blank to keep, or type a new password)"
138                } else {
139                    ""
140                };
141                let block = styles.panel_block(title);
142                let body = format!(
143                    "Username\n{user_line}\n\nPassword (hidden)\n{pass_display}{kr_hint}\n\nTab: switch field"
144                );
145                let p = Paragraph::new(body).style(styles.text()).block(block);
146                f.render_widget(p, main[1]);
147            }
148            Step::Bearer => {
149                let line = format!(
150                    "{}▏{}",
151                    self.bearer_token
152                        .chars()
153                        .take(self.bearer_cursor)
154                        .collect::<String>(),
155                    self.bearer_token
156                        .chars()
157                        .skip(self.bearer_cursor)
158                        .collect::<String>()
159                );
160                let mut bearer_text = Text::from(vec![
161                    Line::from("API Token"),
162                    Line::from(""),
163                    Line::from(line),
164                ]);
165                if self.bearer_token.is_empty() && self.reuse_keyring_bearer {
166                    bearer_text.push_line(Line::from(""));
167                    bearer_text.push_line(Line::from(Span::styled(
168                        "Token stored in OS keyring — leave blank to keep, or type a new token.",
169                        styles.muted(),
170                    )));
171                }
172                let block = styles.panel_block(title);
173                let p = Paragraph::new(bearer_text)
174                    .style(styles.text())
175                    .block(block);
176                f.render_widget(p, main[1]);
177            }
178            Step::PairingCode => {
179                let line = format!(
180                    "{}▏{}",
181                    self.pairing_code
182                        .chars()
183                        .take(self.pairing_cursor)
184                        .collect::<String>(),
185                    self.pairing_code
186                        .chars()
187                        .skip(self.pairing_cursor)
188                        .collect::<String>()
189                );
190                let body = format!("Enter the 8-character code provided.\n\n{line}");
191                let block = styles.panel_block(title);
192                let p = Paragraph::new(body).style(styles.text()).block(block);
193                f.render_widget(p, main[1]);
194            }
195            Step::ApiHeader | Step::ApiKey => {
196                let header_line = if self.step == Step::ApiHeader {
197                    format!(
198                        "{}▏{}",
199                        self.api_header
200                            .chars()
201                            .take(self.header_cursor)
202                            .collect::<String>(),
203                        self.api_header
204                            .chars()
205                            .skip(self.header_cursor)
206                            .collect::<String>()
207                    )
208                } else {
209                    self.api_header.clone()
210                };
211                let key_line = "•".repeat(self.api_key.len());
212                let kr_hint = if self.step == Step::ApiKey
213                    && self.api_key.is_empty()
214                    && self.reuse_keyring_api_key
215                {
216                    "\n\n(stored in OS keyring — leave blank to keep, or type a new key)"
217                } else {
218                    ""
219                };
220                let body = format!(
221                    "Header name\n{header_line}\n\nKey (hidden)\n{key_line}{kr_hint}\n\nTab: switch field"
222                );
223                let block = styles.panel_block(title);
224                let p = Paragraph::new(body).style(styles.text()).block(block);
225                f.render_widget(p, main[1]);
226            }
227            Step::Summary => {
228                let url_line = normalize_romm_origin(self.url.trim());
229                let auth_desc = match self.auth_kind {
230                    AuthKind::Basic => "Basic",
231                    AuthKind::Bearer => "API Token",
232                    AuthKind::ApiKey => "API key header",
233                    AuthKind::Pairing => {
234                        if self.pairing_code.trim().is_empty() {
235                            "Pair with Web UI (no code yet)"
236                        } else {
237                            "Pair with Web UI (code entered)"
238                        }
239                    }
240                };
241                let mut lines = vec![
242                    format!("Server: {url_line}"),
243                    format!("ROMs Dir: {}", self.download_picker.path_trimmed()),
244                    "Layout: base subfolder per console (custom paths in Settings → ROMs)"
245                        .to_string(),
246                    format!("Use HTTPS: {}", if self.use_https { "Yes" } else { "No" }),
247                    format!("Auth: {auth_desc}"),
248                    String::new(),
249                ];
250                if self.testing {
251                    lines.push("Connecting to server…".to_string());
252                } else if let Some(ref e) = self.error {
253                    lines.push(format!("Last error: {e}"));
254                } else {
255                    lines.push("Enter: test connection and save   Esc: quit".to_string());
256                }
257                let block = styles.panel_block(title);
258                let p = Paragraph::new(lines.join("\n"))
259                    .style(styles.text())
260                    .block(block);
261                f.render_widget(p, main[1]);
262            }
263        }
264
265        let footer_keys = match self.step {
266            Step::Url => "Enter: next   Backspace: delete   Esc: quit",
267            Step::Https => "Space: toggle   Enter: next   Esc: quit",
268            Step::Download => "Ctrl+Enter: next (creates path)   ↑ list top: path bar   ↓/↑: list focus   Tab: path/list   Esc: quit",
269            Step::CustomConsolePaths => "Enter: next   Esc: quit",
270            Step::AuthMenu => "↑/↓: choose   Enter: next   Esc: quit",
271            Step::BasicUser | Step::BasicPass => {
272                "Type text   Tab: switch field   Enter: next step   Esc: quit"
273            }
274            Step::Bearer => "Enter: next step   Esc: quit",
275            Step::PairingCode => "Enter: next step   Esc: quit",
276            Step::ApiHeader | Step::ApiKey => "Tab: switch field   Enter: next step   Esc: quit",
277            Step::Summary => {
278                if self.testing {
279                    "Please wait…"
280                } else {
281                    "Enter: connect & save"
282                }
283            }
284        };
285        let p = Paragraph::new(wizard_footer_text(footer_keys, styles))
286            .block(styles.panel_block_untitled());
287        f.render_widget(p, main[2]);
288    }
289
290    pub fn cursor_pos(&self, area: ratatui::layout::Rect) -> Option<(u16, u16)> {
291        let main = wizard_layout(area, self.step);
292        let inner = main[1];
293        match self.step {
294            Step::Url => {
295                let x = inner.x + 1 + self.url_cursor.min(self.url.len()) as u16;
296                Some((x, inner.y + 1))
297            }
298            Step::Download => self
299                .download_picker
300                .cursor_position(inner, "Step 3/6 — ROMs directory"),
301            Step::Bearer => {
302                let x = inner.x + 1 + self.bearer_cursor.min(self.bearer_token.len()) as u16;
303                Some((x, inner.y + 1))
304            }
305            Step::PairingCode => {
306                let x = inner.x + 1 + self.pairing_cursor.min(self.pairing_code.len()) as u16;
307                Some((x, inner.y + 3))
308            }
309            Step::BasicUser => {
310                let x = inner.x + 1 + self.user_cursor.min(self.username.len()) as u16;
311                Some((x, inner.y + 2))
312            }
313            Step::BasicPass => {
314                let x = inner.x + 1 + "•".repeat(self.password.len()).len() as u16;
315                Some((x, inner.y + 6))
316            }
317            Step::ApiHeader => {
318                let x = inner.x + 1 + self.header_cursor.min(self.api_header.len()) as u16;
319                Some((x, inner.y + 2))
320            }
321            Step::ApiKey => {
322                let x = inner.x + 1 + self.api_key_cursor.min(self.api_key.len()) as u16;
323                Some((x, inner.y + 6))
324            }
325            Step::Https | Step::CustomConsolePaths | Step::AuthMenu | Step::Summary => None,
326        }
327    }
328}