Skip to main content

void/app/
mod.rs

1use std::time::{Duration, Instant};
2
3use anyhow::Result;
4use crossterm::event::KeyModifiers;
5use ratatui::widgets::ListState;
6
7use crate::db::Database;
8use crate::model::{
9    AppData, EmptyQueueBehavior, EstimateCompleteBehavior, Priority, StoredSession, TimerMode,
10    TimerState,
11};
12use crate::sound;
13use crate::storage;
14use crate::timer::{Timer, TimerConfig};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum FocusTab {
18    Dashboard,
19    Tasks,
20    Stats,
21    Settings,
22    Help,
23}
24
25impl FocusTab {
26    pub const fn all() -> [FocusTab; 5] {
27        [
28            FocusTab::Dashboard,
29            FocusTab::Tasks,
30            FocusTab::Stats,
31            FocusTab::Settings,
32            FocusTab::Help,
33        ]
34    }
35    pub fn label(&self) -> &'static str {
36        match self {
37            FocusTab::Dashboard => "Dashboard",
38            FocusTab::Tasks => "Tasks",
39            FocusTab::Stats => "Stats",
40            FocusTab::Settings => "Settings",
41            FocusTab::Help => "Help",
42        }
43    }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum InputMode {
48    Normal,
49    Editing,
50}
51
52#[derive(Debug, Clone)]
53pub enum Popup {
54    AddTask,
55    EditTask(u64),
56    ConfirmDelete(u64),
57    EmptyQueueChoice,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum InputField {
62    Title,
63    Notes,
64    Estimate,
65    Priority,
66    DueDate,
67    Tags,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum TaskFilter {
72    All,
73    Pending,
74    Done,
75    Today,
76}
77
78impl TaskFilter {
79    pub fn label(&self) -> &'static str {
80        match self {
81            TaskFilter::All => "All",
82            TaskFilter::Pending => "Open",
83            TaskFilter::Done => "Done",
84            TaskFilter::Today => "Today",
85        }
86    }
87
88    pub fn next(self) -> Self {
89        match self {
90            TaskFilter::All => TaskFilter::Pending,
91            TaskFilter::Pending => TaskFilter::Done,
92            TaskFilter::Done => TaskFilter::Today,
93            TaskFilter::Today => TaskFilter::All,
94        }
95    }
96}
97
98use crate::theme::{self, ThemeCatalog};
99use crate::ui::{IconMode, IconSet};
100
101pub use theme::Theme;
102
103pub mod settings;
104pub use settings::*;
105pub mod keys;
106pub mod popups;
107pub mod task_ops;
108pub mod timer_ops;
109
110pub struct App {
111    pub db: Database,
112    pub data: AppData,
113    pub timer: Timer,
114    pub tab: FocusTab,
115    pub input_mode: InputMode,
116    pub input_buffer: String,
117    pub input_buffer2: String,
118    pub input_due_date: String,
119    pub input_tags: String,
120    pub input_number: u32,
121    pub input_priority: Priority,
122    pub input_field: InputField,
123    pub popup: Option<Popup>,
124    pub task_state: ListState,
125    pub settings_state: SettingsState,
126    pub status: Option<String>,
127    pub status_error: bool,
128    pub last_status_set: Instant,
129    pub should_quit: bool,
130    pub theme: Theme,
131    pub theme_catalog: ThemeCatalog,
132    pub icons: IconSet,
133    pub active_task: Option<u64>,
134    pub zen_mode: bool,
135    pub task_filter: TaskFilter,
136    pub active_tag_filter: Option<String>,
137    pub task_search: String,
138    pub searching: bool,
139    pub weekly_chart: Vec<(String, u32)>,
140    pub heatmap_data: Vec<(String, u32)>,
141    pub session_counts: (u32, u32, u32),
142    pub chart_dirty: bool,
143    pub data_version: u64,
144    pub recent_sessions: Vec<StoredSession>,
145    pub stats_session_selected: usize,
146    pub dashboard_task_selected: usize,
147    pub calendar_date: chrono::NaiveDate,
148}
149
150impl App {
151    pub fn new() -> Result<Self> {
152        let db = Database::open()?;
153        let mut data = db.load_app_data().unwrap_or_default();
154        let _ = storage::ensure_today_reset(&db, &mut data);
155        let config = TimerConfig::from_app_data(&data);
156        let mut timer = Timer::new(config);
157        let (completed, mode) = db.load_timer_state();
158        timer.completed_focus_sessions = completed;
159        timer.configure(mode);
160        let recent_sessions = db.recent_sessions(15).unwrap_or_default();
161        let mut task_state = ListState::default();
162        if !data.tasks.is_empty() {
163            task_state.select(Some(0));
164        }
165        let weekly_chart = storage::minutes_by_date(&db, 7).unwrap_or_default();
166        let heatmap_data = storage::focus_heatmap(&db).unwrap_or_default();
167        let session_counts = db.session_counts_by_mode().unwrap_or((0, 0, 0));
168        let theme_catalog = ThemeCatalog::load();
169        let theme_id = theme::normalize_theme_id(&data.theme);
170        data.theme = theme_id.clone();
171        let theme = theme::resolve(&theme_id, &theme_catalog).unwrap_or_else(|_| Theme::matrix());
172        let icons = IconSet::detect();
173        let active_task = data.active_task_id.filter(|id| {
174            data.tasks
175                .iter()
176                .find(|t| t.id == *id)
177                .is_some_and(|t| t.status != crate::model::TaskStatus::Done)
178        });
179        if active_task != data.active_task_id {
180            data.active_task_id = active_task;
181        }
182        let welcome = match icons.mode() {
183            IconMode::Ascii => {
184                "Welcome to Void! Using text icons (set VOID_USE_NERD_FONTS=1 if your terminal has a Nerd Font)."
185            }
186            IconMode::Nerd => "Welcome to Void! Press 5 or 'h' for help.",
187        };
188        Ok(Self {
189            db,
190            data,
191            timer,
192            tab: FocusTab::Dashboard,
193            input_mode: InputMode::Normal,
194            input_buffer: String::new(),
195            input_buffer2: String::new(),
196            input_due_date: String::new(),
197            input_tags: String::new(),
198            input_number: 25,
199            input_priority: Priority::Medium,
200            input_field: InputField::Title,
201            popup: None,
202            task_state,
203            settings_state: SettingsState::new(),
204            status: Some(welcome.into()),
205            status_error: false,
206            last_status_set: Instant::now(),
207            should_quit: false,
208            theme,
209            theme_catalog,
210            icons,
211            active_task,
212            zen_mode: false,
213            task_filter: TaskFilter::All,
214            active_tag_filter: None,
215            task_search: String::new(),
216            searching: false,
217            weekly_chart,
218            heatmap_data,
219            session_counts,
220            chart_dirty: false,
221            data_version: 0,
222            recent_sessions,
223            stats_session_selected: 0,
224            dashboard_task_selected: 0,
225            calendar_date: chrono::Local::now().date_naive(),
226        })
227    }
228
229    pub fn apply_theme(&mut self, id: &str) {
230        let id = theme::normalize_theme_id(id);
231        match theme::resolve(&id, &self.theme_catalog) {
232            Ok(resolved) => {
233                self.theme = resolved;
234                self.data.theme = id.clone();
235                self.persist_setting("theme", &id);
236            }
237            Err(err) => {
238                self.set_status(format!("Theme `{id}` unavailable: {err:#}"), true);
239            }
240        }
241    }
242
243    pub fn queue_empty(&self) -> bool {
244        storage::queue_empty(&self.data)
245    }
246
247    pub fn daily_goal_met(&self) -> bool {
248        storage::today_focus_minutes(&self.data) >= self.data.daily_goal_minutes
249    }
250
251    fn persist_timer_state(&mut self) {
252        let completed = self.timer.completed_focus_sessions;
253        let mode = self.timer.mode;
254        self.persist(|db| db.persist_timer_state(completed, mode));
255    }
256
257    fn refresh_recent_sessions(&mut self) {
258        match self.db.recent_sessions(15) {
259            Ok(sessions) => self.recent_sessions = sessions,
260            Err(e) => self.set_status(format!("Error loading sessions: {e}"), true),
261        }
262        if self.stats_session_selected >= self.recent_sessions.len() {
263            self.stats_session_selected = self.recent_sessions.len().saturating_sub(1);
264        }
265    }
266
267    pub fn tick_rate(&self) -> Duration {
268        match self.timer.state {
269            TimerState::Running => Duration::from_millis(50),
270            TimerState::Paused => Duration::from_millis(100),
271            TimerState::Idle => Duration::from_millis(100),
272            _ => Duration::from_millis(200),
273        }
274    }
275
276    pub fn window_title(&self) -> String {
277        let (main, tenths, _) = crate::canvas_timer::format_time_stack(&self.timer);
278        let state = match self.timer.state {
279            TimerState::Running => self.icons.play,
280            TimerState::Paused => self.icons.pause,
281            TimerState::Finished => self.icons.check,
282            TimerState::Idle => self.icons.idle,
283        };
284        format!(
285            "Void {} {}{} · {}",
286            state,
287            main,
288            tenths,
289            self.timer.mode.label()
290        )
291    }
292
293    pub fn bump_data(&mut self) {
294        self.data_version = self.data_version.wrapping_add(1);
295        self.chart_dirty = true;
296        self.refresh_recent_sessions();
297        self.clamp_dashboard_task_selection();
298    }
299
300    fn persist<F>(&mut self, op: F)
301    where
302        F: FnOnce(&Database) -> anyhow::Result<()>,
303    {
304        if let Err(e) = op(&self.db) {
305            self.set_status(format!("Save error: {e}"), true);
306        }
307    }
308
309    fn persist_data<F>(&mut self, op: F)
310    where
311        F: FnOnce(&Database, &mut AppData) -> anyhow::Result<()>,
312    {
313        if let Err(e) = op(&self.db, &mut self.data) {
314            self.set_status(format!("Save error: {e}"), true);
315        }
316    }
317
318    fn persist_setting(&mut self, key: &str, value: impl AsRef<str>) {
319        self.persist(|db| db.set_setting(key, value.as_ref()));
320    }
321
322    pub fn refresh_chart_if_needed(&mut self) {
323        if self.chart_dirty {
324            match storage::minutes_by_date(&self.db, 7) {
325                Ok(data) => self.weekly_chart = data,
326                Err(e) => self.set_status(format!("Chart error: {e}"), true),
327            }
328            match storage::focus_heatmap(&self.db) {
329                Ok(data) => self.heatmap_data = data,
330                Err(e) => self.set_status(format!("Heatmap error: {e}"), true),
331            }
332            match self.db.session_counts_by_mode() {
333                Ok(counts) => self.session_counts = counts,
334                Err(e) => self.set_status(format!("Stats error: {e}"), true),
335            }
336            self.chart_dirty = false;
337        }
338    }
339
340    pub fn reload_heatmap(&mut self) {
341        if let Ok(data) = storage::focus_heatmap(&self.db) {
342            self.heatmap_data = data;
343        }
344    }
345
346    fn sync_timer_config_to_data(&mut self) {
347        self.data.focus_minutes = self.timer.config.focus_minutes;
348        self.data.short_break_minutes = self.timer.config.short_break_minutes;
349        self.data.long_break_minutes = self.timer.config.long_break_minutes;
350        self.data.long_break_every = self.timer.config.long_break_every;
351    }
352
353    fn elapsed_minutes(&self, skipped: bool) -> u32 {
354        let secs = self.timer.current_elapsed_seconds();
355        if skipped {
356            secs.div_ceil(60).max(1)
357        } else {
358            (secs / 60).max(1)
359        }
360    }
361
362    pub fn hint(&self) -> String {
363        match self.tab {
364            FocusTab::Dashboard => "[j/k]tasks [f]ocus [Enter]status [x]done [s]tart [z]zen".into(),
365            FocusTab::Tasks => {
366                "[f]ocus [a]dd [e]dit [d]el [Enter]status [t]oday [g]filter [/]search".into()
367            }
368            FocusTab::Stats => "[j/k] sessions [d] delete [+/-] minutes [E] end session".into(),
369            FocusTab::Settings => "[↑↓] nav [Enter] toggle/export [-/+] change values".into(),
370            FocusTab::Help => "Press Tab or 1-5 to leave help".into(),
371        }
372    }
373
374    pub fn set_status(&mut self, msg: impl Into<String>, error: bool) {
375        self.status = Some(msg.into());
376        self.status_error = error;
377        self.last_status_set = Instant::now();
378    }
379
380    fn check_queue_empty(&mut self) {
381        if self.queue_empty() {
382            self.on_queue_empty();
383        }
384    }
385
386    fn on_queue_empty(&mut self) {
387        match self.data.empty_queue_behavior {
388            EmptyQueueBehavior::FreeFocus => {
389                self.set_status(
390                    "All tasks done — free focus. Sessions log as general focus. [E] end session",
391                    false,
392                );
393            }
394            EmptyQueueBehavior::PauseTimer => {
395                if self.timer.state == TimerState::Running {
396                    self.pause_timer();
397                } else if self.timer.state != TimerState::Paused {
398                    self.timer.reset();
399                }
400                self.set_status("All tasks done — timer paused. [E] end session", false);
401            }
402            EmptyQueueBehavior::AskEachTime => {
403                self.popup = Some(Popup::EmptyQueueChoice);
404                self.set_status(
405                    "All tasks done — [Enter] free focus  [p] pause  [a] add task",
406                    false,
407                );
408            }
409        }
410    }
411
412    pub fn export_backup(&mut self) {
413        match self.db.export_json() {
414            Ok(path) => self.set_status(format!("Exported backup to {}", path.display()), false),
415            Err(e) => self.set_status(format!("Export failed: {e}"), true),
416        }
417    }
418
419    pub fn open_add_task(&mut self) {
420        self.input_buffer.clear();
421        self.input_buffer2.clear();
422        self.input_due_date.clear();
423        self.input_tags.clear();
424        self.input_number = 25;
425        self.input_priority = Priority::Medium;
426        self.input_field = InputField::Title;
427        self.popup = Some(Popup::AddTask);
428        self.input_mode = InputMode::Editing;
429    }
430
431    pub fn open_edit_task(&mut self) {
432        let Some(id) = self.selected_task_id() else {
433            self.set_status("No task selected.", true);
434            return;
435        };
436        if let Some(t) = self.data.tasks.iter().find(|t| t.id == id).cloned() {
437            self.input_buffer = t.title;
438            self.input_buffer2 = t.notes;
439            self.input_due_date = t.due_date.unwrap_or_default();
440            self.input_tags = t.tags.join(", ");
441            self.input_number = t.estimated_minutes;
442            self.input_priority = t.priority;
443            self.input_field = InputField::Title;
444            self.popup = Some(Popup::EditTask(id));
445            self.input_mode = InputMode::Editing;
446        }
447    }
448
449    pub fn open_confirm_delete(&mut self) {
450        if let Some(id) = self.selected_task_id() {
451            self.popup = Some(Popup::ConfirmDelete(id));
452        } else {
453            self.set_status("No task to delete.", true);
454        }
455    }
456
457    fn popup_due_date(&self) -> Result<Option<String>, String> {
458        let allow_past = matches!(self.popup, Some(crate::app::Popup::EditTask(_)));
459        storage::normalize_due_date(&self.input_due_date, allow_past)
460    }
461
462    pub fn cycle_tag_filter(&mut self) {
463        let mut tags: Vec<String> = self
464            .data
465            .tasks
466            .iter()
467            .flat_map(|t| t.tags.clone())
468            .collect();
469        tags.sort();
470        tags.dedup();
471
472        if tags.is_empty() {
473            self.set_status("No tags available to filter.", true);
474            return;
475        }
476
477        self.active_tag_filter = match &self.active_tag_filter {
478            None => Some(tags[0].clone()),
479            Some(current) => {
480                if let Some(idx) = tags.iter().position(|t| t == current) {
481                    if idx + 1 < tags.len() {
482                        Some(tags[idx + 1].clone())
483                    } else {
484                        None
485                    }
486                } else {
487                    None
488                }
489            }
490        };
491
492        let msg = match &self.active_tag_filter {
493            Some(t) => format!("Filtered by tag: #{}", t),
494            None => "Tag filter cleared.".to_string(),
495        };
496        self.set_status(msg, false);
497
498        self.clamp_dashboard_task_selection();
499        let len = self.filtered_task_indices().len();
500        if len == 0 {
501            self.task_state.select(None);
502        } else {
503            let sel = self.task_state.selected().unwrap_or(0).min(len - 1);
504            self.task_state.select(Some(sel));
505        }
506    }
507
508    fn popup_tags(&self) -> Vec<String> {
509        storage::parse_tags(&self.input_tags)
510    }
511
512    pub fn selected_task_id(&self) -> Option<u64> {
513        let indices = self.filtered_task_indices();
514        self.task_state
515            .selected()
516            .and_then(|i| indices.get(i).copied())
517            .and_then(|idx| self.data.tasks.get(idx).map(|t| t.id))
518    }
519}