Compare commits

...
41 Commits
Author SHA1 Message Date
JosephFerano 26b1367820 Pedantic clippy pass 2023-06-19 07:55:46 +07:00
JosephFerano 2f98c968f0 Get rid of the mode, it's making github barf 2023-06-18 00:14:10 +07:00
JosephFerano 63be792eee Try with markdown 2023-06-18 00:12:31 +07:00
JosephFerano 8a1ff4f3be See if asciicast will work 2023-06-18 00:06:45 +07:00
JosephFerano 6a1499d090 Add an image 2023-06-17 23:46:08 +07:00
JosephFerano 62e33a8a05 Minor README improvements 2023-06-17 23:31:04 +07:00
JosephFerano b8e0a0307e Fix README backticks 2023-06-17 23:29:09 +07:00
JosephFerano d3ea9fc160 Improving the README further 2023-06-17 23:28:25 +07:00
JosephFerano 6e91544cfe Nicer README 2023-06-17 23:16:31 +07:00
JosephFerano 4beda4fae7 Add docs to everything public. Rename some methods.Remove unused deps 2023-06-17 22:47:54 +07:00
JosephFerano 9a779b1abb Bug: sort_order column was set to 0 when creating a task. Thank you tests 2023-06-17 16:16:22 +07:00
JosephFerano 514c600bb1 Integration tests covering 6 main scenarios
Covers
- Add
- Select
- Select Top/Bottom
- Move
- Edit
- Delete
2023-06-17 12:35:17 +07:00
JosephFerano d23f458444 Redesign UI, project stats panel, show project name, fixes and refactorings 2023-06-16 15:14:39 +07:00
JosephFerano a98b82f3e4 Get rid of some clones. Return Oks from all arm branches 2023-06-16 10:18:29 +07:00
JosephFerano a479b72a29 Refactor task edit by wrapping focus with int-enum, remove some .clone()'s 2023-06-15 20:44:25 +07:00
JosephFerano 41778accdc Random clean up 2023-06-15 19:21:01 +07:00
JosephFerano f951e500f1 Bubble up all db errors. Implement Deref and DerefMut for DBConn 2023-06-15 16:32:23 +07:00
JosephFerano f5b39592a4 Fix bug in moving and deleting a task. Remove some unwraps 2023-06-15 15:54:18 +07:00
JosephFerano dba1e8a640 DONE Refactoring methods: Dedup into single function and fix odds and ends 2023-06-15 14:06:05 +07:00
JosephFerano 02ad7c3da6 WIP Refactoring methods: Fix borrow checker issues 2023-06-15 12:06:50 +07:00
JosephFerano 940e07d72c WIP Refactoring methods: Move everything into app.rs 2023-06-15 11:53:28 +07:00
JosephFerano a3e3fa6551 WIP Refactoring methods: Got it to compile and it mostly works 2023-06-14 22:33:19 +07:00
JosephFerano c00500a4e1 WIP Refactoring methods: Move stuff from input to app 2023-06-14 21:53:38 +07:00
JosephFerano 71f12ec7f9 WIP Refactoring methods: Merge Project into State, add DBConn struct with impl 2023-06-14 19:13:24 +07:00
JosephFerano 4accf41c45 Fix colors and style, condense some logic. Fix task_idx when moving task 2023-06-14 17:38:23 +07:00
JosephFerano d85bb5a77b Remove async dep 2023-06-14 11:13:03 +07:00
JosephFerano c2e3f0e70a Save column/task selections in a few other options 2023-06-14 10:54:19 +07:00
JosephFerano 4e0195e132 Pedantic clippy 2023-06-13 23:00:28 +07:00
JosephFerano 7c2249ed08 Selected column and task now persist. Reworked the borrowing for task edit 2023-06-13 22:51:04 +07:00
JosephFerano c030984d51 Extract funcs from input-check to reduce nesting and make pedantic clippy happy 2023-06-13 16:32:46 +07:00
JosephFerano c3255c97f5 Pedantic clippy pass 2023-06-13 15:59:24 +07:00
JosephFerano 7fe20e6ae8 Run migrations on first creation, fix a bunch of panics 2023-06-13 15:50:16 +07:00
JosephFerano 6cbba10325 Ignore db path 2023-06-13 15:12:19 +07:00
JosephFerano 293113949b Simplified dbpath cli argument handling 2023-06-13 15:11:25 +07:00
JosephFerano d7eb3fb47a Save new sort order when moving tasks to different columns 2023-06-13 14:47:34 +07:00
JosephFerano 403572723c Formatting 2023-06-13 12:45:57 +07:00
JosephFerano 00ac0c351d Update and move task order up/down, read ids, more column helper methods 2023-06-13 12:41:37 +07:00
JosephFerano 4477bac258 Add/delete tasks, read task id from db 2023-06-12 22:25:58 +07:00
JosephFerano 076c87c792 WIP: Switch to rusqlite 2023-06-12 21:36:00 +07:00
JosephFerano 8393ed610f Read tasks from db on startup 2023-06-10 21:48:57 +07:00
JosephFerano 535f332a81 WIP: Fetch all tasks 2023-06-10 13:48:20 +07:00
15 changed files with 1385 additions and 1561 deletions

No files matched your search

+3
View File
@@ -2,3 +2,6 @@
/.idea/ /.idea/
/kanban-tui.json /kanban-tui.json
/kanban.json /kanban.json
/db.sqlite
/kanban.db
/sql/queries.sql
Generated
+31 -1155
View File
File diff suppressed because it is too large. Load diff
+2 -6
View File
@@ -1,5 +1,5 @@
[package] [package]
name = "kanban-tui" name = "kanban_tui"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
@@ -9,11 +9,7 @@ edition = "2021"
tui = { package = "ratatui", version = "0.20.1" } tui = { package = "ratatui", version = "0.20.1" }
tui-textarea = { version = "0.2.0", git = "https://github.com/rhysd/tui-textarea.git", features = ["ratatui-crossterm"], default-features=false } tui-textarea = { version = "0.2.0", git = "https://github.com/rhysd/tui-textarea.git", features = ["ratatui-crossterm"], default-features=false }
crossterm = "0.26.1" crossterm = "0.26.1"
serde = { version = "1.0.148" , features = [ "derive" ] }
serde_json = "1.0.89"
int-enum = "0.5.0" int-enum = "0.5.0"
thiserror = "1"
anyhow = "1" anyhow = "1"
sqlx = { version = "0.6", features = [ "runtime-async-std-native-tls", "sqlite" ] }
async-std = { version = "1", features = [ "attributes" ] }
clap = { version = "4.3.2" , features = [ "derive" ] } clap = { version = "4.3.2" , features = [ "derive" ] }
rusqlite = { version = "0.29", features = [ "bundled" ] }
+103
View File
@@ -0,0 +1,103 @@
# kanban-tui
## About
kanban-tui is a lightweight, minimalist, terminal-based [Kanban](https://en.wikipedia.org/wiki/Kanban_board) tool built in
Rust. Keybindings are vim-like, making it pretty fast to navigate and handle
tasks. Currently it's not that feature-rich, but it provides all the essential
functionality you need to manage tasks in the classic Kanban way.
It uses [ratatui](https://crates.io/crates/ratatui) for the TUI interface rendering and [rusqlite](https://crates.io/crates/rusqlite) for data
persistence. Both awesome crates to work with.
## Motivation
Decided to make kanban-tui to work on a non-trivial Rust application. I enjoy
TUIs for their simplicity and efficiency. I don't really plan on using this, as
I'm an [org](https://orgmode.org/) person myself, but if someone actually decides they like this level
of minimalism, then I might consider working on it more.
## Features
- Terminal-based: Enjoy the simplicity and speed of a text-based interface
- Vim-like keybindings: For those who have `hjkl` burned into their skull
- SQLite backed: Overkill maybe? At least I like SQL
- No bloat: Just create a task and move it around
## Showcase
![img](./assets/screen1.png)
[![asciicast](https://asciinema.org/a/QjEfh3kzvKMHTLRv2vWqr0mzX.svg)](https://asciinema.org/a/QjEfh3kzvKMHTLRv2vWqr0mzX)
## Building and Installing
To build and install kanban-tui, follow these steps:
- Install Rust: If you don't have Rust installed, you can get it from [here](https://www.rust-lang.org/tools/install).
- Clone this repository, cd into it
- Build the project: `cargo build --release`
- The built binary can be found in `target/release/kanban-tui`
- Optionally, you can move the binary to a directory in your `$PATH` for easier
access.
## Usage
It is recommended to run `kanban-tui` from the root of your project so it can
pick up the project's name. This automatically creates a SQLite database there
named `kanban.db`.
If you prefer a different name for the database file, you can specify the file
name when running the app, like `kanban-tui todos.kanban` as an example.
Once open, start creating tasks, then move them across columns to track their
status - from "To Do", through "In Progress", to "Done". All task and navigation
operations are performed through simple keybindings.
## Keybindings
- Quit: `q`
- Navigation: `hjkl`
- Move Task: `HJKL`
- New Task: `n`
- Edit Task: `e`
- Cycle Form Fields: `Tab`
- Column Top: `g`
- Column Bottom: `G`
## Potential Future Features
- Timestamps for tasks (created, moved, completed)
- Customizable column (name, amount, etc;)
- More project stats (velocity, avg time to complete)
- Column visibility toggle (hide "Ideas")
- Attach images (using [Kitty Term Image Protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/))
- Read from other backends (Trello, Jira, etc;)
- Copy task
- Reports
- Export to other formats
## Acknolwedgements
[Karrq](https://github.com/Karrq) was nice enough to provide some help from time to time and even
contributed a handy little macro.
## Contributions
While the project is primarily developed to practice Rust, if for some reason
you like this and want to contribute, open an issue and we can discuss whatever.
## License
kanban-tui is licensed under the terms of the MIT license. For more information,
see the included LICENSE file.
+66 -7
View File
@@ -1,12 +1,71 @@
#+OPTIONS: toc:nil #+OPTIONS: toc:nil
* About * kanban-tui
** About
kanban-tui is a lightweight, minimalist, terminal-based [[https://en.wikipedia.org/wiki/Kanban_board][Kanban]] tool built in
Rust. Keybindings are vim-like, making it pretty fast to navigate and handle
tasks. Currently it's not that feature-rich, but it provides all the essential
functionality you need to manage tasks in the classic Kanban way.
This ia a Rust powered TUI Kanban Board built using the [[https://github.com/fdehau/tui-rs][tui-rs]] library. It has It uses [[https://crates.io/crates/ratatui][ratatui]] for the TUI interface rendering and [[https://crates.io/crates/rusqlite][rusqlite]] for data
vim-friendly keybindings and while not the most featureful experience, it's fast persistence. Both awesome crates to work with.
and gets the job done. You can save your TODOs in your project's root as either ** Motivation
a json file or a SQLite database. Decided to make kanban-tui to work on a non-trivial Rust application. I enjoy
TUIs for their simplicity and efficiency. I don't really plan on using this, as
I'm an [[https://orgmode.org/][org]] person myself, but if someone actually decides they like this level
of minimalism, then I might consider working on it more.
** Features
- Terminal-based: Enjoy the simplicity and speed of a text-based interface
- Vim-like keybindings: For those who have ~hjkl~ burned into their skull
- SQLite backed: Overkill maybe? At least I like SQL
- No bloat: Just create a task and move it around
** Showcase
[[./assets/screen1.png]]
[[file:https://asciinema.org/a/QjEfh3kzvKMHTLRv2vWqr0mzX.svg][asciicast]]
** Building and Installing
To build and install kanban-tui, follow these steps:
- Install Rust: If you don't have Rust installed, you can get it from [[https://www.rust-lang.org/tools/install][here]].
- Clone this repository, cd into it
- Build the project: ~cargo build --release~
- The built binary can be found in ~target/release/kanban-tui~
- Optionally, you can move the binary to a directory in your ~$PATH~ for easier
access.
** Usage
It is recommended to run ~kanban-tui~ from the root of your project so it can
pick up the project's name. This automatically creates a SQLite database there
named ~kanban.db~.
* Installing If you prefer a different name for the database file, you can specify the file
name when running the app, like ~kanban-tui todos.kanban~ as an example.
Clone this repository, then simply run ~cargo build~ or ~cargo run~ Once open, start creating tasks, then move them across columns to track their
status - from "To Do", through "In Progress", to "Done". All task and navigation
operations are performed through simple keybindings.
** Keybindings
- Quit: ~q~
- Navigation: ~hjkl~
- Move Task: ~HJKL~
- New Task: ~n~
- Edit Task: ~e~
- Cycle Form Fields: ~Tab~
- Column Top: ~g~
- Column Bottom: ~G~
** Potential Future Features
- Timestamps for tasks (created, moved, completed)
- Customizable column (name, amount, etc;)
- More project stats (velocity, avg time to complete)
- Column visibility toggle (hide "Ideas")
- Attach images (using [[https://sw.kovidgoyal.net/kitty/graphics-protocol/][Kitty Term Image Protocol]])
- Read from other backends (Trello, Jira, etc;)
- Copy task
- Reports
- Export to other formats
** Acknolwedgements
[[https://github.com/Karrq][Karrq]] was nice enough to provide some help from time to time and even
contributed a handy little macro.
** Contributions
While the project is primarily developed to practice Rust, if for some reason
you like this and want to contribute, open an issue and we can discuss whatever.
** License
kanban-tui is licensed under the terms of the MIT license. For more information,
see the included LICENSE file.
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

+28
View File
@@ -0,0 +1,28 @@
PRAGMA foreign_keys = ON;
create table if not exists task
(
id integer primary key autoincrement,
title text not null,
description text not null,
sort_order integer not null,
column_id integer,
foreign key (column_id) references kb_column(id)
);
create table if not exists kb_column
(
id integer primary key autoincrement,
name text not null,
selected_task integer not null default 0
);
create table if not exists app_state
(
key text not null primary key,
value text not null
);
insert into kb_column(name) values ("Todo"),("InProgress"),("Done"),("Ideas");
insert into app_state(key, value) values ("selected_column", "0");
+352 -170
View File
@@ -1,57 +1,65 @@
// use indexmap::IndexMap; use anyhow::Error;
// use int_enum::IntEnum; use int_enum::IntEnum;
use serde::{Deserialize, Serialize}; use rusqlite::Connection;
use std::cmp::min; use std::cmp::min;
use std::fs::File;
use std::io::Read;
use tui_textarea::TextArea; use tui_textarea::TextArea;
#[cfg(test)] use crate::db;
mod tests;
#[derive(Debug, Serialize, Deserialize)] /// Represents a kanban column containing the tasks and other metadata.
#[derive(Debug)]
pub struct Column { pub struct Column {
/// Id provided by the database
pub id: i64,
/// The name used for the title in the UI
pub name: String, pub name: String,
/// The currently selected [`Task`], which keeps track of the
/// user's position in a column when the go from one to another
pub selected_task_idx: usize, pub selected_task_idx: usize,
/// The collection of [`Task`]
pub tasks: Vec<Task>, pub tasks: Vec<Task>,
} }
// #[derive(Deserialize, Serialize, Debug, Clone, Copy)] /// Basic TODO task with a title and a description.
#[derive(Default, Deserialize, Serialize, Debug)] #[derive(Clone, Default, Debug)]
pub struct Task { pub struct Task {
/// Id provided by the database
pub id: i64,
/// Title of the [`Task`]
pub title: String, pub title: String,
/// Description of the [`Task`]
pub description: String, pub description: String,
} }
/// Type used mainly for serialization at this time /// The number of `TaskEditFocus` variants, used so we can "wrap around"
#[derive(Deserialize, Serialize, Debug)] /// with modulo when cycling through tasks with Tab/Backtab.
pub struct Project { pub const EDIT_WINDOW_FOCUS_STATES: i8 = 4;
pub name: String,
pub filepath: String,
pub selected_column_idx: usize,
pub columns: Vec<Column>,
}
#[derive(Debug, thiserror::Error)] /// Used to track the focus of the form field in the task edit window.
pub enum KanbanError { #[repr(i8)]
#[error("There is something wrong with the json schema, it doesn't match Project struct")] #[derive(Debug, IntEnum, Copy, Clone)]
BadJson,
#[error("IO - {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug)]
pub enum TaskEditFocus { pub enum TaskEditFocus {
Title, /// Title text input line
Description, Title = 0,
ConfirmBtn, /// Description text input box
CancelBtn, Description = 1,
/// Confirm changes button
ConfirmBtn = 2,
/// Cancel changes button
CancelBtn = 3,
} }
/// Represents the transient state of a task while it is being editing
/// by the user through the UI.
pub struct TaskState<'a> { pub struct TaskState<'a> {
/// The title of the Task
pub title: TextArea<'a>, pub title: TextArea<'a>,
/// The description of the Task
pub description: TextArea<'a>, pub description: TextArea<'a>,
/// Where the current focus of the task edit form is
pub focus: TaskEditFocus, pub focus: TaskEditFocus,
/// Used to decide if the user is editing an existing task or
/// creating a new one
pub is_edit: bool, pub is_edit: bool,
} }
@@ -66,75 +74,197 @@ impl Default for TaskState<'_> {
} }
} }
/// Holds the application's state, including all columns and the
/// database connection.
pub struct State<'a> { pub struct State<'a> {
pub project: Project, /// The name of the project, currently derived from the name of
pub quit: bool, /// the current working directory
pub project_name: String,
/// The index of the currently selected [`Column`]
pub selected_column_idx: usize,
/// A vec of all the [`Column`]s
pub columns: Vec<Column>, pub columns: Vec<Column>,
/// The [`db::DBConn`] wrapping a [`rusqlite::Connection`]
pub db_conn: db::DBConn,
/// Flag to check on each loop whether we should exit the app
pub quit: bool,
/// If [`Some(TaskState)`] then we are in the task edit form window
pub task_edit_state: Option<TaskState<'a>>, pub task_edit_state: Option<TaskState<'a>>,
} }
impl State<'_> { impl<'a> State<'a> {
#[must_use] /// Creates a new [`State`].
pub fn new(project: Project) -> Self { ///
State { /// # Errors
///
/// Returns an error if we can't read the database columns
pub fn new(conn: Connection) -> Result<Self, Error> {
let db_conn = db::DBConn::new(conn);
let columns = db_conn.get_all_columns()?;
let selected_column = db_conn.get_selected_column()?;
let project_name = std::env::current_dir()?
.file_name()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or("KANBAN PROJECT")
.to_string();
Ok(State {
project_name,
columns,
selected_column_idx: selected_column,
quit: false, quit: false,
task_edit_state: None, task_edit_state: None,
project, db_conn,
columns: vec![], })
}
}
} }
impl<'a> Column { /// Returns a reference to the currently selected [`Column`].
#[must_use] #[must_use]
pub fn new(name: &str) -> Self { pub fn get_selected_column(&self) -> &Column {
Column { &self.columns[self.selected_column_idx]
name: name.to_owned(),
tasks: vec![],
selected_task_idx: 0,
}
} }
pub fn add_task(&mut self, title: String, description: String) { /// Returns a mutable reference to the currently selected
let task = Task { title, description }; /// [`Column`].
self.tasks.push(task); pub fn get_selected_column_mut(&mut self) -> &mut Column {
self.select_next_task(); &mut self.columns[self.selected_column_idx]
} }
pub fn remove_task(&mut self) { /// Selects the [`Column`] on the left. Does nothing if on the
self.tasks.remove(self.selected_task_idx); /// first column.
self.select_next_task(); ///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn select_column_left(&mut self) -> Result<(), Error> {
self.selected_column_idx = self.selected_column_idx.saturating_sub(1);
self.db_conn.set_selected_column(self.selected_column_idx)
} }
/// Selects the [`Column`] on the right. Does nothing if on the
/// last column.
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn select_column_right(&mut self) -> Result<(), Error> {
self.selected_column_idx = min(self.selected_column_idx + 1, self.columns.len() - 1);
self.db_conn.set_selected_column(self.selected_column_idx)
}
/// Returns a reference to the currently selected [`Task`].
/// Returns `None` if the current [`Column::tasks`] is empty.
#[must_use] #[must_use]
pub fn get_selected_task(&self) -> Option<&Task> { pub fn get_selected_task(&self) -> Option<&Task> {
self.tasks.get(self.selected_task_idx) let column = self.get_selected_column();
} column.tasks.get(column.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().saturating_sub(1));
}
pub fn select_first_task(&mut self) {
self.selected_task_idx = 0;
}
pub fn select_last_task(&mut self) {
self.selected_task_idx = self.tasks.len() - 1;
} }
/// Returns a reference to the [`Task`] above the current one.
/// Returns `None` if it's the first task on the list
#[must_use] #[must_use]
pub fn get_task_state_from_curr_selected_task(&self) -> Option<TaskState<'a>> { pub fn get_task_above(&self) -> Option<&Task> {
let column = self.get_selected_column();
if column.selected_task_idx > 0 {
column.tasks.get(column.selected_task_idx - 1)
} else {
None
}
}
/// Returns a reference to the [`Task`] below the current one.
/// Returns `None` if it's the last task on the list
#[must_use]
pub fn get_task_below(&self) -> Option<&Task> {
let column = self.get_selected_column();
column.tasks.get(column.selected_task_idx + 1)
}
/// Returns a mutable reference to the currently selected
/// [`Task`]. Returns `None` if the current [`Column::tasks`] is
/// empty.
pub fn get_selected_task_mut(&mut self) -> Option<&mut Task> {
let column = self.get_selected_column_mut();
column.tasks.get_mut(column.selected_task_idx)
}
/// Selects the [`Task`] above the current one. Does nothing if
/// it's the first task on the list
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn select_task_above(&mut self) -> Result<(), Error> {
let column = self.get_selected_column_mut();
column.selected_task_idx = column.selected_task_idx.saturating_sub(1);
let task_idx = column.selected_task_idx;
let col_id = column.id;
self.db_conn
.set_selected_task_for_column(task_idx, col_id)?;
Ok(())
}
/// Selects the [`Task`] below the current one. Does nothing if
/// it's the last task on the list
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn select_task_below(&mut self) -> Result<(), Error> {
let column = self.get_selected_column_mut();
column.selected_task_idx = min(
column.selected_task_idx + 1,
column.tasks.len().saturating_sub(1),
);
let task_idx = column.selected_task_idx;
let col_id = column.id;
self.db_conn
.set_selected_task_for_column(task_idx, col_id)?;
Ok(())
}
/// Selects the [`Task`] at the beginning of the list, no matter
/// where you are in the current [`Column`].
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn select_first_task(&mut self) -> Result<(), Error> {
let column = self.get_selected_column_mut();
column.selected_task_idx = 0;
let task_idx = column.selected_task_idx;
let col_id = column.id;
self.db_conn
.set_selected_task_for_column(task_idx, col_id)?;
Ok(())
}
/// Selects the [`Task`] at the end of the list, no matter
/// where you are in the current [`Column`].
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn select_last_task(&mut self) -> Result<(), Error> {
let column = self.get_selected_column_mut();
column.selected_task_idx = column.tasks.len().saturating_sub(1);
let task_idx = column.selected_task_idx;
let col_id = column.id;
self.db_conn
.set_selected_task_for_column(task_idx, col_id)?;
Ok(())
}
/// Helper method to construct a [`TaskState`]. Used when we are
/// going to edit an existing [`Task`]. Returns `None` if the
/// [`Column`] is empty.
#[must_use]
pub fn get_task_state_from_current(&self) -> Option<TaskState<'a>> {
self.get_selected_task().map(|t| TaskState { self.get_selected_task().map(|t| TaskState {
title: TextArea::from(t.title.lines()), title: TextArea::from(t.title.lines()),
description: TextArea::from(t.description.lines()), description: TextArea::from(t.description.lines()),
@@ -142,120 +272,172 @@ impl<'a> Column {
is_edit: true, is_edit: true,
}) })
} }
}
impl Project {
#[must_use]
pub fn new(name: &str, filepath: String) -> Self {
Project {
name: name.to_owned(),
filepath,
columns: vec![
Column::new("Todo"),
Column::new("InProgress"),
Column::new("Done"),
Column::new("Ideas"),
],
selected_column_idx: 0,
}
}
fn load_from_json(json: &str) -> Result<Self, KanbanError> {
serde_json::from_str(json).map_err(|_| KanbanError::BadJson)
}
/// Moves the current [`Task`] up the list towards the top. Does
/// nothing if it's the first task.
///
/// # Errors /// # Errors
/// ///
/// Will return `Err` if `file` contains json that doesn't match State schema /// SQL related errors get bubbled up to here.
pub fn load(path: String, mut file: &File) -> Result<Self, KanbanError> { pub fn move_task_up(&mut self) -> Result<(), Error> {
let mut json = String::new(); self.move_task(false)
file.read_to_string(&mut json)?;
if json.trim().is_empty() {
Ok(Project::new("", path))
} else {
Self::load_from_json(&json)
}
} }
/// # Panics /// Moves the current [`Task`] down the list towards the bottom. Does
/// nothing if it's the last task.
/// ///
/// Will panic if there's an error serializing the Json or there's an issue /// # Errors
/// writing the file ///
pub fn save(&self) { /// SQL related errors get bubbled up to here.
let json = serde_json::to_string_pretty(&self).unwrap(); pub fn move_task_down(&mut self) -> Result<(), Error> {
std::fs::write(&self.filepath, json).unwrap(); self.move_task(true)
} }
#[must_use] /// Private function to handle saving the current [`Task`]'s
pub fn get_selected_column(&self) -> &Column { /// state.
&self.columns[self.selected_column_idx] fn move_task(&mut self, is_down: bool) -> Result<(), Error> {
} let other_task = if is_down {
self.get_task_below()
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 { } else {
col_idx > 0 self.get_task_above()
}; };
if cond && !column.tasks.is_empty() { if let (Some(task1), Some(task2)) = (self.get_selected_task(), other_task) {
let t = column.tasks.remove(column.selected_task_idx); let t1_id = task1.id;
column.select_previous_task(); let t2_id = task2.id;
if move_next { let column = self.get_selected_column_mut();
self.select_next_column(); let task_idx = column.selected_task_idx;
let other_idx = if is_down { task_idx + 1 } else { task_idx - 1 };
column.tasks.swap(task_idx, other_idx);
if is_down {
column.selected_task_idx += 1;
} else { } else {
self.select_previous_column(); column.selected_task_idx -= 1;
} }
let task_idx = column.selected_task_idx;
let col_id = column.id;
self.db_conn.swap_task_order(t1_id, t2_id)?;
self.db_conn
.set_selected_task_for_column(task_idx, col_id)?;
}
Ok(())
}
/// Moves the current [`Task`] to the [`Column`] on the left. Does
/// nothing if it's the first column.
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn move_task_column_left(&mut self) -> Result<(), Error> {
self.move_task_to_column(false)
}
/// Moves the current [`Task`] to the [`Column`] on the right. Does
/// nothing if it's the last column.
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn move_task_column_right(&mut self) -> Result<(), Error> {
self.move_task_to_column(true)
}
fn move_task_to_column(&mut self, move_right: bool) -> Result<(), Error> {
let can_move_right = move_right && self.selected_column_idx < self.columns.len() - 1;
let can_move_left = !move_right && self.selected_column_idx > 0;
let first_col = self.get_selected_column_mut();
if first_col.tasks.is_empty() || !can_move_right && !can_move_left {
// We're at the bounds so just ignore
return Ok(());
}
let t = first_col.tasks.remove(first_col.selected_task_idx);
// Only move it if it was the last task
if first_col.selected_task_idx == first_col.tasks.len() {
self.select_task_above()?;
}
if move_right {
self.select_column_right()?;
} else {
self.select_column_left()?;
}
let col = self.get_selected_column_mut(); let col = self.get_selected_column_mut();
col.tasks.push(t); col.tasks.push(t);
col.select_last_task(); self.select_last_task()?;
self.save(); if let Some(task) = self.get_selected_task() {
let col = self.get_selected_column();
self.db_conn.move_task_to_column(task, col)?;
self.db_conn.set_selected_column(self.selected_column_idx)?;
} }
Ok(())
} }
pub fn move_task_previous_column(&mut self) { /// Inserts a new [`Task`] into [`Column::tasks`] at the bottom of
self.move_task_to_column(false); /// the list and saves the state to the DB.
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn add_new_task(&mut self, title: String, description: String) -> Result<(), Error> {
let col_id = self.get_selected_column().id;
let task = self.db_conn.create_new_task(title, description, col_id)?;
self.select_last_task()?;
let selected_task_idx = self.get_selected_column().selected_task_idx;
self.db_conn
.set_selected_task_for_column(selected_task_idx, col_id)?;
self.get_selected_column_mut().tasks.push(task);
self.select_last_task()?;
Ok(())
} }
pub fn move_task_next_column(&mut self) { /// Edits the selected [`Task`] changing only it's title and/or
self.move_task_to_column(true); /// description. Does nothing if the [`Column`] is empty.
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn edit_task(&mut self, title: String, description: String) -> Result<(), Error> {
if let Some(selected_task) = self.get_selected_task_mut() {
selected_task.title = title;
selected_task.description = description;
}
if let Some(task) = self.get_selected_task() {
self.db_conn.update_task_text(task)?;
}
Ok(())
} }
pub fn move_task_up(&mut self) { /// Deletes the selected [`Task`] from the list. Does nothing if
/// the [`Column`] is empty.
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn delete_task(&mut self) -> Result<(), Error> {
if let Some(task) = self.get_selected_task() {
let task_id = task.id;
let column = self.get_selected_column_mut(); let column = self.get_selected_column_mut();
if column.selected_task_idx > 0 { let mut task_idx = column.selected_task_idx;
column let col_id = column.id;
.tasks
.swap(column.selected_task_idx, column.selected_task_idx - 1); column.tasks.remove(task_idx);
column.selected_task_idx -= 1;
self.save(); if column.selected_task_idx >= column.tasks.len() {
} self.select_task_above()?;
task_idx = task_idx.saturating_sub(1);
} }
pub fn move_task_down(&mut self) { self.db_conn.delete_task(task_id)?;
let column = self.get_selected_column_mut(); self.db_conn
if column.selected_task_idx < column.tasks.len() - 1 { .set_selected_task_for_column(task_idx, col_id)?;
column }
.tasks Ok(())
.swap(column.selected_task_idx, column.selected_task_idx + 1);
column.selected_task_idx += 1;
self.save();
}
} }
} }
-1
View File
@@ -1 +0,0 @@
// use super::*;
+260
View File
@@ -0,0 +1,260 @@
use crate::{Column, Task};
use anyhow::Error;
use rusqlite::{params, Connection, Result};
use std::ops::{Deref, DerefMut};
/// Simple one field struct to wrap a [`rusqlite::Connection`] so we
/// can assign our own methods.
pub struct DBConn(
/// Unfortunately remains public for now so we can do integration tests
/// and reuse to simulate exiting and relaunching the app.
pub Connection
);
impl DerefMut for DBConn {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Deref for DBConn {
type Target = Connection;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DBConn {
pub fn new(conn: Connection) -> Self {
DBConn(conn)
}
/// Query tasks in a [`Column`] by using the column's [`Column::id`].
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn get_tasks_by_column(&self, column_id: i64) -> Result<Vec<Task>> {
let mut stmt = self.prepare(
r#"
select task.id, title, description from task
where column_id = ?1
order by sort_order
"#,
)?;
let mut tasks = Vec::new();
let rows = stmt.query_map([column_id], |row| {
Ok(Task {
id: row.get(0)?,
title: row.get(1)?,
description: row.get(2)?,
})
})?;
for row in rows {
tasks.push(row?);
}
Ok(tasks)
}
/// Uses [`get_tasks_by_column`][`DBConn::get_tasks_by_column`] over
/// a loop to get all [`Column`] populated with the vec of.
/// [`Task`]
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn get_all_columns(&self) -> Result<Vec<Column>> {
let mut stmt = self.prepare("select id, name, selected_task from kb_column")?;
let columns = stmt
.query_map((), |row| {
let id = row.get(0)?;
Ok(Column {
id,
tasks: self.get_tasks_by_column(id)?,
name: row.get(1)?,
selected_task_idx: row.get(2)?,
})
})?
.filter_map(Result::ok)
.collect();
Ok(columns)
}
/// Insert a new task into the DB given a title and description,
/// then return the [`Task`] with the ID provided by the DB.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn create_new_task(
&self,
title: String,
description: String,
column_id: i64,
) -> Result<Task> {
let mut stmt =
self.prepare(
"insert into task(title, description, column_id, sort_order)
values (?1, ?2, ?3,
(coalesce(1 +
(select sort_order from task
where column_id = ?3 order by sort_order desc limit 1),
0)))")?;
stmt.execute(params![title, description, column_id])?;
let id = self.last_insert_rowid();
Ok(Task {
id,
title,
description,
})
}
/// Deletes a [`Task`] given it's ID.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn delete_task(&self, task_id: i64) -> Result<()> {
let mut stmt = self.prepare("delete from task where id = ?1")?;
stmt.execute([task_id])?;
Ok(())
}
/// Updates an existing [`Task`]'s `title` and `description`.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn update_task_text(&self, task: &Task) -> Result<()> {
let mut stmt =
self.prepare("update task set title = ?2, description = ?3 where id = ?1")?;
stmt.execute((&task.id, &task.title, &task.description))?;
Ok(())
}
/// Moves a [`Task`] to the target [`Column`] and updates the sorting order.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn move_task_to_column(&self, task: &Task, target_column: &Column) -> Result<()> {
let mut stmt = self
.prepare(
"update task
set
column_id = ?2,
sort_order = coalesce(1 +
(select sort_order from task
where column_id = ?2 order by sort_order desc limit 1),
0)
where task.id = ?1",
)?;
stmt.execute((&task.id, &target_column.id))?;
self.set_selected_task_for_column(target_column.selected_task_idx, target_column.id)?;
Ok(())
}
/// This is a helper function in case we need to debug `sort_order`, because I ran into
/// a bug when I forgot to insert the `sort_order` when creating a task.
#[allow(dead_code)]
fn get_sort_order(&self) -> Result<Vec<(i32, String, usize)>> {
let mut stmt = self.prepare(
"select id,title,sort_order from task where column_id = 1")?;
let mut rows = stmt.query(())?;
let mut tasks = Vec::new();
while let Some(row) = rows.next()? {
tasks.push((row.get(0)?, row.get(1)?, row.get(2)?,));
}
Ok(tasks)
}
/// The order of a [`Task`] in a [`Column`] needs to be saved to
/// the DB because `SQLite` doesn't have a way to handle the
/// ordering the internal [`Vec<Task>`] has. This takes the
/// current sorting order of two tasks and swaps them.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn swap_task_order(&mut self, task1_id: i64, task2_id: i64) -> Result<()> {
let tx = self.transaction()?;
tx.execute(
"create temp table temp_order as select sort_order from task where id = ?1",
[&task1_id],
)?;
tx.execute(
"update task
set sort_order = (select sort_order from task where id = ?2)
where id = ?1",
(task1_id, task2_id),
)?;
tx.execute(
"update task set sort_order = (select sort_order from temp_order) where id = ?1",
[&task2_id],
)?;
tx.execute("drop table temp_order", ())?;
tx.commit()?;
Ok(())
}
/// Saves the currently selected column's index to `app_state` so
/// when the user reloads the project, they start on the
/// [`Column`] they were last on.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn set_selected_column(&self, column_id: usize) -> Result<(), Error> {
let mut stmt =
self.prepare("insert or replace into app_state(key, value) values (?1, ?2)")?;
stmt.execute((&"selected_column", column_id.to_string()))?;
Ok(())
}
/// Get's the user's last selected [`Column`] before exiting.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn get_selected_column(&self) -> Result<usize> {
let mut stmt = self.prepare("select value from app_state where key = ?1")?;
stmt.query_row(["selected_column"], |row| {
let value: String = row.get::<usize, String>(0)?;
value.parse::<usize>()
.map_err(|_| rusqlite::Error::InvalidQuery)
})
}
/// Saves the index currently selected [`Task`] in a [`Column`] so
/// when the user reloads the project, each column selects the has
/// the last selected task before switching to another column or
/// exiting the app.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn set_selected_task_for_column(&self, task_idx: usize, column_id: i64) -> Result<()> {
let mut stmt = self.prepare("update kb_column set selected_task = ?2 where id = ?1")?;
stmt.execute((column_id, task_idx))?;
Ok(())
}
/// Get's each [`Column`]'s 's last selected [`Task`] before
/// switching or exiting.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn get_selected_task_for_column(&self, column_id: i32) -> Result<usize> {
let mut stmt = self.prepare("select selected_task from kb_column where key = ?1")?;
stmt.query_row([column_id], |row| row.get(0))
}
}
+87 -74
View File
@@ -1,89 +1,102 @@
use crate::app::{State, TaskEditFocus, TaskState}; use crate::app::{State, TaskEditFocus, TaskState, EDIT_WINDOW_FOCUS_STATES};
use anyhow::Error;
use crossterm::event; use crossterm::event;
use crossterm::event::{Event, KeyCode}; use crossterm::event::{Event, KeyCode};
use int_enum::IntEnum;
/// # Errors pub fn cycle_focus(task: &mut TaskState<'_>, forward: bool) -> Result<(), Error> {
/// let cycle = if forward {
/// Crossterm `event::read()` might return an error (task.focus.int_value() + 1) % EDIT_WINDOW_FOCUS_STATES
pub fn handle(state: &mut State<'_>) -> Result<(), std::io::Error> { } else {
let project = &mut state.project; (task.focus.int_value() - 1) % EDIT_WINDOW_FOCUS_STATES
let column = project.get_selected_column_mut(); };
if let Event::Key(key) = event::read()? { task.focus = TaskEditFocus::from_int(cycle)?;
match &mut state.task_edit_state { Ok(())
Some(task) => {
// TODO: Extract this code to a separate function to avoid nesting
match task.focus {
// TODO: Handle wrapping around the enum rather than doing it manually
TaskEditFocus::Title => match key.code {
KeyCode::Tab => task.focus = TaskEditFocus::Description,
KeyCode::BackTab => task.focus = TaskEditFocus::CancelBtn,
KeyCode::Enter => (),
_ => {
task.title.input(key);
} }
},
TaskEditFocus::Description => match key.code { pub fn handle_task_edit(state: &mut State<'_>, key: event::KeyEvent) -> Result<(), Error> {
KeyCode::Tab => task.focus = TaskEditFocus::ConfirmBtn, // .take() the option so we can avoid borrow checker issues when
KeyCode::BackTab => task.focus = TaskEditFocus::Title, // we try to edit the task since that mutably borrows State, then
_ => { // assign later to task_edit_state
task.description.input(key); let updated_task = if let Some(mut task) = state.task_edit_state.take() {
match (key.code, task.focus) {
(KeyCode::Tab, _) => {
cycle_focus(&mut task, true)?;
Some(task)
} }
}, (KeyCode::BackTab, _) => {
TaskEditFocus::ConfirmBtn => match key.code { cycle_focus(&mut task, false)?;
KeyCode::Tab => task.focus = TaskEditFocus::CancelBtn, Some(task)
KeyCode::BackTab => task.focus = TaskEditFocus::Description, }
KeyCode::Enter => { (KeyCode::Enter, TaskEditFocus::ConfirmBtn) => {
let title = task.title.clone().into_lines().join("\n"); // The structure of this function is so we avoid an
let description = task.description.clone().into_lines().join("\n"); // unncessary clone() here. We can just transfer
// ownership of these strings right into the task
// that's going to be created/updated
let title = task.title.into_lines().join("\n");
let description = task.description.into_lines().join("\n");
if task.is_edit { if task.is_edit {
if let Some(selected_task) = column.get_selected_task_mut() { state.edit_task(title, description)?;
selected_task.title = title; } else {
selected_task.description = description; state.add_new_task(title, description)?;
}
None
}
(KeyCode::Enter, TaskEditFocus::CancelBtn) => None,
// Ignore enter on the title bar to effectively make it single line
(KeyCode::Enter, TaskEditFocus::Title) => Some(task),
(_, TaskEditFocus::Title) => {
task.title.input(key);
Some(task)
}
(_, TaskEditFocus::Description) => {
task.description.input(key);
Some(task)
}
_ => Some(task),
} }
} else { } else {
column.add_task(title, description); None
}
state.task_edit_state = None;
project.save();
}
_ => (),
},
TaskEditFocus::CancelBtn => match key.code {
KeyCode::Tab => task.focus = TaskEditFocus::Title,
KeyCode::BackTab => task.focus = TaskEditFocus::ConfirmBtn,
KeyCode::Enter => {
state.task_edit_state = None;
}
_ => (),
},
}; };
state.task_edit_state = updated_task;
Ok(())
} }
None => match key.code {
KeyCode::Char('q') => state.quit = true, #[allow(clippy::unit_arg)]
KeyCode::Char('h') | KeyCode::Left => { pub fn handle_main(state: &mut State<'_>, key: event::KeyEvent) -> Result<(), Error> {
project.select_previous_column(); match key.code {
KeyCode::Char('q') => Ok(state.quit = true),
KeyCode::Char('h') | KeyCode::Left => state.select_column_left(),
KeyCode::Char('j') | KeyCode::Down => state.select_task_below(),
KeyCode::Char('k') | KeyCode::Up => state.select_task_above(),
KeyCode::Char('l') | KeyCode::Right => state.select_column_right(),
KeyCode::Char('g') => state.select_first_task(),
KeyCode::Char('G') => state.select_last_task(),
KeyCode::Char('H') => state.move_task_column_left(),
KeyCode::Char('L') => state.move_task_column_right(),
KeyCode::Char('J') => state.move_task_down(),
KeyCode::Char('K') => state.move_task_up(),
KeyCode::Char('n') => Ok(state.task_edit_state = Some(TaskState::default())),
KeyCode::Char('e') => Ok(state.task_edit_state = state.get_task_state_from_current()),
KeyCode::Char('D') => state.delete_task(),
_ => Ok(()),
} }
KeyCode::Char('j') | KeyCode::Down => column.select_next_task(),
KeyCode::Char('k') | KeyCode::Up => column.select_previous_task(),
KeyCode::Char('l') | KeyCode::Right => {
project.select_next_column();
} }
KeyCode::Char('g') => column.select_first_task(),
KeyCode::Char('G') => column.select_last_task(), /// Takes the app's [`State`] and uses [`event::read`] to get the current keypress.
KeyCode::Char('H') => project.move_task_previous_column(), ///
KeyCode::Char('L') => project.move_task_next_column(), /// # Errors
KeyCode::Char('J') => project.move_task_down(), ///
KeyCode::Char('K') => project.move_task_up(), /// Most of the applications errors will be bubbled up to this layer,
KeyCode::Char('n') => state.task_edit_state = Some(TaskState::default()), /// including all database related ones
KeyCode::Char('e') => { ///
state.task_edit_state = column.get_task_state_from_curr_selected_task() /// Crossterm `event::read()` might return an error,
} pub fn handle_user_keypress(state: &mut State<'_>) -> Result<(), Error> {
KeyCode::Char('D') => { if let Event::Key(key) = event::read()? {
column.remove_task(); if state.task_edit_state.is_some() {
project.save(); handle_task_edit(state, key)?;
} } else {
_ => {} handle_main(state, key)?;
},
} }
} }
Ok(()) Ok(())
+18 -2
View File
@@ -1,8 +1,24 @@
//! Manage your TODOs with kanban columns right from the terminal.
//!
//! kanban-tui is a TUI based application using [`ratatui`] for
//! rendering the UI and capturing user input interaction, with
//! [`Crossterm`] as the lower-level backend system for terminal
//! text-based interfaces. The data is saved to a `SQLite` database
//! ideally placed in the root of your project. For this the
//! [`rusqlite`] crate provides the bindings to handle all the data
//! persistence.
//!
//! [`ratatui`]: https://crates.io/crates/ratatui
//! [`Crossterm`]: https://crates.io/crates/crossterm
//! [`rusqlite`]: https://crates.io/crates/rusqlite
#![deny(rust_2018_idioms)] #![deny(rust_2018_idioms)]
mod app; mod app;
mod db;
mod input; mod input;
mod ui; mod ui;
pub use app::*; pub use app::*;
pub use input::handle; pub use db::*;
pub use ui::draw; pub use input::handle_user_keypress;
pub use ui::draw_ui_from_state;
+21 -84
View File
@@ -4,104 +4,43 @@ use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture}, event::{DisableMouseCapture, EnableMouseCapture},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use kanban_tui::{Project, State}; use kanban_tui::State;
use sqlx::sqlite::SqlitePool; use rusqlite::Connection;
use std::{ use std::{error::Error, io, path::PathBuf};
error::Error,
fs::{File, OpenOptions},
io::{self, Write},
path::PathBuf,
};
use tui::backend::CrosstermBackend; use tui::backend::CrosstermBackend;
use tui::Terminal; use tui::Terminal;
const DEFAULT_DATABASE_NAME: &str = "kanban.json";
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command(name = "kanban")] #[command(name = "kanban")]
/// kanban-tui is a simple, interactive TUI based task manager using kanban columns /// kanban-tui is a simple, interactive TUI based task manager using kanban columns
pub struct CliArgs { pub struct CliArgs {
#[arg(value_name="DATABASE", value_hint=FilePath, index=1)] #[arg(value_name="DATABASE", value_hint=FilePath, index=1)]
/// Path to the /// Path to the SQLite database
pub filepath: Option<PathBuf>, pub filepath: Option<PathBuf>,
} }
// TODO: This should just return a struct beacuse we should add a fn main() -> anyhow::Result<(), Box<dyn Error>> {
// "should_quit" thing instead of calling exit(0) here let dbpath = CliArgs::parse()
fn prompt_project_init(default_name: &str) -> (String, io::Result<File>) { .filepath
let mut input = String::new(); .unwrap_or(PathBuf::from("./kanban.db"));
println!("Database not found, select the name of the database if it exists or enter a name to start a new project"); let migrate = !dbpath.exists();
print!("Database name (default: {default_name}): ");
io::stdout().flush().unwrap();
let result = io::stdin().read_line(&mut input); let mut conn = Connection::open(dbpath)?;
let input = input.trim();
let filename = match result { if migrate {
Ok(b) if b == 0 => std::process::exit(0), let migrations = include_str!("../sql/migrations.sql");
Ok(b) if b > 0 && !input.is_empty() => input, let migrations: Vec<&str> = migrations.split(';').collect();
_ => default_name, let tx = conn.transaction()?;
}; for m in migrations {
if !m.trim().is_empty() {
// TODO: This might be a good time to prompt the user if they want tx.execute(m, ())?;
// to change the default column names
(
filename.to_string(),
OpenOptions::new()
.write(true)
.read(true)
.create(true)
.open(filename),
)
}
#[async_std::main]
async fn main() -> anyhow::Result<(), Box<dyn Error>> {
let (filepath, file) = match CliArgs::parse() {
CliArgs {
filepath: Some(filepath),
} => {
let fpath = filepath.into_os_string().into_string().unwrap();
let file = OpenOptions::new().write(true).read(true).open(&fpath);
if let Ok(f) = file {
(fpath, f)
} else {
let (fp, fname) = prompt_project_init(&fpath);
(fp, fname.unwrap())
} }
} }
CliArgs { filepath: None } => { tx.commit()?;
let file = OpenOptions::new()
.write(true)
.read(true)
.open(DEFAULT_DATABASE_NAME);
if let Ok(f) = file {
(DEFAULT_DATABASE_NAME.to_string(), f)
} else {
let (fp, fname) = prompt_project_init(DEFAULT_DATABASE_NAME);
(fp, fname.unwrap())
}
}
};
let pool = SqlitePool::connect("sqlite:db.sqlite").await?;
let stuff = sqlx::query!(
r#"
select * from kanban
"#
)
.fetch_all(&pool)
.await?;
for item in stuff {
println!("{} - {} - {}", item.id, item.name, item.description);
} }
let mut state = State::new(Project::load(filepath, &file)?); let mut state = State::new(conn)?;
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
@@ -110,12 +49,10 @@ async fn main() -> anyhow::Result<(), Box<dyn Error>> {
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
while !state.quit { while !state.quit {
terminal.draw(|f| kanban_tui::draw(f, &mut state))?; terminal.draw(|f| kanban_tui::draw_ui_from_state(f, &mut state))?;
kanban_tui::handle(&mut state)?; kanban_tui::handle_user_keypress(&mut state)?;
} }
// state.project.save();
// restore terminal // restore terminal
disable_raw_mode()?; disable_raw_mode()?;
crossterm::execute!( crossterm::execute!(
+94 -62
View File
@@ -1,7 +1,7 @@
use crate::app::{State, TaskEditFocus}; use crate::app::{State, TaskEditFocus};
use tui::backend::Backend; use tui::backend::Backend;
use tui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use tui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use tui::style::{Color, Modifier, Style}; use tui::style::{Modifier, Style};
use tui::text::{Span, Spans}; use tui::text::{Span, Spans};
use tui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use tui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use tui::Frame; use tui::Frame;
@@ -11,54 +11,57 @@ fn draw_tasks<B: Backend>(f: &mut Frame<'_, B>, area: Rect, state: &State<'_>) {
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints( .constraints(
vec![ vec![
Constraint::Percentage(100 / u16::try_from(state.project.columns.len()).unwrap()); Constraint::Percentage(100 / u16::try_from(state.columns.len()).unwrap_or(4));
state.project.columns.len() state.columns.len()
] ]
.as_ref(), .as_ref(),
) )
.split(area); .split(area);
for (i, column) in state.project.columns.iter().enumerate() { for (i, column) in state.columns.iter().enumerate() {
let items: Vec<ListItem<'_>> = column let items: Vec<ListItem<'_>> = column
.tasks .tasks
.iter() .iter()
.enumerate() .enumerate()
.map(|(j, task)| { .map(|(j, task)| {
let mut style = Style::default(); let mut style = Style::default();
let col_idx = state.project.selected_column_idx; let col_idx = state.selected_column_idx;
let task_idx = state.project.get_selected_column().selected_task_idx; let task_idx = state.get_selected_column().selected_task_idx;
let item_txt; let mut span;
if i == col_idx && j == task_idx { if i == col_idx && j == task_idx {
style = style.fg(Color::White).add_modifier(Modifier::BOLD); style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
item_txt = format!("{} 👈", task.title); span = Span::raw(format!("{} 👈", task.title));
} else { } else {
style = style.fg(Color::White); span = Span::raw(&task.title);
item_txt = task.title.clone();
} }
let mut s = Span::raw(item_txt); span.style = style;
s.style = style; ListItem::new(vec![Spans::from(span)])
ListItem::new(vec![Spans::from(s)])
}) })
.collect(); .collect();
let mut style = Style::default(); let mut style = Style::default();
if i == state.project.selected_column_idx { if i == state.selected_column_idx {
style = style.fg(Color::Green); style = style.add_modifier(Modifier::REVERSED);
}; };
let mut s = Span::raw(column.name.as_str()); let mut s = Span::raw(column.name.as_str());
s.style = Style::default() let mods = Modifier::BOLD | Modifier::ITALIC | Modifier::UNDERLINED;
.add_modifier(Modifier::BOLD | Modifier::ITALIC | Modifier::UNDERLINED) s.style = Style::default().add_modifier(mods);
.fg(Color::White); let block = Block::default().title(s).borders(Borders::ALL);
let block = Block::default().style(style).title(s).borders(Borders::ALL); let inner_area = block.inner(columns[i]);
let list = List::new(items).block(block); let inner_block = Block::default().style(style);
let list = List::new(items).block(inner_block);
let mut list_state = ListState::default(); let mut list_state = ListState::default();
list_state.select(Some(column.selected_task_idx + 1)); list_state.select(Some(column.selected_task_idx + 1));
f.render_stateful_widget(list, columns[i], &mut list_state);
f.render_widget(block, columns[i]);
f.render_stateful_widget(list, inner_area, &mut list_state);
} }
} }
fn draw_task_info<B: Backend>(f: &mut Frame<'_, B>, area: Rect, state: &State<'_>) { fn draw_task_info<B: Backend>(f: &mut Frame<'_, B>, area: Rect, state: &State<'_>) {
let block = Block::default().title("TASK INFO").borders(Borders::ALL); let block = Block::default().title("TASK INFO").borders(Borders::ALL);
if let Some(task) = state.project.get_selected_column().get_selected_task() { if let Some(task) = state.get_selected_task() {
let p = Paragraph::new(task.description.as_str()) let p = Paragraph::new(task.description.as_str())
.block(block) .block(block)
.wrap(Wrap { trim: true }); .wrap(Wrap { trim: true });
@@ -95,16 +98,16 @@ fn centered_rect_for_popup(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
.split(popup_layout[1])[1] .split(popup_layout[1])[1]
} }
pub fn draw_task_popup<B: Backend>(f: &mut Frame<'_, B>, state: &mut State<'_>, popup_title: &str) { fn draw_task_popup<B: Backend>(f: &mut Frame<'_, B>, state: &mut State<'_>, popup_title: &str) {
let area = centered_rect_for_popup(45, 60, f.size()); let area = centered_rect_for_popup(45, 60, f.size());
let block = Block::default() let block = Block::default()
.title(popup_title) .title(popup_title)
.title_alignment(Alignment::Center) .title_alignment(Alignment::Center)
.borders(Borders::ALL); .borders(Borders::ALL);
f.render_widget(Clear, area);
f.render_widget(Paragraph::new("").block(block.clone()), area);
if let Some(task) = &mut state.task_edit_state {
let block_inner = block.inner(area); let block_inner = block.inner(area);
f.render_widget(Clear, area);
f.render_widget(Paragraph::new("").block(block), area);
if let Some(task) = &mut state.task_edit_state {
let layout = Layout::default() let layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints( .constraints(
@@ -130,30 +133,22 @@ pub fn draw_task_popup<B: Backend>(f: &mut Frame<'_, B>, state: &mut State<'_>,
) )
.split(layout[2]); .split(layout[2]);
let create_style; let (create_style, cancel_style, create_txt, cancel_txt) = match task.focus {
let create_txt; TaskEditFocus::ConfirmBtn => (
let cancel_style; Style::default().add_modifier(Modifier::BOLD),
let cancel_txt; Style::default(),
match task.focus { "[Confirm]",
TaskEditFocus::ConfirmBtn => { " Cancel ",
create_style = Style::default().fg(Color::Yellow); ),
cancel_style = Style::default(); TaskEditFocus::CancelBtn => (
create_txt = "[Confirm]"; Style::default(),
cancel_txt = " Cancel "; Style::default().add_modifier(Modifier::BOLD),
} " Confirm ",
TaskEditFocus::CancelBtn => { "[Cancel]",
create_style = Style::default(); ),
cancel_style = Style::default().fg(Color::Yellow); _ => (Style::default(), Style::default(), " Confirm ", " Cancel "),
create_txt = " Confirm "; };
cancel_txt = "[Cancel]";
}
_ => {
create_style = Style::default();
cancel_style = Style::default();
create_txt = " Confirm ";
cancel_txt = " Cancel ";
}
}
let create_btn = Paragraph::new(create_txt).style(create_style); let create_btn = Paragraph::new(create_txt).style(create_style);
let cancel_btn = Paragraph::new(cancel_txt).style(cancel_style); let cancel_btn = Paragraph::new(cancel_txt).style(cancel_style);
f.render_widget(create_btn, buttons[1]); f.render_widget(create_btn, buttons[1]);
@@ -167,7 +162,8 @@ pub fn draw_task_popup<B: Backend>(f: &mut Frame<'_, B>, state: &mut State<'_>,
task.title.set_block(b1); task.title.set_block(b1);
if let TaskEditFocus::Title = task.focus { if let TaskEditFocus::Title = task.focus {
task.title.set_style(Style::default().fg(Color::Yellow)); task.title
.set_style(Style::default().add_modifier(Modifier::BOLD));
task.title task.title
.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
} else { } else {
@@ -179,7 +175,7 @@ pub fn draw_task_popup<B: Backend>(f: &mut Frame<'_, B>, state: &mut State<'_>,
task.description.set_block(b2); task.description.set_block(b2);
if let TaskEditFocus::Description = task.focus { if let TaskEditFocus::Description = task.focus {
task.description task.description
.set_style(Style::default().fg(Color::Yellow)); .set_style(Style::default().add_modifier(Modifier::BOLD));
task.description task.description
.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
} else { } else {
@@ -193,38 +189,74 @@ pub fn draw_task_popup<B: Backend>(f: &mut Frame<'_, B>, state: &mut State<'_>,
} }
} }
/// Macro to generate keybindings string at compile time #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
fn draw_project_stats<B: Backend>(f: &mut Frame<'_, B>, area: Rect, state: &mut State<'_>) {
let block = Block::default()
.title("PROJECT STATS")
.borders(Borders::ALL);
let c1_len = state.columns[0].tasks.len();
let c2_len = state.columns[1].tasks.len();
let c3_len = state.columns[2].tasks.len();
let c4_len = state.columns[3].tasks.len();
let tocomplete_total = c1_len + c2_len + c3_len;
let percentage = (c3_len as f32 / tocomplete_total as f32 * 100.0) as i8;
let list = List::new(vec![ListItem::new(vec![
Spans::from("Tasks per Column:"),
Spans::from(format!(" Todo ({c1_len})")),
Spans::from(format!(" In Progress ({c2_len})")),
Spans::from(format!(" Done ({c3_len})")),
Spans::from(format!(" Ideas ({c4_len})")),
Spans::from(format!("Progress: {c3_len} / {tocomplete_total} - {percentage}%")),
])])
.block(block);
f.render_widget(list, area);
}
/// Macro to generate the app's keybindings string at compile time
macro_rules! unroll { macro_rules! unroll {
(($first_a:literal, $first_b:literal), $(($a:literal, $b:literal)),*) => { (($first_a:literal, $first_b:literal), $(($a:literal, $b:literal)),*) => {
concat!(concat!($first_a, ": ", $first_b) $(," | ", concat!($a, ": ", $b))*) concat!(concat!($first_a, ": ", $first_b) $(," | ", concat!($a, ": ", $b))*)
}; };
} }
pub fn draw<B: Backend>(f: &mut Frame<'_, B>, state: &mut State<'_>) { /// Takes the app's [`State`] so [ratatui][`tui`] can render it to the
/// terminal screen
pub fn draw_ui_from_state<B: Backend>(f: &mut Frame<'_, B>, state: &mut State<'_>) {
let main_layout = Layout::default() let main_layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints( .constraints(
[ [
Constraint::Percentage(10), Constraint::Length(2),
Constraint::Percentage(65), Constraint::Min(10),
Constraint::Percentage(20), Constraint::Max(10),
Constraint::Length(3), Constraint::Length(2),
] ]
.as_ref(), .as_ref(),
) )
.split(f.size()); .split(f.size());
let block = Block::default().title("KANBAN BOARD").borders(Borders::ALL); let block = Block::default()
.title(format!("⎸ {} ⎹", state.project_name))
.title_alignment(Alignment::Center)
.borders(Borders::TOP);
f.render_widget(block, main_layout[0]); f.render_widget(block, main_layout[0]);
draw_tasks(f, main_layout[1], state); draw_tasks(f, main_layout[1], state);
draw_task_info(f, main_layout[2], state); let info_area = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Min(60), Constraint::Max(60)].as_ref())
.split(main_layout[2]);
draw_task_info(f, info_area[0], state);
draw_project_stats(f, info_area[1], state);
let block = Block::default().title("KEYBINDINGS").borders(Borders::TOP); let block = Block::default().title("KEYBINDINGS").borders(Borders::TOP);
let foot_txt = unroll![ let foot_txt = unroll![
("quit", "c"), ("quit", "q"),
("navigation", "hjkl"), ("navigation", "hjkl"),
("move task", "HJKL"), ("move task", "HJKL"),
("new task", "n"), ("new task", "n"),
+320
View File
@@ -0,0 +1,320 @@
#[cfg(test)]
mod app_tests {
use anyhow::{Error};
use kanban_tui::State;
use rusqlite::Connection;
fn create_connection() -> Result<Connection, Error> {
let mut conn = Connection::open_in_memory()?;
let migrations = include_str!("../sql/migrations.sql");
let migrations: Vec<&str> = migrations.split(';').collect();
let tx = conn.transaction()?;
for m in migrations {
if !m.trim().is_empty() {
tx.execute(m, ())?;
}
}
tx.commit()?;
Ok(conn)
}
#[test]
fn it_adds_tasks_to_different_columns() -> Result<(), Error> {
let mut state = State::new(create_connection()?)?;
state.add_new_task(String::from("T1"), String::from("D1"))?;
state.add_new_task(String::from("T2"), String::from("D2"))?;
state.select_column_right()?;
state.select_column_right()?;
state.add_new_task(String::from("T3"), String::from("D3"))?;
state.select_column_left()?;
state.add_new_task(String::from("T4"), String::from("D4"))?;
assert_eq!(state.columns[0].tasks.len(), 2);
assert_eq!(state.columns[1].tasks.len(), 1);
assert_eq!(state.columns[2].tasks.len(), 1);
assert_eq!(&state.columns[0].tasks[0].title, "T1");
assert_eq!(&state.columns[0].tasks[1].description, "D2");
assert_eq!(&state.columns[1].tasks[0].title, "T4");
assert_eq!(&state.columns[2].tasks[0].description, "D3");
// Reload the data from the database then rerun the asserts to
// make sure everything was saved correctly
let state = State::new(state.db_conn.0)?;
assert_eq!(state.columns[0].tasks.len(), 2);
assert_eq!(state.columns[1].tasks.len(), 1);
assert_eq!(state.columns[2].tasks.len(), 1);
assert_eq!(&state.columns[0].tasks[0].title, "T1");
assert_eq!(&state.columns[0].tasks[1].description, "D2");
assert_eq!(&state.columns[1].tasks[0].title, "T4");
assert_eq!(&state.columns[2].tasks[0].description, "D3");
Ok(())
}
#[test]
fn it_selects_the_correct_tasks_and_stays_inbound() -> Result<(), Error> {
let mut state = State::new(create_connection()?)?;
state.move_task_up()?;
state.move_task_up()?;
state.move_task_down()?;
state.move_task_down()?;
for _ in 0..10 {
state.select_column_right()?;
}
for _ in 0..10 {
state.select_column_left()?;
}
state.add_new_task(String::from("T1"), String::from("D1"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.add_new_task(String::from("T2"), String::from("D2"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
for _ in 0..6 {
state.select_column_right()?;
}
state.select_column_left()?;
state.add_new_task(String::from("T3"), String::from("D3"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
assert_eq!(state.get_selected_column().name, "Done");
for _ in 0..6 {
state.select_column_right()?;
}
for _ in 0..4 {
state.select_column_left()?;
}
assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_column_right()?;
state.select_column_right()?;
// Reload the data from the database then rerun the asserts to
// make sure everything was saved correctly
let mut state = State::new(state.db_conn.0)?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_task_below()?;
state.select_task_below()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_column_left()?;
state.select_column_left()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_task_below()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2");
Ok(())
}
#[test]
fn it_selects_the_correct_first_and_last_task() -> Result<(), Error> {
let mut state = State::new(create_connection()?)?;
for i in 1..11 {
state.add_new_task(format!("T{i}"), format!("D{i}"))?;
}
assert_eq!(state.get_selected_task().unwrap().title, "T10");
state.select_last_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T10");
state.select_first_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
for _ in 0..10 {
state.move_task_down()?;
}
assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_first_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.select_last_task()?;
state.select_task_above()?;
state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T9");
for _ in 0..10 {
state.move_task_up()?;
}
state.select_first_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T9");
state.select_last_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
// Reload the data from the database then rerun the asserts to
// make sure everything was saved correctly
let mut state = State::new(state.db_conn.0)?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_last_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_first_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T9");
Ok(())
}
#[test]
fn it_moves_tasks_up_and_down_and_to_different_columns() -> Result<(), Error> {
let mut state = State::new(create_connection()?)?;
state.add_new_task(String::from("T1"), String::from("D1"))?;
state.add_new_task(String::from("T2"), String::from("D2"))?;
state.select_task_above()?;
state.move_task_up()?;
state.move_task_down()?;
state.move_task_down()?;
assert_eq!(&state.columns[0].tasks[1].title, "T1");
assert_eq!(&state.columns[0].tasks[0].title, "T2");
state.select_column_right()?;
state.add_new_task(String::from("T3"), String::from("D3"))?;
state.move_task_column_right()?;
assert_eq!(state.columns[1].tasks.len(), 0);
assert_eq!(state.columns[2].tasks.len(), 1);
for _ in 0..5 {
state.move_task_column_right()?;
}
for _ in 0..4 {
state.move_task_column_left()?;
}
assert_eq!(state.columns[0].tasks.len(), 3);
assert_eq!(state.columns[1].tasks.len(), 0);
assert_eq!(state.columns[2].tasks.len(), 0);
assert_eq!(state.columns[3].tasks.len(), 0);
assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_task_below()?;
state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.select_first_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.select_last_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_task_above()?;
// Reload the data from the database then rerun the asserts to
// make sure everything was saved correctly
let mut state = State::new(state.db_conn.0)?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_task_below()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_task_below()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_first_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.select_last_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
assert_eq!(state.columns[0].tasks.len(), 3);
assert_eq!(state.columns[1].tasks.len(), 0);
assert_eq!(state.columns[2].tasks.len(), 0);
assert_eq!(state.columns[3].tasks.len(), 0);
Ok(())
}
#[test]
fn it_edits_a_task_and_updates_it() -> Result<(), Error> {
let mut state = State::new(create_connection()?)?;
state.add_new_task(String::from("T1"), String::from("D1"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
assert_eq!(state.get_selected_task().unwrap().description, "D1");
state.edit_task(String::from("T1"), String::from("D2"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
assert_eq!(state.get_selected_task().unwrap().description, "D2");
state.edit_task(String::from("T2"), String::from("D1"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T2");
assert_eq!(state.get_selected_task().unwrap().description, "D1");
for _ in 0..4 {
state.move_task_column_right()?;
}
assert_eq!(state.get_selected_task().unwrap().title, "T2");
assert_eq!(state.get_selected_task().unwrap().description, "D1");
state.edit_task(String::from("T3"), String::from("D3"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
assert_eq!(state.get_selected_task().unwrap().description, "D3");
for _ in 0..4 {
state.move_task_column_left()?;
}
assert_eq!(state.get_selected_task().unwrap().title, "T3");
assert_eq!(state.get_selected_task().unwrap().description, "D3");
state.edit_task(String::from("T3"), String::from("D3"))?;
assert_eq!(state.columns[0].tasks.len(), 1);
assert_eq!(state.columns[1].tasks.len(), 0);
assert_eq!(state.columns[2].tasks.len(), 0);
assert_eq!(state.columns[3].tasks.len(), 0);
// Reload the data from the database then rerun the asserts to
// make sure everything was saved correctly
let state = State::new(state.db_conn.0)?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
assert_eq!(state.get_selected_task().unwrap().description, "D3");
Ok(())
}
#[test]
fn it_deletes_a_task() -> Result<(), Error> {
let mut state = State::new(create_connection()?)?;
state.add_new_task(String::from("T1"), String::from("D1"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
for col in state.columns.iter().skip(1) {
assert_eq!(col.tasks.len(), 0);
}
state.delete_task()?;
assert!(state.get_selected_task().is_none());
for col in &state.columns {
assert_eq!(col.tasks.len(), 0);
}
state.delete_task()?;
state.add_new_task(String::from("T1"), String::from("D1"))?;
state.add_new_task(String::from("T2"), String::from("D2"))?;
state.add_new_task(String::from("T3"), String::from("D3"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.delete_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.add_new_task(String::from("T3"), String::from("D3"))?;
state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.delete_task()?;
state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_last_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.add_new_task(String::from("T2"), String::from("D2"))?;
for _ in 0..4 {
state.move_task_up()?;
}
state.delete_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_task_below()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3");
for _ in 0..4 {
state.delete_task()?;
}
assert!(state.get_selected_task().is_none());
for col in &state.columns {
assert_eq!(col.tasks.len(), 0);
}
// Reload the data from the database then rerun the asserts to
// make sure everything was saved correctly
let state = State::new(state.db_conn.0)?;
assert!(state.get_selected_task().is_none());
for col in state.columns {
assert_eq!(col.tasks.len(), 0);
}
Ok(())
}
}