diff --git a/src/app.rs b/src/app.rs index 732b94a..deaa35f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,11 +7,11 @@ 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, + id: String, + project: String, + start: String, + stop: String, + tags: Vec, } pub enum Screen { @@ -22,7 +22,6 @@ pub enum Screen { ReassignProject, LogView, LogViewHelp, - AddAnnotation, } pub enum LogViewPeriod { @@ -68,12 +67,9 @@ pub struct App { pub log_view_frame_indices: Vec, // Maps display order to frame index pub log_view_scroll: usize, pub log_view_selected: usize, - pub log_view_rounded: bool, // Toggle for 15-minute rounding display pub help_scroll: usize, pub clipboard: Option, pub status_message: Option<(String, std::time::Instant)>, - pub annotation_buffer: String, - pub annotation_cursor: usize, } pub enum NewEntryMode { @@ -82,56 +78,18 @@ pub enum NewEntryMode { } impl App { - // Helper to round a time to the nearest 15-minute interval - fn round_to_15min(dt: &chrono::DateTime) -> chrono::DateTime { - use chrono::{Timelike, Duration}; - - let minutes = dt.minute(); - let rounded_minutes = ((minutes + 7) / 15) * 15; // Round to nearest 15 - - let mut rounded = dt.with_minute(0).unwrap().with_second(0).unwrap().with_nanosecond(0).unwrap(); - rounded = rounded + Duration::minutes(rounded_minutes as i64); - - // Handle hour overflow (e.g., 50 minutes rounds to 60) - if rounded_minutes >= 60 { - rounded = rounded.with_minute(0).unwrap(); - } - - rounded - } - // 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(" "), - LogViewGrouping::ByDate => line.starts_with(" ") && !line.starts_with(" "), + 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, + state: AppState::load()?, + config: Config::load()?, current_screen: Screen::Main, needs_clear: false, new_entry_buffer: String::new(), @@ -149,12 +107,9 @@ impl App { log_view_frame_indices: Vec::new(), log_view_scroll: 0, log_view_selected: 0, - log_view_rounded: false, help_scroll: 0, clipboard: arboard::Clipboard::new().ok(), status_message: None, - annotation_buffer: String::new(), - annotation_cursor: 0, }) } @@ -172,7 +127,6 @@ impl App { 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), - Screen::AddAnnotation => self.handle_add_annotation_event(event), }; // If we switched screens, signal that we need to clear @@ -206,7 +160,7 @@ impl App { (KeyCode::Char('n'), _) => self.start_new_entry(), (KeyCode::Char('p'), _) => self.start_reassign_project(), (KeyCode::Char('v'), _) => self.start_log_view()?, - (KeyCode::Char('x'), _) => self.delete_current_item()?, + (KeyCode::Char('d'), _) => self.delete_current_item()?, _ => {} }, _ => {} @@ -282,7 +236,7 @@ impl App { } } }, - (KeyCode::Char(c), m) if m.is_empty() || m == KeyModifiers::SHIFT => match self.new_entry_mode { + (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; @@ -414,7 +368,7 @@ impl App { } } } - (KeyCode::Char(c), m) if m.is_empty() || m == KeyModifiers::SHIFT => { + (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; @@ -513,29 +467,12 @@ impl App { self.edit_selected_frame()?; } } - KeyCode::Char('a') => { - // Only allow annotation when selecting individual entry - if matches!(self.log_view_selection_level, LogViewSelection::Entry) { - self.start_add_annotation(); - } - } 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('b') => { - // Backfill - adjust start time to match previous entry's end time - // Only works in ByDate mode at Entry level - if !matches!(self.log_view_selection_level, LogViewSelection::Entry) { - self.set_status_message("Backfill only works at Entry level (use h/l)"); - } else if !matches!(self.log_view_grouping, LogViewGrouping::ByDate) { - self.set_status_message("Backfill only works in By Date view (press g)"); - } else { - self.fix_entry_gap()?; - } - } KeyCode::Char('h') | KeyCode::Left => { // Zoom out selection level self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) { @@ -580,12 +517,6 @@ impl App { self.format_log_entries(); self.needs_clear = true; } - KeyCode::Char('R') => { - // Toggle 15-minute rounding for display - self.log_view_rounded = !self.log_view_rounded; - 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)); @@ -660,9 +591,7 @@ impl App { 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() { + 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), @@ -670,52 +599,20 @@ impl App { let start_local: DateTime = start_dt.into(); let stop_local: DateTime = stop_dt.into(); - // Apply rounding if enabled - let (display_start, display_stop) = if self.log_view_rounded { - (Self::round_to_15min(&start_local), Self::round_to_15min(&stop_local)) + 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 { - (start_local, stop_local) + format!(" [{}]", frame.tags.join(", ")) }; - // 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(" ---".to_string()); - // Note: don't add to frame_indices - separator is not an entry - } - } - } - - let start_time = format!("{:02}:{:02}", display_start.hour(), display_start.minute()); - let stop_time = format!("{:02}:{:02}", display_stop.hour(), display_stop.minute()); - - // Calculate duration (use rounded times if enabled) - let duration = display_stop.signed_duration_since(display_start); - let hours = duration.num_hours(); - let minutes = duration.num_minutes() % 60; - let duration_str = if hours > 0 { - format!("({}h {:02}m)", hours, minutes) - } else { - format!("({}m)", minutes) - }; - - let display_text = if frame.tags.is_empty() { - frame.project.clone() - } else { - format!("{} [{}]", frame.tags.join(", "), frame.project) - }; - - let line_text = format!( - " {} to {} {:>9} {}", - start_time, stop_time, duration_str, display_text - ); - - lines.push(line_text); - frame_indices.push(*idx); // Only add actual entries to frame_indices - - prev_stop = Some(stop_local); + lines.push(format!( + "\t{} to {} {}{}", + start_time, stop_time, frame.project, tags_str + )); + frame_indices.push(*idx); } } lines.push(String::new()); // Empty line between dates @@ -788,25 +685,8 @@ impl App { let start_local: DateTime = start_dt.into(); let stop_local: DateTime = stop_dt.into(); - // Apply rounding if enabled - let (display_start, display_stop) = if self.log_view_rounded { - (Self::round_to_15min(&start_local), Self::round_to_15min(&stop_local)) - } else { - (start_local, stop_local) - }; - - let start_time = format!("{:02}:{:02}", display_start.hour(), display_start.minute()); - let stop_time = format!("{:02}:{:02}", display_stop.hour(), display_stop.minute()); - - // Calculate duration (use rounded times if enabled) - let duration = display_stop.signed_duration_since(display_start); - let hours = duration.num_hours(); - let minutes = duration.num_minutes() % 60; - let duration_str = if hours > 0 { - format!("({}h {:02}m)", hours, minutes) - } else { - format!("({}m)", minutes) - }; + 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() @@ -815,8 +695,8 @@ impl App { }; lines.push(format!( - " {} to {} {:>9}{}", - start_time, stop_time, duration_str, tags_str + "\t\t{} to {}{}", + start_time, stop_time, tags_str )); frame_indices.push(*idx); } @@ -907,88 +787,6 @@ impl App { Ok(()) } - fn fix_entry_gap(&mut self) -> anyhow::Result<()> { - use chrono::DateTime; - use serde_json::Value; - - self.set_status_message("Backfill function called"); - - // Check if selection is valid - if self.log_view_selected >= self.log_view_frame_indices.len() { - self.set_status_message("Invalid selection"); - return Ok(()); - } - - // Can't fix the first entry - if self.log_view_selected == 0 { - self.set_status_message("No previous entry!"); - return Ok(()); - } - - // Check if previous entry is on the same day - let current_frame_idx = self.log_view_frame_indices[self.log_view_selected]; - let prev_frame_idx = self.log_view_frame_indices[self.log_view_selected - 1]; - - if let (Some(current), Some(prev)) = ( - self.log_view_frames.get(current_frame_idx), - self.log_view_frames.get(prev_frame_idx), - ) { - // Parse timestamps - if let (Ok(curr_start), Ok(prev_stop)) = ( - DateTime::parse_from_rfc3339(¤t.start), - DateTime::parse_from_rfc3339(&prev.stop), - ) { - // Check if they're on the same day - let curr_date = curr_start.format("%Y-%m-%d").to_string(); - let prev_date = prev_stop.format("%Y-%m-%d").to_string(); - - if curr_date != prev_date { - self.set_status_message("No previous entry on same day!"); - return Ok(()); - } - - // Read watson frames file - let frames_path = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("Could not find config directory"))? - .join("watson") - .join("frames"); - - let frames_content = std::fs::read_to_string(&frames_path)?; - let mut frames: Value = serde_json::from_str(&frames_content)?; - - // Find and update the frame with matching id - if let Some(frames_array) = frames.as_array_mut() { - for frame in frames_array { - if let Some(frame_array) = frame.as_array_mut() { - // Frame format: [start, stop, project, id, tags, ...] - if frame_array.len() > 3 { - if let Some(id) = frame_array[3].as_str() { - if id == current.id { - // Update start timestamp (index 0) - let new_start_timestamp = prev_stop.timestamp(); - frame_array[0] = Value::Number(new_start_timestamp.into()); - break; - } - } - } - } - } - } - - // Write back to frames file - let updated_content = serde_json::to_string_pretty(&frames)?; - std::fs::write(&frames_path, updated_content)?; - - // Reload log content - self.load_log_content()?; - self.set_status_message("Entry start time adjusted"); - } - } - - self.needs_clear = true; - Ok(()) - } - fn copy_log_to_clipboard(&mut self) -> anyhow::Result<()> { if self.log_view_content.is_empty() { return Ok(()); @@ -1415,29 +1213,7 @@ impl App { } 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_pane = ((self.state.current_pane as i32 + delta).rem_euclid(3)) as usize; self.state.current_column = 0; } @@ -1710,115 +1486,4 @@ impl App { Ok(()) } - - fn start_add_annotation(&mut self) { - self.current_screen = Screen::AddAnnotation; - self.annotation_buffer.clear(); - self.annotation_cursor = 0; - } - - fn handle_add_annotation_event(&mut self, event: Event) -> anyhow::Result { - match event { - Event::Key(KeyEvent { - code, modifiers, .. - }) => { - match (code, modifiers) { - (KeyCode::Esc, _) => { - self.current_screen = Screen::LogView; - self.annotation_buffer.clear(); - self.annotation_cursor = 0; - } - (KeyCode::Enter, _) => { - if !self.annotation_buffer.is_empty() { - self.add_annotation_to_frame()?; - } - self.current_screen = Screen::LogView; - self.annotation_buffer.clear(); - self.annotation_cursor = 0; - } - (KeyCode::Backspace, _) => { - if self.annotation_cursor > 0 { - let idx = self.annotation_cursor - 1; - if idx < self.annotation_buffer.len() { - self.annotation_buffer.remove(idx); - self.annotation_cursor -= 1; - } - } - } - (KeyCode::Char(c), m) if m.is_empty() || m == KeyModifiers::SHIFT => { - if self.annotation_cursor <= self.annotation_buffer.len() { - self.annotation_buffer.insert(self.annotation_cursor, c); - self.annotation_cursor += 1; - } - } - _ => {} - } - } - _ => {} - } - Ok(false) - } - - fn add_annotation_to_frame(&mut self) -> anyhow::Result<()> { - use serde_json::Value; - - // Check if selection is valid - if self.log_view_selected >= self.log_view_frame_indices.len() { - self.set_status_message("Invalid selection"); - 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(); - - // Read watson frames file - let frames_path = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("Could not find config directory"))? - .join("watson") - .join("frames"); - - let frames_content = std::fs::read_to_string(&frames_path)?; - let mut frames: Value = serde_json::from_str(&frames_content)?; - - // Find and update the frame with matching id - if let Some(frames_array) = frames.as_array_mut() { - for frame in frames_array { - if let Some(frame_array) = frame.as_array_mut() { - // Frame format: [start, stop, project, id, tags, updated] - if frame_array.len() > 4 { - if let Some(id) = frame_array[3].as_str() { - if id == frame_id { - // Update tags array (index 4) - if let Some(tags) = frame_array[4].as_array_mut() { - // Add the annotation tag - tags.push(Value::String(self.annotation_buffer.clone())); - } - // Update timestamp (index 5) - if frame_array.len() > 5 { - frame_array[5] = Value::Number(chrono::Utc::now().timestamp().into()); - } - break; - } - } - } - } - } - } - - // Write back to frames file - let updated_content = serde_json::to_string_pretty(&frames)?; - std::fs::write(&frames_path, updated_content)?; - - // Reload log content - self.load_log_content()?; - self.set_status_message("Annotation added"); - - self.needs_clear = true; - Ok(()) - } } diff --git a/src/config.rs b/src/config.rs index fb91af3..1ddb538 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,14 +11,6 @@ pub struct Config { pub strict_projects: bool, #[serde(default = "default_multi_column")] pub multi_column: bool, - #[serde(default = "default_true")] - pub show_permanent: bool, - #[serde(default = "default_true")] - pub show_recurring: bool, - #[serde(default = "default_true")] - pub show_recent: bool, - #[serde(default = "default_true")] - pub show_time_gaps: bool, } fn default_show_help_hint() -> bool { @@ -33,10 +25,6 @@ fn default_multi_column() -> bool { true } -fn default_true() -> bool { - true -} - impl Default for Config { fn default() -> Self { Self { @@ -44,10 +32,6 @@ impl Default for Config { projects: Vec::new(), strict_projects: default_strict_projects(), multi_column: default_multi_column(), - show_permanent: default_true(), - show_recurring: default_true(), - show_recent: default_true(), - show_time_gaps: default_true(), } } } diff --git a/src/ui.rs b/src/ui.rs index 1f71251..d7ab0f5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,12 +2,10 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Row, Table, TableState}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Row, Table, TableState}, Frame, }; -use chrono::DateTime; - use crate::{ app::{App, LogViewDayOrder, LogViewGrouping, LogViewPeriod, LogViewSelection, NewEntryMode, Screen}, state::TimeItem, @@ -24,175 +22,96 @@ pub fn render(frame: &mut Frame, app: &App) { Screen::ReassignProject => render_reassign_project(frame, app), Screen::LogView => render_log_view(frame, app), Screen::LogViewHelp => render_log_view_help(frame, app), - Screen::AddAnnotation => render_add_annotation(frame, app), } } fn render_main(frame: &mut Frame, app: &App) { // Calculate layout - accounting for bottom bar if needed - let has_active_timer = app.state.active_timer.is_some(); + let show_bottom_bar = app.config.show_help_hint; let has_status = app.status_message.is_some(); - let show_help_hint = app.config.show_help_hint; - - // Show bottom bar if we have any of: timer, status, or help hint - let show_bottom_bar = has_active_timer || has_status || show_help_hint; - let bottom_height = if show_bottom_bar { if has_status { - 2 // Need extra line for status + 2 } else { - 1 // Just tracking or help hint + 1 } } else { - 0 + if has_status { + 1 + } else { + 0 + } }; - // Count enabled sections - let enabled_sections = [ - app.config.show_permanent, - app.config.show_recurring, - app.config.show_recent, - ] - .iter() - .filter(|&&x| x) - .count(); - - if enabled_sections == 0 { - // No sections enabled - show a message - let block = Block::default() - .borders(Borders::ALL) - .title("WAT") - .style(Style::default().fg(ACTIVE_COLOR)); - - let text = Paragraph::new("No sections enabled. Edit config (press 'c') to enable sections.") - .block(block) - .style(Style::default().fg(Color::Yellow)) - .alignment(Alignment::Center); - - frame.render_widget(text, frame.size()); - return; - } - - // Build constraints for enabled sections - let mut constraints = Vec::new(); - - if bottom_height > 0 { - // Reserve space for bottom bar first, then split remainder among sections - constraints.push(Constraint::Min(0)); // Sections get remaining space - constraints.push(Constraint::Length(bottom_height)); // Bottom bar gets fixed height - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(frame.size()); - - // Split the top area among enabled sections - let section_percentage = 100 / enabled_sections as u16; - let section_constraints = vec![Constraint::Percentage(section_percentage); enabled_sections]; - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(section_constraints) - .split(layout[0]); - - // Render enabled sections - let mut chunk_idx = 0; - - if app.config.show_permanent { - render_section( - frame, - chunks[chunk_idx], - "Permanent Items", - &app.state.permanent_items, - app.state.current_pane == 0, - app.state.selected_indices[0], - app.state.current_column, - app.config.multi_column, - ); - chunk_idx += 1; - } - - if app.config.show_recurring { - render_section( - frame, - chunks[chunk_idx], - "Recurring Items", - &app.state.recurring_items, - app.state.current_pane == 1, - app.state.selected_indices[1], - app.state.current_column, - app.config.multi_column, - ); - chunk_idx += 1; - } - - if app.config.show_recent { - render_section( - frame, - chunks[chunk_idx], - "Ad-Hoc Items", - &app.state.recent_items, - app.state.current_pane == 2, - app.state.selected_indices[2], - app.state.current_column, - app.config.multi_column, - ); - } - - // Render bottom bar - render_bottom_bar(frame, layout[1], app); + let constraints = if bottom_height > 0 { + vec![ + Constraint::Min(3), // At least 3 lines for each section + Constraint::Min(3), + Constraint::Min(3), + Constraint::Length(bottom_height), // Command bar + optional status + ] } else { - // No bottom bar - just render sections - let section_percentage = 100 / enabled_sections as u16; - let constraints = vec![Constraint::Percentage(section_percentage); enabled_sections]; - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(frame.size()); + vec![Constraint::Min(3), Constraint::Min(3), Constraint::Min(3)] + }; - let mut chunk_idx = 0; - - if app.config.show_permanent { - render_section( - frame, - chunks[chunk_idx], - "Permanent Items", - &app.state.permanent_items, - app.state.current_pane == 0, - app.state.selected_indices[0], - app.state.current_column, - app.config.multi_column, - ); - chunk_idx += 1; - } + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(frame.size()); - if app.config.show_recurring { - render_section( - frame, - chunks[chunk_idx], - "Recurring Items", - &app.state.recurring_items, - app.state.current_pane == 1, - app.state.selected_indices[1], - app.state.current_column, - app.config.multi_column, - ); - chunk_idx += 1; - } + let main_height = if bottom_height > 0 { + chunks[0].height + chunks[1].height + chunks[2].height + } else { + frame.size().height + }; - if app.config.show_recent { - render_section( - frame, - chunks[chunk_idx], - "Ad-Hoc Items", - &app.state.recent_items, - app.state.current_pane == 2, - app.state.selected_indices[2], - app.state.current_column, - app.config.multi_column, - ); - } + let section_height = main_height / 3; + + // Create sections with equal height + let sections = vec![ + Rect::new(0, 0, frame.size().width, section_height), + Rect::new(0, section_height, frame.size().width, section_height), + Rect::new(0, section_height * 2, frame.size().width, section_height), + ]; + + // Render main sections + render_section( + frame, + sections[0], + "Permanent Items", + &app.state.permanent_items, + app.state.current_pane == 0, + app.state.selected_indices[0], + app.state.current_column, + app.config.multi_column, + ); + + render_section( + frame, + sections[1], + "Recurring Items", + &app.state.recurring_items, + app.state.current_pane == 1, + app.state.selected_indices[1], + app.state.current_column, + app.config.multi_column, + ); + + render_section( + frame, + sections[2], + "Ad-Hoc Items", + &app.state.recent_items, + app.state.current_pane == 2, + app.state.selected_indices[2], + app.state.current_column, + app.config.multi_column, + ); + + // Render bottom bar if needed + if bottom_height > 0 { + let bottom_area = chunks[3]; + render_bottom_bar(frame, bottom_area, app); } } @@ -342,7 +261,7 @@ fn render_help(frame: &mut Frame, app: &App) { "", "Main Commands:", "Enter - Start/stop timer", - "x - Delete task from list", + "d - Delete task from list", "p - Reassign project name", "v - View Watson log", "Ctrl+e - Edit tasks config file", @@ -409,36 +328,10 @@ fn render_config_help(frame: &mut Frame, app: &App) { " When true, only allows projects from the 'projects' list", " When false, any project name can be used", "", - "multi_column: true/false", - " Default: true", - " When true, sections with many items display in two columns", - " When false, always use single column with scrolling", - "", - "show_permanent: true/false", - " Default: true", - " Show the 'Permanent Items' section", - "", - "show_recurring: true/false", - " Default: true", - " Show the 'Recurring Items' section", - "", - "show_recent: true/false", - " Default: true", - " Show the 'Ad-Hoc Items' section", - "", - "show_time_gaps: true/false", - " Default: true", - " In log view (by date), show '---' separator for 5+ minute gaps", - "", "Example configuration:", "---", "show_help_hint: true", "strict_projects: false", - "multi_column: true", - "show_permanent: true", - "show_recurring: true", - "show_recent: false", - "show_time_gaps: true", "projects:", " - work", " - personal", @@ -692,16 +585,15 @@ fn render_log_view(frame: &mut Frame, app: &App) { LogViewDayOrder::ReverseChronological => "↓", }; - let rounded_indicator = if app.log_view_rounded { " [ROUNDED]" } else { "" }; - let title = format!("Watson Log - {} View ({}) [{}]{}", period_str, grouping_str, order_str, rounded_indicator); + let title = format!("Watson Log - {} View ({}) [{}]", period_str, grouping_str, order_str); let block = Block::default() .title(title) .borders(Borders::ALL) .style(Style::default().fg(ACTIVE_COLOR)); - // Build text lines with selection highlighting based on selection level - let text_lines: Vec = { + // Build list items with selection highlighting based on selection level + let items: Vec = { // Pre-calculate which line indices should be highlighted let mut selected_date = String::new(); let mut selected_project = String::new(); @@ -717,17 +609,17 @@ fn render_log_view(frame: &mut Frame, app: &App) { let is_by_project = matches!(app.log_view_grouping, LogViewGrouping::ByProject); for (_idx, line) in app.log_view_content.iter().enumerate() { - if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { + if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { current_date = line.clone(); - } else if line.starts_with(" ") && !line.starts_with(" ") { + } else if line.starts_with(" ") && !line.starts_with("\t") { current_project = line.clone(); } // Only count actual entries (not all tab-indented lines) let is_entry = if is_by_project { - line.starts_with(" ") // 8 spaces for ByProject + line.starts_with("\t\t") // Double tab for ByProject } else { - line.starts_with(" ") && !line.starts_with(" ") // 4 spaces for ByDate + line.starts_with('\t') && !line.starts_with("\t\t") // Single tab for ByDate }; if is_entry { @@ -746,11 +638,10 @@ fn render_log_view(frame: &mut Frame, app: &App) { // Just find the specific entry line frame_count = 0; for (idx, line) in app.log_view_content.iter().enumerate() { - // Use is_entry_line logic which excludes separator lines let is_entry = if is_by_project { - line.starts_with(" ") + line.starts_with("\t\t") } else { - line.starts_with(" ") && !line.starts_with(" ") && line.trim() != "---" + line.starts_with('\t') && !line.starts_with("\t\t") }; if is_entry { @@ -769,19 +660,19 @@ fn render_log_view(frame: &mut Frame, app: &App) { current_date = String::new(); for (idx, line) in app.log_view_content.iter().enumerate() { - if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { + if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { current_date = line.clone(); if in_target_project { break; // End of project group } - } else if line.starts_with(" ") && !line.starts_with(" ") { + } else if line.starts_with(" ") && !line.starts_with("\t") { if current_date == selected_date && line == &selected_project { selected_line_start = idx; in_target_project = true; } else if in_target_project { break; // Different project } - } else if in_target_project && line.starts_with(" ") { + } else if in_target_project && line.starts_with('\t') { selected_line_end = idx; } } @@ -791,7 +682,7 @@ fn render_log_view(frame: &mut Frame, app: &App) { let mut in_target_day = false; for (idx, line) in app.log_view_content.iter().enumerate() { - if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { + if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { if line == &selected_date { selected_line_start = idx; in_target_day = true; @@ -810,102 +701,28 @@ fn render_log_view(frame: &mut Frame, app: &App) { } } - // Third pass: render with highlighting and overlap detection - let is_by_date = matches!(app.log_view_grouping, LogViewGrouping::ByDate); - - // Pre-calculate overlaps for efficiency - check consecutive entries within same day - let mut overlap_entry_indices = std::collections::HashSet::new(); - if is_by_date && app.log_view_frame_indices.len() > 1 { - // Track which day we're in to avoid comparing across days - let mut current_day_start_entry = 0; - let mut in_day = false; - - for (line_idx, line) in app.log_view_content.iter().enumerate() { - // New day header - if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { - current_day_start_entry = app.log_view_content[..line_idx] - .iter() - .filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---") - .count(); - in_day = true; - } else if in_day && line.starts_with(" ") && !line.is_empty() && line.trim() != "---" { - // This is an entry line - let entry_idx = app.log_view_content[..=line_idx] - .iter() - .filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---") - .count() - .saturating_sub(1); - - // Only check overlap if not the first entry of the day - if entry_idx > current_day_start_entry { - let current_frame_idx = app.log_view_frame_indices[entry_idx]; - let prev_frame_idx = app.log_view_frame_indices[entry_idx - 1]; - - if let (Some(current), Some(prev)) = ( - app.log_view_frames.get(current_frame_idx), - app.log_view_frames.get(prev_frame_idx), - ) { - if let (Ok(curr_start), Ok(prev_stop)) = ( - DateTime::parse_from_rfc3339(¤t.start), - DateTime::parse_from_rfc3339(&prev.stop), - ) { - // Overlap if current starts before previous ends - if curr_start < prev_stop { - overlap_entry_indices.insert(entry_idx); - } - } - } - } - } - } - } - + // Third pass: render with highlighting app.log_view_content .iter() .enumerate() .map(|(idx, line)| { let is_selected = idx >= selected_line_start && idx <= selected_line_end; - - // Skip styling for separator lines - let is_separator = line.trim() == "---"; - - // Check if this line corresponds to an overlapping entry - let has_overlap = if !is_separator && is_by_date && line.starts_with(" ") && !line.is_empty() { - // Count which entry this is (0-based in display order) - // Only count actual entry lines, not separator lines - let entry_idx = app.log_view_content[..=idx] - .iter() - .filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---") - .count() - .saturating_sub(1); - - overlap_entry_indices.contains(&entry_idx) - } else { - false - }; - let style = if is_separator { - Style::default().fg(Color::DarkGray) // Dim the separator - } else if is_selected { + let style = if is_selected { Style::default() .fg(ACTIVE_COLOR) .add_modifier(Modifier::REVERSED) - } else if has_overlap { - Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::White) }; - Line::from(vec![Span::styled(line.clone(), style)]) + ListItem::new(Line::from(vec![Span::styled(line.clone(), style)])) }) .collect() }; - let paragraph = Paragraph::new(text_lines) - .block(block) - .wrap(ratatui::widgets::Wrap { trim: false }); - - frame.render_widget(paragraph, chunks[0]); + let list = List::new(items).block(block); + frame.render_widget(list, chunks[0]); // Render help hint at bottom if enabled if app.config.show_help_hint { @@ -940,12 +757,6 @@ fn render_log_view_help(frame: &mut Frame, app: &App) { " - Days are shown in the chosen order", " - Entries within each day are always chronological (earliest to latest)", "", - "Display Mode:", - "- R: Toggle 15-minute rounding (for estimates/reports)", - " - When enabled, all times are rounded to nearest 15-minute interval", - " - Shows [ROUNDED] indicator in title bar", - " - Affects display and clipboard copy, does not modify Watson data", - "", "Navigation:", "- j/k or ↑/↓: Navigate selection", " - At Entry level: Move to next/previous entry", @@ -965,19 +776,10 @@ fn render_log_view_help(frame: &mut Frame, app: &App) { "", "Actions:", "- e: Edit the selected entry (Entry level only)", - "- a: Add annotation tag to the selected entry (Entry level only)", "- x: Delete the selected entry (Entry level only)", - "- b: Backfill - set entry start time to previous entry's end time", - " (Entry level, By Date view only)", "- c: Copy selection to clipboard (works at all levels)", " Copies based on current selection level", "", - "Visual Indicators (By Date view only):", - "- Yellow highlight: Entry overlaps with previous entry's time", - " (starts before the previous entry ended)", - "- '---' separator: 5+ minute gap between entries", - " (can be disabled with show_time_gaps: false in config)", - "", "Other:", "- ?: Show/hide this help", "- q or ESC: Return to main screen", @@ -1014,51 +816,3 @@ fn render_log_view_help(frame: &mut Frame, app: &App) { frame.render_widget(command_bar, bar_area); } - -fn render_add_annotation(frame: &mut Frame, app: &App) { - let area = centered_rect(60, 5, frame.size()); - frame.render_widget(Clear, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Length(2)]) - .split(area); - - // Get current entry to show in title - let current_entry = if app.log_view_selected < app.log_view_frame_indices.len() { - let frame_idx = app.log_view_frame_indices[app.log_view_selected]; - app.log_view_frames.get(frame_idx).map(|frame| { - if frame.tags.is_empty() { - frame.project.clone() - } else { - format!("{} [{}]", frame.tags.join(", "), frame.project) - } - }) - } else { - None - }; - - let title = if let Some(entry) = current_entry { - format!("Add Annotation to: {}", entry) - } else { - "Add Annotation".to_string() - }; - - // Annotation input - let annotation_block = Block::default() - .title(title) - .borders(Borders::ALL) - .style(Style::default().fg(ACTIVE_COLOR)); - - let annotation_text = Paragraph::new(app.annotation_buffer.as_str()) - .block(annotation_block); - - frame.render_widget(annotation_text, chunks[0]); - - // Help text - let help_text = Paragraph::new("Enter annotation text, press Enter to save, Esc to cancel") - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - - frame.render_widget(help_text, chunks[1]); -}