2025-11-16 09:57:01 -05:00
|
|
|
use ratatui::{
|
2025-11-16 10:19:14 -05:00
|
|
|
layout::{Constraint, Direction, Layout, Rect, Alignment},
|
2025-11-16 09:57:01 -05:00
|
|
|
style::{Color, Modifier, Style},
|
|
|
|
|
text::{Line, Span},
|
2025-11-16 10:19:14 -05:00
|
|
|
widgets::{Block, Borders, List, ListItem, Paragraph, Clear},
|
2025-11-16 09:57:01 -05:00
|
|
|
Frame,
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
use crate::{state::{AppState, TimeItem}, app::{App, Screen}};
|
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 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
|
|
|
|
|
let show_bottom_bar = app.config.show_help_hint || app.config.show_command_hints;
|
|
|
|
|
let constraints = if show_bottom_bar {
|
|
|
|
|
vec![
|
|
|
|
|
Constraint::Min(3), // At least 3 lines for each section
|
|
|
|
|
Constraint::Min(3),
|
|
|
|
|
Constraint::Min(3),
|
|
|
|
|
Constraint::Length(1), // Command bar
|
|
|
|
|
]
|
|
|
|
|
} else {
|
|
|
|
|
vec![
|
|
|
|
|
Constraint::Min(3),
|
|
|
|
|
Constraint::Min(3),
|
|
|
|
|
Constraint::Min(3),
|
|
|
|
|
]
|
|
|
|
|
};
|
|
|
|
|
|
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 10:48:06 -05:00
|
|
|
let main_height = if show_bottom_bar {
|
|
|
|
|
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 09:57:01 -05:00
|
|
|
"Recent 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
|
|
|
|
|
if show_bottom_bar {
|
|
|
|
|
let bottom_area = Rect::new(
|
|
|
|
|
0,
|
|
|
|
|
frame.size().height - 1,
|
|
|
|
|
frame.size().width,
|
|
|
|
|
1,
|
|
|
|
|
);
|
|
|
|
|
render_bottom_bar(frame, bottom_area, app);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
|
|
if app.config.show_command_hints {
|
|
|
|
|
let commands = vec![
|
|
|
|
|
("c", "config"),
|
|
|
|
|
("q", "quit"),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let command_text = format!(" {}", commands.iter()
|
|
|
|
|
.map(|(key, desc)| format!("{} ({})", key, desc))
|
|
|
|
|
.collect::<Vec<_>>()
|
2025-11-16 10:55:48 -05:00
|
|
|
.join(" · "));
|
2025-11-16 10:48:06 -05:00
|
|
|
|
|
|
|
|
let command_area = if app.config.show_help_hint {
|
|
|
|
|
// Leave space for help hint
|
|
|
|
|
Rect::new(area.x, area.y, area.width.saturating_sub(12), area.height)
|
|
|
|
|
} else {
|
|
|
|
|
area
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let command_bar = Paragraph::new(command_text)
|
|
|
|
|
.style(Style::default().fg(Color::White))
|
|
|
|
|
.alignment(Alignment::Left);
|
|
|
|
|
|
|
|
|
|
frame.render_widget(command_bar, command_area);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 10:19:14 -05:00
|
|
|
if app.config.show_help_hint {
|
|
|
|
|
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(
|
|
|
|
|
area.width.saturating_sub(12),
|
|
|
|
|
area.y,
|
|
|
|
|
12,
|
|
|
|
|
1,
|
|
|
|
|
);
|
2025-11-16 10:19:14 -05:00
|
|
|
|
|
|
|
|
frame.render_widget(help_hint, help_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) {
|
|
|
|
|
let commands = vec![
|
|
|
|
|
("c", "configuration help"),
|
|
|
|
|
("q/ESC", "back"),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let command_text = format!(" {}", commands.iter()
|
|
|
|
|
.map(|(key, desc)| format!("{} ({})", key, desc))
|
|
|
|
|
.collect::<Vec<_>>()
|
2025-11-16 10:55:48 -05:00
|
|
|
.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,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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 09:57:01 -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 };
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
} else if i == selected && is_active {
|
|
|
|
|
Style::default().fg(border_color).add_modifier(Modifier::REVERSED)
|
|
|
|
|
} 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
|
|
|
}
|
|
|
|
|
|
2025-11-16 10:48:06 -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 10:19:14 -05:00
|
|
|
|
|
|
|
|
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. Recent Items: One-off tasks, showing the last 20 used",
|
|
|
|
|
"",
|
|
|
|
|
"Navigation:",
|
|
|
|
|
"- Use j/k or ↑/↓ to move selection up/down",
|
|
|
|
|
"- Use h/l or ←/→ to switch between panes",
|
|
|
|
|
"- Press Enter to start/stop time tracking",
|
|
|
|
|
"",
|
|
|
|
|
"Main Commands:",
|
|
|
|
|
"j/k, arrows - Navigate",
|
|
|
|
|
"h/l, arrows - Switch panes",
|
|
|
|
|
"Enter - Start/stop timer",
|
|
|
|
|
"Ctrl+e - Edit tasks config",
|
|
|
|
|
"c - Edit app config",
|
|
|
|
|
"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 });
|
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 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());
|
2025-11-16 10:19:14 -05:00
|
|
|
|
|
|
|
|
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 10:19:14 -05:00
|
|
|
"",
|
|
|
|
|
"Example configuration:",
|
|
|
|
|
"show_help_hint: true",
|
2025-11-16 10:48:06 -05:00
|
|
|
"show_command_hints: true",
|
2025-11-16 10:19:14 -05:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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 });
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-16 09:57:01 -05:00
|
|
|
}
|