wat/src/ui.rs

592 lines
17 KiB
Rust
Raw Normal View History

use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
use crate::{
2025-11-22 11:12:46 -05:00
app::{App, LogViewPeriod, NewEntryMode, Screen},
state::{AppState, TimeItem},
};
const ACTIVE_COLOR: Color = Color::Green;
const INACTIVE_COLOR: Color = Color::Yellow;
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),
}
}
fn render_main(frame: &mut Frame, app: &App) {
2025-11-16 10:48:06 -05:00
// Calculate layout - accounting for bottom bar if needed
let show_bottom_bar = app.config.show_help_hint || app.config.show_command_hints;
2025-11-16 12:25:22 -05:00
let has_status = app.status_message.is_some();
let bottom_height = if show_bottom_bar {
if has_status {
2
} else {
1
}
2025-11-16 12:25:22 -05:00
} else {
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![
Constraint::Min(3), // At least 3 lines for each section
2025-11-16 10:48:06 -05:00
Constraint::Min(3),
Constraint::Min(3),
Constraint::Length(bottom_height), // Command bar + optional status
2025-11-16 10:48:06 -05:00
]
} else {
vec![Constraint::Min(3), Constraint::Min(3), Constraint::Min(3)]
2025-11-16 10:48:06 -05:00
};
let chunks = Layout::default()
.direction(Direction::Vertical)
2025-11-16 10:48:06 -05:00
.constraints(constraints)
.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
render_section(
frame,
2025-11-16 10:48:06 -05:00
sections[0],
"Permanent Items",
&app.state.permanent_items,
app.state.current_pane == 0,
app.state.selected_indices[0],
&app.state,
);
render_section(
frame,
2025-11-16 10:48:06 -05:00
sections[1],
"Recurring Items",
&app.state.recurring_items,
app.state.current_pane == 1,
app.state.selected_indices[1],
&app.state,
);
render_section(
frame,
2025-11-16 10:48:06 -05:00
sections[2],
2025-11-16 11:41:05 -05:00
"Ad-Hoc Items",
&app.state.recent_items,
app.state.current_pane == 2,
app.state.selected_indices[2],
&app.state,
);
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)
.constraints([Constraint::Length(3), Constraint::Length(3)])
2025-11-16 11:41:05 -05:00
.split(area);
// Task name input
let task_block = Block::default()
.title("Task Name")
.borders(Borders::ALL)
.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
let task_text = Paragraph::new(app.new_entry_buffer.as_str()).block(task_block);
2025-11-16 11:41:05 -05:00
frame.render_widget(task_text, chunks[0]);
// Project input
let project_block = Block::default()
.title("Project (optional)")
.borders(Borders::ALL)
.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
let project_text = if !app.config.projects.is_empty() {
format!(
"{} (available: {})",
2025-11-16 11:41:05 -05:00
app.new_entry_project,
app.config.projects.join(", ")
)
} else {
app.new_entry_project.clone()
};
let project_text = Paragraph::new(project_text).block(project_block);
2025-11-16 11:41:05 -05:00
frame.render_widget(project_text, chunks[1]);
// Render command bar
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 {
NewEntryMode::Task => "Enter task name, press Enter to continue",
NewEntryMode::Project => "Enter project name (optional), 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);
}
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
// Split the area into status and command sections if needed
let chunks = if app.status_message.is_some() {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Status message
Constraint::Length(1), // Command bar
2025-11-16 12:25:22 -05:00
])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1)])
.split(area)
};
let mut command_line_idx = 0;
// 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);
frame.render_widget(text, chunks[0]);
command_line_idx = 1;
}
// Render command hints
if app.config.show_command_hints && chunks.len() > command_line_idx {
2025-11-16 10:48:06 -05:00
let commands = vec![
("c", "config"),
2025-11-16 11:41:05 -05:00
("n", "new"),
2025-11-22 10:45:10 -05:00
("p", "project"),
2025-11-22 11:12:46 -05:00
("v", "log"),
2025-11-16 12:25:22 -05:00
("d", "delete"),
2025-11-16 10:48:06 -05:00
("q", "quit"),
];
let command_text = format!(
" {}",
commands
.iter()
.map(|(key, desc)| format!("{} ({})", key, desc))
.collect::<Vec<_>>()
.join(" · ")
);
2025-11-16 10:48:06 -05:00
let command_area = if app.config.show_help_hint {
// Leave space for help hint
2025-11-16 12:25:22 -05:00
Rect::new(
chunks[command_line_idx].x,
chunks[command_line_idx].y,
chunks[command_line_idx].width.saturating_sub(12),
1,
2025-11-16 12:25:22 -05:00
)
2025-11-16 10:48:06 -05:00
} else {
2025-11-16 12:25:22 -05:00
chunks[command_line_idx]
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 10:48:06 -05:00
frame.render_widget(command_bar, command_area);
}
2025-11-16 12:25:22 -05:00
if app.config.show_help_hint && chunks.len() > command_line_idx {
let help_hint = Paragraph::new("(?) for help")
.alignment(Alignment::Right)
.style(Style::default().fg(Color::DarkGray));
2025-11-16 10:48:06 -05:00
let help_area = Rect::new(
2025-11-16 12:25:22 -05:00
chunks[command_line_idx].x + chunks[command_line_idx].width.saturating_sub(12),
chunks[command_line_idx].y,
2025-11-16 10:48:06 -05:00
12,
1,
);
frame.render_widget(help_hint, help_area);
}
}
2025-11-16 10:48:06 -05:00
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(" · ")
);
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 10:48:06 -05:00
let command_bar = Paragraph::new(command_text)
.style(Style::default().fg(Color::White))
.alignment(Alignment::Left);
2025-11-16 10:48:06 -05:00
frame.render_widget(command_bar, bar_area);
}
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());
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",
2025-11-16 11:41:05 -05:00
"3. Ad-Hoc Items: One-off tasks and quick entries",
"",
"Navigation:",
"- Use j/k or ↑/↓ to move selection up/down",
"- Use h/l or ←/→ to switch between panes",
2025-11-16 11:41:05 -05:00
"- Use Ctrl+n/Ctrl+p to switch between panes",
"- Press Enter to start/stop time tracking",
2025-11-16 11:41:05 -05:00
"- Press n to create a new task in the current section",
"",
"Main Commands:",
"j/k, arrows - Navigate",
"h/l, arrows - Switch panes",
"Enter - Start/stop timer",
2025-11-16 12:25:22 -05:00
"d - Delete task",
2025-11-22 10:45:10 -05:00
"p - Reassign project",
2025-11-22 11:27:01 -05:00
"v - View Watson log (e to edit entries, x to delete)",
"Ctrl+e - Edit tasks config",
"c - Edit app config",
2025-11-16 11:41:05 -05:00
"n - New task",
"q - Quit",
"? (or ESC) - Exit help",
];
let text = help_text.join("\n");
let block = Block::default()
.title("Help")
.borders(Borders::ALL)
.style(Style::default().fg(Color::White));
let paragraph = Paragraph::new(text)
.block(block)
2025-11-16 10:55:48 -05:00
.style(Style::default().fg(Color::White))
.wrap(ratatui::widgets::Wrap { trim: true });
frame.render_widget(paragraph, area);
2025-11-16 10:48:06 -05:00
render_help_command_bar(frame);
}
2025-11-16 10:48:06 -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());
frame.render_widget(Clear, area);
let help_text = vec![
"WAT Configuration",
"",
"The configuration file is in YAML format and supports these options:",
"",
"show_help_hint: true/false",
2025-11-16 10:48:06 -05:00
" Controls visibility of the help hint in the bottom bar",
"",
"show_command_hints: true/false",
" Controls visibility of command hints in the bottom bar",
"",
2025-11-16 11:41:05 -05:00
"projects: [list of strings]",
" List of available project names",
"",
"strict_projects: true/false",
" If true, only projects from the projects list are allowed",
"",
"Example configuration:",
"show_help_hint: true",
2025-11-16 10:48:06 -05:00
"show_command_hints: true",
2025-11-16 11:41:05 -05:00
"projects:",
" - project1",
" - project2",
"strict_projects: false",
];
let text = help_text.join("\n");
let block = Block::default()
.title("Configuration Help")
.borders(Borders::ALL)
.style(Style::default().fg(Color::White));
let paragraph = Paragraph::new(text)
.block(block)
2025-11-16 10:55:48 -05:00
.style(Style::default().fg(Color::White))
.wrap(ratatui::widgets::Wrap { trim: true });
frame.render_widget(paragraph, area);
2025-11-16 10:48:06 -05:00
render_help_command_bar(frame);
}
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,
) {
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 {
Style::default()
.fg(ACTIVE_COLOR)
.add_modifier(Modifier::BOLD)
2025-11-16 11:41:05 -05:00
} else if i == selected && is_active {
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);
}
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),
}
}
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()
.title(format!("Reassign Project for: {}", current_item_name))
.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 (leave empty to remove), press Enter to save, Esc to cancel")
.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",
};
let title = format!("Watson Log - {} View", period_str);
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-22 11:27:01 -05:00
// Build list items with selection highlighting
let items: Vec<ListItem> = app
.log_view_content
.iter()
.enumerate()
.map(|(idx, line)| {
// Check if this is a frame line (starts with tab) and matches selected frame
let is_selected = if line.starts_with('\t') {
// Count how many frame lines we've seen so far
let frame_idx = app.log_view_content[..=idx]
.iter()
.filter(|l| l.starts_with('\t'))
.count()
.saturating_sub(1);
frame_idx == app.log_view_selected
} else {
false
};
2025-11-22 11:12:46 -05:00
2025-11-22 11:27:01 -05:00
let style = if is_selected {
Style::default()
.fg(ACTIVE_COLOR)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(Color::White)
};
2025-11-22 11:12:46 -05:00
2025-11-22 11:27:01 -05:00
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
// Render command bar
let commands = vec![
("d", "day"),
("w", "week"),
("m", "month"),
2025-11-22 11:27:01 -05:00
("j/k", "select"),
("e", "edit"),
("x", "delete"),
2025-11-22 11:12:46 -05:00
("q/ESC", "back"),
];
let command_text = format!(
" {}",
commands
.iter()
.map(|(key, desc)| format!("{} ({})", key, desc))
.collect::<Vec<_>>()
.join(" · ")
);
let command_bar = Paragraph::new(command_text)
.style(Style::default().fg(Color::White))
.alignment(Alignment::Left);
frame.render_widget(command_bar, chunks[1]);
}