use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph, Row, Table, TableState}, Frame, }; use chrono::DateTime; use crate::{ app::{App, LogViewDayOrder, LogViewGrouping, LogViewPeriod, LogViewSelection, NewEntryMode, Screen}, state::TimeItem, }; const ACTIVE_COLOR: Color = Color::Green; pub fn render(frame: &mut Frame, app: &App) { match app.current_screen { Screen::Main => render_main(frame, app), Screen::Help => render_help(frame, app), Screen::ConfigHelp => render_config_help(frame, app), Screen::NewEntry => render_new_entry(frame, 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 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 } else { 1 // Just tracking or help hint } } 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); } 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()); 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, ); } } } fn render_new_entry(frame: &mut Frame, app: &App) { let area = centered_rect(60, 6, frame.size()); frame.render_widget(Clear, area); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Length(3)]) .split(area); // Tag input (first) let tag_block = Block::default() .title("Tag") .borders(Borders::ALL) .style( Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Task) { ACTIVE_COLOR } else { Color::White }), ); let tag_text = Paragraph::new(app.new_entry_project.as_str()).block(tag_block); frame.render_widget(tag_text, chunks[0]); // Project input (second) let project_block = Block::default() .title("Project") .borders(Borders::ALL) .style( Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Project) { ACTIVE_COLOR } else { Color::White }), ); let project_text = Paragraph::new(app.new_entry_buffer.as_str()).block(project_block); frame.render_widget(project_text, chunks[1]); // Render command bar let bar_area = Rect::new(0, frame.size().height - 1, frame.size().width, 1); let command_text = match app.new_entry_mode { NewEntryMode::Task => "Enter tag, press Enter to continue", NewEntryMode::Project => "Enter project, press Enter to save", }; let command_bar = Paragraph::new(command_text) .style(Style::default().fg(Color::White)) .alignment(Alignment::Left); frame.render_widget(command_bar, bar_area); } fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) { // Split the bottom bar into left (tracking indicator) and right (help/status) let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) .split(area); // Left side: Tracking indicator (only when actively tracking) if let Some((active_item, _)) = &app.state.active_timer { let tracking_text = if !active_item.tags.is_empty() { format!("❯❯ Tracking {} [{}] ❯❯", active_item.tags.join(", "), active_item.name) } else { format!("❯❯ Tracking {} ❯❯", active_item.name) }; let tracking = Paragraph::new(tracking_text) .alignment(Alignment::Left) .style(Style::default().fg(ACTIVE_COLOR).add_modifier(Modifier::BOLD)); frame.render_widget(tracking, chunks[0]); } // No else - show nothing when not tracking // Right side: Status message or help hint if let Some((ref message, _)) = app.status_message { let text = Paragraph::new(message.as_str()) .style(Style::default().fg(Color::Yellow)) .alignment(Alignment::Right); frame.render_widget(text, chunks[1]); } else if app.config.show_help_hint { render_help_hint(frame, chunks[1]); } } fn render_help_hint(frame: &mut Frame, area: Rect) { let help_hint = Paragraph::new("(?) for help") .alignment(Alignment::Right) .style(Style::default().fg(Color::DarkGray)); frame.render_widget(help_hint, area); } fn render_help_command_bar(frame: &mut Frame) { let commands = vec![("c", "configuration help"), ("q/ESC", "back")]; let command_text = format!( " {}", commands .iter() .map(|(key, desc)| format!("{} ({})", key, desc)) .collect::>() .join(" · ") ); let bar_area = Rect::new( 0, frame.size().height.saturating_sub(1), frame.size().width, 1, ); let command_bar = Paragraph::new(command_text) .style(Style::default().fg(Color::White)) .alignment(Alignment::Left); frame.render_widget(command_bar, bar_area); } fn render_help(frame: &mut Frame, app: &App) { let width = frame.size().width.saturating_sub(4).min(60); let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size()); frame.render_widget(Clear, area); let help_text = vec![ "WAT - Watson Time Tracker Interface", "", "This tool helps you track time using Watson with a convenient interface.", "", "The interface is divided into three sections:", "1. Permanent Items: Configured tasks that are always available", "2. Recurring Items: Frequently used tasks that you might return to", "3. Ad-Hoc Items: One-off tasks and quick entries", "", "Navigation:", "- j/k or ↑/↓: Move selection up/down (wraps between sections)", "- Ctrl+n/Ctrl+p: Switch between sections (Permanent/Recurring/Ad-Hoc)", "- Enter: Start/stop time tracking for selected item", "- n: Create a new task in the current section", "", "Main Commands:", "Enter - Start/stop timer", "x - Delete task from list", "p - Reassign project name", "v - View Watson log", "Ctrl+e - Edit tasks config file", "c - Edit app config file", "n - New task", "q - Quit", "? - Show/hide this help (ESC also works)", "", "Log View (press 'v'):", "Once in log view, you can:", "- Switch time periods: d (day), w (week), m (month)", "- Toggle grouping: g (by date or by project)", "- Toggle day order: r (newest first ↔ oldest first)", "- Change selection: h/l (entry → project → day → all)", "- Navigate: j/k to move through selections", "- Edit entry: e (entry level only)", "- Delete entry: x (entry level only)", "- Copy to clipboard: c (works at all levels)", "- Press ? for detailed log view help", ]; let text = help_text.join("\n"); let block = Block::default() .title("Help (j/k to scroll)") .borders(Borders::ALL) .style(Style::default().fg(Color::White)); let paragraph = Paragraph::new(text) .block(block) .style(Style::default().fg(Color::White)) .scroll((app.help_scroll as u16, 0)) .wrap(ratatui::widgets::Wrap { trim: true }); frame.render_widget(paragraph, area); render_help_command_bar(frame); } fn render_config_help(frame: &mut Frame, app: &App) { let width = frame.size().width.saturating_sub(4).min(60); let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size()); frame.render_widget(Clear, area); let help_text = vec![ "WAT Configuration", "", "Configuration file location:", " ~/.config/wat/config.yaml", "", "Available options:", "", "show_help_hint: true/false", " Default: true", " Shows '(?) for help' hint in the bottom-right corner", "", "projects: [list of project names]", " Default: [] (empty)", " List of available project names shown when reassigning", " Example: [\"work\", \"personal\", \"client-a\"]", "", "strict_projects: true/false", " Default: false", " 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", " - open-source", "", "Note: The config file is created automatically on first run.", "Edit it with 'c' from the main screen or manually with your editor.", ]; let text = help_text.join("\n"); let block = Block::default() .title("Configuration Help (j/k to scroll)") .borders(Borders::ALL) .style(Style::default().fg(Color::White)); let paragraph = Paragraph::new(text) .block(block) .style(Style::default().fg(Color::White)) .scroll((app.help_scroll as u16, 0)) .wrap(ratatui::widgets::Wrap { trim: true }); frame.render_widget(paragraph, area); render_help_command_bar(frame); } fn render_section( frame: &mut Frame, area: Rect, title: &str, items: &[TimeItem], is_active: bool, selected: usize, current_column: usize, multi_column: bool, ) { let block = Block::default() .borders(Borders::ALL) .title(title) .style(Style::default().fg(if is_active { ACTIVE_COLOR } else { Color::White })); // Check if we should use two-column layout let use_two_columns = multi_column && items.len() > 6; if use_two_columns { // Split items into two halves let mid_point = (items.len() + 1) / 2; let left_items = &items[..mid_point]; let right_items = &items[mid_point..]; // Create inner area for columns let inner_area = block.inner(area); let columns = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(inner_area); // Render the block first frame.render_widget(block, area); // Calculate which row to show (same for both columns) let row_in_column = if current_column == 0 { selected // Left column: row = selected index } else { selected.saturating_sub(mid_point) // Right column: row = index - mid_point }; // Left column let left_rows: Vec = left_items .iter() .map(|item| { let display = if !item.tags.is_empty() { format!("{} [{}]", item.tags.join(", "), item.name) } else { item.name.clone() }; Row::new(vec![display]) }) .collect(); let left_table = Table::new(left_rows) .widths(&[Constraint::Percentage(100)]) .highlight_style(if is_active && current_column == 0 { Style::default() .fg(ACTIVE_COLOR) .add_modifier(Modifier::REVERSED) } else { Style::default() // No highlight for inactive column }); // Both columns use the same row offset for synchronized scrolling let mut left_state = TableState::default(); if !left_items.is_empty() { // Always select the same row to force scrolling, but only highlight if active left_state.select(Some(row_in_column.min(left_items.len() - 1))); } // Right column let right_rows: Vec = right_items .iter() .map(|item| { let display = if !item.tags.is_empty() { format!("{} [{}]", item.tags.join(", "), item.name) } else { item.name.clone() }; Row::new(vec![display]) }) .collect(); let right_table = Table::new(right_rows) .widths(&[Constraint::Percentage(100)]) .highlight_style(if is_active && current_column == 1 { Style::default() .fg(ACTIVE_COLOR) .add_modifier(Modifier::REVERSED) } else { Style::default() // No highlight for inactive column }); let mut right_state = TableState::default(); if !right_items.is_empty() { // Always select the same row to force scrolling, but only highlight if active right_state.select(Some(row_in_column.min(right_items.len() - 1))); } frame.render_stateful_widget(left_table, columns[0], &mut left_state); frame.render_stateful_widget(right_table, columns[1], &mut right_state); } else { // Single column mode let rows: Vec = items .iter() .map(|item| { let display = if !item.tags.is_empty() { format!("{} [{}]", item.tags.join(", "), item.name) } else { item.name.clone() }; Row::new(vec![display]) }) .collect(); let table = Table::new(rows) .block(block) .widths(&[Constraint::Percentage(100)]) .highlight_style( Style::default() .fg(ACTIVE_COLOR) .add_modifier(Modifier::REVERSED), ); let mut table_state = TableState::default(); if !items.is_empty() && is_active { table_state.select(Some(selected.min(items.len() - 1))); } frame.render_stateful_widget(table, area, &mut table_state); } } fn centered_rect(width: u16, height: u16, r: Rect) -> Rect { let x = (r.width.saturating_sub(width)) / 2; let y = (r.height.saturating_sub(height)) / 2; Rect { x: r.x + x, y: r.y + y, width: width.min(r.width), height: height.min(r.height), } } fn render_reassign_project(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 item to show in title let items = match app.state.current_pane { 0 => &app.state.permanent_items, 1 => &app.state.recurring_items, 2 => &app.state.recent_items, _ => &app.state.permanent_items, }; let current_item_display = items .get(app.state.selected_indices[app.state.current_pane]) .map(|item| { if !item.tags.is_empty() { format!("{} [{}]", item.tags.join(", "), item.name) } else { item.name.clone() } }) .unwrap_or_default(); // Project input let project_block = Block::default() .title(format!("Reassign Project for: {}", current_item_display)) .borders(Borders::ALL) .style(Style::default().fg(ACTIVE_COLOR)); let project_text = if !app.config.projects.is_empty() { format!( "{} (available: {})", app.reassign_project_buffer, app.config.projects.join(", ") ) } else { app.reassign_project_buffer.clone() }; let project_paragraph = Paragraph::new(project_text).block(project_block); frame.render_widget(project_paragraph, chunks[0]); // Help text let help_text = Paragraph::new("Enter new project name, press Enter to save, Esc to cancel") .style(Style::default().fg(Color::DarkGray)) .alignment(Alignment::Center); frame.render_widget(help_text, chunks[1]); } fn render_log_view(frame: &mut Frame, app: &App) { let area = frame.size(); // Create the main content area (leave space for command bar at bottom) let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(3), Constraint::Length(1)]) .split(area); let period_str = match app.log_view_period { LogViewPeriod::Day => "Day", LogViewPeriod::Week => "Week", LogViewPeriod::Month => "31 Days", }; let grouping_str = match app.log_view_grouping { LogViewGrouping::ByDate => "by Date", LogViewGrouping::ByProject => "by Project", }; let order_str = match app.log_view_day_order { LogViewDayOrder::Chronological => "↑", 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 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 = { // 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(" ") && !line.is_empty() && !line.starts_with(" ") { current_date = line.clone(); } else if line.starts_with(" ") && !line.starts_with(" ") { 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 } else { line.starts_with(" ") && !line.starts_with(" ") // 4 spaces 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() { // Use is_entry_line logic which excludes separator lines let is_entry = if is_by_project { line.starts_with(" ") } else { line.starts_with(" ") && !line.starts_with(" ") && line.trim() != "---" }; 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(" ") && !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(" ") { 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(" ") { 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(" ") && !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; } } } LogViewSelection::All => { // Select everything selected_line_start = 0; selected_line_end = app.log_view_content.len().saturating_sub(1); } } // 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); } } } } } } } 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 { 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)]) }) .collect() }; // Use the scroll position calculated by the app let paragraph = Paragraph::new(text_lines) .block(block) .wrap(ratatui::widgets::Wrap { trim: false }) .scroll((app.log_view_scroll as u16, 0)); frame.render_widget(paragraph, chunks[0]); // Render help hint at bottom if enabled if app.config.show_help_hint { render_help_hint(frame, chunks[1]); } } fn render_log_view_help(frame: &mut Frame, app: &App) { let width = frame.size().width.saturating_sub(4).min(60); let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size()); frame.render_widget(Clear, area); let help_text = vec![ "Watson Log Viewer Help", "", "This view displays your Watson time tracking logs with various options", "for viewing and managing your tracked time.", "", "Time Periods:", "- d: Switch to Day view (current day)", "- w: Switch to Week view (current week)", "- m: Switch to Month view (last 31 days)", "", "Grouping:", "- g: Toggle between grouping by Date or by Project", " - By Date: Shows all entries chronologically", " - By Project: Groups entries by project within each date", "", "Day Order:", "- r: Toggle day order between newest first (↓) and oldest first (↑)", " - 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", " - At Project level: Jump to next/previous project group", " - At Day level: Jump to next/previous day", " - At All level: No navigation (entire view selected)", "- h/l or ←/→: Change selection level", " - l (right): Zoom in (All → Day → Project → Entry)", " - h (left): Zoom out (Entry → Project → Day → All)", "- PageUp/PageDown: Jump 10 entries (Entry level only)", "", "Selection Levels (use h/l to change):", "- Entry: Select individual entry (can edit/delete/copy)", "- Project: Select whole project group (can copy only)", "- Day: Select entire day (can copy only)", "- All: Select entire view/period (can copy only)", "", "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", "", "Tip: Use h/l to quickly select larger blocks for copying!", ]; let text = help_text.join("\n"); let block = Block::default() .title("Log View Help (j/k to scroll)") .borders(Borders::ALL) .style(Style::default().fg(Color::White)); let paragraph = Paragraph::new(text) .block(block) .style(Style::default().fg(Color::White)) .scroll((app.help_scroll as u16, 0)) .wrap(ratatui::widgets::Wrap { trim: true }); frame.render_widget(paragraph, area); // Render command bar let bar_area = Rect::new( 0, frame.size().height.saturating_sub(1), frame.size().width, 1, ); let command_bar = Paragraph::new(" q/ESC/? (back to log view)") .style(Style::default().fg(Color::White)) .alignment(Alignment::Left); 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]); }