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
16pub 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 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 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; }
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), Constraint::Min(10), Constraint::Length(3), Constraint::Length(3), ])
203 .direction(ratatui::layout::Direction::Vertical)
204 .split(area);
205
206 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 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 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 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; let x = list_area.x + 1 + 3 + label_len + self.edit_cursor as u16;
327
328 Some((x, y))
329 }
330}