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, RepoMode};
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    MouseRegions,
18};
19use anyhow::Result;
20use crossterm::event::{Event, KeyEventKind, MouseButton, MouseEventKind};
21use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
22use ratatui::style::{Modifier, Style};
23use ratatui::text::{Line, Span, Text};
24use ratatui::widgets::{
25    Block, Borders, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Wrap,
26};
27use ratatui::Frame;
28
29/// Available settings
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum SettingItem {
32    Theme,
33    IconSet,
34    KeymapPreset,
35    Backups,
36    CheckForUpdates,
37    EmbedCredentials,
38}
39
40impl SettingItem {
41    #[must_use]
42    pub fn all(repo_mode: RepoMode) -> Vec<SettingItem> {
43        let mut items = vec![
44            SettingItem::Theme,
45            SettingItem::IconSet,
46            SettingItem::KeymapPreset,
47            SettingItem::Backups,
48            SettingItem::CheckForUpdates,
49        ];
50        if repo_mode == RepoMode::GitHub {
51            items.push(SettingItem::EmbedCredentials);
52        }
53        items
54    }
55
56    #[must_use]
57    pub fn name(&self) -> &'static str {
58        match self {
59            SettingItem::Theme => "Theme",
60            SettingItem::IconSet => "Icon Set",
61            SettingItem::KeymapPreset => "Keymap Preset",
62            SettingItem::Backups => "Backups",
63            SettingItem::CheckForUpdates => "Check for Updates",
64            SettingItem::EmbedCredentials => "Token in Remote URL",
65        }
66    }
67
68    #[must_use]
69    pub fn from_index(index: usize, repo_mode: RepoMode) -> Option<SettingItem> {
70        Self::all(repo_mode).get(index).copied()
71    }
72}
73
74/// Focus within the settings screen
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub enum SettingsFocus {
77    #[default]
78    List,
79    Options,
80}
81
82/// Settings screen state
83#[derive(Debug)]
84pub struct SettingsState {
85    pub list_state: ListState,
86    pub focus: SettingsFocus,
87    pub option_index: usize, // Selected option within the current setting
88}
89
90impl Default for SettingsState {
91    fn default() -> Self {
92        let mut list_state = ListState::default();
93        list_state.select(Some(0));
94        Self {
95            list_state,
96            focus: SettingsFocus::List,
97            option_index: 0,
98        }
99    }
100}
101
102/// Settings screen controller
103pub struct SettingsScreen {
104    state: SettingsState,
105    /// Clickable regions for settings list items
106    settings_regions: MouseRegions<usize>,
107    /// Clickable regions for option items
108    option_regions: MouseRegions<usize>,
109    /// Area of the settings list pane (for scroll hit-testing)
110    list_pane_area: Option<Rect>,
111    /// Area of the options pane (for scroll hit-testing)
112    options_pane_area: Option<Rect>,
113}
114
115impl Default for SettingsScreen {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl SettingsScreen {
122    #[must_use]
123    pub fn new() -> Self {
124        Self {
125            state: SettingsState::default(),
126            settings_regions: MouseRegions::new(),
127            option_regions: MouseRegions::new(),
128            list_pane_area: None,
129            options_pane_area: None,
130        }
131    }
132
133    fn selected_setting(&self, repo_mode: RepoMode) -> Option<SettingItem> {
134        self.state
135            .list_state
136            .selected()
137            .and_then(|i| SettingItem::from_index(i, repo_mode))
138    }
139
140    /// Get available options for the current setting
141    fn get_options(&self, config: &Config) -> Vec<(String, bool)> {
142        match self.selected_setting(config.repo_mode) {
143            Some(SettingItem::Theme) => {
144                let current = &config.theme;
145                ThemeType::all()
146                    .iter()
147                    .map(|t| (t.name().to_string(), current == t.to_config_string()))
148                    .collect()
149            }
150            Some(SettingItem::IconSet) => {
151                use crate::icons::IconSet;
152                let current = &config.icon_set;
153                vec![
154                    ("auto".to_string(), current == "auto"),
155                    (IconSet::NerdFonts.name().to_string(), current == "nerd"),
156                    (IconSet::Unicode.name().to_string(), current == "unicode"),
157                    (IconSet::Emoji.name().to_string(), current == "emoji"),
158                    (IconSet::Ascii.name().to_string(), current == "ascii"),
159                ]
160            }
161            Some(SettingItem::KeymapPreset) => {
162                let current = config.keymap.preset;
163                vec![
164                    ("Standard".to_string(), current == KeymapPreset::Standard),
165                    ("Vim".to_string(), current == KeymapPreset::Vim),
166                    ("Emacs".to_string(), current == KeymapPreset::Emacs),
167                ]
168            }
169            Some(SettingItem::Backups) => {
170                vec![
171                    ("Enabled".to_string(), config.backup_enabled),
172                    ("Disabled".to_string(), !config.backup_enabled),
173                ]
174            }
175            Some(SettingItem::CheckForUpdates) => {
176                vec![
177                    ("Enabled".to_string(), config.updates.check_enabled),
178                    ("Disabled".to_string(), !config.updates.check_enabled),
179                ]
180            }
181            Some(SettingItem::EmbedCredentials) => {
182                vec![
183                    ("Enabled".to_string(), config.embed_credentials_in_url),
184                    ("Disabled".to_string(), !config.embed_credentials_in_url),
185                ]
186            }
187            None => vec![],
188        }
189    }
190
191    /// Get explanation text for the current setting
192    fn get_explanation(&self, config: &Config) -> Text<'static> {
193        let t = theme();
194        let icons = Icons::from_config(config);
195
196        match self.selected_setting(config.repo_mode) {
197            Some(SettingItem::Theme) => {
198                let lines = vec![
199                    Line::from(Span::styled("Color Theme", t.title_style())),
200                    Line::from(""),
201                    Line::from(Span::styled(
202                        "Choose how DotState looks. The theme affects all colors in the UI.",
203                        t.text_style(),
204                    )),
205                    Line::from(""),
206                    Line::from(vec![
207                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
208                        Span::styled(" Current: ", t.muted_style()),
209                        Span::styled(config.theme.clone(), t.emphasis_style()),
210                    ]),
211                ];
212                Text::from(lines)
213            }
214            Some(SettingItem::IconSet) => {
215                let icons_preview = Icons::from_config(config);
216                let lines = vec![
217                    Line::from(Span::styled("Icon Set", t.title_style())),
218                    Line::from(""),
219                    Line::from(Span::styled(
220                        "Choose which icon set to use in the interface.",
221                        t.text_style(),
222                    )),
223                    Line::from(""),
224                    Line::from(Span::styled("Preview:", t.muted_style())),
225                    Line::from(vec![
226                        Span::styled(
227                            format!("  {} Folder  ", icons_preview.folder()),
228                            t.text_style(),
229                        ),
230                        Span::styled(format!("{} File  ", icons_preview.file()), t.text_style()),
231                        Span::styled(format!("{} Sync", icons_preview.sync()), t.text_style()),
232                    ]),
233                    Line::from(""),
234                    Line::from(vec![
235                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
236                        Span::styled(" Tip: ", Style::default().fg(t.secondary)),
237                        Span::styled(
238                            "Use 'nerd' if you have a NerdFont installed",
239                            t.text_style(),
240                        ),
241                    ]),
242                ];
243                Text::from(lines)
244            }
245            Some(SettingItem::KeymapPreset) => {
246                let lines = vec![
247                    Line::from(Span::styled("Keymap Preset", t.title_style())),
248                    Line::from(""),
249                    Line::from(Span::styled(
250                        "Choose keyboard bindings that feel natural to you.",
251                        t.text_style(),
252                    )),
253                    Line::from(""),
254                    Line::from(vec![
255                        Span::styled("  • ", t.muted_style()),
256                        Span::styled("Standard", t.emphasis_style()),
257                        Span::styled(": Arrow keys, Enter, Escape", t.text_style()),
258                    ]),
259                    Line::from(vec![
260                        Span::styled("  • ", t.muted_style()),
261                        Span::styled("Vim", t.emphasis_style()),
262                        Span::styled(": hjkl navigation, Esc to cancel", t.text_style()),
263                    ]),
264                    Line::from(vec![
265                        Span::styled("  • ", t.muted_style()),
266                        Span::styled("Emacs", t.emphasis_style()),
267                        Span::styled(": Ctrl+n/p/f/b navigation", t.text_style()),
268                    ]),
269                    Line::from(""),
270                    Line::from(vec![
271                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
272                        Span::styled(" Override bindings in config:", t.muted_style()),
273                    ]),
274                    Line::from(Span::styled("  [keymap.overrides]", t.emphasis_style())),
275                    Line::from(Span::styled("  confirm = \"ctrl+s\"", t.emphasis_style())),
276                ];
277                Text::from(lines)
278            }
279            Some(SettingItem::Backups) => {
280                let lines = vec![
281                    Line::from(Span::styled("Automatic Backups", t.title_style())),
282                    Line::from(""),
283                    Line::from(Span::styled(
284                        "When enabled, DotState creates .bak files before overwriting existing files during sync operations.",
285                        t.text_style(),
286                    )),
287                    Line::from(""),
288                    Line::from(vec![
289                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
290                        Span::styled(" Current: ", t.muted_style()),
291                        Span::styled(
292                            if config.backup_enabled { "Enabled" } else { "Disabled" },
293                            t.emphasis_style(),
294                        ),
295                    ]),
296                ];
297                Text::from(lines)
298            }
299            Some(SettingItem::CheckForUpdates) => {
300                let lines = vec![
301                    Line::from(Span::styled("Update Checks", t.title_style())),
302                    Line::from(""),
303                    Line::from(Span::styled(
304                        "When enabled, DotState periodically checks for new versions and shows a notification in the main menu.",
305                        t.text_style(),
306                    )),
307                    Line::from(""),
308                    Line::from(Span::styled(
309                        "You can always manually check for updates using:",
310                        t.text_style(),
311                    )),
312                    Line::from(Span::styled("  dotstate upgrade", t.emphasis_style())),
313                    Line::from(""),
314                    Line::from(vec![
315                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
316                        Span::styled(" Current: ", t.muted_style()),
317                        Span::styled(
318                            if config.updates.check_enabled { "Enabled" } else { "Disabled" },
319                            t.emphasis_style(),
320                        ),
321                    ]),
322                ];
323                Text::from(lines)
324            }
325            Some(SettingItem::EmbedCredentials) => {
326                let lines = vec![
327                    Line::from(Span::styled("Token in Remote URL", t.title_style())),
328                    Line::from(""),
329                    Line::from(Span::styled(
330                        "Controls how DotState authenticates with GitHub when syncing your dotfiles.",
331                        t.text_style(),
332                    )),
333                    Line::from(""),
334                    Line::from(vec![
335                        Span::styled("  • ", t.muted_style()),
336                        Span::styled("Enabled", t.emphasis_style()),
337                        Span::styled(
338                            ": Token is stored in the remote URL",
339                            t.text_style(),
340                        ),
341                    ]),
342                    Line::from(vec![
343                        Span::styled("  • ", t.muted_style()),
344                        Span::styled("Disabled", t.emphasis_style()),
345                        Span::styled(
346                            ": Uses your system's git credential manager",
347                            t.text_style(),
348                        ),
349                    ]),
350                    Line::from(""),
351                    Line::from(vec![
352                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
353                        Span::styled(
354                            " Disable if your environment blocks URLs with embedded tokens.",
355                            t.muted_style(),
356                        ),
357                    ]),
358                    Line::from(""),
359                    Line::from(vec![
360                        Span::styled(icons.lightbulb(), Style::default().fg(t.secondary)),
361                        Span::styled(" Current: ", t.muted_style()),
362                        Span::styled(
363                            if config.embed_credentials_in_url { "Enabled" } else { "Disabled" },
364                            t.emphasis_style(),
365                        ),
366                    ]),
367                ];
368                Text::from(lines)
369            }
370            None => Text::from(""),
371        }
372    }
373
374    /// Apply a setting change by setting name (public, for use by App)
375    pub fn apply_setting_to_config(
376        &self,
377        config: &mut Config,
378        setting_name: &str,
379        option_index: usize,
380    ) -> bool {
381        Self::apply_setting_by_name(config, setting_name, option_index)
382    }
383
384    /// Apply a setting by name and option index
385    fn apply_setting_by_name(config: &mut Config, setting_name: &str, option_index: usize) -> bool {
386        match setting_name {
387            "Theme" => {
388                let themes = ThemeType::all();
389                if option_index < themes.len() {
390                    let selected_theme = themes[option_index];
391                    config.theme = selected_theme.to_config_string().to_string();
392                    // Apply theme immediately
393                    init_theme(selected_theme);
394                    return true;
395                }
396            }
397            "Icon Set" => {
398                let sets = ["auto", "nerd", "unicode", "emoji", "ascii"];
399                if option_index < sets.len() {
400                    config.icon_set = sets[option_index].to_string();
401                    return true;
402                }
403            }
404            "Keymap Preset" => {
405                let presets = [
406                    KeymapPreset::Standard,
407                    KeymapPreset::Vim,
408                    KeymapPreset::Emacs,
409                ];
410                if option_index < presets.len() {
411                    config.keymap.preset = presets[option_index];
412                    // Clear overrides when changing preset to ensure clean bindings
413                    config.keymap.overrides.clear();
414                    return true;
415                }
416            }
417            "Backups" => {
418                config.backup_enabled = option_index == 0;
419                return true;
420            }
421            "Check for Updates" => {
422                config.updates.check_enabled = option_index == 0;
423                return true;
424            }
425            "Token in Remote URL" => {
426                config.embed_credentials_in_url = option_index == 0;
427                return true;
428            }
429            _ => {}
430        }
431        false
432    }
433
434    /// Find the current option index for the selected setting
435    fn current_option_index(&self, config: &Config) -> usize {
436        let options = self.get_options(config);
437        options
438            .iter()
439            .position(|(_, selected)| *selected)
440            .unwrap_or(0)
441    }
442
443    fn render_settings_list(&mut self, frame: &mut Frame, area: Rect, config: &Config) {
444        let t = theme();
445        let icons = Icons::from_config(config);
446        let is_focused = self.state.focus == SettingsFocus::List;
447
448        // Store pane area and populate mouse regions
449        self.list_pane_area = Some(area);
450        self.settings_regions.clear();
451        let inner = Block::default().borders(Borders::ALL).inner(area);
452        let item_count = SettingItem::all(config.repo_mode).len();
453        let scroll_offset = self.state.list_state.offset();
454        for i in 0..item_count {
455            let visible_idx = i.saturating_sub(scroll_offset);
456            if i >= scroll_offset && (visible_idx as u16) < inner.height {
457                let row = Rect::new(inner.x, inner.y + visible_idx as u16, inner.width, 1);
458                self.settings_regions.add(row, i);
459            }
460        }
461
462        let items: Vec<ListItem> = SettingItem::all(config.repo_mode)
463            .iter()
464            .map(|item| {
465                let current_value = match item {
466                    SettingItem::Theme => config.theme.clone(),
467                    SettingItem::IconSet => config.icon_set.clone(),
468                    SettingItem::KeymapPreset => format!("{:?}", config.keymap.preset),
469                    SettingItem::Backups => {
470                        if config.backup_enabled {
471                            "On".to_string()
472                        } else {
473                            "Off".to_string()
474                        }
475                    }
476                    SettingItem::CheckForUpdates => {
477                        if config.updates.check_enabled {
478                            "On".to_string()
479                        } else {
480                            "Off".to_string()
481                        }
482                    }
483                    SettingItem::EmbedCredentials => {
484                        if config.embed_credentials_in_url {
485                            "On".to_string()
486                        } else {
487                            "Off".to_string()
488                        }
489                    }
490                };
491
492                let line = Line::from(vec![
493                    Span::styled(
494                        format!("{} ", icons.cog()),
495                        Style::default().fg(t.secondary),
496                    ),
497                    Span::styled(item.name(), t.text_style()),
498                    Span::styled(format!(" ({current_value})"), t.muted_style()),
499                ]);
500                ListItem::new(line)
501            })
502            .collect();
503
504        let border_style = if is_focused {
505            focused_border_style()
506        } else {
507            unfocused_border_style()
508        };
509
510        let list = List::new(items)
511            .block(
512                Block::default()
513                    .borders(Borders::ALL)
514                    .title(" Settings ")
515                    .title_alignment(Alignment::Center)
516                    .border_type(t.border_type(is_focused))
517                    .border_style(border_style)
518                    .style(t.background_style()),
519            )
520            .highlight_style(t.highlight_style())
521            .highlight_symbol(crate::styles::LIST_HIGHLIGHT_SYMBOL);
522
523        StatefulWidget::render(list, area, frame.buffer_mut(), &mut self.state.list_state);
524    }
525
526    fn render_options_pane(&mut self, frame: &mut Frame, area: Rect, config: &Config) {
527        let t = theme();
528        let is_focused = self.state.focus == SettingsFocus::Options;
529
530        // Split into options (top) and explanation (bottom)
531        let chunks = Layout::default()
532            .direction(Direction::Vertical)
533            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
534            .split(area);
535
536        // Store pane area and populate option regions
537        self.options_pane_area = Some(chunks[0]);
538        self.option_regions.clear();
539
540        // Render options
541        let options = self.get_options(config);
542        let icons = Icons::from_config(config);
543
544        // Populate option click regions (each option is 1 line inside the block)
545        let options_inner = Block::default().borders(Borders::ALL).inner(chunks[0]);
546        for i in 0..options.len() {
547            if (i as u16) < options_inner.height {
548                let row = Rect::new(
549                    options_inner.x,
550                    options_inner.y + i as u16,
551                    options_inner.width,
552                    1,
553                );
554                self.option_regions.add(row, i);
555            }
556        }
557
558        let option_lines: Vec<Line> = options
559            .iter()
560            .enumerate()
561            .map(|(i, (name, selected))| {
562                let marker = if *selected {
563                    icons.circle_filled()
564                } else {
565                    icons.circle_empty()
566                };
567                let style = if *selected {
568                    Style::default().fg(t.success).add_modifier(Modifier::BOLD)
569                } else if is_focused && i == self.state.option_index {
570                    t.highlight_style()
571                } else {
572                    t.text_style()
573                };
574                Line::from(vec![
575                    Span::styled(format!("  {marker} "), style),
576                    Span::styled(name.clone(), style),
577                ])
578            })
579            .collect();
580
581        let border_style = if is_focused {
582            focused_border_style()
583        } else {
584            unfocused_border_style()
585        };
586
587        let options_block = Paragraph::new(option_lines)
588            .block(
589                Block::default()
590                    .borders(Borders::ALL)
591                    .title(" Options ")
592                    .title_alignment(Alignment::Center)
593                    .border_type(t.border_type(is_focused))
594                    .border_style(border_style)
595                    .style(t.background_style()),
596            )
597            .wrap(Wrap { trim: false });
598        frame.render_widget(options_block, chunks[0]);
599
600        // Render explanation
601        let explanation = self.get_explanation(config);
602        let explanation_block = Paragraph::new(explanation)
603            .block(
604                Block::default()
605                    .borders(Borders::ALL)
606                    .title(" Details ")
607                    .title_alignment(Alignment::Center)
608                    .border_type(t.border_type(false))
609                    .border_style(unfocused_border_style())
610                    .padding(Padding::proportional(1))
611                    .style(t.background_style()),
612            )
613            .wrap(Wrap { trim: false });
614        frame.render_widget(explanation_block, chunks[1]);
615    }
616}
617
618impl Screen for SettingsScreen {
619    fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &RenderContext) -> Result<()> {
620        // Standard layout (header=5, footer=2)
621        let (header_chunk, content_chunk, footer_chunk) = create_standard_layout(area, 5, 3);
622
623        // Header
624        Header::render(
625            frame,
626            header_chunk,
627            "DotState - Settings",
628            "Configure your preferences. Changes are applied instantly.",
629        )?;
630
631        // Content: two-pane layout
632        let panes = create_split_layout(content_chunk, &[40, 60]);
633
634        // Left: settings list
635        self.render_settings_list(frame, panes[0], ctx.config);
636
637        // Right: options and explanation
638        self.render_options_pane(frame, panes[1], ctx.config);
639
640        // Footer
641        let k = |a| ctx.config.keymap.get_key_display_for_action(a);
642        let footer_text = format!(
643            "{}: Navigate | {}: Switch Focus | {}: Select | {}: Back",
644            ctx.config.keymap.navigation_display(),
645            k(Action::NextTab),
646            k(Action::Confirm),
647            k(Action::Cancel),
648        );
649        Footer::render(frame, footer_chunk, &footer_text)?;
650
651        Ok(())
652    }
653
654    fn handle_event(&mut self, event: Event, ctx: &ScreenContext) -> Result<ScreenAction> {
655        match event {
656            Event::Key(key) if key.kind == KeyEventKind::Press => {
657                let action = ctx.config.keymap.get_action(key.code, key.modifiers);
658
659                if let Some(action) = action {
660                    match self.state.focus {
661                        SettingsFocus::List => match action {
662                            Action::MoveUp => {
663                                self.state.list_state.select_previous();
664                                self.state.option_index = self.current_option_index(ctx.config);
665                            }
666                            Action::MoveDown => {
667                                self.state.list_state.select_next();
668                                self.state.option_index = self.current_option_index(ctx.config);
669                            }
670                            Action::Confirm | Action::NextTab | Action::MoveRight => {
671                                self.state.focus = SettingsFocus::Options;
672                                self.state.option_index = self.current_option_index(ctx.config);
673                            }
674                            Action::Cancel | Action::Quit => {
675                                return Ok(ScreenAction::Navigate(ScreenId::MainMenu));
676                            }
677                            _ => {}
678                        },
679                        SettingsFocus::Options => {
680                            let options = self.get_options(ctx.config);
681                            match action {
682                                Action::MoveUp if self.state.option_index > 0 => {
683                                    self.state.option_index -= 1;
684                                }
685                                Action::MoveDown
686                                    if self.state.option_index
687                                        < options.len().saturating_sub(1) =>
688                                {
689                                    self.state.option_index += 1;
690                                }
691                                Action::Confirm => {
692                                    return Ok(ScreenAction::UpdateSetting {
693                                        setting: self
694                                            .selected_setting(ctx.config.repo_mode)
695                                            .map(|s| s.name().to_string())
696                                            .unwrap_or_default(),
697                                        option_index: self.state.option_index,
698                                    });
699                                }
700                                Action::NextTab | Action::MoveLeft | Action::Cancel => {
701                                    self.state.focus = SettingsFocus::List;
702                                }
703                                Action::Quit => {
704                                    return Ok(ScreenAction::Navigate(ScreenId::MainMenu));
705                                }
706                                _ => {}
707                            }
708                        }
709                    }
710                }
711            }
712            Event::Mouse(mouse) => {
713                return self.handle_mouse_event(mouse, ctx);
714            }
715            _ => {}
716        }
717
718        Ok(ScreenAction::None)
719    }
720
721    fn is_input_focused(&self) -> bool {
722        false
723    }
724
725    fn on_enter(&mut self, _ctx: &ScreenContext) -> Result<()> {
726        // Reset to first setting
727        self.state.list_state.select(Some(0));
728        self.state.focus = SettingsFocus::List;
729        self.state.option_index = 0;
730        Ok(())
731    }
732}
733
734impl SettingsScreen {
735    fn handle_mouse_event(
736        &mut self,
737        mouse: crossterm::event::MouseEvent,
738        ctx: &ScreenContext,
739    ) -> Result<ScreenAction> {
740        match mouse.kind {
741            MouseEventKind::Down(MouseButton::Left) => {
742                // Check settings list click
743                if let Some(&idx) = self.settings_regions.hit_test(mouse.column, mouse.row) {
744                    self.state.list_state.select(Some(idx));
745                    self.state.focus = SettingsFocus::List;
746                    self.state.option_index = self.current_option_index(ctx.config);
747                    return Ok(ScreenAction::Refresh);
748                }
749                // Check options click
750                if let Some(&idx) = self.option_regions.hit_test(mouse.column, mouse.row) {
751                    self.state.focus = SettingsFocus::Options;
752                    self.state.option_index = idx;
753                    // Apply immediately on click
754                    return Ok(ScreenAction::UpdateSetting {
755                        setting: self
756                            .selected_setting(ctx.config.repo_mode)
757                            .map(|s| s.name().to_string())
758                            .unwrap_or_default(),
759                        option_index: idx,
760                    });
761                }
762            }
763            MouseEventKind::ScrollUp => {
764                if let Some(area) = self.list_pane_area {
765                    if area.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) {
766                        for _ in 0..3 {
767                            self.state.list_state.select_previous();
768                        }
769                        self.state.option_index = self.current_option_index(ctx.config);
770                        return Ok(ScreenAction::Refresh);
771                    }
772                }
773                if let Some(area) = self.options_pane_area {
774                    if area.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) {
775                        self.state.focus = SettingsFocus::Options;
776                        self.state.option_index = self.state.option_index.saturating_sub(3);
777                        return Ok(ScreenAction::Refresh);
778                    }
779                }
780            }
781            MouseEventKind::ScrollDown => {
782                if let Some(area) = self.list_pane_area {
783                    if area.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) {
784                        for _ in 0..3 {
785                            self.state.list_state.select_next();
786                        }
787                        self.state.option_index = self.current_option_index(ctx.config);
788                        return Ok(ScreenAction::Refresh);
789                    }
790                }
791                if let Some(area) = self.options_pane_area {
792                    if area.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) {
793                        self.state.focus = SettingsFocus::Options;
794                        let max = self.get_options(ctx.config).len().saturating_sub(1);
795                        self.state.option_index = (self.state.option_index + 3).min(max);
796                        return Ok(ScreenAction::Refresh);
797                    }
798                }
799            }
800            _ => {}
801        }
802        Ok(ScreenAction::None)
803    }
804}