parent
c6fda36b1c
commit
21ba0d9882
@ -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<dyn Error>> {
|
||||
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(())
|
||||
}
|
@ -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<dyn Error>> {
|
||||
// Get the number of columns from command-line arguments
|
||||
let args: Vec<String> = 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(())
|
||||
}
|
@ -0,0 +1,161 @@
|
||||
use crate::utility::Op;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApplicationState {
|
||||
pub columns: usize,
|
||||
col_idx: Vec<usize>,
|
||||
pub tasks: Vec<Task>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
use crate::{state::ApplicationState, utility::Op};
|
@ -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<dyn Error>> {
|
||||
// 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<Constraint> =
|
||||
vec![Constraint::Percentage(100 / app_state.columns as u16); app_state.columns]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints::<&Vec<Constraint>>(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<Constraint> =
|
||||
vec![Constraint::Max(5); nitems].into_iter().collect();
|
||||
let sub_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints::<&Vec<Constraint>>(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(())
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
@ -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<usize>) -> 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))
|
||||
}
|
@ -0,0 +1 @@
|
||||
pub mod blocks;
|
Loading…
Reference in new issue