Compare commits

...

10 Commits

8 changed files with 333 additions and 194 deletions

68
Cargo.lock generated
View File

@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "aho-corasick"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.66"
@ -26,6 +35,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "cc"
version = "1.0.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d"
[[package]]
name = "cfg-if"
version = "1.0.0"
@ -57,23 +72,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "indexmap"
version = "1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399"
dependencies = [
"autocfg",
"hashbrown",
"serde",
]
[[package]]
name = "int-enum"
version = "0.5.0"
@ -106,12 +104,13 @@ name = "kanban-tui"
version = "0.1.0"
dependencies = [
"anyhow",
"cc",
"crossterm",
"indexmap",
"int-enum",
"serde",
"serde_json",
"thiserror",
"tree-sitter",
"tui",
]
@ -140,6 +139,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "memchr"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "mio"
version = "0.8.5"
@ -219,6 +224,23 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.6.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848"
[[package]]
name = "ryu"
version = "1.0.11"
@ -338,6 +360,16 @@ dependencies = [
"serde",
]
[[package]]
name = "tree-sitter"
version = "0.20.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4423c784fe11398ca91e505cdc71356b07b1a924fc8735cfab5333afe3e18bc"
dependencies = [
"cc",
"regex",
]
[[package]]
name = "tui"
version = "0.19.0"

View File

@ -10,9 +10,10 @@ tui = "0.19.0"
crossterm = "0.25"
serde = { version = "1.0.148" , features = [ "derive" ] }
serde_json = "1.0.89"
indexmap = { version = "1.9.2" , features = [ "serde" ] }
int-enum = "0.5.0"
#error handling
thiserror = "1"
anyhow = "1"
anyhow = "1"
tree-sitter = "0.20.9"
[build-dependencies]
cc="*"

View File

@ -1,18 +1,16 @@
use std::cmp::min;
use indexmap::IndexMap;
use int_enum::IntEnum;
// use indexmap::IndexMap;
// use int_enum::IntEnum;
use serde::{Deserialize, Serialize};
use std::cmp::min;
#[cfg(test)]
mod tests;
#[repr(usize)]
#[derive(Deserialize, Serialize, Copy, Clone, Debug, PartialEq, PartialOrd, Eq, Ord, Hash, IntEnum)]
pub enum TaskStatus {
Todo = 0,
InProgress = 1,
Done = 2,
Ideas = 3,
#[derive(Debug, Serialize, Deserialize)]
pub struct Column {
pub name: String,
pub selected_task_idx: usize,
pub tasks: Vec<Task>,
}
// #[derive(Deserialize, Serialize, Debug, Clone, Copy)]
@ -35,7 +33,8 @@ impl Default for Task {
#[derive(Deserialize, Serialize, Debug)]
pub struct Project {
pub name: String,
pub tasks_per_column: IndexMap<TaskStatus, Vec<Task>>,
pub selected_column_idx: usize,
pub columns: Vec<Column>
}
#[derive(Debug, thiserror::Error)]
@ -46,16 +45,74 @@ pub enum KanbanError {
Io(#[from] std::io::Error),
}
impl Default for Project {
fn default() -> Self {
Project {
name: String::new(),
columns: vec![],
selected_column_idx: 0,
}
}
}
pub struct AppState {
pub project: Project,
pub quit: bool,
pub columns: Vec<Column>,
pub popup_text: Option<String>,
}
impl AppState {
pub fn new(project: Project) -> Self {
AppState {
quit: false,
popup_text: None,
project,
columns: vec![],
}
}
}
impl Column {
pub fn new(name: &str) -> Self {
Column {
name: name.to_owned(),
tasks: vec![],
selected_task_idx: 0,
}
}
pub fn get_selected_task(&self) -> Option<&Task> {
self.tasks.get(self.selected_task_idx)
}
pub fn get_selected_task_mut(&mut self) -> Option<&mut Task> {
self.tasks.get_mut(self.selected_task_idx)
}
pub fn select_previous_task(&mut self) {
let task_idx = &mut self.selected_task_idx;
*task_idx = task_idx.saturating_sub(1)
}
pub fn select_next_task(&mut self) {
let task_idx = &mut self.selected_task_idx;
*task_idx = min(*task_idx + 1, self.tasks.len() - 1)
}
pub fn select_last_task(&mut self) {
self.selected_task_idx = self.tasks.len() - 1;
}
}
impl Project {
pub fn new(name: &str) -> Self {
Project {
name: name.to_owned(),
tasks_per_column: IndexMap::from(
[(TaskStatus::Done, vec![]),
(TaskStatus::Todo, vec![]),
(TaskStatus::InProgress, vec![]),
(TaskStatus::Ideas, vec![])],
),
columns: vec![],
selected_column_idx: 0,
}
}
@ -68,131 +125,83 @@ impl Project {
Self::load_from_json(&json)
}
pub fn add_task(&mut self, status: TaskStatus, task: Task) {
self.tasks_per_column.entry(status).or_default().push(task);
}
// pub fn add_task(&mut self, status: Column, task: Task) {
// self.tasks_per_column.entry(status).or_default().push(task);
// }
pub fn save(&self) {
let json = serde_json::to_string_pretty(&self).unwrap();
std::fs::write("kanban-tui.json", json).unwrap();
}
}
impl Default for Project {
fn default() -> Self {
Project {
name: String::new(),
tasks_per_column: IndexMap::new(),
pub fn get_selected_column(&self) -> &Column {
&self.columns[self.selected_column_idx]
}
pub fn get_selected_column_mut(&mut self) -> &mut Column {
&mut self.columns[self.selected_column_idx]
}
pub fn select_previous_column(&mut self) -> &Column {
self.selected_column_idx = self.selected_column_idx.saturating_sub(1);
&self.columns[self.selected_column_idx]
}
pub fn select_next_column(&mut self) -> &Column {
self.selected_column_idx = min(
self.selected_column_idx + 1,
self.columns.len() - 1,
);
&self.columns[self.selected_column_idx]
}
fn move_task_to_column(&mut self, move_next: bool) {
let col_idx = self.selected_column_idx;
let cols_len = self.columns.len();
let column = self.get_selected_column_mut();
let cond = if move_next {
col_idx < cols_len - 1
} else {
col_idx > 0
};
if cond && column.tasks.len() > 0 {
let t = column.tasks.remove(column.selected_task_idx);
column.select_previous_task();
if move_next {
self.select_next_column();
} else {
self.select_previous_column();
}
let col = self.get_selected_column_mut();
col.tasks.push(t);
col.select_last_task();
self.save();
}
}
}
pub struct AppState {
pub selected_column: usize,
pub selected_task: [usize; 4],
pub project: Project,
pub quit: bool,
}
impl AppState {
pub fn new(project: Project) -> Self {
AppState {
selected_column: 0,
selected_task: [0, 0, 0, 0],
quit: false,
project,
}
}
fn selected_task_idx(&self) -> usize {
self.selected_task[self.selected_column]
}
fn selected_task_idx_mut(&mut self) -> &mut usize {
&mut self.selected_task[self.selected_column]
}
pub fn get_tasks_in_active_column(&self) -> &[Task] {
let column: TaskStatus = TaskStatus::from_int(self.selected_column).unwrap().clone();
self.project.tasks_per_column.get(&column).unwrap()
}
pub fn get_tasks_in_active_column_mut(&mut self) -> &mut Vec<Task> {
let column: TaskStatus = TaskStatus::from_int(self.selected_column).unwrap().clone();
self.project.tasks_per_column.get_mut(&column).unwrap()
}
pub fn get_selected_task(&self) -> Option<&Task> {
let tasks = self.get_tasks_in_active_column();
tasks.get(self.selected_task_idx())
}
pub fn select_previous_task(&mut self) {
*self.selected_task_idx_mut() = self.selected_task_idx().saturating_sub(1)
}
pub fn select_next_task(&mut self) {
let tasks = self.get_tasks_in_active_column();
if tasks.len() > 0 {
let mins = min(self.selected_task_idx() + 1, tasks.len() - 1);
*self.selected_task_idx_mut() = mins;
}
}
pub fn select_previous_column(&mut self) {
self.selected_column = self.selected_column.saturating_sub(1);
}
pub fn select_next_column(&mut self) {
self.selected_column = min(self.selected_column + 1, self.project.tasks_per_column.len() - 1)
}
pub fn move_task_previous_column(&mut self) {
let tasks = self.get_tasks_in_active_column();
let task_idx = self.selected_task_idx();
if self.selected_column > 0 && tasks.len() > 0 && task_idx.clone() < tasks.len() {
let task = self.get_tasks_in_active_column_mut().remove(task_idx);
*self.selected_task_idx_mut() = self.selected_task_idx().saturating_sub(1);
self.select_previous_column();
let target_tasks = self.get_tasks_in_active_column_mut();
target_tasks.push(task);
*self.selected_task_idx_mut() = target_tasks.len() - 1;
self.project.save();
}
self.move_task_to_column(false)
}
pub fn move_task_next_column(&mut self) {
let tasks = self.get_tasks_in_active_column();
let task_idx = self.selected_task_idx();
if self.selected_column < self.project.tasks_per_column.len() && tasks.len() > 0 && task_idx < tasks.len() {
let task = self.get_tasks_in_active_column_mut().remove(task_idx);
*self.selected_task_idx_mut() = self.selected_task_idx().saturating_sub(1);
self.select_next_column();
let target_tasks = self.get_tasks_in_active_column_mut();
target_tasks.push(task);
*self.selected_task_idx_mut() = target_tasks.len() - 1;
self.project.save();
}
self.move_task_to_column(true)
}
pub fn move_task_up(&mut self) {
let task_idx = self.selected_task_idx();
if task_idx > 0 {
let tasks = self.get_tasks_in_active_column_mut();
tasks.swap(task_idx, task_idx - 1);
*self.selected_task_idx_mut() = task_idx - 1;
self.project.save();
let column = self.get_selected_column_mut();
if column.selected_task_idx > 0 {
column.tasks.swap(column.selected_task_idx, column.selected_task_idx - 1);
column.selected_task_idx = column.selected_task_idx - 1;
self.save();
}
}
pub fn move_task_down(&mut self) {
let task_idx = self.selected_task_idx();
let tasks = self.get_tasks_in_active_column_mut();
if task_idx < tasks.len() - 1 {
tasks.swap(task_idx, task_idx + 1);
*self.selected_task_idx_mut() = task_idx + 1;
self.project.save();
let column = self.get_selected_column_mut();
if column.selected_task_idx < column.tasks.len() - 1 {
column.tasks.swap(column.selected_task_idx, column.selected_task_idx + 1);
column.selected_task_idx = column.selected_task_idx + 1;
self.save();
}
}
}

View File

@ -3,25 +3,33 @@ use crossterm::event::{Event, KeyCode};
use crate::app::{AppState};
pub fn handle_input(state: &mut AppState) -> Result<(), std::io::Error> {
let project = &mut state.project;
let column = project.get_selected_column_mut();
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => state.quit = true,
KeyCode::Char('h') |
KeyCode::Left => state.select_previous_column(),
KeyCode::Left => { project.select_previous_column(); },
KeyCode::Char('j') |
KeyCode::Down => state.select_next_task(),
KeyCode::Down => column.select_next_task(),
KeyCode::Char('k') |
KeyCode::Up => state.select_previous_task(),
KeyCode::Up => column.select_previous_task(),
KeyCode::Char('l') |
KeyCode::Right => state.select_next_column(),
KeyCode::Right => { project.select_next_column(); },
KeyCode::Char('<') |
KeyCode::Char('H') => state.move_task_previous_column(),
KeyCode::Char('H') => project.move_task_previous_column(),
KeyCode::Char('>') |
KeyCode::Char('L') => state.move_task_next_column(),
KeyCode::Char('L') => project.move_task_next_column(),
KeyCode::Char('=') |
KeyCode::Char('J') => state.move_task_down(),
KeyCode::Char('J') => project.move_task_down(),
KeyCode::Char('-') |
KeyCode::Char('K') => state.move_task_up(),
KeyCode::Char('K') => project.move_task_up(),
KeyCode::Char('p') => {
match state.popup_text {
None => state.popup_text = Some("".to_string()),
Some(_) => state.popup_text = None,
}
}
_ => {}
}
}

View File

@ -1,7 +1,9 @@
mod app;
mod ui;
mod input;
mod treesitter;
pub use app::*;
pub use ui::draw;
pub use input::handle_input;
pub use treesitter::compile_md_grammar;

View File

@ -1,8 +1,11 @@
#![allow(dead_code)]
use kanban_tui::{AppState, Project};
use kanban_tui::*;
use std::{io};
use crossterm::{event::*, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}};
use crossterm::{
event::*,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::io;
use tui::backend::CrosstermBackend;
use tui::Terminal;
@ -20,6 +23,8 @@ fn main() -> anyhow::Result<()> {
kanban_tui::handle_input(&mut state)?;
}
state.project.save();
// restore terminal
disable_raw_mode()?;
crossterm::execute!(

6
src/treesitter.rs Normal file
View File

@ -0,0 +1,6 @@
use tree_sitter::{Parser, Language};
use std::path::PathBuf;
pub fn compile_md_grammar() {
}

154
src/ui.rs
View File

@ -1,59 +1,133 @@
use tui::backend::{Backend};
use crate::app::*;
use tui::backend::Backend;
use tui::layout::*;
use tui::{Frame};
use tui::style::{Color, Modifier, Style};
use tui::text::{Span, Spans};
use tui::widgets::*;
use crate::app::*;
use tui::Frame;
fn draw_tasks<B: Backend>(f: &mut Frame<B>, area: &Rect, state: &AppState) {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints(
vec![Constraint::Percentage(100 / state.project.tasks_per_column.len() as u16);
state.project.tasks_per_column.len()].as_ref()
vec![
Constraint::Percentage(100 / state.project.columns.len() as u16);
state.project.columns.len()
]
.as_ref(),
)
.split(*area);
for (i, (status, tasks)) in state.project.tasks_per_column.iter().enumerate() {
let items: Vec<ListItem> = tasks.iter().enumerate().map(|(j, task)| {
let mut style = Style::default();
if i == state.selected_column && j == state.selected_task[state.selected_column] {
style = style.fg(Color::White).add_modifier(Modifier::BOLD);
} else {
style = style.fg(Color::White);
}
let mut s = Span::raw(task.title.as_str());
s.style = style;
ListItem::new(vec![Spans::from(s)])
}).collect();
for (i, column) in state.project.columns.iter().enumerate() {
let items: Vec<ListItem> = column.tasks
.iter()
.enumerate()
.map(|(j, task)| {
let mut style = Style::default();
let col_idx = state.project.selected_column_idx;
let task_idx = state.project.get_selected_column().selected_task_idx;
if i == col_idx && j == task_idx {
style = style.fg(Color::White).add_modifier(Modifier::BOLD);
} else {
style = style.fg(Color::White);
}
let mut s = Span::raw(task.title.as_str());
s.style = style;
ListItem::new(vec![Spans::from(s)])
})
.collect();
let mut style = Style::default();
if i == state.selected_column { style = style.fg(Color::Green); };
let mut s = Span::raw(format!("{:?}", status));
if i == state.project.selected_column_idx {
style = style.fg(Color::Green);
};
let mut s = Span::raw(format!("{:?}", column.name));
s.style = Style::default()
.add_modifier(Modifier::BOLD | Modifier::ITALIC | Modifier::UNDERLINED)
.fg(Color::White);
let block = Block::default()
.style(style)
.title(s)
.borders(Borders::ALL);
let block = Block::default().style(style).title(s).borders(Borders::ALL);
let list = List::new(items).block(block);
f.render_widget(list, columns[i])
}
}
fn draw_task_info<B: Backend>(f: &mut Frame<B>, area: &Rect, state: &AppState) {
let block = Block::default()
.title("TASK INFO")
.borders(Borders::ALL);
if let Some(task) = state.get_selected_task() {
let p = Paragraph::new(task.description.as_str()).block(block).wrap(Wrap { trim: true });
let block = Block::default().title("TASK INFO").borders(Borders::ALL);
if let Some(task) = state.project.get_selected_column().get_selected_task() {
let p = Paragraph::new(task.description.as_str())
.block(block)
.wrap(Wrap { trim: true });
f.render_widget(p, *area);
} else {
let p = Paragraph::new("No tasks for this column").block(block);
f.render_widget(p, *area);
}
}
fn centered_rect_for_popup(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
]
.as_ref(),
)
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
]
.as_ref(),
)
.split(popup_layout[1])[1]
}
pub fn draw_new_task_popup<B: Backend>(f: &mut Frame<B>, state: &mut AppState) {
let area = centered_rect_for_popup(45, 60, f.size());
f.render_widget(Clear, area);
match &state.popup_text {
None => {}
Some(s) => {
let block = Block::default()
.title("Add Task")
.title_alignment(Alignment::Center)
.borders(Borders::ALL);
let block_inner = block.inner(area);
let main = Paragraph::new(s.as_ref()).block(block);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3),
Constraint::Max(100),
Constraint::Length(2),
]
.as_ref(),
)
.split(block_inner);
let b1 = Block::default().title("Title").borders(Borders::ALL);
let title = Paragraph::new("Hello I am text")
// .style(Style::default().fg(Color::Yellow))
.block(b1);
let b2 = Block::default().title("Description").borders(Borders::ALL);
let description = Paragraph::new("Fill this out")
// .style(Style::default().fg(Color::Yellow))
.block(b2);
let b3 = Block::default().title("Keys").borders(Borders::TOP);
let footer = Paragraph::new("p : Cancel").block(b3);
f.render_widget(main, area);
f.render_widget(title, layout[0]);
f.render_widget(description, layout[1]);
f.render_widget(footer, layout[2]);
}
}
}
pub fn draw<B: Backend>(f: &mut Frame<B>, state: &mut AppState) {
let main_layout = Layout::default()
@ -61,27 +135,29 @@ pub fn draw<B: Backend>(f: &mut Frame<B>, state: &mut AppState) {
.constraints(
[
Constraint::Percentage(10),
Constraint::Percentage(60),
Constraint::Percentage(65),
Constraint::Percentage(20),
Constraint::Percentage(10),
].as_ref()
).split(f.size());
Constraint::Length(3),
]
.as_ref(),
)
.split(f.size());
let block = Block::default()
.title("KANBAN BOARD")
.borders(Borders::ALL);
let block = Block::default().title("KANBAN BOARD").borders(Borders::ALL);
f.render_widget(block, main_layout[0]);
draw_tasks(f, &main_layout[1], &state);
draw_task_info(f, &main_layout[2], &state);
let block = Block::default()
.title("KEYBINDINGS")
.borders(Borders::ALL);
let block = Block::default().title("KEYBINDINGS").borders(Borders::TOP);
let foot_txt =
Paragraph::new("q : Quit | ⏪🔽🔼⏩ or hjkl : Navigation | < > or H L : Shift task left/right | = - or J K : Shift task up/down")
.block(block);
f.render_widget(foot_txt, main_layout[3]);
}
if state.popup_text.is_some() {
draw_new_task_popup(f, state);
}
}