use crate::config::Config; use crate::state::{AppState, TimeItem}; use chrono::Utc; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use serde::Deserialize; use std::process::Command; #[derive(Debug, Deserialize, Clone)] pub(crate) struct WatsonFrame { id: String, project: String, start: String, stop: String, tags: Vec, } pub enum Screen { Main, Help, ConfigHelp, NewEntry, ReassignProject, LogView, LogViewHelp, } pub enum LogViewPeriod { Day, Week, Month, } pub enum LogViewGrouping { ByDate, ByProject, } pub enum LogViewSelection { Entry, // Individual entry selected Project, // Whole project selected (ByProject mode only) Day, // Whole day selected All, // Entire view/period selected } pub struct App { pub state: AppState, pub config: Config, pub current_screen: Screen, pub needs_clear: bool, pub new_entry_buffer: String, pub new_entry_project: String, pub new_entry_cursor: usize, pub new_entry_mode: NewEntryMode, // Task or Project pub reassign_project_buffer: String, pub reassign_project_cursor: usize, pub log_view_period: LogViewPeriod, pub log_view_grouping: LogViewGrouping, pub log_view_selection_level: LogViewSelection, pub log_view_frames: Vec, pub log_view_content: Vec, pub log_view_scroll: usize, pub log_view_selected: usize, pub help_scroll: usize, pub clipboard: Option, pub status_message: Option<(String, std::time::Instant)>, } pub enum NewEntryMode { Task, Project, } impl App { // Helper to determine if a line is an entry line based on grouping mode fn is_entry_line(&self, line: &str) -> bool { match self.log_view_grouping { LogViewGrouping::ByProject => line.starts_with("\t\t"), LogViewGrouping::ByDate => line.starts_with('\t') && !line.starts_with("\t\t"), } } pub fn new() -> anyhow::Result { Ok(Self { state: AppState::load()?, config: Config::load()?, current_screen: Screen::Main, needs_clear: false, new_entry_buffer: String::new(), new_entry_project: String::new(), new_entry_cursor: 0, new_entry_mode: NewEntryMode::Task, reassign_project_buffer: String::new(), reassign_project_cursor: 0, log_view_period: LogViewPeriod::Day, log_view_grouping: LogViewGrouping::ByDate, log_view_selection_level: LogViewSelection::Entry, log_view_frames: Vec::new(), log_view_content: Vec::new(), log_view_scroll: 0, log_view_selected: 0, help_scroll: 0, clipboard: arboard::Clipboard::new().ok(), status_message: None, }) } pub fn handle_event(&mut self, event: Event) -> anyhow::Result { // Update status message self.update_status_message(); let previous_screen = std::mem::discriminant(&self.current_screen); let result = 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), Screen::NewEntry => self.handle_new_entry_event(event), Screen::ReassignProject => self.handle_reassign_project_event(event), Screen::LogView => self.handle_log_view_event(event), Screen::LogViewHelp => self.handle_log_view_help_event(event), }; // If we switched screens, signal that we need to clear let current_screen = std::mem::discriminant(&self.current_screen); if previous_screen != current_screen { self.needs_clear = true; } result } fn handle_main_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, modifiers, .. }) => 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), (KeyCode::Char('n'), KeyModifiers::CONTROL) => self.change_pane(1), (KeyCode::Char('p'), KeyModifiers::CONTROL) => self.change_pane(-1), (KeyCode::Enter, _) => self.toggle_current_item()?, (KeyCode::Char('e'), KeyModifiers::CONTROL) => self.edit_config()?, (KeyCode::Char('?'), _) => { self.help_scroll = 0; self.current_screen = Screen::Help; } (KeyCode::Char('c'), _) => self.edit_app_config()?, (KeyCode::Char('n'), _) => self.start_new_entry(), (KeyCode::Char('p'), _) => self.start_reassign_project(), (KeyCode::Char('v'), _) => self.start_log_view()?, (KeyCode::Char('d'), _) => self.delete_current_item()?, _ => {} }, _ => {} } Ok(false) } fn handle_new_entry_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, modifiers, .. }) => { 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; self.new_entry_cursor = 0; } (KeyCode::Enter, _) => { match self.new_entry_mode { NewEntryMode::Task => { // Move from Tag to Project self.new_entry_mode = NewEntryMode::Project; self.new_entry_cursor = self.new_entry_buffer.len(); } NewEntryMode::Project => { // Project is required, tag is optional if !self.new_entry_buffer.is_empty() { // 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 { 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 _ => unreachable!(), } self.state.save()?; // 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; } } } } (KeyCode::Backspace, _) => match self.new_entry_mode { NewEntryMode::Task => { if self.new_entry_cursor > 0 { self.new_entry_project.remove(self.new_entry_cursor - 1); self.new_entry_cursor -= 1; } } NewEntryMode::Project => { if self.new_entry_cursor > 0 { let idx = self.new_entry_cursor - 1; if idx < self.new_entry_buffer.len() { self.new_entry_buffer.remove(idx); self.new_entry_cursor -= 1; } } } }, (KeyCode::Char(c), m) if m.is_empty() => match self.new_entry_mode { NewEntryMode::Task => { self.new_entry_project.insert(self.new_entry_cursor, c); self.new_entry_cursor += 1; } NewEntryMode::Project => { if self.new_entry_cursor <= self.new_entry_buffer.len() { self.new_entry_buffer.insert(self.new_entry_cursor, c); self.new_entry_cursor += 1; } } }, _ => {} } } _ => {} } Ok(false) } fn handle_help_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Char('c') => { self.help_scroll = 0; self.current_screen = Screen::ConfigHelp; } KeyCode::Char('j') | KeyCode::Down => { self.help_scroll = self.help_scroll.saturating_add(1); } KeyCode::Char('k') | KeyCode::Up => { self.help_scroll = self.help_scroll.saturating_sub(1); } KeyCode::PageDown => { self.help_scroll = self.help_scroll.saturating_add(10); } KeyCode::PageUp => { self.help_scroll = self.help_scroll.saturating_sub(10); } KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => { self.help_scroll = 0; self.current_screen = Screen::Main; } _ => {} }, _ => {} } Ok(false) } fn handle_config_help_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Char('j') | KeyCode::Down => { self.help_scroll = self.help_scroll.saturating_add(1); } KeyCode::Char('k') | KeyCode::Up => { self.help_scroll = self.help_scroll.saturating_sub(1); } KeyCode::PageDown => { self.help_scroll = self.help_scroll.saturating_add(10); } KeyCode::PageUp => { self.help_scroll = self.help_scroll.saturating_sub(10); } KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => { self.help_scroll = 0; self.current_screen = Screen::Help; } _ => {} }, _ => {} } Ok(false) } fn handle_log_view_help_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Char('j') | KeyCode::Down => { self.help_scroll = self.help_scroll.saturating_add(1); } KeyCode::Char('k') | KeyCode::Up => { self.help_scroll = self.help_scroll.saturating_sub(1); } KeyCode::PageDown => { self.help_scroll = self.help_scroll.saturating_add(10); } KeyCode::PageUp => { self.help_scroll = self.help_scroll.saturating_sub(10); } KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => { self.help_scroll = 0; self.current_screen = Screen::LogView; } _ => {} }, _ => {} } Ok(false) } fn handle_reassign_project_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, modifiers, .. }) => { match (code, modifiers) { (KeyCode::Esc, _) => { self.current_screen = Screen::Main; self.reassign_project_buffer.clear(); self.reassign_project_cursor = 0; } (KeyCode::Enter, _) => { if self.reassign_project_buffer.is_empty() || self.config.is_valid_project(&self.reassign_project_buffer) { self.reassign_project_for_current_item()?; self.current_screen = Screen::Main; self.reassign_project_buffer.clear(); self.reassign_project_cursor = 0; } } (KeyCode::Backspace, _) => { if self.reassign_project_cursor > 0 { let idx = self.reassign_project_cursor - 1; if idx < self.reassign_project_buffer.len() { self.reassign_project_buffer.remove(idx); self.reassign_project_cursor -= 1; } } } (KeyCode::Char(c), m) if m.is_empty() => { if self.reassign_project_cursor <= self.reassign_project_buffer.len() { self.reassign_project_buffer.insert(self.reassign_project_cursor, c); self.reassign_project_cursor += 1; } } _ => {} } } _ => {} } Ok(false) } fn handle_log_view_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Esc | KeyCode::Char('q') => { self.current_screen = Screen::Main; self.log_view_scroll = 0; self.log_view_selected = 0; self.needs_clear = true; } KeyCode::Char('?') => { self.help_scroll = 0; self.current_screen = Screen::LogViewHelp; } KeyCode::Char('d') => { self.log_view_period = LogViewPeriod::Day; self.log_view_scroll = 0; self.log_view_selected = 0; self.log_view_selection_level = LogViewSelection::Entry; self.load_log_content()?; self.needs_clear = true; } KeyCode::Char('w') => { self.log_view_period = LogViewPeriod::Week; self.log_view_scroll = 0; self.log_view_selected = 0; self.log_view_selection_level = LogViewSelection::Entry; self.load_log_content()?; self.needs_clear = true; } KeyCode::Char('m') => { self.log_view_period = LogViewPeriod::Month; self.log_view_scroll = 0; self.log_view_selected = 0; self.log_view_selection_level = LogViewSelection::Entry; self.load_log_content()?; self.needs_clear = true; } KeyCode::Char('j') | KeyCode::Down => { match self.log_view_selection_level { LogViewSelection::Entry => { // Move to next entry if self.log_view_selected < self.log_view_frames.len().saturating_sub(1) { self.log_view_selected += 1; } } LogViewSelection::Project => { // Jump to next project group self.jump_to_next_project(); } LogViewSelection::Day => { // Jump to next day self.jump_to_next_day(); } LogViewSelection::All => { // No navigation at All level - already selecting everything } } } KeyCode::Char('k') | KeyCode::Up => { match self.log_view_selection_level { LogViewSelection::Entry => { // Move to previous entry if self.log_view_selected > 0 { self.log_view_selected -= 1; } } LogViewSelection::Project => { // Jump to previous project group self.jump_to_previous_project(); } LogViewSelection::Day => { // Jump to previous day self.jump_to_previous_day(); } LogViewSelection::All => { // No navigation at All level - already selecting everything } } } KeyCode::Char('e') => { // Only allow edit when selecting individual entry if matches!(self.log_view_selection_level, LogViewSelection::Entry) { self.edit_selected_frame()?; } } KeyCode::Char('x') => { // Only allow delete when selecting individual entry if matches!(self.log_view_selection_level, LogViewSelection::Entry) { self.delete_selected_frame()?; } } KeyCode::Char('h') | KeyCode::Left => { // Zoom out selection level self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) { (LogViewSelection::All, _) => LogViewSelection::All, // Already at highest (LogViewSelection::Day, _) => LogViewSelection::All, (LogViewSelection::Project, _) => LogViewSelection::Day, (LogViewSelection::Entry, LogViewGrouping::ByProject) => LogViewSelection::Project, (LogViewSelection::Entry, LogViewGrouping::ByDate) => LogViewSelection::Day, }; } KeyCode::Char('l') | KeyCode::Right => { // Zoom in selection level self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) { (LogViewSelection::Entry, _) => LogViewSelection::Entry, // Already at lowest (LogViewSelection::Project, _) => LogViewSelection::Entry, (LogViewSelection::Day, LogViewGrouping::ByProject) => LogViewSelection::Project, (LogViewSelection::Day, LogViewGrouping::ByDate) => LogViewSelection::Entry, (LogViewSelection::All, LogViewGrouping::ByProject) => LogViewSelection::Day, (LogViewSelection::All, LogViewGrouping::ByDate) => LogViewSelection::Day, }; } KeyCode::Char('c') => { self.copy_log_to_clipboard()?; } KeyCode::Char('g') => { // Toggle grouping mode self.log_view_grouping = match self.log_view_grouping { LogViewGrouping::ByDate => LogViewGrouping::ByProject, LogViewGrouping::ByProject => LogViewGrouping::ByDate, }; self.log_view_selected = 0; self.log_view_selection_level = LogViewSelection::Entry; self.format_log_entries(); self.needs_clear = true; } KeyCode::PageDown => { self.log_view_selected = (self.log_view_selected + 10) .min(self.log_view_frames.len().saturating_sub(1)); } KeyCode::PageUp => { self.log_view_selected = self.log_view_selected.saturating_sub(10); } _ => {} }, _ => {} } Ok(false) } fn format_log_entries(&mut self) { if self.log_view_frames.is_empty() { self.log_view_content = vec!["No log entries for this period.".to_string()]; return; } match self.log_view_grouping { LogViewGrouping::ByDate => self.format_by_date(), LogViewGrouping::ByProject => self.format_by_project(), } } fn format_by_date(&mut self) { use chrono::{DateTime, Local, Timelike}; use std::collections::BTreeMap; // Group frames by date let mut by_date: BTreeMap> = BTreeMap::new(); for frame in &self.log_view_frames { if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) { let local_dt: DateTime = start_dt.into(); let date_key = local_dt.format("%A %d %B %Y").to_string(); by_date.entry(date_key).or_insert_with(Vec::new).push(frame); } } let mut lines = Vec::new(); for (date, frames) in by_date.iter().rev() { lines.push(date.clone()); for frame in frames { if let (Ok(start_dt), Ok(stop_dt)) = ( DateTime::parse_from_rfc3339(&frame.start), DateTime::parse_from_rfc3339(&frame.stop), ) { let start_local: DateTime = start_dt.into(); let stop_local: DateTime = stop_dt.into(); let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute()); let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute()); let tags_str = if frame.tags.is_empty() { String::new() } else { format!(" [{}]", frame.tags.join(", ")) }; lines.push(format!( "\t{} to {} {}{}", start_time, stop_time, frame.project, tags_str )); } } lines.push(String::new()); // Empty line between dates } self.log_view_content = lines; } fn format_by_project(&mut self) { use chrono::{DateTime, Local, Timelike}; use std::collections::BTreeMap; // Group frames by date, then by project within each date let mut by_date: BTreeMap>> = BTreeMap::new(); for frame in &self.log_view_frames { if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) { let local_dt: DateTime = start_dt.into(); let date_key = local_dt.format("%A %d %B %Y").to_string(); by_date .entry(date_key) .or_insert_with(BTreeMap::new) .entry(frame.project.clone()) .or_insert_with(Vec::new) .push(frame); } } let mut lines = Vec::new(); for (date, projects) in by_date.iter().rev() { lines.push(date.clone()); for (project, frames) in projects.iter() { lines.push(format!(" {}", project)); // Project header with indent for frame in frames { if let (Ok(start_dt), Ok(stop_dt)) = ( DateTime::parse_from_rfc3339(&frame.start), DateTime::parse_from_rfc3339(&frame.stop), ) { let start_local: DateTime = start_dt.into(); let stop_local: DateTime = stop_dt.into(); let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute()); let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute()); let tags_str = if frame.tags.is_empty() { String::new() } else { format!(" [{}]", frame.tags.join(", ")) }; lines.push(format!( "\t\t{} to {}{}", start_time, stop_time, tags_str )); } } } lines.push(String::new()); // Empty line between dates } self.log_view_content = lines; } fn edit_selected_frame(&mut self) -> anyhow::Result<()> { if self.log_view_frames.is_empty() || self.log_view_selected >= self.log_view_frames.len() { return Ok(()); } let frame_id = &self.log_view_frames[self.log_view_selected].id; use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode}, terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; use std::io::stdout; // Leave TUI mode disable_raw_mode()?; execute!(stdout(), LeaveAlternateScreen)?; // Run watson edit let status = Command::new("watson").arg("edit").arg(frame_id).status()?; // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; if status.success() { // Reload log content self.load_log_content()?; } self.needs_clear = true; Ok(()) } fn delete_selected_frame(&mut self) -> anyhow::Result<()> { if self.log_view_frames.is_empty() || self.log_view_selected >= self.log_view_frames.len() { return Ok(()); } let frame_id = self.log_view_frames[self.log_view_selected].id.clone(); // Run watson remove with --force flag (no confirmation) let output = Command::new("watson") .arg("remove") .arg("--force") .arg(&frame_id) .output()?; if output.status.success() { // Reload log content self.load_log_content()?; // Adjust selection if we deleted the last item if self.log_view_selected >= self.log_view_frames.len() && !self.log_view_frames.is_empty() { self.log_view_selected = self.log_view_frames.len() - 1; } } self.needs_clear = true; Ok(()) } fn copy_log_to_clipboard(&mut self) -> anyhow::Result<()> { if self.log_view_content.is_empty() { return Ok(()); } // Determine what to copy based on selection level let text = match self.log_view_selection_level { LogViewSelection::Entry => { // Copy just the selected entry self.get_selected_entry_text() } LogViewSelection::Project => { // Copy the selected project group self.get_selected_project_text() } LogViewSelection::Day => { // Copy the entire day self.get_selected_day_text() } LogViewSelection::All => { // Copy the entire view/period self.log_view_content.join("\n") } }; // Copy to clipboard using the persistent clipboard instance if let Some(ref mut clipboard) = self.clipboard { if let Err(e) = clipboard.set_text(&text) { self.set_status_message(format!("Failed to copy: {}", e)); } } else { // Try to create a new clipboard if we don't have one match arboard::Clipboard::new() { Ok(mut clipboard) => { if let Err(e) = clipboard.set_text(&text) { self.set_status_message(format!("Failed to copy: {}", e)); } self.clipboard = Some(clipboard); } Err(e) => { self.set_status_message(format!("Failed to access clipboard: {}", e)); } } } Ok(()) } fn get_selected_entry_text(&self) -> String { // Find the entry line corresponding to the selected frame let entry_lines: Vec<&String> = self .log_view_content .iter() .filter(|l| self.is_entry_line(l)) .collect(); if self.log_view_selected < entry_lines.len() { entry_lines[self.log_view_selected].to_string() } else { String::new() } } fn get_selected_project_text(&self) -> String { // Find which project group the selected entry belongs to let mut current_project_lines = Vec::new(); let mut frame_count = 0; let mut found = false; for line in &self.log_view_content { if line.starts_with(" ") && !line.starts_with("\t") { // This is a project header if found { break; // We've collected the target project } current_project_lines.clear(); current_project_lines.push(line.clone()); } else if self.is_entry_line(line) { // Entry within a project current_project_lines.push(line.clone()); if frame_count == self.log_view_selected { found = true; } frame_count += 1; } } current_project_lines.join("\n") } fn get_selected_day_text(&self) -> String { // Find which day the selected entry belongs to let mut current_day_lines = Vec::new(); let mut frame_count = 0; let mut found = false; for line in &self.log_view_content { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { // This is a date header if found { break; // We've collected the target day } current_day_lines.clear(); current_day_lines.push(line.clone()); } else if line.starts_with('\t') || line.starts_with(" ") { // Add all lines within the day (project headers and entries) current_day_lines.push(line.clone()); // Count only actual entries if self.is_entry_line(line) { if frame_count == self.log_view_selected { found = true; } frame_count += 1; } } } current_day_lines.join("\n") } fn jump_to_next_project(&mut self) { // Find the current project, then jump to first entry of next project let mut current_date = String::new(); let mut current_project = String::new(); let mut selected_project = String::new(); let mut selected_date = String::new(); let mut frame_count = 0; let mut found_current = false; // Find which project we're currently in for line in &self.log_view_content.clone() { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { current_date = line.clone(); } else if line.starts_with(" ") && !line.starts_with("\t") { current_project = line.clone(); } if self.is_entry_line(line) { if frame_count == self.log_view_selected { selected_project = current_project.clone(); selected_date = current_date.clone(); found_current = true; break; } frame_count += 1; } } if !found_current { return; } // Now find the next project within the same day first, then next day current_project = String::new(); current_date = String::new(); let mut past_selected = false; frame_count = 0; for line in &self.log_view_content.clone() { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { current_date = line.clone(); } else if line.starts_with(" ") && !line.starts_with("\t") { current_project = line.clone(); if past_selected && current_project != selected_project { // This is the next project, select its first entry self.log_view_selected = frame_count; return; } } if self.is_entry_line(line) { if current_project == selected_project && current_date == selected_date { past_selected = true; } frame_count += 1; } } } fn jump_to_previous_project(&mut self) { // Find the previous project and jump to its first entry let mut current_project = String::new(); let mut _selected_project = String::new(); let mut frame_count = 0; let mut previous_project_first_entry = None; let mut last_different_project_entry = None; // Find which project we're currently in for line in &self.log_view_content.clone() { if line.starts_with(" ") && !line.starts_with("\t") { if !current_project.is_empty() && current_project != _selected_project { last_different_project_entry = previous_project_first_entry; } current_project = line.clone(); previous_project_first_entry = Some(frame_count); } if self.is_entry_line(line) { if frame_count == self.log_view_selected { _selected_project = current_project.clone(); // Jump to the last different project we saw if let Some(entry) = last_different_project_entry { self.log_view_selected = entry; } return; } frame_count += 1; } } } fn jump_to_next_day(&mut self) { // Find the current day, then jump to first entry of next day let mut current_date = String::new(); let mut selected_date = String::new(); let mut frame_count = 0; let mut found_current = false; // Find which day we're currently in for line in &self.log_view_content.clone() { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { current_date = line.clone(); } if self.is_entry_line(line) { if frame_count == self.log_view_selected { selected_date = current_date.clone(); found_current = true; break; } frame_count += 1; } } if !found_current { return; } // Now find the next day and jump to its first entry current_date = String::new(); let mut past_selected = false; frame_count = 0; for line in &self.log_view_content.clone() { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { if past_selected && line != &selected_date { // This is the next day, continue to find its first entry current_date = line.clone(); } else if line == &selected_date { past_selected = true; } } if self.is_entry_line(line) { if past_selected && !current_date.is_empty() && current_date != selected_date { // First entry of next day self.log_view_selected = frame_count; return; } frame_count += 1; } } } fn jump_to_previous_day(&mut self) { // Find the previous day and jump to its first entry let mut current_date = String::new(); let mut _selected_date = String::new(); let mut frame_count = 0; let mut last_different_day_entry = None; let mut previous_day_first_entry = None; // Find which day we're currently in for line in &self.log_view_content.clone() { if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { if !current_date.is_empty() && current_date != _selected_date { last_different_day_entry = previous_day_first_entry; } current_date = line.clone(); previous_day_first_entry = Some(frame_count); } if self.is_entry_line(line) { if frame_count == self.log_view_selected { _selected_date = current_date.clone(); // Jump to the last different day we saw if let Some(entry) = last_different_day_entry { self.log_view_selected = entry; } return; } frame_count += 1; } } } 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 { 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() { if self .state .active_timer .as_ref() .map(|(active, _)| active.name == item.name) .unwrap_or(false) { self.state.stop_timer()?; } else { self.state.start_timer(item)?; } self.state.save()?; } Ok(()) } 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]; 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]; if !items.is_empty() && index < items.len() { // Remove the item items.remove(index); // 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; } // Save changes self.state.save()?; } Ok(()) } fn set_status_message>(&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; } } } 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; } fn start_reassign_project(&mut self) { if let Some(item) = self.get_current_item() { self.current_screen = Screen::ReassignProject; // Pre-fill with current project if it exists self.reassign_project_buffer = item.tags.first().cloned().unwrap_or_default(); self.reassign_project_cursor = self.reassign_project_buffer.len(); } } fn start_log_view(&mut self) -> anyhow::Result<()> { self.current_screen = Screen::LogView; self.log_view_period = LogViewPeriod::Day; self.log_view_scroll = 0; self.load_log_content()?; Ok(()) } fn load_log_content(&mut self) -> anyhow::Result<()> { let flag = match self.log_view_period { LogViewPeriod::Day => "--day", LogViewPeriod::Week => "--week", LogViewPeriod::Month => "--month", }; let output = Command::new("watson") .arg("log") .arg(flag) .arg("--json") .output()?; if output.status.success() { let json_str = String::from_utf8_lossy(&output.stdout); // Parse JSON frames match serde_json::from_str::>(&json_str) { Ok(frames) => { self.log_view_frames = frames; self.format_log_entries(); } Err(e) => { self.log_view_frames.clear(); self.log_view_content = vec![ "Failed to parse Watson log JSON:".to_string(), e.to_string(), ]; } } } else { let error = String::from_utf8_lossy(&output.stderr); self.log_view_frames.clear(); self.log_view_content = vec![ "Failed to load Watson log:".to_string(), error.to_string(), ]; } Ok(()) } fn reassign_project_for_current_item(&mut self) -> anyhow::Result<()> { 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]; let needs_restart = if let Some(item) = items.get_mut(index) { // Update the tags if self.reassign_project_buffer.is_empty() { item.tags.clear(); } else { item.tags = vec![self.reassign_project_buffer.clone()]; } // Check if this is the active timer self.state .active_timer .as_ref() .map(|(active, _)| active.name == item.name) .unwrap_or(false) } else { false }; // If this was the active timer, restart it with new tags if needs_restart { let item = items[index].clone(); self.state.stop_timer()?; self.state.start_timer(item)?; } self.state.save()?; Ok(()) } fn edit_app_config(&mut self) -> anyhow::Result<()> { use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode}, terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; use std::io::stdout; let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); let config_path = Config::config_path()?; // Leave TUI mode disable_raw_mode()?; execute!(stdout(), LeaveAlternateScreen)?; // Run editor let status = Command::new(editor).arg(&config_path).status()?; if status.success() { // Reload entire application state self.config = Config::load()?; self.current_screen = Screen::Main; } // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; self.needs_clear = true; Ok(()) } fn edit_config(&mut self) -> anyhow::Result<()> { use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode}, terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; use std::io::stdout; let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); let config_path = AppState::config_file()?; 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")?; } // Leave TUI mode disable_raw_mode()?; execute!(stdout(), LeaveAlternateScreen)?; // Run editor let status = Command::new(editor).arg(&config_path).status()?; if status.success() { // Reload entire application state self.state = AppState::load()?; self.current_screen = Screen::Main; } // Return to TUI mode execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; self.needs_clear = true; Ok(()) } }