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        (
95            "Theme",
96            app.theme_catalog.label(&app.data.theme),
97            "cycle themes",
98        ),
99        (
100            "Custom timer",
101            format!("{} min", app.timer.custom_minutes),
102            "freeform session",
103        ),
104        (
105            "Auto-pick task",
106            if app.data.auto_pick_task {
107                "on".into()
108            } else {
109                "off".into()
110            },
111            "pick best task on start",
112        ),
113        (
114            "Auto-advance task",
115            if app.data.auto_advance_task {
116                "on".into()
117            } else {
118                "off".into()
119            },
120            "next task after focus",
121        ),
122        (
123            "When queue empty",
124            app.data.empty_queue_behavior.label().to_string(),
125            "free focus / pause / ask",
126        ),
127        (
128            "Log breaks",
129            if app.data.log_breaks {
130                "on".into()
131            } else {
132                "off".into()
133            },
134            "record break sessions",
135        ),
136        (
137            "Estimate reached",
138            app.data.estimate_complete.label().to_string(),
139            "nudge / off / auto-done",
140        ),
141        (
142            "Export backup",
143            "Enter to export".into(),
144            "writes data.json for backup",
145        ),
146    ];
147
148    let section_headers: &[(usize, &str, &str)] = &[
149        (0, icons.timer, "Timer"),
150        (5, icons.cycle, "Behavior"),
151        (9, icons.tasks, "Tasks"),
152        (10, icons.star, "Appearance"),
153        (14, icons.play, "Sessions"),
154        (17, icons.export, "Data"),
155    ];
156
157    let mut layout: Vec<SettingsRow> = Vec::new();
158    for (i, _) in settings_labels.iter().enumerate() {
159        for &(at, icon, name) in section_headers {
160            if at == i {
161                layout.push(SettingsRow::Header(icon, name));
162            }
163        }
164        layout.push(SettingsRow::Item(i));
165    }
166
167    let visible_rows: Vec<&SettingsRow> = layout
168        .iter()
169        .skip(scroll_offset)
170        .take(visible_height)
171        .collect();
172
173    let total_rows = layout.len();
174    let can_scroll = total_rows > visible_height;
175
176    let mut rows: Vec<Row> = Vec::new();
177    for entry in &visible_rows {
178        match entry {
179            SettingsRow::Header(icon, name) => {
180                rows.push(Row::new(vec![
181                    Cell::from(""),
182                    Cell::from(Span::styled(
183                        format!("{icon} {name}"),
184                        Style::default()
185                            .fg(theme.accent)
186                            .add_modifier(Modifier::BOLD),
187                    )),
188                    Cell::from(""),
189                ]));
190            }
191            SettingsRow::Item(i) => {
192                let (k, v, desc) = &settings_labels[*i];
193                let is_selected = *i == selected;
194                let marker = if is_selected { icons.chevron } else { " " };
195                let row_style = if is_selected {
196                    Style::default().bg(theme.select_bg).fg(theme.select_fg)
197                } else {
198                    Style::default().fg(theme.text)
199                };
200                let key_style = if is_selected {
201                    Style::default()
202                        .fg(theme.accent)
203                        .bg(theme.select_bg)
204                        .add_modifier(Modifier::BOLD)
205                } else {
206                    Style::default().fg(theme.text)
207                };
208                let val_style = if is_selected {
209                    Style::default().fg(theme.success).bg(theme.select_bg)
210                } else {
211                    Style::default().fg(theme.dim)
212                };
213                let value_with_desc = if desc.is_empty() {
214                    v.clone()
215                } else {
216                    format!("{} ({})", v, desc)
217                };
218                rows.push(
219                    Row::new(vec![
220                        Cell::from(marker.to_string()).style(key_style),
221                        Cell::from(k.to_string()).style(key_style),
222                        Cell::from(value_with_desc).style(val_style),
223                    ])
224                    .style(row_style),
225                );
226            }
227        }
228    }
229
230    let table = Table::new(
231        rows,
232        [
233            Constraint::Length(3),
234            Constraint::Length(22),
235            Constraint::Min(10),
236        ],
237    )
238    .block(themed_panel(
239        theme,
240        Line::from(Span::styled(
241            format!(" {} Settings ", icons.settings),
242            Style::default().fg(theme.accent),
243        )),
244    ));
245    f.render_widget(table, chunks[0]);
246
247    let scroll_hint = if can_scroll {
248        format!(
249            "  {} {}/{}",
250            icons.dot,
251            scroll_offset + visible_rows.len(),
252            total_rows
253        )
254    } else {
255        String::new()
256    };
257    let hint = Paragraph::new(Line::from(vec![
258        Span::styled(
259            format!("{} Up/Down", icons.chevron),
260            Style::default().fg(theme.accent),
261        ),
262        Span::styled(" scroll  ", Style::default().fg(theme.dim)),
263        Span::styled("j/k", Style::default().fg(theme.accent)),
264        Span::styled(" nav  ", Style::default().fg(theme.dim)),
265        Span::styled("Enter", Style::default().fg(theme.accent)),
266        Span::styled(" toggle  ", Style::default().fg(theme.dim)),
267        Span::styled("+/-", Style::default().fg(theme.accent)),
268        Span::styled(" adjust", Style::default().fg(theme.dim)),
269        Span::styled(scroll_hint, Style::default().fg(theme.dim)),
270    ]))
271    .alignment(Alignment::Center);
272    f.render_widget(hint, chunks[1]);
273}