2025-11-16 13:12:45 -05:00
|
|
|
use crate::config::Config;
|
|
|
|
|
use crate::state::{AppState, TimeItem};
|
2025-11-16 11:41:05 -05:00
|
|
|
use chrono::Utc;
|
2025-11-16 09:57:01 -05:00
|
|
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
2025-11-16 13:12:45 -05:00
|
|
|
use std::process::Command;
|
2025-11-16 10:19:14 -05:00
|
|
|
|
|
|
|
|
pub enum Screen {
|
|
|
|
|
Main,
|
|
|
|
|
Help,
|
|
|
|
|
ConfigHelp,
|
2025-11-16 11:41:05 -05:00
|
|
|
NewEntry,
|
2025-11-16 10:19:14 -05:00
|
|
|
}
|
2025-11-16 09:57:01 -05:00
|
|
|
|
|
|
|
|
pub struct App {
|
|
|
|
|
pub state: AppState,
|
2025-11-16 10:19:14 -05:00
|
|
|
pub config: Config,
|
|
|
|
|
pub current_screen: Screen,
|
|
|
|
|
pub needs_redraw: bool,
|
2025-11-16 11:41:05 -05:00
|
|
|
pub new_entry_buffer: String,
|
|
|
|
|
pub new_entry_project: String,
|
|
|
|
|
pub new_entry_cursor: usize,
|
2025-11-16 13:12:45 -05:00
|
|
|
pub new_entry_mode: NewEntryMode, // Task or Project
|
2025-11-16 12:25:22 -05:00
|
|
|
pub status_message: Option<(String, std::time::Instant)>,
|
2025-11-16 11:41:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub enum NewEntryMode {
|
|
|
|
|
Task,
|
|
|
|
|
Project,
|
2025-11-16 09:57:01 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl App {
|
|
|
|
|
pub fn new() -> anyhow::Result<Self> {
|
|
|
|
|
Ok(Self {
|
|
|
|
|
state: AppState::load()?,
|
2025-11-16 10:19:14 -05:00
|
|
|
config: Config::load()?,
|
|
|
|
|
current_screen: Screen::Main,
|
|
|
|
|
needs_redraw: true,
|
2025-11-16 11:41:05 -05:00
|
|
|
new_entry_buffer: String::new(),
|
|
|
|
|
new_entry_project: String::new(),
|
|
|
|
|
new_entry_cursor: 0,
|
|
|
|
|
new_entry_mode: NewEntryMode::Task,
|
2025-11-16 12:25:22 -05:00
|
|
|
status_message: None,
|
2025-11-16 09:57:01 -05:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn handle_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
2025-11-16 12:25:22 -05:00
|
|
|
// Update status message
|
|
|
|
|
self.update_status_message();
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
match self.current_screen {
|
|
|
|
|
Screen::Main => self.handle_main_event(event),
|
|
|
|
|
Screen::Help => self.handle_help_event(event),
|
|
|
|
|
Screen::ConfigHelp => self.handle_config_help_event(event),
|
2025-11-16 11:41:05 -05:00
|
|
|
Screen::NewEntry => self.handle_new_entry_event(event),
|
2025-11-16 10:19:14 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_main_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
2025-11-16 09:57:01 -05:00
|
|
|
match event {
|
|
|
|
|
Event::Key(KeyEvent {
|
2025-11-16 13:12:45 -05:00
|
|
|
code, modifiers, ..
|
2025-11-16 09:57:01 -05:00
|
|
|
}) => match (code, modifiers) {
|
|
|
|
|
(KeyCode::Char('q'), _) => return Ok(true),
|
|
|
|
|
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.move_selection(1),
|
|
|
|
|
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.move_selection(-1),
|
|
|
|
|
(KeyCode::Char('h'), _) | (KeyCode::Left, _) => self.change_pane(-1),
|
|
|
|
|
(KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.change_pane(1),
|
2025-11-16 11:41:05 -05:00
|
|
|
(KeyCode::Char('n'), KeyModifiers::CONTROL) => self.change_pane(1),
|
|
|
|
|
(KeyCode::Char('p'), KeyModifiers::CONTROL) => self.change_pane(-1),
|
2025-11-16 09:57:01 -05:00
|
|
|
(KeyCode::Enter, _) => self.toggle_current_item()?,
|
|
|
|
|
(KeyCode::Char('e'), KeyModifiers::CONTROL) => self.edit_config()?,
|
2025-11-16 10:19:14 -05:00
|
|
|
(KeyCode::Char('?'), _) => self.current_screen = Screen::Help,
|
|
|
|
|
(KeyCode::Char('c'), _) => self.edit_app_config()?,
|
2025-11-16 11:41:05 -05:00
|
|
|
(KeyCode::Char('n'), _) => self.start_new_entry(),
|
2025-11-16 12:25:22 -05:00
|
|
|
(KeyCode::Char('d'), _) => self.delete_current_item()?,
|
2025-11-16 10:19:14 -05:00
|
|
|
_ => {}
|
|
|
|
|
},
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
Ok(false)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 11:41:05 -05:00
|
|
|
fn handle_new_entry_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
|
|
|
|
match event {
|
|
|
|
|
Event::Key(KeyEvent {
|
2025-11-16 13:12:45 -05:00
|
|
|
code, modifiers, ..
|
2025-11-16 11:41:05 -05:00
|
|
|
}) => {
|
|
|
|
|
match (code, modifiers) {
|
|
|
|
|
(KeyCode::Esc, _) => {
|
|
|
|
|
self.current_screen = Screen::Main;
|
|
|
|
|
self.new_entry_buffer.clear();
|
|
|
|
|
self.new_entry_project.clear();
|
|
|
|
|
self.new_entry_mode = NewEntryMode::Task;
|
2025-11-16 13:12:45 -05:00
|
|
|
self.new_entry_cursor = 0;
|
2025-11-16 11:41:05 -05:00
|
|
|
}
|
|
|
|
|
(KeyCode::Enter, _) => {
|
|
|
|
|
match self.new_entry_mode {
|
|
|
|
|
NewEntryMode::Task => {
|
|
|
|
|
if !self.new_entry_buffer.is_empty() {
|
|
|
|
|
self.new_entry_mode = NewEntryMode::Project;
|
2025-11-16 13:12:45 -05:00
|
|
|
self.new_entry_cursor = self.new_entry_project.len();
|
2025-11-16 11:41:05 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
NewEntryMode::Project => {
|
2025-11-16 13:12:45 -05:00
|
|
|
if self.new_entry_project.is_empty()
|
|
|
|
|
|| self.config.is_valid_project(&self.new_entry_project)
|
|
|
|
|
{
|
2025-11-16 11:41:05 -05:00
|
|
|
// Create new time item
|
|
|
|
|
let item = TimeItem {
|
|
|
|
|
name: self.new_entry_buffer.clone(),
|
|
|
|
|
tags: if self.new_entry_project.is_empty() {
|
|
|
|
|
vec![]
|
|
|
|
|
} else {
|
|
|
|
|
vec![self.new_entry_project.clone()]
|
|
|
|
|
},
|
|
|
|
|
last_used: Some(Utc::now()),
|
|
|
|
|
};
|
|
|
|
|
// Add to current pane (or recurring if in recent)
|
|
|
|
|
match self.state.current_pane {
|
2025-11-16 13:12:45 -05:00
|
|
|
0 => self.state.permanent_items.push(item), // Permanent Items
|
|
|
|
|
1 => self.state.recurring_items.push(item), // Recurring Items
|
|
|
|
|
2 => self.state.recent_items.push(item), // Ad-Hoc Items
|
2025-11-16 11:41:05 -05:00
|
|
|
_ => unreachable!(),
|
|
|
|
|
}
|
|
|
|
|
self.state.save()?;
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 11:41:05 -05:00
|
|
|
// Clear and return to main screen
|
|
|
|
|
self.current_screen = Screen::Main;
|
|
|
|
|
self.new_entry_buffer.clear();
|
|
|
|
|
self.new_entry_project.clear();
|
|
|
|
|
self.new_entry_mode = NewEntryMode::Task;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-16 13:12:45 -05:00
|
|
|
(KeyCode::Backspace, _) => match self.new_entry_mode {
|
|
|
|
|
NewEntryMode::Task => {
|
|
|
|
|
if self.new_entry_cursor > 0 {
|
|
|
|
|
self.new_entry_buffer.remove(self.new_entry_cursor - 1);
|
|
|
|
|
self.new_entry_cursor -= 1;
|
2025-11-16 11:41:05 -05:00
|
|
|
}
|
2025-11-16 13:12:45 -05:00
|
|
|
}
|
|
|
|
|
NewEntryMode::Project => {
|
|
|
|
|
if self.new_entry_cursor > 0 {
|
|
|
|
|
let idx = self.new_entry_cursor - 1;
|
|
|
|
|
if idx < self.new_entry_project.len() {
|
|
|
|
|
self.new_entry_project.remove(idx);
|
2025-11-16 11:41:05 -05:00
|
|
|
self.new_entry_cursor -= 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-16 13:12:45 -05:00
|
|
|
},
|
|
|
|
|
(KeyCode::Char(c), m) if m.is_empty() => match self.new_entry_mode {
|
|
|
|
|
NewEntryMode::Task => {
|
|
|
|
|
self.new_entry_buffer.insert(self.new_entry_cursor, c);
|
|
|
|
|
self.new_entry_cursor += 1;
|
|
|
|
|
}
|
|
|
|
|
NewEntryMode::Project => {
|
|
|
|
|
if self.new_entry_cursor <= self.new_entry_project.len() {
|
2025-11-16 11:41:05 -05:00
|
|
|
self.new_entry_project.insert(self.new_entry_cursor, c);
|
|
|
|
|
self.new_entry_cursor += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-16 13:12:45 -05:00
|
|
|
},
|
2025-11-16 11:41:05 -05:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
Ok(false)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
fn handle_help_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
|
|
|
|
match event {
|
|
|
|
|
Event::Key(KeyEvent { code, .. }) => match code {
|
|
|
|
|
KeyCode::Char('c') => self.current_screen = Screen::ConfigHelp,
|
|
|
|
|
KeyCode::Esc | KeyCode::Char('q') => self.current_screen = Screen::Main,
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
Ok(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_config_help_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
|
|
|
|
match event {
|
|
|
|
|
Event::Key(KeyEvent { code, .. }) => match code {
|
|
|
|
|
KeyCode::Esc | KeyCode::Char('q') => self.current_screen = Screen::Help,
|
2025-11-16 09:57:01 -05:00
|
|
|
_ => {}
|
|
|
|
|
},
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
Ok(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn move_selection(&mut self, delta: i32) {
|
|
|
|
|
let items_len = match self.state.current_pane {
|
|
|
|
|
0 => self.state.permanent_items.len(),
|
|
|
|
|
1 => self.state.recurring_items.len(),
|
|
|
|
|
2 => self.state.recent_items.len(),
|
|
|
|
|
_ => return,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if items_len == 0 {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let current = self.state.selected_indices[self.state.current_pane] as i32;
|
|
|
|
|
let new_index = (current + delta).rem_euclid(items_len as i32) as usize;
|
|
|
|
|
self.state.selected_indices[self.state.current_pane] = new_index;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn change_pane(&mut self, delta: i32) {
|
|
|
|
|
self.state.current_pane = ((self.state.current_pane as i32 + delta).rem_euclid(3)) as usize;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn get_current_item(&self) -> Option<TimeItem> {
|
|
|
|
|
let items = match self.state.current_pane {
|
|
|
|
|
0 => &self.state.permanent_items,
|
|
|
|
|
1 => &self.state.recurring_items,
|
|
|
|
|
2 => &self.state.recent_items,
|
|
|
|
|
_ => return None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let index = self.state.selected_indices[self.state.current_pane];
|
|
|
|
|
items.get(index).cloned()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn toggle_current_item(&mut self) -> anyhow::Result<()> {
|
|
|
|
|
if let Some(item) = self.get_current_item() {
|
2025-11-16 13:12:45 -05:00
|
|
|
if self
|
|
|
|
|
.state
|
|
|
|
|
.active_timer
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|(active, _)| active.name == item.name)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
{
|
2025-11-16 09:57:01 -05:00
|
|
|
self.state.stop_timer()?;
|
|
|
|
|
} else {
|
|
|
|
|
self.state.start_timer(item)?;
|
|
|
|
|
}
|
|
|
|
|
self.state.save()?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 12:25:22 -05:00
|
|
|
fn delete_current_item(&mut self) -> anyhow::Result<()> {
|
|
|
|
|
// Check if this is the active timer
|
|
|
|
|
let should_stop = {
|
|
|
|
|
let items = match self.state.current_pane {
|
|
|
|
|
0 => &self.state.permanent_items,
|
|
|
|
|
1 => &self.state.recurring_items,
|
|
|
|
|
2 => &self.state.recent_items,
|
|
|
|
|
_ => return Ok(()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let index = self.state.selected_indices[self.state.current_pane];
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 12:25:22 -05:00
|
|
|
if !items.is_empty() && index < items.len() {
|
|
|
|
|
if let Some((ref active, _)) = self.state.active_timer {
|
|
|
|
|
items[index].name == active.name
|
|
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Stop timer if needed
|
|
|
|
|
if should_stop {
|
|
|
|
|
self.state.stop_timer()?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Now delete the item
|
|
|
|
|
let items = match self.state.current_pane {
|
|
|
|
|
0 => &mut self.state.permanent_items,
|
|
|
|
|
1 => &mut self.state.recurring_items,
|
|
|
|
|
2 => &mut self.state.recent_items,
|
|
|
|
|
_ => return Ok(()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let index = self.state.selected_indices[self.state.current_pane];
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 12:25:22 -05:00
|
|
|
if !items.is_empty() && index < items.len() {
|
|
|
|
|
// Remove the item
|
|
|
|
|
items.remove(index);
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 12:25:22 -05:00
|
|
|
// Adjust index if we're at the end
|
|
|
|
|
if !items.is_empty() && index == items.len() {
|
|
|
|
|
self.state.selected_indices[self.state.current_pane] = items.len() - 1;
|
|
|
|
|
}
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 12:25:22 -05:00
|
|
|
// Save changes
|
|
|
|
|
self.state.save()?;
|
|
|
|
|
}
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 12:25:22 -05:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_status_message<S: Into<String>>(&mut self, message: S) {
|
|
|
|
|
self.status_message = Some((message.into(), std::time::Instant::now()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn update_status_message(&mut self) {
|
|
|
|
|
// Clear status message after 3 seconds
|
|
|
|
|
if let Some((_, instant)) = self.status_message {
|
|
|
|
|
if instant.elapsed().as_secs() >= 3 {
|
|
|
|
|
self.status_message = None;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 11:41:05 -05:00
|
|
|
fn start_new_entry(&mut self) {
|
|
|
|
|
self.current_screen = Screen::NewEntry;
|
|
|
|
|
self.new_entry_buffer.clear();
|
|
|
|
|
self.new_entry_project.clear();
|
|
|
|
|
self.new_entry_cursor = 0;
|
|
|
|
|
self.new_entry_mode = NewEntryMode::Task;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
fn edit_app_config(&mut self) -> anyhow::Result<()> {
|
|
|
|
|
use crossterm::{
|
|
|
|
|
execute,
|
2025-11-16 13:12:45 -05:00
|
|
|
terminal::{disable_raw_mode, enable_raw_mode},
|
|
|
|
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
|
2025-11-16 10:19:14 -05:00
|
|
|
};
|
|
|
|
|
use std::io::stdout;
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
|
|
|
|
|
let config_path = Config::config_path()?;
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
// Leave TUI mode
|
|
|
|
|
disable_raw_mode()?;
|
|
|
|
|
execute!(stdout(), LeaveAlternateScreen)?;
|
|
|
|
|
|
|
|
|
|
// Run editor
|
2025-11-16 13:12:45 -05:00
|
|
|
let status = Command::new(editor).arg(&config_path).status()?;
|
2025-11-16 10:19:14 -05:00
|
|
|
|
|
|
|
|
if status.success() {
|
|
|
|
|
// Reload entire application state
|
|
|
|
|
self.config = Config::load()?;
|
|
|
|
|
// Signal for complete reload
|
|
|
|
|
self.current_screen = Screen::Main;
|
|
|
|
|
self.needs_redraw = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return to TUI mode
|
|
|
|
|
execute!(stdout(), EnterAlternateScreen)?;
|
|
|
|
|
enable_raw_mode()?;
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn edit_config(&mut self) -> anyhow::Result<()> {
|
|
|
|
|
use crossterm::{
|
|
|
|
|
execute,
|
2025-11-16 13:12:45 -05:00
|
|
|
terminal::{disable_raw_mode, enable_raw_mode},
|
|
|
|
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
|
2025-11-16 10:19:14 -05:00
|
|
|
};
|
|
|
|
|
use std::io::stdout;
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 09:57:01 -05:00
|
|
|
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
|
|
|
|
|
let config_path = AppState::config_file()?;
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 09:57:01 -05:00
|
|
|
if !config_path.exists() {
|
|
|
|
|
std::fs::write(&config_path, "# WAT Configuration\n# Add your permanent items here\n\npermanent_items:\n - name: Daily Standup\n tags: [daily, meeting]\n")?;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
// Leave TUI mode
|
|
|
|
|
disable_raw_mode()?;
|
|
|
|
|
execute!(stdout(), LeaveAlternateScreen)?;
|
|
|
|
|
|
|
|
|
|
// Run editor
|
2025-11-16 13:12:45 -05:00
|
|
|
let status = Command::new(editor).arg(&config_path).status()?;
|
2025-11-16 10:19:14 -05:00
|
|
|
|
|
|
|
|
if status.success() {
|
|
|
|
|
// Reload entire application state
|
|
|
|
|
self.state = AppState::load()?;
|
|
|
|
|
// Signal for complete reload
|
|
|
|
|
self.current_screen = Screen::Main;
|
|
|
|
|
self.needs_redraw = true;
|
2025-11-16 09:57:01 -05:00
|
|
|
}
|
|
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
// Return to TUI mode
|
|
|
|
|
execute!(stdout(), EnterAlternateScreen)?;
|
|
|
|
|
enable_raw_mode()?;
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 09:57:01 -05:00
|
|
|
Ok(())
|
|
|
|
|
}
|
2025-11-16 13:12:45 -05:00
|
|
|
}
|