├── .gitignore ├── src ├── util.rs ├── domain │ ├── mod.rs │ ├── task.rs │ └── project.rs ├── tui │ ├── widgets │ │ ├── mod.rs │ │ ├── success_modal.rs │ │ ├── error_modal.rs │ │ ├── confirm_modal.rs │ │ ├── task_id_modal.rs │ │ ├── keys.rs │ │ ├── save_modal.rs │ │ ├── text_input.rs │ │ └── date_picker.rs │ ├── mod.rs │ ├── link_task.rs │ └── edit_task.rs ├── cli.rs ├── main.rs └── render │ └── mod.rs ├── .claude └── settings.local.json ├── Cargo.toml ├── CLAUDE.md └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use jiff::civil::Date; 2 | 3 | pub fn today() -> Date { 4 | let now = jiff::Zoned::now(); 5 | now.date() 6 | } 7 | -------------------------------------------------------------------------------- /src/domain/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod project; 2 | pub mod task; 3 | 4 | pub use project::{DateRange, Project}; 5 | pub use task::{PersonAllocation, Task, TaskId}; 6 | -------------------------------------------------------------------------------- /src/tui/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod confirm_modal; 2 | pub mod date_picker; 3 | pub mod error_modal; 4 | pub mod keys; 5 | pub mod save_modal; 6 | pub mod success_modal; 7 | pub mod task_id_modal; 8 | pub mod text_input; 9 | -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(cargo check:*)", 5 | "Bash(cargo:*)", 6 | "WebSearch", 7 | "WebFetch(domain:ratatui.rs)", 8 | "Bash(git add:*)", 9 | "Bash(timeout 3s cargo run)" 10 | ], 11 | "deny": [], 12 | "ask": [] 13 | } 14 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kantt" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | clap = { version = "4.5.45", features = ["derive", "cargo", "env", "unicode", "wrap_help"] } 8 | color-eyre = "0.6.5" 9 | crossterm = "0.29.0" 10 | jiff = { version = "0.2.15", features = ["serde"] } 11 | rand = "0.9.2" 12 | ratatui = "0.29.0" 13 | regex = "1.10.2" 14 | ron = "0.10.1" 15 | serde = { version = "1.0.219", features = ["derive"] } 16 | synonym = { version = "0.1.6", features = ["with_serde"] } 17 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | use std::path::PathBuf; 3 | 4 | #[derive(Parser, Debug)] 5 | #[command(author = clap::crate_authors!(), version, about, long_about = None, help_template = "\ 6 | {before-help}{name} {version} 7 | by {author-with-newline}{about-with-newline} 8 | {usage-heading} {usage} 9 | 10 | {all-args}{after-help} 11 | ")] 12 | #[command(propagate_version = true)] 13 | pub struct Cli { 14 | #[command(subcommand)] 15 | pub command: Option, 16 | 17 | /// The existing .kantt project file to load (for interactive mode) 18 | pub project: Option, 19 | } 20 | 21 | #[derive(Subcommand, Debug)] 22 | pub enum Commands { 23 | /// Render a project to text format 24 | Render { 25 | /// The .kantt project file to render 26 | project: PathBuf, 27 | 28 | /// Output file path (if not specified, outputs to stdout) 29 | #[arg(short, long)] 30 | output: Option, 31 | }, 32 | } 33 | 34 | pub fn cli() -> Cli { 35 | Cli::parse() 36 | } 37 | -------------------------------------------------------------------------------- /src/domain/task.rs: -------------------------------------------------------------------------------- 1 | use jiff::civil::Date; 2 | use serde::{Deserialize, Serialize}; 3 | use synonym::Synonym; 4 | 5 | #[derive(Synonym)] 6 | pub struct TaskId(pub String); 7 | 8 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] 9 | pub struct Task { 10 | /// One-line description of the task 11 | pub description: String, 12 | /// Optional (hard) start date of the task 13 | pub start_date: Option, 14 | /// Estimated duration of the task in days 15 | pub estimated_days: u32, 16 | /// All tasks that block this task 17 | pub blockers: Vec, 18 | } 19 | 20 | #[derive(Synonym)] 21 | pub struct PersonAllocation(pub f64); 22 | 23 | impl Task { 24 | pub fn set_description(&mut self, description: S) -> &mut Self { 25 | self.description = description.to_string(); 26 | self 27 | } 28 | 29 | pub fn set_start_date(&mut self, date: Option) -> &mut Self { 30 | self.start_date = date; 31 | self 32 | } 33 | 34 | pub fn set_estimated_days(&mut self, days: u32) -> &mut Self { 35 | self.estimated_days = days; 36 | self 37 | } 38 | 39 | pub fn add_blocker(&mut self, blocker_id: TaskId) -> &mut Self { 40 | if !self.blockers.contains(&blocker_id) { 41 | self.blockers.push(blocker_id); // Add blocker if not already present 42 | } 43 | self 44 | } 45 | 46 | pub fn remove_blocker(&mut self, blocker_id: &TaskId) -> &mut Self { 47 | self.blockers.retain(|id| id != blocker_id); // Remove the blocker if it exists 48 | self 49 | } 50 | 51 | pub fn clear_blockers(&mut self) -> &mut Self { 52 | self.blockers.clear(); // Clear all blockers 53 | self 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/tui/widgets/success_modal.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | prelude::*, 3 | text::{Line, Span}, 4 | widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget, Wrap}, 5 | }; 6 | 7 | pub struct SuccessModal<'a> { 8 | title: &'a str, 9 | message: &'a str, 10 | } 11 | 12 | impl<'a> SuccessModal<'a> { 13 | pub fn new(title: &'a str, message: &'a str) -> Self { 14 | Self { title, message } 15 | } 16 | } 17 | 18 | impl<'a> Widget for SuccessModal<'a> { 19 | fn render(self, area: Rect, buf: &mut Buffer) { 20 | // Center the modal 21 | let modal_width = 50; 22 | let modal_height = 6; 23 | let modal_area = Rect { 24 | x: (area.width.saturating_sub(modal_width)) / 2, 25 | y: (area.height.saturating_sub(modal_height)) / 2, 26 | width: modal_width, 27 | height: modal_height, 28 | }; 29 | 30 | // Clear the area first 31 | Clear.render(modal_area, buf); 32 | 33 | let block = Block::default() 34 | .title(Span::styled(self.title, Style::default().fg(Color::Green))) 35 | .borders(Borders::ALL) 36 | .border_type(BorderType::Rounded) 37 | .border_style(Style::default().fg(Color::Green)); 38 | 39 | // Create success message paragraph with regular background 40 | let paragraph = Paragraph::new(self.message) 41 | .block(block) 42 | .wrap(Wrap { trim: true }); 43 | 44 | // Render the modal 45 | paragraph.render(modal_area, buf); 46 | 47 | // Add footer with instruction 48 | let footer_y = modal_area.y + modal_area.height; 49 | if footer_y < area.height { 50 | let footer_line = Line::from(vec![ 51 | Span::styled("Press ", Style::default().fg(Color::DarkGray)), 52 | Span::styled("ESC", Style::default().fg(Color::White)), 53 | Span::styled(" to close", Style::default().fg(Color::DarkGray)), 54 | ]); 55 | 56 | let footer_width = footer_line.width() as u16; 57 | let footer_x = modal_area.x + (modal_area.width.saturating_sub(footer_width)) / 2; 58 | buf.set_line(footer_x, footer_y, &footer_line, footer_width); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/tui/widgets/error_modal.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | prelude::{Buffer, Color, Rect, Style}, 3 | text::{Line, Span}, 4 | widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget, Wrap}, 5 | }; 6 | 7 | pub struct ErrorModal<'a> { 8 | title: String, 9 | message: &'a str, 10 | } 11 | 12 | impl<'a> ErrorModal<'a> { 13 | pub fn new(title: impl Into, message: &'a str) -> Self { 14 | Self { 15 | title: title.into(), 16 | message, 17 | } 18 | } 19 | } 20 | 21 | impl<'a> Widget for ErrorModal<'a> { 22 | fn render(self, area: Rect, buf: &mut Buffer) { 23 | // Calculate modal size and position (centered) 24 | let modal_width = (area.width * 3 / 4).min(60); 25 | let modal_height = (area.height / 3).min(10); 26 | let x = (area.width.saturating_sub(modal_width)) / 2; 27 | let y = (area.height.saturating_sub(modal_height)) / 2; 28 | 29 | let modal_area = Rect { 30 | x: area.x + x, 31 | y: area.y + y, 32 | width: modal_width, 33 | height: modal_height, 34 | }; 35 | 36 | // Clear the area behind the modal 37 | Clear.render(modal_area, buf); 38 | 39 | // Create the modal block with error styling (red border and title) 40 | let block = Block::default() 41 | .title(Span::styled(self.title, Style::default().fg(Color::Red))) 42 | .borders(Borders::ALL) 43 | .border_type(BorderType::Rounded) 44 | .border_style(Style::default().fg(Color::Red)); 45 | 46 | // Create error message paragraph with regular background 47 | let paragraph = Paragraph::new(self.message) 48 | .block(block) 49 | .wrap(Wrap { trim: true }); 50 | 51 | // Render the modal 52 | paragraph.render(modal_area, buf); 53 | 54 | // Add footer with instruction 55 | let footer_y = modal_area.y + modal_area.height; 56 | if footer_y < area.height { 57 | let footer_line = Line::from(vec![ 58 | Span::styled("Press ", Style::default().fg(Color::DarkGray)), 59 | Span::styled("ESC", Style::default().fg(Color::White)), 60 | Span::styled(" to close", Style::default().fg(Color::DarkGray)), 61 | ]); 62 | 63 | let footer_width = footer_line.width() as u16; 64 | let footer_x = modal_area.x + (modal_area.width.saturating_sub(footer_width)) / 2; 65 | buf.set_line(footer_x, footer_y, &footer_line, footer_width); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/tui/widgets/confirm_modal.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | prelude::{Buffer, Color, Rect, Style}, 3 | text::{Line, Span}, 4 | widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget, Wrap}, 5 | }; 6 | 7 | pub struct ConfirmModal<'a> { 8 | title: String, 9 | message: &'a str, 10 | } 11 | 12 | impl<'a> ConfirmModal<'a> { 13 | pub fn new(title: impl Into, message: &'a str) -> Self { 14 | Self { 15 | title: title.into(), 16 | message, 17 | } 18 | } 19 | } 20 | 21 | impl<'a> Widget for ConfirmModal<'a> { 22 | fn render(self, area: Rect, buf: &mut Buffer) { 23 | // Calculate modal size and position (centered) 24 | let modal_width = (area.width * 3 / 4).min(60); 25 | let modal_height = (area.height / 3).min(10); 26 | let x = (area.width.saturating_sub(modal_width)) / 2; 27 | let y = (area.height.saturating_sub(modal_height)) / 2; 28 | 29 | let modal_area = Rect { 30 | x: area.x + x, 31 | y: area.y + y, 32 | width: modal_width, 33 | height: modal_height, 34 | }; 35 | 36 | // Clear the area behind the modal 37 | Clear.render(modal_area, buf); 38 | 39 | // Create the modal block with warning styling (yellow border and title) 40 | let block = Block::default() 41 | .title(Span::styled(self.title, Style::default().fg(Color::Yellow))) 42 | .borders(Borders::ALL) 43 | .border_type(BorderType::Rounded) 44 | .border_style(Style::default().fg(Color::Yellow)); 45 | 46 | // Create confirmation message paragraph 47 | let paragraph = Paragraph::new(self.message) 48 | .block(block) 49 | .wrap(Wrap { trim: true }); 50 | 51 | // Render the modal 52 | paragraph.render(modal_area, buf); 53 | 54 | // Add footer with confirmation instructions 55 | let footer_y = modal_area.y + modal_area.height; 56 | if footer_y < area.height { 57 | let footer_line = Line::from(vec![ 58 | Span::styled("Press ", Style::default().fg(Color::DarkGray)), 59 | Span::styled("y", Style::default().fg(Color::Green)), 60 | Span::styled(" to confirm, ", Style::default().fg(Color::DarkGray)), 61 | Span::styled("n", Style::default().fg(Color::Red)), 62 | Span::styled(" to cancel", Style::default().fg(Color::DarkGray)), 63 | ]); 64 | 65 | let footer_width = footer_line.width() as u16; 66 | let footer_x = modal_area.x + (modal_area.width.saturating_sub(footer_width)) / 2; 67 | buf.set_line(footer_x, footer_y, &footer_line, footer_width); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/tui/mod.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyEvent; 2 | use ratatui::prelude::*; 3 | 4 | use crate::domain::Project; 5 | 6 | mod edit_task; 7 | mod gantt; 8 | mod link_task; 9 | mod widgets; 10 | 11 | pub struct Model { 12 | project: Project, 13 | screen: Screen, 14 | project_filename: Option, 15 | } 16 | 17 | pub enum Screen { 18 | Gantt(Box), 19 | EditTask(Box), 20 | LinkTask(Box), 21 | } 22 | 23 | pub enum Message { 24 | Tick, 25 | KeyboardEvent(KeyEvent), 26 | } 27 | 28 | pub enum Command { 29 | Quit, 30 | ChangeScreen(Box), 31 | } 32 | 33 | impl Model { 34 | pub fn new(project: Project) -> Self { 35 | Self { 36 | project, 37 | screen: Screen::Gantt(Box::default()), 38 | project_filename: None, 39 | } 40 | } 41 | 42 | pub fn new_with_filename(project: Project, filename: String) -> Self { 43 | Self { 44 | project, 45 | screen: Screen::Gantt(Box::default()), 46 | project_filename: Some(filename), 47 | } 48 | } 49 | 50 | pub fn view(&mut self, frame: &mut Frame<'_>) { 51 | match &mut self.screen { 52 | Screen::Gantt(gantt_model) => { 53 | let project = &self.project; 54 | let filename = self.project_filename.as_deref(); 55 | gantt_model.view(project, filename, frame) 56 | } 57 | Screen::EditTask(edit_task_model) => edit_task_model.view(&self.project, frame), 58 | Screen::LinkTask(link_task_model) => link_task::render(link_task_model, frame), 59 | } 60 | } 61 | 62 | pub fn set_screen(&mut self, screen: Screen) { 63 | self.screen = screen; 64 | } 65 | 66 | pub fn project(&self) -> &Project { 67 | &self.project 68 | } 69 | 70 | pub fn project_filename(&self) -> Option<&str> { 71 | self.project_filename.as_deref() 72 | } 73 | 74 | pub fn update(mut self, message: Message) -> (Model, Option) { 75 | match self.screen { 76 | Screen::Gantt(gantt_model) => { 77 | let filename = self.project_filename.as_deref(); 78 | let (new_gantt_model, command) = 79 | gantt_model.update(&mut self.project, filename, message); 80 | ( 81 | Model { 82 | project: self.project, 83 | screen: Screen::Gantt(Box::new(new_gantt_model)), 84 | project_filename: self.project_filename, 85 | }, 86 | command, 87 | ) 88 | } 89 | Screen::EditTask(edit_task_model) => { 90 | let (new_edit_task_model, command) = 91 | (*edit_task_model).update(&mut self.project, message); 92 | ( 93 | Model { 94 | project: self.project, 95 | screen: Screen::EditTask(Box::new(new_edit_task_model)), 96 | project_filename: self.project_filename, 97 | }, 98 | command, 99 | ) 100 | } 101 | Screen::LinkTask(mut link_task_model) => { 102 | let action = match message { 103 | Message::KeyboardEvent(key_event) => { 104 | (*link_task_model).handle_key(key_event.code) 105 | } 106 | _ => link_task::ScreenAction::Continue, 107 | }; 108 | 109 | match action { 110 | link_task::ScreenAction::Continue => ( 111 | Model { 112 | project: self.project, 113 | screen: Screen::LinkTask(link_task_model), 114 | project_filename: self.project_filename, 115 | }, 116 | None, 117 | ), 118 | link_task::ScreenAction::GoToGantt => { 119 | // Apply blocker changes to the project 120 | let updated_blockers = (*link_task_model).get_updated_blockers(); 121 | if let Some(task) = self 122 | .project 123 | .tasks 124 | .get_mut(&link_task_model.selected_task_id) 125 | { 126 | task.blockers = updated_blockers; 127 | } 128 | 129 | ( 130 | Model { 131 | project: self.project, 132 | screen: Screen::Gantt(Box::default()), 133 | project_filename: self.project_filename, 134 | }, 135 | None, 136 | ) 137 | } 138 | } 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{io::stdout, panic, time::Duration}; 2 | 3 | use color_eyre::{Result, eyre::WrapErr}; 4 | use crossterm::event::Event; 5 | use ratatui::{ 6 | Terminal, 7 | crossterm::{ 8 | ExecutableCommand, 9 | terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 10 | }, 11 | prelude::{Backend, CrosstermBackend}, 12 | }; 13 | 14 | use crate::tui::Message; 15 | 16 | pub mod cli; 17 | pub mod domain; 18 | pub mod render; 19 | pub mod tui; 20 | pub mod util; 21 | 22 | fn load_project_from_file(path: &std::path::Path) -> Result { 23 | let project_str = std::fs::read_to_string(path) 24 | .wrap_err_with(|| format!("failed to read project file: {}", path.display()))?; 25 | let project: domain::Project = ron::from_str(&project_str) 26 | .wrap_err_with(|| format!("failed to parse project file: {}", path.display()))?; 27 | Ok(project) 28 | } 29 | 30 | fn main() -> Result<()> { 31 | let cli::Cli { command, project } = cli::cli(); 32 | if let Some(command) = command { 33 | match command { 34 | cli::Commands::Render { 35 | project: project_path, 36 | output, 37 | } => { 38 | let project = load_project_from_file(&project_path).wrap_err_with(|| { 39 | format!("failed to load project file: {}", project_path.display()) 40 | })?; 41 | let rendered = render::render_project(&project).wrap_err_with(|| { 42 | format!("failed to render project: {}", project_path.display()) 43 | })?; 44 | 45 | if let Some(output_path) = output { 46 | std::fs::write(&output_path, rendered).wrap_err_with(|| { 47 | format!("failed to write output file: {}", output_path.display()) 48 | })?; 49 | println!("Rendered project to {}", output_path.display()); 50 | } else { 51 | println!("{}", rendered); 52 | } 53 | 54 | return Ok(()); 55 | } 56 | } 57 | } 58 | 59 | let (project, filename) = if let Some(project_path) = &project { 60 | let project = load_project_from_file(project_path) 61 | .wrap_err_with(|| format!("failed to load project file: {}", project_path.display()))?; 62 | 63 | // Convert path to relative if it's in current directory 64 | let filename = 65 | if let Ok(relative_path) = project_path.strip_prefix(std::env::current_dir()?) { 66 | relative_path.to_string_lossy().to_string() 67 | } else { 68 | project_path.to_string_lossy().to_string() 69 | }; 70 | 71 | (project, Some(filename)) 72 | } else { 73 | (domain::Project::default(), None) 74 | }; 75 | 76 | install_panic_hook(); 77 | let mut terminal = init_terminal().wrap_err("failed to initialize terminal")?; 78 | let result = run_tui(&mut terminal, project, filename); 79 | restore_terminal().wrap_err("failed to restore terminal")?; 80 | result 81 | } 82 | 83 | fn run_tui( 84 | terminal: &mut Terminal, 85 | project: domain::Project, 86 | filename: Option, 87 | ) -> Result<()> { 88 | let mut model = if let Some(filename) = filename { 89 | tui::Model::new_with_filename(project, filename) 90 | } else { 91 | tui::Model::new(project) 92 | }; 93 | 94 | loop { 95 | terminal.draw(|frame| { 96 | model.view(frame); 97 | })?; 98 | 99 | let message = if crossterm::event::poll(Duration::from_millis(100)) 100 | .wrap_err_with(|| "Failed to poll terminal events")? 101 | { 102 | if let Event::Key(key_event) = 103 | crossterm::event::read().wrap_err_with(|| "Failed to read terminal event")? 104 | { 105 | Message::KeyboardEvent(key_event) 106 | } else { 107 | Message::Tick 108 | } 109 | } else { 110 | Message::Tick 111 | }; 112 | 113 | let (mut new_model, command) = model.update(message); 114 | match command { 115 | None => {} 116 | Some(tui::Command::Quit) => break, 117 | Some(tui::Command::ChangeScreen(screen)) => { 118 | new_model.set_screen(*screen); 119 | } 120 | } 121 | model = new_model; 122 | } 123 | 124 | Ok(()) 125 | } 126 | 127 | fn init_terminal() -> Result> { 128 | enable_raw_mode().wrap_err("failed to enable raw mode")?; 129 | stdout() 130 | .execute(EnterAlternateScreen) 131 | .wrap_err("failed to enter alternate screen")?; 132 | let terminal = Terminal::new(CrosstermBackend::new(std::io::stdout())) 133 | .wrap_err("failed to create terminal")?; 134 | Ok(terminal) 135 | } 136 | 137 | fn restore_terminal() -> Result<()> { 138 | stdout() 139 | .execute(LeaveAlternateScreen) 140 | .wrap_err("failed to leave alternate screen")?; 141 | disable_raw_mode().wrap_err("failed to disable raw mode")?; 142 | Ok(()) 143 | } 144 | 145 | fn install_panic_hook() { 146 | let original_hook = panic::take_hook(); 147 | panic::set_hook(Box::new(move |info| { 148 | stdout() 149 | .execute(LeaveAlternateScreen) 150 | .expect("can leave alternate screen on panic"); 151 | disable_raw_mode().expect("can disable raw mode on panic"); 152 | original_hook(info); 153 | })); 154 | } 155 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Commands 6 | 7 | **Build and run:** 8 | ```bash 9 | cargo build 10 | cargo run [project-file.kantt] 11 | ``` 12 | 13 | **Testing:** 14 | ```bash 15 | cargo test 16 | ``` 17 | 18 | **Development:** 19 | ```bash 20 | cargo check # Fast compilation check 21 | cargo clippy # Linting 22 | cargo fmt # Code formatting 23 | ``` 24 | 25 | ## Architecture 26 | 27 | This is a Rust-based terminal UI application for project management and task scheduling, built with: 28 | 29 | - **TUI Framework**: ratatui with crossterm backend for terminal interface 30 | - **CLI**: clap for command-line argument parsing 31 | - **Data Format**: RON (Rusty Object Notation) for project file serialization 32 | - **Date/Time**: jiff for date handling 33 | - **Error Handling**: color-eyre for enhanced error reporting 34 | 35 | ### Core Structure 36 | 37 | - `main.rs`: Application entry point with terminal initialization and project loading 38 | - `cli.rs`: Command-line interface definition 39 | - `domain/`: Core business logic 40 | - `project.rs`: Project management with task collections and dependency detection 41 | - `task.rs`: Task definition with blockers, estimates, and metadata 42 | 43 | ### Domain Model 44 | 45 | **Project**: Container for tasks with dependency management capabilities. Key features: 46 | - Task creation and retrieval by ID 47 | - **Cyclic dependency detection** using DFS with recursion stack 48 | - **Task scheduling algorithm** with topological sorting (Kahn's algorithm) 49 | - **Weekend-aware scheduling** that skips Saturdays and Sundays 50 | - **Task ID renaming** with atomic blocker relationship updates via `rename_task_id()` 51 | - RON serialization for persistence 52 | 53 | **Task**: Individual work items with: 54 | - String-based TaskId using synonym pattern 55 | - Description, estimated duration, optional start date 56 | - Blocker relationships for dependency management 57 | - Fluent API for modifications 58 | 59 | ### Scheduling Algorithm 60 | 61 | The `schedule_tasks()` method implements a complete task scheduling system: 62 | 1. **Dependency validation**: Detects cyclic dependencies before scheduling 63 | 2. **Project start date**: Uses earliest hard start date or today's date 64 | 3. **Topological sorting**: Orders tasks based on dependencies using Kahn's algorithm 65 | 4. **Weekend skipping**: Automatically skips weekends when scheduling task days 66 | 5. **Dependency enforcement**: Ensures dependent tasks start after blockers complete 67 | 68 | ### Terminal User Interface (TUI) 69 | 70 | The application features a complete terminal UI built with ratatui: 71 | 72 | **Main Components:** 73 | - `main.rs`: Complete TUI event loop with 100ms tick system and keyboard handling 74 | - `tui/`: Comprehensive TUI implementation with modular screen architecture 75 | 76 | **Screen Management:** 77 | - **Gantt Screen** (`tui/gantt.rs`): Main project view with comprehensive features: 78 | - Task creation with custom ID input modal ('n' key) 79 | - **Multi-directional sorting** with Tab/Shift+Tab cycling through 8 sort combinations 80 | - **Responsive layout** with auto-adjusting description column (10-30 chars) 81 | - **Schedule display** with proper task ID truncation (16-char width) 82 | - **EditTask Screen** (`tui/edit_task.rs`): Form-based task editing with: 83 | - **Task ID editing** with blocker relationship preservation 84 | - Text input fields for description and estimated days 85 | - Date picker for start date selection 86 | - **LinkTask Screen**: Task dependency management interface 87 | - **Modal system**: Error/success handling with dismissible modals (ESC key) 88 | 89 | **Reusable Widgets** (`tui/widgets/`): 90 | - **Keys** (`keys.rs`): **Responsive multi-line key legend** that dynamically adjusts height based on terminal width 91 | - **TaskIdModal** (`task_id_modal.rs`): Custom task ID input with validation 92 | - **SaveModal** (`save_modal.rs`): Project save interface with overwrite confirmation 93 | - **ErrorModal/SuccessModal**: Centered status display with styled borders 94 | - **TextInput** (`text_input.rs`): StatefulWidget with cursor blinking and full text editing 95 | 96 | **Key Features:** 97 | - **Event-driven architecture**: Tick-based updates with keyboard event handling 98 | - **Screen transitions**: Seamless navigation between gantt, task editing, and linking 99 | - **Visual feedback**: Cursor blinking (500ms), focus indicators, styled borders 100 | - **Error handling**: Modal error display with proper input bypassing 101 | - **Text editing**: Full keyboard support including shortcuts (Ctrl+A/E/U) 102 | - **Sorting system**: Tab/Shift+Tab cycling through Start Date, End Date, Duration, and Task ID (ascending/descending) 103 | - **Responsive design**: Keys view automatically wraps to multiple lines on narrow terminals 104 | 105 | **Workflow:** 106 | 1. Gantt screen shows project overview with sorting controls and task management 107 | 2. Task creation opens TaskIdModal for custom ID input, then EditTask screen 108 | 3. Task editing supports ID changes with automatic blocker relationship updates 109 | 4. Comprehensive keyboard navigation with visual sort indicators 110 | 5. Responsive UI adapts to terminal size changes 111 | 112 | ## Code Quality 113 | 114 | - **Clippy Clean**: All linting warnings resolved 115 | - **Well Tested**: 32 comprehensive unit tests covering sorting, UI responsiveness, and domain logic 116 | - **Performance Optimized**: Boxed large enum variants to reduce memory footprint 117 | - **Clean Architecture**: Refactored functions with parameter grouping (RenderParams struct) -------------------------------------------------------------------------------- /src/tui/widgets/task_id_modal.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyEvent}; 2 | use ratatui::{ 3 | layout::{Constraint, Layout, Margin}, 4 | prelude::*, 5 | text::{Line, Span}, 6 | widgets::{Block, BorderType, Borders, Clear, StatefulWidget}, 7 | }; 8 | 9 | use super::text_input::{TextInput, TextInputState}; 10 | use crate::domain::{Project, TaskId}; 11 | 12 | #[derive(Debug, Clone)] 13 | pub struct TaskIdModalState { 14 | text_input: TextInput, 15 | text_input_state: TextInputState, 16 | } 17 | 18 | impl TaskIdModalState { 19 | pub fn new() -> Self { 20 | let mut text_input_state = TextInputState::default(); 21 | text_input_state.focus(); 22 | 23 | Self { 24 | text_input: TextInput::new().with_placeholder("Enter task ID..."), 25 | text_input_state, 26 | } 27 | } 28 | 29 | pub fn handle_key_event( 30 | &mut self, 31 | key_event: KeyEvent, 32 | project: &Project, 33 | ) -> TaskIdModalAction { 34 | match key_event.code { 35 | KeyCode::Esc => TaskIdModalAction::Cancel, 36 | KeyCode::Enter => { 37 | let task_id = self.text_input_state.content().trim(); 38 | if task_id.is_empty() { 39 | TaskIdModalAction::Error("Task ID cannot be empty".to_string()) 40 | } else if project.tasks.contains_key(&TaskId(task_id.to_string())) { 41 | TaskIdModalAction::Error(format!("Task '{}' already exists", task_id)) 42 | } else if !is_valid_task_id(task_id) { 43 | TaskIdModalAction::Error( 44 | "Task ID can only contain letters, numbers, hyphens, and underscores" 45 | .to_string(), 46 | ) 47 | } else { 48 | TaskIdModalAction::Create(TaskId(task_id.to_string())) 49 | } 50 | } 51 | _ => { 52 | self.text_input_state 53 | .handle_key_event(key_event, &self.text_input); 54 | TaskIdModalAction::Continue 55 | } 56 | } 57 | } 58 | 59 | pub fn handle_tick(&mut self) { 60 | self.text_input_state.handle_tick(); 61 | } 62 | } 63 | 64 | #[derive(Debug)] 65 | pub enum TaskIdModalAction { 66 | Continue, 67 | Cancel, 68 | Create(TaskId), 69 | Error(String), 70 | } 71 | 72 | pub struct TaskIdModal; 73 | 74 | impl TaskIdModal { 75 | pub fn new() -> Self { 76 | Self 77 | } 78 | } 79 | 80 | impl StatefulWidget for TaskIdModal { 81 | type State = TaskIdModalState; 82 | 83 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 84 | let block = Block::default() 85 | .title(" New Task ") 86 | .borders(Borders::ALL) 87 | .border_type(BorderType::Rounded) 88 | .border_style(Style::default().fg(Color::Green)); 89 | 90 | // Center the modal 91 | let modal_width = 50; 92 | let modal_height = 5; 93 | 94 | let modal_area = Rect { 95 | x: (area.width.saturating_sub(modal_width)) / 2, 96 | y: (area.height.saturating_sub(modal_height)) / 2, 97 | width: modal_width, 98 | height: modal_height, 99 | }; 100 | 101 | // Clear the area and render the modal 102 | Clear.render(modal_area, buf); 103 | block.render(modal_area, buf); 104 | 105 | let inner_area = modal_area.inner(Margin { 106 | vertical: 1, 107 | horizontal: 2, 108 | }); 109 | 110 | let text_layout = Layout::default() 111 | .direction(ratatui::layout::Direction::Vertical) 112 | .constraints([ 113 | Constraint::Length(1), // "Task ID:" label 114 | Constraint::Length(1), // Text input 115 | Constraint::Length(1), // Space 116 | ]) 117 | .split(inner_area); 118 | 119 | // Task ID label 120 | buf.set_string( 121 | text_layout[0].x, 122 | text_layout[0].y, 123 | "Task ID:", 124 | Style::default(), 125 | ); 126 | 127 | // Text input - render the actual TextInput widget 128 | state 129 | .text_input 130 | .clone() 131 | .render(text_layout[1], buf, &mut state.text_input_state); 132 | 133 | // Add footer with keys help underneath the modal 134 | let footer_y = modal_area.y + modal_area.height; 135 | if footer_y < area.height { 136 | let footer_line = Line::from(vec![ 137 | Span::styled("Press ", Style::default().fg(Color::DarkGray)), 138 | Span::styled("Enter", Style::default().fg(Color::White)), 139 | Span::styled(" to create task, ", Style::default().fg(Color::DarkGray)), 140 | Span::styled("Esc", Style::default().fg(Color::White)), 141 | Span::styled(" to cancel", Style::default().fg(Color::DarkGray)), 142 | ]); 143 | 144 | let footer_width = footer_line.width() as u16; 145 | let footer_x = modal_area.x + (modal_area.width.saturating_sub(footer_width)) / 2; 146 | buf.set_line(footer_x, footer_y, &footer_line, footer_width); 147 | } 148 | } 149 | } 150 | 151 | // Validate task ID contains only alphanumeric characters, hyphens, and underscores 152 | fn is_valid_task_id(task_id: &str) -> bool { 153 | task_id 154 | .chars() 155 | .all(|c| c.is_alphanumeric() || c == '-' || c == '_') 156 | } 157 | 158 | #[cfg(test)] 159 | mod tests { 160 | use super::*; 161 | 162 | #[test] 163 | fn test_valid_task_ids() { 164 | assert!(is_valid_task_id("task1")); 165 | assert!(is_valid_task_id("task-1")); 166 | assert!(is_valid_task_id("task_1")); 167 | assert!(is_valid_task_id("Task123")); 168 | assert!(is_valid_task_id("feature-abc_123")); 169 | } 170 | 171 | #[test] 172 | fn test_invalid_task_ids() { 173 | assert!(!is_valid_task_id("task 1")); // space 174 | assert!(!is_valid_task_id("task.1")); // dot 175 | assert!(!is_valid_task_id("task@1")); // special char 176 | assert!(!is_valid_task_id("task/1")); // slash 177 | // Note: empty string passes is_valid_task_id but is handled separately in modal logic 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/tui/widgets/keys.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use ratatui::{ 4 | prelude::{Buffer, Color, Rect, Style}, 5 | text::{Line, Span}, 6 | widgets::Widget, 7 | }; 8 | 9 | #[derive(Default)] 10 | pub struct Keys { 11 | pub keys: BTreeMap, 12 | } 13 | 14 | impl Keys { 15 | pub fn with_key(mut self, key: S1, description: S2) -> Self { 16 | self.keys.insert(key.to_string(), description.to_string()); 17 | self 18 | } 19 | 20 | /// Calculate the height needed to render all keys given the available width 21 | pub fn calculate_height(&self, available_width: u16) -> u16 { 22 | if self.keys.is_empty() { 23 | return 0; 24 | } 25 | 26 | let lines = self.build_lines(available_width); 27 | lines.len() as u16 28 | } 29 | 30 | /// Build the lines of spans that will fit within the given width 31 | fn build_lines(&self, available_width: u16) -> Vec> { 32 | let mut lines = Vec::new(); 33 | let mut current_spans = Vec::new(); 34 | let mut current_width = 0u16; 35 | 36 | let margin = 2; // Account for padding 37 | let usable_width = available_width.saturating_sub(margin); 38 | 39 | for (i, (key, description)) in self.keys.iter().enumerate() { 40 | let mut key_spans = Vec::new(); 41 | 42 | // Add separator for items after the first 43 | if i > 0 { 44 | key_spans.push(Span::styled(" • ", Style::default().fg(Color::DarkGray))); 45 | } 46 | 47 | key_spans.push(Span::styled(key.clone(), Style::default().fg(Color::White))); 48 | key_spans.push(Span::styled(": ", Style::default().fg(Color::DarkGray))); 49 | key_spans.push(Span::styled( 50 | description.clone(), 51 | Style::default().fg(Color::DarkGray), 52 | )); 53 | 54 | // Calculate the width of this key group 55 | let key_group_width: u16 = key_spans.iter().map(|span| span.content.len() as u16).sum(); 56 | 57 | // Check if this key group fits on the current line 58 | let would_fit = if current_spans.is_empty() { 59 | key_group_width <= usable_width 60 | } else { 61 | current_width + key_group_width <= usable_width 62 | }; 63 | 64 | if would_fit { 65 | // Add to current line 66 | current_spans.extend(key_spans); 67 | current_width += key_group_width; 68 | } else { 69 | // Start a new line 70 | if !current_spans.is_empty() { 71 | lines.push(Line::from(current_spans.clone())); 72 | current_spans.clear(); 73 | current_width = 0; 74 | } 75 | 76 | // Add this key group to the new line 77 | // Remove the separator span if this is the first item on the line 78 | let had_separator = key_spans.first().map(|s| s.content.as_ref()) == Some(" • "); 79 | if had_separator { 80 | key_spans.remove(0); 81 | } 82 | 83 | current_spans.extend(key_spans); 84 | current_width += key_group_width; 85 | if had_separator { 86 | current_width -= 3; // Account for removed separator 87 | } 88 | } 89 | } 90 | 91 | // Add the last line if it has content 92 | if !current_spans.is_empty() { 93 | lines.push(Line::from(current_spans)); 94 | } 95 | 96 | // Ensure we have at least one empty line if no keys 97 | if lines.is_empty() { 98 | lines.push(Line::from("")); 99 | } 100 | 101 | lines 102 | } 103 | } 104 | 105 | impl Widget for Keys { 106 | fn render(self, area: Rect, buf: &mut Buffer) 107 | where 108 | Self: Sized, 109 | { 110 | let lines = self.build_lines(area.width); 111 | 112 | for (i, line) in lines.iter().enumerate() { 113 | let y = area.y + i as u16; 114 | if y >= area.y + area.height { 115 | break; // Don't render beyond the allocated area 116 | } 117 | 118 | let line_width = line.width() as u16; 119 | let x_offset = if area.width > line_width + 2 { 120 | area.x + area.width - line_width - 1 // Right-align 121 | } else { 122 | area.x + 1 // Left-align if it doesn't fit 123 | }; 124 | 125 | buf.set_line(x_offset, y, line, area.width.saturating_sub(2)); 126 | } 127 | } 128 | } 129 | 130 | #[cfg(test)] 131 | mod tests { 132 | use super::*; 133 | 134 | #[test] 135 | fn test_keys_single_line_fits() { 136 | let keys = Keys::default().with_key("q", "quit").with_key("s", "save"); 137 | 138 | // Should fit on a single line with reasonable width 139 | let height = keys.calculate_height(100); 140 | assert_eq!(height, 1); 141 | } 142 | 143 | #[test] 144 | fn test_keys_multi_line_when_narrow() { 145 | let keys = Keys::default() 146 | .with_key("q", "quit") 147 | .with_key("s", "save") 148 | .with_key("n", "new task") 149 | .with_key("e", "edit task") 150 | .with_key("d", "delete task") 151 | .with_key("Enter", "link tasks") 152 | .with_key("Tab", "cycle sort") 153 | .with_key("Shift+Tab", "reverse cycle sort") 154 | .with_key("↑/↓", "select task") 155 | .with_key("←/→", "scroll schedule"); 156 | 157 | // Should require multiple lines when width is very narrow 158 | let height_narrow = keys.calculate_height(30); 159 | assert!( 160 | height_narrow > 1, 161 | "Should require multiple lines for narrow width" 162 | ); 163 | 164 | // Should fit on fewer lines when width is wider 165 | let height_wide = keys.calculate_height(200); 166 | assert!( 167 | height_wide < height_narrow, 168 | "Should require fewer lines for wider width" 169 | ); 170 | assert!(height_wide >= 1, "Should require at least one line"); 171 | } 172 | 173 | #[test] 174 | fn test_keys_empty() { 175 | let keys = Keys::default(); 176 | 177 | let height = keys.calculate_height(100); 178 | assert_eq!(height, 0); 179 | } 180 | 181 | #[test] 182 | fn test_build_lines_formatting() { 183 | let keys = Keys::default() 184 | .with_key("a", "first") 185 | .with_key("b", "second"); 186 | 187 | let lines = keys.build_lines(100); 188 | assert_eq!(lines.len(), 1); 189 | 190 | // Check that the line contains the expected content with separators 191 | let line_text = lines[0] 192 | .spans 193 | .iter() 194 | .map(|span| span.content.as_ref()) 195 | .collect::>() 196 | .join(""); 197 | 198 | assert!(line_text.contains("a: first")); 199 | assert!(line_text.contains("b: second")); 200 | assert!(line_text.contains(" • "), "Should contain separator"); 201 | } 202 | 203 | #[test] 204 | fn test_build_lines_no_leading_separator() { 205 | let keys = Keys::default() 206 | .with_key("a", "first") 207 | .with_key("b", "second"); 208 | 209 | // Force a narrow width to create multiple lines 210 | let lines = keys.build_lines(15); 211 | 212 | // First line should not start with separator 213 | if !lines.is_empty() { 214 | let first_line_text = lines[0] 215 | .spans 216 | .iter() 217 | .map(|span| span.content.as_ref()) 218 | .collect::>() 219 | .join(""); 220 | 221 | assert!( 222 | !first_line_text.starts_with(" • "), 223 | "First line should not start with separator" 224 | ); 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/tui/widgets/save_modal.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyEvent}; 2 | use ratatui::{ 3 | layout::{Constraint, Layout, Margin}, 4 | prelude::*, 5 | text::{Line, Span}, 6 | widgets::{Block, BorderType, Borders, Clear, Paragraph, StatefulWidget, Wrap}, 7 | }; 8 | 9 | use super::text_input::{TextInput, TextInputState}; 10 | use crate::domain::Project; 11 | 12 | #[derive(Debug, Clone)] 13 | pub enum SaveModalState { 14 | FilenameInput { 15 | text_input: Box, 16 | text_input_state: TextInputState, 17 | }, 18 | OverwriteConfirmation { 19 | filename: String, 20 | }, 21 | } 22 | 23 | impl SaveModalState { 24 | pub fn new(initial_filename: String) -> Self { 25 | let mut text_input_state = TextInputState::default(); 26 | text_input_state = text_input_state.with_content(initial_filename.clone()); 27 | text_input_state.focus(); 28 | 29 | Self::FilenameInput { 30 | text_input: Box::new(TextInput::new()), 31 | text_input_state, 32 | } 33 | } 34 | 35 | pub fn handle_key_event( 36 | &mut self, 37 | key_event: KeyEvent, 38 | _project: &mut Project, 39 | ) -> SaveModalAction { 40 | match self { 41 | SaveModalState::FilenameInput { 42 | text_input, 43 | text_input_state, 44 | .. 45 | } => match key_event.code { 46 | KeyCode::Esc => SaveModalAction::Cancel, 47 | KeyCode::Enter => { 48 | let filename = text_input_state.content().trim(); 49 | if filename.is_empty() { 50 | SaveModalAction::Error("Filename cannot be empty".to_string()) 51 | } else if file_exists(filename) { 52 | *self = SaveModalState::OverwriteConfirmation { 53 | filename: filename.to_string(), 54 | }; 55 | SaveModalAction::Continue 56 | } else { 57 | SaveModalAction::Save(filename.to_string()) 58 | } 59 | } 60 | _ => { 61 | text_input_state.handle_key_event(key_event, text_input); 62 | SaveModalAction::Continue 63 | } 64 | }, 65 | SaveModalState::OverwriteConfirmation { filename } => match key_event.code { 66 | KeyCode::Char('y') | KeyCode::Char('Y') => SaveModalAction::Save(filename.clone()), 67 | KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => SaveModalAction::Cancel, 68 | _ => SaveModalAction::Continue, 69 | }, 70 | } 71 | } 72 | 73 | pub fn handle_tick(&mut self) { 74 | if let SaveModalState::FilenameInput { 75 | text_input_state, .. 76 | } = self 77 | { 78 | text_input_state.handle_tick(); 79 | } 80 | } 81 | } 82 | 83 | #[derive(Debug)] 84 | pub enum SaveModalAction { 85 | Continue, 86 | Cancel, 87 | Save(String), 88 | Error(String), 89 | } 90 | 91 | pub struct SaveModal; 92 | 93 | impl SaveModal { 94 | pub fn new() -> Self { 95 | Self 96 | } 97 | } 98 | 99 | impl StatefulWidget for SaveModal { 100 | type State = SaveModalState; 101 | 102 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 103 | let block = Block::default() 104 | .title(" Save Project ") 105 | .borders(Borders::ALL) 106 | .border_type(BorderType::Rounded) 107 | .border_style(Style::default().fg(Color::Blue)); 108 | 109 | // Center the modal 110 | let modal_width = 60; 111 | let modal_height = match state { 112 | SaveModalState::FilenameInput { .. } => 5, // Reduced from 7 since keys moved outside 113 | SaveModalState::OverwriteConfirmation { .. } => 4, // Reduced from 6 since keys moved outside 114 | }; 115 | 116 | let modal_area = Rect { 117 | x: (area.width.saturating_sub(modal_width)) / 2, 118 | y: (area.height.saturating_sub(modal_height)) / 2, 119 | width: modal_width, 120 | height: modal_height, 121 | }; 122 | 123 | // Clear the area and render the modal 124 | Clear.render(modal_area, buf); 125 | block.render(modal_area, buf); 126 | 127 | let inner_area = modal_area.inner(Margin { 128 | vertical: 1, 129 | horizontal: 2, 130 | }); 131 | 132 | match state { 133 | SaveModalState::FilenameInput { 134 | text_input, 135 | text_input_state, 136 | } => { 137 | let text_layout = Layout::default() 138 | .direction(ratatui::layout::Direction::Vertical) 139 | .constraints([ 140 | Constraint::Length(1), // "Filename:" label 141 | Constraint::Length(1), // Text input 142 | Constraint::Length(1), // Space 143 | ]) 144 | .split(inner_area); 145 | 146 | // Filename label 147 | buf.set_string( 148 | text_layout[0].x, 149 | text_layout[0].y, 150 | "Filename:", 151 | Style::default(), 152 | ); 153 | 154 | // Text input - render the actual TextInput widget 155 | (**text_input) 156 | .clone() 157 | .render(text_layout[1], buf, text_input_state); 158 | } 159 | SaveModalState::OverwriteConfirmation { filename } => { 160 | let text_layout = Layout::default() 161 | .direction(ratatui::layout::Direction::Vertical) 162 | .constraints([ 163 | Constraint::Length(2), // Message 164 | ]) 165 | .split(inner_area); 166 | 167 | let message = format!("File '{}' already exists.\nOverwrite it?", filename); 168 | let paragraph = Paragraph::new(message).wrap(Wrap { trim: true }); 169 | paragraph.render(text_layout[0], buf); 170 | } 171 | } 172 | 173 | // Add footer with keys help underneath the modal 174 | let footer_y = modal_area.y + modal_area.height; 175 | if footer_y < area.height { 176 | let footer_line = match state { 177 | SaveModalState::FilenameInput { .. } => Line::from(vec![ 178 | Span::styled("Press ", Style::default().fg(Color::DarkGray)), 179 | Span::styled("Enter", Style::default().fg(Color::White)), 180 | Span::styled(" to save, ", Style::default().fg(Color::DarkGray)), 181 | Span::styled("Esc", Style::default().fg(Color::White)), 182 | Span::styled(" to cancel", Style::default().fg(Color::DarkGray)), 183 | ]), 184 | SaveModalState::OverwriteConfirmation { .. } => Line::from(vec![ 185 | Span::styled("Press ", Style::default().fg(Color::DarkGray)), 186 | Span::styled("y", Style::default().fg(Color::Green)), 187 | Span::styled(" to overwrite, ", Style::default().fg(Color::DarkGray)), 188 | Span::styled("n", Style::default().fg(Color::Red)), 189 | Span::styled(" to cancel", Style::default().fg(Color::DarkGray)), 190 | ]), 191 | }; 192 | 193 | let footer_width = footer_line.width() as u16; 194 | let footer_x = modal_area.x + (modal_area.width.saturating_sub(footer_width)) / 2; 195 | buf.set_line(footer_x, footer_y, &footer_line, footer_width); 196 | } 197 | } 198 | } 199 | 200 | // File operations for saving projects 201 | pub fn save_project_to_file(project: &Project, filename: &str) -> color_eyre::Result<()> { 202 | // Ensure filename ends with .kantt 203 | let filename = if filename.ends_with(".kantt") { 204 | filename.to_string() 205 | } else { 206 | format!("{}.kantt", filename) 207 | }; 208 | 209 | // Serialize project to RON 210 | let ron_string = ron::ser::to_string_pretty(project, ron::ser::PrettyConfig::default()) 211 | .map_err(|e| color_eyre::eyre::eyre!("Failed to serialize project: {}", e))?; 212 | 213 | // Write to file 214 | std::fs::write(&filename, ron_string) 215 | .map_err(|e| color_eyre::eyre::eyre!("Failed to write to file '{}': {}", filename, e))?; 216 | 217 | Ok(()) 218 | } 219 | 220 | fn file_exists(filename: &str) -> bool { 221 | let filename = if filename.ends_with(".kantt") { 222 | filename.to_string() 223 | } else { 224 | format!("{}.kantt", filename) 225 | }; 226 | std::fs::metadata(filename).is_ok() 227 | } 228 | -------------------------------------------------------------------------------- /src/tui/widgets/text_input.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, Instant}; 2 | 3 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 4 | use ratatui::{ 5 | prelude::{Buffer, Color, Rect, Style}, 6 | text::{Line, Span}, 7 | widgets::{Block, StatefulWidget, Widget}, 8 | }; 9 | use regex::Regex; 10 | 11 | #[derive(Clone, Debug, Default)] 12 | pub struct TextInput { 13 | placeholder: String, 14 | block: Option>, 15 | mask_regex: Option, 16 | validator: Option bool>, 17 | } 18 | 19 | #[derive(Debug, Clone)] 20 | pub struct TextInputState { 21 | content: String, 22 | cursor_position: usize, 23 | focused: bool, 24 | cursor_visible: bool, 25 | last_blink_time: Instant, 26 | } 27 | 28 | impl Default for TextInputState { 29 | fn default() -> Self { 30 | Self { 31 | content: String::new(), 32 | cursor_position: 0, 33 | focused: false, 34 | cursor_visible: true, 35 | last_blink_time: Instant::now(), 36 | } 37 | } 38 | } 39 | 40 | impl TextInput { 41 | pub fn new() -> Self { 42 | Self::default() 43 | } 44 | 45 | pub fn with_placeholder(mut self, placeholder: S) -> Self { 46 | self.placeholder = placeholder.to_string(); 47 | self 48 | } 49 | 50 | pub fn with_block(mut self, block: Block<'static>) -> Self { 51 | self.block = Some(block); 52 | self 53 | } 54 | 55 | pub fn with_mask_regex(mut self, regex: Regex) -> Self { 56 | self.mask_regex = Some(regex); 57 | self 58 | } 59 | 60 | pub fn with_validator(mut self, validator: fn(&str) -> bool) -> Self { 61 | self.validator = Some(validator); 62 | self 63 | } 64 | 65 | pub fn number_input() -> Self { 66 | Self::new() 67 | .with_mask_regex(Regex::new(r"^\d*$").unwrap()) 68 | .with_validator(|s| s.is_empty() || s.parse::().is_ok()) 69 | } 70 | 71 | fn is_valid_input(&self, content: &str) -> bool { 72 | // First check regex mask if present 73 | if let Some(ref regex) = self.mask_regex 74 | && !regex.is_match(content) 75 | { 76 | return false; 77 | } 78 | 79 | // Then check validator if present 80 | if let Some(validator) = self.validator 81 | && !validator(content) 82 | { 83 | return false; 84 | } 85 | 86 | true 87 | } 88 | } 89 | 90 | impl TextInputState { 91 | pub fn with_content(mut self, content: S) -> Self { 92 | let content = content.to_string(); 93 | self.cursor_position = content.len(); 94 | self.content = content; 95 | self 96 | } 97 | 98 | pub fn focus(&mut self) { 99 | self.focused = true; 100 | self.cursor_visible = true; 101 | self.last_blink_time = Instant::now(); 102 | } 103 | 104 | pub fn unfocus(&mut self) { 105 | self.focused = false; 106 | self.cursor_visible = false; 107 | } 108 | 109 | pub fn content(&self) -> &str { 110 | &self.content 111 | } 112 | 113 | pub fn handle_key_event(&mut self, key_event: KeyEvent, input: &TextInput) -> bool { 114 | if !self.focused { 115 | return false; 116 | } 117 | 118 | // Reset cursor visibility and blink timer on key press 119 | self.cursor_visible = true; 120 | self.last_blink_time = Instant::now(); 121 | 122 | match key_event.code { 123 | KeyCode::Char(c) => { 124 | if key_event.modifiers.contains(KeyModifiers::CONTROL) { 125 | match c { 126 | 'a' => self.cursor_position = 0, // Move to start 127 | 'e' => self.cursor_position = self.content.len(), // Move to end 128 | 'u' => { 129 | // Clear line (validate empty string) 130 | if input.is_valid_input("") { 131 | self.content.clear(); 132 | self.cursor_position = 0; 133 | } 134 | } 135 | _ => return false, 136 | } 137 | } else { 138 | // Test if inserting character would be valid 139 | let mut test_content = self.content.clone(); 140 | test_content.insert(self.cursor_position, c); 141 | 142 | if input.is_valid_input(&test_content) { 143 | self.content.insert(self.cursor_position, c); 144 | self.cursor_position += 1; 145 | } 146 | } 147 | true 148 | } 149 | KeyCode::Backspace => { 150 | if self.cursor_position > 0 { 151 | let mut test_content = self.content.clone(); 152 | test_content.remove(self.cursor_position - 1); 153 | 154 | if input.is_valid_input(&test_content) { 155 | self.cursor_position -= 1; 156 | self.content.remove(self.cursor_position); 157 | } 158 | } 159 | true 160 | } 161 | KeyCode::Delete => { 162 | if self.cursor_position < self.content.len() { 163 | let mut test_content = self.content.clone(); 164 | test_content.remove(self.cursor_position); 165 | 166 | if input.is_valid_input(&test_content) { 167 | self.content.remove(self.cursor_position); 168 | } 169 | } 170 | true 171 | } 172 | KeyCode::Left => { 173 | if self.cursor_position > 0 { 174 | self.cursor_position -= 1; 175 | } 176 | true 177 | } 178 | KeyCode::Right => { 179 | if self.cursor_position < self.content.len() { 180 | self.cursor_position += 1; 181 | } 182 | true 183 | } 184 | KeyCode::Home => { 185 | self.cursor_position = 0; 186 | true 187 | } 188 | KeyCode::End => { 189 | self.cursor_position = self.content.len(); 190 | true 191 | } 192 | _ => false, 193 | } 194 | } 195 | 196 | pub fn handle_tick(&mut self) { 197 | if self.focused { 198 | let now = Instant::now(); 199 | if now.duration_since(self.last_blink_time) >= Duration::from_millis(500) { 200 | self.cursor_visible = !self.cursor_visible; 201 | self.last_blink_time = now; 202 | } 203 | } 204 | } 205 | } 206 | 207 | impl StatefulWidget for TextInput { 208 | type State = TextInputState; 209 | 210 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 211 | let area = if let Some(block) = &self.block { 212 | let inner = block.inner(area); 213 | block.clone().render(area, buf); 214 | inner 215 | } else { 216 | area 217 | }; 218 | 219 | if area.width < 1 || area.height < 1 { 220 | return; 221 | } 222 | 223 | let text = if state.content.is_empty() && !self.placeholder.is_empty() { 224 | &self.placeholder 225 | } else { 226 | &state.content 227 | }; 228 | 229 | let style = if state.focused { 230 | Style::default().fg(Color::White) 231 | } else { 232 | Style::default().fg(Color::DarkGray) 233 | }; 234 | 235 | let placeholder_style = Style::default().fg(Color::DarkGray); 236 | 237 | // Add input field visual markers 238 | let prefix = if state.focused { "▸ " } else { " " }; 239 | let prefix_span = Span::styled( 240 | prefix, 241 | if state.focused { 242 | Style::default().fg(Color::Cyan) 243 | } else { 244 | Style::default().fg(Color::DarkGray) 245 | }, 246 | ); 247 | 248 | // Create the line with appropriate styling 249 | let mut spans = vec![prefix_span]; 250 | if state.content.is_empty() && !self.placeholder.is_empty() { 251 | spans.push(Span::styled(text, placeholder_style)); 252 | } else { 253 | spans.push(Span::styled(text, style)); 254 | } 255 | 256 | let line = Line::from(spans); 257 | buf.set_line(area.x, area.y, &line, area.width); 258 | 259 | // Render cursor if focused and visible (for blinking effect) 260 | if state.focused && state.cursor_visible { 261 | let cursor_x = area.x + 2 + state.cursor_position as u16; // Account for prefix 262 | if cursor_x < area.x + area.width { 263 | // Show cursor as a prominent block 264 | buf.set_string( 265 | cursor_x, 266 | area.y, 267 | "█", // Use block character for more visible cursor 268 | Style::default().fg(Color::Cyan), 269 | ); 270 | } 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/tui/widgets/date_picker.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyEvent}; 2 | use jiff::civil::Date; 3 | use ratatui::{ 4 | layout::{Constraint, Layout}, 5 | prelude::{Buffer, Color, Rect, Style}, 6 | widgets::{Block, StatefulWidget, Widget}, 7 | }; 8 | 9 | use crate::util; 10 | 11 | #[derive(Clone, Debug, Default)] 12 | pub struct DatePicker { 13 | block: Option>, 14 | } 15 | 16 | #[derive(Debug)] 17 | pub struct DatePickerState { 18 | view_start: Date, // First Sunday of the 5-week view 19 | selected_date: Option, // Currently selected date (None = no date) 20 | cursor_position: (usize, usize), // (row, col) in 5x7 grid 21 | focused: bool, 22 | } 23 | 24 | impl Default for DatePickerState { 25 | fn default() -> Self { 26 | let today = util::today(); 27 | Self::new(today) 28 | } 29 | } 30 | 31 | impl DatePicker { 32 | pub fn new() -> Self { 33 | Self::default() 34 | } 35 | 36 | pub fn with_block(mut self, block: Block<'static>) -> Self { 37 | self.block = Some(block); 38 | self 39 | } 40 | } 41 | 42 | impl DatePickerState { 43 | pub fn new(initial_date: Date) -> Self { 44 | let view_start = Self::calculate_view_start(initial_date); 45 | let (_, col) = Self::date_to_cursor_position(initial_date, view_start); 46 | // Always position cursor in middle row (row 2) 47 | let cursor_position = (2, col); 48 | 49 | Self { 50 | view_start, 51 | selected_date: Some(initial_date), 52 | cursor_position, 53 | focused: false, 54 | } 55 | } 56 | 57 | pub fn with_date(mut self, date: Option) -> Self { 58 | if let Some(date) = date { 59 | self.selected_date = Some(date); 60 | self.view_start = Self::calculate_view_start(date); 61 | let (_, col) = Self::date_to_cursor_position(date, self.view_start); 62 | // Always position cursor in middle row (row 2) 63 | self.cursor_position = (2, col); 64 | } else { 65 | // No date selected - center view on today but don't select it 66 | let today = util::today(); 67 | self.selected_date = None; 68 | self.view_start = Self::calculate_view_start(today); 69 | self.cursor_position = (2, 3); // Center position (Wednesday) 70 | } 71 | self 72 | } 73 | 74 | pub fn focus(&mut self) { 75 | self.focused = true; 76 | } 77 | 78 | pub fn unfocus(&mut self) { 79 | self.focused = false; 80 | } 81 | 82 | pub fn selected_date(&self) -> Option { 83 | self.selected_date 84 | } 85 | 86 | /// Calculate the first Sunday for a 5-week view centered on the given date 87 | fn calculate_view_start(center_date: Date) -> Date { 88 | // Find how many days since the most recent Sunday (0 = Sunday, 1 = Monday, etc.) 89 | let weekday = center_date.weekday(); 90 | let days_since_sunday = match weekday { 91 | jiff::civil::Weekday::Sunday => 0, 92 | jiff::civil::Weekday::Monday => 1, 93 | jiff::civil::Weekday::Tuesday => 2, 94 | jiff::civil::Weekday::Wednesday => 3, 95 | jiff::civil::Weekday::Thursday => 4, 96 | jiff::civil::Weekday::Friday => 5, 97 | jiff::civil::Weekday::Saturday => 6, 98 | }; 99 | 100 | // Find the Sunday of the week containing center_date 101 | let week_start = center_date 102 | .checked_sub(jiff::ToSpan::days(days_since_sunday as i64)) 103 | .unwrap(); 104 | 105 | // Go back 2 weeks to center the view (middle row = row 2) 106 | week_start.checked_sub(jiff::ToSpan::weeks(2)).unwrap() 107 | } 108 | 109 | /// Convert a date to its (row, col) position in the 5x7 grid 110 | fn date_to_cursor_position(date: Date, view_start: Date) -> (usize, usize) { 111 | let days_diff = date.since(view_start).unwrap().get_days() as usize; 112 | (days_diff / 7, days_diff % 7) 113 | } 114 | 115 | /// Convert cursor position to the corresponding date 116 | fn cursor_position_to_date(&self) -> Date { 117 | let days_offset = self.cursor_position.0 * 7 + self.cursor_position.1; 118 | self.view_start 119 | .checked_add(jiff::ToSpan::days(days_offset as i64)) 120 | .unwrap() 121 | } 122 | 123 | /// Get all 35 dates in the current view 124 | fn get_view_dates(&self) -> Vec> { 125 | let mut weeks = Vec::new(); 126 | for week in 0..5 { 127 | let mut days = Vec::new(); 128 | for day in 0..7 { 129 | let days_offset = week * 7 + day; 130 | let date = self 131 | .view_start 132 | .checked_add(jiff::ToSpan::days(days_offset as i64)) 133 | .unwrap(); 134 | days.push(date); 135 | } 136 | weeks.push(days); 137 | } 138 | weeks 139 | } 140 | 141 | pub fn handle_key_event(&mut self, key_event: KeyEvent) -> bool { 142 | if !self.focused { 143 | return false; 144 | } 145 | 146 | let (_, col) = self.cursor_position; 147 | 148 | match key_event.code { 149 | KeyCode::Delete | KeyCode::Backspace => { 150 | // Toggle between no date and current cursor date 151 | if self.selected_date.is_some() { 152 | self.selected_date = None; 153 | } else { 154 | self.selected_date = Some(self.cursor_position_to_date()); 155 | } 156 | return true; 157 | } 158 | _ => {} 159 | } 160 | 161 | let new_col = match key_event.code { 162 | KeyCode::Up => { 163 | // Always scroll up and keep cursor in middle row (row 2) 164 | self.scroll_up(); 165 | col // Keep same column 166 | } 167 | KeyCode::Down => { 168 | // Always scroll down and keep cursor in middle row (row 2) 169 | self.scroll_down(); 170 | col // Keep same column 171 | } 172 | KeyCode::Left => { 173 | if col == 0 { 174 | // Move to end of previous week, staying in middle row 175 | self.scroll_up(); 176 | 6 177 | } else { 178 | col - 1 179 | } 180 | } 181 | KeyCode::Right => { 182 | if col == 6 { 183 | // Move to start of next week, staying in middle row 184 | self.scroll_down(); 185 | 0 186 | } else { 187 | col + 1 188 | } 189 | } 190 | _ => return false, 191 | }; 192 | 193 | // Always stay in middle row (row 2) 194 | self.cursor_position = (2, new_col); 195 | 196 | // Update selected date (navigation always sets a date) 197 | self.selected_date = Some(self.cursor_position_to_date()); 198 | true 199 | } 200 | 201 | fn scroll_up(&mut self) { 202 | self.view_start = self.view_start.checked_sub(jiff::ToSpan::weeks(1)).unwrap(); 203 | } 204 | 205 | fn scroll_down(&mut self) { 206 | self.view_start = self.view_start.checked_add(jiff::ToSpan::weeks(1)).unwrap(); 207 | } 208 | } 209 | 210 | impl StatefulWidget for DatePicker { 211 | type State = DatePickerState; 212 | 213 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 214 | let area = if let Some(block) = &self.block { 215 | let inner = block.inner(area); 216 | block.clone().render(area, buf); 217 | inner 218 | } else { 219 | area 220 | }; 221 | 222 | if area.width < 32 || area.height < 6 { 223 | return; // Not enough space for calendar + month labels 224 | } 225 | 226 | let today = util::today(); 227 | let weeks = state.get_view_dates(); 228 | 229 | // Create layout: weekdays header + 5 calendar rows 230 | let chunks = Layout::vertical([ 231 | Constraint::Length(1), // Week days header 232 | Constraint::Length(5), // Calendar grid (5 weeks) 233 | ]) 234 | .split(area); 235 | 236 | let has_selected_date = state.selected_date.is_some(); 237 | 238 | // Render weekday headers 239 | let weekdays = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; 240 | let mut x_offset = 0; 241 | for (i, &day) in weekdays.iter().enumerate() { 242 | let style = if !has_selected_date { 243 | // No date selected - grey out headers 244 | Style::default().fg(Color::DarkGray) 245 | } else if i == 0 || i == 6 { 246 | // Weekend 247 | Style::default().fg(Color::DarkGray) 248 | } else { 249 | Style::default().fg(Color::White) 250 | }; 251 | buf.set_string(area.x + x_offset, chunks[0].y, day, style); 252 | x_offset += 3; 253 | } 254 | 255 | // Show "No date" message if no date is selected 256 | if !has_selected_date { 257 | let no_date_msg = "No start date - Del/Backspace to select"; 258 | let msg_x = area.x + 23; // After calendar 259 | buf.set_string( 260 | msg_x, 261 | chunks[0].y, 262 | no_date_msg, 263 | Style::default().fg(Color::Yellow), 264 | ); 265 | } 266 | 267 | // Render calendar grid with month labels 268 | for (week_idx, week) in weeks.iter().enumerate() { 269 | let y = chunks[1].y + week_idx as u16; 270 | let mut x_offset = 0; 271 | 272 | // Check if this week contains the 1st of a month 273 | let month_label = week 274 | .iter() 275 | .find(|date| date.day() == 1) 276 | .map(|first_date| format!("{} {}", first_date.strftime("%b"), first_date.year())); 277 | 278 | for (day_idx, &date) in week.iter().enumerate() { 279 | let is_today = date == today; 280 | let is_cursor_position = state.cursor_position == (week_idx, day_idx); 281 | let is_selected = has_selected_date && state.selected_date == Some(date); 282 | let is_weekend = day_idx == 0 || day_idx == 6; // Sunday or Saturday 283 | 284 | let day_str = format!("{:2}", date.day()); 285 | 286 | let style = if is_selected { 287 | // Selected date: invert colors 288 | Style::default().bg(Color::White).fg(Color::Black) 289 | } else if !has_selected_date && is_cursor_position { 290 | // Cursor position when no date selected: dim highlight 291 | Style::default().fg(Color::DarkGray).bg(Color::Gray) 292 | } else if is_today { 293 | // Today: highlighted 294 | Style::default().fg(Color::Cyan).bg(Color::DarkGray) 295 | } else if !has_selected_date { 296 | // No date selected: grey out all dates 297 | Style::default().fg(Color::DarkGray) 298 | } else if is_weekend { 299 | // Weekend: grey 300 | Style::default().fg(Color::DarkGray) 301 | } else { 302 | // Weekday: white 303 | Style::default().fg(Color::White) 304 | }; 305 | 306 | buf.set_string(area.x + x_offset, y, &day_str, style); 307 | x_offset += 3; 308 | } 309 | 310 | // Render month label if this week contains the 1st of a month 311 | if let Some(label) = month_label { 312 | let label_x = area.x + 21; // After 7 days * 3 chars each = 21 chars 313 | buf.set_string( 314 | label_x, 315 | y, 316 | format!(" {}", label), 317 | Style::default().fg(Color::Yellow), 318 | ); 319 | } 320 | } 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /src/tui/link_task.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyCode; 2 | use ratatui::{ 3 | layout::{Constraint, Layout}, 4 | prelude::*, 5 | widgets::{Block, BorderType, Borders, Paragraph, Wrap}, 6 | }; 7 | use std::collections::HashSet; 8 | 9 | use crate::domain::{Project, Task, TaskId}; 10 | use crate::tui::widgets::{ 11 | keys::Keys, 12 | text_input::{TextInput, TextInputState}, 13 | }; 14 | 15 | #[derive(Debug, Clone)] 16 | pub struct TaskItem { 17 | pub id: TaskId, 18 | pub description: String, 19 | pub is_blocking: bool, 20 | } 21 | 22 | #[derive(Debug)] 23 | pub struct ScreenModel { 24 | pub selected_task_id: TaskId, 25 | pub selected_task: Task, 26 | pub task_list: Vec, 27 | pub filtered_task_list: Vec, 28 | pub selected_index: usize, 29 | pub filter_input: TextInput, 30 | pub filter_input_state: TextInputState, 31 | pub filter_active: bool, 32 | pub filter_text: String, 33 | } 34 | 35 | impl ScreenModel { 36 | pub fn new(project: &Project, task_id: TaskId) -> Option { 37 | let selected_task = project.tasks.get(&task_id)?.clone(); 38 | let blocking_ids: HashSet = selected_task.blockers.iter().cloned().collect(); 39 | 40 | // Build task list excluding the selected task itself 41 | let mut task_list: Vec = project 42 | .tasks 43 | .iter() 44 | .filter(|(id, _)| *id != &task_id) // Exclude self 45 | .map(|(id, task)| TaskItem { 46 | id: id.clone(), 47 | description: task.description.clone(), 48 | is_blocking: blocking_ids.contains(id), 49 | }) 50 | .collect(); 51 | 52 | // Sort: blocking tasks first, then non-blocking, alphabetically within each group 53 | task_list.sort_by(|a, b| { 54 | match (a.is_blocking, b.is_blocking) { 55 | (true, false) => std::cmp::Ordering::Less, // blocking tasks first 56 | (false, true) => std::cmp::Ordering::Greater, // non-blocking tasks last 57 | _ => a.id.0.cmp(&b.id.0), // alphabetical within group 58 | } 59 | }); 60 | 61 | let filtered_task_list = task_list.clone(); 62 | 63 | Some(Self { 64 | selected_task_id: task_id, 65 | selected_task, 66 | task_list, 67 | filtered_task_list, 68 | selected_index: 0, 69 | filter_input: TextInput::new(), 70 | filter_input_state: TextInputState::default(), 71 | filter_active: false, 72 | filter_text: String::new(), 73 | }) 74 | } 75 | 76 | pub fn handle_key(&mut self, key: KeyCode) -> ScreenAction { 77 | if self.filter_active { 78 | match key { 79 | KeyCode::Esc => { 80 | self.filter_active = false; 81 | self.filter_input_state.unfocus(); 82 | ScreenAction::Continue 83 | } 84 | KeyCode::Enter => { 85 | self.filter_active = false; 86 | self.filter_input_state.unfocus(); 87 | ScreenAction::Continue 88 | } 89 | _ => { 90 | self.filter_input_state.handle_key_event( 91 | crossterm::event::KeyEvent::new( 92 | key, 93 | crossterm::event::KeyModifiers::empty(), 94 | ), 95 | &self.filter_input, 96 | ); 97 | self.filter_text = self.filter_input_state.content().to_string(); 98 | self.update_filter(); 99 | ScreenAction::Continue 100 | } 101 | } 102 | } else { 103 | match key { 104 | KeyCode::Esc => ScreenAction::GoToGantt, 105 | KeyCode::Up => { 106 | if self.selected_index > 0 { 107 | self.selected_index -= 1; 108 | } 109 | ScreenAction::Continue 110 | } 111 | KeyCode::Down => { 112 | if self.selected_index < self.filtered_task_list.len().saturating_sub(1) { 113 | self.selected_index += 1; 114 | } 115 | ScreenAction::Continue 116 | } 117 | KeyCode::Enter => { 118 | self.toggle_selected_blocker(); 119 | ScreenAction::Continue 120 | } 121 | KeyCode::Char('/') => { 122 | self.filter_active = true; 123 | self.filter_input_state.focus(); 124 | ScreenAction::Continue 125 | } 126 | _ => ScreenAction::Continue, 127 | } 128 | } 129 | } 130 | 131 | fn toggle_selected_blocker(&mut self) { 132 | if self.selected_index < self.filtered_task_list.len() { 133 | let task_id = self.filtered_task_list[self.selected_index].id.clone(); 134 | let new_blocking_state = !self.filtered_task_list[self.selected_index].is_blocking; 135 | 136 | // Update both lists 137 | for item in &mut self.filtered_task_list { 138 | if item.id == task_id { 139 | item.is_blocking = new_blocking_state; 140 | } 141 | } 142 | 143 | for item in &mut self.task_list { 144 | if item.id == task_id { 145 | item.is_blocking = new_blocking_state; 146 | } 147 | } 148 | 149 | // Re-sort and re-filter 150 | self.sort_task_list(); 151 | self.update_filter(); 152 | 153 | // Adjust selected index if needed after re-sorting 154 | if let Some(new_index) = self 155 | .filtered_task_list 156 | .iter() 157 | .position(|item| item.id == task_id) 158 | { 159 | self.selected_index = new_index; 160 | } 161 | } 162 | } 163 | 164 | fn sort_task_list(&mut self) { 165 | self.task_list 166 | .sort_by(|a, b| match (a.is_blocking, b.is_blocking) { 167 | (true, false) => std::cmp::Ordering::Less, 168 | (false, true) => std::cmp::Ordering::Greater, 169 | _ => a.id.0.cmp(&b.id.0), 170 | }); 171 | } 172 | 173 | fn update_filter(&mut self) { 174 | if self.filter_text.trim().is_empty() { 175 | self.filtered_task_list = self.task_list.clone(); 176 | } else { 177 | let filter_lower = self.filter_text.to_lowercase(); 178 | self.filtered_task_list = self 179 | .task_list 180 | .iter() 181 | .filter(|task| { 182 | task.id.0.to_lowercase().contains(&filter_lower) 183 | || task.description.to_lowercase().contains(&filter_lower) 184 | }) 185 | .cloned() 186 | .collect(); 187 | } 188 | 189 | // Reset selected index if it's out of bounds 190 | if self.selected_index >= self.filtered_task_list.len() { 191 | self.selected_index = self.filtered_task_list.len().saturating_sub(1); 192 | } 193 | } 194 | 195 | pub fn get_updated_blockers(&self) -> Vec { 196 | self.task_list 197 | .iter() 198 | .filter(|item| item.is_blocking) 199 | .map(|item| item.id.clone()) 200 | .collect() 201 | } 202 | } 203 | 204 | #[derive(Debug, PartialEq)] 205 | pub enum ScreenAction { 206 | Continue, 207 | GoToGantt, 208 | } 209 | 210 | pub fn render(model: &mut ScreenModel, frame: &mut Frame<'_>) { 211 | let area = frame.area(); 212 | 213 | // Create main layout 214 | let main_layout = Layout::default() 215 | .direction(ratatui::layout::Direction::Vertical) 216 | .constraints([ 217 | Constraint::Length(3), // Selected task header 218 | Constraint::Min(5), // Task list 219 | Constraint::Length(3), // Filter input 220 | Constraint::Length(3), // Keys help 221 | ]) 222 | .split(area); 223 | 224 | // Render selected task header 225 | let header_block = Block::default() 226 | .borders(Borders::ALL) 227 | .border_type(BorderType::Rounded) 228 | .title(format!(" Task Linking: {} ", model.selected_task_id.0)); 229 | 230 | let header_text = format!( 231 | "Selected: {} - \"{}\"", 232 | model.selected_task_id.0, model.selected_task.description 233 | ); 234 | let header_paragraph = Paragraph::new(header_text) 235 | .block(header_block) 236 | .wrap(Wrap { trim: true }); 237 | 238 | frame.render_widget(header_paragraph, main_layout[0]); 239 | 240 | // Render task list 241 | render_task_list(model, frame, main_layout[1]); 242 | 243 | // Render filter section 244 | render_filter_section(model, frame, main_layout[2]); 245 | 246 | // Render keys help 247 | let keys_widget = Keys::default() 248 | .with_key("↑↓", "Navigate") 249 | .with_key("Enter", "Toggle") 250 | .with_key("/", "Filter") 251 | .with_key("Esc", "Back"); 252 | frame.render_widget(keys_widget, main_layout[3]); 253 | } 254 | 255 | fn render_task_list(model: &ScreenModel, frame: &mut Frame<'_>, area: Rect) { 256 | let list_block = Block::default() 257 | .borders(Borders::ALL) 258 | .border_type(BorderType::Rounded) 259 | .title(" Tasks "); 260 | 261 | let inner_area = list_block.inner(area); 262 | frame.render_widget(list_block, area); 263 | 264 | // Calculate visible range 265 | let visible_height = inner_area.height as usize; 266 | let start_idx = if model.selected_index >= visible_height { 267 | model.selected_index - visible_height + 1 268 | } else { 269 | 0 270 | }; 271 | 272 | // Render task items 273 | for (i, task_item) in model 274 | .filtered_task_list 275 | .iter() 276 | .enumerate() 277 | .skip(start_idx) 278 | .take(visible_height) 279 | { 280 | let y = inner_area.y + (i - start_idx) as u16; 281 | let is_selected = i == model.selected_index; 282 | 283 | let bullet = if task_item.is_blocking { "●" } else { "○" }; 284 | let text = format!( 285 | "{} {} - \"{}\"", 286 | bullet, task_item.id.0, task_item.description 287 | ); 288 | 289 | let style = if is_selected { 290 | Style::default().bg(Color::DarkGray) 291 | } else { 292 | Style::default() 293 | }; 294 | 295 | frame.buffer_mut().set_string(inner_area.x, y, &text, style); 296 | } 297 | 298 | // Show "No tasks" message if list is empty 299 | if model.filtered_task_list.is_empty() { 300 | let no_tasks_text = if model.filter_text.trim().is_empty() { 301 | "No other tasks in this project." 302 | } else { 303 | "No tasks match the current filter." 304 | }; 305 | 306 | frame.buffer_mut().set_string( 307 | inner_area.x, 308 | inner_area.y, 309 | no_tasks_text, 310 | Style::default().fg(Color::DarkGray), 311 | ); 312 | } 313 | } 314 | 315 | fn render_filter_section(model: &mut ScreenModel, frame: &mut Frame<'_>, area: Rect) { 316 | let filter_block = Block::default() 317 | .borders(Borders::ALL) 318 | .border_type(BorderType::Rounded) 319 | .title(" Filter "); 320 | 321 | let inner_area = filter_block.inner(area); 322 | frame.render_widget(filter_block, area); 323 | 324 | if model.filter_active { 325 | // Render active text input 326 | frame.render_stateful_widget( 327 | model.filter_input.clone(), 328 | inner_area, 329 | &mut model.filter_input_state, 330 | ); 331 | } else { 332 | // Render current filter text or placeholder 333 | let filter_text = if model.filter_text.trim().is_empty() { 334 | "Press '/' to filter tasks..." 335 | } else { 336 | &model.filter_text 337 | }; 338 | 339 | let style = if model.filter_text.trim().is_empty() { 340 | Style::default().fg(Color::DarkGray) 341 | } else { 342 | Style::default() 343 | }; 344 | 345 | frame 346 | .buffer_mut() 347 | .set_string(inner_area.x, inner_area.y, filter_text, style); 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/render/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::{Project, TaskId}; 2 | use color_eyre::Result; 3 | use jiff::civil::Date; 4 | use std::collections::HashMap; 5 | 6 | pub fn render_project(project: &Project) -> Result { 7 | let mut output = String::new(); 8 | 9 | if project.tasks.is_empty() { 10 | output.push_str("No tasks found in this project.\n"); 11 | return Ok(output); 12 | } 13 | 14 | // Get schedule data 15 | let schedule_data = project 16 | .schedule_tasks() 17 | .map_err(|e| color_eyre::eyre::eyre!(e))?; 18 | 19 | // Sort tasks by start date (ascending) 20 | let mut tasks: Vec<_> = project.tasks.iter().collect(); 21 | tasks.sort_by(|a, b| { 22 | use std::cmp::Ordering; 23 | match (a.1.start_date, b.1.start_date) { 24 | (Some(a_date), Some(b_date)) => a_date.cmp(&b_date), 25 | (None, Some(_)) => Ordering::Greater, // None sorts last for ascending 26 | (Some(_), None) => Ordering::Less, 27 | (None, None) => a.0.0.cmp(&b.0.0), // Fallback to task ID 28 | } 29 | }); 30 | 31 | // Calculate column widths 32 | let task_id_width = tasks 33 | .iter() 34 | .map(|(task_id, _)| task_id.0.len()) 35 | .max() 36 | .unwrap_or(0) 37 | .max(7); // "Task ID".len() 38 | 39 | let description_width = tasks 40 | .iter() 41 | .map(|(_, task)| task.description.len()) 42 | .max() 43 | .unwrap_or(0) 44 | .max(11); // "Description".len() 45 | 46 | let days_width = tasks 47 | .iter() 48 | .map(|(_, task)| task.estimated_days.to_string().len()) 49 | .max() 50 | .unwrap_or(0) 51 | .max(4); // "Days".len() 52 | 53 | // Calculate date range for schedule 54 | let (start_date, end_date) = calculate_date_range(project, &schedule_data); 55 | 56 | // Render simple header 57 | output.push_str(&format!( 58 | "{:width3$} {}\n", 59 | "Task ID", 60 | "Description", 61 | "Days", 62 | "Schedule", 63 | width1 = task_id_width, 64 | width2 = description_width, 65 | width3 = days_width, 66 | )); 67 | 68 | // Render date header for schedule 69 | let date_header = render_date_header( 70 | start_date, 71 | end_date, 72 | task_id_width, 73 | description_width, 74 | days_width, 75 | ); 76 | output.push_str(&date_header); 77 | 78 | // Render tasks 79 | for (task_id, task) in &tasks { 80 | let schedule_line = render_task_schedule(task_id, &schedule_data, start_date, end_date); 81 | 82 | output.push_str(&format!( 83 | "{:width3$} {}\n", 84 | task_id.0, 85 | task.description, 86 | task.estimated_days, 87 | schedule_line, 88 | width1 = task_id_width, 89 | width2 = description_width, 90 | width3 = days_width, 91 | )); 92 | } 93 | 94 | Ok(output) 95 | } 96 | 97 | fn calculate_date_range( 98 | project: &Project, 99 | schedule_data: &HashMap>, 100 | ) -> (Date, Date) { 101 | if schedule_data.is_empty() { 102 | let today = crate::util::today(); 103 | return (today, today.saturating_add(jiff::ToSpan::days(30))); 104 | } 105 | 106 | let mut min_date = None; 107 | let mut max_date = None; 108 | 109 | for dates in schedule_data.values() { 110 | for &date in dates { 111 | if min_date.is_none() || Some(date) < min_date { 112 | min_date = Some(date); 113 | } 114 | if max_date.is_none() || Some(date) > max_date { 115 | max_date = Some(date); 116 | } 117 | } 118 | } 119 | 120 | // Also consider any hard start dates from tasks 121 | for task in project.tasks.values() { 122 | if let Some(start_date) = task.start_date 123 | && (min_date.is_none() || Some(start_date) < min_date) 124 | { 125 | min_date = Some(start_date); 126 | } 127 | } 128 | 129 | let start_date = min_date.unwrap_or_else(crate::util::today); 130 | let end_date = max_date.unwrap_or_else(|| start_date.saturating_add(jiff::ToSpan::days(30))); 131 | 132 | (start_date, end_date) 133 | } 134 | 135 | fn render_date_header( 136 | start_date: Date, 137 | end_date: Date, 138 | task_id_width: usize, 139 | description_width: usize, 140 | days_width: usize, 141 | ) -> String { 142 | // Build the schedule content 143 | let mut month_line = String::new(); 144 | let mut date_line = String::new(); 145 | let mut day_line = String::new(); 146 | 147 | let mut current_date = start_date; 148 | let mut last_month = None; 149 | 150 | while current_date <= end_date { 151 | // Add week separator (except at start) 152 | if current_date != start_date && current_date.weekday() == jiff::civil::Weekday::Sunday { 153 | month_line.push_str(" │ "); 154 | date_line.push_str(" │ "); 155 | day_line.push_str(" │ "); 156 | } 157 | 158 | // Month line - show month when it changes 159 | let current_month = current_date.month(); 160 | if last_month != Some(current_month) { 161 | let month_name = match current_month { 162 | 1 => "Jan", 163 | 2 => "Feb", 164 | 3 => "Mar", 165 | 4 => "Apr", 166 | 5 => "May", 167 | 6 => "Jun", 168 | 7 => "Jul", 169 | 8 => "Aug", 170 | 9 => "Sep", 171 | 10 => "Oct", 172 | 11 => "Nov", 173 | 12 => "Dec", 174 | _ => "???", 175 | }; 176 | month_line.push_str(month_name); 177 | last_month = Some(current_month); 178 | } else { 179 | month_line.push_str(" "); 180 | } 181 | 182 | // Date line 183 | date_line.push_str(&format!("{:2} ", current_date.day())); 184 | 185 | // Day line 186 | let day_char = match current_date.weekday() { 187 | jiff::civil::Weekday::Monday => " M ", 188 | jiff::civil::Weekday::Tuesday => " T ", 189 | jiff::civil::Weekday::Wednesday => " W ", 190 | jiff::civil::Weekday::Thursday => " T ", 191 | jiff::civil::Weekday::Friday => " F ", 192 | jiff::civil::Weekday::Saturday => " S ", 193 | jiff::civil::Weekday::Sunday => " S ", 194 | }; 195 | day_line.push_str(day_char); 196 | 197 | current_date = current_date.saturating_add(jiff::ToSpan::days(1)); 198 | } 199 | 200 | // Calculate offset to align with schedule column 201 | let offset = task_id_width + 1 + description_width + 1 + days_width + 1; 202 | 203 | format!( 204 | "{:offset$}{}\n{:offset$}{}\n{:offset$}{}\n", 205 | "", 206 | month_line, 207 | "", 208 | date_line, 209 | "", 210 | day_line, 211 | offset = offset, 212 | ) 213 | } 214 | 215 | fn render_task_schedule( 216 | task_id: &TaskId, 217 | schedule_data: &HashMap>, 218 | start_date: Date, 219 | end_date: Date, 220 | ) -> String { 221 | let mut schedule_line = String::new(); 222 | let task_dates = schedule_data.get(task_id).cloned().unwrap_or_default(); 223 | let task_date_set: std::collections::HashSet = task_dates.into_iter().collect(); 224 | 225 | let mut current_date = start_date; 226 | 227 | while current_date <= end_date { 228 | // Add week separator 229 | if current_date != start_date && current_date.weekday() == jiff::civil::Weekday::Sunday { 230 | schedule_line.push_str(" │ "); 231 | } 232 | 233 | if task_date_set.contains(¤t_date) { 234 | // Task is scheduled on this day 235 | schedule_line.push_str("███"); 236 | } else { 237 | // Empty day 238 | schedule_line.push_str("···"); 239 | } 240 | 241 | current_date = current_date.saturating_add(jiff::ToSpan::days(1)); 242 | } 243 | 244 | schedule_line 245 | } 246 | 247 | #[cfg(test)] 248 | mod tests { 249 | use super::*; 250 | use crate::domain::{Project, Task}; 251 | use jiff::civil::Date; 252 | 253 | fn create_test_project() -> Project { 254 | let mut project = Project::default(); 255 | 256 | // Task 1: starts on specific date 257 | let mut task1 = Task::default(); 258 | task1.description = "First task".to_string(); 259 | task1.estimated_days = 3; 260 | task1.start_date = Some(Date::new(2025, 1, 6).unwrap()); // Monday 261 | project.tasks.insert(TaskId("task1".to_string()), task1); 262 | 263 | // Task 2: depends on task1 264 | let mut task2 = Task::default(); 265 | task2.description = "Second task".to_string(); 266 | task2.estimated_days = 2; 267 | task2.blockers = vec![TaskId("task1".to_string())]; 268 | project.tasks.insert(TaskId("task2".to_string()), task2); 269 | 270 | project 271 | } 272 | 273 | #[test] 274 | fn test_render_empty_project() { 275 | let project = Project::default(); 276 | let result = render_project(&project).unwrap(); 277 | assert_eq!(result, "No tasks found in this project.\n"); 278 | } 279 | 280 | #[test] 281 | fn test_render_project_with_tasks() { 282 | let project = create_test_project(); 283 | let result = render_project(&project).unwrap(); 284 | 285 | // Check that it contains expected headers 286 | assert!(result.contains("Task ID")); 287 | assert!(result.contains("Description")); 288 | assert!(result.contains("Days")); 289 | assert!(result.contains("Schedule")); 290 | 291 | // Check that tasks are present 292 | assert!(result.contains("task1")); 293 | assert!(result.contains("task2")); 294 | assert!(result.contains("First task")); 295 | assert!(result.contains("Second task")); 296 | 297 | // Check right-aligned days (should have spaces before numbers) 298 | assert!(result.contains(" 3")); 299 | assert!(result.contains(" 2")); 300 | } 301 | 302 | #[test] 303 | fn test_task_sorting_by_start_date() { 304 | let mut project = Project::default(); 305 | 306 | // Create tasks with different start dates (out of order) 307 | let mut task_b = Task::default(); 308 | task_b.description = "Task B".to_string(); 309 | task_b.estimated_days = 1; 310 | task_b.start_date = Some(Date::new(2025, 1, 8).unwrap()); // Later date 311 | project.tasks.insert(TaskId("task-b".to_string()), task_b); 312 | 313 | let mut task_a = Task::default(); 314 | task_a.description = "Task A".to_string(); 315 | task_a.estimated_days = 1; 316 | task_a.start_date = Some(Date::new(2025, 1, 6).unwrap()); // Earlier date 317 | project.tasks.insert(TaskId("task-a".to_string()), task_a); 318 | 319 | let result = render_project(&project).unwrap(); 320 | let lines: Vec<&str> = result.lines().collect(); 321 | 322 | // Find task lines (skip headers) 323 | let task_lines: Vec<&str> = lines.iter() 324 | .filter(|line| line.starts_with("task-")) 325 | .cloned() 326 | .collect(); 327 | 328 | assert_eq!(task_lines.len(), 2); 329 | // task-a should come before task-b due to earlier start date 330 | assert!(task_lines[0].starts_with("task-a")); 331 | assert!(task_lines[1].starts_with("task-b")); 332 | } 333 | 334 | #[test] 335 | fn test_schedule_visualization() { 336 | let project = create_test_project(); 337 | let result = render_project(&project).unwrap(); 338 | 339 | // Should contain schedule blocks for task days 340 | assert!(result.contains("███")); 341 | 342 | // Should contain empty day markers 343 | assert!(result.contains("···")); 344 | 345 | // Should contain week separators (might not appear if schedule doesn't span weeks) 346 | // Just check that the output contains some schedule content 347 | assert!(result.lines().any(|line| line.contains("███") || line.contains("···"))); 348 | } 349 | 350 | #[test] 351 | fn test_date_header_alignment() { 352 | let project = create_test_project(); 353 | let result = render_project(&project).unwrap(); 354 | let lines: Vec<&str> = result.lines().collect(); 355 | 356 | // Find the header line and month line 357 | let header_line = lines.iter().find(|line| line.starts_with("Task ID")).unwrap(); 358 | let month_line = lines.iter().find(|line| line.trim_start().starts_with("Jan")).unwrap(); 359 | 360 | // Month line should start at the same position as "Schedule" in header 361 | let schedule_pos = header_line.find("Schedule").unwrap(); 362 | let month_start_pos = month_line.len() - month_line.trim_start().len(); 363 | 364 | // Allow for some tolerance due to spacing 365 | assert!((schedule_pos as i32 - month_start_pos as i32).abs() <= 2); 366 | } 367 | 368 | #[test] 369 | fn test_column_width_calculation() { 370 | let mut project = Project::default(); 371 | 372 | // Create task with very long description 373 | let mut task = Task::default(); 374 | task.description = "This is a very long task description".to_string(); 375 | task.estimated_days = 1000; // Large number to test days width 376 | project.tasks.insert(TaskId("very-long-task-id".to_string()), task); 377 | 378 | let result = render_project(&project).unwrap(); 379 | 380 | // Should contain the full task ID and description 381 | assert!(result.contains("very-long-task-id")); 382 | assert!(result.contains("This is a very long task description")); 383 | assert!(result.contains("1000")); 384 | } 385 | 386 | #[test] 387 | fn test_weekend_handling() { 388 | let mut project = Project::default(); 389 | 390 | // Create a task that starts on Friday (will span into weekend) 391 | let mut task = Task::default(); 392 | task.description = "Weekend task".to_string(); 393 | task.estimated_days = 5; // Will go Fri, Mon, Tue, Wed, Thu (skipping weekend) 394 | task.start_date = Some(Date::new(2025, 1, 3).unwrap()); // Friday 395 | project.tasks.insert(TaskId("weekend".to_string()), task); 396 | 397 | let result = render_project(&project).unwrap(); 398 | 399 | // Should contain schedule visualization 400 | assert!(result.contains("███")); 401 | assert!(result.contains("···")); 402 | 403 | // The scheduling algorithm should skip weekends, so we should see 404 | // gaps in the schedule where weekends occur 405 | let lines: Vec<&str> = result.lines().collect(); 406 | let task_line = lines.iter().find(|line| line.starts_with("weekend")).unwrap(); 407 | 408 | // Should have some schedule content 409 | assert!(task_line.contains("███")); 410 | } 411 | 412 | #[test] 413 | fn test_task_dependencies() { 414 | let project = create_test_project(); 415 | let result = render_project(&project).unwrap(); 416 | let lines: Vec<&str> = result.lines().collect(); 417 | 418 | // Get task lines 419 | let task1_line = lines.iter().find(|line| line.starts_with("task1")).unwrap(); 420 | let task2_line = lines.iter().find(|line| line.starts_with("task2")).unwrap(); 421 | 422 | // Both should have schedule blocks, but task2 should start after task1 423 | assert!(task1_line.contains("███")); 424 | assert!(task2_line.contains("███")); 425 | 426 | // Since task2 depends on task1, task1 should come first in the output 427 | let task1_pos = lines.iter().position(|line| line.starts_with("task1")).unwrap(); 428 | let task2_pos = lines.iter().position(|line| line.starts_with("task2")).unwrap(); 429 | assert!(task1_pos < task2_pos); 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /src/tui/edit_task.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyModifiers}; 2 | use ratatui::{ 3 | layout::{Constraint, Layout}, 4 | prelude::*, 5 | widgets::{Block, BorderType, Borders, Padding, Paragraph}, 6 | }; 7 | 8 | use crate::{ 9 | domain::{Project, TaskId}, 10 | tui::{ 11 | Command, Message, Screen, 12 | widgets::{ 13 | date_picker::{DatePicker, DatePickerState}, 14 | keys::Keys, 15 | text_input::{TextInput, TextInputState}, 16 | }, 17 | }, 18 | }; 19 | 20 | #[derive(Debug, Clone, Copy, PartialEq)] 21 | enum FocusedField { 22 | TaskId, 23 | Description, 24 | EstimatedDays, 25 | StartDate, 26 | } 27 | 28 | impl FocusedField { 29 | const ALL_FIELDS: [Self; 4] = [ 30 | Self::TaskId, 31 | Self::Description, 32 | Self::EstimatedDays, 33 | Self::StartDate, 34 | ]; 35 | 36 | fn next(self) -> Self { 37 | let current_index = Self::ALL_FIELDS 38 | .iter() 39 | .position(|&field| field == self) 40 | .unwrap(); 41 | let next_index = (current_index + 1) % Self::ALL_FIELDS.len(); 42 | Self::ALL_FIELDS[next_index] 43 | } 44 | 45 | fn previous(self) -> Self { 46 | let current_index = Self::ALL_FIELDS 47 | .iter() 48 | .position(|&field| field == self) 49 | .unwrap(); 50 | let previous_index = (current_index + Self::ALL_FIELDS.len() - 1) % Self::ALL_FIELDS.len(); 51 | Self::ALL_FIELDS[previous_index] 52 | } 53 | } 54 | 55 | pub struct ScreenModel { 56 | task_id: TaskId, 57 | original_task_id: TaskId, // Keep track of the original ID for updates 58 | task_id_input: TextInput, 59 | task_id_state: TextInputState, 60 | description_input: TextInput, 61 | description_state: TextInputState, 62 | estimated_days_input: TextInput, 63 | estimated_days_state: TextInputState, 64 | start_date_picker: DatePicker, 65 | start_date_state: DatePickerState, 66 | focused_field: FocusedField, 67 | } 68 | 69 | impl Default for ScreenModel { 70 | fn default() -> Self { 71 | let mut task_id_state = TextInputState::default(); 72 | task_id_state.focus(); 73 | let description_state = TextInputState::default(); 74 | let estimated_days_state = TextInputState::default(); 75 | let start_date_state = DatePickerState::default(); 76 | 77 | Self { 78 | task_id: TaskId::default(), 79 | original_task_id: TaskId::default(), 80 | task_id_input: TextInput::new().with_placeholder("Enter task ID..."), 81 | task_id_state, 82 | description_input: TextInput::new().with_placeholder("Enter task description..."), 83 | description_state, 84 | estimated_days_input: TextInput::number_input().with_placeholder("0"), 85 | estimated_days_state, 86 | start_date_picker: DatePicker::new(), 87 | start_date_state, 88 | focused_field: FocusedField::TaskId, 89 | } 90 | } 91 | } 92 | 93 | impl ScreenModel { 94 | pub fn new(task_id: TaskId, project: &Project) -> Self { 95 | let task = project.get_task(&task_id); 96 | 97 | let description = task 98 | .map(|task| task.description.clone()) 99 | .unwrap_or_default(); 100 | let estimated_days = task 101 | .map(|task| task.estimated_days.to_string()) 102 | .unwrap_or_default(); 103 | let start_date = task.and_then(|task| task.start_date); 104 | 105 | let mut task_id_state = TextInputState::default().with_content(&task_id.0); 106 | task_id_state.focus(); 107 | let description_state = TextInputState::default().with_content(description); 108 | let estimated_days_state = TextInputState::default().with_content(estimated_days); 109 | let start_date_state = DatePickerState::default().with_date(start_date); 110 | 111 | Self { 112 | task_id: task_id.clone(), 113 | original_task_id: task_id, 114 | task_id_input: TextInput::new().with_placeholder("Enter task ID..."), 115 | task_id_state, 116 | description_input: TextInput::new().with_placeholder("Enter task description..."), 117 | description_state, 118 | estimated_days_input: TextInput::number_input().with_placeholder("0"), 119 | estimated_days_state, 120 | start_date_picker: DatePicker::new(), 121 | start_date_state, 122 | focused_field: FocusedField::TaskId, 123 | } 124 | } 125 | 126 | fn update_focus(&mut self) { 127 | // Update focus state based on focused_field 128 | match self.focused_field { 129 | FocusedField::TaskId => { 130 | self.task_id_state.focus(); 131 | self.description_state.unfocus(); 132 | self.estimated_days_state.unfocus(); 133 | self.start_date_state.unfocus(); 134 | } 135 | FocusedField::Description => { 136 | self.task_id_state.unfocus(); 137 | self.description_state.focus(); 138 | self.estimated_days_state.unfocus(); 139 | self.start_date_state.unfocus(); 140 | } 141 | FocusedField::EstimatedDays => { 142 | self.task_id_state.unfocus(); 143 | self.description_state.unfocus(); 144 | self.estimated_days_state.focus(); 145 | self.start_date_state.unfocus(); 146 | } 147 | FocusedField::StartDate => { 148 | self.task_id_state.unfocus(); 149 | self.description_state.unfocus(); 150 | self.estimated_days_state.unfocus(); 151 | self.start_date_state.focus(); 152 | } 153 | } 154 | } 155 | 156 | fn set_focus(&mut self, field: FocusedField) { 157 | self.focused_field = field; 158 | self.update_focus(); 159 | } 160 | 161 | pub fn view(&mut self, _project: &Project, frame: &mut Frame<'_>) { 162 | let area = frame.area(); 163 | 164 | // Split area for main content and keys help 165 | let chunks = Layout::vertical([ 166 | Constraint::Min(3), // Main content area 167 | Constraint::Length(1), // Keys help area 168 | ]) 169 | .split(area); 170 | 171 | let main_block = Block::default() 172 | .title(format!("Edit Task: {}", self.task_id.0)) 173 | .borders(Borders::ALL) 174 | .border_type(BorderType::Thick) 175 | .padding(Padding::new(1, 1, 1, 1)); 176 | 177 | let inner_area = main_block.inner(chunks[0]); 178 | frame.render_widget(main_block, chunks[0]); 179 | 180 | // Layout inside the main block 181 | let form_chunks = Layout::vertical([ 182 | Constraint::Length(1), // Task ID label 183 | Constraint::Length(3), // Task ID input field with border 184 | Constraint::Length(1), // Spacing 185 | Constraint::Length(1), // Description label 186 | Constraint::Length(3), // Description input field with border 187 | Constraint::Length(1), // Spacing 188 | Constraint::Length(1), // Estimated days label 189 | Constraint::Length(3), // Estimated days input field with border 190 | Constraint::Length(1), // Spacing 191 | Constraint::Length(1), // Start date label 192 | Constraint::Length(8), // Start date picker (weekdays + 5 weeks + borders) 193 | Constraint::Min(1), // Rest of the space 194 | ]) 195 | .split(inner_area); 196 | 197 | // Task ID label 198 | frame.render_widget(Paragraph::new("Task ID:"), form_chunks[0]); 199 | 200 | // Task ID input field with subtle border 201 | let task_id_border_style = if self.focused_field == FocusedField::TaskId { 202 | Style::default().fg(Color::Cyan) 203 | } else { 204 | Style::default().fg(Color::DarkGray) 205 | }; 206 | 207 | let task_id_input_block = Block::default() 208 | .borders(Borders::ALL) 209 | .border_type(BorderType::Rounded) 210 | .border_style(task_id_border_style); 211 | 212 | let task_id_input = self.task_id_input.clone().with_block(task_id_input_block); 213 | frame.render_stateful_widget(task_id_input, form_chunks[1], &mut self.task_id_state); 214 | 215 | // Description label 216 | frame.render_widget(Paragraph::new("Description:"), form_chunks[3]); 217 | 218 | // Description input field with subtle border 219 | let description_border_style = if self.focused_field == FocusedField::Description { 220 | Style::default().fg(Color::Cyan) 221 | } else { 222 | Style::default().fg(Color::DarkGray) 223 | }; 224 | 225 | let description_input_block = Block::default() 226 | .borders(Borders::ALL) 227 | .border_type(BorderType::Rounded) 228 | .border_style(description_border_style); 229 | 230 | let description_input = self 231 | .description_input 232 | .clone() 233 | .with_block(description_input_block); 234 | frame.render_stateful_widget( 235 | description_input, 236 | form_chunks[4], 237 | &mut self.description_state, 238 | ); 239 | 240 | // Estimated days label 241 | frame.render_widget(Paragraph::new("Estimated Days:"), form_chunks[6]); 242 | 243 | // Estimated days input field with subtle border 244 | let estimated_days_border_style = if self.focused_field == FocusedField::EstimatedDays { 245 | Style::default().fg(Color::Cyan) 246 | } else { 247 | Style::default().fg(Color::DarkGray) 248 | }; 249 | 250 | let estimated_days_input_block = Block::default() 251 | .borders(Borders::ALL) 252 | .border_type(BorderType::Rounded) 253 | .border_style(estimated_days_border_style); 254 | 255 | let estimated_days_input = self 256 | .estimated_days_input 257 | .clone() 258 | .with_block(estimated_days_input_block); 259 | frame.render_stateful_widget( 260 | estimated_days_input, 261 | form_chunks[7], 262 | &mut self.estimated_days_state, 263 | ); 264 | 265 | // Start date label 266 | frame.render_widget(Paragraph::new("Start Date:"), form_chunks[9]); 267 | 268 | // Start date picker with border 269 | let start_date_border_style = if self.focused_field == FocusedField::StartDate { 270 | Style::default().fg(Color::Cyan) 271 | } else { 272 | Style::default().fg(Color::DarkGray) 273 | }; 274 | 275 | let start_date_block = Block::default() 276 | .borders(Borders::ALL) 277 | .border_type(BorderType::Rounded) 278 | .border_style(start_date_border_style); 279 | 280 | let start_date_picker = self.start_date_picker.clone().with_block(start_date_block); 281 | frame.render_stateful_widget( 282 | start_date_picker, 283 | form_chunks[10], 284 | &mut self.start_date_state, 285 | ); 286 | 287 | // Keys help 288 | let keys = Keys::default() 289 | .with_key("Tab", "next field") 290 | .with_key("Ctrl+↑/↓", "navigate fields") 291 | .with_key("Arrow keys", "navigate date") 292 | .with_key("Del/Backspace", "clear/set date") 293 | .with_key("Enter", "save") 294 | .with_key("Esc", "cancel"); 295 | frame.render_widget(keys, chunks[1]); 296 | } 297 | 298 | pub fn update(mut self, project: &mut Project, message: Message) -> (Self, Option) { 299 | match message { 300 | Message::Tick => { 301 | // Handle cursor blinking for text inputs 302 | self.task_id_state.handle_tick(); 303 | self.description_state.handle_tick(); 304 | self.estimated_days_state.handle_tick(); 305 | // Date picker doesn't need tick handling 306 | (self, None) 307 | } 308 | Message::KeyboardEvent(key_event) => { 309 | // Handle Tab and arrow navigation first 310 | match key_event.code { 311 | KeyCode::Tab => { 312 | if key_event.modifiers.contains(KeyModifiers::SHIFT) { 313 | self.set_focus(self.focused_field.previous()); 314 | } else { 315 | self.set_focus(self.focused_field.next()); 316 | } 317 | return (self, None); 318 | } 319 | KeyCode::BackTab => { 320 | // BackTab is the proper key code for Shift+Tab 321 | self.set_focus(self.focused_field.previous()); 322 | return (self, None); 323 | } 324 | KeyCode::Up => { 325 | // Ctrl+Up for reliable navigation in SSH environments 326 | if key_event.modifiers.contains(KeyModifiers::CONTROL) { 327 | self.set_focus(self.focused_field.previous()); 328 | return (self, None); 329 | } 330 | } 331 | KeyCode::Down => { 332 | // Ctrl+Down for reliable navigation in SSH environments 333 | if key_event.modifiers.contains(KeyModifiers::CONTROL) { 334 | self.set_focus(self.focused_field.next()); 335 | return (self, None); 336 | } 337 | } 338 | _ => {} 339 | } 340 | 341 | // Handle input for the focused field 342 | let input_handled = match self.focused_field { 343 | FocusedField::TaskId => self 344 | .task_id_state 345 | .handle_key_event(key_event, &self.task_id_input), 346 | FocusedField::Description => self 347 | .description_state 348 | .handle_key_event(key_event, &self.description_input), 349 | FocusedField::EstimatedDays => self 350 | .estimated_days_state 351 | .handle_key_event(key_event, &self.estimated_days_input), 352 | FocusedField::StartDate => self.start_date_state.handle_key_event(key_event), 353 | }; 354 | 355 | if input_handled { 356 | return (self, None); 357 | } 358 | 359 | // Handle other keys 360 | match key_event.code { 361 | KeyCode::Esc => ( 362 | self, 363 | Some(Command::ChangeScreen(Box::new(Screen::Gantt( 364 | Box::default(), 365 | )))), 366 | ), 367 | KeyCode::Enter => { 368 | // Get the new task ID from input 369 | let new_task_id = TaskId(self.task_id_state.content().to_string()); 370 | 371 | // Handle task ID change if needed 372 | if self.original_task_id != new_task_id { 373 | match project.rename_task_id(&self.original_task_id, &new_task_id) { 374 | Ok(()) => { 375 | // Update our internal task_id to the new one 376 | self.task_id = new_task_id.clone(); 377 | } 378 | Err(_error) => { 379 | // Return to edit mode with error - we could add error display later 380 | // For now, just prevent the save and stay in edit mode 381 | return (self, None); 382 | } 383 | } 384 | } 385 | 386 | // Save all other fields 387 | if let Some(task) = project.get_task_mut(&self.task_id) { 388 | task.set_description(self.description_state.content()); 389 | 390 | // Parse and set estimated days 391 | let estimated_days = self 392 | .estimated_days_state 393 | .content() 394 | .parse::() 395 | .unwrap_or(0); 396 | task.set_estimated_days(estimated_days); 397 | 398 | // Set start date 399 | let start_date = self.start_date_state.selected_date(); 400 | task.set_start_date(start_date); 401 | } 402 | ( 403 | self, 404 | Some(Command::ChangeScreen(Box::new(Screen::Gantt( 405 | Box::default(), 406 | )))), 407 | ) 408 | } 409 | _ => (self, None), 410 | } 411 | } 412 | } 413 | } 414 | } 415 | -------------------------------------------------------------------------------- /src/domain/project.rs: -------------------------------------------------------------------------------- 1 | use super::{Task, TaskId}; 2 | use jiff::{ToSpan, civil::Date}; 3 | use serde::{Deserialize, Serialize}; 4 | use std::collections::{HashMap, HashSet}; 5 | 6 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] 7 | pub struct Project { 8 | /// All the tasks in the project 9 | pub tasks: HashMap, 10 | } 11 | 12 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] 13 | pub struct DateRange { 14 | /// The start date of the range (inclusive) 15 | pub start: Date, 16 | /// The end date of the range (inclusive) 17 | pub end: Date, 18 | } 19 | 20 | impl Project { 21 | pub fn random_new_task_id(&self) -> TaskId { 22 | use rand::distr::{Alphanumeric, SampleString}; 23 | let mut rng = rand::rng(); 24 | for _ in 0..100 { 25 | let id: String = Alphanumeric.sample_string(&mut rng, 6).to_lowercase(); 26 | let task_id = TaskId(id); 27 | if !self.tasks.contains_key(&task_id) { 28 | return task_id; 29 | } 30 | } 31 | panic!("Failed to generate a unique task ID after 100 attempts"); 32 | } 33 | 34 | pub fn create_task(&mut self, task_id: TaskId) -> Result<&mut Task, String> { 35 | if self.tasks.contains_key(&task_id) { 36 | return Err(format!("Task with ID {} already exists", task_id.0)); 37 | } 38 | 39 | self.tasks.insert(task_id.clone(), Task::default()); 40 | let task = self 41 | .tasks 42 | .get_mut(&task_id) 43 | .expect("Just inserted task should be present"); 44 | 45 | Ok(task) 46 | } 47 | 48 | pub fn get_task(&self, task_id: S) -> Option<&Task> { 49 | self.tasks.get(&TaskId(task_id.to_string())) 50 | } 51 | 52 | pub fn get_task_mut(&mut self, task_id: S) -> Option<&mut Task> { 53 | self.tasks.get_mut(&TaskId(task_id.to_string())) 54 | } 55 | 56 | pub fn delete_task(&mut self, task_id: S) -> Result<(), String> { 57 | let task_id = TaskId(task_id.to_string()); 58 | 59 | // Check if task exists 60 | if !self.tasks.contains_key(&task_id) { 61 | return Err(format!("Task with ID {} does not exist", task_id.0)); 62 | } 63 | 64 | // Remove the task 65 | self.tasks.remove(&task_id); 66 | 67 | // Remove this task from all other tasks' blocker lists 68 | for (_, task) in self.tasks.iter_mut() { 69 | task.blockers.retain(|blocker_id| blocker_id != &task_id); 70 | } 71 | 72 | Ok(()) 73 | } 74 | 75 | /// Rename a task ID and update all references to it in blocker lists 76 | pub fn rename_task_id( 77 | &mut self, 78 | old_task_id: S, 79 | new_task_id: S, 80 | ) -> Result<(), String> { 81 | let old_id = TaskId(old_task_id.to_string()); 82 | let new_id = TaskId(new_task_id.to_string()); 83 | 84 | // Check if old task exists 85 | if !self.tasks.contains_key(&old_id) { 86 | return Err(format!("Task '{}' does not exist", old_id.0)); 87 | } 88 | 89 | // Check if new task ID already exists (and it's different from the old one) 90 | if old_id != new_id && self.tasks.contains_key(&new_id) { 91 | return Err(format!("Task '{}' already exists", new_id.0)); 92 | } 93 | 94 | // If the ID is the same, no change needed 95 | if old_id == new_id { 96 | return Ok(()); 97 | } 98 | 99 | // Validate the new task ID format 100 | if !new_id 101 | .0 102 | .chars() 103 | .all(|c| c.is_alphanumeric() || c == '-' || c == '_') 104 | { 105 | return Err( 106 | "Task ID can only contain letters, numbers, hyphens, and underscores".to_string(), 107 | ); 108 | } 109 | 110 | if new_id.0.trim().is_empty() { 111 | return Err("Task ID cannot be empty".to_string()); 112 | } 113 | 114 | // Remove the task with old ID and insert with new ID 115 | if let Some(task) = self.tasks.remove(&old_id) { 116 | self.tasks.insert(new_id.clone(), task); 117 | } 118 | 119 | // Update all blocker references 120 | for task in self.tasks.values_mut() { 121 | for blocker in &mut task.blockers { 122 | if *blocker == old_id { 123 | *blocker = new_id.clone(); 124 | } 125 | } 126 | } 127 | 128 | Ok(()) 129 | } 130 | 131 | /// Returns true if there are cyclic dependencies among tasks, meaning that 132 | /// tasks cannot be automatically scheduled. 133 | pub fn detect_cyclic_dependencies(&self) -> bool { 134 | use std::collections::HashSet; 135 | 136 | let mut visited = HashSet::new(); 137 | let mut rec_stack = HashSet::new(); 138 | 139 | for task_id in self.tasks.keys() { 140 | if !visited.contains(task_id) 141 | && self.has_cycle_dfs(task_id, &mut visited, &mut rec_stack) 142 | { 143 | return true; 144 | } 145 | } 146 | false 147 | } 148 | 149 | /// Depth-first search helper to detect cycles 150 | fn has_cycle_dfs( 151 | &self, 152 | task_id: &TaskId, 153 | visited: &mut HashSet, 154 | rec_stack: &mut HashSet, 155 | ) -> bool { 156 | visited.insert(task_id.clone()); 157 | rec_stack.insert(task_id.clone()); 158 | 159 | if let Some(task) = self.tasks.get(task_id) { 160 | for blocker_id in &task.blockers { 161 | // if blocker doesn't exist in tasks, skip it (dangling dependency) 162 | if !self.tasks.contains_key(blocker_id) { 163 | continue; 164 | } 165 | 166 | // if blocker is in recursion stack, we found a cycle 167 | if rec_stack.contains(blocker_id) { 168 | return true; 169 | } 170 | 171 | // if not visited, recursively check 172 | if !visited.contains(blocker_id) 173 | && self.has_cycle_dfs(blocker_id, visited, rec_stack) 174 | { 175 | return true; 176 | } 177 | } 178 | } 179 | 180 | // remove from recursion stack before returning 181 | rec_stack.remove(task_id); 182 | false 183 | } 184 | 185 | /// Schedule tasks based on their dependencies and start dates. 186 | pub fn schedule_tasks(&self) -> Result>, String> { 187 | if self.detect_cyclic_dependencies() { 188 | return Err("Cannot schedule tasks with cyclic dependencies".into()); 189 | } 190 | 191 | if self.tasks.is_empty() { 192 | return Ok(HashMap::new()); 193 | } 194 | 195 | // 1. Figure out the project start date 196 | let project_start_date = self 197 | .tasks 198 | .values() 199 | .filter_map(|task| task.start_date) 200 | .min() 201 | .unwrap_or_else(crate::util::today); 202 | 203 | // 2. Topologically sort the tasks 204 | let sorted_tasks = self.topological_sort()?; 205 | 206 | let mut schedule = HashMap::new(); 207 | let mut task_end_dates: HashMap = HashMap::new(); 208 | 209 | // 3. Schedule each task in topological order 210 | for task_id in sorted_tasks { 211 | let task = self.tasks.get(&task_id).unwrap(); 212 | 213 | // Calculate earliest start date based on dependencies 214 | let earliest_from_blockers = task 215 | .blockers 216 | .iter() 217 | .filter_map(|blocker_id| task_end_dates.get(blocker_id)) 218 | .map(|end_date| end_date.saturating_add(1.days())) 219 | .max() 220 | .unwrap_or(project_start_date); 221 | 222 | // Use the later of: hard start date or blocker completion 223 | let start_date = task 224 | .start_date 225 | .map(|hard_start| hard_start.max(earliest_from_blockers)) 226 | .unwrap_or(earliest_from_blockers); 227 | 228 | // Generate schedule for this task 229 | let task_dates = self.generate_task_dates(start_date, task.estimated_days); 230 | let end_date = task_dates.last().copied().unwrap_or(start_date); 231 | 232 | task_end_dates.insert(task_id.clone(), end_date); 233 | schedule.insert(task_id, task_dates); 234 | } 235 | 236 | Ok(schedule) 237 | } 238 | 239 | /// Topologically sort tasks based on their dependencies 240 | fn topological_sort(&self) -> Result, String> { 241 | let mut in_degree: HashMap = HashMap::new(); 242 | let mut graph: HashMap> = HashMap::new(); 243 | 244 | // Initialize in-degree and graph 245 | for task_id in self.tasks.keys() { 246 | in_degree.insert(task_id.clone(), 0); 247 | graph.insert(task_id.clone(), Vec::new()); 248 | } 249 | 250 | // Build the dependency graph and calculate in-degrees 251 | for (task_id, task) in &self.tasks { 252 | for blocker_id in &task.blockers { 253 | if self.tasks.contains_key(blocker_id) { 254 | graph.get_mut(blocker_id).unwrap().push(task_id.clone()); 255 | *in_degree.get_mut(task_id).unwrap() += 1; 256 | } 257 | } 258 | } 259 | 260 | // Kahn's algorithm 261 | let mut queue: Vec = in_degree 262 | .iter() 263 | .filter(|&(_, °ree)| degree == 0) 264 | .map(|(id, _)| id.clone()) 265 | .collect(); 266 | 267 | let mut result = Vec::new(); 268 | 269 | while let Some(current) = queue.pop() { 270 | result.push(current.clone()); 271 | 272 | if let Some(dependents) = graph.get(¤t) { 273 | for dependent in dependents { 274 | let degree = in_degree.get_mut(dependent).unwrap(); 275 | *degree -= 1; 276 | if *degree == 0 { 277 | queue.push(dependent.clone()); 278 | } 279 | } 280 | } 281 | } 282 | 283 | if result.len() != self.tasks.len() { 284 | return Err("Cyclic dependency detected during topological sort".into()); 285 | } 286 | 287 | Ok(result) 288 | } 289 | 290 | /// Generate consecutive dates for a task, skipping weekends 291 | fn generate_task_dates(&self, start_date: Date, estimated_days: u32) -> Vec { 292 | if estimated_days == 0 { 293 | return vec![start_date]; 294 | } 295 | 296 | let mut dates = Vec::new(); 297 | let mut current_date = start_date; 298 | let mut days_scheduled = 0; 299 | 300 | while days_scheduled < estimated_days { 301 | // Skip weekends (Saturday = 6, Sunday = 0) 302 | let weekday = current_date.weekday(); 303 | if weekday != jiff::civil::Weekday::Saturday && weekday != jiff::civil::Weekday::Sunday 304 | { 305 | dates.push(current_date); 306 | days_scheduled += 1; 307 | } 308 | current_date = current_date.saturating_add(1.days()); 309 | } 310 | 311 | dates 312 | } 313 | } 314 | 315 | #[cfg(test)] 316 | mod test { 317 | use jiff::{ToSpan, civil::date}; 318 | 319 | use super::*; 320 | 321 | #[test] 322 | fn correctly_detects_basic_cyclic_dependencies() { 323 | let mut project = Project::default(); 324 | 325 | assert!(!project.detect_cyclic_dependencies()); 326 | project.create_task("A".into()).expect("Can create task A"); 327 | project.create_task("B".into()).expect("Can create task B"); 328 | 329 | project 330 | .get_task_mut("B") 331 | .expect("Task B should exist") 332 | .add_blocker("A".into()); 333 | assert!(!project.detect_cyclic_dependencies()); 334 | 335 | project 336 | .get_task_mut("A") 337 | .expect("Task A should exist") 338 | .add_blocker("B".into()); 339 | assert!(project.detect_cyclic_dependencies()); 340 | } 341 | 342 | #[test] 343 | fn correctly_detects_deeper_cyclic_dependencies() { 344 | let mut project = Project::default(); 345 | 346 | project.create_task("A".into()).expect("Can create task A"); 347 | project.create_task("B".into()).expect("Can create task B"); 348 | project.create_task("C".into()).expect("Can create task C"); 349 | 350 | project 351 | .get_task_mut("B") 352 | .expect("Task B should exist") 353 | .add_blocker("A".into()); 354 | project 355 | .get_task_mut("C") 356 | .expect("Task C should exist") 357 | .add_blocker("B".into()); 358 | assert!(!project.detect_cyclic_dependencies()); 359 | 360 | project 361 | .get_task_mut("A") 362 | .expect("Task A should exist") 363 | .add_blocker("C".into()); 364 | assert!(project.detect_cyclic_dependencies()); 365 | } 366 | 367 | #[test] 368 | fn can_schedule_tasks_without_start_dates_or_dependencies() { 369 | let mut project = Project::default(); 370 | 371 | // Use a fixed Monday (2024-06-10) to make the test predictable 372 | let monday = date(2024, 6, 10); 373 | project 374 | .create_task("A".into()) 375 | .expect("Can create task A") 376 | .set_estimated_days(3) 377 | .set_start_date(Some(monday)); 378 | project 379 | .create_task("B".into()) 380 | .expect("Can create task B") 381 | .set_estimated_days(2); 382 | project 383 | .create_task("C".into()) 384 | .expect("Can create task C") 385 | .set_estimated_days(1); 386 | 387 | let schedule = project.schedule_tasks().expect("Can schedule tasks"); 388 | assert_eq!(schedule.len(), 3); 389 | 390 | // Task A: 3 days starting Monday: Mon, Tue, Wed 391 | assert_eq!( 392 | schedule.get(&TaskId("A".into())).unwrap(), 393 | &vec![ 394 | monday, // Mon 395 | monday.saturating_add(1.days()), // Tue 396 | monday.saturating_add(2.days()) // Wed 397 | ] 398 | ); 399 | // Task B: 2 days starting Monday: Mon, Tue 400 | assert_eq!( 401 | schedule.get(&TaskId("B".into())).unwrap(), 402 | &vec![monday, monday.saturating_add(1.days())] 403 | ); 404 | // Task C: 1 day starting Monday: Mon 405 | assert_eq!(schedule.get(&TaskId("C".into())).unwrap(), &vec![monday]); 406 | } 407 | 408 | #[test] 409 | fn can_schedule_basic_tasks_with_start_dates() { 410 | let mut project = Project::default(); 411 | 412 | let start_date_a = date(2024, 6, 10); 413 | project 414 | .create_task("A".into()) 415 | .expect("Can create task A") 416 | .set_estimated_days(2) 417 | .set_start_date(Some(start_date_a)); 418 | project 419 | .create_task("B".into()) 420 | .expect("Can create task B") 421 | .set_estimated_days(2); 422 | 423 | let schedule = project.schedule_tasks().expect("Can schedule tasks"); 424 | assert_eq!(schedule.len(), 2); 425 | 426 | // Task A should start from its hard start date 427 | // Task B should start from task A's start date since that is the start of the project 428 | 429 | assert_eq!( 430 | schedule.get(&TaskId("A".into())).unwrap(), 431 | &vec![start_date_a, start_date_a.saturating_add(1.days())] 432 | ); 433 | assert_eq!( 434 | schedule.get(&TaskId("B".into())).unwrap(), 435 | &vec![start_date_a, start_date_a.saturating_add(1.days())] 436 | ); 437 | } 438 | 439 | #[test] 440 | fn can_schedule_tasks_with_basic_dependencies() { 441 | let mut project = Project::default(); 442 | 443 | // Use a fixed Monday (2024-06-10) to make the test predictable 444 | let monday = date(2024, 6, 10); 445 | project 446 | .create_task("A".into()) 447 | .expect("Can create task A") 448 | .set_estimated_days(3) 449 | .set_start_date(Some(monday)); 450 | project 451 | .create_task("B".into()) 452 | .expect("Can create task B") 453 | .set_estimated_days(2) 454 | .add_blocker("A".into()); 455 | project 456 | .create_task("C".into()) 457 | .expect("Can create task C") 458 | .set_estimated_days(1) 459 | .add_blocker("B".into()); 460 | 461 | let schedule = project.schedule_tasks().expect("Can schedule tasks"); 462 | assert_eq!(schedule.len(), 3); 463 | 464 | // Task A: Mon-Wed (3 days) 465 | assert_eq!( 466 | schedule.get(&TaskId("A".into())).unwrap(), 467 | &vec![ 468 | monday, // Mon 469 | monday.saturating_add(1.days()), // Tue 470 | monday.saturating_add(2.days()) // Wed 471 | ] 472 | ); 473 | // Task B: Thu-Fri (2 days, starts after A finishes) 474 | assert_eq!( 475 | schedule.get(&TaskId("B".into())).unwrap(), 476 | &vec![ 477 | monday.saturating_add(3.days()), // Thu 478 | monday.saturating_add(4.days()) // Fri 479 | ] 480 | ); 481 | // Task C: Mon (1 day, starts after B finishes, skipping weekend) 482 | assert_eq!( 483 | schedule.get(&TaskId("C".into())).unwrap(), 484 | &vec![monday.saturating_add(7.days())] // Next Monday 485 | ); 486 | } 487 | 488 | #[test] 489 | fn correctly_skips_weekends_in_scheduling() { 490 | let mut project = Project::default(); 491 | 492 | // Use Friday (2024-06-07) as start date to test weekend skipping 493 | let friday = date(2024, 6, 7); 494 | project 495 | .create_task("A".into()) 496 | .expect("Can create task A") 497 | .set_estimated_days(3) 498 | .set_start_date(Some(friday)); 499 | 500 | let schedule = project.schedule_tasks().expect("Can schedule tasks"); 501 | 502 | // Should schedule: Friday, then skip weekend, then Monday, Tuesday 503 | assert_eq!( 504 | schedule.get(&TaskId("A".into())).unwrap(), 505 | &vec![ 506 | friday, // Friday 507 | friday.saturating_add(3.days()), // Monday (skipping Sat, Sun) 508 | friday.saturating_add(4.days()) // Tuesday 509 | ] 510 | ); 511 | } 512 | 513 | #[test] 514 | fn handles_tasks_spanning_multiple_weekends() { 515 | let mut project = Project::default(); 516 | 517 | // Use Friday (2024-06-07) as start date 518 | let friday = date(2024, 6, 7); 519 | project 520 | .create_task("A".into()) 521 | .expect("Can create task A") 522 | .set_estimated_days(8) 523 | .set_start_date(Some(friday)); 524 | 525 | let schedule = project.schedule_tasks().expect("Can schedule tasks"); 526 | 527 | // Should schedule: Fri, Mon, Tue, Wed, Thu, Fri, Mon, Tue (8 weekdays) 528 | let expected = vec![ 529 | friday, // Fri (week 1) 530 | friday.saturating_add(3.days()), // Mon (week 2) 531 | friday.saturating_add(4.days()), // Tue 532 | friday.saturating_add(5.days()), // Wed 533 | friday.saturating_add(6.days()), // Thu 534 | friday.saturating_add(7.days()), // Fri 535 | friday.saturating_add(10.days()), // Mon (week 3) 536 | friday.saturating_add(11.days()), // Tue 537 | ]; 538 | 539 | assert_eq!(schedule.get(&TaskId("A".into())).unwrap(), &expected); 540 | } 541 | 542 | #[test] 543 | fn can_rename_task_id_and_update_blockers() { 544 | let mut project = Project::default(); 545 | 546 | // Create tasks 547 | let task_a = project.create_task(TaskId("task-a".to_string())).unwrap(); 548 | task_a.set_description("Task A"); 549 | 550 | let task_b = project.create_task(TaskId("task-b".to_string())).unwrap(); 551 | task_b 552 | .set_description("Task B") 553 | .add_blocker(TaskId("task-a".to_string())); 554 | 555 | let task_c = project.create_task(TaskId("task-c".to_string())).unwrap(); 556 | task_c 557 | .set_description("Task C") 558 | .add_blocker(TaskId("task-a".to_string())) 559 | .add_blocker(TaskId("task-b".to_string())); 560 | 561 | // Verify initial state 562 | assert!(project.tasks.contains_key(&TaskId("task-a".to_string()))); 563 | assert!( 564 | project.tasks[&TaskId("task-b".to_string())] 565 | .blockers 566 | .contains(&TaskId("task-a".to_string())) 567 | ); 568 | assert!( 569 | project.tasks[&TaskId("task-c".to_string())] 570 | .blockers 571 | .contains(&TaskId("task-a".to_string())) 572 | ); 573 | 574 | // Rename task-a to new-task-a 575 | let result = project.rename_task_id("task-a", "new-task-a"); 576 | assert!(result.is_ok()); 577 | 578 | // Verify task was renamed 579 | assert!(!project.tasks.contains_key(&TaskId("task-a".to_string()))); 580 | assert!( 581 | project 582 | .tasks 583 | .contains_key(&TaskId("new-task-a".to_string())) 584 | ); 585 | assert_eq!( 586 | project.tasks[&TaskId("new-task-a".to_string())].description, 587 | "Task A" 588 | ); 589 | 590 | // Verify blocker references were updated 591 | assert!( 592 | !project.tasks[&TaskId("task-b".to_string())] 593 | .blockers 594 | .contains(&TaskId("task-a".to_string())) 595 | ); 596 | assert!( 597 | project.tasks[&TaskId("task-b".to_string())] 598 | .blockers 599 | .contains(&TaskId("new-task-a".to_string())) 600 | ); 601 | 602 | assert!( 603 | !project.tasks[&TaskId("task-c".to_string())] 604 | .blockers 605 | .contains(&TaskId("task-a".to_string())) 606 | ); 607 | assert!( 608 | project.tasks[&TaskId("task-c".to_string())] 609 | .blockers 610 | .contains(&TaskId("new-task-a".to_string())) 611 | ); 612 | assert!( 613 | project.tasks[&TaskId("task-c".to_string())] 614 | .blockers 615 | .contains(&TaskId("task-b".to_string())) 616 | ); 617 | } 618 | 619 | #[test] 620 | fn rename_task_id_prevents_duplicates() { 621 | let mut project = Project::default(); 622 | 623 | project.create_task(TaskId("task-a".to_string())).unwrap(); 624 | project.create_task(TaskId("task-b".to_string())).unwrap(); 625 | 626 | // Try to rename task-a to task-b (which already exists) 627 | let result = project.rename_task_id("task-a", "task-b"); 628 | assert!(result.is_err()); 629 | assert!(result.unwrap_err().contains("already exists")); 630 | 631 | // Verify original tasks are unchanged 632 | assert!(project.tasks.contains_key(&TaskId("task-a".to_string()))); 633 | assert!(project.tasks.contains_key(&TaskId("task-b".to_string()))); 634 | } 635 | 636 | #[test] 637 | fn rename_task_id_validates_format() { 638 | let mut project = Project::default(); 639 | project.create_task(TaskId("task-a".to_string())).unwrap(); 640 | 641 | // Try to rename to invalid ID format 642 | let result = project.rename_task_id("task-a", "task with spaces"); 643 | assert!(result.is_err()); 644 | assert!(result.unwrap_err().contains("can only contain")); 645 | 646 | // Try to rename to empty ID 647 | let result = project.rename_task_id("task-a", ""); 648 | assert!(result.is_err()); 649 | assert!(result.unwrap_err().contains("cannot be empty")); 650 | 651 | // Verify original task is unchanged 652 | assert!(project.tasks.contains_key(&TaskId("task-a".to_string()))); 653 | } 654 | 655 | #[test] 656 | fn rename_task_id_handles_same_id() { 657 | let mut project = Project::default(); 658 | let task = project.create_task(TaskId("task-a".to_string())).unwrap(); 659 | task.set_description("Original description"); 660 | 661 | // Rename to the same ID should succeed with no changes 662 | let result = project.rename_task_id("task-a", "task-a"); 663 | assert!(result.is_ok()); 664 | 665 | // Verify task is unchanged 666 | assert!(project.tasks.contains_key(&TaskId("task-a".to_string()))); 667 | assert_eq!( 668 | project.tasks[&TaskId("task-a".to_string())].description, 669 | "Original description" 670 | ); 671 | } 672 | 673 | #[test] 674 | fn rename_nonexistent_task_returns_error() { 675 | let mut project = Project::default(); 676 | 677 | let result = project.rename_task_id("nonexistent", "new-name"); 678 | assert!(result.is_err()); 679 | assert!(result.unwrap_err().contains("does not exist")); 680 | } 681 | 682 | #[test] 683 | fn can_delete_task_and_remove_from_blockers() { 684 | let mut project = Project::default(); 685 | 686 | // Create tasks A, B, C where B blocks A and C blocks B 687 | project.create_task("A".into()).expect("Can create task A"); 688 | project.create_task("B".into()).expect("Can create task B"); 689 | project.create_task("C".into()).expect("Can create task C"); 690 | 691 | project 692 | .get_task_mut("A") 693 | .expect("Task A should exist") 694 | .add_blocker("B".into()); 695 | project 696 | .get_task_mut("B") 697 | .expect("Task B should exist") 698 | .add_blocker("C".into()); 699 | 700 | // Verify initial dependencies 701 | assert_eq!(project.get_task("A").unwrap().blockers.len(), 1); 702 | assert_eq!(project.get_task("B").unwrap().blockers.len(), 1); 703 | assert_eq!(project.tasks.len(), 3); 704 | 705 | // Delete task B 706 | project.delete_task("B").expect("Can delete task B"); 707 | 708 | // Verify task B is removed and A's blocker list is cleaned up 709 | assert_eq!(project.tasks.len(), 2); 710 | assert!(project.get_task("B").is_none()); 711 | assert_eq!(project.get_task("A").unwrap().blockers.len(), 0); 712 | assert_eq!(project.get_task("C").unwrap().blockers.len(), 0); 713 | } 714 | 715 | #[test] 716 | fn delete_nonexistent_task_returns_error() { 717 | let mut project = Project::default(); 718 | 719 | let result = project.delete_task("nonexistent"); 720 | assert!(result.is_err()); 721 | assert!(result.unwrap_err().contains("does not exist")); 722 | } 723 | } 724 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.3" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "allocator-api2" 31 | version = "0.2.21" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 34 | 35 | [[package]] 36 | name = "anstream" 37 | version = "0.6.20" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" 40 | dependencies = [ 41 | "anstyle", 42 | "anstyle-parse", 43 | "anstyle-query", 44 | "anstyle-wincon", 45 | "colorchoice", 46 | "is_terminal_polyfill", 47 | "utf8parse", 48 | ] 49 | 50 | [[package]] 51 | name = "anstyle" 52 | version = "1.0.11" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" 55 | 56 | [[package]] 57 | name = "anstyle-parse" 58 | version = "0.2.7" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 61 | dependencies = [ 62 | "utf8parse", 63 | ] 64 | 65 | [[package]] 66 | name = "anstyle-query" 67 | version = "1.1.4" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" 70 | dependencies = [ 71 | "windows-sys 0.60.2", 72 | ] 73 | 74 | [[package]] 75 | name = "anstyle-wincon" 76 | version = "3.0.10" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" 79 | dependencies = [ 80 | "anstyle", 81 | "once_cell_polyfill", 82 | "windows-sys 0.60.2", 83 | ] 84 | 85 | [[package]] 86 | name = "autocfg" 87 | version = "1.5.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 90 | 91 | [[package]] 92 | name = "backtrace" 93 | version = "0.3.75" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" 96 | dependencies = [ 97 | "addr2line", 98 | "cfg-if", 99 | "libc", 100 | "miniz_oxide", 101 | "object", 102 | "rustc-demangle", 103 | "windows-targets 0.52.6", 104 | ] 105 | 106 | [[package]] 107 | name = "base64" 108 | version = "0.22.1" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 111 | 112 | [[package]] 113 | name = "bitflags" 114 | version = "2.9.4" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 117 | dependencies = [ 118 | "serde", 119 | ] 120 | 121 | [[package]] 122 | name = "cassowary" 123 | version = "0.3.0" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 126 | 127 | [[package]] 128 | name = "castaway" 129 | version = "0.2.4" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 132 | dependencies = [ 133 | "rustversion", 134 | ] 135 | 136 | [[package]] 137 | name = "cfg-if" 138 | version = "1.0.3" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" 141 | 142 | [[package]] 143 | name = "clap" 144 | version = "4.5.47" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" 147 | dependencies = [ 148 | "clap_builder", 149 | "clap_derive", 150 | ] 151 | 152 | [[package]] 153 | name = "clap_builder" 154 | version = "4.5.47" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" 157 | dependencies = [ 158 | "anstream", 159 | "anstyle", 160 | "clap_lex", 161 | "strsim", 162 | "terminal_size", 163 | "unicase", 164 | "unicode-width 0.2.0", 165 | ] 166 | 167 | [[package]] 168 | name = "clap_derive" 169 | version = "4.5.47" 170 | source = "registry+https://github.com/rust-lang/crates.io-index" 171 | checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" 172 | dependencies = [ 173 | "heck", 174 | "proc-macro2", 175 | "quote", 176 | "syn", 177 | ] 178 | 179 | [[package]] 180 | name = "clap_lex" 181 | version = "0.7.5" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" 184 | 185 | [[package]] 186 | name = "color-eyre" 187 | version = "0.6.5" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" 190 | dependencies = [ 191 | "backtrace", 192 | "color-spantrace", 193 | "eyre", 194 | "indenter", 195 | "once_cell", 196 | "owo-colors", 197 | "tracing-error", 198 | ] 199 | 200 | [[package]] 201 | name = "color-spantrace" 202 | version = "0.3.0" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" 205 | dependencies = [ 206 | "once_cell", 207 | "owo-colors", 208 | "tracing-core", 209 | "tracing-error", 210 | ] 211 | 212 | [[package]] 213 | name = "colorchoice" 214 | version = "1.0.4" 215 | source = "registry+https://github.com/rust-lang/crates.io-index" 216 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 217 | 218 | [[package]] 219 | name = "compact_str" 220 | version = "0.8.1" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 223 | dependencies = [ 224 | "castaway", 225 | "cfg-if", 226 | "itoa", 227 | "rustversion", 228 | "ryu", 229 | "static_assertions", 230 | ] 231 | 232 | [[package]] 233 | name = "convert_case" 234 | version = "0.7.1" 235 | source = "registry+https://github.com/rust-lang/crates.io-index" 236 | checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" 237 | dependencies = [ 238 | "unicode-segmentation", 239 | ] 240 | 241 | [[package]] 242 | name = "crossterm" 243 | version = "0.28.1" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 246 | dependencies = [ 247 | "bitflags", 248 | "crossterm_winapi", 249 | "mio", 250 | "parking_lot", 251 | "rustix 0.38.44", 252 | "signal-hook", 253 | "signal-hook-mio", 254 | "winapi", 255 | ] 256 | 257 | [[package]] 258 | name = "crossterm" 259 | version = "0.29.0" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" 262 | dependencies = [ 263 | "bitflags", 264 | "crossterm_winapi", 265 | "derive_more", 266 | "document-features", 267 | "mio", 268 | "parking_lot", 269 | "rustix 1.0.8", 270 | "signal-hook", 271 | "signal-hook-mio", 272 | "winapi", 273 | ] 274 | 275 | [[package]] 276 | name = "crossterm_winapi" 277 | version = "0.9.1" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 280 | dependencies = [ 281 | "winapi", 282 | ] 283 | 284 | [[package]] 285 | name = "darling" 286 | version = "0.20.11" 287 | source = "registry+https://github.com/rust-lang/crates.io-index" 288 | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 289 | dependencies = [ 290 | "darling_core", 291 | "darling_macro", 292 | ] 293 | 294 | [[package]] 295 | name = "darling_core" 296 | version = "0.20.11" 297 | source = "registry+https://github.com/rust-lang/crates.io-index" 298 | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 299 | dependencies = [ 300 | "fnv", 301 | "ident_case", 302 | "proc-macro2", 303 | "quote", 304 | "strsim", 305 | "syn", 306 | ] 307 | 308 | [[package]] 309 | name = "darling_macro" 310 | version = "0.20.11" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 313 | dependencies = [ 314 | "darling_core", 315 | "quote", 316 | "syn", 317 | ] 318 | 319 | [[package]] 320 | name = "derive_more" 321 | version = "2.0.1" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" 324 | dependencies = [ 325 | "derive_more-impl", 326 | ] 327 | 328 | [[package]] 329 | name = "derive_more-impl" 330 | version = "2.0.1" 331 | source = "registry+https://github.com/rust-lang/crates.io-index" 332 | checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" 333 | dependencies = [ 334 | "convert_case", 335 | "proc-macro2", 336 | "quote", 337 | "syn", 338 | ] 339 | 340 | [[package]] 341 | name = "document-features" 342 | version = "0.2.11" 343 | source = "registry+https://github.com/rust-lang/crates.io-index" 344 | checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" 345 | dependencies = [ 346 | "litrs", 347 | ] 348 | 349 | [[package]] 350 | name = "either" 351 | version = "1.15.0" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 354 | 355 | [[package]] 356 | name = "equivalent" 357 | version = "1.0.2" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 360 | 361 | [[package]] 362 | name = "errno" 363 | version = "0.3.13" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" 366 | dependencies = [ 367 | "libc", 368 | "windows-sys 0.60.2", 369 | ] 370 | 371 | [[package]] 372 | name = "eyre" 373 | version = "0.6.12" 374 | source = "registry+https://github.com/rust-lang/crates.io-index" 375 | checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" 376 | dependencies = [ 377 | "indenter", 378 | "once_cell", 379 | ] 380 | 381 | [[package]] 382 | name = "fnv" 383 | version = "1.0.7" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 386 | 387 | [[package]] 388 | name = "foldhash" 389 | version = "0.1.5" 390 | source = "registry+https://github.com/rust-lang/crates.io-index" 391 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 392 | 393 | [[package]] 394 | name = "getrandom" 395 | version = "0.3.3" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 398 | dependencies = [ 399 | "cfg-if", 400 | "libc", 401 | "r-efi", 402 | "wasi 0.14.3+wasi-0.2.4", 403 | ] 404 | 405 | [[package]] 406 | name = "gimli" 407 | version = "0.31.1" 408 | source = "registry+https://github.com/rust-lang/crates.io-index" 409 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 410 | 411 | [[package]] 412 | name = "hashbrown" 413 | version = "0.15.5" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 416 | dependencies = [ 417 | "allocator-api2", 418 | "equivalent", 419 | "foldhash", 420 | ] 421 | 422 | [[package]] 423 | name = "heck" 424 | version = "0.5.0" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 427 | 428 | [[package]] 429 | name = "ident_case" 430 | version = "1.0.1" 431 | source = "registry+https://github.com/rust-lang/crates.io-index" 432 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 433 | 434 | [[package]] 435 | name = "indenter" 436 | version = "0.3.4" 437 | source = "registry+https://github.com/rust-lang/crates.io-index" 438 | checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" 439 | 440 | [[package]] 441 | name = "indoc" 442 | version = "2.0.6" 443 | source = "registry+https://github.com/rust-lang/crates.io-index" 444 | checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" 445 | 446 | [[package]] 447 | name = "instability" 448 | version = "0.3.9" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" 451 | dependencies = [ 452 | "darling", 453 | "indoc", 454 | "proc-macro2", 455 | "quote", 456 | "syn", 457 | ] 458 | 459 | [[package]] 460 | name = "is_terminal_polyfill" 461 | version = "1.70.1" 462 | source = "registry+https://github.com/rust-lang/crates.io-index" 463 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 464 | 465 | [[package]] 466 | name = "itertools" 467 | version = "0.13.0" 468 | source = "registry+https://github.com/rust-lang/crates.io-index" 469 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 470 | dependencies = [ 471 | "either", 472 | ] 473 | 474 | [[package]] 475 | name = "itoa" 476 | version = "1.0.15" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 479 | 480 | [[package]] 481 | name = "jiff" 482 | version = "0.2.15" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" 485 | dependencies = [ 486 | "jiff-static", 487 | "jiff-tzdb-platform", 488 | "log", 489 | "portable-atomic", 490 | "portable-atomic-util", 491 | "serde", 492 | "windows-sys 0.59.0", 493 | ] 494 | 495 | [[package]] 496 | name = "jiff-static" 497 | version = "0.2.15" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" 500 | dependencies = [ 501 | "proc-macro2", 502 | "quote", 503 | "syn", 504 | ] 505 | 506 | [[package]] 507 | name = "jiff-tzdb" 508 | version = "0.1.4" 509 | source = "registry+https://github.com/rust-lang/crates.io-index" 510 | checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" 511 | 512 | [[package]] 513 | name = "jiff-tzdb-platform" 514 | version = "0.1.3" 515 | source = "registry+https://github.com/rust-lang/crates.io-index" 516 | checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" 517 | dependencies = [ 518 | "jiff-tzdb", 519 | ] 520 | 521 | [[package]] 522 | name = "kantt" 523 | version = "0.1.0" 524 | dependencies = [ 525 | "clap", 526 | "color-eyre", 527 | "crossterm 0.29.0", 528 | "jiff", 529 | "rand", 530 | "ratatui", 531 | "regex", 532 | "ron", 533 | "serde", 534 | "synonym", 535 | ] 536 | 537 | [[package]] 538 | name = "lazy_static" 539 | version = "1.5.0" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 542 | 543 | [[package]] 544 | name = "libc" 545 | version = "0.2.175" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" 548 | 549 | [[package]] 550 | name = "linux-raw-sys" 551 | version = "0.4.15" 552 | source = "registry+https://github.com/rust-lang/crates.io-index" 553 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 554 | 555 | [[package]] 556 | name = "linux-raw-sys" 557 | version = "0.9.4" 558 | source = "registry+https://github.com/rust-lang/crates.io-index" 559 | checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 560 | 561 | [[package]] 562 | name = "litrs" 563 | version = "0.4.2" 564 | source = "registry+https://github.com/rust-lang/crates.io-index" 565 | checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" 566 | 567 | [[package]] 568 | name = "lock_api" 569 | version = "0.4.13" 570 | source = "registry+https://github.com/rust-lang/crates.io-index" 571 | checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" 572 | dependencies = [ 573 | "autocfg", 574 | "scopeguard", 575 | ] 576 | 577 | [[package]] 578 | name = "log" 579 | version = "0.4.28" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 582 | 583 | [[package]] 584 | name = "lru" 585 | version = "0.12.5" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 588 | dependencies = [ 589 | "hashbrown", 590 | ] 591 | 592 | [[package]] 593 | name = "memchr" 594 | version = "2.7.5" 595 | source = "registry+https://github.com/rust-lang/crates.io-index" 596 | checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" 597 | 598 | [[package]] 599 | name = "miniz_oxide" 600 | version = "0.8.9" 601 | source = "registry+https://github.com/rust-lang/crates.io-index" 602 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 603 | dependencies = [ 604 | "adler2", 605 | ] 606 | 607 | [[package]] 608 | name = "mio" 609 | version = "1.0.4" 610 | source = "registry+https://github.com/rust-lang/crates.io-index" 611 | checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 612 | dependencies = [ 613 | "libc", 614 | "log", 615 | "wasi 0.11.1+wasi-snapshot-preview1", 616 | "windows-sys 0.59.0", 617 | ] 618 | 619 | [[package]] 620 | name = "object" 621 | version = "0.36.7" 622 | source = "registry+https://github.com/rust-lang/crates.io-index" 623 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 624 | dependencies = [ 625 | "memchr", 626 | ] 627 | 628 | [[package]] 629 | name = "once_cell" 630 | version = "1.21.3" 631 | source = "registry+https://github.com/rust-lang/crates.io-index" 632 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 633 | 634 | [[package]] 635 | name = "once_cell_polyfill" 636 | version = "1.70.1" 637 | source = "registry+https://github.com/rust-lang/crates.io-index" 638 | checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 639 | 640 | [[package]] 641 | name = "owo-colors" 642 | version = "4.2.2" 643 | source = "registry+https://github.com/rust-lang/crates.io-index" 644 | checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" 645 | 646 | [[package]] 647 | name = "parking_lot" 648 | version = "0.12.4" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" 651 | dependencies = [ 652 | "lock_api", 653 | "parking_lot_core", 654 | ] 655 | 656 | [[package]] 657 | name = "parking_lot_core" 658 | version = "0.9.11" 659 | source = "registry+https://github.com/rust-lang/crates.io-index" 660 | checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" 661 | dependencies = [ 662 | "cfg-if", 663 | "libc", 664 | "redox_syscall", 665 | "smallvec", 666 | "windows-targets 0.52.6", 667 | ] 668 | 669 | [[package]] 670 | name = "paste" 671 | version = "1.0.15" 672 | source = "registry+https://github.com/rust-lang/crates.io-index" 673 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 674 | 675 | [[package]] 676 | name = "pin-project-lite" 677 | version = "0.2.16" 678 | source = "registry+https://github.com/rust-lang/crates.io-index" 679 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 680 | 681 | [[package]] 682 | name = "portable-atomic" 683 | version = "1.11.1" 684 | source = "registry+https://github.com/rust-lang/crates.io-index" 685 | checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 686 | 687 | [[package]] 688 | name = "portable-atomic-util" 689 | version = "0.2.4" 690 | source = "registry+https://github.com/rust-lang/crates.io-index" 691 | checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" 692 | dependencies = [ 693 | "portable-atomic", 694 | ] 695 | 696 | [[package]] 697 | name = "ppv-lite86" 698 | version = "0.2.21" 699 | source = "registry+https://github.com/rust-lang/crates.io-index" 700 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 701 | dependencies = [ 702 | "zerocopy", 703 | ] 704 | 705 | [[package]] 706 | name = "proc-macro2" 707 | version = "1.0.101" 708 | source = "registry+https://github.com/rust-lang/crates.io-index" 709 | checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" 710 | dependencies = [ 711 | "unicode-ident", 712 | ] 713 | 714 | [[package]] 715 | name = "quote" 716 | version = "1.0.40" 717 | source = "registry+https://github.com/rust-lang/crates.io-index" 718 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 719 | dependencies = [ 720 | "proc-macro2", 721 | ] 722 | 723 | [[package]] 724 | name = "r-efi" 725 | version = "5.3.0" 726 | source = "registry+https://github.com/rust-lang/crates.io-index" 727 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 728 | 729 | [[package]] 730 | name = "rand" 731 | version = "0.9.2" 732 | source = "registry+https://github.com/rust-lang/crates.io-index" 733 | checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 734 | dependencies = [ 735 | "rand_chacha", 736 | "rand_core", 737 | ] 738 | 739 | [[package]] 740 | name = "rand_chacha" 741 | version = "0.9.0" 742 | source = "registry+https://github.com/rust-lang/crates.io-index" 743 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 744 | dependencies = [ 745 | "ppv-lite86", 746 | "rand_core", 747 | ] 748 | 749 | [[package]] 750 | name = "rand_core" 751 | version = "0.9.3" 752 | source = "registry+https://github.com/rust-lang/crates.io-index" 753 | checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 754 | dependencies = [ 755 | "getrandom", 756 | ] 757 | 758 | [[package]] 759 | name = "ratatui" 760 | version = "0.29.0" 761 | source = "registry+https://github.com/rust-lang/crates.io-index" 762 | checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 763 | dependencies = [ 764 | "bitflags", 765 | "cassowary", 766 | "compact_str", 767 | "crossterm 0.28.1", 768 | "indoc", 769 | "instability", 770 | "itertools", 771 | "lru", 772 | "paste", 773 | "strum", 774 | "unicode-segmentation", 775 | "unicode-truncate", 776 | "unicode-width 0.2.0", 777 | ] 778 | 779 | [[package]] 780 | name = "redox_syscall" 781 | version = "0.5.17" 782 | source = "registry+https://github.com/rust-lang/crates.io-index" 783 | checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" 784 | dependencies = [ 785 | "bitflags", 786 | ] 787 | 788 | [[package]] 789 | name = "regex" 790 | version = "1.11.2" 791 | source = "registry+https://github.com/rust-lang/crates.io-index" 792 | checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" 793 | dependencies = [ 794 | "aho-corasick", 795 | "memchr", 796 | "regex-automata", 797 | "regex-syntax", 798 | ] 799 | 800 | [[package]] 801 | name = "regex-automata" 802 | version = "0.4.10" 803 | source = "registry+https://github.com/rust-lang/crates.io-index" 804 | checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" 805 | dependencies = [ 806 | "aho-corasick", 807 | "memchr", 808 | "regex-syntax", 809 | ] 810 | 811 | [[package]] 812 | name = "regex-syntax" 813 | version = "0.8.6" 814 | source = "registry+https://github.com/rust-lang/crates.io-index" 815 | checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" 816 | 817 | [[package]] 818 | name = "ron" 819 | version = "0.10.1" 820 | source = "registry+https://github.com/rust-lang/crates.io-index" 821 | checksum = "beceb6f7bf81c73e73aeef6dd1356d9a1b2b4909e1f0fc3e59b034f9572d7b7f" 822 | dependencies = [ 823 | "base64", 824 | "bitflags", 825 | "serde", 826 | "serde_derive", 827 | "unicode-ident", 828 | ] 829 | 830 | [[package]] 831 | name = "rustc-demangle" 832 | version = "0.1.26" 833 | source = "registry+https://github.com/rust-lang/crates.io-index" 834 | checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" 835 | 836 | [[package]] 837 | name = "rustix" 838 | version = "0.38.44" 839 | source = "registry+https://github.com/rust-lang/crates.io-index" 840 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 841 | dependencies = [ 842 | "bitflags", 843 | "errno", 844 | "libc", 845 | "linux-raw-sys 0.4.15", 846 | "windows-sys 0.59.0", 847 | ] 848 | 849 | [[package]] 850 | name = "rustix" 851 | version = "1.0.8" 852 | source = "registry+https://github.com/rust-lang/crates.io-index" 853 | checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" 854 | dependencies = [ 855 | "bitflags", 856 | "errno", 857 | "libc", 858 | "linux-raw-sys 0.9.4", 859 | "windows-sys 0.60.2", 860 | ] 861 | 862 | [[package]] 863 | name = "rustversion" 864 | version = "1.0.22" 865 | source = "registry+https://github.com/rust-lang/crates.io-index" 866 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 867 | 868 | [[package]] 869 | name = "ryu" 870 | version = "1.0.20" 871 | source = "registry+https://github.com/rust-lang/crates.io-index" 872 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 873 | 874 | [[package]] 875 | name = "scopeguard" 876 | version = "1.2.0" 877 | source = "registry+https://github.com/rust-lang/crates.io-index" 878 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 879 | 880 | [[package]] 881 | name = "serde" 882 | version = "1.0.219" 883 | source = "registry+https://github.com/rust-lang/crates.io-index" 884 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 885 | dependencies = [ 886 | "serde_derive", 887 | ] 888 | 889 | [[package]] 890 | name = "serde_derive" 891 | version = "1.0.219" 892 | source = "registry+https://github.com/rust-lang/crates.io-index" 893 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 894 | dependencies = [ 895 | "proc-macro2", 896 | "quote", 897 | "syn", 898 | ] 899 | 900 | [[package]] 901 | name = "sharded-slab" 902 | version = "0.1.7" 903 | source = "registry+https://github.com/rust-lang/crates.io-index" 904 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 905 | dependencies = [ 906 | "lazy_static", 907 | ] 908 | 909 | [[package]] 910 | name = "signal-hook" 911 | version = "0.3.18" 912 | source = "registry+https://github.com/rust-lang/crates.io-index" 913 | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 914 | dependencies = [ 915 | "libc", 916 | "signal-hook-registry", 917 | ] 918 | 919 | [[package]] 920 | name = "signal-hook-mio" 921 | version = "0.2.4" 922 | source = "registry+https://github.com/rust-lang/crates.io-index" 923 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 924 | dependencies = [ 925 | "libc", 926 | "mio", 927 | "signal-hook", 928 | ] 929 | 930 | [[package]] 931 | name = "signal-hook-registry" 932 | version = "1.4.6" 933 | source = "registry+https://github.com/rust-lang/crates.io-index" 934 | checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" 935 | dependencies = [ 936 | "libc", 937 | ] 938 | 939 | [[package]] 940 | name = "smallvec" 941 | version = "1.15.1" 942 | source = "registry+https://github.com/rust-lang/crates.io-index" 943 | checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 944 | 945 | [[package]] 946 | name = "static_assertions" 947 | version = "1.1.0" 948 | source = "registry+https://github.com/rust-lang/crates.io-index" 949 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 950 | 951 | [[package]] 952 | name = "strsim" 953 | version = "0.11.1" 954 | source = "registry+https://github.com/rust-lang/crates.io-index" 955 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 956 | 957 | [[package]] 958 | name = "strum" 959 | version = "0.26.3" 960 | source = "registry+https://github.com/rust-lang/crates.io-index" 961 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 962 | dependencies = [ 963 | "strum_macros", 964 | ] 965 | 966 | [[package]] 967 | name = "strum_macros" 968 | version = "0.26.4" 969 | source = "registry+https://github.com/rust-lang/crates.io-index" 970 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 971 | dependencies = [ 972 | "heck", 973 | "proc-macro2", 974 | "quote", 975 | "rustversion", 976 | "syn", 977 | ] 978 | 979 | [[package]] 980 | name = "syn" 981 | version = "2.0.106" 982 | source = "registry+https://github.com/rust-lang/crates.io-index" 983 | checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" 984 | dependencies = [ 985 | "proc-macro2", 986 | "quote", 987 | "unicode-ident", 988 | ] 989 | 990 | [[package]] 991 | name = "synonym" 992 | version = "0.1.6" 993 | source = "registry+https://github.com/rust-lang/crates.io-index" 994 | checksum = "5e5fd271874fac45215946c0364feab55cebe1e5524963d179398e0a388b7922" 995 | dependencies = [ 996 | "darling", 997 | "proc-macro2", 998 | "quote", 999 | "syn", 1000 | ] 1001 | 1002 | [[package]] 1003 | name = "terminal_size" 1004 | version = "0.4.3" 1005 | source = "registry+https://github.com/rust-lang/crates.io-index" 1006 | checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" 1007 | dependencies = [ 1008 | "rustix 1.0.8", 1009 | "windows-sys 0.60.2", 1010 | ] 1011 | 1012 | [[package]] 1013 | name = "thread_local" 1014 | version = "1.1.9" 1015 | source = "registry+https://github.com/rust-lang/crates.io-index" 1016 | checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 1017 | dependencies = [ 1018 | "cfg-if", 1019 | ] 1020 | 1021 | [[package]] 1022 | name = "tracing" 1023 | version = "0.1.41" 1024 | source = "registry+https://github.com/rust-lang/crates.io-index" 1025 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 1026 | dependencies = [ 1027 | "pin-project-lite", 1028 | "tracing-core", 1029 | ] 1030 | 1031 | [[package]] 1032 | name = "tracing-core" 1033 | version = "0.1.34" 1034 | source = "registry+https://github.com/rust-lang/crates.io-index" 1035 | checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 1036 | dependencies = [ 1037 | "once_cell", 1038 | "valuable", 1039 | ] 1040 | 1041 | [[package]] 1042 | name = "tracing-error" 1043 | version = "0.2.1" 1044 | source = "registry+https://github.com/rust-lang/crates.io-index" 1045 | checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" 1046 | dependencies = [ 1047 | "tracing", 1048 | "tracing-subscriber", 1049 | ] 1050 | 1051 | [[package]] 1052 | name = "tracing-subscriber" 1053 | version = "0.3.20" 1054 | source = "registry+https://github.com/rust-lang/crates.io-index" 1055 | checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 1056 | dependencies = [ 1057 | "sharded-slab", 1058 | "thread_local", 1059 | "tracing-core", 1060 | ] 1061 | 1062 | [[package]] 1063 | name = "unicase" 1064 | version = "2.8.1" 1065 | source = "registry+https://github.com/rust-lang/crates.io-index" 1066 | checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 1067 | 1068 | [[package]] 1069 | name = "unicode-ident" 1070 | version = "1.0.18" 1071 | source = "registry+https://github.com/rust-lang/crates.io-index" 1072 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 1073 | 1074 | [[package]] 1075 | name = "unicode-segmentation" 1076 | version = "1.12.0" 1077 | source = "registry+https://github.com/rust-lang/crates.io-index" 1078 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 1079 | 1080 | [[package]] 1081 | name = "unicode-truncate" 1082 | version = "1.1.0" 1083 | source = "registry+https://github.com/rust-lang/crates.io-index" 1084 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 1085 | dependencies = [ 1086 | "itertools", 1087 | "unicode-segmentation", 1088 | "unicode-width 0.1.14", 1089 | ] 1090 | 1091 | [[package]] 1092 | name = "unicode-width" 1093 | version = "0.1.14" 1094 | source = "registry+https://github.com/rust-lang/crates.io-index" 1095 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 1096 | 1097 | [[package]] 1098 | name = "unicode-width" 1099 | version = "0.2.0" 1100 | source = "registry+https://github.com/rust-lang/crates.io-index" 1101 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 1102 | 1103 | [[package]] 1104 | name = "utf8parse" 1105 | version = "0.2.2" 1106 | source = "registry+https://github.com/rust-lang/crates.io-index" 1107 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1108 | 1109 | [[package]] 1110 | name = "valuable" 1111 | version = "0.1.1" 1112 | source = "registry+https://github.com/rust-lang/crates.io-index" 1113 | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 1114 | 1115 | [[package]] 1116 | name = "wasi" 1117 | version = "0.11.1+wasi-snapshot-preview1" 1118 | source = "registry+https://github.com/rust-lang/crates.io-index" 1119 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 1120 | 1121 | [[package]] 1122 | name = "wasi" 1123 | version = "0.14.3+wasi-0.2.4" 1124 | source = "registry+https://github.com/rust-lang/crates.io-index" 1125 | checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" 1126 | dependencies = [ 1127 | "wit-bindgen", 1128 | ] 1129 | 1130 | [[package]] 1131 | name = "winapi" 1132 | version = "0.3.9" 1133 | source = "registry+https://github.com/rust-lang/crates.io-index" 1134 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1135 | dependencies = [ 1136 | "winapi-i686-pc-windows-gnu", 1137 | "winapi-x86_64-pc-windows-gnu", 1138 | ] 1139 | 1140 | [[package]] 1141 | name = "winapi-i686-pc-windows-gnu" 1142 | version = "0.4.0" 1143 | source = "registry+https://github.com/rust-lang/crates.io-index" 1144 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1145 | 1146 | [[package]] 1147 | name = "winapi-x86_64-pc-windows-gnu" 1148 | version = "0.4.0" 1149 | source = "registry+https://github.com/rust-lang/crates.io-index" 1150 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1151 | 1152 | [[package]] 1153 | name = "windows-link" 1154 | version = "0.1.3" 1155 | source = "registry+https://github.com/rust-lang/crates.io-index" 1156 | checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 1157 | 1158 | [[package]] 1159 | name = "windows-sys" 1160 | version = "0.59.0" 1161 | source = "registry+https://github.com/rust-lang/crates.io-index" 1162 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1163 | dependencies = [ 1164 | "windows-targets 0.52.6", 1165 | ] 1166 | 1167 | [[package]] 1168 | name = "windows-sys" 1169 | version = "0.60.2" 1170 | source = "registry+https://github.com/rust-lang/crates.io-index" 1171 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 1172 | dependencies = [ 1173 | "windows-targets 0.53.3", 1174 | ] 1175 | 1176 | [[package]] 1177 | name = "windows-targets" 1178 | version = "0.52.6" 1179 | source = "registry+https://github.com/rust-lang/crates.io-index" 1180 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1181 | dependencies = [ 1182 | "windows_aarch64_gnullvm 0.52.6", 1183 | "windows_aarch64_msvc 0.52.6", 1184 | "windows_i686_gnu 0.52.6", 1185 | "windows_i686_gnullvm 0.52.6", 1186 | "windows_i686_msvc 0.52.6", 1187 | "windows_x86_64_gnu 0.52.6", 1188 | "windows_x86_64_gnullvm 0.52.6", 1189 | "windows_x86_64_msvc 0.52.6", 1190 | ] 1191 | 1192 | [[package]] 1193 | name = "windows-targets" 1194 | version = "0.53.3" 1195 | source = "registry+https://github.com/rust-lang/crates.io-index" 1196 | checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" 1197 | dependencies = [ 1198 | "windows-link", 1199 | "windows_aarch64_gnullvm 0.53.0", 1200 | "windows_aarch64_msvc 0.53.0", 1201 | "windows_i686_gnu 0.53.0", 1202 | "windows_i686_gnullvm 0.53.0", 1203 | "windows_i686_msvc 0.53.0", 1204 | "windows_x86_64_gnu 0.53.0", 1205 | "windows_x86_64_gnullvm 0.53.0", 1206 | "windows_x86_64_msvc 0.53.0", 1207 | ] 1208 | 1209 | [[package]] 1210 | name = "windows_aarch64_gnullvm" 1211 | version = "0.52.6" 1212 | source = "registry+https://github.com/rust-lang/crates.io-index" 1213 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1214 | 1215 | [[package]] 1216 | name = "windows_aarch64_gnullvm" 1217 | version = "0.53.0" 1218 | source = "registry+https://github.com/rust-lang/crates.io-index" 1219 | checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 1220 | 1221 | [[package]] 1222 | name = "windows_aarch64_msvc" 1223 | version = "0.52.6" 1224 | source = "registry+https://github.com/rust-lang/crates.io-index" 1225 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1226 | 1227 | [[package]] 1228 | name = "windows_aarch64_msvc" 1229 | version = "0.53.0" 1230 | source = "registry+https://github.com/rust-lang/crates.io-index" 1231 | checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 1232 | 1233 | [[package]] 1234 | name = "windows_i686_gnu" 1235 | version = "0.52.6" 1236 | source = "registry+https://github.com/rust-lang/crates.io-index" 1237 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1238 | 1239 | [[package]] 1240 | name = "windows_i686_gnu" 1241 | version = "0.53.0" 1242 | source = "registry+https://github.com/rust-lang/crates.io-index" 1243 | checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 1244 | 1245 | [[package]] 1246 | name = "windows_i686_gnullvm" 1247 | version = "0.52.6" 1248 | source = "registry+https://github.com/rust-lang/crates.io-index" 1249 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1250 | 1251 | [[package]] 1252 | name = "windows_i686_gnullvm" 1253 | version = "0.53.0" 1254 | source = "registry+https://github.com/rust-lang/crates.io-index" 1255 | checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 1256 | 1257 | [[package]] 1258 | name = "windows_i686_msvc" 1259 | version = "0.52.6" 1260 | source = "registry+https://github.com/rust-lang/crates.io-index" 1261 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1262 | 1263 | [[package]] 1264 | name = "windows_i686_msvc" 1265 | version = "0.53.0" 1266 | source = "registry+https://github.com/rust-lang/crates.io-index" 1267 | checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 1268 | 1269 | [[package]] 1270 | name = "windows_x86_64_gnu" 1271 | version = "0.52.6" 1272 | source = "registry+https://github.com/rust-lang/crates.io-index" 1273 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1274 | 1275 | [[package]] 1276 | name = "windows_x86_64_gnu" 1277 | version = "0.53.0" 1278 | source = "registry+https://github.com/rust-lang/crates.io-index" 1279 | checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 1280 | 1281 | [[package]] 1282 | name = "windows_x86_64_gnullvm" 1283 | version = "0.52.6" 1284 | source = "registry+https://github.com/rust-lang/crates.io-index" 1285 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1286 | 1287 | [[package]] 1288 | name = "windows_x86_64_gnullvm" 1289 | version = "0.53.0" 1290 | source = "registry+https://github.com/rust-lang/crates.io-index" 1291 | checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 1292 | 1293 | [[package]] 1294 | name = "windows_x86_64_msvc" 1295 | version = "0.52.6" 1296 | source = "registry+https://github.com/rust-lang/crates.io-index" 1297 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1298 | 1299 | [[package]] 1300 | name = "windows_x86_64_msvc" 1301 | version = "0.53.0" 1302 | source = "registry+https://github.com/rust-lang/crates.io-index" 1303 | checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 1304 | 1305 | [[package]] 1306 | name = "wit-bindgen" 1307 | version = "0.45.0" 1308 | source = "registry+https://github.com/rust-lang/crates.io-index" 1309 | checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" 1310 | 1311 | [[package]] 1312 | name = "zerocopy" 1313 | version = "0.8.26" 1314 | source = "registry+https://github.com/rust-lang/crates.io-index" 1315 | checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" 1316 | dependencies = [ 1317 | "zerocopy-derive", 1318 | ] 1319 | 1320 | [[package]] 1321 | name = "zerocopy-derive" 1322 | version = "0.8.26" 1323 | source = "registry+https://github.com/rust-lang/crates.io-index" 1324 | checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" 1325 | dependencies = [ 1326 | "proc-macro2", 1327 | "quote", 1328 | "syn", 1329 | ] 1330 | --------------------------------------------------------------------------------