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::{ 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), 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), } } 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 || app.config.show_command_hints; 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, ); render_section( frame, sections[1], "Recurring Items", &app.state.recurring_items, app.state.current_pane == 1, app.state.selected_indices[1], &app.state, ); render_section( frame, sections[2], "Ad-Hoc Items", &app.state.recent_items, app.state.current_pane == 2, app.state.selected_indices[2], &app.state, ); // 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); // 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 }), ); let task_text = Paragraph::new(app.new_entry_buffer.as_str()).block(task_block); 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 }), ); let project_text = if !app.config.projects.is_empty() { format!( "{} (available: {})", app.new_entry_project, app.config.projects.join(", ") ) } else { app.new_entry_project.clone() }; let project_text = Paragraph::new(project_text).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 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); } fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) { // 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 ]) .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 { let commands = vec![ ("c", "config"), ("n", "new"), ("p", "project"), ("v", "log"), ("d", "delete"), ("q", "quit"), ]; let command_text = format!( " {}", commands .iter() .map(|(key, desc)| format!("{} ({})", key, desc)) .collect::>() .join(" · ") ); let command_area = if app.config.show_help_hint { // Leave space for help hint Rect::new( chunks[command_line_idx].x, chunks[command_line_idx].y, chunks[command_line_idx].width.saturating_sub(12), 1, ) } else { chunks[command_line_idx] }; let command_bar = Paragraph::new(command_text) .style(Style::default().fg(Color::White)) .alignment(Alignment::Left); frame.render_widget(command_bar, command_area); } 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)); let help_area = Rect::new( chunks[command_line_idx].x + chunks[command_line_idx].width.saturating_sub(12), chunks[command_line_idx].y, 12, 1, ); frame.render_widget(help_hint, help_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::>() .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:", "- Use j/k or ↑/↓ to move selection up/down", "- Use h/l or ←/→ to switch between panes", "- Use Ctrl+n/Ctrl+p to switch between panes", "- Press Enter to start/stop time tracking", "- 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", "d - Delete task", "p - Reassign project", "v - View Watson log (e to edit entries, x to delete)", "Ctrl+e - Edit tasks config", "c - Edit app config", "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) .style(Style::default().fg(Color::White)) .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", "", "The configuration file is in YAML format and supports these options:", "", "show_help_hint: true/false", " Controls visibility of the help hint in the bottom bar", "", "show_command_hints: true/false", " Controls visibility of command hints in the bottom bar", "", "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", "show_command_hints: true", "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) .style(Style::default().fg(Color::White)) .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, 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 = 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); } 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_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]); } 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) .style(Style::default().fg(ACTIVE_COLOR)); // Build list items with selection highlighting let items: Vec = 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 }; 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 command bar let commands = vec![ ("d", "day"), ("w", "week"), ("m", "month"), ("j/k", "select"), ("e", "edit"), ("x", "delete"), ("q/ESC", "back"), ]; let command_text = format!( " {}", commands .iter() .map(|(key, desc)| format!("{} ({})", key, desc)) .collect::>() .join(" · ") ); let command_bar = Paragraph::new(command_text) .style(Style::default().fg(Color::White)) .alignment(Alignment::Left); frame.render_widget(command_bar, chunks[1]); }