Skip to main content

romm_cli/tui/screens/
settings.rs

1use ratatui::layout::{Constraint, Layout, Rect};
2use ratatui::style::{Color, Modifier, Style};
3use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
4use ratatui::Frame;
5
6use crate::config::{disk_has_unresolved_keyring_sentinel, Config};
7use crate::tui::path_picker::{PathPicker, PathPickerMode};
8
9#[derive(PartialEq, Eq)]
10pub enum SettingsField {
11    BaseUrl,
12    DownloadDir,
13    UseHttps,
14}
15
16#[derive(PartialEq, Eq)]
17pub enum SettingsConfirm {
18    Reset,
19    ClearCache,
20}
21
22/// Interactive settings screen for editing current config.
23pub struct SettingsScreen {
24    pub base_url: String,
25    pub download_dir: String,
26    pub use_https: bool,
27    /// Default: pre-check related ROMs (updates/DLC) in TUI extras picker.
28    pub extras_include_related_roms: bool,
29    /// Default: pre-check cover in TUI extras picker when available.
30    pub extras_include_cover: bool,
31    /// Default: pre-check manual in TUI extras picker when available.
32    pub extras_include_manual: bool,
33    pub auth_status: String,
34    pub version: String,
35    pub server_version: String,
36    pub github_url: String,
37
38    pub selected_index: usize,
39    pub editing: bool,
40    pub confirm: Option<SettingsConfirm>,
41    pub edit_buffer: String,
42    pub edit_cursor: usize,
43    /// ROMs directory browser (`None` when not choosing a folder).
44    pub path_picker: Option<PathPicker>,
45    pub message: Option<(String, Color)>,
46}
47
48impl SettingsScreen {
49    pub fn new(config: &Config, romm_server_version: Option<&str>) -> Self {
50        let auth_status = match &config.auth {
51            Some(crate::config::AuthConfig::Basic { username, .. }) => {
52                format!("Basic (user: {})", username)
53            }
54            Some(crate::config::AuthConfig::Bearer { .. }) => "API Token".to_string(),
55            Some(crate::config::AuthConfig::ApiKey { header, .. }) => {
56                format!("API key (header: {})", header)
57            }
58            None => {
59                if disk_has_unresolved_keyring_sentinel(config) {
60                    "None — disk still references keyring; set API_TOKEN / ROMM_TOKEN_FILE or see docs/troubleshooting-auth.md"
61                        .to_string()
62                } else {
63                    "None (no API credentials in env/keyring)".to_string()
64                }
65            }
66        };
67
68        let server_version = romm_server_version
69            .map(String::from)
70            .unwrap_or_else(|| "unavailable (heartbeat failed)".to_string());
71
72        Self {
73            base_url: config.base_url.clone(),
74            download_dir: config.download_dir.clone(),
75            use_https: config.use_https,
76            extras_include_related_roms: config.extras_defaults.include_related_roms,
77            extras_include_cover: config.extras_defaults.include_cover,
78            extras_include_manual: config.extras_defaults.include_manual,
79            auth_status,
80            version: env!("CARGO_PKG_VERSION").to_string(),
81            server_version,
82            github_url: "https://github.com/patricksmill/romm-cli".to_string(),
83            selected_index: 0,
84            editing: false,
85            confirm: None,
86            edit_buffer: String::new(),
87            edit_cursor: 0,
88            path_picker: None,
89            message: None,
90        }
91    }
92
93    const ROW_COUNT: usize = 9;
94
95    pub fn next(&mut self) {
96        if !self.editing && self.confirm.is_none() {
97            self.selected_index = (self.selected_index + 1) % Self::ROW_COUNT;
98        }
99    }
100
101    pub fn previous(&mut self) {
102        if !self.editing && self.confirm.is_none() {
103            if self.selected_index == 0 {
104                self.selected_index = Self::ROW_COUNT - 1;
105            } else {
106                self.selected_index -= 1;
107            }
108        }
109    }
110
111    pub fn enter_edit(&mut self) {
112        if self.selected_index == 8 {
113            self.confirm = Some(SettingsConfirm::Reset);
114        } else if self.selected_index == 7 {
115            self.confirm = Some(SettingsConfirm::ClearCache);
116        } else if self.selected_index == 5 {
117            self.extras_include_manual = !self.extras_include_manual;
118            self.message = Some((
119                format!(
120                    "Extras default (manual): {}",
121                    if self.extras_include_manual {
122                        "on"
123                    } else {
124                        "off"
125                    }
126                ),
127                Color::Green,
128            ));
129        } else if self.selected_index == 4 {
130            self.extras_include_cover = !self.extras_include_cover;
131            self.message = Some((
132                format!(
133                    "Extras default (cover): {}",
134                    if self.extras_include_cover {
135                        "on"
136                    } else {
137                        "off"
138                    }
139                ),
140                Color::Green,
141            ));
142        } else if self.selected_index == 3 {
143            self.extras_include_related_roms = !self.extras_include_related_roms;
144            self.message = Some((
145                format!(
146                    "Extras default (updates/DLC): {}",
147                    if self.extras_include_related_roms {
148                        "on"
149                    } else {
150                        "off"
151                    }
152                ),
153                Color::Green,
154            ));
155        } else if self.selected_index == 2 {
156            // Toggle HTTPS directly and keep the Base URL scheme in sync.
157            self.use_https = !self.use_https;
158            if self.use_https && self.base_url.starts_with("http://") {
159                self.base_url = self.base_url.replace("http://", "https://");
160                self.message = Some(("Updated URL scheme (HTTPS)".to_string(), Color::Green));
161            } else if !self.use_https && self.base_url.starts_with("https://") {
162                self.base_url = self.base_url.replace("https://", "http://");
163                self.message = Some(("Updated URL scheme (HTTP)".to_string(), Color::Green));
164            }
165        } else if self.selected_index == 1 {
166            self.path_picker = Some(PathPicker::new(
167                PathPickerMode::Directory,
168                self.download_dir.as_str(),
169            ));
170        } else {
171            self.editing = true;
172            self.edit_buffer = self.base_url.clone();
173            self.edit_cursor = self.edit_buffer.len();
174        }
175    }
176
177    pub fn save_edit(&mut self) -> bool {
178        if !self.editing {
179            return true; // UseHttps toggle is "saved" immediately in memory
180        }
181        if self.selected_index == 0 {
182            self.base_url = self.edit_buffer.trim().to_string();
183        }
184        self.editing = false;
185        true
186    }
187
188    pub fn cancel_edit(&mut self) {
189        self.editing = false;
190        self.confirm = None;
191        self.path_picker = None;
192        self.message = None;
193    }
194
195    pub fn add_char(&mut self, c: char) {
196        if self.editing {
197            self.edit_buffer.insert(self.edit_cursor, c);
198            self.edit_cursor += 1;
199        }
200    }
201
202    pub fn delete_char(&mut self) {
203        if self.editing && self.edit_cursor > 0 {
204            self.edit_buffer.remove(self.edit_cursor - 1);
205            self.edit_cursor -= 1;
206        }
207    }
208
209    pub fn move_cursor_left(&mut self) {
210        if self.editing && self.edit_cursor > 0 {
211            self.edit_cursor -= 1;
212        }
213    }
214
215    pub fn move_cursor_right(&mut self) {
216        if self.editing && self.edit_cursor < self.edit_buffer.len() {
217            self.edit_cursor += 1;
218        }
219    }
220
221    pub fn render(&mut self, f: &mut Frame, area: Rect) {
222        if let Some(ref mut picker) = self.path_picker {
223            let chunks = Layout::default()
224                .constraints([
225                    Constraint::Length(4),
226                    Constraint::Min(12),
227                    Constraint::Length(3),
228                ])
229                .direction(ratatui::layout::Direction::Vertical)
230                .split(area);
231            let info = [
232                format!(
233                    "romm-cli: v{} | RomM server: {}",
234                    self.version, self.server_version
235                ),
236                format!("GitHub:   {}", self.github_url),
237                format!("Auth:     {}", self.auth_status),
238            ];
239            f.render_widget(
240                Paragraph::new(info.join("\n")).block(Block::default().borders(Borders::BOTTOM)),
241                chunks[0],
242            );
243            let hint = "Esc: cancel   Ctrl+Enter: apply typed path (creates folders)   ↑ list top: path   Tab: path/list";
244            picker.render(f, chunks[1], "Choose ROMs directory", hint);
245            f.render_widget(
246                Paragraph::new("ROMs directory picker — Esc returns without changing")
247                    .style(Style::default().fg(Color::Cyan))
248                    .block(Block::default().borders(Borders::ALL)),
249                chunks[2],
250            );
251            return;
252        }
253
254        let chunks = Layout::default()
255            .constraints([
256                Constraint::Length(4), // Header info
257                Constraint::Min(10),   // Editable list
258                Constraint::Length(3), // Message/Hint
259                Constraint::Length(3), // Footer help
260            ])
261            .direction(ratatui::layout::Direction::Vertical)
262            .split(area);
263
264        // -- Header Info --
265        let info = [
266            format!(
267                "romm-cli: v{} | RomM server: {}",
268                self.version, self.server_version
269            ),
270            format!("GitHub:   {}", self.github_url),
271            format!("Auth:     {}", self.auth_status),
272        ];
273        f.render_widget(
274            Paragraph::new(info.join("\n")).block(Block::default().borders(Borders::BOTTOM)),
275            chunks[0],
276        );
277
278        // -- Editable List --
279        let items = [
280            ListItem::new(format!(
281                "Base URL:     {}",
282                if self.editing && self.selected_index == 0 {
283                    &self.edit_buffer
284                } else {
285                    &self.base_url
286                }
287            )),
288            ListItem::new(format!("Roms Dir:     {}", self.download_dir)),
289            ListItem::new(format!(
290                "Use HTTPS:    {}",
291                if self.use_https { "[X] Yes" } else { "[ ] No" }
292            )),
293            ListItem::new(format!(
294                "Extras: incl. updates/DLC (picker default): {}",
295                if self.extras_include_related_roms {
296                    "[X] Yes"
297                } else {
298                    "[ ] No"
299                }
300            )),
301            ListItem::new(format!(
302                "Extras: incl. cover (picker default):     {}",
303                if self.extras_include_cover {
304                    "[X] Yes"
305                } else {
306                    "[ ] No"
307                }
308            )),
309            ListItem::new(format!(
310                "Extras: incl. manual (picker default):   {}",
311                if self.extras_include_manual {
312                    "[X] Yes"
313                } else {
314                    "[ ] No"
315                }
316            )),
317            ListItem::new(format!(
318                "Auth:         {} (Enter to change)",
319                self.auth_status
320            )),
321            ListItem::new("Clear Cache (Remove cached ROM data)"),
322            ListItem::new("Reset Configuration (Delete settings from disk & keyring)"),
323        ];
324
325        let mut state = ListState::default();
326        state.select(Some(self.selected_index));
327
328        let list = List::new(items)
329            .block(
330                Block::default()
331                    .title(" Configuration ")
332                    .borders(Borders::ALL),
333            )
334            .highlight_style(
335                Style::default()
336                    .add_modifier(Modifier::BOLD)
337                    .fg(Color::Yellow),
338            )
339            .highlight_symbol(">> ");
340
341        f.render_stateful_widget(list, chunks[1], &mut state);
342
343        // -- Message Area --
344        if let Some(confirm) = &self.confirm {
345            let msg = match confirm {
346                SettingsConfirm::Reset => {
347                    "Are you sure you want to delete all settings? (Enter: Yes, Esc: Cancel)"
348                }
349                SettingsConfirm::ClearCache => {
350                    "Are you sure you want to clear the ROM cache? (Enter: Yes, Esc: Cancel)"
351                }
352            };
353            f.render_widget(
354                Paragraph::new(msg)
355                    .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
356                chunks[2],
357            );
358        } else if let Some((msg, color)) = &self.message {
359            f.render_widget(
360                Paragraph::new(msg.as_str()).style(Style::default().fg(*color)),
361                chunks[2],
362            );
363        } else if self.editing {
364            f.render_widget(
365                Paragraph::new("Editing... Enter: save   Esc: cancel")
366                    .style(Style::default().fg(Color::Cyan)),
367                chunks[2],
368            );
369        }
370
371        // -- Footer Help --
372        let help = if self.confirm.is_some() {
373            "Enter: confirm   Esc: cancel"
374        } else if self.editing {
375            "Backspace: delete   Arrows: move cursor   Enter: save   Esc: cancel"
376        } else {
377            "↑/↓: select   Enter: edit/toggle   S: save to disk   Esc: back"
378        };
379        f.render_widget(
380            Paragraph::new(help).block(Block::default().borders(Borders::ALL)),
381            chunks[3],
382        );
383    }
384
385    pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
386        if let Some(ref picker) = self.path_picker {
387            let chunks = Layout::default()
388                .constraints([
389                    Constraint::Length(4),
390                    Constraint::Min(12),
391                    Constraint::Length(3),
392                ])
393                .direction(ratatui::layout::Direction::Vertical)
394                .split(area);
395            return picker.cursor_position(chunks[1], "Choose ROMs directory");
396        }
397
398        if !self.editing {
399            return None;
400        }
401
402        let chunks = Layout::default()
403            .constraints([
404                Constraint::Length(4),
405                Constraint::Min(10),
406                Constraint::Length(3),
407                Constraint::Length(3),
408            ])
409            .direction(ratatui::layout::Direction::Vertical)
410            .split(area);
411
412        let list_area = chunks[1];
413        let y = list_area.y + 1 + self.selected_index as u16;
414        let label_len = 14; // "Base URL:     ".len()
415        let x = list_area.x + 1 /* border */ + 3 /* highlight symbol */ + label_len + self.edit_cursor as u16;
416
417        Some((x, y))
418    }
419}