diff --git a/src/app.rs b/src/app.rs index cf812f5..077903b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use std::process::Command; +use chrono::Utc; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use crate::state::{AppState, TimeItem}; use crate::config::Config; @@ -7,6 +8,7 @@ pub enum Screen { Main, Help, ConfigHelp, + NewEntry, } pub struct App { @@ -14,6 +16,15 @@ pub struct App { pub config: Config, pub current_screen: Screen, pub needs_redraw: bool, + pub new_entry_buffer: String, + pub new_entry_project: String, + pub new_entry_cursor: usize, + pub new_entry_mode: NewEntryMode, // Task or Project +} + +pub enum NewEntryMode { + Task, + Project, } impl App { @@ -23,6 +34,10 @@ impl App { config: Config::load()?, current_screen: Screen::Main, needs_redraw: true, + new_entry_buffer: String::new(), + new_entry_project: String::new(), + new_entry_cursor: 0, + new_entry_mode: NewEntryMode::Task, }) } @@ -31,6 +46,7 @@ impl App { Screen::Main => self.handle_main_event(event), Screen::Help => self.handle_help_event(event), Screen::ConfigHelp => self.handle_config_help_event(event), + Screen::NewEntry => self.handle_new_entry_event(event), } } @@ -46,10 +62,13 @@ impl App { (KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.move_selection(-1), (KeyCode::Char('h'), _) | (KeyCode::Left, _) => self.change_pane(-1), (KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.change_pane(1), + (KeyCode::Char('n'), KeyModifiers::CONTROL) => self.change_pane(1), + (KeyCode::Char('p'), KeyModifiers::CONTROL) => self.change_pane(-1), (KeyCode::Enter, _) => self.toggle_current_item()?, (KeyCode::Char('e'), KeyModifiers::CONTROL) => self.edit_config()?, (KeyCode::Char('?'), _) => self.current_screen = Screen::Help, (KeyCode::Char('c'), _) => self.edit_app_config()?, + (KeyCode::Char('n'), _) => self.start_new_entry(), _ => {} }, _ => {} @@ -57,6 +76,93 @@ impl App { Ok(false) } + fn handle_new_entry_event(&mut self, event: Event) -> anyhow::Result { + match event { + Event::Key(KeyEvent { + code, + modifiers, + .. + }) => { + match (code, modifiers) { + (KeyCode::Esc, _) => { + self.current_screen = Screen::Main; + self.new_entry_buffer.clear(); + self.new_entry_project.clear(); + self.new_entry_mode = NewEntryMode::Task; + } + (KeyCode::Enter, _) => { + match self.new_entry_mode { + NewEntryMode::Task => { + if !self.new_entry_buffer.is_empty() { + self.new_entry_mode = NewEntryMode::Project; + } + } + NewEntryMode::Project => { + if self.new_entry_project.is_empty() || self.config.is_valid_project(&self.new_entry_project) { + // Create new time item + let item = TimeItem { + name: self.new_entry_buffer.clone(), + tags: if self.new_entry_project.is_empty() { + vec![] + } else { + vec![self.new_entry_project.clone()] + }, + last_used: Some(Utc::now()), + }; + // Add to current pane (or recurring if in recent) + match self.state.current_pane { + 0 => self.state.permanent_items.push(item), // Permanent Items + 1 => self.state.recurring_items.push(item), // Recurring Items + 2 => self.state.recent_items.push(item), // Ad-Hoc Items + _ => unreachable!(), + } + self.state.save()?; + + // Clear and return to main screen + self.current_screen = Screen::Main; + self.new_entry_buffer.clear(); + self.new_entry_project.clear(); + self.new_entry_mode = NewEntryMode::Task; + } + } + } + } + (KeyCode::Backspace, _) => { + match self.new_entry_mode { + NewEntryMode::Task => { + if self.new_entry_cursor > 0 { + self.new_entry_buffer.remove(self.new_entry_cursor - 1); + self.new_entry_cursor -= 1; + } + } + NewEntryMode::Project => { + if self.new_entry_cursor > 0 { + self.new_entry_project.remove(self.new_entry_cursor - 1); + self.new_entry_cursor -= 1; + } + } + } + } + (KeyCode::Char(c), m) if m.is_empty() => { + match self.new_entry_mode { + NewEntryMode::Task => { + self.new_entry_buffer.insert(self.new_entry_cursor, c); + self.new_entry_cursor += 1; + } + NewEntryMode::Project => { + self.new_entry_project.insert(self.new_entry_cursor, c); + self.new_entry_cursor += 1; + } + } + } + _ => {} + } + } + _ => {} + } + Ok(false) + } + fn handle_help_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, .. }) => match code { @@ -125,6 +231,14 @@ impl App { Ok(()) } + fn start_new_entry(&mut self) { + self.current_screen = Screen::NewEntry; + self.new_entry_buffer.clear(); + self.new_entry_project.clear(); + self.new_entry_cursor = 0; + self.new_entry_mode = NewEntryMode::Task; + } + fn edit_app_config(&mut self) -> anyhow::Result<()> { use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode}, diff --git a/src/config.rs b/src/config.rs index 5171b8d..7599a8a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,10 @@ pub struct Config { pub show_help_hint: bool, #[serde(default = "default_show_command_hints")] pub show_command_hints: bool, + #[serde(default = "Vec::new")] + pub projects: Vec, + #[serde(default = "default_strict_projects")] + pub strict_projects: bool, } fn default_show_help_hint() -> bool { @@ -17,11 +21,17 @@ fn default_show_command_hints() -> bool { true } +fn default_strict_projects() -> bool { + false +} + impl Default for Config { fn default() -> Self { Self { show_help_hint: default_show_help_hint(), show_command_hints: default_show_command_hints(), + projects: Vec::new(), + strict_projects: default_strict_projects(), } } } @@ -54,4 +64,11 @@ impl Config { path.push("config.yaml"); Ok(path) } + + pub fn is_valid_project(&self, project: &str) -> bool { + if !self.strict_projects { + return true; + } + self.projects.contains(&project.to_string()) + } } \ No newline at end of file diff --git a/src/ui.rs b/src/ui.rs index db5b537..bae37f1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; -use crate::{state::{AppState, TimeItem}, app::{App, Screen}}; +use crate::{state::{AppState, TimeItem}, app::{App, Screen, NewEntryMode}}; const ACTIVE_COLOR: Color = Color::Green; const INACTIVE_COLOR: Color = Color::Yellow; @@ -16,6 +16,7 @@ pub fn render(frame: &mut Frame, app: &App) { 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), } } @@ -81,7 +82,7 @@ fn render_main(frame: &mut Frame, app: &App) { render_section( frame, sections[2], - "Recent Items", + "Ad-Hoc Items", &app.state.recent_items, app.state.current_pane == 2, app.state.selected_indices[2], @@ -100,10 +101,82 @@ fn render_main(frame: &mut Frame, app: &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) { if app.config.show_command_hints { let commands = vec![ ("c", "config"), + ("n", "new"), ("q", "quit"), ]; @@ -167,6 +240,104 @@ fn render_help_command_bar(frame: &mut Frame) { 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", + "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, @@ -216,91 +387,6 @@ fn render_section( frame.render_widget(list, 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. 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) - .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", - "", - "Example configuration:", - "show_help_hint: true", - "show_command_hints: true", - ]; - - 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 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;