Skip to main content

romm_cli/tui/screens/
settings.rs

1use ratatui::layout::{Constraint, Layout, Rect};
2use ratatui::style::{Color, Modifier, Style};
3use ratatui::text::{Line, Span};
4use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs};
5use ratatui::Frame;
6
7use crate::config::{disk_has_unresolved_keyring_sentinel, Config};
8use crate::endpoints::device::DeviceSchema;
9use crate::feature_compat::SaveSyncCompatibility;
10use crate::tui::path_picker::{PathPicker, PathPickerMode};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum SettingsTab {
14    Connection,
15    Saves,
16    Extras,
17    AuthMaintenance,
18}
19
20impl SettingsTab {
21    pub const ALL: [SettingsTab; 4] = [
22        SettingsTab::Connection,
23        SettingsTab::Saves,
24        SettingsTab::Extras,
25        SettingsTab::AuthMaintenance,
26    ];
27
28    pub const COUNT: usize = Self::ALL.len();
29
30    pub fn index(self) -> usize {
31        match self {
32            SettingsTab::Connection => 0,
33            SettingsTab::Saves => 1,
34            SettingsTab::Extras => 2,
35            SettingsTab::AuthMaintenance => 3,
36        }
37    }
38
39    fn title(self) -> &'static str {
40        match self {
41            SettingsTab::Connection => "Connection",
42            SettingsTab::Saves => "Saves",
43            SettingsTab::Extras => "Extras",
44            SettingsTab::AuthMaintenance => "Auth/Maint",
45        }
46    }
47
48    pub fn rows(self) -> &'static [SettingsRow] {
49        match self {
50            SettingsTab::Connection => &CONNECTION_ROWS,
51            SettingsTab::Saves => &SAVES_ROWS,
52            SettingsTab::Extras => &EXTRAS_ROWS,
53            SettingsTab::AuthMaintenance => &AUTH_MAINT_ROWS,
54        }
55    }
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum SettingsRow {
60    BaseUrl,
61    RomsDir,
62    UseHttps,
63    SaveDir,
64    SyncDevice,
65    SyncNow,
66    ExtrasRelatedRoms,
67    ExtrasCover,
68    ExtrasManual,
69    Auth,
70    ClearCache,
71    ResetConfiguration,
72}
73
74const CONNECTION_ROWS: [SettingsRow; 3] = [
75    SettingsRow::BaseUrl,
76    SettingsRow::RomsDir,
77    SettingsRow::UseHttps,
78];
79const SAVES_ROWS: [SettingsRow; 3] = [
80    SettingsRow::SaveDir,
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(Debug, PartialEq, Eq)]
102pub enum SettingsConfirm {
103    Reset,
104    ClearCache,
105}
106
107/// Interactive settings screen for editing current config.
108pub struct SettingsScreen {
109    pub base_url: String,
110    pub download_dir: String,
111    pub use_https: bool,
112    /// Default: pre-check related ROMs (updates/DLC) in TUI extras picker.
113    pub extras_include_related_roms: bool,
114    /// Default: pre-check cover in TUI extras picker when available.
115    pub extras_include_cover: bool,
116    /// Default: pre-check manual in TUI extras picker when available.
117    pub extras_include_manual: bool,
118    pub auth_status: String,
119    pub version: String,
120    pub server_version: String,
121    pub github_url: String,
122
123    pub selected_tab: SettingsTab,
124    selected_indices: [usize; SettingsTab::COUNT],
125    pub editing: bool,
126    pub confirm: Option<SettingsConfirm>,
127    pub edit_buffer: String,
128    pub edit_cursor: usize,
129    /// ROMs directory browser (`None` when not choosing a folder).
130    pub path_picker: Option<(SettingsPickerKind, PathPicker)>,
131    pub save_dir: String,
132    pub sync_device_id: Option<String>,
133    pub devices: Vec<DeviceSchema>,
134    pub device_picker_open: bool,
135    pub device_picker_loading: bool,
136    pub device_picker_error: Option<String>,
137    pub device_selected_index: usize,
138    pub sync_inflight: bool,
139    pub message: Option<(String, Color)>,
140    pub save_sync_compat: SaveSyncCompatibility,
141}
142
143impl SettingsScreen {
144    pub fn new(
145        config: &Config,
146        romm_server_version: Option<&str>,
147        save_sync_compat: SaveSyncCompatibility,
148    ) -> Self {
149        let auth_status = match &config.auth {
150            Some(crate::config::AuthConfig::Basic { username, .. }) => {
151                format!("Basic (user: {})", username)
152            }
153            Some(crate::config::AuthConfig::Bearer { .. }) => "API Token".to_string(),
154            Some(crate::config::AuthConfig::ApiKey { header, .. }) => {
155                format!("API key (header: {})", header)
156            }
157            None => {
158                if disk_has_unresolved_keyring_sentinel(config) {
159                    "None — disk still references keyring; set API_TOKEN / ROMM_TOKEN_FILE or see docs/troubleshooting-auth.md"
160                        .to_string()
161                } else {
162                    "None (no API credentials in env/keyring)".to_string()
163                }
164            }
165        };
166
167        let server_version = romm_server_version
168            .map(String::from)
169            .unwrap_or_else(|| "unavailable (heartbeat failed)".to_string());
170
171        Self {
172            base_url: config.base_url.clone(),
173            download_dir: config.download_dir.clone(),
174            save_dir: crate::config::resolved_save_dir(config)
175                .display()
176                .to_string(),
177            sync_device_id: config.save_sync.device_id.clone(),
178            use_https: config.use_https,
179            extras_include_related_roms: config.extras_defaults.include_related_roms,
180            extras_include_cover: config.extras_defaults.include_cover,
181            extras_include_manual: config.extras_defaults.include_manual,
182            auth_status,
183            version: env!("CARGO_PKG_VERSION").to_string(),
184            server_version,
185            github_url: "https://github.com/patricksmill/romm-cli".to_string(),
186            selected_tab: SettingsTab::Connection,
187            selected_indices: [0; SettingsTab::COUNT],
188            editing: false,
189            confirm: None,
190            edit_buffer: String::new(),
191            edit_cursor: 0,
192            path_picker: None,
193            devices: Vec::new(),
194            device_picker_open: false,
195            device_picker_loading: false,
196            device_picker_error: None,
197            device_selected_index: 0,
198            sync_inflight: false,
199            message: None,
200            save_sync_compat,
201        }
202    }
203
204    pub fn save_sync_supported(&self) -> bool {
205        self.save_sync_compat.supported
206    }
207
208    pub fn set_save_sync_unsupported_message(&mut self) {
209        self.message = Some((self.save_sync_compat.unsupported_message(), Color::Yellow));
210    }
211
212    pub fn selected_row_index(&self) -> usize {
213        let rows = self.selected_tab.rows();
214        self.selected_indices[self.selected_tab.index()].min(rows.len().saturating_sub(1))
215    }
216
217    fn set_selected_row_index(&mut self, index: usize) {
218        let max = self.selected_tab.rows().len().saturating_sub(1);
219        self.selected_indices[self.selected_tab.index()] = index.min(max);
220    }
221
222    pub fn selected_row(&self) -> SettingsRow {
223        self.selected_tab.rows()[self.selected_row_index()]
224    }
225
226    pub fn active_rows(&self) -> &'static [SettingsRow] {
227        self.selected_tab.rows()
228    }
229
230    pub fn next_tab(&mut self) {
231        if self.editing || self.confirm.is_some() {
232            return;
233        }
234        let next = (self.selected_tab.index() + 1) % SettingsTab::COUNT;
235        self.selected_tab = SettingsTab::ALL[next];
236        self.set_selected_row_index(self.selected_row_index());
237    }
238
239    pub fn previous_tab(&mut self) {
240        if self.editing || self.confirm.is_some() {
241            return;
242        }
243        let previous = (self.selected_tab.index() + SettingsTab::COUNT - 1) % SettingsTab::COUNT;
244        self.selected_tab = SettingsTab::ALL[previous];
245        self.set_selected_row_index(self.selected_row_index());
246    }
247
248    pub fn next(&mut self) {
249        if !self.editing && self.confirm.is_none() {
250            let len = self.selected_tab.rows().len();
251            if len > 0 {
252                self.set_selected_row_index((self.selected_row_index() + 1) % len);
253            }
254        }
255    }
256
257    pub fn previous(&mut self) {
258        if !self.editing && self.confirm.is_none() {
259            let len = self.selected_tab.rows().len();
260            if len == 0 {
261                return;
262            }
263            if self.selected_row_index() == 0 {
264                self.set_selected_row_index(len - 1);
265            } else {
266                self.set_selected_row_index(self.selected_row_index() - 1);
267            }
268        }
269    }
270
271    pub fn enter_edit(&mut self) {
272        match self.selected_row() {
273            SettingsRow::ResetConfiguration => self.confirm = Some(SettingsConfirm::Reset),
274            SettingsRow::ClearCache => self.confirm = Some(SettingsConfirm::ClearCache),
275            SettingsRow::SyncDevice => {
276                if !self.save_sync_supported() {
277                    self.set_save_sync_unsupported_message();
278                    return;
279                }
280                self.device_picker_open = true;
281                self.device_picker_loading = true;
282                self.device_picker_error = None;
283                self.message = Some(("Loading devices...".to_string(), Color::Yellow));
284            }
285            SettingsRow::SyncNow => {
286                if !self.save_sync_supported() {
287                    self.set_save_sync_unsupported_message();
288                    return;
289                }
290                self.message = Some(("Starting save sync...".to_string(), Color::Yellow));
291            }
292            SettingsRow::ExtrasManual => {
293                self.extras_include_manual = !self.extras_include_manual;
294                self.message = Some((
295                    format!(
296                        "Extras default (manual): {}",
297                        if self.extras_include_manual {
298                            "on"
299                        } else {
300                            "off"
301                        }
302                    ),
303                    Color::Green,
304                ));
305            }
306            SettingsRow::ExtrasCover => {
307                self.extras_include_cover = !self.extras_include_cover;
308                self.message = Some((
309                    format!(
310                        "Extras default (cover): {}",
311                        if self.extras_include_cover {
312                            "on"
313                        } else {
314                            "off"
315                        }
316                    ),
317                    Color::Green,
318                ));
319            }
320            SettingsRow::ExtrasRelatedRoms => {
321                self.extras_include_related_roms = !self.extras_include_related_roms;
322                self.message = Some((
323                    format!(
324                        "Extras default (updates/DLC): {}",
325                        if self.extras_include_related_roms {
326                            "on"
327                        } else {
328                            "off"
329                        }
330                    ),
331                    Color::Green,
332                ));
333            }
334            SettingsRow::UseHttps => {
335                // Toggle HTTPS directly and keep the Base URL scheme in sync.
336                self.use_https = !self.use_https;
337                if self.use_https && self.base_url.starts_with("http://") {
338                    self.base_url = self.base_url.replace("http://", "https://");
339                    self.message = Some(("Updated URL scheme (HTTPS)".to_string(), Color::Green));
340                } else if !self.use_https && self.base_url.starts_with("https://") {
341                    self.base_url = self.base_url.replace("https://", "http://");
342                    self.message = Some(("Updated URL scheme (HTTP)".to_string(), Color::Green));
343                }
344            }
345            SettingsRow::RomsDir => {
346                self.path_picker = Some((
347                    SettingsPickerKind::RomsDir,
348                    PathPicker::new(PathPickerMode::Directory, self.download_dir.as_str()),
349                ));
350            }
351            SettingsRow::SaveDir => {
352                self.path_picker = Some((
353                    SettingsPickerKind::SaveDir,
354                    PathPicker::new(PathPickerMode::Directory, self.save_dir.as_str()),
355                ));
356            }
357            SettingsRow::BaseUrl => {
358                self.editing = true;
359                self.edit_buffer = self.base_url.clone();
360                self.edit_cursor = self.edit_buffer.len();
361            }
362            SettingsRow::Auth => {}
363        }
364    }
365
366    pub fn save_edit(&mut self) -> bool {
367        if !self.editing {
368            return true; // UseHttps toggle is "saved" immediately in memory
369        }
370        if self.selected_row() == SettingsRow::BaseUrl {
371            self.base_url = self.edit_buffer.trim().to_string();
372        }
373        self.editing = false;
374        true
375    }
376
377    pub fn cancel_edit(&mut self) {
378        self.editing = false;
379        self.confirm = None;
380        self.path_picker = None;
381        self.message = None;
382    }
383
384    pub fn add_char(&mut self, c: char) {
385        if self.editing {
386            self.edit_buffer.insert(self.edit_cursor, c);
387            self.edit_cursor += 1;
388        }
389    }
390
391    pub fn delete_char(&mut self) {
392        if self.editing && self.edit_cursor > 0 {
393            self.edit_buffer.remove(self.edit_cursor - 1);
394            self.edit_cursor -= 1;
395        }
396    }
397
398    pub fn move_cursor_left(&mut self) {
399        if self.editing && self.edit_cursor > 0 {
400            self.edit_cursor -= 1;
401        }
402    }
403
404    pub fn move_cursor_right(&mut self) {
405        if self.editing && self.edit_cursor < self.edit_buffer.len() {
406            self.edit_cursor += 1;
407        }
408    }
409
410    pub fn render(&mut self, f: &mut Frame, area: Rect) {
411        if let Some((kind, ref mut picker)) = self.path_picker {
412            let chunks = Layout::default()
413                .constraints([
414                    Constraint::Length(4),
415                    Constraint::Min(12),
416                    Constraint::Length(3),
417                ])
418                .direction(ratatui::layout::Direction::Vertical)
419                .split(area);
420            let info = [
421                format!(
422                    "romm-cli: v{} | RomM server: {}",
423                    self.version, self.server_version
424                ),
425                format!("GitHub:   {}", self.github_url),
426                format!("Auth:     {}", self.auth_status),
427            ];
428            f.render_widget(
429                Paragraph::new(info.join("\n")).block(Block::default().borders(Borders::BOTTOM)),
430                chunks[0],
431            );
432            let hint =
433                "Esc: cancel   Ctrl+Enter: apply typed path (creates folders)   Tab: path/list";
434            let title = match kind {
435                SettingsPickerKind::RomsDir => "Choose ROMs directory",
436                SettingsPickerKind::SaveDir => "Choose save directory",
437            };
438            picker.render(f, chunks[1], title, hint);
439            f.render_widget(
440                Paragraph::new("ROMs directory picker — Esc returns without changing")
441                    .style(Style::default().fg(Color::Cyan))
442                    .block(Block::default().borders(Borders::ALL)),
443                chunks[2],
444            );
445            return;
446        }
447
448        if self.device_picker_open {
449            self.render_device_picker(f, area);
450            return;
451        }
452
453        let chunks = Layout::default()
454            .constraints([
455                Constraint::Length(4), // Header info
456                Constraint::Length(3), // Settings tabs
457                Constraint::Min(10),   // Editable list
458                Constraint::Length(3), // Message/Hint
459                Constraint::Length(3), // Footer help
460            ])
461            .direction(ratatui::layout::Direction::Vertical)
462            .split(area);
463
464        // -- Header Info --
465        let info = [
466            format!(
467                "romm-cli: v{} | RomM server: {}",
468                self.version, self.server_version
469            ),
470            format!("GitHub:   {}", self.github_url),
471            format!("Auth:     {}", self.auth_status),
472        ];
473        f.render_widget(
474            Paragraph::new(info.join("\n")).block(Block::default().borders(Borders::BOTTOM)),
475            chunks[0],
476        );
477
478        // -- Tabs --
479        let titles = SettingsTab::ALL
480            .iter()
481            .map(|tab| Line::from(Span::raw(tab.title())))
482            .collect::<Vec<_>>();
483        let tabs = Tabs::new(titles)
484            .select(self.selected_tab.index())
485            .block(Block::default().borders(Borders::ALL))
486            .style(Style::default().fg(Color::Gray))
487            .highlight_style(
488                Style::default()
489                    .fg(Color::Yellow)
490                    .add_modifier(Modifier::BOLD),
491            );
492        f.render_widget(tabs, chunks[1]);
493
494        // -- Editable List --
495        let items = self
496            .active_rows()
497            .iter()
498            .copied()
499            .map(|row| self.render_row_item(row))
500            .collect::<Vec<_>>();
501
502        let mut state = ListState::default();
503        state.select(Some(self.selected_row_index()));
504
505        let list = List::new(items)
506            .block(
507                Block::default()
508                    .title(format!(" {} ", self.selected_tab.title()))
509                    .borders(Borders::ALL),
510            )
511            .highlight_style(
512                Style::default()
513                    .add_modifier(Modifier::BOLD)
514                    .fg(Color::Yellow),
515            )
516            .highlight_symbol(">> ");
517
518        f.render_stateful_widget(list, chunks[2], &mut state);
519
520        // -- Message Area --
521        if let Some(confirm) = &self.confirm {
522            let msg = match confirm {
523                SettingsConfirm::Reset => {
524                    "Are you sure you want to delete all settings? (Enter: Yes, Esc: Cancel)"
525                }
526                SettingsConfirm::ClearCache => {
527                    "Are you sure you want to clear the ROM cache? (Enter: Yes, Esc: Cancel)"
528                }
529            };
530            f.render_widget(
531                Paragraph::new(msg)
532                    .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
533                chunks[3],
534            );
535        } else if let Some((msg, color)) = &self.message {
536            f.render_widget(
537                Paragraph::new(msg.as_str()).style(Style::default().fg(*color)),
538                chunks[3],
539            );
540        } else if self.editing {
541            f.render_widget(
542                Paragraph::new("Editing... Enter: save   Esc: cancel")
543                    .style(Style::default().fg(Color::Cyan)),
544                chunks[3],
545            );
546        }
547
548        // -- Footer Help --
549        let help = if self.confirm.is_some() {
550            "Enter: confirm   Esc: cancel"
551        } else if self.editing {
552            "Backspace: delete   Arrows: move cursor   Enter: save   Esc: cancel"
553        } else {
554            "Tab/←/→: tabs   ↑/↓: select   Enter: edit/toggle   S: save to disk   Esc: back"
555        };
556        f.render_widget(
557            Paragraph::new(help).block(Block::default().borders(Borders::ALL)),
558            chunks[4],
559        );
560    }
561
562    fn render_row_item(&self, row: SettingsRow) -> ListItem<'static> {
563        let label = self.row_label(row);
564        match row {
565            SettingsRow::SyncDevice | SettingsRow::SyncNow if !self.save_sync_supported() => {
566                ListItem::new(label).style(Style::default().fg(Color::DarkGray))
567            }
568            _ => ListItem::new(label),
569        }
570    }
571
572    fn row_label(&self, row: SettingsRow) -> String {
573        let label = match row {
574            SettingsRow::BaseUrl => format!(
575                "Base URL:     {}",
576                if self.editing && self.selected_row() == SettingsRow::BaseUrl {
577                    &self.edit_buffer
578                } else {
579                    &self.base_url
580                }
581            ),
582            SettingsRow::RomsDir => format!("Roms Dir:     {}", self.download_dir),
583            SettingsRow::UseHttps => format!(
584                "Use HTTPS:    {}",
585                if self.use_https { "[X] Yes" } else { "[ ] No" }
586            ),
587            SettingsRow::SaveDir => format!("Save Dir:     {}", self.save_dir),
588            SettingsRow::SyncDevice => format!(
589                "Sync Device:  {}",
590                self.sync_device_id.as_deref().unwrap_or("(not selected)")
591            ),
592            SettingsRow::SyncNow => "Sync Saves Now".to_string(),
593            SettingsRow::ExtrasRelatedRoms => format!(
594                "Incl. updates/DLC (picker default): {}",
595                if self.extras_include_related_roms {
596                    "[X] Yes"
597                } else {
598                    "[ ] No"
599                }
600            ),
601            SettingsRow::ExtrasCover => format!(
602                "Incl. cover (picker default):       {}",
603                if self.extras_include_cover {
604                    "[X] Yes"
605                } else {
606                    "[ ] No"
607                }
608            ),
609            SettingsRow::ExtrasManual => format!(
610                "Incl. manual (picker default):      {}",
611                if self.extras_include_manual {
612                    "[X] Yes"
613                } else {
614                    "[ ] No"
615                }
616            ),
617            SettingsRow::Auth => format!("Auth:         {} (Enter to change)", self.auth_status),
618            SettingsRow::ClearCache => "Clear Cache (Remove cached ROM data)".to_string(),
619            SettingsRow::ResetConfiguration => {
620                "Reset Configuration (Delete settings from disk & keyring)".to_string()
621            }
622        };
623        if matches!(row, SettingsRow::SyncDevice | SettingsRow::SyncNow)
624            && !self.save_sync_supported()
625        {
626            format!("{label} (requires newer RomM server)")
627        } else {
628            label
629        }
630    }
631
632    pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
633        if let Some((kind, ref picker)) = self.path_picker {
634            let chunks = Layout::default()
635                .constraints([
636                    Constraint::Length(4),
637                    Constraint::Min(12),
638                    Constraint::Length(3),
639                ])
640                .direction(ratatui::layout::Direction::Vertical)
641                .split(area);
642            let title = match kind {
643                SettingsPickerKind::RomsDir => "Choose ROMs directory",
644                SettingsPickerKind::SaveDir => "Choose save directory",
645            };
646            return picker.cursor_position(chunks[1], title);
647        }
648
649        if !self.editing || self.selected_row() != SettingsRow::BaseUrl {
650            return None;
651        }
652
653        let chunks = Layout::default()
654            .constraints([
655                Constraint::Length(4),
656                Constraint::Length(3),
657                Constraint::Min(10),
658                Constraint::Length(3),
659                Constraint::Length(3),
660            ])
661            .direction(ratatui::layout::Direction::Vertical)
662            .split(area);
663
664        let list_area = chunks[2];
665        let y = list_area.y + 1 + self.selected_row_index() as u16;
666        let label_len = 14; // "Base URL:     ".len()
667        let x = list_area.x + 1 /* border */ + 3 /* highlight symbol */ + label_len + self.edit_cursor as u16;
668
669        Some((x, y))
670    }
671
672    pub fn set_devices(&mut self, devices: Vec<DeviceSchema>) {
673        self.devices = devices;
674        self.device_picker_loading = false;
675        self.device_picker_error = None;
676        self.device_selected_index = self
677            .sync_device_id
678            .as_ref()
679            .and_then(|id| self.devices.iter().position(|d| &d.id == id))
680            .unwrap_or(0)
681            .min(self.devices.len().saturating_sub(1));
682    }
683
684    pub fn set_device_error(&mut self, error: String) {
685        self.device_picker_loading = false;
686        self.device_picker_error = Some(error);
687    }
688
689    pub fn device_next(&mut self) {
690        if !self.devices.is_empty() {
691            self.device_selected_index =
692                (self.device_selected_index + 1).min(self.devices.len() - 1);
693        }
694    }
695
696    pub fn device_previous(&mut self) {
697        self.device_selected_index = self.device_selected_index.saturating_sub(1);
698    }
699
700    pub fn confirm_device(&mut self) {
701        if let Some(device) = self.devices.get(self.device_selected_index) {
702            self.sync_device_id = Some(device.id.clone());
703            self.device_picker_open = false;
704            self.message = Some((
705                "Sync device updated (press S to save)".to_string(),
706                Color::Green,
707            ));
708        }
709    }
710
711    fn render_device_picker(&mut self, f: &mut Frame, area: Rect) {
712        let chunks = Layout::default()
713            .constraints([
714                Constraint::Length(4),
715                Constraint::Min(10),
716                Constraint::Length(3),
717            ])
718            .direction(ratatui::layout::Direction::Vertical)
719            .split(area);
720        let info = [
721            format!(
722                "romm-cli: v{} | RomM server: {}",
723                self.version, self.server_version
724            ),
725            "Select the RomM sync device used for manual push-pull.".to_string(),
726        ];
727        f.render_widget(
728            Paragraph::new(info.join("\n")).block(Block::default().borders(Borders::BOTTOM)),
729            chunks[0],
730        );
731        if self.device_picker_loading {
732            f.render_widget(
733                Paragraph::new("Loading devices...")
734                    .block(Block::default().title(" Devices ").borders(Borders::ALL)),
735                chunks[1],
736            );
737        } else if let Some(error) = &self.device_picker_error {
738            f.render_widget(
739                Paragraph::new(format!("Could not load devices: {error}"))
740                    .style(Style::default().fg(Color::Red))
741                    .block(Block::default().title(" Devices ").borders(Borders::ALL)),
742                chunks[1],
743            );
744        } else {
745            let items: Vec<ListItem> = self
746                .devices
747                .iter()
748                .map(|d| {
749                    let name = d.name.as_deref().unwrap_or("(unnamed)");
750                    ListItem::new(format!("{name}  [{}]  mode={:?}", d.id, d.sync_mode))
751                })
752                .collect();
753            let mut state = ListState::default();
754            state.select(Some(self.device_selected_index));
755            f.render_stateful_widget(
756                List::new(items)
757                    .block(Block::default().title(" Devices ").borders(Borders::ALL))
758                    .highlight_symbol(">> ")
759                    .highlight_style(
760                        Style::default()
761                            .fg(Color::Yellow)
762                            .add_modifier(Modifier::BOLD),
763                    ),
764                chunks[1],
765                &mut state,
766            );
767        }
768        f.render_widget(
769            Paragraph::new("Enter: choose   Esc: cancel   ↑/↓: select")
770                .block(Block::default().borders(Borders::ALL)),
771            chunks[2],
772        );
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use crate::config::{ExtrasDefaults, SaveSyncConfig};
780    use crate::feature_compat::{
781        supported_save_sync_compatibility, FeatureCompatibility, RequiredEndpoint,
782        SAVE_SYNC_FEATURE, SAVE_SYNC_UNSUPPORTED_MESSAGE,
783    };
784
785    fn test_config() -> Config {
786        Config {
787            base_url: "https://romm.example.com".to_string(),
788            download_dir: "C:\\roms".to_string(),
789            use_https: true,
790            auth: None,
791            extras_defaults: ExtrasDefaults::default(),
792            save_sync: SaveSyncConfig {
793                save_dir: Some("C:\\saves".to_string()),
794                device_id: None,
795            },
796        }
797    }
798
799    fn screen() -> SettingsScreen {
800        SettingsScreen::new(
801            &test_config(),
802            Some("1.0.0"),
803            supported_save_sync_compatibility(),
804        )
805    }
806
807    fn unsupported_screen() -> SettingsScreen {
808        SettingsScreen::new(
809            &test_config(),
810            Some("1.0.0"),
811            FeatureCompatibility::from_registry(
812                SAVE_SYNC_FEATURE,
813                SAVE_SYNC_UNSUPPORTED_MESSAGE,
814                &[RequiredEndpoint {
815                    method: "GET",
816                    path: "/api/devices",
817                }],
818                &crate::openapi::EndpointRegistry::default(),
819            ),
820        )
821    }
822
823    #[test]
824    fn tabs_expose_expected_rows() {
825        assert_eq!(
826            SettingsTab::Connection.rows(),
827            &[
828                SettingsRow::BaseUrl,
829                SettingsRow::RomsDir,
830                SettingsRow::UseHttps
831            ]
832        );
833        assert_eq!(
834            SettingsTab::Saves.rows(),
835            &[
836                SettingsRow::SaveDir,
837                SettingsRow::SyncDevice,
838                SettingsRow::SyncNow
839            ]
840        );
841        assert_eq!(
842            SettingsTab::Extras.rows(),
843            &[
844                SettingsRow::ExtrasRelatedRoms,
845                SettingsRow::ExtrasCover,
846                SettingsRow::ExtrasManual
847            ]
848        );
849        assert_eq!(
850            SettingsTab::AuthMaintenance.rows(),
851            &[
852                SettingsRow::Auth,
853                SettingsRow::ClearCache,
854                SettingsRow::ResetConfiguration
855            ]
856        );
857    }
858
859    #[test]
860    fn row_navigation_wraps_within_active_tab() {
861        let mut s = screen();
862
863        assert_eq!(s.selected_row(), SettingsRow::BaseUrl);
864        s.previous();
865        assert_eq!(s.selected_row(), SettingsRow::UseHttps);
866        s.next();
867        assert_eq!(s.selected_row(), SettingsRow::BaseUrl);
868    }
869
870    #[test]
871    fn tab_navigation_preserves_per_tab_selection() {
872        let mut s = screen();
873
874        s.next();
875        s.next();
876        assert_eq!(s.selected_row(), SettingsRow::UseHttps);
877
878        s.next_tab();
879        assert_eq!(s.selected_tab, SettingsTab::Saves);
880        assert_eq!(s.selected_row(), SettingsRow::SaveDir);
881
882        s.next();
883        assert_eq!(s.selected_row(), SettingsRow::SyncDevice);
884
885        s.previous_tab();
886        assert_eq!(s.selected_tab, SettingsTab::Connection);
887        assert_eq!(s.selected_row(), SettingsRow::UseHttps);
888
889        s.next_tab();
890        assert_eq!(s.selected_tab, SettingsTab::Saves);
891        assert_eq!(s.selected_row(), SettingsRow::SyncDevice);
892    }
893
894    #[test]
895    fn activation_rows_resolve_to_expected_intents() {
896        let mut s = screen();
897
898        s.selected_tab = SettingsTab::AuthMaintenance;
899        assert_eq!(s.selected_row(), SettingsRow::Auth);
900
901        s.next();
902        assert_eq!(s.selected_row(), SettingsRow::ClearCache);
903        s.enter_edit();
904        assert_eq!(s.confirm, Some(SettingsConfirm::ClearCache));
905
906        s.cancel_edit();
907        s.next();
908        assert_eq!(s.selected_row(), SettingsRow::ResetConfiguration);
909        s.enter_edit();
910        assert_eq!(s.confirm, Some(SettingsConfirm::Reset));
911    }
912
913    #[test]
914    fn save_action_rows_trigger_matching_state() {
915        let mut s = screen();
916        s.selected_tab = SettingsTab::Saves;
917
918        s.next();
919        assert_eq!(s.selected_row(), SettingsRow::SyncDevice);
920        s.enter_edit();
921        assert!(s.device_picker_open);
922        assert!(s.device_picker_loading);
923
924        s.device_picker_open = false;
925        s.device_picker_loading = false;
926        s.next();
927        assert_eq!(s.selected_row(), SettingsRow::SyncNow);
928        s.enter_edit();
929        assert_eq!(
930            s.message.as_ref().map(|(msg, _)| msg.as_str()),
931            Some("Starting save sync...")
932        );
933    }
934
935    #[test]
936    fn extras_rows_toggle_matching_defaults() {
937        let mut s = screen();
938        s.selected_tab = SettingsTab::Extras;
939
940        s.enter_edit();
941        assert!(!s.extras_include_related_roms);
942        assert!(s.extras_include_cover);
943        assert!(s.extras_include_manual);
944
945        s.next();
946        s.enter_edit();
947        assert!(!s.extras_include_cover);
948        assert!(s.extras_include_manual);
949
950        s.next();
951        s.enter_edit();
952        assert!(!s.extras_include_manual);
953    }
954
955    #[test]
956    fn unsupported_save_sync_rows_do_not_open_device_picker_or_start_sync() {
957        let mut s = unsupported_screen();
958        s.selected_tab = SettingsTab::Saves;
959
960        s.next();
961        assert_eq!(s.selected_row(), SettingsRow::SyncDevice);
962        s.enter_edit();
963        assert!(!s.device_picker_open);
964        assert_eq!(
965            s.message.as_ref().map(|(msg, _)| msg.as_str()),
966            Some(
967                "This RomM server does not expose save-sync endpoints; upgrade RomM to use romm-cli sync. Missing endpoint(s): GET /api/devices"
968            )
969        );
970
971        s.next();
972        assert_eq!(s.selected_row(), SettingsRow::SyncNow);
973        s.enter_edit();
974        assert!(!s.sync_inflight);
975        assert!(s
976            .message
977            .as_ref()
978            .map(|(msg, _)| msg.contains(SAVE_SYNC_UNSUPPORTED_MESSAGE))
979            .unwrap_or(false));
980    }
981
982    #[test]
983    fn unsupported_save_sync_rows_render_requires_newer_server_annotation() {
984        let s = unsupported_screen();
985
986        assert!(s
987            .row_label(SettingsRow::SyncDevice)
988            .contains("requires newer RomM server"));
989        assert!(s
990            .row_label(SettingsRow::SyncNow)
991            .contains("requires newer RomM server"));
992    }
993}