wat/src/ui.rs

1064 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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::<Vec<_>>()
.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<Row> = 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<Row> = 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<Row> = 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<Line> = {
// 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(&current.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()
};
let paragraph = Paragraph::new(text_lines)
.block(block)
.wrap(ratatui::widgets::Wrap { trim: false });
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]);
}