Skip to main content

romm_cli/tui/screens/settings/
state.rs

1use std::collections::HashMap;
2
3use crate::config::{
4    disk_has_unresolved_keyring_sentinel, Config, RomsLayoutConfig, SaveSyncConfig,
5};
6use crate::feature_compat::SaveSyncCompatibility;
7use crate::tui::path_picker::{PathPicker, PathPickerMode};
8
9use super::types::{
10    ConsolePathKind, SettingsConfirm, SettingsPickerKind, SettingsRow, SettingsScreen, SettingsTab,
11    APPEARANCE_ROWS, AUTH_MAINT_ROWS, CONNECTION_ROWS, EXTRAS_ROWS, SAVES_ROWS,
12};
13use crate::tui::theme::{next_theme_id, prev_theme_id, theme_display_name, MessageTone};
14
15impl SettingsScreen {
16    pub fn new(
17        config: &Config,
18        romm_server_version: Option<&str>,
19        save_sync_compat: SaveSyncCompatibility,
20    ) -> Self {
21        let auth_status = match &config.auth {
22            Some(crate::config::AuthConfig::Basic { username, .. }) => {
23                format!("Basic (user: {})", username)
24            }
25            Some(crate::config::AuthConfig::Bearer { .. }) => "API Token".to_string(),
26            Some(crate::config::AuthConfig::ApiKey { header, .. }) => {
27                format!("API key (header: {})", header)
28            }
29            None => {
30                if disk_has_unresolved_keyring_sentinel(config) {
31                    "None — disk still references keyring; set API_TOKEN / ROMM_TOKEN_FILE or see docs/troubleshooting-auth.md"
32                        .to_string()
33                } else {
34                    "None (no API credentials in env/keyring)".to_string()
35                }
36            }
37        };
38
39        let server_version = romm_server_version
40            .map(String::from)
41            .unwrap_or_else(|| "unavailable (heartbeat failed)".to_string());
42
43        Self {
44            base_url: config.base_url.clone(),
45            download_dir: config.download_dir.clone(),
46            save_dir: crate::config::resolved_save_dir(config)
47                .display()
48                .to_string(),
49            sync_device_id: config.save_sync.device_id.clone(),
50            use_https: config.use_https,
51            extras_include_related_roms: config.extras_defaults.include_related_roms,
52            extras_include_cover: config.extras_defaults.include_cover,
53            extras_include_manual: config.extras_defaults.include_manual,
54            auth_status,
55            version: env!("CARGO_PKG_VERSION").to_string(),
56            server_version,
57            github_url: "https://github.com/patricksmill/romm-cli".to_string(),
58            theme_id: config.theme.clone(),
59            selected_tab: SettingsTab::Connection,
60            selected_indices: [0; SettingsTab::COUNT],
61            editing: false,
62            confirm: None,
63            edit_buffer: String::new(),
64            edit_cursor: 0,
65            path_picker: None,
66            devices: Vec::new(),
67            device_picker_open: false,
68            device_picker_loading: false,
69            device_picker_error: None,
70            device_selected_index: 0,
71            sync_inflight: false,
72            message: None,
73            save_sync_compat,
74            rom_platform_dirs: config.roms_layout.platform_dirs.clone(),
75            save_platform_dirs: config.save_sync.platform_dirs.clone(),
76            console_picker_open: false,
77            active_console_kind: None,
78            console_picker_loading: false,
79            console_picker_error: None,
80            console_platforms: Vec::new(),
81            console_selected_index: 0,
82            console_path_picker: None,
83        }
84    }
85
86    pub fn roms_layout_config(&self) -> RomsLayoutConfig {
87        let mut layout = RomsLayoutConfig::default();
88        layout.platform_dirs = self.rom_platform_dirs.clone();
89        layout
90    }
91
92    pub fn save_sync_config(&self) -> SaveSyncConfig {
93        SaveSyncConfig {
94            save_dir: Some(self.save_dir.clone()),
95            device_id: self.sync_device_id.clone(),
96            platform_dirs: self.save_platform_dirs.clone(),
97        }
98    }
99
100    /// Whether the on-screen values differ from the last saved in-memory config.
101    pub fn has_unsaved_changes(&self, saved: &Config) -> bool {
102        if self.base_url != saved.base_url {
103            return true;
104        }
105        if self.download_dir != saved.download_dir {
106            return true;
107        }
108        if self.use_https != saved.use_https {
109            return true;
110        }
111        if self.extras_include_related_roms != saved.extras_defaults.include_related_roms {
112            return true;
113        }
114        if self.extras_include_cover != saved.extras_defaults.include_cover {
115            return true;
116        }
117        if self.extras_include_manual != saved.extras_defaults.include_manual {
118            return true;
119        }
120        if self.theme_id != saved.theme {
121            return true;
122        }
123        if self.roms_layout_config() != saved.roms_layout {
124            return true;
125        }
126        if self.sync_device_id != saved.save_sync.device_id {
127            return true;
128        }
129        if self.save_platform_dirs != saved.save_sync.platform_dirs {
130            return true;
131        }
132        let saved_save_dir = crate::config::resolved_save_dir(saved)
133            .display()
134            .to_string();
135        self.save_dir != saved_save_dir
136    }
137
138    pub(crate) fn console_dirs(&self, kind: ConsolePathKind) -> &HashMap<u64, String> {
139        match kind {
140            ConsolePathKind::Roms => &self.rom_platform_dirs,
141            ConsolePathKind::Saves => &self.save_platform_dirs,
142        }
143    }
144
145    pub(crate) fn console_dirs_mut(&mut self, kind: ConsolePathKind) -> &mut HashMap<u64, String> {
146        match kind {
147            ConsolePathKind::Roms => &mut self.rom_platform_dirs,
148            ConsolePathKind::Saves => &mut self.save_platform_dirs,
149        }
150    }
151
152    pub fn visible_rows(&self) -> Vec<SettingsRow> {
153        match self.selected_tab {
154            SettingsTab::Connection => CONNECTION_ROWS.to_vec(),
155            SettingsTab::Roms => vec![SettingsRow::RomsDir, SettingsRow::ConsolePaths],
156            SettingsTab::Saves => SAVES_ROWS.to_vec(),
157            SettingsTab::Extras => EXTRAS_ROWS.to_vec(),
158            SettingsTab::Appearance => APPEARANCE_ROWS.to_vec(),
159            SettingsTab::AuthMaintenance => AUTH_MAINT_ROWS.to_vec(),
160        }
161    }
162
163    pub fn save_sync_supported(&self) -> bool {
164        self.save_sync_compat.supported
165    }
166
167    pub fn set_save_sync_unsupported_message(&mut self) {
168        self.message = Some((
169            self.save_sync_compat.unsupported_message(),
170            MessageTone::Warning,
171        ));
172    }
173
174    pub fn selected_row_index(&self) -> usize {
175        let rows = self.visible_rows();
176        self.selected_indices[self.selected_tab.index()].min(rows.len().saturating_sub(1))
177    }
178
179    fn set_selected_row_index(&mut self, index: usize) {
180        let max = self.visible_rows().len().saturating_sub(1);
181        self.selected_indices[self.selected_tab.index()] = index.min(max);
182    }
183
184    pub fn selected_row(&self) -> SettingsRow {
185        let rows = self.visible_rows();
186        rows[self.selected_row_index()]
187    }
188
189    pub fn active_rows(&self) -> &[SettingsRow] {
190        // Legacy helper for tests; prefer visible_rows().
191        match self.selected_tab {
192            SettingsTab::Connection => &CONNECTION_ROWS,
193            SettingsTab::Saves => &SAVES_ROWS,
194            SettingsTab::Extras => &EXTRAS_ROWS,
195            SettingsTab::Appearance => &APPEARANCE_ROWS,
196            SettingsTab::AuthMaintenance => &AUTH_MAINT_ROWS,
197            SettingsTab::Roms => &[],
198        }
199    }
200
201    pub fn cycle_theme_next(&mut self) {
202        self.theme_id = next_theme_id(&self.theme_id);
203    }
204
205    pub fn cycle_theme_prev(&mut self) {
206        self.theme_id = prev_theme_id(&self.theme_id);
207    }
208
209    pub fn theme_display_name(&self) -> String {
210        theme_display_name(&self.theme_id)
211    }
212
213    pub fn next_tab(&mut self) {
214        if self.editing || self.confirm.is_some() {
215            return;
216        }
217        let next = (self.selected_tab.index() + 1) % SettingsTab::COUNT;
218        self.selected_tab = SettingsTab::ALL[next];
219        self.set_selected_row_index(self.selected_row_index());
220    }
221
222    pub fn previous_tab(&mut self) {
223        if self.editing || self.confirm.is_some() {
224            return;
225        }
226        let previous = (self.selected_tab.index() + SettingsTab::COUNT - 1) % SettingsTab::COUNT;
227        self.selected_tab = SettingsTab::ALL[previous];
228        self.set_selected_row_index(self.selected_row_index());
229    }
230
231    pub fn next(&mut self) {
232        if !self.editing && self.confirm.is_none() {
233            let len = self.visible_rows().len();
234            if len > 0 {
235                self.set_selected_row_index((self.selected_row_index() + 1) % len);
236            }
237        }
238    }
239
240    pub fn previous(&mut self) {
241        if !self.editing && self.confirm.is_none() {
242            let len = self.visible_rows().len();
243            if len == 0 {
244                return;
245            }
246            if self.selected_row_index() == 0 {
247                self.set_selected_row_index(len - 1);
248            } else {
249                self.set_selected_row_index(self.selected_row_index() - 1);
250            }
251        }
252    }
253
254    pub fn enter_edit(&mut self) {
255        match self.selected_row() {
256            SettingsRow::ResetConfiguration => self.confirm = Some(SettingsConfirm::Reset),
257            SettingsRow::ClearCache => self.confirm = Some(SettingsConfirm::ClearCache),
258            SettingsRow::SyncDevice => {
259                if !self.save_sync_supported() {
260                    self.set_save_sync_unsupported_message();
261                    return;
262                }
263                self.device_picker_open = true;
264                self.device_picker_loading = true;
265                self.device_picker_error = None;
266                self.message = Some(("Loading devices...".to_string(), MessageTone::Warning));
267            }
268            SettingsRow::SyncNow => {
269                if !self.save_sync_supported() {
270                    self.set_save_sync_unsupported_message();
271                    return;
272                }
273                self.message = Some(("Starting save sync...".to_string(), MessageTone::Warning));
274            }
275            SettingsRow::ExtrasManual => {
276                self.extras_include_manual = !self.extras_include_manual;
277                self.message = Some((
278                    format!(
279                        "Extras default (manual): {}",
280                        if self.extras_include_manual {
281                            "on"
282                        } else {
283                            "off"
284                        }
285                    ),
286                    MessageTone::Success,
287                ));
288            }
289            SettingsRow::ExtrasCover => {
290                self.extras_include_cover = !self.extras_include_cover;
291                self.message = Some((
292                    format!(
293                        "Extras default (cover): {}",
294                        if self.extras_include_cover {
295                            "on"
296                        } else {
297                            "off"
298                        }
299                    ),
300                    MessageTone::Success,
301                ));
302            }
303            SettingsRow::ExtrasRelatedRoms => {
304                self.extras_include_related_roms = !self.extras_include_related_roms;
305                self.message = Some((
306                    format!(
307                        "Extras default (updates/DLC): {}",
308                        if self.extras_include_related_roms {
309                            "on"
310                        } else {
311                            "off"
312                        }
313                    ),
314                    MessageTone::Success,
315                ));
316            }
317            SettingsRow::UseHttps => {
318                // Toggle HTTPS directly and keep the Base URL scheme in sync.
319                self.use_https = !self.use_https;
320                if self.use_https && self.base_url.starts_with("http://") {
321                    self.base_url = self.base_url.replace("http://", "https://");
322                    self.message = Some((
323                        "Updated URL scheme (HTTPS)".to_string(),
324                        MessageTone::Success,
325                    ));
326                } else if !self.use_https && self.base_url.starts_with("https://") {
327                    self.base_url = self.base_url.replace("https://", "http://");
328                    self.message = Some((
329                        "Updated URL scheme (HTTP)".to_string(),
330                        MessageTone::Success,
331                    ));
332                }
333            }
334            SettingsRow::RomsDir => {
335                self.path_picker = Some((
336                    SettingsPickerKind::RomsDir,
337                    PathPicker::new(PathPickerMode::Directory, self.download_dir.as_str()),
338                ));
339            }
340            SettingsRow::ConsolePaths | SettingsRow::SaveConsolePaths => {}
341            SettingsRow::SaveDir => {
342                self.path_picker = Some((
343                    SettingsPickerKind::SaveDir,
344                    PathPicker::new(PathPickerMode::Directory, self.save_dir.as_str()),
345                ));
346            }
347            SettingsRow::BaseUrl => {
348                self.editing = true;
349                self.edit_buffer = self.base_url.clone();
350                self.edit_cursor = self.edit_buffer.len();
351            }
352            SettingsRow::Theme => {}
353            SettingsRow::Auth => {}
354        }
355    }
356
357    pub fn save_edit(&mut self) -> bool {
358        if !self.editing {
359            return true; // UseHttps toggle is "saved" immediately in memory
360        }
361        if self.selected_row() == SettingsRow::BaseUrl {
362            self.base_url = self.edit_buffer.trim().to_string();
363        }
364        self.editing = false;
365        true
366    }
367
368    pub fn cancel_edit(&mut self) {
369        self.editing = false;
370        self.confirm = None;
371        self.path_picker = None;
372        self.console_path_picker = None;
373        self.console_picker_open = false;
374        self.active_console_kind = None;
375        self.message = None;
376    }
377
378    pub fn add_char(&mut self, c: char) {
379        if self.editing {
380            self.edit_buffer.insert(self.edit_cursor, c);
381            self.edit_cursor += 1;
382        }
383    }
384
385    pub fn delete_char(&mut self) {
386        if self.editing && self.edit_cursor > 0 {
387            self.edit_buffer.remove(self.edit_cursor - 1);
388            self.edit_cursor -= 1;
389        }
390    }
391
392    pub fn move_cursor_left(&mut self) {
393        if self.editing && self.edit_cursor > 0 {
394            self.edit_cursor -= 1;
395        }
396    }
397
398    pub fn move_cursor_right(&mut self) {
399        if self.editing && self.edit_cursor < self.edit_buffer.len() {
400            self.edit_cursor += 1;
401        }
402    }
403}