1use std::collections::HashMap;
2
3use ratatui::layout::{Constraint, Layout, Rect};
4use ratatui::style::{Color, Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs};
7use ratatui::Frame;
8
9use crate::config::{
10 disk_has_unresolved_keyring_sentinel, Config, RomsLayoutConfig, SaveSyncConfig,
11};
12use crate::core::utils;
13use crate::endpoints::device::DeviceSchema;
14use crate::feature_compat::SaveSyncCompatibility;
15use crate::tui::path_picker::{PathPicker, PathPickerMode};
16use crate::types::Platform;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum SettingsTab {
20 Connection,
21 Roms,
22 Saves,
23 Extras,
24 AuthMaintenance,
25}
26
27impl SettingsTab {
28 pub const ALL: [SettingsTab; 5] = [
29 SettingsTab::Connection,
30 SettingsTab::Roms,
31 SettingsTab::Saves,
32 SettingsTab::Extras,
33 SettingsTab::AuthMaintenance,
34 ];
35
36 pub const COUNT: usize = Self::ALL.len();
37
38 pub fn index(self) -> usize {
39 match self {
40 SettingsTab::Connection => 0,
41 SettingsTab::Roms => 1,
42 SettingsTab::Saves => 2,
43 SettingsTab::Extras => 3,
44 SettingsTab::AuthMaintenance => 4,
45 }
46 }
47
48 fn title(self) -> &'static str {
49 match self {
50 SettingsTab::Connection => "Connection",
51 SettingsTab::Roms => "ROMs",
52 SettingsTab::Saves => "Saves",
53 SettingsTab::Extras => "Extras",
54 SettingsTab::AuthMaintenance => "Auth/Maint",
55 }
56 }
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum SettingsRow {
61 BaseUrl,
62 RomsDir,
63 ConsolePaths,
64 UseHttps,
65 SaveDir,
66 SaveConsolePaths,
67 SyncDevice,
68 SyncNow,
69 ExtrasRelatedRoms,
70 ExtrasCover,
71 ExtrasManual,
72 Auth,
73 ClearCache,
74 ResetConfiguration,
75}
76
77const CONNECTION_ROWS: [SettingsRow; 2] = [SettingsRow::BaseUrl, SettingsRow::UseHttps];
78const SAVES_ROWS: [SettingsRow; 4] = [
79 SettingsRow::SaveDir,
80 SettingsRow::SaveConsolePaths,
81 SettingsRow::SyncDevice,
82 SettingsRow::SyncNow,
83];
84const EXTRAS_ROWS: [SettingsRow; 3] = [
85 SettingsRow::ExtrasRelatedRoms,
86 SettingsRow::ExtrasCover,
87 SettingsRow::ExtrasManual,
88];
89const AUTH_MAINT_ROWS: [SettingsRow; 3] = [
90 SettingsRow::Auth,
91 SettingsRow::ClearCache,
92 SettingsRow::ResetConfiguration,
93];
94
95#[derive(Clone, Copy, PartialEq, Eq)]
96pub enum SettingsPickerKind {
97 RomsDir,
98 SaveDir,
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum ConsolePathKind {
103 Roms,
104 Saves,
105}
106
107#[derive(Debug, PartialEq, Eq)]
108pub enum SettingsConfirm {
109 Reset,
110 ClearCache,
111}
112
113pub struct SettingsScreen {
115 pub base_url: String,
116 pub download_dir: String,
117 pub use_https: bool,
118 pub extras_include_related_roms: bool,
120 pub extras_include_cover: bool,
122 pub extras_include_manual: bool,
124 pub auth_status: String,
125 pub version: String,
126 pub server_version: String,
127 pub github_url: String,
128
129 pub selected_tab: SettingsTab,
130 selected_indices: [usize; SettingsTab::COUNT],
131 pub editing: bool,
132 pub confirm: Option<SettingsConfirm>,
133 pub edit_buffer: String,
134 pub edit_cursor: usize,
135 pub path_picker: Option<(SettingsPickerKind, PathPicker)>,
137 pub save_dir: String,
138 pub sync_device_id: Option<String>,
139 pub devices: Vec<DeviceSchema>,
140 pub device_picker_open: bool,
141 pub device_picker_loading: bool,
142 pub device_picker_error: Option<String>,
143 pub device_selected_index: usize,
144 pub sync_inflight: bool,
145 pub message: Option<(String, Color)>,
146 pub save_sync_compat: SaveSyncCompatibility,
147 pub rom_platform_dirs: HashMap<u64, String>,
148 pub save_platform_dirs: HashMap<u64, String>,
149 pub console_picker_open: bool,
150 pub active_console_kind: Option<ConsolePathKind>,
151 pub console_picker_loading: bool,
152 pub console_picker_error: Option<String>,
153 pub console_platforms: Vec<Platform>,
154 pub console_selected_index: usize,
155 pub console_path_picker: Option<(u64, PathPicker)>,
157}
158
159impl SettingsScreen {
160 pub fn new(
161 config: &Config,
162 romm_server_version: Option<&str>,
163 save_sync_compat: SaveSyncCompatibility,
164 ) -> Self {
165 let auth_status = match &config.auth {
166 Some(crate::config::AuthConfig::Basic { username, .. }) => {
167 format!("Basic (user: {})", username)
168 }
169 Some(crate::config::AuthConfig::Bearer { .. }) => "API Token".to_string(),
170 Some(crate::config::AuthConfig::ApiKey { header, .. }) => {
171 format!("API key (header: {})", header)
172 }
173 None => {
174 if disk_has_unresolved_keyring_sentinel(config) {
175 "None — disk still references keyring; set API_TOKEN / ROMM_TOKEN_FILE or see docs/troubleshooting-auth.md"
176 .to_string()
177 } else {
178 "None (no API credentials in env/keyring)".to_string()
179 }
180 }
181 };
182
183 let server_version = romm_server_version
184 .map(String::from)
185 .unwrap_or_else(|| "unavailable (heartbeat failed)".to_string());
186
187 Self {
188 base_url: config.base_url.clone(),
189 download_dir: config.download_dir.clone(),
190 save_dir: crate::config::resolved_save_dir(config)
191 .display()
192 .to_string(),
193 sync_device_id: config.save_sync.device_id.clone(),
194 use_https: config.use_https,
195 extras_include_related_roms: config.extras_defaults.include_related_roms,
196 extras_include_cover: config.extras_defaults.include_cover,
197 extras_include_manual: config.extras_defaults.include_manual,
198 auth_status,
199 version: env!("CARGO_PKG_VERSION").to_string(),
200 server_version,
201 github_url: "https://github.com/patricksmill/romm-cli".to_string(),
202 selected_tab: SettingsTab::Connection,
203 selected_indices: [0; SettingsTab::COUNT],
204 editing: false,
205 confirm: None,
206 edit_buffer: String::new(),
207 edit_cursor: 0,
208 path_picker: None,
209 devices: Vec::new(),
210 device_picker_open: false,
211 device_picker_loading: false,
212 device_picker_error: None,
213 device_selected_index: 0,
214 sync_inflight: false,
215 message: None,
216 save_sync_compat,
217 rom_platform_dirs: config.roms_layout.platform_dirs.clone(),
218 save_platform_dirs: config.save_sync.platform_dirs.clone(),
219 console_picker_open: false,
220 active_console_kind: None,
221 console_picker_loading: false,
222 console_picker_error: None,
223 console_platforms: Vec::new(),
224 console_selected_index: 0,
225 console_path_picker: None,
226 }
227 }
228
229 pub fn roms_layout_config(&self) -> RomsLayoutConfig {
230 let mut layout = RomsLayoutConfig::default();
231 layout.platform_dirs = self.rom_platform_dirs.clone();
232 layout
233 }
234
235 pub fn save_sync_config(&self) -> SaveSyncConfig {
236 SaveSyncConfig {
237 save_dir: Some(self.save_dir.clone()),
238 device_id: self.sync_device_id.clone(),
239 platform_dirs: self.save_platform_dirs.clone(),
240 }
241 }
242
243 fn console_dirs(&self, kind: ConsolePathKind) -> &HashMap<u64, String> {
244 match kind {
245 ConsolePathKind::Roms => &self.rom_platform_dirs,
246 ConsolePathKind::Saves => &self.save_platform_dirs,
247 }
248 }
249
250 fn console_dirs_mut(&mut self, kind: ConsolePathKind) -> &mut HashMap<u64, String> {
251 match kind {
252 ConsolePathKind::Roms => &mut self.rom_platform_dirs,
253 ConsolePathKind::Saves => &mut self.save_platform_dirs,
254 }
255 }
256
257 pub fn visible_rows(&self) -> Vec<SettingsRow> {
258 match self.selected_tab {
259 SettingsTab::Connection => CONNECTION_ROWS.to_vec(),
260 SettingsTab::Roms => vec![SettingsRow::RomsDir, SettingsRow::ConsolePaths],
261 SettingsTab::Saves => SAVES_ROWS.to_vec(),
262 SettingsTab::Extras => EXTRAS_ROWS.to_vec(),
263 SettingsTab::AuthMaintenance => AUTH_MAINT_ROWS.to_vec(),
264 }
265 }
266
267 fn platform_display_name(platform: &Platform) -> String {
268 platform
269 .custom_name
270 .as_deref()
271 .filter(|s| !s.is_empty())
272 .unwrap_or(platform.name.as_str())
273 .to_string()
274 }
275
276 fn auto_console_dir_preview(&self, kind: ConsolePathKind, platform: &Platform) -> String {
277 let slug = platform.fs_slug.as_str();
278 let base = match kind {
279 ConsolePathKind::Roms => self.download_dir.trim_end_matches(['/', '\\']),
280 ConsolePathKind::Saves => self.save_dir.trim_end_matches(['/', '\\']),
281 };
282 format!("{}/{}", base, utils::sanitize_filename(slug))
283 }
284
285 fn console_dir_preview(&self, kind: ConsolePathKind, platform: &Platform) -> String {
286 self.console_dirs(kind)
287 .get(&platform.id)
288 .map(|s| s.trim())
289 .filter(|s| !s.is_empty())
290 .map(str::to_string)
291 .unwrap_or_else(|| self.auto_console_dir_preview(kind, platform))
292 }
293
294 pub fn open_console_picker(&mut self, kind: ConsolePathKind) {
295 self.console_selected_index = 0;
296 self.console_picker_open = true;
297 self.active_console_kind = Some(kind);
298 self.console_path_picker = None;
299 self.console_picker_loading = true;
300 self.console_picker_error = None;
301 self.console_platforms.clear();
302 }
303
304 pub fn set_console_platforms(&mut self, platforms: Vec<Platform>) {
305 self.console_platforms = platforms;
306 self.console_picker_loading = false;
307 self.console_picker_error = None;
308 self.console_selected_index = self
309 .console_selected_index
310 .min(self.console_platforms.len().saturating_sub(1));
311 }
312
313 pub fn set_console_platform_error(&mut self, error: String) {
314 self.console_picker_loading = false;
315 self.console_picker_error = Some(error);
316 }
317
318 pub fn clear_console_path(&mut self, platform_id: u64) {
319 let Some(kind) = self.active_console_kind else {
320 return;
321 };
322 self.console_dirs_mut(kind).remove(&platform_id);
323 self.message = Some((
324 "Custom path cleared (press S to save)".to_string(),
325 Color::Green,
326 ));
327 }
328
329 pub fn console_next(&mut self) {
330 if !self.console_platforms.is_empty() {
331 self.console_selected_index =
332 (self.console_selected_index + 1).min(self.console_platforms.len() - 1);
333 }
334 }
335
336 pub fn console_previous(&mut self) {
337 self.console_selected_index = self.console_selected_index.saturating_sub(1);
338 }
339
340 pub fn open_console_path_picker(&mut self) {
341 let Some(kind) = self.active_console_kind else {
342 return;
343 };
344 let Some(platform) = self.console_platforms.get(self.console_selected_index) else {
345 return;
346 };
347 let initial = self.console_dir_preview(kind, platform);
348 self.console_path_picker = Some((
349 platform.id,
350 PathPicker::new(PathPickerMode::Directory, &initial),
351 ));
352 }
353
354 pub fn confirm_console_path(&mut self, platform_id: u64, path: String) {
355 let Some(kind) = self.active_console_kind else {
356 return;
357 };
358 self.console_dirs_mut(kind).insert(platform_id, path);
359 self.console_path_picker = None;
360 let label = match kind {
361 ConsolePathKind::Roms => "Custom console path updated (press S to save)",
362 ConsolePathKind::Saves => "Custom save path updated (press S to save)",
363 };
364 self.message = Some((label.to_string(), Color::Green));
365 }
366
367 pub fn save_sync_supported(&self) -> bool {
368 self.save_sync_compat.supported
369 }
370
371 pub fn set_save_sync_unsupported_message(&mut self) {
372 self.message = Some((self.save_sync_compat.unsupported_message(), Color::Yellow));
373 }
374
375 pub fn selected_row_index(&self) -> usize {
376 let rows = self.visible_rows();
377 self.selected_indices[self.selected_tab.index()].min(rows.len().saturating_sub(1))
378 }
379
380 fn set_selected_row_index(&mut self, index: usize) {
381 let max = self.visible_rows().len().saturating_sub(1);
382 self.selected_indices[self.selected_tab.index()] = index.min(max);
383 }
384
385 pub fn selected_row(&self) -> SettingsRow {
386 let rows = self.visible_rows();
387 rows[self.selected_row_index()]
388 }
389
390 pub fn active_rows(&self) -> &[SettingsRow] {
391 match self.selected_tab {
393 SettingsTab::Connection => &CONNECTION_ROWS,
394 SettingsTab::Saves => &SAVES_ROWS,
395 SettingsTab::Extras => &EXTRAS_ROWS,
396 SettingsTab::AuthMaintenance => &AUTH_MAINT_ROWS,
397 SettingsTab::Roms => &[],
398 }
399 }
400
401 pub fn next_tab(&mut self) {
402 if self.editing || self.confirm.is_some() {
403 return;
404 }
405 let next = (self.selected_tab.index() + 1) % SettingsTab::COUNT;
406 self.selected_tab = SettingsTab::ALL[next];
407 self.set_selected_row_index(self.selected_row_index());
408 }
409
410 pub fn previous_tab(&mut self) {
411 if self.editing || self.confirm.is_some() {
412 return;
413 }
414 let previous = (self.selected_tab.index() + SettingsTab::COUNT - 1) % SettingsTab::COUNT;
415 self.selected_tab = SettingsTab::ALL[previous];
416 self.set_selected_row_index(self.selected_row_index());
417 }
418
419 pub fn next(&mut self) {
420 if !self.editing && self.confirm.is_none() {
421 let len = self.visible_rows().len();
422 if len > 0 {
423 self.set_selected_row_index((self.selected_row_index() + 1) % len);
424 }
425 }
426 }
427
428 pub fn previous(&mut self) {
429 if !self.editing && self.confirm.is_none() {
430 let len = self.visible_rows().len();
431 if len == 0 {
432 return;
433 }
434 if self.selected_row_index() == 0 {
435 self.set_selected_row_index(len - 1);
436 } else {
437 self.set_selected_row_index(self.selected_row_index() - 1);
438 }
439 }
440 }
441
442 pub fn enter_edit(&mut self) {
443 match self.selected_row() {
444 SettingsRow::ResetConfiguration => self.confirm = Some(SettingsConfirm::Reset),
445 SettingsRow::ClearCache => self.confirm = Some(SettingsConfirm::ClearCache),
446 SettingsRow::SyncDevice => {
447 if !self.save_sync_supported() {
448 self.set_save_sync_unsupported_message();
449 return;
450 }
451 self.device_picker_open = true;
452 self.device_picker_loading = true;
453 self.device_picker_error = None;
454 self.message = Some(("Loading devices...".to_string(), Color::Yellow));
455 }
456 SettingsRow::SyncNow => {
457 if !self.save_sync_supported() {
458 self.set_save_sync_unsupported_message();
459 return;
460 }
461 self.message = Some(("Starting save sync...".to_string(), Color::Yellow));
462 }
463 SettingsRow::ExtrasManual => {
464 self.extras_include_manual = !self.extras_include_manual;
465 self.message = Some((
466 format!(
467 "Extras default (manual): {}",
468 if self.extras_include_manual {
469 "on"
470 } else {
471 "off"
472 }
473 ),
474 Color::Green,
475 ));
476 }
477 SettingsRow::ExtrasCover => {
478 self.extras_include_cover = !self.extras_include_cover;
479 self.message = Some((
480 format!(
481 "Extras default (cover): {}",
482 if self.extras_include_cover {
483 "on"
484 } else {
485 "off"
486 }
487 ),
488 Color::Green,
489 ));
490 }
491 SettingsRow::ExtrasRelatedRoms => {
492 self.extras_include_related_roms = !self.extras_include_related_roms;
493 self.message = Some((
494 format!(
495 "Extras default (updates/DLC): {}",
496 if self.extras_include_related_roms {
497 "on"
498 } else {
499 "off"
500 }
501 ),
502 Color::Green,
503 ));
504 }
505 SettingsRow::UseHttps => {
506 self.use_https = !self.use_https;
508 if self.use_https && self.base_url.starts_with("http://") {
509 self.base_url = self.base_url.replace("http://", "https://");
510 self.message = Some(("Updated URL scheme (HTTPS)".to_string(), Color::Green));
511 } else if !self.use_https && self.base_url.starts_with("https://") {
512 self.base_url = self.base_url.replace("https://", "http://");
513 self.message = Some(("Updated URL scheme (HTTP)".to_string(), Color::Green));
514 }
515 }
516 SettingsRow::RomsDir => {
517 self.path_picker = Some((
518 SettingsPickerKind::RomsDir,
519 PathPicker::new(PathPickerMode::Directory, self.download_dir.as_str()),
520 ));
521 }
522 SettingsRow::ConsolePaths | SettingsRow::SaveConsolePaths => {}
523 SettingsRow::SaveDir => {
524 self.path_picker = Some((
525 SettingsPickerKind::SaveDir,
526 PathPicker::new(PathPickerMode::Directory, self.save_dir.as_str()),
527 ));
528 }
529 SettingsRow::BaseUrl => {
530 self.editing = true;
531 self.edit_buffer = self.base_url.clone();
532 self.edit_cursor = self.edit_buffer.len();
533 }
534 SettingsRow::Auth => {}
535 }
536 }
537
538 pub fn save_edit(&mut self) -> bool {
539 if !self.editing {
540 return true; }
542 if self.selected_row() == SettingsRow::BaseUrl {
543 self.base_url = self.edit_buffer.trim().to_string();
544 }
545 self.editing = false;
546 true
547 }
548
549 pub fn cancel_edit(&mut self) {
550 self.editing = false;
551 self.confirm = None;
552 self.path_picker = None;
553 self.console_path_picker = None;
554 self.console_picker_open = false;
555 self.active_console_kind = None;
556 self.message = None;
557 }
558
559 pub fn add_char(&mut self, c: char) {
560 if self.editing {
561 self.edit_buffer.insert(self.edit_cursor, c);
562 self.edit_cursor += 1;
563 }
564 }
565
566 pub fn delete_char(&mut self) {
567 if self.editing && self.edit_cursor > 0 {
568 self.edit_buffer.remove(self.edit_cursor - 1);
569 self.edit_cursor -= 1;
570 }
571 }
572
573 pub fn move_cursor_left(&mut self) {
574 if self.editing && self.edit_cursor > 0 {
575 self.edit_cursor -= 1;
576 }
577 }
578
579 pub fn move_cursor_right(&mut self) {
580 if self.editing && self.edit_cursor < self.edit_buffer.len() {
581 self.edit_cursor += 1;
582 }
583 }
584
585 pub fn render(&mut self, f: &mut Frame, area: Rect) {
586 if let Some((platform_id, ref mut picker)) = self.console_path_picker {
587 let chunks = Layout::default()
588 .constraints([
589 Constraint::Length(4),
590 Constraint::Min(12),
591 Constraint::Length(3),
592 ])
593 .direction(ratatui::layout::Direction::Vertical)
594 .split(area);
595 let platform_name = self
596 .console_platforms
597 .iter()
598 .find(|p| p.id == platform_id)
599 .map(Self::platform_display_name)
600 .unwrap_or_else(|| format!("Platform {platform_id}"));
601 let info = [
602 format!(
603 "romm-cli: v{} | RomM server: {}",
604 self.version, self.server_version
605 ),
606 format!("Console: {platform_name}"),
607 ];
608 f.render_widget(
609 Paragraph::new(info.join("\n")).block(Block::default().borders(Borders::BOTTOM)),
610 chunks[0],
611 );
612 picker.render(
613 f,
614 chunks[1],
615 "Choose console directory",
616 "Esc: cancel Ctrl+Enter: apply typed path (creates folders) Tab: path/list",
617 );
618 f.render_widget(
619 Paragraph::new("Console directory picker — Esc returns without changing")
620 .style(Style::default().fg(Color::Cyan))
621 .block(Block::default().borders(Borders::ALL)),
622 chunks[2],
623 );
624 return;
625 }
626
627 if self.console_picker_open {
628 self.render_console_picker(f, area);
629 return;
630 }
631
632 if let Some((kind, ref mut picker)) = self.path_picker {
633 let chunks = Layout::default()
634 .constraints([
635 Constraint::Length(4),
636 Constraint::Min(12),
637 Constraint::Length(3),
638 ])
639 .direction(ratatui::layout::Direction::Vertical)
640 .split(area);
641 let info = [
642 format!(
643 "romm-cli: v{} | RomM server: {}",
644 self.version, self.server_version
645 ),
646 format!("GitHub: {}", self.github_url),
647 format!("Auth: {}", self.auth_status),
648 ];
649 f.render_widget(
650 Paragraph::new(info.join("\n")).block(Block::default().borders(Borders::BOTTOM)),
651 chunks[0],
652 );
653 let hint =
654 "Esc: cancel Ctrl+Enter: apply typed path (creates folders) Tab: path/list";
655 let title = match kind {
656 SettingsPickerKind::RomsDir => "Choose ROMs directory",
657 SettingsPickerKind::SaveDir => "Choose save directory",
658 };
659 picker.render(f, chunks[1], title, hint);
660 f.render_widget(
661 Paragraph::new("ROMs directory picker — Esc returns without changing")
662 .style(Style::default().fg(Color::Cyan))
663 .block(Block::default().borders(Borders::ALL)),
664 chunks[2],
665 );
666 return;
667 }
668
669 if self.device_picker_open {
670 self.render_device_picker(f, area);
671 return;
672 }
673
674 let chunks = Layout::default()
675 .constraints([
676 Constraint::Length(4), Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), Constraint::Length(3), ])
682 .direction(ratatui::layout::Direction::Vertical)
683 .split(area);
684
685 let info = [
687 format!(
688 "romm-cli: v{} | RomM server: {}",
689 self.version, self.server_version
690 ),
691 format!("GitHub: {}", self.github_url),
692 format!("Auth: {}", self.auth_status),
693 ];
694 f.render_widget(
695 Paragraph::new(info.join("\n")).block(Block::default().borders(Borders::BOTTOM)),
696 chunks[0],
697 );
698
699 let titles = SettingsTab::ALL
701 .iter()
702 .map(|tab| Line::from(Span::raw(tab.title())))
703 .collect::<Vec<_>>();
704 let tabs = Tabs::new(titles)
705 .select(self.selected_tab.index())
706 .block(Block::default().borders(Borders::ALL))
707 .style(Style::default().fg(Color::Gray))
708 .highlight_style(
709 Style::default()
710 .fg(Color::Yellow)
711 .add_modifier(Modifier::BOLD),
712 );
713 f.render_widget(tabs, chunks[1]);
714
715 let items = self
717 .visible_rows()
718 .iter()
719 .copied()
720 .map(|row| self.render_row_item(row))
721 .collect::<Vec<_>>();
722
723 let mut state = ListState::default();
724 state.select(Some(self.selected_row_index()));
725
726 let list = List::new(items)
727 .block(
728 Block::default()
729 .title(format!(" {} ", self.selected_tab.title()))
730 .borders(Borders::ALL),
731 )
732 .highlight_style(
733 Style::default()
734 .add_modifier(Modifier::BOLD)
735 .fg(Color::Yellow),
736 )
737 .highlight_symbol(">> ");
738
739 f.render_stateful_widget(list, chunks[2], &mut state);
740
741 if let Some(confirm) = &self.confirm {
743 let msg = match confirm {
744 SettingsConfirm::Reset => {
745 "Are you sure you want to delete all settings? (Enter: Yes, Esc: Cancel)"
746 }
747 SettingsConfirm::ClearCache => {
748 "Are you sure you want to clear the ROM cache? (Enter: Yes, Esc: Cancel)"
749 }
750 };
751 f.render_widget(
752 Paragraph::new(msg)
753 .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
754 chunks[3],
755 );
756 } else if let Some((msg, color)) = &self.message {
757 f.render_widget(
758 Paragraph::new(msg.as_str()).style(Style::default().fg(*color)),
759 chunks[3],
760 );
761 } else if self.editing {
762 f.render_widget(
763 Paragraph::new("Editing... Enter: save Esc: cancel")
764 .style(Style::default().fg(Color::Cyan)),
765 chunks[3],
766 );
767 }
768
769 let help = if self.confirm.is_some() {
771 "Enter: confirm Esc: cancel"
772 } else if self.editing {
773 "Backspace: delete Arrows: move cursor Enter: save Esc: cancel"
774 } else {
775 "Tab/←/→: tabs ↑/↓: select Enter: edit/toggle S: save to disk Esc: back"
776 };
777 f.render_widget(
778 Paragraph::new(help).block(Block::default().borders(Borders::ALL)),
779 chunks[4],
780 );
781 }
782
783 fn render_row_item(&self, row: SettingsRow) -> ListItem<'static> {
784 let label = self.row_label(row);
785 match row {
786 SettingsRow::SyncDevice | SettingsRow::SyncNow if !self.save_sync_supported() => {
787 ListItem::new(label).style(Style::default().fg(Color::DarkGray))
788 }
789 _ => ListItem::new(label),
790 }
791 }
792
793 fn row_label(&self, row: SettingsRow) -> String {
794 let label = match row {
795 SettingsRow::BaseUrl => format!(
796 "Base URL: {}",
797 if self.editing && self.selected_row() == SettingsRow::BaseUrl {
798 &self.edit_buffer
799 } else {
800 &self.base_url
801 }
802 ),
803 SettingsRow::RomsDir => format!("Roms Dir: {}", self.download_dir),
804 SettingsRow::ConsolePaths => {
805 let mapped = self.rom_platform_dirs.len();
806 format!("Console paths: {mapped} custom · Enter to edit")
807 }
808 SettingsRow::SaveConsolePaths => {
809 let mapped = self.save_platform_dirs.len();
810 format!("Save console paths: {mapped} custom · Enter to edit")
811 }
812 SettingsRow::UseHttps => format!(
813 "Use HTTPS: {}",
814 if self.use_https { "[X] Yes" } else { "[ ] No" }
815 ),
816 SettingsRow::SaveDir => format!("Save Dir: {}", self.save_dir),
817 SettingsRow::SyncDevice => format!(
818 "Sync Device: {}",
819 self.sync_device_id.as_deref().unwrap_or("(not selected)")
820 ),
821 SettingsRow::SyncNow => "Sync Saves Now".to_string(),
822 SettingsRow::ExtrasRelatedRoms => format!(
823 "Incl. updates/DLC (picker default): {}",
824 if self.extras_include_related_roms {
825 "[X] Yes"
826 } else {
827 "[ ] No"
828 }
829 ),
830 SettingsRow::ExtrasCover => format!(
831 "Incl. cover (picker default): {}",
832 if self.extras_include_cover {
833 "[X] Yes"
834 } else {
835 "[ ] No"
836 }
837 ),
838 SettingsRow::ExtrasManual => format!(
839 "Incl. manual (picker default): {}",
840 if self.extras_include_manual {
841 "[X] Yes"
842 } else {
843 "[ ] No"
844 }
845 ),
846 SettingsRow::Auth => format!("Auth: {} (Enter to change)", self.auth_status),
847 SettingsRow::ClearCache => "Clear Cache (Remove cached ROM data)".to_string(),
848 SettingsRow::ResetConfiguration => {
849 "Reset Configuration (Delete settings from disk & keyring)".to_string()
850 }
851 };
852 if matches!(row, SettingsRow::SyncDevice | SettingsRow::SyncNow)
853 && !self.save_sync_supported()
854 {
855 format!("{label} (requires newer RomM server)")
856 } else {
857 label
858 }
859 }
860
861 pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
862 if let Some((kind, ref picker)) = self.path_picker {
863 let chunks = Layout::default()
864 .constraints([
865 Constraint::Length(4),
866 Constraint::Min(12),
867 Constraint::Length(3),
868 ])
869 .direction(ratatui::layout::Direction::Vertical)
870 .split(area);
871 let title = match kind {
872 SettingsPickerKind::RomsDir => "Choose ROMs directory",
873 SettingsPickerKind::SaveDir => "Choose save directory",
874 };
875 return picker.cursor_position(chunks[1], title);
876 }
877
878 if !self.editing || self.selected_row() != SettingsRow::BaseUrl {
879 return None;
880 }
881
882 let chunks = Layout::default()
883 .constraints([
884 Constraint::Length(4),
885 Constraint::Length(3),
886 Constraint::Min(10),
887 Constraint::Length(3),
888 Constraint::Length(3),
889 ])
890 .direction(ratatui::layout::Direction::Vertical)
891 .split(area);
892
893 let list_area = chunks[2];
894 let y = list_area.y + 1 + self.selected_row_index() as u16;
895 let label_len = 14; let x = list_area.x + 1 + 3 + label_len + self.edit_cursor as u16;
897
898 Some((x, y))
899 }
900
901 pub fn set_devices(&mut self, devices: Vec<DeviceSchema>) {
902 self.devices = devices;
903 self.device_picker_loading = false;
904 self.device_picker_error = None;
905 self.device_selected_index = self
906 .sync_device_id
907 .as_ref()
908 .and_then(|id| self.devices.iter().position(|d| &d.id == id))
909 .unwrap_or(0)
910 .min(self.devices.len().saturating_sub(1));
911 }
912
913 pub fn set_device_error(&mut self, error: String) {
914 self.device_picker_loading = false;
915 self.device_picker_error = Some(error);
916 }
917
918 pub fn device_next(&mut self) {
919 if !self.devices.is_empty() {
920 self.device_selected_index =
921 (self.device_selected_index + 1).min(self.devices.len() - 1);
922 }
923 }
924
925 pub fn device_previous(&mut self) {
926 self.device_selected_index = self.device_selected_index.saturating_sub(1);
927 }
928
929 pub fn confirm_device(&mut self) {
930 if let Some(device) = self.devices.get(self.device_selected_index) {
931 self.sync_device_id = Some(device.id.clone());
932 self.device_picker_open = false;
933 self.message = Some((
934 "Sync device updated (press S to save)".to_string(),
935 Color::Green,
936 ));
937 }
938 }
939
940 fn render_device_picker(&mut self, f: &mut Frame, area: Rect) {
941 let chunks = Layout::default()
942 .constraints([
943 Constraint::Length(4),
944 Constraint::Min(10),
945 Constraint::Length(3),
946 ])
947 .direction(ratatui::layout::Direction::Vertical)
948 .split(area);
949 let info = [
950 format!(
951 "romm-cli: v{} | RomM server: {}",
952 self.version, self.server_version
953 ),
954 "Select the RomM sync device used for manual push-pull.".to_string(),
955 ];
956 f.render_widget(
957 Paragraph::new(info.join("\n")).block(Block::default().borders(Borders::BOTTOM)),
958 chunks[0],
959 );
960 if self.device_picker_loading {
961 f.render_widget(
962 Paragraph::new("Loading devices...")
963 .block(Block::default().title(" Devices ").borders(Borders::ALL)),
964 chunks[1],
965 );
966 } else if let Some(error) = &self.device_picker_error {
967 f.render_widget(
968 Paragraph::new(format!("Could not load devices: {error}"))
969 .style(Style::default().fg(Color::Red))
970 .block(Block::default().title(" Devices ").borders(Borders::ALL)),
971 chunks[1],
972 );
973 } else {
974 let items: Vec<ListItem> = self
975 .devices
976 .iter()
977 .map(|d| {
978 let name = d.name.as_deref().unwrap_or("(unnamed)");
979 ListItem::new(format!("{name} [{}] mode={:?}", d.id, d.sync_mode))
980 })
981 .collect();
982 let mut state = ListState::default();
983 state.select(Some(self.device_selected_index));
984 f.render_stateful_widget(
985 List::new(items)
986 .block(Block::default().title(" Devices ").borders(Borders::ALL))
987 .highlight_symbol(">> ")
988 .highlight_style(
989 Style::default()
990 .fg(Color::Yellow)
991 .add_modifier(Modifier::BOLD),
992 ),
993 chunks[1],
994 &mut state,
995 );
996 }
997 f.render_widget(
998 Paragraph::new("Enter: choose Esc: cancel ↑/↓: select")
999 .block(Block::default().borders(Borders::ALL)),
1000 chunks[2],
1001 );
1002 }
1003
1004 fn render_console_picker(&mut self, f: &mut Frame, area: Rect) {
1005 let kind = self.active_console_kind.unwrap_or(ConsolePathKind::Roms);
1006 let chunks = Layout::default()
1007 .constraints([
1008 Constraint::Length(4),
1009 Constraint::Min(10),
1010 Constraint::Length(3),
1011 ])
1012 .direction(ratatui::layout::Direction::Vertical)
1013 .split(area);
1014 let subtitle = match kind {
1015 ConsolePathKind::Roms => "Set a custom ROM path for consoles on other drives.",
1016 ConsolePathKind::Saves => "Set a custom save path for consoles on other drives.",
1017 };
1018 let info = [
1019 format!(
1020 "romm-cli: v{} | RomM server: {}",
1021 self.version, self.server_version
1022 ),
1023 subtitle.to_string(),
1024 ];
1025 f.render_widget(
1026 Paragraph::new(info.join("\n")).block(Block::default().borders(Borders::BOTTOM)),
1027 chunks[0],
1028 );
1029 if self.console_picker_loading {
1030 f.render_widget(
1031 Paragraph::new("Loading platforms...")
1032 .block(Block::default().title(" Consoles ").borders(Borders::ALL)),
1033 chunks[1],
1034 );
1035 } else if let Some(error) = &self.console_picker_error {
1036 f.render_widget(
1037 Paragraph::new(format!("Could not load platforms: {error}"))
1038 .style(Style::default().fg(Color::Red))
1039 .block(Block::default().title(" Consoles ").borders(Borders::ALL)),
1040 chunks[1],
1041 );
1042 } else if self.console_platforms.is_empty() {
1043 f.render_widget(
1044 Paragraph::new("No platforms returned from the server.")
1045 .style(Style::default().fg(Color::Yellow))
1046 .block(Block::default().title(" Consoles ").borders(Borders::ALL)),
1047 chunks[1],
1048 );
1049 } else {
1050 let items: Vec<ListItem> = self
1051 .console_platforms
1052 .iter()
1053 .map(|platform| {
1054 let name = Self::platform_display_name(platform);
1055 let path = self.console_dir_preview(kind, platform);
1056 let custom = self
1057 .console_dirs(kind)
1058 .get(&platform.id)
1059 .is_some_and(|s| !s.trim().is_empty());
1060 let tag = if custom {
1061 "custom path"
1062 } else {
1063 "base default"
1064 };
1065 ListItem::new(format!("{name} [{tag}] {path}"))
1066 })
1067 .collect();
1068 let mut state = ListState::default();
1069 state.select(Some(self.console_selected_index));
1070 f.render_stateful_widget(
1071 List::new(items)
1072 .block(Block::default().title(" Consoles ").borders(Borders::ALL))
1073 .highlight_symbol(">> ")
1074 .highlight_style(
1075 Style::default()
1076 .fg(Color::Yellow)
1077 .add_modifier(Modifier::BOLD),
1078 ),
1079 chunks[1],
1080 &mut state,
1081 );
1082 }
1083 f.render_widget(
1084 Paragraph::new("Enter: set path Del: clear custom Esc: back ↑/↓: select")
1085 .block(Block::default().borders(Borders::ALL)),
1086 chunks[2],
1087 );
1088 }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use std::collections::HashMap;
1094
1095 use super::*;
1096 use crate::config::{ExtrasDefaults, SaveSyncConfig};
1097 use crate::feature_compat::{
1098 supported_save_sync_compatibility, FeatureCompatibility, RequiredEndpoint,
1099 SAVE_SYNC_FEATURE, SAVE_SYNC_UNSUPPORTED_MESSAGE,
1100 };
1101
1102 fn test_config() -> Config {
1103 Config {
1104 base_url: "https://romm.example.com".to_string(),
1105 download_dir: "C:\\roms".to_string(),
1106 use_https: true,
1107 auth: None,
1108 extras_defaults: ExtrasDefaults::default(),
1109 save_sync: SaveSyncConfig {
1110 save_dir: Some("C:\\saves".to_string()),
1111 device_id: None,
1112 platform_dirs: HashMap::new(),
1113 },
1114 roms_layout: RomsLayoutConfig::default(),
1115 }
1116 }
1117
1118 fn screen() -> SettingsScreen {
1119 SettingsScreen::new(
1120 &test_config(),
1121 Some("1.0.0"),
1122 supported_save_sync_compatibility(),
1123 )
1124 }
1125
1126 fn unsupported_screen() -> SettingsScreen {
1127 SettingsScreen::new(
1128 &test_config(),
1129 Some("1.0.0"),
1130 FeatureCompatibility::from_registry(
1131 SAVE_SYNC_FEATURE,
1132 SAVE_SYNC_UNSUPPORTED_MESSAGE,
1133 &[RequiredEndpoint {
1134 method: "GET",
1135 path: "/api/devices",
1136 }],
1137 &crate::openapi::EndpointRegistry::default(),
1138 ),
1139 )
1140 }
1141
1142 #[test]
1143 fn tabs_expose_expected_rows() {
1144 let s = screen();
1145 assert_eq!(
1146 s.visible_rows(),
1147 vec![SettingsRow::BaseUrl, SettingsRow::UseHttps]
1148 );
1149 assert_eq!(
1150 CONNECTION_ROWS,
1151 [SettingsRow::BaseUrl, SettingsRow::UseHttps]
1152 );
1153 assert_eq!(SettingsTab::Saves as usize, SettingsTab::Roms.index() + 1);
1154 assert_eq!(
1155 SAVES_ROWS,
1156 [
1157 SettingsRow::SaveDir,
1158 SettingsRow::SaveConsolePaths,
1159 SettingsRow::SyncDevice,
1160 SettingsRow::SyncNow
1161 ]
1162 );
1163 assert_eq!(
1164 EXTRAS_ROWS,
1165 [
1166 SettingsRow::ExtrasRelatedRoms,
1167 SettingsRow::ExtrasCover,
1168 SettingsRow::ExtrasManual
1169 ]
1170 );
1171 assert_eq!(
1172 AUTH_MAINT_ROWS,
1173 [
1174 SettingsRow::Auth,
1175 SettingsRow::ClearCache,
1176 SettingsRow::ResetConfiguration
1177 ]
1178 );
1179 }
1180
1181 #[test]
1182 fn row_navigation_wraps_within_active_tab() {
1183 let mut s = screen();
1184
1185 assert_eq!(s.selected_row(), SettingsRow::BaseUrl);
1186 s.previous();
1187 assert_eq!(s.selected_row(), SettingsRow::UseHttps);
1188 s.next();
1189 assert_eq!(s.selected_row(), SettingsRow::BaseUrl);
1190 }
1191
1192 #[test]
1193 fn saves_tab_shows_save_console_paths_row() {
1194 let mut s = screen();
1195 s.selected_tab = SettingsTab::Saves;
1196 assert_eq!(
1197 s.visible_rows(),
1198 vec![
1199 SettingsRow::SaveDir,
1200 SettingsRow::SaveConsolePaths,
1201 SettingsRow::SyncDevice,
1202 SettingsRow::SyncNow,
1203 ]
1204 );
1205 s.next();
1206 assert_eq!(s.selected_row(), SettingsRow::SaveConsolePaths);
1207 }
1208
1209 #[test]
1210 fn roms_tab_always_shows_console_paths_row() {
1211 let mut s = screen();
1212 s.selected_tab = SettingsTab::Roms;
1213 assert_eq!(
1214 s.visible_rows(),
1215 vec![SettingsRow::RomsDir, SettingsRow::ConsolePaths]
1216 );
1217 s.next();
1218 assert_eq!(s.selected_row(), SettingsRow::ConsolePaths);
1219 }
1220
1221 #[test]
1222 fn tab_navigation_preserves_per_tab_selection() {
1223 let mut s = screen();
1224
1225 s.next();
1226 assert_eq!(s.selected_row(), SettingsRow::UseHttps);
1227
1228 s.next_tab();
1229 assert_eq!(s.selected_tab, SettingsTab::Roms);
1230 assert_eq!(s.selected_row(), SettingsRow::RomsDir);
1231
1232 s.next_tab();
1233 assert_eq!(s.selected_tab, SettingsTab::Saves);
1234 assert_eq!(s.selected_row(), SettingsRow::SaveDir);
1235
1236 s.next();
1237 assert_eq!(s.selected_row(), SettingsRow::SaveConsolePaths);
1238
1239 s.next();
1240 assert_eq!(s.selected_row(), SettingsRow::SyncDevice);
1241
1242 s.previous_tab();
1243 assert_eq!(s.selected_tab, SettingsTab::Roms);
1244 assert_eq!(s.selected_row(), SettingsRow::RomsDir);
1245
1246 s.previous_tab();
1247 assert_eq!(s.selected_tab, SettingsTab::Connection);
1248 assert_eq!(s.selected_row(), SettingsRow::UseHttps);
1249
1250 s.next_tab();
1251 assert_eq!(s.selected_tab, SettingsTab::Roms);
1252 assert_eq!(s.selected_row(), SettingsRow::RomsDir);
1253
1254 s.next_tab();
1255 assert_eq!(s.selected_tab, SettingsTab::Saves);
1256 assert_eq!(s.selected_row(), SettingsRow::SyncDevice);
1257 }
1258
1259 #[test]
1260 fn activation_rows_resolve_to_expected_intents() {
1261 let mut s = screen();
1262
1263 s.selected_tab = SettingsTab::AuthMaintenance;
1264 assert_eq!(s.selected_row(), SettingsRow::Auth);
1265
1266 s.next();
1267 assert_eq!(s.selected_row(), SettingsRow::ClearCache);
1268 s.enter_edit();
1269 assert_eq!(s.confirm, Some(SettingsConfirm::ClearCache));
1270
1271 s.cancel_edit();
1272 s.next();
1273 assert_eq!(s.selected_row(), SettingsRow::ResetConfiguration);
1274 s.enter_edit();
1275 assert_eq!(s.confirm, Some(SettingsConfirm::Reset));
1276 }
1277
1278 #[test]
1279 fn save_action_rows_trigger_matching_state() {
1280 let mut s = screen();
1281 s.selected_tab = SettingsTab::Saves;
1282
1283 s.next();
1284 s.next();
1285 assert_eq!(s.selected_row(), SettingsRow::SyncDevice);
1286 s.enter_edit();
1287 assert!(s.device_picker_open);
1288 assert!(s.device_picker_loading);
1289
1290 s.device_picker_open = false;
1291 s.device_picker_loading = false;
1292 s.next();
1293 assert_eq!(s.selected_row(), SettingsRow::SyncNow);
1294 s.enter_edit();
1295 assert_eq!(
1296 s.message.as_ref().map(|(msg, _)| msg.as_str()),
1297 Some("Starting save sync...")
1298 );
1299 }
1300
1301 #[test]
1302 fn extras_rows_toggle_matching_defaults() {
1303 let mut s = screen();
1304 s.selected_tab = SettingsTab::Extras;
1305
1306 s.enter_edit();
1307 assert!(!s.extras_include_related_roms);
1308 assert!(s.extras_include_cover);
1309 assert!(s.extras_include_manual);
1310
1311 s.next();
1312 s.enter_edit();
1313 assert!(!s.extras_include_cover);
1314 assert!(s.extras_include_manual);
1315
1316 s.next();
1317 s.enter_edit();
1318 assert!(!s.extras_include_manual);
1319 }
1320
1321 #[test]
1322 fn unsupported_save_sync_rows_do_not_open_device_picker_or_start_sync() {
1323 let mut s = unsupported_screen();
1324 s.selected_tab = SettingsTab::Saves;
1325
1326 s.next();
1327 s.next();
1328 assert_eq!(s.selected_row(), SettingsRow::SyncDevice);
1329 s.enter_edit();
1330 assert!(!s.device_picker_open);
1331 assert_eq!(
1332 s.message.as_ref().map(|(msg, _)| msg.as_str()),
1333 Some(
1334 "This RomM server does not expose save-sync endpoints; upgrade RomM to use romm-cli sync. Missing endpoint(s): GET /api/devices"
1335 )
1336 );
1337
1338 s.next();
1339 assert_eq!(s.selected_row(), SettingsRow::SyncNow);
1340 s.enter_edit();
1341 assert!(!s.sync_inflight);
1342 assert!(s
1343 .message
1344 .as_ref()
1345 .map(|(msg, _)| msg.contains(SAVE_SYNC_UNSUPPORTED_MESSAGE))
1346 .unwrap_or(false));
1347 }
1348
1349 #[test]
1350 fn unsupported_save_sync_rows_render_requires_newer_server_annotation() {
1351 let s = unsupported_screen();
1352
1353 assert!(s
1354 .row_label(SettingsRow::SyncDevice)
1355 .contains("requires newer RomM server"));
1356 assert!(s
1357 .row_label(SettingsRow::SyncNow)
1358 .contains("requires newer RomM server"));
1359 }
1360}