Compare commits

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

No files matched your search

+1
View File
@@ -4,3 +4,4 @@
/kanban.json /kanban.json
/db.sqlite /db.sqlite
/kanban.db /kanban.db
/sql/queries.sql
Generated
+1 -459
View File
@@ -68,124 +68,6 @@ version = "1.0.71"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "async-attributes"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5"
dependencies = [
"quote",
"syn 1.0.109",
]
[[package]]
name = "async-channel"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf46fee83e5ccffc220104713af3292ff9bc7c64c7de289f66dae8e38d826833"
dependencies = [
"concurrent-queue",
"event-listener",
"futures-core",
]
[[package]]
name = "async-executor"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fa3dc5f2a8564f07759c008b9109dc0d39de92a88d5588b8a5036d286383afb"
dependencies = [
"async-lock",
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"slab",
]
[[package]]
name = "async-global-executor"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1b6f5d7df27bd294849f8eec66ecfc63d11814df7a4f5d74168a2394467b776"
dependencies = [
"async-channel",
"async-executor",
"async-io",
"async-lock",
"blocking",
"futures-lite",
"once_cell",
]
[[package]]
name = "async-io"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af"
dependencies = [
"async-lock",
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-lite",
"log",
"parking",
"polling",
"rustix",
"slab",
"socket2",
"waker-fn",
]
[[package]]
name = "async-lock"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa24f727524730b077666307f2734b4a1a1c57acb79193127dcc8914d5242dd7"
dependencies = [
"event-listener",
]
[[package]]
name = "async-std"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62565bb4402e926b29953c785397c6dc0391b7b446e45008b0049eb43cec6f5d"
dependencies = [
"async-attributes",
"async-channel",
"async-global-executor",
"async-io",
"async-lock",
"crossbeam-utils",
"futures-channel",
"futures-core",
"futures-io",
"futures-lite",
"gloo-timers",
"kv-log-macro",
"log",
"memchr",
"once_cell",
"pin-project-lite",
"pin-utils",
"slab",
"wasm-bindgen-futures",
]
[[package]]
name = "async-task"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc7ab41815b3c653ccd2978ec3255c81349336702dfdf62ee6f7069b12a3aae"
[[package]]
name = "atomic-waker"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1181e1e0d1fce796a03db1ae795d67167da795f9cf4a39c37589e85ef57f26d3"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.1.0" version = "1.1.0"
@@ -204,27 +86,6 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6776fc96284a0bb647b615056fc496d1fe1644a7ab01829818a6d91cae888b84" checksum = "6776fc96284a0bb647b615056fc496d1fe1644a7ab01829818a6d91cae888b84"
[[package]]
name = "blocking"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77231a1c8f801696fc0123ec6150ce92cffb8e164a02afb9c8ddee0e9b65ad65"
dependencies = [
"async-channel",
"async-lock",
"async-task",
"atomic-waker",
"fastrand",
"futures-lite",
"log",
]
[[package]]
name = "bumpalo"
version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"
[[package]] [[package]]
name = "cassowary" name = "cassowary"
version = "0.3.0" version = "0.3.0"
@@ -291,24 +152,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
[[package]]
name = "concurrent-queue"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62ec6771ecfa0762d24683ee5a32ad78487a3d3afdc0fb8cae19d2c5deb50b7c"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c063cd8cc95f5c377ed0d4b49a4b21f632396ff690e8470c29b3359b346984b"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.26.1" version = "0.26.1"
@@ -355,12 +198,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "event-listener"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.2.0" version = "0.2.0"
@@ -373,63 +210,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
dependencies = [
"instant",
]
[[package]]
name = "futures-channel"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c"
[[package]]
name = "futures-io"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964"
[[package]]
name = "futures-lite"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"memchr",
"parking",
"pin-project-lite",
"waker-fn",
]
[[package]]
name = "gloo-timers"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -476,15 +256,6 @@ dependencies = [
"hashbrown 0.12.3", "hashbrown 0.12.3",
] ]
[[package]]
name = "instant"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "int-enum" name = "int-enum"
version = "0.5.0" version = "0.5.0"
@@ -530,46 +301,18 @@ 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 = "js-sys"
version = "0.3.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f37a4a5928311ac501dee68b3c7613a1037d0edb30c8e5427bd832d55d1b790"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kanban-tui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-std",
"clap", "clap",
"crossterm", "crossterm",
"int-enum", "int-enum",
"ratatui", "ratatui",
"rusqlite", "rusqlite",
"serde",
"serde_json",
"thiserror",
"tui-textarea", "tui-textarea",
] ]
[[package]]
name = "kv-log-macro"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f"
dependencies = [
"log",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.145" version = "0.2.145"
@@ -608,9 +351,6 @@ name = "log"
version = "0.4.18" version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de"
dependencies = [
"value-bag",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
@@ -636,12 +376,6 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "parking"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14f2252c834a40ed9bb5422029649578e63aa341ac401f74e719dd1afda8394e"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.1" version = "0.12.1"
@@ -665,40 +399,12 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "pin-project-lite"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.27" version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964"
[[package]]
name = "polling"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce"
dependencies = [
"autocfg",
"bitflags 1.3.2",
"cfg-if",
"concurrent-queue",
"libc",
"log",
"pin-project-lite",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "1.3.1" version = "1.3.1"
@@ -777,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"
@@ -850,31 +519,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "slab"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.10.0" version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
[[package]]
name = "socket2"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662"
dependencies = [
"libc",
"winapi",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.10.0" version = "0.10.0"
@@ -903,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"
@@ -973,12 +603,6 @@ 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 = "value-bag"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4d330786735ea358f3bc09eea4caa098569c1c93f342d9aca0514915022fe7e"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@@ -991,94 +615,12 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "waker-fn"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.0+wasi-snapshot-preview1" version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
version = "0.2.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.18",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d1985d03709c53167ce907ff394f5316aa22cb4e12761295c5dc57dacb6297e"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93"
[[package]]
name = "web-sys"
version = "0.3.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bdd9ef4e984da1187bf8110c5cf5b845fbc87a23602cdf912386a76fcd3a7c2"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
+1 -5
View File
@@ -1,5 +1,5 @@
[package] [package]
name = "kanban-tui" name = "kanban_tui"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
@@ -9,11 +9,7 @@ edition = "2021"
tui = { package = "ratatui", version = "0.20.1" } tui = { package = "ratatui", version = "0.20.1" }
tui-textarea = { version = "0.2.0", git = "https://github.com/rhysd/tui-textarea.git", features = ["ratatui-crossterm"], default-features=false } tui-textarea = { version = "0.2.0", git = "https://github.com/rhysd/tui-textarea.git", features = ["ratatui-crossterm"], default-features=false }
crossterm = "0.26.1" crossterm = "0.26.1"
serde = { version = "1.0.148" , features = [ "derive" ] }
serde_json = "1.0.89"
int-enum = "0.5.0" int-enum = "0.5.0"
thiserror = "1"
anyhow = "1" anyhow = "1"
clap = { version = "4.3.2" , features = [ "derive" ] } clap = { version = "4.3.2" , features = [ "derive" ] }
rusqlite = { version = "0.29", features = [ "bundled" ] } rusqlite = { version = "0.29", features = [ "bundled" ] }
async-std = { version = "1", features = [ "attributes" ] }
+103
View File
@@ -0,0 +1,103 @@
# kanban-tui
## About
kanban-tui is a lightweight, minimalist, terminal-based [Kanban](https://en.wikipedia.org/wiki/Kanban_board) tool built in
Rust. Keybindings are vim-like, making it pretty fast to navigate and handle
tasks. Currently it's not that feature-rich, but it provides all the essential
functionality you need to manage tasks in the classic Kanban way.
It uses [ratatui](https://crates.io/crates/ratatui) for the TUI interface rendering and [rusqlite](https://crates.io/crates/rusqlite) for data
persistence. Both awesome crates to work with.
## Motivation
Decided to make kanban-tui to work on a non-trivial Rust application. I enjoy
TUIs for their simplicity and efficiency. I don't really plan on using this, as
I'm an [org](https://orgmode.org/) person myself, but if someone actually decides they like this level
of minimalism, then I might consider working on it more.
## Features
- Terminal-based: Enjoy the simplicity and speed of a text-based interface
- Vim-like keybindings: For those who have `hjkl` burned into their skull
- SQLite backed: Overkill maybe? At least I like SQL
- No bloat: Just create a task and move it around
## Showcase
![img](./assets/screen1.png)
[![asciicast](https://asciinema.org/a/QjEfh3kzvKMHTLRv2vWqr0mzX.svg)](https://asciinema.org/a/QjEfh3kzvKMHTLRv2vWqr0mzX)
## Building and Installing
To build and install kanban-tui, follow these steps:
- Install Rust: If you don't have Rust installed, you can get it from [here](https://www.rust-lang.org/tools/install).
- Clone this repository, cd into it
- Build the project: `cargo build --release`
- The built binary can be found in `target/release/kanban-tui`
- Optionally, you can move the binary to a directory in your `$PATH` for easier
access.
## Usage
It is recommended to run `kanban-tui` from the root of your project so it can
pick up the project's name. This automatically creates a SQLite database there
named `kanban.db`.
If you prefer a different name for the database file, you can specify the file
name when running the app, like `kanban-tui todos.kanban` as an example.
Once open, start creating tasks, then move them across columns to track their
status - from "To Do", through "In Progress", to "Done". All task and navigation
operations are performed through simple keybindings.
## Keybindings
- Quit: `q`
- Navigation: `hjkl`
- Move Task: `HJKL`
- New Task: `n`
- Edit Task: `e`
- Cycle Form Fields: `Tab`
- Column Top: `g`
- Column Bottom: `G`
## Potential Future Features
- Timestamps for tasks (created, moved, completed)
- Customizable column (name, amount, etc;)
- More project stats (velocity, avg time to complete)
- Column visibility toggle (hide "Ideas")
- Attach images (using [Kitty Term Image Protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/))
- Read from other backends (Trello, Jira, etc;)
- Copy task
- Reports
- Export to other formats
## Acknolwedgements
[Karrq](https://github.com/Karrq) was nice enough to provide some help from time to time and even
contributed a handy little macro.
## Contributions
While the project is primarily developed to practice Rust, if for some reason
you like this and want to contribute, open an issue and we can discuss whatever.
## License
kanban-tui is licensed under the terms of the MIT license. For more information,
see the included LICENSE file.
+66 -7
View File
@@ -1,12 +1,71 @@
#+OPTIONS: toc:nil #+OPTIONS: toc:nil
* About * kanban-tui
** About
kanban-tui is a lightweight, minimalist, terminal-based [[https://en.wikipedia.org/wiki/Kanban_board][Kanban]] tool built in
Rust. Keybindings are vim-like, making it pretty fast to navigate and handle
tasks. Currently it's not that feature-rich, but it provides all the essential
functionality you need to manage tasks in the classic Kanban way.
This ia a Rust powered TUI Kanban Board built using the [[https://github.com/fdehau/tui-rs][tui-rs]] library. It has It uses [[https://crates.io/crates/ratatui][ratatui]] for the TUI interface rendering and [[https://crates.io/crates/rusqlite][rusqlite]] for data
vim-friendly keybindings and while not the most featureful experience, it's fast persistence. Both awesome crates to work with.
and gets the job done. You can save your TODOs in your project's root as either ** Motivation
a json file or a SQLite database. Decided to make kanban-tui to work on a non-trivial Rust application. I enjoy
TUIs for their simplicity and efficiency. I don't really plan on using this, as
I'm an [[https://orgmode.org/][org]] person myself, but if someone actually decides they like this level
of minimalism, then I might consider working on it more.
** Features
- Terminal-based: Enjoy the simplicity and speed of a text-based interface
- Vim-like keybindings: For those who have ~hjkl~ burned into their skull
- SQLite backed: Overkill maybe? At least I like SQL
- No bloat: Just create a task and move it around
** Showcase
[[./assets/screen1.png]]
[[file:https://asciinema.org/a/QjEfh3kzvKMHTLRv2vWqr0mzX.svg][asciicast]]
** Building and Installing
To build and install kanban-tui, follow these steps:
- Install Rust: If you don't have Rust installed, you can get it from [[https://www.rust-lang.org/tools/install][here]].
- Clone this repository, cd into it
- Build the project: ~cargo build --release~
- The built binary can be found in ~target/release/kanban-tui~
- Optionally, you can move the binary to a directory in your ~$PATH~ for easier
access.
** Usage
It is recommended to run ~kanban-tui~ from the root of your project so it can
pick up the project's name. This automatically creates a SQLite database there
named ~kanban.db~.
* Installing If you prefer a different name for the database file, you can specify the file
name when running the app, like ~kanban-tui todos.kanban~ as an example.
Clone this repository, then simply run ~cargo build~ or ~cargo run~ Once open, start creating tasks, then move them across columns to track their
status - from "To Do", through "In Progress", to "Done". All task and navigation
operations are performed through simple keybindings.
** Keybindings
- Quit: ~q~
- Navigation: ~hjkl~
- Move Task: ~HJKL~
- New Task: ~n~
- Edit Task: ~e~
- Cycle Form Fields: ~Tab~
- Column Top: ~g~
- Column Bottom: ~G~
** Potential Future Features
- Timestamps for tasks (created, moved, completed)
- Customizable column (name, amount, etc;)
- More project stats (velocity, avg time to complete)
- Column visibility toggle (hide "Ideas")
- Attach images (using [[https://sw.kovidgoyal.net/kitty/graphics-protocol/][Kitty Term Image Protocol]])
- Read from other backends (Trello, Jira, etc;)
- Copy task
- Reports
- Export to other formats
** Acknolwedgements
[[https://github.com/Karrq][Karrq]] was nice enough to provide some help from time to time and even
contributed a handy little macro.
** Contributions
While the project is primarily developed to practice Rust, if for some reason
you like this and want to contribute, open an issue and we can discuss whatever.
** License
kanban-tui is licensed under the terms of the MIT license. For more information,
see the included LICENSE file.
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

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