diff --git a/src/app.rs b/src/app.rs index 5607c92..71e6c63 100644 --- a/src/app.rs +++ b/src/app.rs @@ -36,11 +36,13 @@ pub enum LogViewDayOrder { ReverseChronological, // Newest first (default) } +#[derive(Debug, Copy, Clone)] pub enum LogViewGrouping { ByDate, ByProject, } +#[derive(Debug, Copy, Clone)] pub enum LogViewSelection { Entry, // Individual entry selected Project, // Whole project selected (ByProject mode only) @@ -101,7 +103,7 @@ impl App { } // Helper to determine if a line is an entry line based on grouping mode - fn is_entry_line(&self, line: &str) -> bool { + pub fn is_entry_line(&self, line: &str) -> bool { // Exclude separator lines (time gap markers) if line.trim() == "---" { return false; @@ -489,6 +491,7 @@ impl App { self.needs_clear = true; } KeyCode::Char('j') | KeyCode::Down => { + let old_selected = self.log_view_selected; match self.log_view_selection_level { LogViewSelection::Entry => { // Move to next entry @@ -508,8 +511,13 @@ impl App { // No navigation at All level - already selecting everything } } + // Update scroll if selection changed + if self.log_view_selected != old_selected { + self.update_log_view_scroll(); + } } KeyCode::Char('k') | KeyCode::Up => { + let old_selected = self.log_view_selected; match self.log_view_selection_level { LogViewSelection::Entry => { // Move to previous entry @@ -529,6 +537,10 @@ impl App { // No navigation at All level - already selecting everything } } + // Update scroll if selection changed + if self.log_view_selected != old_selected { + self.update_log_view_scroll(); + } } KeyCode::Char('e') => { // Only allow edit when selecting individual entry @@ -561,6 +573,7 @@ impl App { } KeyCode::Char('h') | KeyCode::Left => { // Zoom out selection level + let old_level = self.log_view_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, @@ -571,6 +584,7 @@ impl App { } KeyCode::Char('l') | KeyCode::Right => { // Zoom in selection level + let old_level = self.log_view_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, @@ -612,9 +626,11 @@ impl App { KeyCode::PageDown => { self.log_view_selected = (self.log_view_selected + 10) .min(self.log_view_frame_indices.len().saturating_sub(1)); + self.update_log_view_scroll(); } KeyCode::PageUp => { self.log_view_selected = self.log_view_selected.saturating_sub(10); + self.update_log_view_scroll(); } _ => {} }, @@ -633,6 +649,9 @@ impl App { LogViewGrouping::ByDate => self.format_by_date(), LogViewGrouping::ByProject => self.format_by_project(), } + + // Update scroll position after formatting + self.update_log_view_scroll(); } fn format_by_date(&mut self) { @@ -1037,10 +1056,15 @@ impl App { } }; + // Debug what we're copying + std::fs::write("/tmp/wat_copy_debug.log", format!("Selection level: {:?}\nSelected index: {}\nText to copy:\n{}\n", self.log_view_selection_level, self.log_view_selected, text)).ok(); + // 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 { + self.set_status_message(format!("Copied {} chars to clipboard", text.len())); } } else { // Try to create a new clipboard if we don't have one @@ -1048,6 +1072,8 @@ impl App { Ok(mut clipboard) => { if let Err(e) = clipboard.set_text(&text) { self.set_status_message(format!("Failed to copy: {}", e)); + } else { + self.set_status_message(format!("Copied {} chars to clipboard", text.len())); } self.clipboard = Some(clipboard); } @@ -1076,30 +1102,70 @@ impl App { } 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; + // Find all project headers and their entry ranges + let mut projects = Vec::new(); // (first_entry_index, project_line_index) + let mut entry_count = 0; + let mut expecting_first_entry = false; + let mut current_project_line_index = 0; - for line in &self.log_view_content { - if line.starts_with(" ") && !line.starts_with("\t") { + for (line_index, line) in self.log_view_content.iter().enumerate() { + if line.starts_with(" ") && !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()); + current_project_line_index = line_index; + expecting_first_entry = true; } 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; + if expecting_first_entry { + // This is the first entry of a project + projects.push((entry_count, current_project_line_index)); + expecting_first_entry = false; } - frame_count += 1; + entry_count += 1; } } - current_project_lines.join("\n") + // Find which project contains our current selection + let mut target_project_line_index = None; + for i in 0..projects.len() { + let project_start = projects[i].0; + let project_end = if i + 1 < projects.len() { + projects[i + 1].0 + } else { + entry_count + }; + + if project_start <= self.log_view_selected && self.log_view_selected < project_end { + target_project_line_index = Some(projects[i].1); + break; + } + } + + if let Some(target_line_index) = target_project_line_index { + // Collect all lines for the target project by position + let mut project_lines = Vec::new(); + let mut in_target_project = false; + let mut current_project_line_index = 0; + + for (line_index, line) in self.log_view_content.iter().enumerate() { + if line.starts_with(" ") && !line.starts_with(" ") && !line.starts_with("\t") { + // This is a project header + current_project_line_index = line_index; + if line_index == target_line_index { + in_target_project = true; + project_lines.push(line.clone()); + } else if in_target_project { + // Hit a different project header, we're done + break; + } + } else if in_target_project && self.is_entry_line(line) { + // Entry within our target project + project_lines.push(line.clone()); + } + } + + project_lines.join("\n") + } else { + String::new() + } } fn get_selected_day_text(&self) -> String { @@ -1134,177 +1200,179 @@ impl App { } 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 all project headers and their first entry positions + let mut projects = Vec::new(); // (first_entry_index) + let mut entry_count = 0; + let mut expecting_first_entry = 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; + for line in &self.log_view_content { + if line.starts_with(" ") && !line.starts_with(" ") && !line.starts_with("\t") { + // This is a project header - next entry will be first entry of this project + expecting_first_entry = true; + } else if self.is_entry_line(line) { + if expecting_first_entry { + // This is the first entry of a project + projects.push(entry_count); + expecting_first_entry = false; } - frame_count += 1; + entry_count += 1; } } - if !found_current { - return; - } + // Find which project contains our current selection and jump to next + for i in 0..projects.len() { + let project_start = projects[i]; + let project_end = if i + 1 < projects.len() { + projects[i + 1] + } else { + entry_count + }; - // 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 project_start <= self.log_view_selected && self.log_view_selected < project_end { + // Found current project, jump to next if it exists + if i + 1 < projects.len() { + self.log_view_selected = projects[i + 1]; } - } - - if self.is_entry_line(line) { - if current_project == selected_project && current_date == selected_date { - past_selected = true; - } - frame_count += 1; + return; } } } 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 all project headers and their first entry positions + let mut projects = Vec::new(); // (first_entry_index) + let mut entry_count = 0; + let mut expecting_first_entry = false; - // 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; + for line in &self.log_view_content { + if line.starts_with(" ") && !line.starts_with(" ") && !line.starts_with("\t") { + // This is a project header - next entry will be first entry of this project + expecting_first_entry = true; + } else if self.is_entry_line(line) { + if expecting_first_entry { + // This is the first entry of a project + projects.push(entry_count); + expecting_first_entry = false; } - current_project = line.clone(); - previous_project_first_entry = Some(frame_count); + entry_count += 1; } + } - 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; + // Find which project contains our current selection and jump to previous + for i in 0..projects.len() { + let project_start = projects[i]; + let project_end = if i + 1 < projects.len() { + projects[i + 1] + } else { + entry_count + }; + + if project_start <= self.log_view_selected && self.log_view_selected < project_end { + // Found current project, jump to previous if it exists + if i > 0 { + self.log_view_selected = projects[i - 1]; } - frame_count += 1; + return; } } } - 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(); + fn update_log_view_scroll(&mut self) { + // Calculate which content line the selected entry is on + let mut content_line: usize = 0; 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(); - } - + for line in &self.log_view_content { 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; } + content_line += 1; } - if !found_current { - return; - } + // Center the selection in the viewport + // Assume a reasonable viewport height for centering calculation + let viewport_center_offset: usize = 10; // Lines from top to center selection + self.log_view_scroll = content_line.saturating_sub(viewport_center_offset); + } - // Now find the next day and jump to its first entry - current_date = String::new(); - let mut past_selected = false; - frame_count = 0; + fn jump_to_next_day(&mut self) { + // Find all day headers and their first entry positions + let mut days = Vec::new(); // (first_entry_index) + let mut entry_count = 0; + let mut current_day_seen = false; - for line in &self.log_view_content.clone() { + for line in &self.log_view_content { 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; - } + // This is a day header - mark that we'll record the next entry + current_day_seen = true; + } else if self.is_entry_line(line) && current_day_seen { + // This is the first entry of a day + days.push(entry_count); + current_day_seen = false; } 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; + entry_count += 1; + } + } + + // Find which day contains our current selection and jump to next + for i in 0..days.len() { + let day_start = days[i]; + let day_end = if i + 1 < days.len() { + days[i + 1] + } else { + entry_count + }; + + if day_start <= self.log_view_selected && self.log_view_selected < day_end { + // Found current day, jump to next if it exists + if i + 1 < days.len() { + self.log_view_selected = days[i + 1]; } - frame_count += 1; + return; } } } 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 all day headers and their first entry positions + let mut days = Vec::new(); // (first_entry_index) + let mut entry_count = 0; + let mut current_day_seen = false; - // Find which day we're currently in - for line in &self.log_view_content.clone() { + for line in &self.log_view_content { 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); + // This is a day header - mark that we'll record the next entry + current_day_seen = true; + } else if self.is_entry_line(line) && current_day_seen { + // This is the first entry of a day + days.push(entry_count); + current_day_seen = false; } 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; + entry_count += 1; + } + } + + // Find which day contains our current selection and jump to previous + for i in 0..days.len() { + let day_start = days[i]; + let day_end = if i + 1 < days.len() { + days[i + 1] + } else { + entry_count + }; + + if day_start <= self.log_view_selected && self.log_view_selected < day_end { + // Found current day, jump to previous if it exists + if i > 0 { + self.log_view_selected = days[i - 1]; } - frame_count += 1; + return; } } } diff --git a/src/ui.rs b/src/ui.rs index b56f424..4f28eaa 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -901,9 +901,11 @@ fn render_log_view(frame: &mut Frame, app: &App) { .collect() }; + // Use the scroll position calculated by the app let paragraph = Paragraph::new(text_lines) .block(block) - .wrap(ratatui::widgets::Wrap { trim: false }); + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((app.log_view_scroll as u16, 0)); frame.render_widget(paragraph, chunks[0]);