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