1064 lines
38 KiB
Rust
1064 lines
38 KiB
Rust
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(¤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()
|
||
};
|
||
|
||
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]);
|
||
}
|