Skip to main content

void/app/
settings.rs

1use super::*;
2use crate::model::{EmptyQueueBehavior, EstimateCompleteBehavior};
3use crossterm::event::{KeyCode, KeyEvent};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum SettingsItem {
7    FocusMinutes,
8    ShortBreak,
9    LongBreak,
10    LongBreakEvery,
11    DailyGoal,
12    Sound,
13    Notifications,
14    AutoStartBreaks,
15    AutoStartFocus,
16    ActiveTaskCycle,
17    Theme,
18    CustomMinutes,
19    AutoPickTask,
20    AutoAdvanceTask,
21    EmptyQueueBehavior,
22    LogBreaks,
23    EstimateComplete,
24    ExportBackup,
25    TerminalTitle,
26    WarnOneMinute,
27    AutoPauseIdle,
28    ArchiveAfterDays,
29}
30
31#[derive(Debug, Clone, Copy)]
32pub(crate) struct NumericSettingSpec<'a> {
33    pub key: &'a str,
34    pub label: &'a str,
35    pub min: u32,
36    pub max: u32,
37    pub step: i32,
38}
39
40#[derive(Debug, Clone, Default)]
41pub struct SettingsState {
42    pub selected: usize,
43    pub scroll_offset: usize,
44    /// Visible rows in the settings table (updated each draw).
45    pub page_size: usize,
46    pub items: Vec<SettingsItem>,
47}
48
49impl SettingsState {
50    pub fn new() -> Self {
51        Self {
52            selected: 0,
53            scroll_offset: 0,
54            page_size: 12,
55            items: vec![
56                SettingsItem::FocusMinutes,
57                SettingsItem::ShortBreak,
58                SettingsItem::LongBreak,
59                SettingsItem::LongBreakEvery,
60                SettingsItem::DailyGoal,
61                SettingsItem::Sound,
62                SettingsItem::Notifications,
63                SettingsItem::AutoStartBreaks,
64                SettingsItem::AutoStartFocus,
65                SettingsItem::ActiveTaskCycle,
66                SettingsItem::Theme,
67                SettingsItem::CustomMinutes,
68                SettingsItem::AutoPickTask,
69                SettingsItem::AutoAdvanceTask,
70                SettingsItem::EmptyQueueBehavior,
71                SettingsItem::LogBreaks,
72                SettingsItem::EstimateComplete,
73                SettingsItem::TerminalTitle,
74                SettingsItem::WarnOneMinute,
75                SettingsItem::AutoPauseIdle,
76                SettingsItem::ArchiveAfterDays,
77                SettingsItem::ExportBackup,
78            ],
79        }
80    }
81}
82
83impl App {
84    pub(crate) fn adjust_numeric_setting<F>(
85        &mut self,
86        dir: i32,
87        spec: NumericSettingSpec<'_>,
88        mut getter: F,
89    ) where
90        F: FnMut(&mut crate::model::AppData) -> &mut u32,
91    {
92        let val = getter(&mut self.data);
93        let cur = *val as i32;
94        let new_val = (cur + dir * spec.step).clamp(spec.min as i32, spec.max as i32) as u32;
95        *val = new_val;
96        if !spec.key.is_empty() {
97            self.persist_setting(spec.key, new_val.to_string());
98        }
99        self.set_status(format!("{}: {}", spec.label, new_val), false);
100    }
101
102    pub(crate) fn toggle_bool_setting<F>(&mut self, key: &str, label: &str, mut getter: F)
103    where
104        F: FnMut(&mut crate::model::AppData) -> &mut bool,
105    {
106        let val = getter(&mut self.data);
107        *val = !*val;
108        let is_on = *val;
109        self.persist_setting(key, if is_on { "1" } else { "0" });
110        self.set_status(
111            format!("{}: {}", label, if is_on { "on" } else { "off" }),
112            false,
113        );
114    }
115
116    pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) {
117        let n = self.settings_state.items.len();
118        match key.code {
119            KeyCode::Down | KeyCode::Char('j') => {
120                self.settings_state.selected = (self.settings_state.selected + 1) % n;
121                self.sync_settings_scroll();
122            }
123            KeyCode::Up | KeyCode::Char('k') => {
124                if self.settings_state.selected == 0 {
125                    self.settings_state.selected = n - 1;
126                } else {
127                    self.settings_state.selected -= 1;
128                }
129                self.sync_settings_scroll();
130            }
131            KeyCode::Enter => {
132                let item = self.settings_state.items[self.settings_state.selected];
133                if item == SettingsItem::ExportBackup {
134                    self.export_backup();
135                } else {
136                    self.adjust_setting(1);
137                }
138            }
139            KeyCode::Right | KeyCode::Char('+') | KeyCode::Char('=') => {
140                self.adjust_setting(1);
141            }
142            KeyCode::Left | KeyCode::Char('-') => {
143                self.adjust_setting(-1);
144            }
145            _ => {}
146        }
147    }
148
149    pub fn settings_visual_row(selected: usize) -> usize {
150        const HEADERS: [usize; 6] = [0, 5, 9, 10, 14, 17];
151        selected + HEADERS.iter().filter(|h| **h <= selected).count()
152    }
153
154    pub fn sync_settings_scroll(&mut self) {
155        let visible = self.settings_state.page_size.max(4);
156        let visual = Self::settings_visual_row(self.settings_state.selected);
157        if visual < self.settings_state.scroll_offset {
158            self.settings_state.scroll_offset = visual;
159        } else if visual >= self.settings_state.scroll_offset + visible {
160            self.settings_state.scroll_offset = visual.saturating_sub(visible - 1);
161        }
162    }
163
164    pub(crate) fn adjust_setting(&mut self, dir: i32) {
165        let item = self.settings_state.items[self.settings_state.selected];
166        match item {
167            SettingsItem::FocusMinutes => {
168                let cur = self.timer.config.focus_minutes as i32;
169                let v = (cur + dir).clamp(1, 240) as u32;
170                self.timer.set_focus_minutes(v);
171                self.data.focus_minutes = v;
172                if let Err(e) = self.db.persist_timer_settings(&self.data) {
173                    self.set_status(format!("Save error: {e}"), true);
174                }
175                self.set_status(format!("Focus: {} min", v), false);
176            }
177            SettingsItem::ShortBreak => {
178                let cur = self.timer.config.short_break_minutes as i32;
179                let v = (cur + dir).clamp(1, 60) as u32;
180                self.timer.config.short_break_minutes = v;
181                self.data.short_break_minutes = v;
182                if let Err(e) = self.db.persist_timer_settings(&self.data) {
183                    self.set_status(format!("Save error: {e}"), true);
184                }
185                self.set_status(format!("Short break: {} min", v), false);
186            }
187            SettingsItem::LongBreak => {
188                let cur = self.timer.config.long_break_minutes as i32;
189                let v = (cur + dir).clamp(1, 120) as u32;
190                self.timer.config.long_break_minutes = v;
191                self.data.long_break_minutes = v;
192                if let Err(e) = self.db.persist_timer_settings(&self.data) {
193                    self.set_status(format!("Save error: {e}"), true);
194                }
195                self.set_status(format!("Long break: {} min", v), false);
196            }
197            SettingsItem::LongBreakEvery => {
198                let cur = self.timer.config.long_break_every as i32;
199                let v = (cur + dir).clamp(1, 12) as u32;
200                self.timer.config.long_break_every = v;
201                self.data.long_break_every = v;
202                if let Err(e) = self.db.persist_timer_settings(&self.data) {
203                    self.set_status(format!("Save error: {e}"), true);
204                }
205                self.set_status(format!("Long break every: {} sessions", v), false);
206            }
207            SettingsItem::DailyGoal => {
208                self.adjust_numeric_setting(
209                    dir,
210                    NumericSettingSpec {
211                        key: "daily_goal_minutes",
212                        label: "Daily goal (min)",
213                        min: 15,
214                        max: 1440,
215                        step: 15,
216                    },
217                    |d| &mut d.daily_goal_minutes,
218                );
219            }
220            SettingsItem::Sound => {
221                self.toggle_bool_setting("sound_enabled", "Sound", |d| &mut d.sound_enabled);
222            }
223            SettingsItem::Notifications => {
224                self.toggle_bool_setting("notify_on_finish", "Notifications", |d| {
225                    &mut d.notify_on_finish
226                });
227            }
228            SettingsItem::AutoStartBreaks => {
229                self.toggle_bool_setting("auto_start_breaks", "Auto-start breaks", |d| {
230                    &mut d.auto_start_breaks
231                });
232            }
233            SettingsItem::AutoStartFocus => {
234                self.toggle_bool_setting("auto_start_focus", "Auto-start focus", |d| {
235                    &mut d.auto_start_focus
236                });
237            }
238            SettingsItem::ActiveTaskCycle => {
239                if self.data.tasks.is_empty() {
240                    self.set_active_task(None);
241                    self.set_status("No tasks to activate.", true);
242                    return;
243                }
244                let ids: Vec<u64> = self.data.tasks.iter().map(|t| t.id).collect();
245                let cur = self
246                    .active_task
247                    .and_then(|id| ids.iter().position(|x| *x == id));
248                let next_idx = match (cur, dir) {
249                    (Some(i), d) if d > 0 => (i + 1) % ids.len(),
250                    (Some(i), d) if d < 0 => {
251                        if i == 0 {
252                            ids.len() - 1
253                        } else {
254                            i - 1
255                        }
256                    }
257                    (None, _) => 0,
258                    _ => 0,
259                };
260                self.set_active_task(Some(ids[next_idx]));
261                if let Some(task) = self.data.tasks.iter().find(|t| t.id == ids[next_idx]) {
262                    self.set_status(format!("Active task: {}", task.title), false);
263                }
264            }
265            SettingsItem::Theme => {
266                let next = self.theme_catalog.next_id(&self.data.theme);
267                let label = self.theme_catalog.label(&next);
268                self.apply_theme(&next);
269                self.set_status(format!("Theme: {label}"), false);
270            }
271            SettingsItem::CustomMinutes => {
272                let cur = self.timer.custom_minutes as i32;
273                let v = (cur + dir).clamp(1, 240) as u32;
274                self.timer.set_custom_minutes(v);
275                self.set_status(format!("Custom timer: {} min", v), false);
276            }
277            SettingsItem::AutoPickTask => {
278                self.toggle_bool_setting("auto_pick_task", "Auto-pick task", |d| {
279                    &mut d.auto_pick_task
280                });
281            }
282            SettingsItem::AutoAdvanceTask => {
283                self.toggle_bool_setting("auto_advance_task", "Auto-advance task", |d| {
284                    &mut d.auto_advance_task
285                });
286            }
287            SettingsItem::EmptyQueueBehavior => {
288                self.data.empty_queue_behavior = self.data.empty_queue_behavior.next();
289                let key = match self.data.empty_queue_behavior {
290                    EmptyQueueBehavior::FreeFocus => "free-focus",
291                    EmptyQueueBehavior::PauseTimer => "pause-timer",
292                    EmptyQueueBehavior::AskEachTime => "ask",
293                };
294                self.persist_setting("empty_queue_behavior", key);
295                self.set_status(
296                    format!(
297                        "When queue empty: {}",
298                        self.data.empty_queue_behavior.label()
299                    ),
300                    false,
301                );
302            }
303            SettingsItem::LogBreaks => {
304                self.toggle_bool_setting("log_breaks", "Log breaks", |d| &mut d.log_breaks);
305            }
306            SettingsItem::EstimateComplete => {
307                self.data.estimate_complete = self.data.estimate_complete.next();
308                let key = match self.data.estimate_complete {
309                    EstimateCompleteBehavior::Nudge => "nudge",
310                    EstimateCompleteBehavior::None => "none",
311                    EstimateCompleteBehavior::AutoDone => "auto-done",
312                };
313                self.persist_setting("estimate_complete", key);
314                self.set_status(
315                    format!("Estimate reached: {}", self.data.estimate_complete.label()),
316                    false,
317                );
318            }
319            SettingsItem::TerminalTitle => {
320                self.toggle_bool_setting("show_terminal_title", "Terminal title", |d| {
321                    &mut d.show_terminal_title
322                });
323            }
324            SettingsItem::WarnOneMinute => {
325                self.toggle_bool_setting("warn_one_minute", "1-min warning", |d| {
326                    &mut d.warn_one_minute
327                });
328            }
329            SettingsItem::AutoPauseIdle => {
330                self.adjust_numeric_setting(
331                    dir,
332                    NumericSettingSpec {
333                        key: "auto_pause_idle_minutes",
334                        label: "Auto-pause idle (min, 0=off)",
335                        min: 0,
336                        max: 120,
337                        step: 5,
338                    },
339                    |d| &mut d.auto_pause_idle_minutes,
340                );
341            }
342            SettingsItem::ArchiveAfterDays => {
343                self.adjust_numeric_setting(
344                    dir,
345                    NumericSettingSpec {
346                        key: "archive_after_days",
347                        label: "Auto-archive after (days, 0=off)",
348                        min: 0,
349                        max: 365,
350                        step: 7,
351                    },
352                    |d| &mut d.archive_after_days,
353                );
354            }
355            SettingsItem::ExportBackup => {}
356        }
357        self.sync_timer_config_to_data();
358    }
359}