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