diff --git a/src/event_handler.rs b/src/event_handler.rs new file mode 100644 index 0000000..8251457 --- /dev/null +++ b/src/event_handler.rs @@ -0,0 +1,40 @@ +use std::error::Error; + +use crossterm::event::{self, KeyCode}; + +use crate::{ + state::{ApplicationState, Task}, + utility::Op, +}; + +/// Handle all key events +pub fn event_key_handler(app: &mut ApplicationState) -> Result<(), Box> { + if event::poll(std::time::Duration::from_millis(100))? { + if let event::Event::Key(key_event) = event::read()? { + match key_event.code { + // Escape to exit + KeyCode::Esc => app.should_quit = true, + KeyCode::Char('a') => app.insert_task(app.selected_col, Task::new_test_task()), + KeyCode::Char('h') => app.update_selected_column(&Op::Decrement), + KeyCode::Char('l') => app.update_selected_column(&Op::Increment), + KeyCode::Char('k') => app.update_selected_item(&Op::Decrement), + KeyCode::Char('j') => app.update_selected_item(&Op::Increment), + KeyCode::Char('H') => { + app.move_task(app.selected_col, app.selected_item, &Op::Decrement, false) + } + KeyCode::Char('L') => { + app.move_task(app.selected_col, app.selected_item, &Op::Increment, false) + } + KeyCode::Char('K') => { + app.move_task(app.selected_col, app.selected_item, &Op::Decrement, true) + } + KeyCode::Char('J') => { + app.move_task(app.selected_col, app.selected_item, &Op::Increment, true) + } + // Resize the window (event handling is automatic with ratatui) + _ => {} + } + } + }; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..34faea1 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,36 @@ +pub mod event_handler; +pub mod state; +#[cfg(test)] +pub mod test; +pub mod tui; +pub mod utility; +pub mod widgets; + +use crossterm::{cursor, event, event::KeyCode, terminal, ExecutableCommand}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + widgets::{Block, Borders, List, ListItem}, + Terminal, +}; +use state::ApplicationState; +use std::{env, error::Error, io}; +use tui::run; + +fn main() -> Result<(), Box> { + // Get the number of columns from command-line arguments + let args: Vec = env::args().collect(); + let num_columns: usize = if args.len() > 1 { + args[1].parse()? + } else { + // Default to 3 columns if no argument is passed + 3 + }; + + // Initialize application state + let mut app_state = ApplicationState::new(num_columns); + run(&mut app_state)?; + // Clean up terminal state + terminal::disable_raw_mode()?; + Ok(()) +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..6685cff --- /dev/null +++ b/src/state.rs @@ -0,0 +1,161 @@ +use crate::utility::Op; + +#[derive(Debug)] +pub struct ApplicationState { + pub columns: usize, + col_idx: Vec, + pub tasks: Vec, + pub new_task_popup: bool, + pub should_quit: bool, + pub selected_col: usize, + pub selected_item: usize, +} + +#[derive(Debug, Clone)] +pub struct Task { + pub title: String, + pub notes: String, + pub tags: Vec, +} + +impl Task { + pub fn new_test_task() -> Self { + Task { + title: "This is a test".to_string(), + notes: "".to_string(), + tags: vec![], + } + } +} + +impl ApplicationState { + pub fn new(columns: usize) -> Self { + ApplicationState { + columns, + col_idx: (0..columns + 1).map(|_| 0).collect(), + tasks: Vec::new(), + new_task_popup: false, + should_quit: false, + selected_col: 0, + selected_item: 0, + } + } + + /// Get a slice containing all of the tasks associated with that column + /// and the index of the highlighted column in that + pub fn get_col_slice(&self, col: usize) -> Vec<(&Task, bool)> { + (self.col_idx[col]..self.col_idx[col + 1]) + .map(|idx| (&self.tasks[idx], idx == self.selected_item)) + .collect() + } + + /// Get number of items in a column + pub fn col_n_items(&self, i: usize) -> usize { + self.col_idx[i + 1] - self.col_idx[i] + } + + /// Determine whether a column is empty + pub fn col_is_empty(&self, col: usize) -> bool { + self.col_idx[col] == self.col_idx[col + 1] + } + + /// Placeholder testing function to insert a new task in a column + pub fn insert_task(&mut self, col: usize, task: Task) { + // Compute the index of the new item + let idx = self.col_idx[col + 1]; + + // Update the other columns + self.col_idx[col + 1..] + .iter_mut() + .for_each(|idx: &mut usize| *idx += 1); + + // Now insert the new task into the vector + self.tasks.insert(idx, task); + self.selected_item = idx; + } + + pub fn remove_task(&mut self, col: usize, item: usize) { + self.tasks.remove(item); + for start_idx in self.col_idx[col + 1..].iter_mut() { + *start_idx -= 1; + } + } + + /// Update the selected column + pub fn update_selected_column(&mut self, op: &Op) { + self.selected_col = op.apply(self.selected_col).min(self.columns - 1); + self.selected_item = self.col_idx[self.selected_col]; + } + + /// Update the selected column + pub fn update_selected_item(&mut self, op: &Op) { + if self.col_n_items(self.selected_col) > 0 { + self.selected_item = op + .apply(self.selected_item) + .max(self.col_idx[self.selected_col]) + .min(self.col_idx[self.selected_col + 1] - 1) + }; + } + + /// Move a task either between columns or within a column + pub fn move_task(&mut self, col: usize, item: usize, op: &Op, in_col: bool) { + if in_col { + let new_idx = op + .apply(item) + .max(self.col_idx[col]) + .min(self.col_idx[col + 1] - 1); + self.tasks.swap(item, new_idx); + self.selected_item = new_idx; + } else { + match op { + Op::Decrement => { + if self.selected_col > 0 { + let task_copy = self.tasks[item].clone(); + self.remove_task(col, item); + self.insert_task(col - 1, task_copy); + self.selected_col -= 1; + } + } + Op::Increment => { + if self.selected_col < self.columns - 1 { + let task_copy = self.tasks[item].clone(); + self.remove_task(col, item); + self.insert_task(col + 1, task_copy); + self.selected_col += 1; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + pub fn test_highlighted_item() { + let mut app = ApplicationState::new(3); + app.insert_task(app.selected_col, Task::new_test_task()); + app.insert_task(app.selected_col, Task::new_test_task()); + app.insert_task(app.selected_col, Task::new_test_task()); + app.update_selected_column(&Op::Increment); + app.insert_task(app.selected_col, Task::new_test_task()); + assert_eq!(app.selected_item, 3); + + let sol = app.get_col_slice(1); + assert!(sol[0].1) + } + + #[test] + pub fn test_move_task() { + let mut app = ApplicationState::new(3); + app.insert_task(app.selected_col, Task::new_test_task()); + app.insert_task(app.selected_col, Task::new_test_task()); + app.insert_task(app.selected_col, Task::new_test_task()); + app.move_task(app.selected_col, app.selected_item, &Op::Decrement, true); + assert_eq!(app.selected_item, 1); + app.move_task(app.selected_col, app.selected_item, &Op::Increment, true); + assert_eq!(app.selected_item, 2); + } +} diff --git a/src/test.rs b/src/test.rs new file mode 100644 index 0000000..7181fc3 --- /dev/null +++ b/src/test.rs @@ -0,0 +1 @@ +use crate::{state::ApplicationState, utility::Op}; diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..9db469c --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,88 @@ +use std::{error::Error, io, iter::zip}; + +use crossterm::{terminal, ExecutableCommand}; +use ratatui::{ + layout::{Constraint, Direction, Layout}, + prelude::CrosstermBackend, + style::{Color, Style}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + Terminal, +}; + +use crate::{ + event_handler::event_key_handler, + state::ApplicationState, + widgets::blocks::{basic_block, highlighted_border_block, highlighted_item_block}, +}; + +pub fn run(app_state: &mut ApplicationState) -> Result<(), Box> { + // Set up terminal + let mut stdout = io::stdout(); + terminal::enable_raw_mode()?; + stdout.execute(terminal::Clear(terminal::ClearType::All))?; + stdout.execute(terminal::EnterAlternateScreen)?; + + // Initialize the terminal with the Crossterm backend + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Start the main loop for rendering + loop { + terminal.draw(|f| { + // Define the layout with the specified number of columns + let size = f.area(); + let constraints: Vec = + vec![Constraint::Percentage(100 / app_state.columns as u16); app_state.columns] + .into_iter() + .collect(); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints::<&Vec>(constraints.as_ref()) + .split(size); + + // Render a item blocks in each column + for (i, chunk) in chunks.iter().enumerate() { + let block = if i == app_state.selected_col { + highlighted_border_block(i) + } else { + basic_block(Some(i)) + }; + f.render_widget(block, *chunk); + + let nitems = app_state.col_n_items(i); + if nitems == 0 { + continue; + } + let constraints: Vec = + vec![Constraint::Max(5); nitems].into_iter().collect(); + let sub_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints::<&Vec>(constraints.as_ref()) + .margin(1) + .split(*chunk); + for ((task, highlight), sub_chunk) in + zip(app_state.get_col_slice(i), sub_chunks.iter()) + { + let style = if highlight { + Style::default().bg(Color::Blue).fg(Color::DarkGray) + } else { + Style::default() + }; + let paragraph = Paragraph::new(task.title.clone()) + .block(basic_block(None)) + .style(style); + f.render_widget(paragraph, *sub_chunk); + } + } + })?; + + // Handle events such as key presses + event_key_handler(app_state)?; + if app_state.should_quit { + break; + }; + } + + Ok(()) +} diff --git a/src/utility.rs b/src/utility.rs new file mode 100644 index 0000000..e68db0e --- /dev/null +++ b/src/utility.rs @@ -0,0 +1,14 @@ +/// Enum used to determine whether we increment or decrement a value +pub enum Op { + Decrement, + Increment, +} + +impl Op { + pub fn apply(&self, val: usize) -> usize { + match &self { + Op::Decrement => val.saturating_sub(1), + Op::Increment => val + 1, + } + } +} diff --git a/src/widgets/blocks.rs b/src/widgets/blocks.rs new file mode 100644 index 0000000..3623373 --- /dev/null +++ b/src/widgets/blocks.rs @@ -0,0 +1,29 @@ +use ratatui::{ + style::{Color, Style}, + widgets::{Block, Borders}, +}; + +/// Return the basic block which uses normal foreground coloring +pub fn basic_block(i: Option) -> Block<'static> { + let block = Block::new().borders(Borders::ALL); + + if let Some(i) = i { + block.title(format!("Column {}", i + 1)) + } else { + block + } +} + +/// Return the block used for highlighted borders +pub fn highlighted_border_block(i: usize) -> Block<'static> { + Block::new() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Blue)) + .title(format!("Column {}", i + 1)) +} + +pub fn highlighted_item_block() -> Block<'static> { + Block::new() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Blue).bg(Color::Blue)) +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..049a8aa --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1 @@ +pub mod blocks;