Skip to main content

void/ui/
settings.rs

1use super::*;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4enum SettingsRow {
5    Header(&'static str, &'static str),
6    Item(usize),
7}
8
9pub(crate) fn draw_settings(f: &mut Frame, app: &mut App, area: Rect) {
10    let chunks = Layout::default()
11        .direction(Direction::Vertical)
12        .margin(1)
13        .constraints([Constraint::Min(8), Constraint::Length(3)])
14        .split(area);
15
16    let visible_height = chunks[0].height.saturating_sub(3) as usize;
17    let selected = app.settings_state.selected;
18    app.settings_state.page_size = visible_height.max(6);
19    app.sync_settings_scroll();
20    let scroll_offset = app.settings_state.scroll_offset;
21
22    let theme = &app.theme;
23    let icons = app.icons;
24    let settings_labels: Vec<(&str, String, &str)> = vec![
25        (
26            "Focus minutes",
27            format!("{} min", app.data.focus_minutes),
28            "per focus session",
29        ),
30        (
31            "Short break",
32            format!("{} min", app.data.short_break_minutes),
33            "between sessions",
34        ),
35        (
36            "Long break",
37            format!("{} min", app.data.long_break_minutes),
38            "after cycle",
39        ),
40        (
41            "Long break every",
42            format!("{} sessions", app.data.long_break_every),
43            "focus sessions per cycle",
44        ),
45        (
46            "Daily goal",
47            format!("{} min", app.data.daily_goal_minutes),
48            "+/-15 per step",
49        ),
50        (
51            "Sound on finish",
52            if app.data.sound_enabled {
53                "on".into()
54            } else {
55                "off".into()
56            },
57            "plays on completion",
58        ),
59        (
60            "Notifications",
61            if app.data.notify_on_finish {
62                "on".into()
63            } else {
64                "off".into()
65            },
66            "desktop alerts",
67        ),
68        (
69            "Auto-start breaks",
70            if app.data.auto_start_breaks {
71                "on".into()
72            } else {
73                "off".into()
74            },
75            "begin break automatically",
76        ),
77        (
78            "Auto-start focus",
79            if app.data.auto_start_focus {
80                "on".into()
81            } else {
82                "off".into()
83            },
84            "begin focus after break",
85        ),
86        (
87            "Active task",
88            app.active_task
89                .and_then(|id| app.data.tasks.iter().find(|t| t.id == id))
90                .map(|t| t.title.clone())
91                .unwrap_or_else(|| "(none)".into()),
92            "cycle with Enter",
93        ),
94        ("Theme", app.data.theme.label().to_string(), "cycle themes"),
95        (
96            "Custom timer",
97            format!("{} min", app.timer.custom_minutes),
98            "freeform session",
99        ),
100        (
101            "Auto-pick task",
102            if app.data.auto_pick_task {
103                "on".into()
104            } else {
105                "off".into()
106            },
107            "pick best task on start",
108        ),
109        (
110            "Auto-advance task",
111            if app.data.auto_advance_task {
112                "on".into()
113            } else {
114                "off".into()
115            },
116            "next task after focus",
117        ),
118        (
119            "When queue empty",
120            app.data.empty_queue_behavior.label().to_string(),
121            "free focus / pause / ask",
122        ),
123        (
124            "Log breaks",
125            if app.data.log_breaks {
126                "on".into()
127            } else {
128                "off".into()
129            },
130            "record break sessions",
131        ),
132        (
133            "Estimate reached",
134            app.data.estimate_complete.label().to_string(),
135            "nudge / off / auto-done",
136        ),
137        (
138            "Export backup",
139            "Enter to export".into(),
140            "writes data.json for backup",
141        ),
142    ];
143
144    let section_headers: &[(usize, &str, &str)] = &[
145        (0, icons.timer, "Timer"),
146        (5, icons.cycle, "Behavior"),
147        (9, icons.tasks, "Tasks"),
148        (10, icons.star, "Appearance"),
149        (14, icons.play, "Sessions"),
150        (17, icons.export, "Data"),
151    ];
152
153    let mut layout: Vec<SettingsRow> = Vec::new();
154    for (i, _) in settings_labels.iter().enumerate() {
155        for &(at, icon, name) in section_headers {
156            if at == i {
157                layout.push(SettingsRow::Header(icon, name));
158            }
159        }
160        layout.push(SettingsRow::Item(i));
161    }
162
163    let visible_rows: Vec<&SettingsRow> = layout
164        .iter()
165        .skip(scroll_offset)
166        .take(visible_height)
167        .collect();
168
169    let total_rows = layout.len();
170    let can_scroll = total_rows > visible_height;
171
172    let mut rows: Vec<Row> = Vec::new();
173    for entry in &visible_rows {
174        match entry {
175            SettingsRow::Header(icon, name) => {
176                rows.push(Row::new(vec![
177                    Cell::from(""),
178                    Cell::from(Span::styled(
179                        format!("{icon} {name}"),
180                        Style::default()
181                            .fg(theme.accent)
182                            .add_modifier(Modifier::BOLD),
183                    )),
184                    Cell::from(""),
185                ]));
186            }
187            SettingsRow::Item(i) => {
188                let (k, v, desc) = &settings_labels[*i];
189                let is_selected = *i == selected;
190                let marker = if is_selected { icons.chevron } else { " " };
191                let row_style = if is_selected {
192                    Style::default().bg(theme.select_bg).fg(theme.select_fg)
193                } else {
194                    Style::default().fg(theme.text)
195                };
196                let key_style = if is_selected {
197                    Style::default()
198                        .fg(theme.accent)
199                        .bg(theme.select_bg)
200                        .add_modifier(Modifier::BOLD)
201                } else {
202                    Style::default().fg(theme.text)
203                };
204                let val_style = if is_selected {
205                    Style::default().fg(theme.success).bg(theme.select_bg)
206                } else {
207                    Style::default().fg(theme.dim)
208                };
209                let value_with_desc = if desc.is_empty() {
210                    v.clone()
211                } else {
212                    format!("{} ({})", v, desc)
213                };
214                rows.push(
215                    Row::new(vec![
216                        Cell::from(marker.to_string()).style(key_style),
217                        Cell::from(k.to_string()).style(key_style),
218                        Cell::from(value_with_desc).style(val_style),
219                    ])
220                    .style(row_style),
221                );
222            }
223        }
224    }
225
226    let table = Table::new(
227        rows,
228        [
229            Constraint::Length(3),
230            Constraint::Length(22),
231            Constraint::Min(10),
232        ],
233    )
234    .block(themed_panel(
235        theme,
236        Line::from(Span::styled(
237            format!(" {} Settings ", icons.settings),
238            Style::default().fg(theme.accent),
239        )),
240    ));
241    f.render_widget(table, chunks[0]);
242
243    let scroll_hint = if can_scroll {
244        format!(
245            "  {} {}/{}",
246            icons.dot,
247            scroll_offset + visible_rows.len(),
248            total_rows
249        )
250    } else {
251        String::new()
252    };
253    let hint = Paragraph::new(Line::from(vec![
254        Span::styled(
255            format!("{} Up/Down", icons.chevron),
256            Style::default().fg(theme.accent),
257        ),
258        Span::styled(" scroll  ", Style::default().fg(theme.dim)),
259        Span::styled("j/k", Style::default().fg(theme.accent)),
260        Span::styled(" nav  ", Style::default().fg(theme.dim)),
261        Span::styled("Enter", Style::default().fg(theme.accent)),
262        Span::styled(" toggle  ", Style::default().fg(theme.dim)),
263        Span::styled("+/-", Style::default().fg(theme.accent)),
264        Span::styled(" adjust", Style::default().fg(theme.dim)),
265        Span::styled(scroll_hint, Style::default().fg(theme.dim)),
266    ]))
267    .alignment(Alignment::Center);
268    f.render_widget(hint, chunks[1]);
269}