Skip to main content

dotstate/screens/
settings.rs

1//! Settings screen for configuring application options.
2//!
3//! Provides a two-pane interface:
4//! - Left: List of settings
5//! - Right: Current value, options, and explanation
6
7use crate::components::footer::Footer;
8use crate::components::header::Header;
9use crate::config::Config;
10use crate::icons::Icons;
11use crate::keymap::{Action, KeymapPreset};
12use crate::screens::screen_trait::{RenderContext, Screen, ScreenAction, ScreenContext};
13use crate::styles::{init_theme, theme, ThemeType};
14use crate::ui::Screen as ScreenId;
15use crate::utils::{
16    create_split_layout, create_standard_layout, focused_border_style, unfocused_border_style,
17};
18use anyhow::Result;
19use crossterm::event::{Event, KeyEventKind};
20use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
21use ratatui::style::{Modifier, Style};
22use ratatui::text::{Line, Span, Text};
23use ratatui::widgets::{
24    Block, Borders, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Wrap,
25};
26use ratatui::Frame;
27
28/// Available settings
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum SettingItem {
31    Theme,
32    IconSet,
33    KeymapPreset,
34    Backups,
35    CheckForUpdates,
36}
37
38impl SettingItem {
39    #[must_use]
40    pub fn all() -> Vec<SettingItem> {
41        vec![
42            SettingItem::Theme,
43            SettingItem::IconSet,
44            SettingItem::KeymapPreset,
45            SettingItem::Backups,
46            SettingItem::CheckForUpdates,
47        ]
48    }
49
50    #[must_use]
51    pub fn name(&self) -> &'static str {
52        match self {
53            SettingItem::Theme => "Theme",
54            SettingItem::IconSet => "Icon Set",
55            SettingItem::KeymapPreset => "Keymap Preset",
56            SettingItem::Backups => "Backups",
57            SettingItem::CheckForUpdates => "Check for Updates",
58        }
59    }
60
61    #[must_use]
62    pub fn from_index(index: usize) -> Option<SettingItem> {
63        Self::all().get(index).copied()
64    }
65}
66
67/// Focus within the settings screen
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum SettingsFocus {
70    #[default]
71    List,
72    Options,
73}
74
75/// Settings screen state
76#[derive(Debug)]
77pub struct SettingsState {
78    pub list_state: ListState,
79    pub focus: SettingsFocus,
80    pub option_index: usize, // Selected option within the current setting
81}
82
83impl Default for SettingsState {
84    fn default() -> Self {
85        let mut list_state = ListState::default();
86        list_state.select(Some(0));
87        Self {
88            list_state,
89            focus: SettingsFocus::List,
90            option_index: 0,
91        }
92    }
93}
94
95/// Settings screen controller
96pub struct SettingsScreen {
97    state: SettingsState,
98}
99
100impl Default for SettingsScreen {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl SettingsScreen {
107    #[must_use]
108    pub fn new() -> Self {
109        Self {
110            state: SettingsState::default(),
111        }
112    }
113
114    fn selected_setting(&self) -> Option<SettingItem> {
115        self.state
116            .list_state
117            .selected()
118            .and_then(SettingItem::from_index)
119    }
120
121    /// Get available options for the current setting
122    fn get_options(&self, config: &Config) -> Vec<(String, bool)> {
123        match self.selected_setting() {
124            Some(SettingItem::Theme) => {
125                let current = &config.theme;
126                ThemeType::all()
127                    .iter()
128                    .map(|t| (t.name().to_string(), current == t.to_config_string()))
129                    .collect()
130            }
131            Some(SettingItem::IconSet) => {
132                use crate::icons::IconSet;
133                let current = &config.icon_set;
134                vec![
135                    ("auto".to_string(), current == "auto"),
136                    (IconSet::NerdFonts.name().to_string(), current == "nerd"),
137                    (IconSet::Unicode.name().to_string(), current == "unicode"),
138                    (IconSet::Emoji.name().to_string(), current == "emoji"),
139                    (IconSet::Ascii.name().to_string(), current == "ascii"),
140                ]
141            }
142            Some(SettingItem::KeymapPreset) => {
143                let current = config.keymap.preset;
144                vec![
145                    ("Standard".to_string(), current == KeymapPreset::Standard),
146                    ("Vim".to_string(), current == KeymapPreset::Vim),
147                    ("Emacs".to_string(), current == KeymapPreset::Emacs),
148                ]
149            }
150            Some(SettingItem::Backups) => {
151                vec![
152                    ("Enabled".to_string(), config.backup_enabled),
153                    ("Disabled".to_string(), !config.backup_enabled),
154                ]
155            }
156            Some(SettingItem::CheckForUpdates) => {
157                vec![
158                    ("Enabled".to_string(), config.updates.check_enabled),
159                    ("Disabled".to_string(), !config.updates.check_enabled),
160                ]
161            }
162            None => vec![],
163        }
164    }
165
166    /// Get explanation text for the current setting
167    fn get_explanation(&self, config: &Config) -> Text<'static> {
168        let t = theme();
169        let icons = Icons::from_config(config);
170
171        match self.selected_setting() {
172            Some(SettingItem::Theme) => {
173                let lines = vec![
174                    Line::from(Span::styled("Color Theme", t.title_style())),
175                    Line::from(""),
176                    Line::from(Span::styled(
177                        "Choose how DotState looks. The theme affects all colors in the UI.",
178                        t.text_style(),
179                    )),
180                    Line::from(""),
181                    Line::from(vec![
182                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
183                        Span::styled(" Current: ", t.muted_style()),
184                        Span::styled(config.theme.clone(), t.emphasis_style()),
185                    ]),
186                ];
187                Text::from(lines)
188            }
189            Some(SettingItem::IconSet) => {
190                let icons_preview = Icons::from_config(config);
191                let lines = vec![
192                    Line::from(Span::styled("Icon Set", t.title_style())),
193                    Line::from(""),
194                    Line::from(Span::styled(
195                        "Choose which icon set to use in the interface.",
196                        t.text_style(),
197                    )),
198                    Line::from(""),
199                    Line::from(Span::styled("Preview:", t.muted_style())),
200                    Line::from(vec![
201                        Span::styled(
202                            format!("  {} Folder  ", icons_preview.folder()),
203                            t.text_style(),
204                        ),
205                        Span::styled(format!("{} File  ", icons_preview.file()), t.text_style()),
206                        Span::styled(format!("{} Sync", icons_preview.sync()), t.text_style()),
207                    ]),
208                    Line::from(""),
209                    Line::from(vec![
210                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
211                        Span::styled(" Tip: ", Style::default().fg(t.secondary)),
212                        Span::styled(
213                            "Use 'nerd' if you have a NerdFont installed",
214                            t.text_style(),
215                        ),
216                    ]),
217                ];
218                Text::from(lines)
219            }
220            Some(SettingItem::KeymapPreset) => {
221                let lines = vec![
222                    Line::from(Span::styled("Keymap Preset", t.title_style())),
223                    Line::from(""),
224                    Line::from(Span::styled(
225                        "Choose keyboard bindings that feel natural to you.",
226                        t.text_style(),
227                    )),
228                    Line::from(""),
229                    Line::from(vec![
230                        Span::styled("  • ", t.muted_style()),
231                        Span::styled("Standard", t.emphasis_style()),
232                        Span::styled(": Arrow keys, Enter, Escape", t.text_style()),
233                    ]),
234                    Line::from(vec![
235                        Span::styled("  • ", t.muted_style()),
236                        Span::styled("Vim", t.emphasis_style()),
237                        Span::styled(": hjkl navigation, Esc to cancel", t.text_style()),
238                    ]),
239                    Line::from(vec![
240                        Span::styled("  • ", t.muted_style()),
241                        Span::styled("Emacs", t.emphasis_style()),
242                        Span::styled(": Ctrl+n/p/f/b navigation", t.text_style()),
243                    ]),
244                    Line::from(""),
245                    Line::from(vec![
246                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
247                        Span::styled(" Override bindings in config:", t.muted_style()),
248                    ]),
249                    Line::from(Span::styled("  [keymap.overrides]", t.emphasis_style())),
250                    Line::from(Span::styled("  confirm = \"ctrl+s\"", t.emphasis_style())),
251                ];
252                Text::from(lines)
253            }
254            Some(SettingItem::Backups) => {
255                let lines = vec![
256                    Line::from(Span::styled("Automatic Backups", t.title_style())),
257                    Line::from(""),
258                    Line::from(Span::styled(
259                        "When enabled, DotState creates .bak files before overwriting existing files during sync operations.",
260                        t.text_style(),
261                    )),
262                    Line::from(""),
263                    Line::from(vec![
264                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
265                        Span::styled(" Current: ", t.muted_style()),
266                        Span::styled(
267                            if config.backup_enabled { "Enabled" } else { "Disabled" },
268                            t.emphasis_style(),
269                        ),
270                    ]),
271                ];
272                Text::from(lines)
273            }
274            Some(SettingItem::CheckForUpdates) => {
275                let lines = vec![
276                    Line::from(Span::styled("Update Checks", t.title_style())),
277                    Line::from(""),
278                    Line::from(Span::styled(
279                        "When enabled, DotState periodically checks for new versions and shows a notification in the main menu.",
280                        t.text_style(),
281                    )),
282                    Line::from(""),
283                    Line::from(Span::styled(
284                        "You can always manually check for updates using:",
285                        t.text_style(),
286                    )),
287                    Line::from(Span::styled("  dotstate upgrade", t.emphasis_style())),
288                    Line::from(""),
289                    Line::from(vec![
290                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
291                        Span::styled(" Current: ", t.muted_style()),
292                        Span::styled(
293                            if config.updates.check_enabled { "Enabled" } else { "Disabled" },
294                            t.emphasis_style(),
295                        ),
296                    ]),
297                ];
298                Text::from(lines)
299            }
300            None => Text::from(""),
301        }
302    }
303
304    /// Apply a setting change by setting name (public, for use by App)
305    pub fn apply_setting_to_config(
306        &self,
307        config: &mut Config,
308        setting_name: &str,
309        option_index: usize,
310    ) -> bool {
311        Self::apply_setting_by_name(config, setting_name, option_index)
312    }
313
314    /// Apply a setting by name and option index
315    fn apply_setting_by_name(config: &mut Config, setting_name: &str, option_index: usize) -> bool {
316        match setting_name {
317            "Theme" => {
318                let themes = ThemeType::all();
319                if option_index < themes.len() {
320                    let selected_theme = themes[option_index];
321                    config.theme = selected_theme.to_config_string().to_string();
322                    // Apply theme immediately
323                    init_theme(selected_theme);
324                    return true;
325                }
326            }
327            "Icon Set" => {
328                let sets = ["auto", "nerd", "unicode", "emoji", "ascii"];
329                if option_index < sets.len() {
330                    config.icon_set = sets[option_index].to_string();
331                    return true;
332                }
333            }
334            "Keymap Preset" => {
335                let presets = [
336                    KeymapPreset::Standard,
337                    KeymapPreset::Vim,
338                    KeymapPreset::Emacs,
339                ];
340                if option_index < presets.len() {
341                    config.keymap.preset = presets[option_index];
342                    // Clear overrides when changing preset to ensure clean bindings
343                    config.keymap.overrides.clear();
344                    return true;
345                }
346            }
347            "Backups" => {
348                config.backup_enabled = option_index == 0;
349                return true;
350            }
351            "Check for Updates" => {
352                config.updates.check_enabled = option_index == 0;
353                return true;
354            }
355            _ => {}
356        }
357        false
358    }
359
360    /// Find the current option index for the selected setting
361    fn current_option_index(&self, config: &Config) -> usize {
362        let options = self.get_options(config);
363        options
364            .iter()
365            .position(|(_, selected)| *selected)
366            .unwrap_or(0)
367    }
368
369    fn render_settings_list(&mut self, frame: &mut Frame, area: Rect, config: &Config) {
370        let t = theme();
371        let icons = Icons::from_config(config);
372        let is_focused = self.state.focus == SettingsFocus::List;
373
374        let items: Vec<ListItem> = SettingItem::all()
375            .iter()
376            .map(|item| {
377                let current_value = match item {
378                    SettingItem::Theme => config.theme.clone(),
379                    SettingItem::IconSet => config.icon_set.clone(),
380                    SettingItem::KeymapPreset => format!("{:?}", config.keymap.preset),
381                    SettingItem::Backups => {
382                        if config.backup_enabled {
383                            "On".to_string()
384                        } else {
385                            "Off".to_string()
386                        }
387                    }
388                    SettingItem::CheckForUpdates => {
389                        if config.updates.check_enabled {
390                            "On".to_string()
391                        } else {
392                            "Off".to_string()
393                        }
394                    }
395                };
396
397                let line = Line::from(vec![
398                    Span::styled(
399                        format!("{} ", icons.cog()),
400                        Style::default().fg(t.secondary),
401                    ),
402                    Span::styled(item.name(), t.text_style()),
403                    Span::styled(format!(" ({current_value})"), t.muted_style()),
404                ]);
405                ListItem::new(line)
406            })
407            .collect();
408
409        let border_style = if is_focused {
410            focused_border_style()
411        } else {
412            unfocused_border_style()
413        };
414
415        let list = List::new(items)
416            .block(
417                Block::default()
418                    .borders(Borders::ALL)
419                    .title(" Settings ")
420                    .title_alignment(Alignment::Center)
421                    .border_type(t.border_type(is_focused))
422                    .border_style(border_style)
423                    .style(t.background_style()),
424            )
425            .highlight_style(t.highlight_style())
426            .highlight_symbol(crate::styles::LIST_HIGHLIGHT_SYMBOL);
427
428        StatefulWidget::render(list, area, frame.buffer_mut(), &mut self.state.list_state);
429    }
430
431    fn render_options_pane(&self, frame: &mut Frame, area: Rect, config: &Config) {
432        let t = theme();
433        let is_focused = self.state.focus == SettingsFocus::Options;
434
435        // Split into options (top) and explanation (bottom)
436        let chunks = Layout::default()
437            .direction(Direction::Vertical)
438            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
439            .split(area);
440
441        // Render options
442        let options = self.get_options(config);
443        let icons = Icons::from_config(config);
444
445        let option_lines: Vec<Line> = options
446            .iter()
447            .enumerate()
448            .map(|(i, (name, selected))| {
449                let marker = if *selected {
450                    icons.circle_filled()
451                } else {
452                    icons.circle_empty()
453                };
454                let style = if *selected {
455                    Style::default().fg(t.success).add_modifier(Modifier::BOLD)
456                } else if is_focused && i == self.state.option_index {
457                    t.highlight_style()
458                } else {
459                    t.text_style()
460                };
461                Line::from(vec![
462                    Span::styled(format!("  {marker} "), style),
463                    Span::styled(name.clone(), style),
464                ])
465            })
466            .collect();
467
468        let border_style = if is_focused {
469            focused_border_style()
470        } else {
471            unfocused_border_style()
472        };
473
474        let options_block = Paragraph::new(option_lines)
475            .block(
476                Block::default()
477                    .borders(Borders::ALL)
478                    .title(" Options ")
479                    .title_alignment(Alignment::Center)
480                    .border_type(t.border_type(is_focused))
481                    .border_style(border_style)
482                    .style(t.background_style()),
483            )
484            .wrap(Wrap { trim: false });
485        frame.render_widget(options_block, chunks[0]);
486
487        // Render explanation
488        let explanation = self.get_explanation(config);
489        let explanation_block = Paragraph::new(explanation)
490            .block(
491                Block::default()
492                    .borders(Borders::ALL)
493                    .title(" Details ")
494                    .title_alignment(Alignment::Center)
495                    .border_type(t.border_type(false))
496                    .border_style(unfocused_border_style())
497                    .padding(Padding::proportional(1))
498                    .style(t.background_style()),
499            )
500            .wrap(Wrap { trim: false });
501        frame.render_widget(explanation_block, chunks[1]);
502    }
503}
504
505impl Screen for SettingsScreen {
506    fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &RenderContext) -> Result<()> {
507        // Standard layout (header=5, footer=2)
508        let (header_chunk, content_chunk, footer_chunk) = create_standard_layout(area, 5, 3);
509
510        // Header
511        Header::render(
512            frame,
513            header_chunk,
514            "DotState - Settings",
515            "Configure your preferences. Changes are applied instantly.",
516        )?;
517
518        // Content: two-pane layout
519        let panes = create_split_layout(content_chunk, &[40, 60]);
520
521        // Left: settings list
522        self.render_settings_list(frame, panes[0], ctx.config);
523
524        // Right: options and explanation
525        self.render_options_pane(frame, panes[1], ctx.config);
526
527        // Footer
528        let k = |a| ctx.config.keymap.get_key_display_for_action(a);
529        let footer_text = format!(
530            "{}: Navigate | {}: Switch Focus | {}: Select | {}: Back",
531            ctx.config.keymap.navigation_display(),
532            k(Action::NextTab),
533            k(Action::Confirm),
534            k(Action::Cancel),
535        );
536        Footer::render(frame, footer_chunk, &footer_text)?;
537
538        Ok(())
539    }
540
541    fn handle_event(&mut self, event: Event, ctx: &ScreenContext) -> Result<ScreenAction> {
542        if let Event::Key(key) = event {
543            if key.kind != KeyEventKind::Press {
544                return Ok(ScreenAction::None);
545            }
546
547            let action = ctx.config.keymap.get_action(key.code, key.modifiers);
548
549            if let Some(action) = action {
550                match self.state.focus {
551                    SettingsFocus::List => match action {
552                        Action::MoveUp => {
553                            self.state.list_state.select_previous();
554                            // Update option index to current selection
555                            self.state.option_index = self.current_option_index(ctx.config);
556                        }
557                        Action::MoveDown => {
558                            self.state.list_state.select_next();
559                            self.state.option_index = self.current_option_index(ctx.config);
560                        }
561                        Action::Confirm | Action::NextTab | Action::MoveRight => {
562                            self.state.focus = SettingsFocus::Options;
563                            self.state.option_index = self.current_option_index(ctx.config);
564                        }
565                        Action::Cancel | Action::Quit => {
566                            return Ok(ScreenAction::Navigate(ScreenId::MainMenu));
567                        }
568                        _ => {}
569                    },
570                    SettingsFocus::Options => {
571                        let options = self.get_options(ctx.config);
572                        match action {
573                            Action::MoveUp => {
574                                if self.state.option_index > 0 {
575                                    self.state.option_index -= 1;
576                                }
577                            }
578                            Action::MoveDown => {
579                                if self.state.option_index < options.len().saturating_sub(1) {
580                                    self.state.option_index += 1;
581                                }
582                            }
583                            Action::Confirm => {
584                                // Apply the selected option
585                                return Ok(ScreenAction::UpdateSetting {
586                                    setting: self
587                                        .selected_setting()
588                                        .map(|s| s.name().to_string())
589                                        .unwrap_or_default(),
590                                    option_index: self.state.option_index,
591                                });
592                            }
593                            Action::NextTab | Action::MoveLeft | Action::Cancel => {
594                                self.state.focus = SettingsFocus::List;
595                            }
596                            Action::Quit => {
597                                return Ok(ScreenAction::Navigate(ScreenId::MainMenu));
598                            }
599                            _ => {}
600                        }
601                    }
602                }
603            }
604        }
605
606        Ok(ScreenAction::None)
607    }
608
609    fn is_input_focused(&self) -> bool {
610        false
611    }
612
613    fn on_enter(&mut self, _ctx: &ScreenContext) -> Result<()> {
614        // Reset to first setting
615        self.state.list_state.select(Some(0));
616        self.state.focus = SettingsFocus::List;
617        self.state.option_index = 0;
618        Ok(())
619    }
620}