1use ratatui::layout::{Constraint, Layout, Rect};
2use ratatui::style::Modifier;
3use ratatui::text::{Line, Span};
4use ratatui::widgets::{List, ListItem, ListState, Paragraph, Tabs};
5use ratatui::Frame;
6
7use crate::tui::theme::RommStyles;
8
9use super::types::{
10 ConsolePathKind, SettingsConfirm, SettingsPickerKind, SettingsRow, SettingsScreen, SettingsTab,
11};
12
13impl SettingsScreen {
14 pub fn render(&mut self, f: &mut Frame, area: Rect, styles: &RommStyles) {
15 if let Some((platform_id, ref mut picker)) = self.console_path_picker {
16 let chunks = Layout::default()
17 .constraints([
18 Constraint::Length(4),
19 Constraint::Min(12),
20 Constraint::Length(3),
21 ])
22 .direction(ratatui::layout::Direction::Vertical)
23 .split(area);
24 let platform_name = self
25 .console_platforms
26 .iter()
27 .find(|p| p.id == platform_id)
28 .map(Self::platform_display_name)
29 .unwrap_or_else(|| format!("Platform {platform_id}"));
30 let info = [
31 format!(
32 "romm-cli: v{} | RomM server: {}",
33 self.version, self.server_version
34 ),
35 format!("Console: {platform_name}"),
36 ];
37 f.render_widget(
38 Paragraph::new(info.join("\n"))
39 .style(styles.text())
40 .block(styles.header_block()),
41 chunks[0],
42 );
43 picker.render(
44 f,
45 chunks[1],
46 "Choose console directory",
47 "Esc: cancel Ctrl+Enter: apply typed path (creates folders) Tab: path/list",
48 styles,
49 );
50 f.render_widget(
51 Paragraph::new("Console directory picker — Esc returns without changing")
52 .style(styles.footer_hint())
53 .block(styles.panel_block_untitled()),
54 chunks[2],
55 );
56 return;
57 }
58
59 if self.console_picker_open {
60 self.render_console_picker(f, area, styles);
61 return;
62 }
63
64 if let Some((kind, ref mut picker)) = self.path_picker {
65 let chunks = Layout::default()
66 .constraints([
67 Constraint::Length(4),
68 Constraint::Min(12),
69 Constraint::Length(3),
70 ])
71 .direction(ratatui::layout::Direction::Vertical)
72 .split(area);
73 let info = [
74 format!(
75 "romm-cli: v{} | RomM server: {}",
76 self.version, self.server_version
77 ),
78 format!("GitHub: {}", self.github_url),
79 format!("Auth: {}", self.auth_status),
80 ];
81 f.render_widget(
82 Paragraph::new(info.join("\n"))
83 .style(styles.text())
84 .block(styles.header_block()),
85 chunks[0],
86 );
87 let hint =
88 "Esc: cancel Ctrl+Enter: apply typed path (creates folders) Tab: path/list";
89 let title = match kind {
90 SettingsPickerKind::RomsDir => "Choose ROMs directory",
91 SettingsPickerKind::SaveDir => "Choose save directory",
92 };
93 picker.render(f, chunks[1], title, hint, styles);
94 f.render_widget(
95 Paragraph::new("ROMs directory picker — Esc returns without changing")
96 .style(styles.footer_hint())
97 .block(styles.panel_block_untitled()),
98 chunks[2],
99 );
100 return;
101 }
102
103 if self.device_picker_open {
104 self.render_device_picker(f, area, styles);
105 return;
106 }
107
108 let chunks = Layout::default()
109 .constraints([
110 Constraint::Length(4), Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), Constraint::Length(3), ])
116 .direction(ratatui::layout::Direction::Vertical)
117 .split(area);
118
119 let info = [
121 format!(
122 "romm-cli: v{} | RomM server: {}",
123 self.version, self.server_version
124 ),
125 format!("GitHub: {}", self.github_url),
126 format!("Auth: {}", self.auth_status),
127 ];
128 f.render_widget(
129 Paragraph::new(info.join("\n"))
130 .style(styles.text())
131 .block(styles.header_block()),
132 chunks[0],
133 );
134
135 let titles = SettingsTab::ALL
137 .iter()
138 .map(|tab| Line::from(Span::raw(tab.title())))
139 .collect::<Vec<_>>();
140 let tabs = Tabs::new(titles)
141 .select(self.selected_tab.index())
142 .block(styles.panel_block_untitled())
143 .style(styles.muted())
144 .highlight_style(styles.selection());
145 f.render_widget(tabs, chunks[1]);
146
147 let items = self
149 .visible_rows()
150 .iter()
151 .copied()
152 .map(|row| self.render_row_item(row, styles))
153 .collect::<Vec<_>>();
154
155 let mut state = ListState::default();
156 state.select(Some(self.selected_row_index()));
157
158 let list = List::new(items)
159 .block(styles.panel_block(format!(" {} ", self.selected_tab.title())))
160 .highlight_style(styles.selection())
161 .highlight_symbol(">> ");
162
163 f.render_stateful_widget(list, chunks[2], &mut state);
164
165 if let Some(confirm) = &self.confirm {
167 let msg = match confirm {
168 SettingsConfirm::Reset => {
169 "Are you sure you want to delete all settings? (Enter: Yes, Esc: Cancel)"
170 }
171 SettingsConfirm::ClearCache => {
172 "Are you sure you want to clear the ROM cache? (Enter: Yes, Esc: Cancel)"
173 }
174 SettingsConfirm::ExitUnsaved => {
175 "Save changes before leaving? (Enter: Save, N: Don't save, Esc: Cancel)"
176 }
177 };
178 f.render_widget(
179 Paragraph::new(msg).style(styles.error().add_modifier(Modifier::BOLD)),
180 chunks[3],
181 );
182 } else if let Some((msg, tone)) = &self.message {
183 f.render_widget(
184 Paragraph::new(msg.as_str()).style(styles.tone(*tone)),
185 chunks[3],
186 );
187 } else if self.editing {
188 f.render_widget(
189 Paragraph::new("Editing... Enter: save Esc: cancel").style(styles.label()),
190 chunks[3],
191 );
192 }
193
194 let help = if self.confirm == Some(SettingsConfirm::ExitUnsaved) {
196 "Enter: save N: don't save Esc: cancel"
197 } else if self.confirm.is_some() {
198 "Enter: confirm Esc: cancel"
199 } else if self.editing {
200 "Backspace: delete Arrows: move cursor Enter: save Esc: cancel"
201 } else {
202 "Tab/←/→: tabs ↑/↓: select Enter: edit/toggle S: save to disk Esc: back"
203 };
204 f.render_widget(
205 Paragraph::new(help)
206 .style(styles.footer_hint())
207 .block(styles.panel_block_untitled()),
208 chunks[4],
209 );
210 }
211
212 fn render_row_item(&self, row: SettingsRow, styles: &RommStyles) -> ListItem<'static> {
213 let label = self.row_label(row);
214 match row {
215 SettingsRow::SyncDevice | SettingsRow::SyncNow if !self.save_sync_supported() => {
216 ListItem::new(label).style(styles.muted())
217 }
218 _ => ListItem::new(label).style(styles.text()),
219 }
220 }
221
222 pub(crate) fn row_label(&self, row: SettingsRow) -> String {
223 let label = match row {
224 SettingsRow::BaseUrl => format!(
225 "Base URL: {}",
226 if self.editing && self.selected_row() == SettingsRow::BaseUrl {
227 &self.edit_buffer
228 } else {
229 &self.base_url
230 }
231 ),
232 SettingsRow::RomsDir => format!("Roms Dir: {}", self.download_dir),
233 SettingsRow::ConsolePaths => {
234 let mapped = self.rom_platform_dirs.len();
235 format!("Console paths: {mapped} custom · Enter to edit")
236 }
237 SettingsRow::SaveConsolePaths => {
238 let mapped = self.save_platform_dirs.len();
239 format!("Save console paths: {mapped} custom · Enter to edit")
240 }
241 SettingsRow::UseHttps => format!(
242 "Use HTTPS: {}",
243 if self.use_https { "[X] Yes" } else { "[ ] No" }
244 ),
245 SettingsRow::SaveDir => format!("Save Dir: {}", self.save_dir),
246 SettingsRow::SyncDevice => format!(
247 "Sync Device: {}",
248 self.sync_device_id.as_deref().unwrap_or("(not selected)")
249 ),
250 SettingsRow::SyncNow => "Sync Saves Now".to_string(),
251 SettingsRow::ExtrasRelatedRoms => format!(
252 "Incl. updates/DLC (picker default): {}",
253 if self.extras_include_related_roms {
254 "[X] Yes"
255 } else {
256 "[ ] No"
257 }
258 ),
259 SettingsRow::ExtrasCover => format!(
260 "Incl. cover (picker default): {}",
261 if self.extras_include_cover {
262 "[X] Yes"
263 } else {
264 "[ ] No"
265 }
266 ),
267 SettingsRow::ExtrasManual => format!(
268 "Incl. manual (picker default): {}",
269 if self.extras_include_manual {
270 "[X] Yes"
271 } else {
272 "[ ] No"
273 }
274 ),
275 SettingsRow::Theme => format!(
276 "Theme: {} (←/→ to change)",
277 self.theme_display_name()
278 ),
279 SettingsRow::Auth => format!("Auth: {} (Enter to change)", self.auth_status),
280 SettingsRow::ClearCache => "Clear Cache (Remove cached ROM data)".to_string(),
281 SettingsRow::ResetConfiguration => {
282 "Reset Configuration (Delete settings from disk & keyring)".to_string()
283 }
284 };
285 if matches!(row, SettingsRow::SyncDevice | SettingsRow::SyncNow)
286 && !self.save_sync_supported()
287 {
288 format!("{label} (requires newer RomM server)")
289 } else {
290 label
291 }
292 }
293
294 pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
295 if let Some((kind, ref picker)) = self.path_picker {
296 let chunks = Layout::default()
297 .constraints([
298 Constraint::Length(4),
299 Constraint::Min(12),
300 Constraint::Length(3),
301 ])
302 .direction(ratatui::layout::Direction::Vertical)
303 .split(area);
304 let title = match kind {
305 SettingsPickerKind::RomsDir => "Choose ROMs directory",
306 SettingsPickerKind::SaveDir => "Choose save directory",
307 };
308 return picker.cursor_position(chunks[1], title);
309 }
310
311 if !self.editing || self.selected_row() != SettingsRow::BaseUrl {
312 return None;
313 }
314
315 let chunks = Layout::default()
316 .constraints([
317 Constraint::Length(4),
318 Constraint::Length(3),
319 Constraint::Min(10),
320 Constraint::Length(3),
321 Constraint::Length(3),
322 ])
323 .direction(ratatui::layout::Direction::Vertical)
324 .split(area);
325
326 let list_area = chunks[2];
327 let y = list_area.y + 1 + self.selected_row_index() as u16;
328 let label_len = 14; let x = list_area.x + 1 + 3 + label_len + self.edit_cursor as u16;
330
331 Some((x, y))
332 }
333
334 fn render_device_picker(&mut self, f: &mut Frame, area: Rect, styles: &RommStyles) {
335 let chunks = Layout::default()
336 .constraints([
337 Constraint::Length(4),
338 Constraint::Min(10),
339 Constraint::Length(3),
340 ])
341 .direction(ratatui::layout::Direction::Vertical)
342 .split(area);
343 let info = [
344 format!(
345 "romm-cli: v{} | RomM server: {}",
346 self.version, self.server_version
347 ),
348 "Select the RomM sync device used for manual push-pull.".to_string(),
349 ];
350 f.render_widget(
351 Paragraph::new(info.join("\n"))
352 .style(styles.text())
353 .block(styles.header_block()),
354 chunks[0],
355 );
356 if self.device_picker_loading {
357 f.render_widget(
358 Paragraph::new("Loading devices...")
359 .style(styles.text())
360 .block(styles.panel_block(" Devices ")),
361 chunks[1],
362 );
363 } else if let Some(error) = &self.device_picker_error {
364 f.render_widget(
365 Paragraph::new(format!("Could not load devices: {error}"))
366 .style(styles.error())
367 .block(styles.panel_block(" Devices ")),
368 chunks[1],
369 );
370 } else {
371 let items: Vec<ListItem> = self
372 .devices
373 .iter()
374 .map(|d| {
375 let name = d.name.as_deref().unwrap_or("(unnamed)");
376 ListItem::new(format!("{name} [{}] mode={:?}", d.id, d.sync_mode))
377 .style(styles.text())
378 })
379 .collect();
380 let mut state = ListState::default();
381 state.select(Some(self.device_selected_index));
382 f.render_stateful_widget(
383 List::new(items)
384 .block(styles.panel_block(" Devices "))
385 .highlight_symbol(">> ")
386 .highlight_style(styles.selection()),
387 chunks[1],
388 &mut state,
389 );
390 }
391 f.render_widget(
392 Paragraph::new("Enter: choose Esc: cancel ↑/↓: select")
393 .style(styles.footer_hint())
394 .block(styles.panel_block_untitled()),
395 chunks[2],
396 );
397 }
398
399 fn render_console_picker(&mut self, f: &mut Frame, area: Rect, styles: &RommStyles) {
400 let kind = self.active_console_kind.unwrap_or(ConsolePathKind::Roms);
401 let chunks = Layout::default()
402 .constraints([
403 Constraint::Length(4),
404 Constraint::Min(10),
405 Constraint::Length(3),
406 ])
407 .direction(ratatui::layout::Direction::Vertical)
408 .split(area);
409 let subtitle = match kind {
410 ConsolePathKind::Roms => "Set a custom ROM path for consoles on other drives.",
411 ConsolePathKind::Saves => "Set a custom save path for consoles on other drives.",
412 };
413 let info = [
414 format!(
415 "romm-cli: v{} | RomM server: {}",
416 self.version, self.server_version
417 ),
418 subtitle.to_string(),
419 ];
420 f.render_widget(
421 Paragraph::new(info.join("\n"))
422 .style(styles.text())
423 .block(styles.header_block()),
424 chunks[0],
425 );
426 if self.console_picker_loading {
427 f.render_widget(
428 Paragraph::new("Loading platforms...")
429 .style(styles.text())
430 .block(styles.panel_block(" Consoles ")),
431 chunks[1],
432 );
433 } else if let Some(error) = &self.console_picker_error {
434 f.render_widget(
435 Paragraph::new(format!("Could not load platforms: {error}"))
436 .style(styles.error())
437 .block(styles.panel_block(" Consoles ")),
438 chunks[1],
439 );
440 } else if self.console_platforms.is_empty() {
441 f.render_widget(
442 Paragraph::new("No platforms returned from the server.")
443 .style(styles.warning())
444 .block(styles.panel_block(" Consoles ")),
445 chunks[1],
446 );
447 } else {
448 let items: Vec<ListItem> = self
449 .console_platforms
450 .iter()
451 .map(|platform| {
452 let name = Self::platform_display_name(platform);
453 let path = self.console_dir_preview(kind, platform);
454 let custom = self
455 .console_dirs(kind)
456 .get(&platform.id)
457 .is_some_and(|s| !s.trim().is_empty());
458 let tag = if custom {
459 "custom path"
460 } else {
461 "base default"
462 };
463 ListItem::new(format!("{name} [{tag}] {path}")).style(styles.text())
464 })
465 .collect();
466 let mut state = ListState::default();
467 state.select(Some(self.console_selected_index));
468 f.render_stateful_widget(
469 List::new(items)
470 .block(styles.panel_block(" Consoles "))
471 .highlight_symbol(">> ")
472 .highlight_style(styles.selection()),
473 chunks[1],
474 &mut state,
475 );
476 }
477 f.render_widget(
478 Paragraph::new("Enter: set path Del: clear custom Esc: back ↑/↓: select")
479 .style(styles.footer_hint())
480 .block(styles.panel_block_untitled()),
481 chunks[2],
482 );
483 }
484}