diff --git a/src/app.rs b/src/app.rs index 7d9444d..d09da4b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -35,6 +35,12 @@ pub enum LogViewGrouping { ByProject, } +pub enum LogViewSelection { + Entry, // Individual entry selected + Project, // Whole project selected (ByProject mode only) + Day, // Whole day selected +} + pub struct App { pub state: AppState, pub config: Config, @@ -48,6 +54,7 @@ pub struct App { 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, @@ -62,6 +69,14 @@ pub enum NewEntryMode { } 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()?, @@ -76,6 +91,7 @@ impl App { 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, @@ -321,6 +337,7 @@ impl App { 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; } @@ -328,6 +345,7 @@ impl App { 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; } @@ -335,24 +353,75 @@ impl App { 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 => { - if self.log_view_selected < self.log_view_frames.len().saturating_sub(1) { - self.log_view_selected += 1; + 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(); + } } } KeyCode::Char('k') | KeyCode::Up => { - if self.log_view_selected > 0 { - self.log_view_selected -= 1; + 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(); + } } } KeyCode::Char('e') => { - self.edit_selected_frame()?; + // Only allow edit when selecting individual entry + if matches!(self.log_view_selection_level, LogViewSelection::Entry) { + self.edit_selected_frame()?; + } } KeyCode::Char('x') => { - self.delete_selected_frame()?; + // 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::Day, _) => LogViewSelection::Day, // Already at highest + (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, + }; } KeyCode::Char('c') => { self.copy_log_to_clipboard()?; @@ -364,6 +433,7 @@ impl App { LogViewGrouping::ByProject => LogViewGrouping::ByDate, }; self.log_view_selected = 0; + self.log_view_selection_level = LogViewSelection::Entry; self.format_log_entries(); self.needs_clear = true; } @@ -572,15 +642,27 @@ impl App { return Ok(()); } - // Join all the formatted log content - let text = self.log_view_content.join("\n"); + // 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() + } + }; // 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)); } - // Success - clipboard instance stays alive in App struct } else { // Try to create a new clipboard if we don't have one match arboard::Clipboard::new() { @@ -588,7 +670,6 @@ impl App { if let Err(e) = clipboard.set_text(&text) { self.set_status_message(format!("Failed to copy: {}", e)); } - // Store it for future use self.clipboard = Some(clipboard); } Err(e) => { @@ -600,6 +681,260 @@ impl App { 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_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 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('\t') && !line.is_empty() && !line.starts_with(" ") { + current_date = line.clone(); + } else 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(); + selected_date = current_date.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(), diff --git a/src/ui.rs b/src/ui.rs index 294a8b5..591a91e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,7 +7,7 @@ use ratatui::{ }; use crate::{ - app::{App, LogViewGrouping, LogViewPeriod, NewEntryMode, Screen}, + app::{App, LogViewGrouping, LogViewPeriod, LogViewSelection, NewEntryMode, Screen}, state::{AppState, TimeItem}, }; @@ -461,36 +461,129 @@ fn render_log_view(frame: &mut Frame, app: &App) { .borders(Borders::ALL) .style(Style::default().fg(ACTIVE_COLOR)); - // Build list items with selection highlighting - let items: Vec = app - .log_view_content - .iter() - .enumerate() - .map(|(idx, line)| { - // Check if this is a frame line (starts with tab) and matches selected frame - let is_selected = if line.starts_with('\t') { - // Count how many frame lines we've seen so far - let frame_idx = app.log_view_content[..=idx] - .iter() - .filter(|l| l.starts_with('\t')) - .count() - .saturating_sub(1); - frame_idx == app.log_view_selected + // 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(); + let mut frame_count = 0; + let mut selected_line_start = 0; + let mut selected_line_end = 0; + + // First pass: find the date/project containing the selected frame + let mut current_date = String::new(); + let mut current_project = String::new(); + + // Determine what counts as an entry based on grouping mode + 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('\t') && !line.is_empty() && !line.starts_with(" ") { + current_date = line.clone(); + } 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("\t\t") // Double tab for ByProject } else { - false + line.starts_with('\t') && !line.starts_with("\t\t") // Single tab for ByDate }; + + if is_entry { + if frame_count == app.log_view_selected { + selected_date = current_date.clone(); + selected_project = current_project.clone(); + break; + } + frame_count += 1; + } + } + + // Second pass: determine the range of lines to highlight + match app.log_view_selection_level { + LogViewSelection::Entry => { + // Just find the specific entry line + frame_count = 0; + for (idx, line) in app.log_view_content.iter().enumerate() { + let is_entry = if is_by_project { + line.starts_with("\t\t") + } else { + line.starts_with('\t') && !line.starts_with("\t\t") + }; + + if is_entry { + if frame_count == app.log_view_selected { + selected_line_start = idx; + selected_line_end = idx; + break; + } + frame_count += 1; + } + } + } + LogViewSelection::Project => { + // Find the range of the selected project (within the same day) + let mut in_target_project = false; + current_date = String::new(); + + for (idx, line) in app.log_view_content.iter().enumerate() { + 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("\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('\t') { + selected_line_end = idx; + } + } + } + LogViewSelection::Day => { + // Find the range of the selected day + let mut in_target_day = false; + + for (idx, line) in app.log_view_content.iter().enumerate() { + if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") { + if line == &selected_date { + selected_line_start = idx; + in_target_day = true; + } else if in_target_day { + break; // End of day + } + } else if in_target_day && !line.is_empty() { + selected_line_end = 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; + + let style = if is_selected { + Style::default() + .fg(ACTIVE_COLOR) + .add_modifier(Modifier::REVERSED) + } else { + Style::default().fg(Color::White) + }; - let style = if is_selected { - Style::default() - .fg(ACTIVE_COLOR) - .add_modifier(Modifier::REVERSED) - } else { - Style::default().fg(Color::White) - }; - - ListItem::new(Line::from(vec![Span::styled(line.clone(), style)])) - }) - .collect(); + ListItem::new(Line::from(vec![Span::styled(line.clone(), style)])) + }) + .collect() + }; let list = List::new(items).block(block); frame.render_widget(list, chunks[0]); @@ -524,13 +617,22 @@ fn render_log_view_help(frame: &mut Frame, _app: &App) { " - By Project: Groups entries by project within each date", "", "Navigation:", - "- j/k or ↑/↓: Select entries", - "- PageUp/PageDown: Jump 10 entries", + "- j/k or ↑/↓: Navigate selection", + " - At Entry level: Move to next/previous entry", + " - At Project level: Jump to next/previous project group", + " - At Day level: Jump to next/previous day", + "- h/l or ←/→: Change selection level (Entry ↔ Project ↔ Day)", + "- PageUp/PageDown: Jump 10 entries (Entry level only)", + "", + "Selection Levels:", + "- Entry: Select individual entry (can edit/delete/copy)", + "- Project: Select whole project group (can copy only)", + "- Day: Select entire day (can copy only)", "", "Actions:", - "- e: Edit the selected entry (opens Watson's editor)", - "- x: Delete the selected entry (no confirmation)", - "- c: Copy all visible log entries to clipboard", + "- e: Edit the selected entry (Entry level only)", + "- x: Delete the selected entry (Entry level only)", + "- c: Copy selection to clipboard (works at all levels)", "", "Other:", "- ?: Show this help",