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 { pub id: String, pub project: String, pub start: String, pub stop: String, pub tags: Vec, } pub enum Screen { Main, Help, ConfigHelp, NewEntry, ReassignProject, LogView, LogViewHelp, } pub enum LogViewPeriod { Day, Week, Month, } pub enum LogViewDayOrder { Chronological, // Oldest first ReverseChronological, // Newest first (default) } 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_day_order: LogViewDayOrder, pub log_view_selection_level: LogViewSelection, pub log_view_frames: Vec, pub log_view_content: Vec, pub log_view_frame_indices: Vec, // Maps display order to frame index 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 { // Exclude separator lines (time gap markers) if line.trim() == "---" { return false; } 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 { let config = Config::load()?; let mut state = AppState::load()?; // Initialize current_pane to first enabled section let enabled = [ config.show_permanent, config.show_recurring, config.show_recent, ]; // Find first enabled pane if let Some(first_enabled) = enabled.iter().position(|&x| x) { state.current_pane = first_enabled; } Ok(Self { state, config, 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_day_order: LogViewDayOrder::ReverseChronological, log_view_selection_level: LogViewSelection::Entry, log_view_frames: Vec::new(), log_view_content: Vec::new(), log_view_frame_indices: 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.move_column(-1), (KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.move_column(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_frame_indices.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::Char('r') => { // Toggle day order (chronological vs reverse chronological) self.log_view_day_order = match self.log_view_day_order { LogViewDayOrder::Chronological => LogViewDayOrder::ReverseChronological, LogViewDayOrder::ReverseChronological => LogViewDayOrder::Chronological, }; self.format_log_entries(); self.needs_clear = true; } KeyCode::PageDown => { self.log_view_selected = (self.log_view_selected + 10) .min(self.log_view_frame_indices.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, tracking their original indices // Use YYYY-MM-DD as the key for proper sorting let mut by_date: BTreeMap)> = BTreeMap::new(); // Choose date format based on view period let date_format = match self.log_view_period { LogViewPeriod::Month => "%d %B %Y", // "23 November 2025" (no weekday) _ => "%A %d %B %Y", // "Saturday 23 November 2025" }; for (idx, frame) in self.log_view_frames.iter().enumerate() { if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) { let local_dt: DateTime = start_dt.into(); let sort_key = local_dt.format("%Y-%m-%d").to_string(); // For sorting let display_date = local_dt.format(date_format).to_string(); // For display by_date .entry(sort_key) .or_insert_with(|| (display_date.clone(), Vec::new())) .1 .push((idx, frame)); } } let mut lines = Vec::new(); let mut frame_indices = Vec::new(); // Sort each day's frames chronologically for (_, frames) in by_date.values_mut() { frames.sort_by(|(_, a), (_, b)| a.start.cmp(&b.start)); } // Collect dates in the desired order let dates: Vec<_> = match self.log_view_day_order { LogViewDayOrder::ReverseChronological => { by_date.iter().rev().collect() } LogViewDayOrder::Chronological => { by_date.iter().collect() } }; for (_sort_key, (display_date, frames)) in dates { lines.push(display_date.clone()); let mut prev_stop: Option> = None; for (frame_idx, (idx, frame)) in frames.iter().enumerate() { 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(); // Check for time gap and add separator line if enabled if self.config.show_time_gaps && frame_idx > 0 { if let Some(prev) = prev_stop { let gap = start_local.signed_duration_since(prev); if gap.num_minutes() >= 5 { lines.push("\t ---".to_string()); // Note: don't add to frame_indices - separator is not an entry } } } 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 display_text = if frame.tags.is_empty() { frame.project.clone() } else { format!("{} [{}]", frame.tags.join(", "), frame.project) }; let line_text = format!( "\t{} to {} {}", start_time, stop_time, display_text ); lines.push(line_text); frame_indices.push(*idx); // Only add actual entries to frame_indices prev_stop = Some(stop_local); } } lines.push(String::new()); // Empty line between dates } self.log_view_content = lines; self.log_view_frame_indices = frame_indices; } 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, tracking indices // Use YYYY-MM-DD as the key for proper sorting let mut by_date: BTreeMap>)> = BTreeMap::new(); // Choose date format based on view period let date_format = match self.log_view_period { LogViewPeriod::Month => "%d %B %Y", // "23 November 2025" (no weekday) _ => "%A %d %B %Y", // "Saturday 23 November 2025" }; for (idx, frame) in self.log_view_frames.iter().enumerate() { if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) { let local_dt: DateTime = start_dt.into(); let sort_key = local_dt.format("%Y-%m-%d").to_string(); // For sorting let display_date = local_dt.format(date_format).to_string(); // For display by_date .entry(sort_key) .or_insert_with(|| (display_date.clone(), BTreeMap::new())) .1 .entry(frame.project.clone()) .or_insert_with(Vec::new) .push((idx, frame)); } } let mut lines = Vec::new(); let mut frame_indices = Vec::new(); // Sort frames within each project chronologically for (_, projects) in by_date.values_mut() { for frames in projects.values_mut() { frames.sort_by(|(_, a), (_, b)| a.start.cmp(&b.start)); } } // Collect dates in the desired order let dates: Vec<_> = match self.log_view_day_order { LogViewDayOrder::ReverseChronological => { by_date.iter().rev().collect() } LogViewDayOrder::Chronological => { by_date.iter().collect() } }; for (_sort_key, (display_date, projects)) in dates { lines.push(display_date.clone()); for (project, frames) in projects.iter() { lines.push(format!(" {}", project)); // Project header with indent for (idx, 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 )); frame_indices.push(*idx); } } } lines.push(String::new()); // Empty line between dates } self.log_view_content = lines; self.log_view_frame_indices = frame_indices; } fn edit_selected_frame(&mut self) -> anyhow::Result<()> { // Check if selection is valid if self.log_view_selected >= self.log_view_frame_indices.len() { return Ok(()); } // Get the actual frame index from the display index let frame_idx = self.log_view_frame_indices[self.log_view_selected]; if frame_idx >= self.log_view_frames.len() { return Ok(()); } let frame_id = &self.log_view_frames[frame_idx].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<()> { // Check if selection is valid if self.log_view_selected >= self.log_view_frame_indices.len() { return Ok(()); } // Get the actual frame index from the display index let frame_idx = self.log_view_frame_indices[self.log_view_selected]; if frame_idx >= self.log_view_frames.len() { return Ok(()); } let frame_id = self.log_view_frames[frame_idx].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_frame_indices.len() && !self.log_view_frame_indices.is_empty() { self.log_view_selected = self.log_view_frame_indices.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 = match self.state.current_pane { 0 => &self.state.permanent_items, 1 => &self.state.recurring_items, 2 => &self.state.recent_items, _ => return, }; if items.is_empty() { return; } let current = self.state.selected_indices[self.state.current_pane] as i32; let items_len = items.len() as i32; // Check if we're using two-column mode let use_two_columns = self.config.multi_column && items.len() > 6; if use_two_columns { let mid_point = (items.len() + 1) / 2; let (col_start, col_end) = if self.state.current_column == 0 { (0, mid_point as i32 - 1) } else { (mid_point as i32, items_len - 1) }; let new_index = current + delta; if new_index < col_start && delta < 0 { // Moving up beyond column - go to previous pane self.change_pane(-1); let new_items = match self.state.current_pane { 0 => &self.state.permanent_items, 1 => &self.state.recurring_items, 2 => &self.state.recent_items, _ => return, }; if !new_items.is_empty() { self.state.selected_indices[self.state.current_pane] = new_items.len() - 1; // Stay in right column if new pane has enough items if self.config.multi_column && new_items.len() > 6 { self.state.current_column = 1; } else { self.state.current_column = 0; } } } else if new_index > col_end && delta > 0 { // Moving down beyond column - go to next pane self.change_pane(1); self.state.selected_indices[self.state.current_pane] = 0; self.state.current_column = 0; } else { // Normal movement within column self.state.selected_indices[self.state.current_pane] = new_index.clamp(col_start, col_end) as usize; } } else { // Single column mode let new_index = current + delta; if new_index < 0 && delta < 0 { // At top, move to previous pane self.change_pane(-1); let new_items = match self.state.current_pane { 0 => &self.state.permanent_items, 1 => &self.state.recurring_items, 2 => &self.state.recent_items, _ => return, }; if !new_items.is_empty() { self.state.selected_indices[self.state.current_pane] = new_items.len() - 1; } } else if new_index >= items_len && delta > 0 { // At bottom, move to next pane self.change_pane(1); self.state.selected_indices[self.state.current_pane] = 0; } else { // Normal movement within pane self.state.selected_indices[self.state.current_pane] = new_index.clamp(0, items_len - 1) as usize; } } } fn move_column(&mut self, delta: i32) { if !self.config.multi_column { return; } let items = match self.state.current_pane { 0 => &self.state.permanent_items, 1 => &self.state.recurring_items, 2 => &self.state.recent_items, _ => return, }; // Only switch columns if we have enough items for two columns if items.len() <= 6 { return; } let mid_point = (items.len() + 1) / 2; let new_column = if delta > 0 { 1 } else { 0 }; if new_column != self.state.current_column { let current_selected = self.state.selected_indices[self.state.current_pane]; // Calculate which row we're on in the current column let current_row = if self.state.current_column == 0 { current_selected // In left column } else { current_selected.saturating_sub(mid_point) // In right column }; // Switch column and jump to the same row in the new column self.state.current_column = new_column; if new_column == 0 { // Moving to left column - jump to same row self.state.selected_indices[self.state.current_pane] = current_row.min(mid_point - 1); } else { // Moving to right column - jump to same row let right_items_len = items.len() - mid_point; self.state.selected_indices[self.state.current_pane] = (mid_point + current_row).min(items.len() - 1).min(mid_point + right_items_len - 1); } } } fn change_pane(&mut self, delta: i32) { // Find next enabled pane let mut next_pane = self.state.current_pane; let enabled = [ self.config.show_permanent, self.config.show_recurring, self.config.show_recent, ]; // Count enabled panes let enabled_count = enabled.iter().filter(|&&x| x).count(); if enabled_count == 0 { return; // No panes to switch to } // Find next enabled pane for _ in 0..3 { next_pane = ((next_pane as i32 + delta).rem_euclid(3)) as usize; if enabled[next_pane] { break; } } self.state.current_pane = next_pane; self.state.current_column = 0; } 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 (the name field) self.reassign_project_buffer = item.name.clone(); 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 project name (item.name field) let old_name = item.name.clone(); item.name = self.reassign_project_buffer.clone(); // Check if this is the active timer self.state .active_timer .as_ref() .map(|(active, _)| active.name == old_name) .unwrap_or(false) } else { false }; // If this was the active timer, restart it with new project name 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(()) } }