Compare commits

...
60 Commits
Author SHA1 Message Date
JosephFerano 26b1367820 Pedantic clippy pass 2023-06-19 07:55:46 +07:00
JosephFerano 2f98c968f0 Get rid of the mode, it's making github barf 2023-06-18 00:14:10 +07:00
JosephFerano 63be792eee Try with markdown 2023-06-18 00:12:31 +07:00
JosephFerano 8a1ff4f3be See if asciicast will work 2023-06-18 00:06:45 +07:00
JosephFerano 6a1499d090 Add an image 2023-06-17 23:46:08 +07:00
JosephFerano 62e33a8a05 Minor README improvements 2023-06-17 23:31:04 +07:00
JosephFerano b8e0a0307e Fix README backticks 2023-06-17 23:29:09 +07:00
JosephFerano d3ea9fc160 Improving the README further 2023-06-17 23:28:25 +07:00
JosephFerano 6e91544cfe Nicer README 2023-06-17 23:16:31 +07:00
JosephFerano 4beda4fae7 Add docs to everything public. Rename some methods.Remove unused deps 2023-06-17 22:47:54 +07:00
JosephFerano 9a779b1abb Bug: sort_order column was set to 0 when creating a task. Thank you tests 2023-06-17 16:16:22 +07:00
JosephFerano 514c600bb1 Integration tests covering 6 main scenarios
Covers
- Add
- Select
- Select Top/Bottom
- Move
- Edit
- Delete
2023-06-17 12:35:17 +07:00
JosephFerano d23f458444 Redesign UI, project stats panel, show project name, fixes and refactorings 2023-06-16 15:14:39 +07:00
JosephFerano a98b82f3e4 Get rid of some clones. Return Oks from all arm branches 2023-06-16 10:18:29 +07:00
JosephFerano a479b72a29 Refactor task edit by wrapping focus with int-enum, remove some .clone()'s 2023-06-15 20:44:25 +07:00
JosephFerano 41778accdc Random clean up 2023-06-15 19:21:01 +07:00
JosephFerano f951e500f1 Bubble up all db errors. Implement Deref and DerefMut for DBConn 2023-06-15 16:32:23 +07:00
JosephFerano f5b39592a4 Fix bug in moving and deleting a task. Remove some unwraps 2023-06-15 15:54:18 +07:00
JosephFerano dba1e8a640 DONE Refactoring methods: Dedup into single function and fix odds and ends 2023-06-15 14:06:05 +07:00
JosephFerano 02ad7c3da6 WIP Refactoring methods: Fix borrow checker issues 2023-06-15 12:06:50 +07:00
JosephFerano 940e07d72c WIP Refactoring methods: Move everything into app.rs 2023-06-15 11:53:28 +07:00
JosephFerano a3e3fa6551 WIP Refactoring methods: Got it to compile and it mostly works 2023-06-14 22:33:19 +07:00
JosephFerano c00500a4e1 WIP Refactoring methods: Move stuff from input to app 2023-06-14 21:53:38 +07:00
JosephFerano 71f12ec7f9 WIP Refactoring methods: Merge Project into State, add DBConn struct with impl 2023-06-14 19:13:24 +07:00
JosephFerano 4accf41c45 Fix colors and style, condense some logic. Fix task_idx when moving task 2023-06-14 17:38:23 +07:00
JosephFerano d85bb5a77b Remove async dep 2023-06-14 11:13:03 +07:00
JosephFerano c2e3f0e70a Save column/task selections in a few other options 2023-06-14 10:54:19 +07:00
JosephFerano 4e0195e132 Pedantic clippy 2023-06-13 23:00:28 +07:00
JosephFerano 7c2249ed08 Selected column and task now persist. Reworked the borrowing for task edit 2023-06-13 22:51:04 +07:00
JosephFerano c030984d51 Extract funcs from input-check to reduce nesting and make pedantic clippy happy 2023-06-13 16:32:46 +07:00
JosephFerano c3255c97f5 Pedantic clippy pass 2023-06-13 15:59:24 +07:00
JosephFerano 7fe20e6ae8 Run migrations on first creation, fix a bunch of panics 2023-06-13 15:50:16 +07:00
JosephFerano 6cbba10325 Ignore db path 2023-06-13 15:12:19 +07:00
JosephFerano 293113949b Simplified dbpath cli argument handling 2023-06-13 15:11:25 +07:00
JosephFerano d7eb3fb47a Save new sort order when moving tasks to different columns 2023-06-13 14:47:34 +07:00
JosephFerano 403572723c Formatting 2023-06-13 12:45:57 +07:00
JosephFerano 00ac0c351d Update and move task order up/down, read ids, more column helper methods 2023-06-13 12:41:37 +07:00
JosephFerano 4477bac258 Add/delete tasks, read task id from db 2023-06-12 22:25:58 +07:00
JosephFerano 076c87c792 WIP: Switch to rusqlite 2023-06-12 21:36:00 +07:00
JosephFerano 8393ed610f Read tasks from db on startup 2023-06-10 21:48:57 +07:00
JosephFerano 535f332a81 WIP: Fetch all tasks 2023-06-10 13:48:20 +07:00
JosephFerano a8e3a44816 Install sqlx, basic select 2023-06-10 11:12:23 +07:00
JosephFerano 5d3e69d679 rustfmt 2023-06-10 11:12:17 +07:00
JosephFerano e3ce612ca3 Use a macro to concat the keybindings at compile time 2023-06-10 10:11:33 +07:00
JosephFerano 09177ddbae Simplify keybindings and description. Add g and G movements. Remove -d flag 2023-06-09 15:49:52 +07:00
JosephFerano c6343a4805 Run and fix pedantic clippy and 2018 idioms 2023-06-09 13:26:29 +07:00
JosephFerano db92090916 Exit 0 if the user presses Ctrl+d when prompted for db name 2023-06-08 17:37:04 +07:00
JosephFerano 6e5dcebc2e Small cleanup 2023-06-08 17:30:47 +07:00
JosephFerano f081238e33 Prompt the user for a project name if it can't find the database file 2023-06-08 17:26:01 +07:00
JosephFerano bc81c05f1f Using clap to parse path to database 2023-06-08 15:54:39 +07:00
JosephFerano b2bf10f260 Scroll columns with a lot of tasks 2023-06-08 10:31:54 +07:00
JosephFerano 6a323d06c1 Create or open file. Save on add/remove/edit task 2023-06-07 23:34:40 +07:00
JosephFerano e7564f8f82 Take DB path from cli arg, marker emoji for currently selected task 2023-06-07 13:26:39 +07:00
JosephFerano 88990089c2 Move task state getter to column methods 2023-06-07 11:22:19 +07:00
JosephFerano 78cb7fe84a You can now edit an existing task 2023-06-06 21:59:18 +07:00
JosephFerano bef0836184 WIP: Edit an existing task 2023-06-06 21:38:36 +07:00
JosephFerano da10719250 Fix task edit border and rename some symbols 2023-06-06 21:03:19 +07:00
JosephFerano fc237b9f29 Remove task with D, move methods to column struct, select next/previous 2023-06-06 12:49:34 +07:00
JosephFerano be4fc3566d Buttons to create or cancel the task entry 2023-06-06 11:17:13 +07:00
JosephFerano 61feafaf7e Highlight which text area is currently selected 2023-06-05 11:44:59 +07:00
15 changed files with 1771 additions and 390 deletions

No files matched your search

+4
View File
@@ -1,3 +1,7 @@
/target /target
/.idea/ /.idea/
/kanban-tui.json /kanban-tui.json
/kanban.json
/db.sqlite
/kanban.db
/sql/queries.sql
Generated
+282 -65
View File
@@ -2,6 +2,66 @@
# 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]]
name = "anstream"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is-terminal",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d"
[[package]]
name = "anstyle-parse"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b"
dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "anstyle-wincon"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188"
dependencies = [
"anstyle",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.71" version = "1.0.71"
@@ -20,25 +80,85 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "cc"
version = "1.0.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.0" 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 = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clap"
version = "4.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "401a4694d2bf92537b6867d94de48c4842089645fdcdf6c71865b175d836e9c2"
dependencies = [
"clap_builder",
"clap_derive",
"once_cell",
]
[[package]]
name = "clap_builder"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72394f3339a76daf211e57d4bcb374410f3965dcc606dd0e03738c7888766980"
dependencies = [
"anstream",
"anstyle",
"bitflags 1.3.2",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8cd2b2a819ad6eec39e8f1d6b53001af1e5469f8c177579cdaeb313115b825f"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.18",
]
[[package]]
name = "clap_lex"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b"
[[package]]
name = "colorchoice"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.26.1" 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",
@@ -57,12 +177,75 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "errno"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a"
dependencies = [
"errno-dragonfly",
"libc",
"windows-sys 0.48.0",
]
[[package]]
name = "errno-dragonfly"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
dependencies = [
"cc",
"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]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "hermit-abi"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -70,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]]
@@ -95,22 +278,38 @@ dependencies = [
] ]
[[package]] [[package]]
name = "itoa" name = "io-lifetimes"
version = "1.0.6" version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "kanban-tui" name = "is-terminal"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f"
dependencies = [
"hermit-abi",
"io-lifetimes",
"rustix",
"windows-sys 0.48.0",
]
[[package]]
name = "kanban_tui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap",
"crossterm", "crossterm",
"int-enum", "int-enum",
"ratatui", "ratatui",
"serde", "rusqlite",
"serde_json",
"thiserror",
"tui-textarea", "tui-textarea",
] ]
@@ -120,6 +319,23 @@ 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]]
name = "linux-raw-sys"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.9" version = "0.4.9"
@@ -183,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"
@@ -217,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",
@@ -230,14 +452,36 @@ 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]] [[package]]
name = "ryu" name = "rusqlite"
version = "1.0.13" version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2"
dependencies = [
"bitflags 2.3.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustix"
version = "0.37.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d"
dependencies = [
"bitflags 1.3.2",
"errno",
"io-lifetimes",
"libc",
"linux-raw-sys",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
@@ -245,37 +489,6 @@ 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"
@@ -312,6 +525,12 @@ 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 = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@@ -334,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"
@@ -398,6 +597,24 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
[[package]]
name = "utf8parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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 -4
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,8 +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" ] }
rusqlite = { version = "0.29", features = [ "bundled" ] }
+103
View File
@@ -0,0 +1,103 @@
# kanban-tui
## About
kanban-tui is a lightweight, minimalist, terminal-based [Kanban](https://en.wikipedia.org/wiki/Kanban_board) tool built in
Rust. Keybindings are vim-like, making it pretty fast to navigate and handle
tasks. Currently it's not that feature-rich, but it provides all the essential
functionality you need to manage tasks in the classic Kanban way.
It uses [ratatui](https://crates.io/crates/ratatui) for the TUI interface rendering and [rusqlite](https://crates.io/crates/rusqlite) for data
persistence. Both awesome crates to work with.
## Motivation
Decided to make kanban-tui to work on a non-trivial Rust application. I enjoy
TUIs for their simplicity and efficiency. I don't really plan on using this, as
I'm an [org](https://orgmode.org/) person myself, but if someone actually decides they like this level
of minimalism, then I might consider working on it more.
## Features
- Terminal-based: Enjoy the simplicity and speed of a text-based interface
- Vim-like keybindings: For those who have `hjkl` burned into their skull
- SQLite backed: Overkill maybe? At least I like SQL
- No bloat: Just create a task and move it around
## Showcase
![img](./assets/screen1.png)
[![asciicast](https://asciinema.org/a/QjEfh3kzvKMHTLRv2vWqr0mzX.svg)](https://asciinema.org/a/QjEfh3kzvKMHTLRv2vWqr0mzX)
## Building and Installing
To build and install kanban-tui, follow these steps:
- Install Rust: If you don't have Rust installed, you can get it from [here](https://www.rust-lang.org/tools/install).
- Clone this repository, cd into it
- Build the project: `cargo build --release`
- The built binary can be found in `target/release/kanban-tui`
- Optionally, you can move the binary to a directory in your `$PATH` for easier
access.
## Usage
It is recommended to run `kanban-tui` from the root of your project so it can
pick up the project's name. This automatically creates a SQLite database there
named `kanban.db`.
If you prefer a different name for the database file, you can specify the file
name when running the app, like `kanban-tui todos.kanban` as an example.
Once open, start creating tasks, then move them across columns to track their
status - from "To Do", through "In Progress", to "Done". All task and navigation
operations are performed through simple keybindings.
## Keybindings
- Quit: `q`
- Navigation: `hjkl`
- Move Task: `HJKL`
- New Task: `n`
- Edit Task: `e`
- Cycle Form Fields: `Tab`
- Column Top: `g`
- Column Bottom: `G`
## Potential Future Features
- Timestamps for tasks (created, moved, completed)
- Customizable column (name, amount, etc;)
- More project stats (velocity, avg time to complete)
- Column visibility toggle (hide "Ideas")
- Attach images (using [Kitty Term Image Protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/))
- Read from other backends (Trello, Jira, etc;)
- Copy task
- Reports
- Export to other formats
## Acknolwedgements
[Karrq](https://github.com/Karrq) was nice enough to provide some help from time to time and even
contributed a handy little macro.
## Contributions
While the project is primarily developed to practice Rust, if for some reason
you like this and want to contribute, open an issue and we can discuss whatever.
## License
kanban-tui is licensed under the terms of the MIT license. For more information,
see the included LICENSE file.
+66 -7
View File
@@ -1,12 +1,71 @@
#+OPTIONS: toc:nil #+OPTIONS: toc:nil
* About * kanban-tui
** About
kanban-tui is a lightweight, minimalist, terminal-based [[https://en.wikipedia.org/wiki/Kanban_board][Kanban]] tool built in
Rust. Keybindings are vim-like, making it pretty fast to navigate and handle
tasks. Currently it's not that feature-rich, but it provides all the essential
functionality you need to manage tasks in the classic Kanban way.
This ia a Rust powered TUI Kanban Board built using the [[https://github.com/fdehau/tui-rs][tui-rs]] library. It has It uses [[https://crates.io/crates/ratatui][ratatui]] for the TUI interface rendering and [[https://crates.io/crates/rusqlite][rusqlite]] for data
vim-friendly keybindings and while not the most featureful experience, it's fast persistence. Both awesome crates to work with.
and gets the job done. You can save your TODOs in your project's root as either ** Motivation
a json file or a SQLite database. Decided to make kanban-tui to work on a non-trivial Rust application. I enjoy
TUIs for their simplicity and efficiency. I don't really plan on using this, as
I'm an [[https://orgmode.org/][org]] person myself, but if someone actually decides they like this level
of minimalism, then I might consider working on it more.
** Features
- Terminal-based: Enjoy the simplicity and speed of a text-based interface
- Vim-like keybindings: For those who have ~hjkl~ burned into their skull
- SQLite backed: Overkill maybe? At least I like SQL
- No bloat: Just create a task and move it around
** Showcase
[[./assets/screen1.png]]
[[file:https://asciinema.org/a/QjEfh3kzvKMHTLRv2vWqr0mzX.svg][asciicast]]
** Building and Installing
To build and install kanban-tui, follow these steps:
- Install Rust: If you don't have Rust installed, you can get it from [[https://www.rust-lang.org/tools/install][here]].
- Clone this repository, cd into it
- Build the project: ~cargo build --release~
- The built binary can be found in ~target/release/kanban-tui~
- Optionally, you can move the binary to a directory in your ~$PATH~ for easier
access.
** Usage
It is recommended to run ~kanban-tui~ from the root of your project so it can
pick up the project's name. This automatically creates a SQLite database there
named ~kanban.db~.
* Installing If you prefer a different name for the database file, you can specify the file
name when running the app, like ~kanban-tui todos.kanban~ as an example.
Clone this repository, then simply run ~cargo build~ or ~cargo run~ Once open, start creating tasks, then move them across columns to track their
status - from "To Do", through "In Progress", to "Done". All task and navigation
operations are performed through simple keybindings.
** Keybindings
- Quit: ~q~
- Navigation: ~hjkl~
- Move Task: ~HJKL~
- New Task: ~n~
- Edit Task: ~e~
- Cycle Form Fields: ~Tab~
- Column Top: ~g~
- Column Bottom: ~G~
** Potential Future Features
- Timestamps for tasks (created, moved, completed)
- Customizable column (name, amount, etc;)
- More project stats (velocity, avg time to complete)
- Column visibility toggle (hide "Ideas")
- Attach images (using [[https://sw.kovidgoyal.net/kitty/graphics-protocol/][Kitty Term Image Protocol]])
- Read from other backends (Trello, Jira, etc;)
- Copy task
- Reports
- Export to other formats
** Acknolwedgements
[[https://github.com/Karrq][Karrq]] was nice enough to provide some help from time to time and even
contributed a handy little macro.
** Contributions
While the project is primarily developed to practice Rust, if for some reason
you like this and want to contribute, open an issue and we can discuss whatever.
** License
kanban-tui is licensed under the terms of the MIT license. For more information,
see the included LICENSE file.
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

+28
View File
@@ -0,0 +1,28 @@
PRAGMA foreign_keys = ON;
create table if not exists task
(
id integer primary key autoincrement,
title text not null,
description text not null,
sort_order integer not null,
column_id integer,
foreign key (column_id) references kb_column(id)
);
create table if not exists kb_column
(
id integer primary key autoincrement,
name text not null,
selected_task integer not null default 0
);
create table if not exists app_state
(
key text not null primary key,
value text not null
);
insert into kb_column(name) values ("Todo"),("InProgress"),("Done"),("Ideas");
insert into app_state(key, value) values ("selected_column", "0");
+381 -168
View File
@@ -1,230 +1,443 @@
// 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 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(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,
} }
impl Default for Task { /// The number of `TaskEditFocus` variants, used so we can "wrap around"
fn default() -> Self { /// with modulo when cycling through tasks with Tab/Backtab.
Task { pub const EDIT_WINDOW_FOCUS_STATES: i8 = 4;
title: String::new(),
description: String::new(), /// Used to track the focus of the form field in the task edit window.
} #[repr(i8)]
} #[derive(Debug, IntEnum, Copy, Clone)]
pub enum TaskEditFocus {
/// Title text input line
Title = 0,
/// Description text input box
Description = 1,
/// Confirm changes button
ConfirmBtn = 2,
/// Cancel changes button
CancelBtn = 3,
} }
/// Type used mainly for serialization at this time /// Represents the transient state of a task while it is being editing
#[derive(Deserialize, Serialize, Debug)] /// by the user through the UI.
pub struct Project { pub struct TaskState<'a> {
pub name: String, /// The title of the Task
pub selected_column_idx: usize,
pub columns: Vec<Column>
}
#[derive(Debug, thiserror::Error)]
pub enum KanbanError {
#[error("There is something wrong with the json schema, it doesn't match Project struct")]
BadJson,
#[error("Some form of IO error occured: {0}")]
Io(#[from] std::io::Error),
}
impl Default for Project {
fn default() -> Self {
Project {
name: String::new(),
columns: vec![],
selected_column_idx: 0,
}
}
}
#[derive(Debug)]
pub enum NewTaskFocus {
Title,
Description,
Buttons
}
pub struct NewTask<'a> {
pub title: TextArea<'a>, pub title: TextArea<'a>,
/// The description of the Task
pub description: TextArea<'a>, pub description: TextArea<'a>,
pub focus: NewTaskFocus /// Where the current focus of the task edit form is
pub focus: TaskEditFocus,
/// Used to decide if the user is editing an existing task or
/// creating a new one
pub is_edit: bool,
} }
impl Default for NewTask<'_> { impl Default for TaskState<'_> {
fn default() -> Self { fn default() -> Self {
NewTask { TaskState {
title: TextArea::default(), title: TextArea::default(),
description: TextArea::default(), description: TextArea::default(),
focus: NewTaskFocus::Title focus: TaskEditFocus::Title,
is_edit: false,
} }
} }
} }
pub struct AppState<'a> { /// Holds the application's state, including all columns and the
pub project: Project, /// database connection.
pub quit: bool, pub struct State<'a> {
/// The name of the project, currently derived from the name of
/// 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>,
pub new_task_state: Option<NewTask<'a>>, /// 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>>,
} }
impl AppState<'_> { impl<'a> State<'a> {
pub fn new(project: Project) -> Self { /// Creates a new [`State`].
AppState { ///
/// # 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,
new_task_state: None, task_edit_state: None,
project, db_conn,
columns: vec![], })
}
}
}
impl Column {
pub fn new(name: &str) -> Self {
Column {
name: name.to_owned(),
tasks: vec![],
selected_task_idx: 0,
}
}
pub fn get_selected_task(&self) -> Option<&Task> {
self.tasks.get(self.selected_task_idx)
}
pub fn get_selected_task_mut(&mut self) -> Option<&mut Task> {
self.tasks.get_mut(self.selected_task_idx)
}
pub fn select_previous_task(&mut self) {
let task_idx = &mut self.selected_task_idx;
*task_idx = task_idx.saturating_sub(1)
}
pub fn select_next_task(&mut self) {
let task_idx = &mut self.selected_task_idx;
*task_idx = min(*task_idx + 1, self.tasks.len() - 1)
}
pub fn select_last_task(&mut self) {
self.selected_task_idx = self.tasks.len() - 1;
}
}
impl Project {
pub fn new(name: &str) -> Self {
Project {
name: name.to_owned(),
columns: vec![],
selected_column_idx: 0,
}
}
fn load_from_json(json: &str) -> Result<Self, KanbanError> {
serde_json::from_str(json).map_err(|_| KanbanError::BadJson)
}
pub fn load() -> Result<Self, KanbanError> {
let json = std::fs::read_to_string("kanban-tui.json")?;
Self::load_from_json(&json)
}
// pub fn add_task(&mut self, status: Column, task: Task) {
// self.tasks_per_column.entry(status).or_default().push(task);
// }
pub fn save(&self) {
let json = serde_json::to_string_pretty(&self).unwrap();
std::fs::write("kanban-tui.json", json).unwrap();
} }
/// Returns a reference to the currently selected [`Column`].
#[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]
pub fn get_selected_task(&self) -> Option<&Task> {
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]
pub fn get_task_above(&self) -> Option<&Task> {
let column = self.get_selected_column();
if column.selected_task_idx > 0 {
column.tasks.get(column.selected_task_idx - 1)
} else {
None
}
}
/// Returns a reference to the [`Task`] below the current one.
/// Returns `None` if it's the last task on the list
#[must_use]
pub fn get_task_below(&self) -> Option<&Task> {
let column = self.get_selected_column();
column.tasks.get(column.selected_task_idx + 1)
}
/// Returns a mutable reference to the currently selected
/// [`Task`]. Returns `None` if the current [`Column::tasks`] is
/// empty.
pub fn get_selected_task_mut(&mut self) -> Option<&mut Task> {
let column = self.get_selected_column_mut(); let column = self.get_selected_column_mut();
let cond = if move_next { column.tasks.get_mut(column.selected_task_idx)
col_idx < cols_len - 1
} else {
col_idx > 0
};
if cond && column.tasks.len() > 0 {
let t = column.tasks.remove(column.selected_task_idx);
column.select_previous_task();
if move_next {
self.select_next_column();
} else {
self.select_previous_column();
}
let col = self.get_selected_column_mut();
col.tasks.push(t);
col.select_last_task();
self.save();
}
} }
pub fn move_task_previous_column(&mut self) { /// 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) self.move_task_to_column(false)
} }
pub fn move_task_next_column(&mut self) { /// 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) self.move_task_to_column(true)
} }
pub fn move_task_up(&mut self) { fn move_task_to_column(&mut self, move_right: bool) -> Result<(), Error> {
let column = self.get_selected_column_mut(); let can_move_right = move_right && self.selected_column_idx < self.columns.len() - 1;
if column.selected_task_idx > 0 { let can_move_left = !move_right && self.selected_column_idx > 0;
column.tasks.swap(column.selected_task_idx, column.selected_task_idx - 1);
column.selected_task_idx = column.selected_task_idx - 1; let first_col = self.get_selected_column_mut();
self.save(); 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()?;
} }
pub fn move_task_down(&mut self) { if move_right {
let column = self.get_selected_column_mut(); self.select_column_right()?;
if column.selected_task_idx < column.tasks.len() - 1 { } else {
column.tasks.swap(column.selected_task_idx, column.selected_task_idx + 1); self.select_column_left()?;
column.selected_task_idx = column.selected_task_idx + 1;
self.save();
} }
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
View File
@@ -1 +0,0 @@
use super::*;
+260
View File
@@ -0,0 +1,260 @@
use crate::{Column, Task};
use anyhow::Error;
use rusqlite::{params, Connection, Result};
use std::ops::{Deref, DerefMut};
/// Simple one field struct to wrap a [`rusqlite::Connection`] so we
/// can assign our own methods.
pub struct DBConn(
/// Unfortunately remains public for now so we can do integration tests
/// and reuse to simulate exiting and relaunching the app.
pub Connection
);
impl DerefMut for DBConn {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Deref for DBConn {
type Target = Connection;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DBConn {
pub fn new(conn: Connection) -> Self {
DBConn(conn)
}
/// Query tasks in a [`Column`] by using the column's [`Column::id`].
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn get_tasks_by_column(&self, column_id: i64) -> Result<Vec<Task>> {
let mut stmt = self.prepare(
r#"
select task.id, title, description from task
where column_id = ?1
order by sort_order
"#,
)?;
let mut tasks = Vec::new();
let rows = stmt.query_map([column_id], |row| {
Ok(Task {
id: row.get(0)?,
title: row.get(1)?,
description: row.get(2)?,
})
})?;
for row in rows {
tasks.push(row?);
}
Ok(tasks)
}
/// Uses [`get_tasks_by_column`][`DBConn::get_tasks_by_column`] over
/// a loop to get all [`Column`] populated with the vec of.
/// [`Task`]
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn get_all_columns(&self) -> Result<Vec<Column>> {
let mut stmt = self.prepare("select id, name, selected_task from kb_column")?;
let columns = stmt
.query_map((), |row| {
let id = row.get(0)?;
Ok(Column {
id,
tasks: self.get_tasks_by_column(id)?,
name: row.get(1)?,
selected_task_idx: row.get(2)?,
})
})?
.filter_map(Result::ok)
.collect();
Ok(columns)
}
/// Insert a new task into the DB given a title and description,
/// then return the [`Task`] with the ID provided by the DB.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn create_new_task(
&self,
title: String,
description: String,
column_id: i64,
) -> Result<Task> {
let mut stmt =
self.prepare(
"insert into task(title, description, column_id, sort_order)
values (?1, ?2, ?3,
(coalesce(1 +
(select sort_order from task
where column_id = ?3 order by sort_order desc limit 1),
0)))")?;
stmt.execute(params![title, description, column_id])?;
let id = self.last_insert_rowid();
Ok(Task {
id,
title,
description,
})
}
/// Deletes a [`Task`] given it's ID.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn delete_task(&self, task_id: i64) -> Result<()> {
let mut stmt = self.prepare("delete from task where id = ?1")?;
stmt.execute([task_id])?;
Ok(())
}
/// Updates an existing [`Task`]'s `title` and `description`.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn update_task_text(&self, task: &Task) -> Result<()> {
let mut stmt =
self.prepare("update task set title = ?2, description = ?3 where id = ?1")?;
stmt.execute((&task.id, &task.title, &task.description))?;
Ok(())
}
/// Moves a [`Task`] to the target [`Column`] and updates the sorting order.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn move_task_to_column(&self, task: &Task, target_column: &Column) -> Result<()> {
let mut stmt = self
.prepare(
"update task
set
column_id = ?2,
sort_order = coalesce(1 +
(select sort_order from task
where column_id = ?2 order by sort_order desc limit 1),
0)
where task.id = ?1",
)?;
stmt.execute((&task.id, &target_column.id))?;
self.set_selected_task_for_column(target_column.selected_task_idx, target_column.id)?;
Ok(())
}
/// This is a helper function in case we need to debug `sort_order`, because I ran into
/// a bug when I forgot to insert the `sort_order` when creating a task.
#[allow(dead_code)]
fn get_sort_order(&self) -> Result<Vec<(i32, String, usize)>> {
let mut stmt = self.prepare(
"select id,title,sort_order from task where column_id = 1")?;
let mut rows = stmt.query(())?;
let mut tasks = Vec::new();
while let Some(row) = rows.next()? {
tasks.push((row.get(0)?, row.get(1)?, row.get(2)?,));
}
Ok(tasks)
}
/// The order of a [`Task`] in a [`Column`] needs to be saved to
/// the DB because `SQLite` doesn't have a way to handle the
/// ordering the internal [`Vec<Task>`] has. This takes the
/// current sorting order of two tasks and swaps them.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn swap_task_order(&mut self, task1_id: i64, task2_id: i64) -> Result<()> {
let tx = self.transaction()?;
tx.execute(
"create temp table temp_order as select sort_order from task where id = ?1",
[&task1_id],
)?;
tx.execute(
"update task
set sort_order = (select sort_order from task where id = ?2)
where id = ?1",
(task1_id, task2_id),
)?;
tx.execute(
"update task set sort_order = (select sort_order from temp_order) where id = ?1",
[&task2_id],
)?;
tx.execute("drop table temp_order", ())?;
tx.commit()?;
Ok(())
}
/// Saves the currently selected column's index to `app_state` so
/// when the user reloads the project, they start on the
/// [`Column`] they were last on.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn set_selected_column(&self, column_id: usize) -> Result<(), Error> {
let mut stmt =
self.prepare("insert or replace into app_state(key, value) values (?1, ?2)")?;
stmt.execute((&"selected_column", column_id.to_string()))?;
Ok(())
}
/// Get's the user's last selected [`Column`] before exiting.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn get_selected_column(&self) -> Result<usize> {
let mut stmt = self.prepare("select value from app_state where key = ?1")?;
stmt.query_row(["selected_column"], |row| {
let value: String = row.get::<usize, String>(0)?;
value.parse::<usize>()
.map_err(|_| rusqlite::Error::InvalidQuery)
})
}
/// Saves the index currently selected [`Task`] in a [`Column`] so
/// when the user reloads the project, each column selects the has
/// the last selected task before switching to another column or
/// exiting the app.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn set_selected_task_for_column(&self, task_idx: usize, column_id: i64) -> Result<()> {
let mut stmt = self.prepare("update kb_column set selected_task = ?2 where id = ?1")?;
stmt.execute((column_id, task_idx))?;
Ok(())
}
/// Get's each [`Column`]'s 's last selected [`Task`] before
/// switching or exiting.
///
/// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn get_selected_task_for_column(&self, column_id: i32) -> Result<usize> {
let mut stmt = self.prepare("select selected_task from kb_column where key = ?1")?;
stmt.query_row([column_id], |row| row.get(0))
}
}
+92 -65
View File
@@ -1,75 +1,102 @@
#![allow(unused_imports)] 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::{NewTask, AppState, NewTaskFocus}; use int_enum::IntEnum;
use std::io::{stdout, Write};
use tui_textarea::TextArea;
pub fn handle_input(state: &mut AppState) -> Result<(), std::io::Error> { pub fn cycle_focus(task: &mut TaskState<'_>, forward: bool) -> Result<(), Error> {
let project = &mut state.project; let cycle = if forward {
let column = project.get_selected_column_mut(); (task.focus.int_value() + 1) % EDIT_WINDOW_FOCUS_STATES
if let Event::Key(key) = event::read()? { } else {
match &mut state.new_task_state { (task.focus.int_value() - 1) % EDIT_WINDOW_FOCUS_STATES
Some(task) => {
match task.focus {
// TODO: Handle wrapping around the enum rather than doing it manually
NewTaskFocus::Title => {
match key.code {
KeyCode::Tab => task.focus = NewTaskFocus::Description,
KeyCode::BackTab => task.focus = NewTaskFocus::Buttons,
_ => { task.title.input(key); }
}
}
NewTaskFocus::Description => {
match key.code {
KeyCode::Tab => task.focus = NewTaskFocus::Buttons,
KeyCode::BackTab => task.focus = NewTaskFocus::Title,
_ => { task.description.input(key); }
}
}
NewTaskFocus::Buttons => {
match key.code {
KeyCode::Tab => task.focus = NewTaskFocus::Title,
KeyCode::BackTab => task.focus = NewTaskFocus::Description,
KeyCode::Enter => {
// TODO: Need a function that clears state and adds a new TODO
// into the right column
state.new_task_state = None
}
_ => (),
}
}
}; };
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)
} }
None => { (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 { match key.code {
KeyCode::Char('q') => state.quit = true, KeyCode::Char('q') => Ok(state.quit = true),
KeyCode::Char('h') | KeyCode::Char('h') | KeyCode::Left => state.select_column_left(),
KeyCode::Left => { project.select_previous_column(); }, KeyCode::Char('j') | KeyCode::Down => state.select_task_below(),
KeyCode::Char('j') | KeyCode::Char('k') | KeyCode::Up => state.select_task_above(),
KeyCode::Down => column.select_next_task(), KeyCode::Char('l') | KeyCode::Right => state.select_column_right(),
KeyCode::Char('k') | KeyCode::Char('g') => state.select_first_task(),
KeyCode::Up => column.select_previous_task(), KeyCode::Char('G') => state.select_last_task(),
KeyCode::Char('l') | KeyCode::Char('H') => state.move_task_column_left(),
KeyCode::Right => { project.select_next_column(); }, KeyCode::Char('L') => state.move_task_column_right(),
KeyCode::Char('<') | KeyCode::Char('J') => state.move_task_down(),
KeyCode::Char('H') => project.move_task_previous_column(), KeyCode::Char('K') => state.move_task_up(),
KeyCode::Char('>') | KeyCode::Char('n') => Ok(state.task_edit_state = Some(TaskState::default())),
KeyCode::Char('L') => project.move_task_next_column(), KeyCode::Char('e') => Ok(state.task_edit_state = state.get_task_state_from_current()),
KeyCode::Char('=') | KeyCode::Char('D') => state.delete_task(),
KeyCode::Char('J') => project.move_task_down(), _ => Ok(()),
KeyCode::Char('-') |
KeyCode::Char('K') => project.move_task_up(),
KeyCode::Char('p') => {
match state.new_task_state {
None => state.new_task_state = Some(NewTask::default()),
Some(_) => state.new_task_state = None,
}
}
_ => {}
}
} }
}
/// Takes the app's [`State`] and uses [`event::read`] to get the current keypress.
///
/// # Errors
///
/// Most of the applications errors will be bubbled up to this layer,
/// including all database related ones
///
/// 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 state.task_edit_state.is_some() {
handle_task_edit(state, key)?;
} else {
handle_main(state, key)?;
} }
} }
Ok(()) Ok(())
+20 -3
View File
@@ -1,7 +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)]
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_input; pub use input::handle_user_keypress;
pub use ui::draw_ui_from_state;
+40 -12
View File
@@ -1,30 +1,58 @@
#![allow(unused_imports)] #![deny(rust_2018_idioms)]
#![allow(dead_code)] use clap::{Parser, ValueHint::FilePath};
use kanban_tui::*;
use crossterm::{ use crossterm::{
event::*, event::{DisableMouseCapture, EnableMouseCapture},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use std::io; 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;
fn main() -> anyhow::Result<()> { #[derive(Debug, Parser)]
#[command(name = "kanban")]
/// kanban-tui is a simple, interactive TUI based task manager using kanban columns
pub struct CliArgs {
#[arg(value_name="DATABASE", value_hint=FilePath, index=1)]
/// Path to the SQLite database
pub filepath: Option<PathBuf>,
}
fn main() -> anyhow::Result<(), Box<dyn Error>> {
let dbpath = CliArgs::parse()
.filepath
.unwrap_or(PathBuf::from("./kanban.db"));
let migrate = !dbpath.exists();
let mut conn = Connection::open(dbpath)?;
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();
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
let mut state = AppState::new(Project::load()?);
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_input(&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!(
+172 -65
View File
@@ -1,65 +1,74 @@
use crate::app::*; use crate::app::{State, TaskEditFocus};
use tui::backend::Backend; use tui::backend::Backend;
use tui::layout::*; 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::*; use tui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use tui::Frame; use tui::Frame;
fn draw_tasks<B: Backend>(f: &mut Frame<B>, area: &Rect, state: &AppState) { fn draw_tasks<B: Backend>(f: &mut Frame<'_, B>, area: Rect, state: &State<'_>) {
let columns = Layout::default() let columns = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints( .constraints(
vec![ vec![
Constraint::Percentage(100 / state.project.columns.len() as u16); 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 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);
span = Span::raw(format!("{} 👈", task.title));
} else { } else {
style = style.fg(Color::White); span = Span::raw(&task.title);
} }
let mut s = Span::raw(task.title.as_str()); 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(format!("{:?}", column.name)); 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);
f.render_widget(list, columns[i]) let list = List::new(items).block(inner_block);
let mut list_state = ListState::default();
list_state.select(Some(column.selected_task_idx + 1));
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: &AppState) { 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 });
f.render_widget(p, *area); f.render_widget(p, area);
} else { } else {
let p = Paragraph::new("No tasks for this column").block(block); let p = Paragraph::new("No tasks for this column").block(block);
f.render_widget(p, *area); f.render_widget(p, area);
} }
} }
@@ -89,80 +98,178 @@ 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_new_task_popup<B: Backend>(f: &mut Frame<B>, state: &mut AppState) { 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());
f.render_widget(Clear, area);
match &mut state.new_task_state {
None => {}
Some(task) => {
let block = Block::default() let block = Block::default()
.title("Add Task") .title(popup_title)
.title_alignment(Alignment::Center) .title_alignment(Alignment::Center)
.borders(Borders::ALL); .borders(Borders::ALL);
let block_inner = block.inner(area); let block_inner = block.inner(area);
// let main = Paragraph::new(task.description.clone()).block(block); 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(
[ [
Constraint::Length(3), Constraint::Length(3),
Constraint::Max(100), Constraint::Max(100),
Constraint::Length(1),
Constraint::Length(2), Constraint::Length(2),
] ]
.as_ref(), .as_ref(),
) )
.split(block_inner); .split(block_inner);
let buttons = Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage(80),
Constraint::Min(10),
Constraint::Min(10),
]
.as_ref(),
)
.split(layout[2]);
let (create_style, cancel_style, create_txt, cancel_txt) = match task.focus {
TaskEditFocus::ConfirmBtn => (
Style::default().add_modifier(Modifier::BOLD),
Style::default(),
"[Confirm]",
" Cancel ",
),
TaskEditFocus::CancelBtn => (
Style::default(),
Style::default().add_modifier(Modifier::BOLD),
" Confirm ",
"[Cancel]",
),
_ => (Style::default(), Style::default(), " Confirm ", " Cancel "),
};
let create_btn = Paragraph::new(create_txt).style(create_style);
let cancel_btn = Paragraph::new(cancel_txt).style(cancel_style);
f.render_widget(create_btn, buttons[1]);
f.render_widget(cancel_btn, buttons[2]);
let b1 = Block::default().title("Title").borders(Borders::ALL); let b1 = Block::default().title("Title").borders(Borders::ALL);
// let title = Paragraph::new("Hello I am text")
// .style(Style::default().fg(Color::Yellow))
// .block(b1);
let b2 = Block::default().title("Description").borders(Borders::ALL); let b2 = Block::default().title("Description").borders(Borders::ALL);
// let description = Paragraph::new("Fill this out")
// .style(Style::default().fg(Color::Yellow))
// .block(b2);
let b3 = Block::default().title("Keys").borders(Borders::TOP); let b3 = Block::default().title("Keys").borders(Borders::TOP);
let footer = Paragraph::new("p : Cancel").block(b3); task.title.set_cursor_line_style(Style::default());
// f.render_widget(main, area); task.description.set_cursor_line_style(Style::default());
// f.render_widget(title, layout[0]);
task.title.set_block(b1); task.title.set_block(b1);
f.render_widget(task.title.widget(), layout[0]); if let TaskEditFocus::Title = task.focus {
// f.render_widget(description, layout[1]); task.title
task.description.set_block(b2); .set_style(Style::default().add_modifier(Modifier::BOLD));
f.render_widget(task.description.widget(), layout[1]); task.title
f.render_widget(footer, layout[2]); .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
} else {
task.title.set_style(Style::default());
task.title.set_cursor_style(Style::default());
} }
f.render_widget(task.title.widget(), layout[0]);
task.description.set_block(b2);
if let TaskEditFocus::Description = task.focus {
task.description
.set_style(Style::default().add_modifier(Modifier::BOLD));
task.description
.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
} else {
task.description.set_style(Style::default());
task.description.set_cursor_style(Style::default());
}
f.render_widget(task.description.widget(), layout[1]);
let footer = Paragraph::new("Tab/Backtab : Cycle").block(b3);
f.render_widget(footer, layout[3]);
} }
} }
pub fn draw<B: Backend>(f: &mut Frame<B>, state: &mut AppState) { #[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 {
(($first_a:literal, $first_b:literal), $(($a:literal, $b:literal)),*) => {
concat!(concat!($first_a, ": ", $first_b) $(," | ", concat!($a, ": ", $b))*)
};
}
/// 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 = let foot_txt = unroll![
Paragraph::new("q : Quit | ⏪🔽🔼⏩ or hjkl : Navigation | < > or H L : Shift task left/right | = - or J K : Shift task up/down") ("quit", "q"),
.block(block); ("navigation", "hjkl"),
f.render_widget(foot_txt, main_layout[3]); ("move task", "HJKL"),
("new task", "n"),
("edit task", "e"),
("cycle edit fields", "Tab"),
("column top", "g"),
("column bottom", "G")
];
if state.new_task_state.is_some() { let footer = Paragraph::new(foot_txt).block(block);
draw_new_task_popup(f, state); f.render_widget(footer, main_layout[3]);
if state.task_edit_state.is_some() {
draw_task_popup(f, state, "Create Task");
} }
} }
+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(())
}
}