wat/src/ui.rs
2025-11-25 18:01:00 -05:00

818 lines
28 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, List, ListItem, Paragraph, Row, Table, TableState},
Frame,
};
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),
}
}
fn render_main(frame: &mut Frame, app: &App) {
// Calculate layout - accounting for bottom bar if needed
let show_bottom_bar = app.config.show_help_hint;
let has_status = app.status_message.is_some();
let bottom_height = if show_bottom_bar {
if has_status {
2
} else {
1
}
} else {
if has_status {
1
} else {
0
}
};
let constraints = if bottom_height > 0 {
vec![
Constraint::Min(3), // At least 3 lines for each section
Constraint::Min(3),
Constraint::Min(3),
Constraint::Length(bottom_height), // Command bar + optional status
]
} else {
vec![Constraint::Min(3), Constraint::Min(3), Constraint::Min(3)]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.size());
let main_height = if bottom_height > 0 {
chunks[0].height + chunks[1].height + chunks[2].height
} else {
frame.size().height
};
let section_height = main_height / 3;
// Create sections with equal height
let sections = vec![
Rect::new(0, 0, frame.size().width, section_height),
Rect::new(0, section_height, frame.size().width, section_height),
Rect::new(0, section_height * 2, frame.size().width, section_height),
];
// Render main sections
render_section(
frame,
sections[0],
"Permanent Items",
&app.state.permanent_items,
app.state.current_pane == 0,
app.state.selected_indices[0],
app.state.current_column,
app.config.multi_column,
);
render_section(
frame,
sections[1],
"Recurring Items",
&app.state.recurring_items,
app.state.current_pane == 1,
app.state.selected_indices[1],
app.state.current_column,
app.config.multi_column,
);
render_section(
frame,
sections[2],
"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 if needed
if bottom_height > 0 {
let bottom_area = chunks[3];
render_bottom_bar(frame, bottom_area, app);
}
}
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",
"d - 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",
"",
"Example configuration:",
"---",
"show_help_hint: true",
"strict_projects: false",
"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 => "Month",
};
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 title = format!("Watson Log - {} View ({}) [{}]", period_str, grouping_str, order_str);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.style(Style::default().fg(ACTIVE_COLOR));
// Build list items with selection highlighting based on selection level
let items: Vec<ListItem> = {
// Pre-calculate which line indices should be highlighted
let mut selected_date = String::new();
let mut selected_project = String::new();
let mut frame_count = 0;
let mut selected_line_start = 0;
let mut selected_line_end = 0;
// First pass: find the date/project containing the selected frame
let mut current_date = String::new();
let mut current_project = String::new();
// Determine what counts as an entry based on grouping mode
let is_by_project = matches!(app.log_view_grouping, LogViewGrouping::ByProject);
for (_idx, line) in app.log_view_content.iter().enumerate() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_date = line.clone();
} else if line.starts_with(" ") && !line.starts_with("\t") {
current_project = line.clone();
}
// Only count actual entries (not all tab-indented lines)
let is_entry = if is_by_project {
line.starts_with("\t\t") // Double tab for ByProject
} else {
line.starts_with('\t') && !line.starts_with("\t\t") // Single tab for ByDate
};
if is_entry {
if frame_count == app.log_view_selected {
selected_date = current_date.clone();
selected_project = current_project.clone();
break;
}
frame_count += 1;
}
}
// Second pass: determine the range of lines to highlight
match app.log_view_selection_level {
LogViewSelection::Entry => {
// Just find the specific entry line
frame_count = 0;
for (idx, line) in app.log_view_content.iter().enumerate() {
let is_entry = if is_by_project {
line.starts_with("\t\t")
} else {
line.starts_with('\t') && !line.starts_with("\t\t")
};
if is_entry {
if frame_count == app.log_view_selected {
selected_line_start = idx;
selected_line_end = idx;
break;
}
frame_count += 1;
}
}
}
LogViewSelection::Project => {
// Find the range of the selected project (within the same day)
let mut in_target_project = false;
current_date = String::new();
for (idx, line) in app.log_view_content.iter().enumerate() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_date = line.clone();
if in_target_project {
break; // End of project group
}
} else if line.starts_with(" ") && !line.starts_with("\t") {
if current_date == selected_date && line == &selected_project {
selected_line_start = idx;
in_target_project = true;
} else if in_target_project {
break; // Different project
}
} else if in_target_project && line.starts_with('\t') {
selected_line_end = idx;
}
}
}
LogViewSelection::Day => {
// Find the range of the selected day
let mut in_target_day = false;
for (idx, line) in app.log_view_content.iter().enumerate() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
if line == &selected_date {
selected_line_start = idx;
in_target_day = true;
} else if in_target_day {
break; // End of day
}
} else if in_target_day && !line.is_empty() {
selected_line_end = idx;
}
}
}
LogViewSelection::All => {
// Select everything
selected_line_start = 0;
selected_line_end = app.log_view_content.len().saturating_sub(1);
}
}
// Third pass: render with highlighting
app.log_view_content
.iter()
.enumerate()
.map(|(idx, line)| {
let is_selected = idx >= selected_line_start && idx <= selected_line_end;
let style = if is_selected {
Style::default()
.fg(ACTIVE_COLOR)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(Color::White)
};
ListItem::new(Line::from(vec![Span::styled(line.clone(), style)]))
})
.collect()
};
let list = List::new(items).block(block);
frame.render_widget(list, 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 (current month)",
"",
"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)",
"",
"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)",
"- x: Delete the selected entry (Entry level only)",
"- c: Copy selection to clipboard (works at all levels)",
" Copies based on current selection level",
"",
"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);
}