Compare commits
43
Commits
e3ce612ca3
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26b1367820 | ||
|
|
2f98c968f0 | ||
|
|
63be792eee | ||
|
|
8a1ff4f3be | ||
|
|
6a1499d090 | ||
|
|
62e33a8a05 | ||
|
|
b8e0a0307e | ||
|
|
d3ea9fc160 | ||
|
|
6e91544cfe | ||
|
|
4beda4fae7 | ||
|
|
9a779b1abb | ||
|
|
514c600bb1 | ||
|
|
d23f458444 | ||
|
|
a98b82f3e4 | ||
|
|
a479b72a29 | ||
|
|
41778accdc | ||
|
|
f951e500f1 | ||
|
|
f5b39592a4 | ||
|
|
dba1e8a640 | ||
|
|
02ad7c3da6 | ||
|
|
940e07d72c | ||
|
|
a3e3fa6551 | ||
|
|
c00500a4e1 | ||
|
|
71f12ec7f9 | ||
|
|
4accf41c45 | ||
|
|
d85bb5a77b | ||
|
|
c2e3f0e70a | ||
|
|
4e0195e132 | ||
|
|
7c2249ed08 | ||
|
|
c030984d51 | ||
|
|
c3255c97f5 | ||
|
|
7fe20e6ae8 | ||
|
|
6cbba10325 | ||
|
|
293113949b | ||
|
|
d7eb3fb47a | ||
|
|
403572723c | ||
|
|
00ac0c351d | ||
|
|
4477bac258 | ||
|
|
076c87c792 | ||
|
|
8393ed610f | ||
|
|
535f332a81 | ||
|
|
a8e3a44816 | ||
|
|
5d3e69d679 |
No files matched your search
@@ -2,3 +2,6 @@
|
|||||||
/.idea/
|
/.idea/
|
||||||
/kanban-tui.json
|
/kanban-tui.json
|
||||||
/kanban.json
|
/kanban.json
|
||||||
|
/db.sqlite
|
||||||
|
/kanban.db
|
||||||
|
/sql/queries.sql
|
||||||
Generated
+98
-73
@@ -2,6 +2,17 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 3
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -69,6 +80,12 @@ version = "1.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6776fc96284a0bb647b615056fc496d1fe1644a7ab01829818a6d91cae888b84"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cassowary"
|
name = "cassowary"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -106,7 +123,7 @@ checksum = "72394f3339a76daf211e57d4bcb374410f3965dcc606dd0e03738c7888766980"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"bitflags",
|
"bitflags 1.3.2",
|
||||||
"clap_lex",
|
"clap_lex",
|
||||||
"strsim",
|
"strsim",
|
||||||
]
|
]
|
||||||
@@ -141,7 +158,7 @@ version = "0.26.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13"
|
checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 1.3.2",
|
||||||
"crossterm_winapi",
|
"crossterm_winapi",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
@@ -181,12 +198,42 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0761a1b9491c4f2e3d66aa0f62d0fba0af9a0e2852e4d48ea506632a4b56e6aa"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.13.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -206,7 +253,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
"hashbrown",
|
"hashbrown 0.12.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -254,13 +301,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "kanban_tui"
|
||||||
version = "1.0.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kanban-tui"
|
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -268,9 +309,7 @@ dependencies = [
|
|||||||
"crossterm",
|
"crossterm",
|
||||||
"int-enum",
|
"int-enum",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"rusqlite",
|
||||||
"serde_json",
|
|
||||||
"thiserror",
|
|
||||||
"tui-textarea",
|
"tui-textarea",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -280,6 +319,17 @@ version = "0.2.145"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc86cde3ff845662b8f4ef6cb50ea0e20c524eb3d29ae048287e06a1b3fa6a81"
|
checksum = "fc86cde3ff845662b8f4ef6cb50ea0e20c524eb3d29ae048287e06a1b3fa6a81"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
@@ -349,6 +399,12 @@ dependencies = [
|
|||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -383,7 +439,7 @@ version = "0.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dcc0d032bccba900ee32151ec0265667535c230169f5a011154cdcd984e16829"
|
checksum = "dcc0d032bccba900ee32151ec0265667535c230169f5a011154cdcd984e16829"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 1.3.2",
|
||||||
"cassowary",
|
"cassowary",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
@@ -396,7 +452,21 @@ version = "0.2.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
|
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 1.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.3.1",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -405,7 +475,7 @@ version = "0.37.19"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d"
|
checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 1.3.2",
|
||||||
"errno",
|
"errno",
|
||||||
"io-lifetimes",
|
"io-lifetimes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -413,49 +483,12 @@ dependencies = [
|
|||||||
"windows-sys 0.48.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ryu"
|
|
||||||
version = "1.0.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
|
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde"
|
|
||||||
version = "1.0.163"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2"
|
|
||||||
dependencies = [
|
|
||||||
"serde_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_derive"
|
|
||||||
version = "1.0.163"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.18",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_json"
|
|
||||||
version = "1.0.96"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1"
|
|
||||||
dependencies = [
|
|
||||||
"itoa",
|
|
||||||
"ryu",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook"
|
name = "signal-hook"
|
||||||
version = "0.3.15"
|
version = "0.3.15"
|
||||||
@@ -520,26 +553,6 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror"
|
|
||||||
version = "1.0.40"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac"
|
|
||||||
dependencies = [
|
|
||||||
"thiserror-impl",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror-impl"
|
|
||||||
version = "1.0.40"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.18",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -590,6 +603,18 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
|
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.0+wasi-snapshot-preview1"
|
version = "0.11.0+wasi-snapshot-preview1"
|
||||||
|
|||||||
+3
-5
@@ -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,9 +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"
|
||||||
clap = { version = "4.3.2" , features = [ "derive" ] }
|
clap = { version = "4.3.2" , features = [ "derive" ] }
|
||||||
|
rusqlite = { version = "0.29", features = [ "bundled" ] }
|
||||||
@@ -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
|
||||||
|
|
||||||
|

|
||||||
|
[](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
@@ -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 |
@@ -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");
|
||||||
+375
-194
@@ -1,58 +1,66 @@
|
|||||||
// 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,
|
||||||
pub is_edit: bool
|
/// Used to decide if the user is editing an existing task or
|
||||||
|
/// creating a new one
|
||||||
|
pub is_edit: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TaskState<'_> {
|
impl Default for TaskState<'_> {
|
||||||
@@ -61,202 +69,375 @@ impl Default for TaskState<'_> {
|
|||||||
title: TextArea::default(),
|
title: TextArea::default(),
|
||||||
description: TextArea::default(),
|
description: TextArea::default(),
|
||||||
focus: TaskEditFocus::Title,
|
focus: TaskEditFocus::Title,
|
||||||
is_edit: false
|
is_edit: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
|
||||||
quit: false,
|
|
||||||
task_edit_state: None,
|
|
||||||
project,
|
|
||||||
columns: vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Column {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(name: &str) -> Self {
|
|
||||||
Column {
|
|
||||||
name: name.to_owned(),
|
|
||||||
tasks: vec![],
|
|
||||||
selected_task_idx: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_task(&mut self, title: String, description: String) {
|
|
||||||
let task = Task { title, description };
|
|
||||||
self.tasks.push(task);
|
|
||||||
self.select_next_task();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_task(&mut self) {
|
|
||||||
self.tasks.remove(self.selected_task_idx);
|
|
||||||
self.select_next_task();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
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().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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn get_task_state_from_curr_selected_task(&self) -> Option<TaskState<'a>> {
|
|
||||||
self.get_selected_task().map(|t| {
|
|
||||||
TaskState {
|
|
||||||
title: TextArea::from(t.title.lines()),
|
|
||||||
description: TextArea::from(t.description.lines()),
|
|
||||||
focus: TaskEditFocus::Title,
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Will return `Err` if `file` contains json that doesn't match State schema
|
/// Returns an error if we can't read the database columns
|
||||||
pub fn load(path: String, mut file: &File) -> Result<Self, KanbanError> {
|
pub fn new(conn: Connection) -> Result<Self, Error> {
|
||||||
let mut json = String::new();
|
let db_conn = db::DBConn::new(conn);
|
||||||
file.read_to_string(&mut json)?;
|
let columns = db_conn.get_all_columns()?;
|
||||||
if json.trim().is_empty() {
|
let selected_column = db_conn.get_selected_column()?;
|
||||||
Ok(Project::new("", path))
|
|
||||||
} else {
|
let project_name = std::env::current_dir()?
|
||||||
Self::load_from_json(&json)
|
.file_name()
|
||||||
}
|
.and_then(std::ffi::OsStr::to_str)
|
||||||
}
|
.unwrap_or("KANBAN PROJECT")
|
||||||
|
.to_string();
|
||||||
/// # Panics
|
|
||||||
///
|
Ok(State {
|
||||||
/// Will panic if there's an error serializing the Json or there's an issue
|
project_name,
|
||||||
/// writing the file
|
columns,
|
||||||
pub fn save(&self) {
|
selected_column_idx: selected_column,
|
||||||
let json = serde_json::to_string_pretty(&self).unwrap();
|
quit: false,
|
||||||
std::fs::write(&self.filepath, json).unwrap();
|
task_edit_state: None,
|
||||||
|
db_conn,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the currently selected [`Column`].
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get_selected_column(&self) -> &Column {
|
pub fn get_selected_column(&self) -> &Column {
|
||||||
&self.columns[self.selected_column_idx]
|
&self.columns[self.selected_column_idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the currently selected
|
||||||
|
/// [`Column`].
|
||||||
pub fn get_selected_column_mut(&mut self) -> &mut Column {
|
pub fn get_selected_column_mut(&mut self) -> &mut Column {
|
||||||
&mut self.columns[self.selected_column_idx]
|
&mut self.columns[self.selected_column_idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn select_previous_column(&mut self) -> &Column {
|
/// Selects the [`Column`] on the left. Does nothing if on the
|
||||||
|
/// first column.
|
||||||
|
///
|
||||||
|
/// # 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.selected_column_idx = self.selected_column_idx.saturating_sub(1);
|
||||||
&self.columns[self.selected_column_idx]
|
self.db_conn.set_selected_column(self.selected_column_idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn select_next_column(&mut self) -> &Column {
|
/// Selects the [`Column`] on the right. Does nothing if on the
|
||||||
self.selected_column_idx = min(
|
/// last column.
|
||||||
self.selected_column_idx + 1,
|
///
|
||||||
self.columns.len() - 1,
|
/// # Errors
|
||||||
);
|
///
|
||||||
&self.columns[self.selected_column_idx]
|
/// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_task_to_column(&mut self, move_next: bool) {
|
/// Returns a reference to the currently selected [`Task`].
|
||||||
let col_idx = self.selected_column_idx;
|
/// Returns `None` if the current [`Column::tasks`] is empty.
|
||||||
let cols_len = self.columns.len();
|
#[must_use]
|
||||||
let column = self.get_selected_column_mut();
|
pub fn get_selected_task(&self) -> Option<&Task> {
|
||||||
let cond = if move_next {
|
let column = self.get_selected_column();
|
||||||
col_idx < cols_len - 1
|
column.tasks.get(column.selected_task_idx)
|
||||||
} else {
|
|
||||||
col_idx > 0
|
|
||||||
};
|
|
||||||
if cond && !column.tasks.is_empty() {
|
|
||||||
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 fn move_task_previous_column(&mut self) {
|
/// Returns a reference to the [`Task`] above the current one.
|
||||||
self.move_task_to_column(false);
|
/// Returns `None` if it's the first task on the list
|
||||||
}
|
#[must_use]
|
||||||
|
pub fn get_task_above(&self) -> Option<&Task> {
|
||||||
pub fn move_task_next_column(&mut self) {
|
let column = self.get_selected_column();
|
||||||
self.move_task_to_column(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_task_up(&mut self) {
|
|
||||||
let column = self.get_selected_column_mut();
|
|
||||||
if column.selected_task_idx > 0 {
|
if column.selected_task_idx > 0 {
|
||||||
column.tasks.swap(column.selected_task_idx, column.selected_task_idx - 1);
|
column.tasks.get(column.selected_task_idx - 1)
|
||||||
column.selected_task_idx -= 1;
|
} else {
|
||||||
self.save();
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_task_down(&mut self) {
|
/// 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();
|
let column = self.get_selected_column_mut();
|
||||||
if column.selected_task_idx < column.tasks.len() - 1 {
|
column.tasks.get_mut(column.selected_task_idx)
|
||||||
column.tasks.swap(column.selected_task_idx, column.selected_task_idx + 1);
|
}
|
||||||
column.selected_task_idx += 1;
|
|
||||||
self.save();
|
/// 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 {
|
||||||
|
title: TextArea::from(t.title.lines()),
|
||||||
|
description: TextArea::from(t.description.lines()),
|
||||||
|
focus: TaskEditFocus::Title,
|
||||||
|
is_edit: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the current [`Task`] up the list towards the top. Does
|
||||||
|
/// nothing if it's the first task.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// SQL related errors get bubbled up to here.
|
||||||
|
pub fn move_task_up(&mut self) -> Result<(), Error> {
|
||||||
|
self.move_task(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the current [`Task`] down the list towards the bottom. Does
|
||||||
|
/// nothing if it's the last task.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// SQL related errors get bubbled up to here.
|
||||||
|
pub fn move_task_down(&mut self) -> Result<(), Error> {
|
||||||
|
self.move_task(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private function to handle saving the current [`Task`]'s
|
||||||
|
/// state.
|
||||||
|
fn move_task(&mut self, is_down: bool) -> Result<(), Error> {
|
||||||
|
let other_task = if is_down {
|
||||||
|
self.get_task_below()
|
||||||
|
} else {
|
||||||
|
self.get_task_above()
|
||||||
|
};
|
||||||
|
if let (Some(task1), Some(task2)) = (self.get_selected_task(), other_task) {
|
||||||
|
let t1_id = task1.id;
|
||||||
|
let t2_id = task2.id;
|
||||||
|
let column = self.get_selected_column_mut();
|
||||||
|
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 {
|
||||||
|
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();
|
||||||
|
col.tasks.push(t);
|
||||||
|
self.select_last_task()?;
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts a new [`Task`] into [`Column::tasks`] at the bottom of
|
||||||
|
/// 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edits the selected [`Task`] changing only it's title and/or
|
||||||
|
/// 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 mut task_idx = column.selected_task_idx;
|
||||||
|
let col_id = column.id;
|
||||||
|
|
||||||
|
column.tasks.remove(task_idx);
|
||||||
|
|
||||||
|
if column.selected_task_idx >= column.tasks.len() {
|
||||||
|
self.select_task_above()?;
|
||||||
|
task_idx = task_idx.saturating_sub(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db_conn.delete_task(task_id)?;
|
||||||
|
self.db_conn
|
||||||
|
.set_selected_task_for_column(task_idx, col_id)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
// use super::*;
|
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-85
@@ -1,94 +1,102 @@
|
|||||||
|
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 crate::app::{TaskState, State, TaskEditFocus};
|
use int_enum::IntEnum;
|
||||||
|
|
||||||
|
pub fn cycle_focus(task: &mut TaskState<'_>, forward: bool) -> Result<(), Error> {
|
||||||
|
let cycle = if forward {
|
||||||
|
(task.focus.int_value() + 1) % EDIT_WINDOW_FOCUS_STATES
|
||||||
|
} else {
|
||||||
|
(task.focus.int_value() - 1) % EDIT_WINDOW_FOCUS_STATES
|
||||||
|
};
|
||||||
|
task.focus = TaskEditFocus::from_int(cycle)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_task_edit(state: &mut State<'_>, key: event::KeyEvent) -> Result<(), Error> {
|
||||||
|
// .take() the option so we can avoid borrow checker issues when
|
||||||
|
// we try to edit the task since that mutably borrows State, then
|
||||||
|
// assign later to task_edit_state
|
||||||
|
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, _) => {
|
||||||
|
cycle_focus(&mut task, false)?;
|
||||||
|
Some(task)
|
||||||
|
}
|
||||||
|
(KeyCode::Enter, TaskEditFocus::ConfirmBtn) => {
|
||||||
|
// The structure of this function is so we avoid an
|
||||||
|
// 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 {
|
||||||
|
state.edit_task(title, description)?;
|
||||||
|
} else {
|
||||||
|
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 {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
state.task_edit_state = updated_task;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::unit_arg)]
|
||||||
|
pub fn handle_main(state: &mut State<'_>, key: event::KeyEvent) -> Result<(), Error> {
|
||||||
|
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(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the app's [`State`] and uses [`event::read`] to get the current keypress.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Crossterm `event::read()` might return an error
|
/// Most of the applications errors will be bubbled up to this layer,
|
||||||
pub fn handle(state: &mut State<'_>) -> Result<(), std::io::Error> {
|
/// including all database related ones
|
||||||
let project = &mut state.project;
|
///
|
||||||
let column = project.get_selected_column_mut();
|
/// Crossterm `event::read()` might return an error,
|
||||||
|
pub fn handle_user_keypress(state: &mut State<'_>) -> Result<(), Error> {
|
||||||
if let Event::Key(key) = event::read()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
match &mut state.task_edit_state {
|
if state.task_edit_state.is_some() {
|
||||||
Some(task) => {
|
handle_task_edit(state, key)?;
|
||||||
// TODO: Extract this code to a separate function to avoid nesting
|
} else {
|
||||||
match task.focus {
|
handle_main(state, key)?;
|
||||||
// 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 {
|
|
||||||
KeyCode::Tab => task.focus = TaskEditFocus::ConfirmBtn,
|
|
||||||
KeyCode::BackTab => task.focus = TaskEditFocus::Title,
|
|
||||||
_ => { task.description.input(key); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TaskEditFocus::ConfirmBtn => {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Tab => task.focus = TaskEditFocus::CancelBtn,
|
|
||||||
KeyCode::BackTab => task.focus = TaskEditFocus::Description,
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let title = task.title.clone().into_lines().join("\n");
|
|
||||||
let description = task.description.clone().into_lines().join("\n");
|
|
||||||
if task.is_edit {
|
|
||||||
if let Some(selected_task) = column.get_selected_task_mut() {
|
|
||||||
selected_task.title = title;
|
|
||||||
selected_task.description = description;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
column.add_task(title, description);
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('q') => state.quit = true,
|
|
||||||
KeyCode::Char('h') |
|
|
||||||
KeyCode::Left => { project.select_previous_column(); },
|
|
||||||
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(),
|
|
||||||
KeyCode::Char('H') => project.move_task_previous_column(),
|
|
||||||
KeyCode::Char('L') => project.move_task_next_column(),
|
|
||||||
KeyCode::Char('J') => project.move_task_down(),
|
|
||||||
KeyCode::Char('K') => project.move_task_up(),
|
|
||||||
KeyCode::Char('n') => state.task_edit_state = Some(TaskState::default()),
|
|
||||||
KeyCode::Char('e') =>
|
|
||||||
state.task_edit_state = column.get_task_state_from_curr_selected_task(),
|
|
||||||
KeyCode::Char('D') => {
|
|
||||||
column.remove_task();
|
|
||||||
project.save();
|
|
||||||
},
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+19
-3
@@ -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 ui;
|
mod db;
|
||||||
mod input;
|
mod input;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
pub use app::*;
|
pub use app::*;
|
||||||
pub use ui::draw;
|
pub use db::*;
|
||||||
pub use input::handle;
|
pub use input::handle_user_keypress;
|
||||||
|
pub use ui::draw_ui_from_state;
|
||||||
+28
-80
@@ -1,96 +1,46 @@
|
|||||||
#![deny(rust_2018_idioms)]
|
#![deny(rust_2018_idioms)]
|
||||||
use kanban_tui::{Project, State};
|
use clap::{Parser, ValueHint::FilePath};
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{DisableMouseCapture, EnableMouseCapture},
|
event::{DisableMouseCapture, EnableMouseCapture},
|
||||||
terminal::{
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
disable_raw_mode,
|
|
||||||
enable_raw_mode,
|
|
||||||
EnterAlternateScreen,
|
|
||||||
LeaveAlternateScreen
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use std::{
|
|
||||||
io::{self, Write},
|
|
||||||
path::PathBuf,
|
|
||||||
fs::{File, OpenOptions},
|
|
||||||
error::Error
|
|
||||||
};
|
};
|
||||||
|
use kanban_tui::State;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::{error::Error, io, path::PathBuf};
|
||||||
use tui::backend::CrosstermBackend;
|
use tui::backend::CrosstermBackend;
|
||||||
use tui::Terminal;
|
use tui::Terminal;
|
||||||
use clap::{Parser, ValueHint::FilePath};
|
|
||||||
|
|
||||||
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
|
|
||||||
// "should_quit" thing instead of calling exit(0) here
|
|
||||||
fn prompt_project_init(default_name: &str) -> (String, io::Result<File>) {
|
|
||||||
let mut input = String::new();
|
|
||||||
|
|
||||||
println!("Database not found, select the name of the database if it exists or enter a name to start a new project");
|
|
||||||
print!("Database name (default: {default_name}): ");
|
|
||||||
io::stdout().flush().unwrap();
|
|
||||||
|
|
||||||
let result = io::stdin().read_line(&mut input);
|
|
||||||
let input = input.trim();
|
|
||||||
|
|
||||||
let filename =
|
|
||||||
match result {
|
|
||||||
Ok(b) if b == 0 => std::process::exit(0),
|
|
||||||
Ok(b) if b > 0 && !input.is_empty() => input,
|
|
||||||
_ => default_name
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: This might be a good time to prompt the user if they want
|
|
||||||
// to change the default column names
|
|
||||||
|
|
||||||
(filename.to_string(),
|
|
||||||
OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.read(true)
|
|
||||||
.create(true)
|
|
||||||
.open(filename))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> anyhow::Result<(), Box<dyn Error>> {
|
fn main() -> anyhow::Result<(), Box<dyn Error>> {
|
||||||
let (filepath, file) =
|
let dbpath = CliArgs::parse()
|
||||||
match CliArgs::parse() {
|
.filepath
|
||||||
CliArgs { filepath: Some(filepath) } => {
|
.unwrap_or(PathBuf::from("./kanban.db"));
|
||||||
let fpath = filepath.into_os_string().into_string().unwrap();
|
|
||||||
let file = OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.read(true)
|
|
||||||
.open(&fpath);
|
|
||||||
|
|
||||||
if let Ok(f) = file {
|
let migrate = !dbpath.exists();
|
||||||
(fpath, f)
|
|
||||||
} else {
|
let mut conn = Connection::open(dbpath)?;
|
||||||
let (fp, fname) = prompt_project_init(&fpath);
|
|
||||||
(fp, fname.unwrap())
|
if migrate {
|
||||||
}
|
let migrations = include_str!("../sql/migrations.sql");
|
||||||
},
|
let migrations: Vec<&str> = migrations.split(';').collect();
|
||||||
CliArgs { filepath: None } => {
|
let tx = conn.transaction()?;
|
||||||
let file = OpenOptions::new()
|
for m in migrations {
|
||||||
.write(true)
|
if !m.trim().is_empty() {
|
||||||
.read(true)
|
tx.execute(m, ())?;
|
||||||
.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 mut state = State::new(Project::load(filepath, &file)?);
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = State::new(conn)?;
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
@@ -99,12 +49,10 @@ 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!(
|
||||||
|
|||||||
@@ -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,53 +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.tasks
|
let items: Vec<ListItem<'_>> = column
|
||||||
|
.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 });
|
||||||
@@ -94,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);
|
||||||
|
let block_inner = block.inner(area);
|
||||||
f.render_widget(Clear, area);
|
f.render_widget(Clear, area);
|
||||||
f.render_widget(Paragraph::new("").block(block.clone()), area);
|
f.render_widget(Paragraph::new("").block(block), area);
|
||||||
if let Some(task) = &mut state.task_edit_state {
|
if let Some(task) = &mut state.task_edit_state {
|
||||||
let block_inner = block.inner(area);
|
|
||||||
let layout = Layout::default()
|
let layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints(
|
.constraints(
|
||||||
@@ -123,36 +127,28 @@ pub fn draw_task_popup<B: Backend>(f: &mut Frame<'_, B>, state: &mut State<'_>,
|
|||||||
[
|
[
|
||||||
Constraint::Percentage(80),
|
Constraint::Percentage(80),
|
||||||
Constraint::Min(10),
|
Constraint::Min(10),
|
||||||
Constraint::Min(10)
|
Constraint::Min(10),
|
||||||
]
|
]
|
||||||
.as_ref(),
|
.as_ref(),
|
||||||
)
|
)
|
||||||
.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]);
|
||||||
@@ -166,8 +162,10 @@ 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
|
||||||
task.title.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
.set_style(Style::default().add_modifier(Modifier::BOLD));
|
||||||
|
task.title
|
||||||
|
.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
} else {
|
} else {
|
||||||
task.title.set_style(Style::default());
|
task.title.set_style(Style::default());
|
||||||
task.title.set_cursor_style(Style::default());
|
task.title.set_cursor_style(Style::default());
|
||||||
@@ -176,8 +174,10 @@ 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.set_style(Style::default().fg(Color::Yellow));
|
task.description
|
||||||
task.description.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
.set_style(Style::default().add_modifier(Modifier::BOLD));
|
||||||
|
task.description
|
||||||
|
.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
} else {
|
} else {
|
||||||
task.description.set_style(Style::default());
|
task.description.set_style(Style::default());
|
||||||
task.description.set_cursor_style(Style::default());
|
task.description.set_cursor_style(Style::default());
|
||||||
@@ -189,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"),
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user