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}