2025-11-16 09:57:01 -05:00
|
|
|
use ratatui::{
|
2025-11-16 13:12:45 -05:00
|
|
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
2025-11-16 09:57:01 -05:00
|
|
|
style::{Color, Modifier, Style},
|
|
|
|
|
text::{Line, Span},
|
2025-11-16 13:12:45 -05:00
|
|
|
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
|
2025-11-16 09:57:01 -05:00
|
|
|
Frame,
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-16 13:12:45 -05:00
|
|
|
use crate::{
|
2025-11-23 12:50:31 -05:00
|
|
|
app::{App, LogViewDayOrder, LogViewGrouping, LogViewPeriod, LogViewSelection, NewEntryMode, Screen},
|
2025-11-16 13:12:45 -05:00
|
|
|
state::{AppState, TimeItem},
|
|
|
|
|
};
|
2025-11-16 09:57:01 -05:00
|
|
|
|
|
|
|
|
const ACTIVE_COLOR: Color = Color::Green;
|
|
|
|
|
const INACTIVE_COLOR: Color = Color::Yellow;
|
|
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
pub fn render(frame: &mut Frame, app: &App) {
|
|
|
|
|
match app.current_screen {
|
|
|
|
|
Screen::Main => render_main(frame, app),
|
2025-11-16 10:48:06 -05:00
|
|
|
Screen::Help => render_help(frame, app),
|
|
|
|
|
Screen::ConfigHelp => render_config_help(frame, app),
|
2025-11-16 11:41:05 -05:00
|
|
|
Screen::NewEntry => render_new_entry(frame, app),
|
2025-11-22 10:45:10 -05:00
|
|
|
Screen::ReassignProject => render_reassign_project(frame, app),
|
2025-11-22 11:12:46 -05:00
|
|
|
Screen::LogView => render_log_view(frame, app),
|
2025-11-22 11:57:28 -05:00
|
|
|
Screen::LogViewHelp => render_log_view_help(frame, app),
|
2025-11-16 10:19:14 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_main(frame: &mut Frame, app: &App) {
|
2025-11-16 10:48:06 -05:00
|
|
|
// Calculate layout - accounting for bottom bar if needed
|
2025-11-22 12:02:11 -05:00
|
|
|
let show_bottom_bar = app.config.show_help_hint;
|
2025-11-16 12:25:22 -05:00
|
|
|
let has_status = app.status_message.is_some();
|
|
|
|
|
let bottom_height = if show_bottom_bar {
|
2025-11-16 13:12:45 -05:00
|
|
|
if has_status {
|
|
|
|
|
2
|
|
|
|
|
} else {
|
|
|
|
|
1
|
|
|
|
|
}
|
2025-11-16 12:25:22 -05:00
|
|
|
} else {
|
2025-11-16 13:12:45 -05:00
|
|
|
if has_status {
|
|
|
|
|
1
|
|
|
|
|
} else {
|
|
|
|
|
0
|
|
|
|
|
}
|
2025-11-16 12:25:22 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let constraints = if bottom_height > 0 {
|
2025-11-16 10:48:06 -05:00
|
|
|
vec![
|
2025-11-16 13:12:45 -05:00
|
|
|
Constraint::Min(3), // At least 3 lines for each section
|
2025-11-16 10:48:06 -05:00
|
|
|
Constraint::Min(3),
|
|
|
|
|
Constraint::Min(3),
|
2025-11-16 13:12:45 -05:00
|
|
|
Constraint::Length(bottom_height), // Command bar + optional status
|
2025-11-16 10:48:06 -05:00
|
|
|
]
|
|
|
|
|
} else {
|
2025-11-16 13:12:45 -05:00
|
|
|
vec![Constraint::Min(3), Constraint::Min(3), Constraint::Min(3)]
|
2025-11-16 10:48:06 -05:00
|
|
|
};
|
|
|
|
|
|
2025-11-16 09:57:01 -05:00
|
|
|
let chunks = Layout::default()
|
|
|
|
|
.direction(Direction::Vertical)
|
2025-11-16 10:48:06 -05:00
|
|
|
.constraints(constraints)
|
2025-11-16 09:57:01 -05:00
|
|
|
.split(frame.size());
|
|
|
|
|
|
2025-11-16 12:25:22 -05:00
|
|
|
let main_height = if bottom_height > 0 {
|
2025-11-16 10:48:06 -05:00
|
|
|
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
|
2025-11-16 09:57:01 -05:00
|
|
|
render_section(
|
|
|
|
|
frame,
|
2025-11-16 10:48:06 -05:00
|
|
|
sections[0],
|
2025-11-16 09:57:01 -05:00
|
|
|
"Permanent Items",
|
2025-11-16 10:19:14 -05:00
|
|
|
&app.state.permanent_items,
|
|
|
|
|
app.state.current_pane == 0,
|
|
|
|
|
app.state.selected_indices[0],
|
|
|
|
|
&app.state,
|
2025-11-16 09:57:01 -05:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
render_section(
|
|
|
|
|
frame,
|
2025-11-16 10:48:06 -05:00
|
|
|
sections[1],
|
2025-11-16 09:57:01 -05:00
|
|
|
"Recurring Items",
|
2025-11-16 10:19:14 -05:00
|
|
|
&app.state.recurring_items,
|
|
|
|
|
app.state.current_pane == 1,
|
|
|
|
|
app.state.selected_indices[1],
|
|
|
|
|
&app.state,
|
2025-11-16 09:57:01 -05:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
render_section(
|
|
|
|
|
frame,
|
2025-11-16 10:48:06 -05:00
|
|
|
sections[2],
|
2025-11-16 11:41:05 -05:00
|
|
|
"Ad-Hoc Items",
|
2025-11-16 10:19:14 -05:00
|
|
|
&app.state.recent_items,
|
|
|
|
|
app.state.current_pane == 2,
|
|
|
|
|
app.state.selected_indices[2],
|
|
|
|
|
&app.state,
|
2025-11-16 09:57:01 -05:00
|
|
|
);
|
2025-11-16 10:19:14 -05:00
|
|
|
|
2025-11-16 10:48:06 -05:00
|
|
|
// Render bottom bar if needed
|
2025-11-16 12:25:22 -05:00
|
|
|
if bottom_height > 0 {
|
|
|
|
|
let bottom_area = chunks[3];
|
2025-11-16 10:48:06 -05:00
|
|
|
render_bottom_bar(frame, bottom_area, app);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 11:41:05 -05:00
|
|
|
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)
|
2025-11-16 13:12:45 -05:00
|
|
|
.constraints([Constraint::Length(3), Constraint::Length(3)])
|
2025-11-16 11:41:05 -05:00
|
|
|
.split(area);
|
|
|
|
|
|
2025-11-22 11:53:43 -05:00
|
|
|
// Tag input (first)
|
|
|
|
|
let tag_block = Block::default()
|
|
|
|
|
.title("Tag")
|
2025-11-16 11:41:05 -05:00
|
|
|
.borders(Borders::ALL)
|
2025-11-16 13:12:45 -05:00
|
|
|
.style(
|
|
|
|
|
Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Task) {
|
|
|
|
|
ACTIVE_COLOR
|
|
|
|
|
} else {
|
|
|
|
|
Color::White
|
|
|
|
|
}),
|
|
|
|
|
);
|
2025-11-16 11:41:05 -05:00
|
|
|
|
2025-11-22 11:53:43 -05:00
|
|
|
let tag_text = Paragraph::new(app.new_entry_project.as_str()).block(tag_block);
|
2025-11-16 11:41:05 -05:00
|
|
|
|
2025-11-22 11:53:43 -05:00
|
|
|
frame.render_widget(tag_text, chunks[0]);
|
2025-11-16 11:41:05 -05:00
|
|
|
|
2025-11-22 11:53:43 -05:00
|
|
|
// Project input (second)
|
2025-11-16 11:41:05 -05:00
|
|
|
let project_block = Block::default()
|
2025-11-22 11:53:43 -05:00
|
|
|
.title("Project")
|
2025-11-16 11:41:05 -05:00
|
|
|
.borders(Borders::ALL)
|
2025-11-16 13:12:45 -05:00
|
|
|
.style(
|
|
|
|
|
Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Project) {
|
|
|
|
|
ACTIVE_COLOR
|
|
|
|
|
} else {
|
|
|
|
|
Color::White
|
|
|
|
|
}),
|
|
|
|
|
);
|
2025-11-16 11:41:05 -05:00
|
|
|
|
2025-11-22 11:53:43 -05:00
|
|
|
let project_text = Paragraph::new(app.new_entry_buffer.as_str()).block(project_block);
|
2025-11-16 11:41:05 -05:00
|
|
|
|
|
|
|
|
frame.render_widget(project_text, chunks[1]);
|
|
|
|
|
|
|
|
|
|
// Render command bar
|
2025-11-16 13:12:45 -05:00
|
|
|
let bar_area = Rect::new(0, frame.size().height - 1, frame.size().width, 1);
|
2025-11-16 11:41:05 -05:00
|
|
|
|
|
|
|
|
let command_text = match app.new_entry_mode {
|
2025-11-22 11:53:43 -05:00
|
|
|
NewEntryMode::Task => "Enter tag, press Enter to continue",
|
|
|
|
|
NewEntryMode::Project => "Enter project, press Enter to save",
|
2025-11-16 11:41:05 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let command_bar = Paragraph::new(command_text)
|
|
|
|
|
.style(Style::default().fg(Color::White))
|
|
|
|
|
.alignment(Alignment::Left);
|
|
|
|
|
|
|
|
|
|
frame.render_widget(command_bar, bar_area);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 10:48:06 -05:00
|
|
|
fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) {
|
2025-11-16 12:25:22 -05:00
|
|
|
// Render status message if present
|
|
|
|
|
if let Some((ref message, _)) = app.status_message {
|
|
|
|
|
let text = Paragraph::new(message.as_str())
|
|
|
|
|
.style(Style::default().fg(Color::Yellow))
|
|
|
|
|
.alignment(Alignment::Center);
|
2025-11-22 12:02:11 -05:00
|
|
|
frame.render_widget(text, area);
|
|
|
|
|
} else if app.config.show_help_hint {
|
|
|
|
|
render_help_hint(frame, area);
|
2025-11-16 12:25:22 -05:00
|
|
|
}
|
2025-11-22 12:02:11 -05:00
|
|
|
}
|
2025-11-16 12:25:22 -05:00
|
|
|
|
2025-11-22 12:02:11 -05:00
|
|
|
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);
|
2025-11-16 09:57:01 -05:00
|
|
|
}
|
|
|
|
|
|
2025-11-16 10:48:06 -05:00
|
|
|
fn render_help_command_bar(frame: &mut Frame) {
|
2025-11-16 13:12:45 -05:00
|
|
|
let commands = vec![("c", "configuration help"), ("q/ESC", "back")];
|
|
|
|
|
|
|
|
|
|
let command_text = format!(
|
|
|
|
|
" {}",
|
|
|
|
|
commands
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(key, desc)| format!("{} ({})", key, desc))
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join(" · ")
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-16 10:48:06 -05:00
|
|
|
let bar_area = Rect::new(
|
|
|
|
|
0,
|
|
|
|
|
frame.size().height.saturating_sub(1),
|
|
|
|
|
frame.size().width,
|
|
|
|
|
1,
|
|
|
|
|
);
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:48:06 -05:00
|
|
|
let command_bar = Paragraph::new(command_text)
|
|
|
|
|
.style(Style::default().fg(Color::White))
|
|
|
|
|
.alignment(Alignment::Left);
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:48:06 -05:00
|
|
|
frame.render_widget(command_bar, bar_area);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-23 12:21:03 -05:00
|
|
|
fn render_help(frame: &mut Frame, app: &App) {
|
2025-11-16 10:55:48 -05:00
|
|
|
let width = frame.size().width.saturating_sub(4).min(60);
|
|
|
|
|
let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size());
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
frame.render_widget(Clear, area);
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
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",
|
2025-11-16 11:41:05 -05:00
|
|
|
"3. Ad-Hoc Items: One-off tasks and quick entries",
|
2025-11-16 10:19:14 -05:00
|
|
|
"",
|
|
|
|
|
"Navigation:",
|
2025-11-23 12:21:03 -05:00
|
|
|
"- j/k or ↑/↓: Move selection up/down",
|
|
|
|
|
"- h/l or ←/→: Switch between panes",
|
|
|
|
|
"- Ctrl+n/Ctrl+p: Switch between panes",
|
|
|
|
|
"- Enter: Start/stop time tracking for selected item",
|
|
|
|
|
"- n: Create a new task in the current section",
|
2025-11-16 10:19:14 -05:00
|
|
|
"",
|
|
|
|
|
"Main Commands:",
|
|
|
|
|
"Enter - Start/stop timer",
|
2025-11-23 12:21:03 -05:00
|
|
|
"d - Delete task from list",
|
|
|
|
|
"p - Reassign project/tag",
|
|
|
|
|
"v - View Watson log",
|
|
|
|
|
"Ctrl+e - Edit tasks config file",
|
|
|
|
|
"c - Edit app config file",
|
2025-11-16 11:41:05 -05:00
|
|
|
"n - New task",
|
2025-11-16 10:19:14 -05:00
|
|
|
"q - Quit",
|
2025-11-23 12:21:03 -05:00
|
|
|
"? - 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)",
|
2025-11-23 12:50:31 -05:00
|
|
|
"- Toggle day order: r (newest first ↔ oldest first)",
|
2025-11-23 12:21:03 -05:00
|
|
|
"- 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",
|
2025-11-16 10:19:14 -05:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let text = help_text.join("\n");
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
let block = Block::default()
|
2025-11-23 12:21:03 -05:00
|
|
|
.title("Help (j/k to scroll)")
|
2025-11-16 10:19:14 -05:00
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.style(Style::default().fg(Color::White));
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
let paragraph = Paragraph::new(text)
|
|
|
|
|
.block(block)
|
2025-11-16 10:55:48 -05:00
|
|
|
.style(Style::default().fg(Color::White))
|
2025-11-23 12:21:03 -05:00
|
|
|
.scroll((app.help_scroll as u16, 0))
|
2025-11-16 10:55:48 -05:00
|
|
|
.wrap(ratatui::widgets::Wrap { trim: true });
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
frame.render_widget(paragraph, area);
|
2025-11-16 10:48:06 -05:00
|
|
|
render_help_command_bar(frame);
|
2025-11-16 10:19:14 -05:00
|
|
|
}
|
|
|
|
|
|
2025-11-23 12:21:03 -05:00
|
|
|
fn render_config_help(frame: &mut Frame, app: &App) {
|
2025-11-16 10:55:48 -05:00
|
|
|
let width = frame.size().width.saturating_sub(4).min(60);
|
|
|
|
|
let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size());
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
frame.render_widget(Clear, area);
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
let help_text = vec![
|
|
|
|
|
"WAT Configuration",
|
|
|
|
|
"",
|
2025-11-23 12:21:03 -05:00
|
|
|
"Configuration file location:",
|
|
|
|
|
" ~/.config/wat/config.yaml",
|
|
|
|
|
"",
|
|
|
|
|
"Available options:",
|
2025-11-16 10:19:14 -05:00
|
|
|
"",
|
|
|
|
|
"show_help_hint: true/false",
|
2025-11-23 12:21:03 -05:00
|
|
|
" Default: true",
|
|
|
|
|
" Shows '(?) for help' hint in the bottom-right corner",
|
2025-11-16 10:48:06 -05:00
|
|
|
"",
|
2025-11-23 12:21:03 -05:00
|
|
|
"projects: [list of project names]",
|
|
|
|
|
" Default: [] (empty)",
|
|
|
|
|
" List of available project names shown when reassigning",
|
|
|
|
|
" Example: [\"work\", \"personal\", \"client-a\"]",
|
2025-11-16 11:41:05 -05:00
|
|
|
"",
|
|
|
|
|
"strict_projects: true/false",
|
2025-11-23 12:21:03 -05:00
|
|
|
" Default: false",
|
|
|
|
|
" When true, only allows projects from the 'projects' list",
|
|
|
|
|
" When false, any project name can be used",
|
2025-11-16 11:41:05 -05:00
|
|
|
"",
|
2025-11-16 10:19:14 -05:00
|
|
|
"Example configuration:",
|
2025-11-23 12:21:03 -05:00
|
|
|
"---",
|
2025-11-16 10:19:14 -05:00
|
|
|
"show_help_hint: true",
|
2025-11-16 11:41:05 -05:00
|
|
|
"strict_projects: false",
|
2025-11-23 12:21:03 -05:00
|
|
|
"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.",
|
2025-11-16 10:19:14 -05:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let text = help_text.join("\n");
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
let block = Block::default()
|
2025-11-23 12:21:03 -05:00
|
|
|
.title("Configuration Help (j/k to scroll)")
|
2025-11-16 10:19:14 -05:00
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.style(Style::default().fg(Color::White));
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
let paragraph = Paragraph::new(text)
|
|
|
|
|
.block(block)
|
2025-11-16 10:55:48 -05:00
|
|
|
.style(Style::default().fg(Color::White))
|
2025-11-23 12:21:03 -05:00
|
|
|
.scroll((app.help_scroll as u16, 0))
|
2025-11-16 10:55:48 -05:00
|
|
|
.wrap(ratatui::widgets::Wrap { trim: true });
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
frame.render_widget(paragraph, area);
|
2025-11-16 10:48:06 -05:00
|
|
|
render_help_command_bar(frame);
|
2025-11-16 10:19:14 -05:00
|
|
|
}
|
|
|
|
|
|
2025-11-16 11:41:05 -05:00
|
|
|
fn render_section(
|
|
|
|
|
frame: &mut Frame,
|
|
|
|
|
area: Rect,
|
|
|
|
|
title: &str,
|
|
|
|
|
items: &[TimeItem],
|
|
|
|
|
is_active: bool,
|
|
|
|
|
selected: usize,
|
|
|
|
|
state: &AppState,
|
|
|
|
|
) {
|
2025-11-16 13:12:45 -05:00
|
|
|
let border_color = if is_active {
|
|
|
|
|
ACTIVE_COLOR
|
|
|
|
|
} else {
|
|
|
|
|
INACTIVE_COLOR
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-16 11:41:05 -05:00
|
|
|
let block = Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.title(title)
|
|
|
|
|
.style(Style::default().fg(border_color));
|
|
|
|
|
|
|
|
|
|
let items: Vec<ListItem> = items
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(i, item)| {
|
|
|
|
|
let is_running = state
|
|
|
|
|
.active_timer
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|(active, _)| active.name == item.name)
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
|
|
|
|
|
let style = if is_running {
|
2025-11-16 13:12:45 -05:00
|
|
|
Style::default()
|
|
|
|
|
.fg(ACTIVE_COLOR)
|
|
|
|
|
.add_modifier(Modifier::BOLD)
|
2025-11-16 11:41:05 -05:00
|
|
|
} else if i == selected && is_active {
|
2025-11-16 13:12:45 -05:00
|
|
|
Style::default()
|
|
|
|
|
.fg(border_color)
|
|
|
|
|
.add_modifier(Modifier::REVERSED)
|
2025-11-16 11:41:05 -05:00
|
|
|
} else {
|
|
|
|
|
Style::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut line = item.name.clone();
|
|
|
|
|
if !item.tags.is_empty() {
|
|
|
|
|
line.push_str(" [");
|
|
|
|
|
line.push_str(&item.tags.join(", "));
|
|
|
|
|
line.push(']');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ListItem::new(Line::from(vec![Span::styled(line, style)]))
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let list = List::new(items).block(block);
|
|
|
|
|
frame.render_widget(list, area);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
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;
|
2025-11-16 13:12:45 -05:00
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
Rect {
|
|
|
|
|
x: r.x + x,
|
|
|
|
|
y: r.y + y,
|
|
|
|
|
width: width.min(r.width),
|
|
|
|
|
height: height.min(r.height),
|
|
|
|
|
}
|
2025-11-16 13:12:45 -05:00
|
|
|
}
|
2025-11-22 10:45:10 -05:00
|
|
|
|
|
|
|
|
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_name = items
|
|
|
|
|
.get(app.state.selected_indices[app.state.current_pane])
|
|
|
|
|
.map(|item| item.name.as_str())
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
|
|
|
|
|
// Project input
|
|
|
|
|
let project_block = Block::default()
|
2025-11-22 11:53:43 -05:00
|
|
|
.title(format!("Reassign Tag for: {}", current_item_name))
|
2025-11-22 10:45:10 -05:00
|
|
|
.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
|
2025-11-22 11:53:43 -05:00
|
|
|
let help_text = Paragraph::new("Enter new tag (leave empty to remove), press Enter to save, Esc to cancel")
|
2025-11-22 10:45:10 -05:00
|
|
|
.style(Style::default().fg(Color::DarkGray))
|
|
|
|
|
.alignment(Alignment::Center);
|
|
|
|
|
|
|
|
|
|
frame.render_widget(help_text, chunks[1]);
|
|
|
|
|
}
|
2025-11-22 11:12:46 -05:00
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-22 11:53:43 -05:00
|
|
|
let grouping_str = match app.log_view_grouping {
|
|
|
|
|
LogViewGrouping::ByDate => "by Date",
|
|
|
|
|
LogViewGrouping::ByProject => "by Project",
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-23 12:50:31 -05:00
|
|
|
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);
|
2025-11-22 11:12:46 -05:00
|
|
|
|
|
|
|
|
let block = Block::default()
|
|
|
|
|
.title(title)
|
|
|
|
|
.borders(Borders::ALL)
|
2025-11-22 11:27:01 -05:00
|
|
|
.style(Style::default().fg(ACTIVE_COLOR));
|
2025-11-22 11:12:46 -05:00
|
|
|
|
2025-11-23 12:01:13 -05:00
|
|
|
// 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);
|
|
|
|
|
|
2025-11-23 12:11:46 -05:00
|
|
|
for (_idx, line) in app.log_view_content.iter().enumerate() {
|
2025-11-23 12:01:13 -05:00
|
|
|
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
|
2025-11-22 11:27:01 -05:00
|
|
|
} else {
|
2025-11-23 12:01:13 -05:00
|
|
|
line.starts_with('\t') && !line.starts_with("\t\t") // Single tab for ByDate
|
2025-11-22 11:27:01 -05:00
|
|
|
};
|
2025-11-23 12:01:13 -05:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-23 12:05:10 -05:00
|
|
|
LogViewSelection::All => {
|
|
|
|
|
// Select everything
|
|
|
|
|
selected_line_start = 0;
|
|
|
|
|
selected_line_end = app.log_view_content.len().saturating_sub(1);
|
|
|
|
|
}
|
2025-11-23 12:01:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
};
|
2025-11-22 11:12:46 -05:00
|
|
|
|
2025-11-22 11:27:01 -05:00
|
|
|
let list = List::new(items).block(block);
|
|
|
|
|
frame.render_widget(list, chunks[0]);
|
2025-11-22 11:12:46 -05:00
|
|
|
|
2025-11-22 12:02:11 -05:00
|
|
|
// Render help hint at bottom if enabled
|
|
|
|
|
if app.config.show_help_hint {
|
|
|
|
|
render_help_hint(frame, chunks[1]);
|
|
|
|
|
}
|
2025-11-22 11:12:46 -05:00
|
|
|
}
|
2025-11-22 11:57:28 -05:00
|
|
|
|
2025-11-23 12:21:03 -05:00
|
|
|
fn render_log_view_help(frame: &mut Frame, app: &App) {
|
2025-11-22 11:57:28 -05:00
|
|
|
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",
|
|
|
|
|
"",
|
2025-11-23 12:50:31 -05:00
|
|
|
"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)",
|
|
|
|
|
"",
|
2025-11-22 11:57:28 -05:00
|
|
|
"Navigation:",
|
2025-11-23 12:01:13 -05:00
|
|
|
"- 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",
|
2025-11-23 12:21:03 -05:00
|
|
|
" - 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)",
|
2025-11-23 12:01:13 -05:00
|
|
|
"- PageUp/PageDown: Jump 10 entries (Entry level only)",
|
|
|
|
|
"",
|
2025-11-23 12:21:03 -05:00
|
|
|
"Selection Levels (use h/l to change):",
|
2025-11-23 12:01:13 -05:00
|
|
|
"- Entry: Select individual entry (can edit/delete/copy)",
|
|
|
|
|
"- Project: Select whole project group (can copy only)",
|
|
|
|
|
"- Day: Select entire day (can copy only)",
|
2025-11-23 12:21:03 -05:00
|
|
|
"- All: Select entire view/period (can copy only)",
|
2025-11-22 11:57:28 -05:00
|
|
|
"",
|
|
|
|
|
"Actions:",
|
2025-11-23 12:01:13 -05:00
|
|
|
"- 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)",
|
2025-11-23 12:21:03 -05:00
|
|
|
" Copies based on current selection level",
|
2025-11-22 11:57:28 -05:00
|
|
|
"",
|
|
|
|
|
"Other:",
|
2025-11-23 12:21:03 -05:00
|
|
|
"- ?: Show/hide this help",
|
2025-11-22 11:57:28 -05:00
|
|
|
"- q or ESC: Return to main screen",
|
2025-11-23 12:21:03 -05:00
|
|
|
"",
|
|
|
|
|
"Tip: Use h/l to quickly select larger blocks for copying!",
|
2025-11-22 11:57:28 -05:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let text = help_text.join("\n");
|
|
|
|
|
|
|
|
|
|
let block = Block::default()
|
2025-11-23 12:21:03 -05:00
|
|
|
.title("Log View Help (j/k to scroll)")
|
2025-11-22 11:57:28 -05:00
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.style(Style::default().fg(Color::White));
|
|
|
|
|
|
|
|
|
|
let paragraph = Paragraph::new(text)
|
|
|
|
|
.block(block)
|
|
|
|
|
.style(Style::default().fg(Color::White))
|
2025-11-23 12:21:03 -05:00
|
|
|
.scroll((app.help_scroll as u16, 0))
|
2025-11-22 11:57:28 -05:00
|
|
|
.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);
|
|
|
|
|
}
|