romm_cli/tui/screens/setup_wizard/
render.rs1use 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}