From cae499f3eed9f7aa691bfc8d1835c2135e358a1f Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Sat, 22 Nov 2025 10:45:10 -0500 Subject: [PATCH] Add project re-assignment --- README.md | 2 ++ src/app.rs | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/ui.rs | 53 +++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/README.md b/README.md index 3d9d04c..c7a037f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - **Navigation**: `j/k` or arrows move within a pane; `h/l`, arrows, or `Ctrl+n/p` switch panes; `q` quits. - **Timer control**: `Enter` toggles the selected item. Starting a different item automatically stops the previous timer. - **New entries**: `n` launches a modal (task name + optional project tag). Item is added to the current pane. +- **Project reassignment**: `p` opens a modal to change the project tag for the selected item. - **Deletion**: `d` removes the selected entry; no noisy status message. - **Config editing**: - `Ctrl+e` edits task config (`state.yaml`). @@ -53,6 +54,7 @@ Requires `watson` on your `PATH` with a configured workspace. | `h` / `l`, arrows, `Ctrl+n` / `Ctrl+p` | Switch panes | | `Enter` | Start/stop timer | | `n` | New entry (task + optional project) | +| `p` | Reassign project for selected item | | `d` | Delete entry | | `Ctrl+e` | Edit task config (`state.yaml`) | | `c` | Edit app config (`config.yaml`) or show config help | diff --git a/src/app.rs b/src/app.rs index 9e60534..17683c3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,7 @@ pub enum Screen { Help, ConfigHelp, NewEntry, + ReassignProject, } pub struct App { @@ -20,6 +21,8 @@ pub struct App { pub new_entry_project: String, pub new_entry_cursor: usize, pub new_entry_mode: NewEntryMode, // Task or Project + pub reassign_project_buffer: String, + pub reassign_project_cursor: usize, pub status_message: Option<(String, std::time::Instant)>, } @@ -39,6 +42,8 @@ impl App { new_entry_project: String::new(), new_entry_cursor: 0, new_entry_mode: NewEntryMode::Task, + reassign_project_buffer: String::new(), + reassign_project_cursor: 0, status_message: None, }) } @@ -52,6 +57,7 @@ impl App { Screen::Help => self.handle_help_event(event), Screen::ConfigHelp => self.handle_config_help_event(event), Screen::NewEntry => self.handle_new_entry_event(event), + Screen::ReassignProject => self.handle_reassign_project_event(event), } } @@ -72,6 +78,7 @@ impl App { (KeyCode::Char('?'), _) => self.current_screen = Screen::Help, (KeyCode::Char('c'), _) => self.edit_app_config()?, (KeyCode::Char('n'), _) => self.start_new_entry(), + (KeyCode::Char('p'), _) => self.start_reassign_project(), (KeyCode::Char('d'), _) => self.delete_current_item()?, _ => {} }, @@ -193,6 +200,50 @@ impl App { Ok(false) } + fn handle_reassign_project_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.reassign_project_buffer.clear(); + self.reassign_project_cursor = 0; + } + (KeyCode::Enter, _) => { + if self.reassign_project_buffer.is_empty() + || self.config.is_valid_project(&self.reassign_project_buffer) + { + self.reassign_project_for_current_item()?; + self.current_screen = Screen::Main; + self.reassign_project_buffer.clear(); + self.reassign_project_cursor = 0; + } + } + (KeyCode::Backspace, _) => { + if self.reassign_project_cursor > 0 { + let idx = self.reassign_project_cursor - 1; + if idx < self.reassign_project_buffer.len() { + self.reassign_project_buffer.remove(idx); + self.reassign_project_cursor -= 1; + } + } + } + (KeyCode::Char(c), m) if m.is_empty() => { + if self.reassign_project_cursor <= self.reassign_project_buffer.len() { + self.reassign_project_buffer.insert(self.reassign_project_cursor, c); + self.reassign_project_cursor += 1; + } + } + _ => {} + } + } + _ => {} + } + Ok(false) + } + fn move_selection(&mut self, delta: i32) { let items_len = match self.state.current_pane { 0 => self.state.permanent_items.len(), @@ -319,6 +370,54 @@ impl App { self.new_entry_mode = NewEntryMode::Task; } + fn start_reassign_project(&mut self) { + if let Some(item) = self.get_current_item() { + self.current_screen = Screen::ReassignProject; + // Pre-fill with current project if it exists + self.reassign_project_buffer = item.tags.first().cloned().unwrap_or_default(); + self.reassign_project_cursor = self.reassign_project_buffer.len(); + } + } + + fn reassign_project_for_current_item(&mut self) -> anyhow::Result<()> { + let items = match self.state.current_pane { + 0 => &mut self.state.permanent_items, + 1 => &mut self.state.recurring_items, + 2 => &mut self.state.recent_items, + _ => return Ok(()), + }; + + let index = self.state.selected_indices[self.state.current_pane]; + + let needs_restart = if let Some(item) = items.get_mut(index) { + // Update the tags + if self.reassign_project_buffer.is_empty() { + item.tags.clear(); + } else { + item.tags = vec![self.reassign_project_buffer.clone()]; + } + + // Check if this is the active timer + self.state + .active_timer + .as_ref() + .map(|(active, _)| active.name == item.name) + .unwrap_or(false) + } else { + false + }; + + // If this was the active timer, restart it with new tags + if needs_restart { + let item = items[index].clone(); + self.state.stop_timer()?; + self.state.start_timer(item)?; + } + + self.state.save()?; + Ok(()) + } + fn edit_app_config(&mut self) -> anyhow::Result<()> { use crossterm::{ execute, diff --git a/src/ui.rs b/src/ui.rs index b05b852..95b9d38 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -20,6 +20,7 @@ pub fn render(frame: &mut Frame, app: &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), } } @@ -209,6 +210,7 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) { let commands = vec![ ("c", "config"), ("n", "new"), + ("p", "project"), ("d", "delete"), ("q", "quit"), ]; @@ -311,6 +313,7 @@ fn render_help(frame: &mut Frame, _app: &App) { "h/l, arrows - Switch panes", "Enter - Start/stop timer", "d - Delete task", + "p - Reassign project", "Ctrl+e - Edit tasks config", "c - Edit app config", "n - New task", @@ -450,3 +453,53 @@ fn centered_rect(width: u16, height: u16, r: Rect) -> Rect { 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]); +}