Skip to main content

romm_cli/tui/screens/
settings.rs

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
113/// Interactive settings screen for editing current config.
114pub struct SettingsScreen {
115    pub base_url: String,
116    pub download_dir: String,
117    pub use_https: bool,
118    /// Default: pre-check related ROMs (updates/DLC) in TUI extras picker.
119    pub extras_include_related_roms: bool,
120    /// Default: pre-check cover in TUI extras picker when available.
121    pub extras_include_cover: bool,
122    /// Default: pre-check manual in TUI extras picker when available.
123    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    /// ROMs directory browser (`None` when not choosing a folder).
136    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    /// Per-console directory browser (`None` when not picking for a platform).
156    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        // Legacy helper for tests; prefer visible_rows().
392        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                // Toggle HTTPS directly and keep the Base URL scheme in sync.
507                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; // UseHttps toggle is "saved" immediately in memory
541        }
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), // Header info
677                Constraint::Length(3), // Settings tabs
678                Constraint::Min(10),   // Editable list
679                Constraint::Length(3), // Message/Hint
680                Constraint::Length(3), // Footer help
681            ])
682            .direction(ratatui::layout::Direction::Vertical)
683            .split(area);
684
685        // -- Header Info --
686        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        // -- Tabs --
700        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        // -- Editable List --
716        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        // -- Message Area --
742        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        // -- Footer Help --
770        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; // "Base URL:     ".len()
896        let x = list_area.x + 1 /* border */ + 3 /* highlight symbol */ + 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}