1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum SettingsFocus {
70 #[default]
71 List,
72 Options,
73}
74
75#[derive(Debug)]
77pub struct SettingsState {
78 pub list_state: ListState,
79 pub focus: SettingsFocus,
80 pub option_index: usize, }
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
95pub 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 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 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 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 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 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 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 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 let chunks = Layout::default()
437 .direction(Direction::Vertical)
438 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
439 .split(area);
440
441 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 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 let (header_chunk, content_chunk, footer_chunk) = create_standard_layout(area, 5, 3);
509
510 Header::render(
512 frame,
513 header_chunk,
514 "DotState - Settings",
515 "Configure your preferences. Changes are applied instantly.",
516 )?;
517
518 let panes = create_split_layout(content_chunk, &[40, 60]);
520
521 self.render_settings_list(frame, panes[0], ctx.config);
523
524 self.render_options_pane(frame, panes[1], ctx.config);
526
527 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 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 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 self.state.list_state.select(Some(0));
616 self.state.focus = SettingsFocus::List;
617 self.state.option_index = 0;
618 Ok(())
619 }
620}