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
22pub struct SettingsScreen {
24 pub base_url: String,
25 pub download_dir: String,
26 pub use_https: bool,
27 pub extras_include_related_roms: bool,
29 pub extras_include_cover: bool,
31 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 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 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; }
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), Constraint::Min(10), Constraint::Length(3), Constraint::Length(3), ])
261 .direction(ratatui::layout::Direction::Vertical)
262 .split(area);
263
264 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 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 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 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; let x = list_area.x + 1 + 3 + label_len + self.edit_cursor as u16;
416
417 Some((x, y))
418 }
419}