diff --git a/Cargo.toml b/Cargo.toml index de142cf..078ebc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,10 @@ name = "vikunja-tui" version = "0.1.0" edition = "2021" +license = "MIT" +description = "A terminal application to manage tasks in vikunja" +repository = "https://github.com/mark-pitblado/vikunja-tui" +readme = "README.md" [dependencies] regex = "1" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..4da5e57 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,8 @@ +Copyright 2024 Mark Pitblado + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md index 5061140..345f317 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ api_key = "" - Add tasks - Title - Priority, via ![1-5] (vikunja quick add magic syntax) - - Description, via {} in the add task flow + - Description, via a seperate input box during task creation ## Roadmap diff --git a/src/app.rs b/src/app.rs index 9a229e5..74aa32a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,9 @@ pub struct App { pub state: ListState, pub task_detail: Option, pub input_mode: InputMode, + pub active_input: ActiveInput, pub new_task_title: String, + pub new_task_description: String, pub page: usize, pub show_done_tasks: bool, } @@ -18,6 +20,12 @@ pub struct App { pub enum InputMode { Normal, Editing, + Insert, +} +#[derive(PartialEq)] +pub enum ActiveInput { + Title, + Description, } impl App { @@ -33,7 +41,9 @@ impl App { state, task_detail: None, input_mode: InputMode::Normal, + active_input: ActiveInput::Title, new_task_title: String::new(), + new_task_description: String::new(), page: 1, show_done_tasks: false, } @@ -139,6 +149,8 @@ impl App { KeyCode::Char('a') => { self.input_mode = InputMode::Editing; self.new_task_title.clear(); + self.new_task_description.clear(); + self.active_input = ActiveInput::Title; } KeyCode::Enter => { if let Err(err) = self.select_task(instance_url, api_key).await { @@ -147,47 +159,72 @@ impl App { } _ => {} }, - InputMode::Editing => { - match key.code { - KeyCode::Enter => { - if !self.new_task_title.trim().is_empty() { - let parsed_task = parse_task_input(&self.new_task_title); - if let Err(err) = create_new_task( - instance_url, - api_key, - &parsed_task.title, - parsed_task.description.as_deref(), - parsed_task.priority, - ) - .await - { - eprintln!("Error creating task: {}", err); - } else { - // Refresh the task list - if let Ok(all_tasks) = - fetch_tasks(instance_url, api_key, self.page).await - { - self.tasks = - all_tasks.into_iter().filter(|task| !task.done).collect(); - self.state.select(Some(0)); - } - } + InputMode::Editing => match key.code { + KeyCode::Char('i') => { + self.input_mode = InputMode::Insert; + } + KeyCode::Tab => { + self.active_input = match self.active_input { + ActiveInput::Title => ActiveInput::Description, + ActiveInput::Description => ActiveInput::Title, + }; + } + KeyCode::Enter => { + if self.new_task_title.trim().is_empty() { + eprintln!("Task title cannot be empty."); + } else { + let parsed_task = parse_task_input(&self.new_task_title); + + let description = if self.new_task_description.trim().is_empty() { + None + } else { + Some(self.new_task_description.as_str()) + }; + + if let Err(err) = create_new_task( + instance_url, + api_key, + &parsed_task.title, + description, + parsed_task.priority, + ) + .await + { + eprintln!("Error creating new task: {}", err); + } else if let Err(err) = self.refresh_tasks(instance_url, api_key).await { + eprintln!("Error fetching tasks: {}", err); } + self.new_task_title.clear(); + self.new_task_description.clear(); self.input_mode = InputMode::Normal; } - KeyCode::Char(c) => { - self.new_task_title.push(c); // Handle character input - } - KeyCode::Backspace => { - self.new_task_title.pop(); // Handle backspace + } + KeyCode::Esc => { + self.new_task_title.clear(); + self.new_task_description.clear(); + self.input_mode = InputMode::Normal; + } + _ => {} + }, + InputMode::Insert => match key.code { + KeyCode::Char(c) => match self.active_input { + ActiveInput::Title => self.new_task_title.push(c), + ActiveInput::Description => self.new_task_description.push(c), + }, + KeyCode::Backspace => match self.active_input { + ActiveInput::Title => { + self.new_task_title.pop(); } - KeyCode::Esc => { - self.input_mode = InputMode::Normal; // Exit editing mode + ActiveInput::Description => { + self.new_task_description.pop(); } - _ => {} + }, + KeyCode::Esc => { + self.input_mode = InputMode::Editing; } - } + _ => {} + }, } Ok(false) } diff --git a/src/models.rs b/src/models.rs index c9f242d..79cc560 100644 --- a/src/models.rs +++ b/src/models.rs @@ -11,9 +11,6 @@ pub struct Task { // TaskDetail struct with description #[derive(Deserialize, Debug)] pub struct TaskDetail { - pub id: u64, - pub title: String, - pub done: bool, pub due_date: Option, pub labels: Option>, pub priority: Option, @@ -23,6 +20,5 @@ pub struct TaskDetail { // Label struct #[derive(Deserialize, Debug)] pub struct Label { - pub id: u64, pub title: String, } diff --git a/src/parser.rs b/src/parser.rs index 6935c55..4a9c99c 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -3,16 +3,13 @@ use regex::Regex; #[derive(Debug, PartialEq)] pub struct ParsedTask { pub title: String, - pub description: Option, pub priority: Option, } pub fn parse_task_input(input: &str) -> ParsedTask { let priority_re = Regex::new(r"!(\d+)\s*").unwrap(); - let description_re = Regex::new(r"\{([^}]*)\}").unwrap(); let mut priority = None; - let mut description = None; // Priority for caps in priority_re.captures_iter(input) { @@ -25,17 +22,7 @@ pub fn parse_task_input(input: &str) -> ParsedTask { } } - // Description - let mut title = input.to_string(); - if let Some(caps) = description_re.captures(&title) { - if let Some(desc_match) = caps.get(1) { - description = Some(desc_match.as_str().trim().to_string()); - } - } - - title = description_re.replace_all(&title, "").to_string(); - - title = priority_re.replace_all(&title, "").to_string(); + let title = priority_re.replace_all(input, "").into_owned(); let title = Regex::new(r"\s+") .unwrap() @@ -43,72 +30,19 @@ pub fn parse_task_input(input: &str) -> ParsedTask { .trim() .to_string(); - ParsedTask { - title, - priority, - description, - } + ParsedTask { title, priority } } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_parse_with_description() { - let input = "Implement feature X {This is the description of the task}"; - let expected = ParsedTask { - title: "Implement feature X".to_string(), - description: Some("This is the description of the task".to_string()), - priority: None, - }; - let result = parse_task_input(input); - assert_eq!(result, expected); - } - - #[test] - fn test_parse_with_description_and_priority() { - let input = "Fix bug in module !2 {Critical issue that needs immediate attention}"; - let expected = ParsedTask { - title: "Fix bug in module".to_string(), - description: Some("Critical issue that needs immediate attention".to_string()), - priority: Some(2), - }; - let result = parse_task_input(input); - assert_eq!(result, expected); - } - - #[test] - fn test_parse_with_description_and_priority_in_any_order() { - let input = "{Detailed description here} Update documentation !3"; - let expected = ParsedTask { - title: "Update documentation".to_string(), - description: Some("Detailed description here".to_string()), - priority: Some(3), - }; - let result = parse_task_input(input); - assert_eq!(result, expected); - } - - #[test] - fn test_parse_with_multiple_descriptions() { - let input = "Task title {First description} {Second description}"; - let expected = ParsedTask { - title: "Task title".to_string(), - description: Some("First description".to_string()), - priority: None, - }; - let result = parse_task_input(input); - assert_eq!(result, expected); - } - #[test] fn test_parse_with_priority_in_middle() { let input = "Update !4 software documentation"; let expected = ParsedTask { title: "Update software documentation".to_string(), priority: Some(4), - description: None, }; let result = parse_task_input(input); assert_eq!(result, expected); @@ -120,7 +54,6 @@ mod tests { let expected = ParsedTask { title: "Fix bugs in the code".to_string(), priority: Some(2), - description: None, }; let result = parse_task_input(input); assert_eq!(result, expected); @@ -132,7 +65,6 @@ mod tests { let expected = ParsedTask { title: "Write tests for the parser".to_string(), priority: Some(3), - description: None, }; let result = parse_task_input(input); assert_eq!(result, expected); @@ -144,7 +76,6 @@ mod tests { let expected = ParsedTask { title: "Deploy to production".to_string(), priority: Some(5), - description: None, }; let result = parse_task_input(input); assert_eq!(result, expected); @@ -156,7 +87,6 @@ mod tests { let expected = ParsedTask { title: "Prepare presentation slides".to_string(), priority: Some(2), - description: None, }; let result = parse_task_input(input); assert_eq!(result, expected); @@ -168,7 +98,6 @@ mod tests { let expected = ParsedTask { title: "Organize team building event".to_string(), priority: Some(1), - description: None, }; let result = parse_task_input(input); assert_eq!(result, expected); @@ -180,7 +109,6 @@ mod tests { let expected = ParsedTask { title: "Check logs immediately".to_string(), priority: None, - description: None, }; let result = parse_task_input(input); assert_eq!(result, expected); diff --git a/src/ui.rs b/src/ui.rs index d362d8e..0efb19b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,9 +1,9 @@ -use crate::app::{App, InputMode}; +use crate::app::{ActiveInput, App, InputMode}; use ansi_parser::{AnsiParser, Output}; use crossterm::event::{self, Event as CEvent}; use ratatui::{ backend::Backend, - layout::{Alignment, Constraint, Direction, Layout, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, @@ -12,29 +12,31 @@ use ratatui::{ use std::io; use std::time::Duration; +fn get_cursor_position(input: &str, area: Rect) -> (u16, u16) { + let lines: Vec<&str> = input.lines().collect(); + let last_line = lines.last().unwrap_or(&""); + let x = area.x + last_line.chars().count() as u16 + 1; + let y = area.y + lines.len() as u16 - 1 + 1; + (x, y) +} + fn centered_rect_absolute(width: u16, height: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) - .constraints( - [ - Constraint::Length((r.height.saturating_sub(height)) / 2), - Constraint::Length(height), - Constraint::Length((r.height.saturating_sub(height) + 1) / 2), - ] - .as_ref(), - ) + .constraints([ + Constraint::Length((r.height.saturating_sub(height)) / 2u16), + Constraint::Length(height), + Constraint::Length((r.height.saturating_sub(height) + 1u16) / 2u16), + ]) .split(r); Layout::default() .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Length((r.width.saturating_sub(width)) / 2), - Constraint::Length(width), - Constraint::Length((r.width.saturating_sub(width) + 1) / 2), - ] - .as_ref(), - ) + .constraints([ + Constraint::Length((r.width.saturating_sub(width)) / 2u16), + Constraint::Length(width), + Constraint::Length((r.width.saturating_sub(width) + 1u16) / 2u16), + ]) .split(popup_layout[1])[1] } @@ -77,11 +79,19 @@ fn get_legend(input_mode: &InputMode) -> Text<'static> { Span::raw(": Add Task "), ])), InputMode::Editing => Text::from(Line::from(vec![ + Span::styled(" i ", Style::default().fg(Color::Red)), + Span::raw(": Insert "), + Span::styled(" Tab ", Style::default().fg(Color::Red)), + Span::raw(": Switch Input "), Span::styled(" Enter ", Style::default().fg(Color::Red)), Span::raw(": Submit "), Span::styled(" Esc ", Style::default().fg(Color::Red)), Span::raw(": Cancel "), ])), + InputMode::Insert => Text::from(Line::from(vec![ + Span::styled(" Esc ", Style::default().fg(Color::Red)), + Span::raw(": Exit Insert Mode "), + ])), } } @@ -99,7 +109,7 @@ pub async fn run_app( let chunks = Layout::default() .direction(Direction::Vertical) .margin(0) - .constraints([Constraint::Min(0), Constraint::Length(2)].as_ref()) + .constraints([Constraint::Min(0), Constraint::Length(2u16)]) .split(size); let body_chunk = chunks[0]; @@ -109,9 +119,7 @@ pub async fn run_app( InputMode::Normal => { let chunks = Layout::default() .direction(Direction::Horizontal) - .constraints( - [Constraint::Percentage(65), Constraint::Percentage(35)].as_ref(), - ) + .constraints([Constraint::Percentage(65), Constraint::Percentage(35)]) .split(body_chunk); let task_title = if app.show_done_tasks { @@ -157,10 +165,9 @@ pub async fn run_app( let detail_block = Block::default().borders(Borders::ALL).title("Task Details"); if let Some(ref detail) = app.task_detail { - // Initialize lines with 'static lifetime let mut lines: Vec> = Vec::new(); - // due_date + // Due date let due_date = match &detail.due_date { Some(date) if date != "0001-01-01T00:00:00Z" => date.clone(), _ => "No due date".to_string(), @@ -173,7 +180,7 @@ pub async fn run_app( Span::raw(due_date), ])); - // priority + // Priority let priority_str = match detail.priority { Some(p) => p.to_string(), None => "No priority".to_string(), @@ -186,7 +193,7 @@ pub async fn run_app( Span::raw(priority_str), ])); - // labels + // Labels lines.push(Line::from(vec![Span::styled( "Labels: ", Style::default().add_modifier(Modifier::BOLD), @@ -197,7 +204,7 @@ pub async fn run_app( let mut label_spans: Vec> = Vec::new(); for (i, label) in labels.iter().enumerate() { if i > 0 { - label_spans.push(Span::raw(" ".to_string())); + label_spans.push(Span::raw(" ")); } label_spans.push(Span::styled( format!(" {} ", label.title), @@ -211,7 +218,7 @@ pub async fn run_app( } } - // description + // Description lines.push(Line::from(vec![Span::styled( "Description: ", Style::default().add_modifier(Modifier::BOLD), @@ -242,36 +249,95 @@ pub async fn run_app( f.render_widget(paragraph, chunks[1]); } } - InputMode::Editing => { - let popup_width_percentage = 60; - let popup_width = (size.width * popup_width_percentage / 100).saturating_sub(2); + InputMode::Editing | InputMode::Insert => { + let popup_width_percentage = 60u16; + let popup_width = + (size.width * popup_width_percentage / 100u16).saturating_sub(2u16); - let lines_required = calculate_wrapped_lines(&app.new_task_title, popup_width); + // Calculate the required heights for the input boxes + let title_lines_required = + calculate_wrapped_lines(&app.new_task_title, popup_width); + let description_lines_required = + calculate_wrapped_lines(&app.new_task_description, popup_width); - let min_required_height = 1; + let title_height = std::cmp::max(title_lines_required as u16, 1u16); + let description_height = std::cmp::max(description_lines_required as u16, 2u16); // At least 2 lines tall - let required_height = std::cmp::max(lines_required as u16, min_required_height); + let total_height = title_height + description_height + 6u16; // +6 for borders and titles - let popup_height = required_height + 2; - - let max_popup_height = size.height - 2; - let popup_height = std::cmp::min(popup_height, max_popup_height); + let max_popup_height = size.height - 2u16; + let popup_height = std::cmp::min(total_height, max_popup_height); let popup_area = - centered_rect_absolute(popup_width + 2, popup_height, body_chunk); + centered_rect_absolute(popup_width + 2u16, popup_height, body_chunk); let popup_block = Block::default() - .title("Enter New Task (Press Enter to Submit)") + .title("Enter New Task (Press Enter to Submit, Tab to Switch)") .borders(Borders::ALL) .style(Style::default().fg(Color::Green)); - let input = Paragraph::new(app.new_task_title.as_str()) + // Split the popup area vertically for the two input boxes + let input_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(title_height + 2u16), // +2 for borders + Constraint::Length(description_height + 2u16), // +2 for borders + ]) + .split(popup_area.inner(Margin { + vertical: 1u16, + horizontal: 1u16, + })); // Adjust for popup_block borders + + // Title input box + let title_block = Block::default().borders(Borders::ALL).title("Title").style( + if app.active_input == ActiveInput::Title { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }, + ); + + let title_paragraph = Paragraph::new(app.new_task_title.as_str()) + .style(Style::default().fg(Color::White)) + .block(title_block) + .wrap(Wrap { trim: false }); + + // Description input box + let description_block = Block::default() + .borders(Borders::ALL) + .title("Description") + .style(if app.active_input == ActiveInput::Description { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }); + + let description_paragraph = Paragraph::new(app.new_task_description.as_str()) .style(Style::default().fg(Color::White)) - .block(popup_block) + .block(description_block) .wrap(Wrap { trim: false }); f.render_widget(Clear, popup_area); - f.render_widget(input, popup_area); + f.render_widget(popup_block, popup_area); + + f.render_widget(title_paragraph, input_chunks[0]); + f.render_widget(description_paragraph, input_chunks[1]); + + // Set cursor position + match app.active_input { + ActiveInput::Title => { + // Calculate cursor position in title input + let cursor_position = + get_cursor_position(&app.new_task_title, input_chunks[0]); + f.set_cursor(cursor_position.0, cursor_position.1); + } + ActiveInput::Description => { + // Calculate cursor position in description input + let cursor_position = + get_cursor_position(&app.new_task_description, input_chunks[1]); + f.set_cursor(cursor_position.0, cursor_position.1); + } + } } } @@ -300,7 +366,8 @@ fn calculate_wrapped_lines(text: &str, max_width: u16) -> usize { let mut line_count = 0; for line in text.lines() { let line_width = line.chars().count() as u16; - line_count += ((line_width + max_width - 1) / max_width) as usize; + let total_width = line_width + max_width - 1u16; + line_count += (total_width / max_width) as usize; } line_count }