Skip to main content

romm_cli/tui/screens/settings/
render.rs

1use ratatui::layout::{Constraint, Layout, Rect};
2use ratatui::style::Modifier;
3use ratatui::text::{Line, Span};
4use ratatui::widgets::{List, ListItem, ListState, Paragraph, Tabs};
5use ratatui::Frame;
6
7use crate::tui::theme::RommStyles;
8
9use super::types::{
10    ConsolePathKind, SettingsConfirm, SettingsPickerKind, SettingsRow, SettingsScreen, SettingsTab,
11};
12
13impl SettingsScreen {
14    pub fn render(&mut self, f: &mut Frame, area: Rect, styles: &RommStyles) {
15        if let Some((platform_id, ref mut picker)) = self.console_path_picker {
16            let chunks = Layout::default()
17                .constraints([
18                    Constraint::Length(4),
19                    Constraint::Min(12),
20                    Constraint::Length(3),
21                ])
22                .direction(ratatui::layout::Direction::Vertical)
23                .split(area);
24            let platform_name = self
25                .console_platforms
26                .iter()
27                .find(|p| p.id == platform_id)
28                .map(Self::platform_display_name)
29                .unwrap_or_else(|| format!("Platform {platform_id}"));
30            let info = [
31                format!(
32                    "romm-cli: v{} | RomM server: {}",
33                    self.version, self.server_version
34                ),
35                format!("Console: {platform_name}"),
36            ];
37            f.render_widget(
38                Paragraph::new(info.join("\n"))
39                    .style(styles.text())
40                    .block(styles.header_block()),
41                chunks[0],
42            );
43            picker.render(
44                f,
45                chunks[1],
46                "Choose console directory",
47                "Esc: cancel   Ctrl+Enter: apply typed path (creates folders)   Tab: path/list",
48                styles,
49            );
50            f.render_widget(
51                Paragraph::new("Console directory picker — Esc returns without changing")
52                    .style(styles.footer_hint())
53                    .block(styles.panel_block_untitled()),
54                chunks[2],
55            );
56            return;
57        }
58
59        if self.console_picker_open {
60            self.render_console_picker(f, area, styles);
61            return;
62        }
63
64        if let Some((kind, ref mut picker)) = self.path_picker {
65            let chunks = Layout::default()
66                .constraints([
67                    Constraint::Length(4),
68                    Constraint::Min(12),
69                    Constraint::Length(3),
70                ])
71                .direction(ratatui::layout::Direction::Vertical)
72                .split(area);
73            let info = [
74                format!(
75                    "romm-cli: v{} | RomM server: {}",
76                    self.version, self.server_version
77                ),
78                format!("GitHub:   {}", self.github_url),
79                format!("Auth:     {}", self.auth_status),
80            ];
81            f.render_widget(
82                Paragraph::new(info.join("\n"))
83                    .style(styles.text())
84                    .block(styles.header_block()),
85                chunks[0],
86            );
87            let hint =
88                "Esc: cancel   Ctrl+Enter: apply typed path (creates folders)   Tab: path/list";
89            let title = match kind {
90                SettingsPickerKind::RomsDir => "Choose ROMs directory",
91                SettingsPickerKind::SaveDir => "Choose save directory",
92            };
93            picker.render(f, chunks[1], title, hint, styles);
94            f.render_widget(
95                Paragraph::new("ROMs directory picker — Esc returns without changing")
96                    .style(styles.footer_hint())
97                    .block(styles.panel_block_untitled()),
98                chunks[2],
99            );
100            return;
101        }
102
103        if self.device_picker_open {
104            self.render_device_picker(f, area, styles);
105            return;
106        }
107
108        let chunks = Layout::default()
109            .constraints([
110                Constraint::Length(4), // Header info
111                Constraint::Length(3), // Settings tabs
112                Constraint::Min(10),   // Editable list
113                Constraint::Length(3), // Message/Hint
114                Constraint::Length(3), // Footer help
115            ])
116            .direction(ratatui::layout::Direction::Vertical)
117            .split(area);
118
119        // -- Header Info --
120        let info = [
121            format!(
122                "romm-cli: v{} | RomM server: {}",
123                self.version, self.server_version
124            ),
125            format!("GitHub:   {}", self.github_url),
126            format!("Auth:     {}", self.auth_status),
127        ];
128        f.render_widget(
129            Paragraph::new(info.join("\n"))
130                .style(styles.text())
131                .block(styles.header_block()),
132            chunks[0],
133        );
134
135        // -- Tabs --
136        let titles = SettingsTab::ALL
137            .iter()
138            .map(|tab| Line::from(Span::raw(tab.title())))
139            .collect::<Vec<_>>();
140        let tabs = Tabs::new(titles)
141            .select(self.selected_tab.index())
142            .block(styles.panel_block_untitled())
143            .style(styles.muted())
144            .highlight_style(styles.selection());
145        f.render_widget(tabs, chunks[1]);
146
147        // -- Editable List --
148        let items = self
149            .visible_rows()
150            .iter()
151            .copied()
152            .map(|row| self.render_row_item(row, styles))
153            .collect::<Vec<_>>();
154
155        let mut state = ListState::default();
156        state.select(Some(self.selected_row_index()));
157
158        let list = List::new(items)
159            .block(styles.panel_block(format!(" {} ", self.selected_tab.title())))
160            .highlight_style(styles.selection())
161            .highlight_symbol(">> ");
162
163        f.render_stateful_widget(list, chunks[2], &mut state);
164
165        // -- Message Area --
166        if let Some(confirm) = &self.confirm {
167            let msg = match confirm {
168                SettingsConfirm::Reset => {
169                    "Are you sure you want to delete all settings? (Enter: Yes, Esc: Cancel)"
170                }
171                SettingsConfirm::ClearCache => {
172                    "Are you sure you want to clear the ROM cache? (Enter: Yes, Esc: Cancel)"
173                }
174                SettingsConfirm::ExitUnsaved => {
175                    "Save changes before leaving? (Enter: Save, N: Don't save, Esc: Cancel)"
176                }
177            };
178            f.render_widget(
179                Paragraph::new(msg).style(styles.error().add_modifier(Modifier::BOLD)),
180                chunks[3],
181            );
182        } else if let Some((msg, tone)) = &self.message {
183            f.render_widget(
184                Paragraph::new(msg.as_str()).style(styles.tone(*tone)),
185                chunks[3],
186            );
187        } else if self.editing {
188            f.render_widget(
189                Paragraph::new("Editing... Enter: save   Esc: cancel").style(styles.label()),
190                chunks[3],
191            );
192        }
193
194        // -- Footer Help --
195        let help = if self.confirm == Some(SettingsConfirm::ExitUnsaved) {
196            "Enter: save   N: don't save   Esc: cancel"
197        } else if self.confirm.is_some() {
198            "Enter: confirm   Esc: cancel"
199        } else if self.editing {
200            "Backspace: delete   Arrows: move cursor   Enter: save   Esc: cancel"
201        } else {
202            "Tab/←/→: tabs   ↑/↓: select   Enter: edit/toggle   S: save to disk   Esc: back"
203        };
204        f.render_widget(
205            Paragraph::new(help)
206                .style(styles.footer_hint())
207                .block(styles.panel_block_untitled()),
208            chunks[4],
209        );
210    }
211
212    fn render_row_item(&self, row: SettingsRow, styles: &RommStyles) -> ListItem<'static> {
213        let label = self.row_label(row);
214        match row {
215            SettingsRow::SyncDevice | SettingsRow::SyncNow if !self.save_sync_supported() => {
216                ListItem::new(label).style(styles.muted())
217            }
218            _ => ListItem::new(label).style(styles.text()),
219        }
220    }
221
222    pub(crate) fn row_label(&self, row: SettingsRow) -> String {
223        let label = match row {
224            SettingsRow::BaseUrl => format!(
225                "Base URL:     {}",
226                if self.editing && self.selected_row() == SettingsRow::BaseUrl {
227                    &self.edit_buffer
228                } else {
229                    &self.base_url
230                }
231            ),
232            SettingsRow::RomsDir => format!("Roms Dir:     {}", self.download_dir),
233            SettingsRow::ConsolePaths => {
234                let mapped = self.rom_platform_dirs.len();
235                format!("Console paths: {mapped} custom · Enter to edit")
236            }
237            SettingsRow::SaveConsolePaths => {
238                let mapped = self.save_platform_dirs.len();
239                format!("Save console paths: {mapped} custom · Enter to edit")
240            }
241            SettingsRow::UseHttps => format!(
242                "Use HTTPS:    {}",
243                if self.use_https { "[X] Yes" } else { "[ ] No" }
244            ),
245            SettingsRow::SaveDir => format!("Save Dir:     {}", self.save_dir),
246            SettingsRow::SyncDevice => format!(
247                "Sync Device:  {}",
248                self.sync_device_id.as_deref().unwrap_or("(not selected)")
249            ),
250            SettingsRow::SyncNow => "Sync Saves Now".to_string(),
251            SettingsRow::ExtrasRelatedRoms => format!(
252                "Incl. updates/DLC (picker default): {}",
253                if self.extras_include_related_roms {
254                    "[X] Yes"
255                } else {
256                    "[ ] No"
257                }
258            ),
259            SettingsRow::ExtrasCover => format!(
260                "Incl. cover (picker default):       {}",
261                if self.extras_include_cover {
262                    "[X] Yes"
263                } else {
264                    "[ ] No"
265                }
266            ),
267            SettingsRow::ExtrasManual => format!(
268                "Incl. manual (picker default):      {}",
269                if self.extras_include_manual {
270                    "[X] Yes"
271                } else {
272                    "[ ] No"
273                }
274            ),
275            SettingsRow::Theme => format!(
276                "Theme:        {} (Enter to change)",
277                self.theme_display_name()
278            ),
279            SettingsRow::Auth => format!("Auth:         {} (Enter to change)", self.auth_status),
280            SettingsRow::ClearCache => "Clear Cache (Remove cached ROM data)".to_string(),
281            SettingsRow::ResetConfiguration => {
282                "Reset Configuration (Delete settings from disk & keyring)".to_string()
283            }
284        };
285        if matches!(row, SettingsRow::SyncDevice | SettingsRow::SyncNow)
286            && !self.save_sync_supported()
287        {
288            format!("{label} (requires newer RomM server)")
289        } else {
290            label
291        }
292    }
293
294    pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
295        if let Some((kind, ref picker)) = self.path_picker {
296            let chunks = Layout::default()
297                .constraints([
298                    Constraint::Length(4),
299                    Constraint::Min(12),
300                    Constraint::Length(3),
301                ])
302                .direction(ratatui::layout::Direction::Vertical)
303                .split(area);
304            let title = match kind {
305                SettingsPickerKind::RomsDir => "Choose ROMs directory",
306                SettingsPickerKind::SaveDir => "Choose save directory",
307            };
308            return picker.cursor_position(chunks[1], title);
309        }
310
311        if !self.editing || self.selected_row() != SettingsRow::BaseUrl {
312            return None;
313        }
314
315        let chunks = Layout::default()
316            .constraints([
317                Constraint::Length(4),
318                Constraint::Length(3),
319                Constraint::Min(10),
320                Constraint::Length(3),
321                Constraint::Length(3),
322            ])
323            .direction(ratatui::layout::Direction::Vertical)
324            .split(area);
325
326        let list_area = chunks[2];
327        let y = list_area.y + 1 + self.selected_row_index() as u16;
328        let label_len = 14; // "Base URL:     ".len()
329        let x = list_area.x + 1 /* border */ + 3 /* highlight symbol */ + label_len + self.edit_cursor as u16;
330
331        Some((x, y))
332    }
333
334    fn render_device_picker(&mut self, f: &mut Frame, area: Rect, styles: &RommStyles) {
335        let chunks = Layout::default()
336            .constraints([
337                Constraint::Length(4),
338                Constraint::Min(10),
339                Constraint::Length(3),
340            ])
341            .direction(ratatui::layout::Direction::Vertical)
342            .split(area);
343        let info = [
344            format!(
345                "romm-cli: v{} | RomM server: {}",
346                self.version, self.server_version
347            ),
348            "Select the RomM sync device used for manual push-pull.".to_string(),
349        ];
350        f.render_widget(
351            Paragraph::new(info.join("\n"))
352                .style(styles.text())
353                .block(styles.header_block()),
354            chunks[0],
355        );
356        if self.device_picker_loading {
357            f.render_widget(
358                Paragraph::new("Loading devices...")
359                    .style(styles.text())
360                    .block(styles.panel_block(" Devices ")),
361                chunks[1],
362            );
363        } else if let Some(error) = &self.device_picker_error {
364            f.render_widget(
365                Paragraph::new(format!("Could not load devices: {error}"))
366                    .style(styles.error())
367                    .block(styles.panel_block(" Devices ")),
368                chunks[1],
369            );
370        } else {
371            let items: Vec<ListItem> = self
372                .devices
373                .iter()
374                .map(|d| {
375                    let name = d.name.as_deref().unwrap_or("(unnamed)");
376                    ListItem::new(format!("{name}  [{}]  mode={:?}", d.id, d.sync_mode))
377                        .style(styles.text())
378                })
379                .collect();
380            let mut state = ListState::default();
381            state.select(Some(self.device_selected_index));
382            f.render_stateful_widget(
383                List::new(items)
384                    .block(styles.panel_block(" Devices "))
385                    .highlight_symbol(">> ")
386                    .highlight_style(styles.selection()),
387                chunks[1],
388                &mut state,
389            );
390        }
391        f.render_widget(
392            Paragraph::new("Enter: choose   Esc: cancel   ↑/↓: select")
393                .style(styles.footer_hint())
394                .block(styles.panel_block_untitled()),
395            chunks[2],
396        );
397    }
398
399    fn render_console_picker(&mut self, f: &mut Frame, area: Rect, styles: &RommStyles) {
400        let kind = self.active_console_kind.unwrap_or(ConsolePathKind::Roms);
401        let chunks = Layout::default()
402            .constraints([
403                Constraint::Length(4),
404                Constraint::Min(10),
405                Constraint::Length(3),
406            ])
407            .direction(ratatui::layout::Direction::Vertical)
408            .split(area);
409        let subtitle = match kind {
410            ConsolePathKind::Roms => "Set a custom ROM path for consoles on other drives.",
411            ConsolePathKind::Saves => "Set a custom save path for consoles on other drives.",
412        };
413        let info = [
414            format!(
415                "romm-cli: v{} | RomM server: {}",
416                self.version, self.server_version
417            ),
418            subtitle.to_string(),
419        ];
420        f.render_widget(
421            Paragraph::new(info.join("\n"))
422                .style(styles.text())
423                .block(styles.header_block()),
424            chunks[0],
425        );
426        if self.console_picker_loading {
427            f.render_widget(
428                Paragraph::new("Loading platforms...")
429                    .style(styles.text())
430                    .block(styles.panel_block(" Consoles ")),
431                chunks[1],
432            );
433        } else if let Some(error) = &self.console_picker_error {
434            f.render_widget(
435                Paragraph::new(format!("Could not load platforms: {error}"))
436                    .style(styles.error())
437                    .block(styles.panel_block(" Consoles ")),
438                chunks[1],
439            );
440        } else if self.console_platforms.is_empty() {
441            f.render_widget(
442                Paragraph::new("No platforms returned from the server.")
443                    .style(styles.warning())
444                    .block(styles.panel_block(" Consoles ")),
445                chunks[1],
446            );
447        } else {
448            let items: Vec<ListItem> = self
449                .console_platforms
450                .iter()
451                .map(|platform| {
452                    let name = Self::platform_display_name(platform);
453                    let path = self.console_dir_preview(kind, platform);
454                    let custom = self
455                        .console_dirs(kind)
456                        .get(&platform.id)
457                        .is_some_and(|s| !s.trim().is_empty());
458                    let tag = if custom {
459                        "custom path"
460                    } else {
461                        "base default"
462                    };
463                    ListItem::new(format!("{name}  [{tag}]  {path}")).style(styles.text())
464                })
465                .collect();
466            let mut state = ListState::default();
467            state.select(Some(self.console_selected_index));
468            f.render_stateful_widget(
469                List::new(items)
470                    .block(styles.panel_block(" Consoles "))
471                    .highlight_symbol(">> ")
472                    .highlight_style(styles.selection()),
473                chunks[1],
474                &mut state,
475            );
476        }
477        f.render_widget(
478            Paragraph::new("Enter: set path   Del: clear custom   Esc: back   ↑/↓: select")
479                .style(styles.footer_hint())
480                .block(styles.panel_block_untitled()),
481            chunks[2],
482        );
483    }
484}