use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::event::KeyModifiers;
use ratatui::widgets::ListState;
use crate::db::Database;
use crate::model::{
AppData, EmptyQueueBehavior, EstimateCompleteBehavior, Priority, StoredSession, TimerMode,
TimerState,
};
use crate::sound;
use crate::storage;
use crate::timer::{Timer, TimerConfig};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusTab {
Dashboard,
Tasks,
Stats,
Settings,
Help,
}
impl FocusTab {
pub const fn all() -> [FocusTab; 5] {
[
FocusTab::Dashboard,
FocusTab::Tasks,
FocusTab::Stats,
FocusTab::Settings,
FocusTab::Help,
]
}
pub fn label(&self) -> &'static str {
match self {
FocusTab::Dashboard => "Dashboard",
FocusTab::Tasks => "Tasks",
FocusTab::Stats => "Stats",
FocusTab::Settings => "Settings",
FocusTab::Help => "Help",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Editing,
}
#[derive(Debug, Clone)]
pub enum Popup {
AddTask,
EditTask(u64),
ConfirmDelete(u64),
EmptyQueueChoice,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputField {
Title,
Notes,
Estimate,
Priority,
DueDate,
Tags,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskFilter {
All,
Pending,
Done,
Today,
}
impl TaskFilter {
pub fn label(&self) -> &'static str {
match self {
TaskFilter::All => "All",
TaskFilter::Pending => "Open",
TaskFilter::Done => "Done",
TaskFilter::Today => "Today",
}
}
pub fn next(self) -> Self {
match self {
TaskFilter::All => TaskFilter::Pending,
TaskFilter::Pending => TaskFilter::Done,
TaskFilter::Done => TaskFilter::Today,
TaskFilter::Today => TaskFilter::All,
}
}
}
use crate::theme::{self, ThemeCatalog};
use crate::ui::{IconMode, IconSet};
pub use theme::Theme;
pub mod settings;
pub use settings::*;
pub mod keys;
pub mod popups;
pub mod task_ops;
pub mod timer_ops;
pub struct App {
pub db: Database,
pub data: AppData,
pub timer: Timer,
pub tab: FocusTab,
pub input_mode: InputMode,
pub input_buffer: String,
pub input_buffer2: String,
pub input_due_date: String,
pub input_tags: String,
pub input_number: u32,
pub input_priority: Priority,
pub input_field: InputField,
pub popup: Option<Popup>,
pub task_state: ListState,
pub settings_state: SettingsState,
pub status: Option<String>,
pub status_error: bool,
pub last_status_set: Instant,
pub should_quit: bool,
pub theme: Theme,
pub theme_catalog: ThemeCatalog,
pub icons: IconSet,
pub active_task: Option<u64>,
pub zen_mode: bool,
pub task_filter: TaskFilter,
pub active_tag_filter: Option<String>,
pub task_search: String,
pub searching: bool,
pub weekly_chart: Vec<(String, u32)>,
pub heatmap_data: Vec<(String, u32)>,
pub session_counts: (u32, u32, u32),
pub chart_dirty: bool,
pub data_version: u64,
pub recent_sessions: Vec<StoredSession>,
pub stats_session_selected: usize,
pub dashboard_task_selected: usize,
pub calendar_date: chrono::NaiveDate,
}
impl App {
pub fn new() -> Result<Self> {
let db = Database::open()?;
let mut data = db.load_app_data().unwrap_or_default();
let _ = storage::ensure_today_reset(&db, &mut data);
let config = TimerConfig::from_app_data(&data);
let mut timer = Timer::new(config);
let (completed, mode) = db.load_timer_state();
timer.completed_focus_sessions = completed;
timer.configure(mode);
let recent_sessions = db.recent_sessions(15).unwrap_or_default();
let mut task_state = ListState::default();
if !data.tasks.is_empty() {
task_state.select(Some(0));
}
let weekly_chart = storage::minutes_by_date(&db, 7).unwrap_or_default();
let heatmap_data = storage::focus_heatmap(&db).unwrap_or_default();
let session_counts = db.session_counts_by_mode().unwrap_or((0, 0, 0));
let theme_catalog = ThemeCatalog::load();
let theme_id = theme::normalize_theme_id(&data.theme);
data.theme = theme_id.clone();
let theme = theme::resolve(&theme_id, &theme_catalog).unwrap_or_else(|_| Theme::matrix());
let icons = IconSet::detect();
let active_task = data.active_task_id.filter(|id| {
data.tasks
.iter()
.find(|t| t.id == *id)
.is_some_and(|t| t.status != crate::model::TaskStatus::Done)
});
if active_task != data.active_task_id {
data.active_task_id = active_task;
}
let welcome = match icons.mode() {
IconMode::Ascii => {
"Welcome to Void! Using text icons (set VOID_USE_NERD_FONTS=1 if your terminal has a Nerd Font)."
}
IconMode::Nerd => "Welcome to Void! Press 5 or 'h' for help.",
};
Ok(Self {
db,
data,
timer,
tab: FocusTab::Dashboard,
input_mode: InputMode::Normal,
input_buffer: String::new(),
input_buffer2: String::new(),
input_due_date: String::new(),
input_tags: String::new(),
input_number: 25,
input_priority: Priority::Medium,
input_field: InputField::Title,
popup: None,
task_state,
settings_state: SettingsState::new(),
status: Some(welcome.into()),
status_error: false,
last_status_set: Instant::now(),
should_quit: false,
theme,
theme_catalog,
icons,
active_task,
zen_mode: false,
task_filter: TaskFilter::All,
active_tag_filter: None,
task_search: String::new(),
searching: false,
weekly_chart,
heatmap_data,
session_counts,
chart_dirty: false,
data_version: 0,
recent_sessions,
stats_session_selected: 0,
dashboard_task_selected: 0,
calendar_date: chrono::Local::now().date_naive(),
})
}
pub fn apply_theme(&mut self, id: &str) {
let id = theme::normalize_theme_id(id);
match theme::resolve(&id, &self.theme_catalog) {
Ok(resolved) => {
self.theme = resolved;
self.data.theme = id.clone();
self.persist_setting("theme", &id);
}
Err(err) => {
self.set_status(format!("Theme `{id}` unavailable: {err:#}"), true);
}
}
}
pub fn queue_empty(&self) -> bool {
storage::queue_empty(&self.data)
}
pub fn daily_goal_met(&self) -> bool {
storage::today_focus_minutes(&self.data) >= self.data.daily_goal_minutes
}
fn persist_timer_state(&mut self) {
let completed = self.timer.completed_focus_sessions;
let mode = self.timer.mode;
self.persist(|db| db.persist_timer_state(completed, mode));
}
fn refresh_recent_sessions(&mut self) {
match self.db.recent_sessions(15) {
Ok(sessions) => self.recent_sessions = sessions,
Err(e) => self.set_status(format!("Error loading sessions: {e}"), true),
}
if self.stats_session_selected >= self.recent_sessions.len() {
self.stats_session_selected = self.recent_sessions.len().saturating_sub(1);
}
}
pub fn tick_rate(&self) -> Duration {
match self.timer.state {
TimerState::Running => Duration::from_millis(50),
TimerState::Paused => Duration::from_millis(100),
TimerState::Idle => Duration::from_millis(100),
_ => Duration::from_millis(200),
}
}
pub fn window_title(&self) -> String {
let (main, tenths, _) = crate::canvas_timer::format_time_stack(&self.timer);
let state = match self.timer.state {
TimerState::Running => self.icons.play,
TimerState::Paused => self.icons.pause,
TimerState::Finished => self.icons.check,
TimerState::Idle => self.icons.idle,
};
format!(
"Void {} {}{} · {}",
state,
main,
tenths,
self.timer.mode.label()
)
}
pub fn bump_data(&mut self) {
self.data_version = self.data_version.wrapping_add(1);
self.chart_dirty = true;
self.refresh_recent_sessions();
self.clamp_dashboard_task_selection();
}
fn persist<F>(&mut self, op: F)
where
F: FnOnce(&Database) -> anyhow::Result<()>,
{
if let Err(e) = op(&self.db) {
self.set_status(format!("Save error: {e}"), true);
}
}
fn persist_data<F>(&mut self, op: F)
where
F: FnOnce(&Database, &mut AppData) -> anyhow::Result<()>,
{
if let Err(e) = op(&self.db, &mut self.data) {
self.set_status(format!("Save error: {e}"), true);
}
}
fn persist_setting(&mut self, key: &str, value: impl AsRef<str>) {
self.persist(|db| db.set_setting(key, value.as_ref()));
}
pub fn refresh_chart_if_needed(&mut self) {
if self.chart_dirty {
match storage::minutes_by_date(&self.db, 7) {
Ok(data) => self.weekly_chart = data,
Err(e) => self.set_status(format!("Chart error: {e}"), true),
}
match storage::focus_heatmap(&self.db) {
Ok(data) => self.heatmap_data = data,
Err(e) => self.set_status(format!("Heatmap error: {e}"), true),
}
match self.db.session_counts_by_mode() {
Ok(counts) => self.session_counts = counts,
Err(e) => self.set_status(format!("Stats error: {e}"), true),
}
self.chart_dirty = false;
}
}
pub fn reload_heatmap(&mut self) {
if let Ok(data) = storage::focus_heatmap(&self.db) {
self.heatmap_data = data;
}
}
fn sync_timer_config_to_data(&mut self) {
self.data.focus_minutes = self.timer.config.focus_minutes;
self.data.short_break_minutes = self.timer.config.short_break_minutes;
self.data.long_break_minutes = self.timer.config.long_break_minutes;
self.data.long_break_every = self.timer.config.long_break_every;
}
fn elapsed_minutes(&self, skipped: bool) -> u32 {
let secs = self.timer.current_elapsed_seconds();
if skipped {
secs.div_ceil(60).max(1)
} else {
(secs / 60).max(1)
}
}
pub fn hint(&self) -> String {
match self.tab {
FocusTab::Dashboard => "[j/k]tasks [f]ocus [Enter]status [x]done [s]tart [z]zen".into(),
FocusTab::Tasks => {
"[f]ocus [a]dd [e]dit [d]el [Enter]status [t]oday [g]filter [/]search".into()
}
FocusTab::Stats => "[j/k] sessions [d] delete [+/-] minutes [E] end session".into(),
FocusTab::Settings => "[↑↓] nav [Enter] toggle/export [-/+] change values".into(),
FocusTab::Help => "Press Tab or 1-5 to leave help".into(),
}
}
pub fn set_status(&mut self, msg: impl Into<String>, error: bool) {
self.status = Some(msg.into());
self.status_error = error;
self.last_status_set = Instant::now();
}
fn check_queue_empty(&mut self) {
if self.queue_empty() {
self.on_queue_empty();
}
}
fn on_queue_empty(&mut self) {
match self.data.empty_queue_behavior {
EmptyQueueBehavior::FreeFocus => {
self.set_status(
"All tasks done — free focus. Sessions log as general focus. [E] end session",
false,
);
}
EmptyQueueBehavior::PauseTimer => {
if self.timer.state == TimerState::Running {
self.pause_timer();
} else if self.timer.state != TimerState::Paused {
self.timer.reset();
}
self.set_status("All tasks done — timer paused. [E] end session", false);
}
EmptyQueueBehavior::AskEachTime => {
self.popup = Some(Popup::EmptyQueueChoice);
self.set_status(
"All tasks done — [Enter] free focus [p] pause [a] add task",
false,
);
}
}
}
pub fn export_backup(&mut self) {
match self.db.export_json() {
Ok(path) => self.set_status(format!("Exported backup to {}", path.display()), false),
Err(e) => self.set_status(format!("Export failed: {e}"), true),
}
}
pub fn open_add_task(&mut self) {
self.input_buffer.clear();
self.input_buffer2.clear();
self.input_due_date.clear();
self.input_tags.clear();
self.input_number = 25;
self.input_priority = Priority::Medium;
self.input_field = InputField::Title;
self.popup = Some(Popup::AddTask);
self.input_mode = InputMode::Editing;
}
pub fn open_edit_task(&mut self) {
let Some(id) = self.selected_task_id() else {
self.set_status("No task selected.", true);
return;
};
if let Some(t) = self.data.tasks.iter().find(|t| t.id == id).cloned() {
self.input_buffer = t.title;
self.input_buffer2 = t.notes;
self.input_due_date = t.due_date.unwrap_or_default();
self.input_tags = t.tags.join(", ");
self.input_number = t.estimated_minutes;
self.input_priority = t.priority;
self.input_field = InputField::Title;
self.popup = Some(Popup::EditTask(id));
self.input_mode = InputMode::Editing;
}
}
pub fn open_confirm_delete(&mut self) {
if let Some(id) = self.selected_task_id() {
self.popup = Some(Popup::ConfirmDelete(id));
} else {
self.set_status("No task to delete.", true);
}
}
fn popup_due_date(&self) -> Result<Option<String>, String> {
let allow_past = matches!(self.popup, Some(crate::app::Popup::EditTask(_)));
storage::normalize_due_date(&self.input_due_date, allow_past)
}
pub fn cycle_tag_filter(&mut self) {
let mut tags: Vec<String> = self
.data
.tasks
.iter()
.flat_map(|t| t.tags.clone())
.collect();
tags.sort();
tags.dedup();
if tags.is_empty() {
self.set_status("No tags available to filter.", true);
return;
}
self.active_tag_filter = match &self.active_tag_filter {
None => Some(tags[0].clone()),
Some(current) => {
if let Some(idx) = tags.iter().position(|t| t == current) {
if idx + 1 < tags.len() {
Some(tags[idx + 1].clone())
} else {
None
}
} else {
None
}
}
};
let msg = match &self.active_tag_filter {
Some(t) => format!("Filtered by tag: #{}", t),
None => "Tag filter cleared.".to_string(),
};
self.set_status(msg, false);
self.clamp_dashboard_task_selection();
let len = self.filtered_task_indices().len();
if len == 0 {
self.task_state.select(None);
} else {
let sel = self.task_state.selected().unwrap_or(0).min(len - 1);
self.task_state.select(Some(sel));
}
}
fn popup_tags(&self) -> Vec<String> {
storage::parse_tags(&self.input_tags)
}
pub fn selected_task_id(&self) -> Option<u64> {
let indices = self.filtered_task_indices();
self.task_state
.selected()
.and_then(|i| indices.get(i).copied())
.and_then(|idx| self.data.tasks.get(idx).map(|t| t.id))
}
}