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
107pub struct SettingsScreen {
109 pub base_url: String,
110 pub download_dir: String,
111 pub use_https: bool,
112 pub extras_include_related_roms: bool,
114 pub extras_include_cover: bool,
116 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 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 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; }
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), Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), Constraint::Length(3), ])
461 .direction(ratatui::layout::Direction::Vertical)
462 .split(area);
463
464 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 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 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 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 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; let x = list_area.x + 1 + 3 + 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}