Compare commits

..
64 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
JosephFerano ef90532d12 WIP: TextArea form to add a new TODO item, switch to ratatui 2023-06-05 11:24:18 +07:00
JosephFerano 167f506291 NewTask state to manage adding a new task 2023-06-04 14:51:47 +07:00
JosephFerano c9a69c7abe Get rid of treesitter dep, add more to readme 2023-05-29 17:02:24 +07:00
JosephFerano fa17ca9688 No toc 2023-05-29 15:46:54 +07:00
16 changed files with 1970 additions and 442 deletions

No files matched your search

+4
View File
@@ -1,3 +1,7 @@
/target
/.idea/
/kanban-tui.json
/kanban.json
/db.sqlite
/kanban.db
/sql/queries.sql
Generated
+461 -163
View File
@@ -3,19 +3,70 @@
version = 3
[[package]]
name = "aho-corasick"
version = "0.7.20"
name = "ahash"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
dependencies = [
"memchr",
"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]]
name = "anyhow"
version = "1.0.66"
version = "1.0.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6"
checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "autocfg"
@@ -29,6 +80,12 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6776fc96284a0bb647b615056fc496d1fe1644a7ab01829818a6d91cae888b84"
[[package]]
name = "cassowary"
version = "0.3.0"
@@ -37,9 +94,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "cc"
version = "1.0.78"
version = "1.0.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d"
checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f"
[[package]]
name = "cfg-if"
@@ -48,12 +105,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "crossterm"
version = "0.25.0"
name = "clap"
version = "4.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
checksum = "401a4694d2bf92537b6867d94de48c4842089645fdcdf6c71865b175d836e9c2"
dependencies = [
"bitflags",
"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]]
name = "crossterm"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13"
dependencies = [
"bitflags 1.3.2",
"crossterm_winapi",
"libc",
"mio",
@@ -72,6 +177,85 @@ dependencies = [
"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]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
]
[[package]]
name = "int-enum"
version = "0.5.0"
@@ -90,35 +274,67 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
"syn 1.0.109",
]
[[package]]
name = "itoa"
version = "1.0.4"
name = "io-lifetimes"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc"
checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.48.0",
]
[[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"
dependencies = [
"anyhow",
"cc",
"clap",
"crossterm",
"int-enum",
"serde",
"serde_json",
"thiserror",
"tree-sitter",
"tui",
"ratatui",
"rusqlite",
"tui-textarea",
]
[[package]]
name = "libc"
version = "0.2.137"
version = "0.2.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89"
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]]
name = "lock_api"
@@ -132,12 +348,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.17"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e"
dependencies = [
"cfg-if",
]
checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de"
[[package]]
name = "memchr"
@@ -147,21 +360,21 @@ checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "mio"
version = "0.8.5"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de"
checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys",
"windows-sys 0.48.0",
]
[[package]]
name = "once_cell"
version = "1.16.0"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "parking_lot"
@@ -175,77 +388,100 @@ dependencies = [
[[package]]
name = "parking_lot_core"
version = "0.9.5"
version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ff9f3fef3968a3ec5945535ed654cb38ff72d7495a25619e2247fb15a2ed9ba"
checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-sys",
"windows-sys 0.45.0",
]
[[package]]
name = "proc-macro-crate"
version = "1.2.1"
name = "pkg-config"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eda0fc3b0fb7c975631757e14d9049da17374063edb6ebbcbc54d880d4fe94e9"
checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964"
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
dependencies = [
"once_cell",
"thiserror",
"toml",
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.47"
version = "1.0.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725"
checksum = "6aeca18b86b413c660b781aa319e4e2648a3e6f9eadc9b47e9038e6fe9f3451b"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.21"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179"
checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ratatui"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcc0d032bccba900ee32151ec0265667535c230169f5a011154cdcd984e16829"
dependencies = [
"bitflags 1.3.2",
"cassowary",
"crossterm",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "redox_syscall"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
dependencies = [
"bitflags",
"bitflags 1.3.2",
]
[[package]]
name = "regex"
version = "1.7.0"
name = "rusqlite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a"
checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
"bitflags 2.3.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "regex-syntax"
version = "0.6.28"
name = "rustix"
version = "0.37.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848"
[[package]]
name = "ryu"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09"
checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d"
dependencies = [
"bitflags 1.3.2",
"errno",
"io-lifetimes",
"libc",
"linux-raw-sys",
"windows-sys 0.48.0",
]
[[package]]
name = "scopeguard"
@@ -253,42 +489,11 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "serde"
version = "1.0.148"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e53f64bb4ba0191d6d0676e1b141ca55047d83b74f5607e6d8eb88126c52c2dc"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.148"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a55492425aa53521babf6137309e7d34c20bbfbbfcfe2c7f3a047fd1f6b92c0c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020ff22c755c2ed3f8cf162dbb41a7268d934702f3ed3631656ea597e08fc3db"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "signal-hook"
version = "0.3.14"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d"
checksum = "732768f1176d21d09e076c23a93123d40bba92d50c4058da34d45c8de8e682b9"
dependencies = [
"libc",
"signal-hook-registry",
@@ -307,9 +512,9 @@ dependencies = [
[[package]]
name = "signal-hook-registry"
version = "1.4.0"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0"
checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
dependencies = [
"libc",
]
@@ -321,10 +526,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
[[package]]
name = "syn"
version = "1.0.105"
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b9b43d45702de4c839cb9b51d9f529c5dd26a4aff255b42b1ebc03e88ee908"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
@@ -332,68 +543,53 @@ dependencies = [
]
[[package]]
name = "thiserror"
version = "1.0.37"
name = "syn"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb"
checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"unicode-ident",
]
[[package]]
name = "toml"
version = "0.5.9"
name = "toml_datetime"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7"
checksum = "5a76a9312f5ba4c2dec6b9161fdf25d87ad8a09256ccea5a556fef03c706a10f"
[[package]]
name = "toml_edit"
version = "0.19.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2380d56e8670370eee6566b0bfd4265f65b3f432e8c6d85623f728d4fa31f739"
dependencies = [
"serde",
"indexmap",
"toml_datetime",
"winnow",
]
[[package]]
name = "tree-sitter"
version = "0.20.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4423c784fe11398ca91e505cdc71356b07b1a924fc8735cfab5333afe3e18bc"
name = "tui-textarea"
version = "0.2.0"
source = "git+https://github.com/rhysd/tui-textarea.git#525070f52ccd96866267b6dbcd7d096178ff5163"
dependencies = [
"cc",
"regex",
]
[[package]]
name = "tui"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1"
dependencies = [
"bitflags",
"cassowary",
"crossterm",
"unicode-segmentation",
"unicode-width",
"ratatui",
]
[[package]]
name = "unicode-ident"
version = "1.0.5"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3"
checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0"
[[package]]
name = "unicode-segmentation"
version = "1.10.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fdbf052a0783de01e944a6ce7a8cb939e295b1e7be835a1112c3b9a7f047a5a"
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
[[package]]
name = "unicode-width"
@@ -401,6 +597,24 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
@@ -431,57 +645,141 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-sys"
version = "0.42.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.0",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5"
dependencies = [
"windows_aarch64_gnullvm 0.48.0",
"windows_aarch64_msvc 0.48.0",
"windows_i686_gnu 0.48.0",
"windows_i686_msvc 0.48.0",
"windows_x86_64_gnu 0.48.0",
"windows_x86_64_gnullvm 0.48.0",
"windows_x86_64_msvc 0.48.0",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.0"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.0"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3"
[[package]]
name = "windows_i686_gnu"
version = "0.42.0"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"
[[package]]
name = "windows_i686_msvc"
version = "0.42.0"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.0"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.0"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.0"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"
[[package]]
name = "winnow"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61de7bac303dc551fe038e2b3cef0f571087a47571ea6e79a87692ac99b99699"
dependencies = [
"memchr",
]
+6 -10
View File
@@ -1,19 +1,15 @@
[package]
name = "kanban-tui"
name = "kanban_tui"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
tui = "0.19.0"
crossterm = "0.25"
serde = { version = "1.0.148" , features = [ "derive" ] }
serde_json = "1.0.89"
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 }
crossterm = "0.26.1"
int-enum = "0.5.0"
thiserror = "1"
anyhow = "1"
tree-sitter = "0.20.9"
[build-dependencies]
cc="*"
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.
+71 -2
View File
@@ -1,2 +1,71 @@
*** Kanban TUI
Using *tui-rs* to build a kanban board
#+OPTIONS: toc:nil
* 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.
It uses [[https://crates.io/crates/ratatui][ratatui]] for the TUI interface rendering and [[https://crates.io/crates/rusqlite][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 [[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~.
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 [[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");
+389 -153
View File
@@ -1,207 +1,443 @@
// use indexmap::IndexMap;
// use int_enum::IntEnum;
use serde::{Deserialize, Serialize};
use anyhow::Error;
use int_enum::IntEnum;
use rusqlite::Connection;
use std::cmp::min;
use tui_textarea::TextArea;
#[cfg(test)]
mod tests;
use crate::db;
#[derive(Debug, Serialize, Deserialize)]
/// Represents a kanban column containing the tasks and other metadata.
#[derive(Debug)]
pub struct Column {
/// Id provided by the database
pub id: i64,
/// The name used for the title in the UI
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,
/// The collection of [`Task`]
pub tasks: Vec<Task>,
}
// #[derive(Deserialize, Serialize, Debug, Clone, Copy)]
#[derive(Deserialize, Serialize, Debug)]
/// Basic TODO task with a title and a description.
#[derive(Clone, Default, Debug)]
pub struct Task {
/// Id provided by the database
pub id: i64,
/// Title of the [`Task`]
pub title: String,
/// Description of the [`Task`]
pub description: String,
}
impl Default for Task {
/// The number of `TaskEditFocus` variants, used so we can "wrap around"
/// with modulo when cycling through tasks with Tab/Backtab.
pub const EDIT_WINDOW_FOCUS_STATES: i8 = 4;
/// 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,
}
/// Represents the transient state of a task while it is being editing
/// by the user through the UI.
pub struct TaskState<'a> {
/// The title of the Task
pub title: TextArea<'a>,
/// The description of the Task
pub description: TextArea<'a>,
/// 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 TaskState<'_> {
fn default() -> Self {
Task {
title: String::new(),
description: String::new(),
TaskState {
title: TextArea::default(),
description: TextArea::default(),
focus: TaskEditFocus::Title,
is_edit: false,
}
}
}
/// Type used mainly for serialization at this time
#[derive(Deserialize, Serialize, Debug)]
pub struct Project {
pub name: String,
/// Holds the application's state, including all columns and the
/// database connection.
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,
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,
}
}
}
pub struct AppState {
pub project: Project,
pub quit: bool,
/// A vec of all the [`Column`]s
pub columns: Vec<Column>,
pub popup_text: Option<String>,
/// 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 {
pub fn new(project: Project) -> Self {
AppState {
impl<'a> State<'a> {
/// Creates a new [`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,
popup_text: None,
project,
columns: vec![],
}
}
}
impl Column {
pub fn new(name: &str) -> Self {
Column {
name: name.to_owned(),
tasks: vec![],
selected_task_idx: 0,
}
}
pub fn get_selected_task(&self) -> Option<&Task> {
self.tasks.get(self.selected_task_idx)
}
pub fn get_selected_task_mut(&mut self) -> Option<&mut Task> {
self.tasks.get_mut(self.selected_task_idx)
}
pub fn select_previous_task(&mut self) {
let task_idx = &mut self.selected_task_idx;
*task_idx = task_idx.saturating_sub(1)
}
pub fn select_next_task(&mut self) {
let task_idx = &mut self.selected_task_idx;
*task_idx = min(*task_idx + 1, self.tasks.len() - 1)
}
pub fn select_last_task(&mut self) {
self.selected_task_idx = self.tasks.len() - 1;
}
}
impl Project {
pub fn new(name: &str) -> Self {
Project {
name: name.to_owned(),
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();
task_edit_state: None,
db_conn,
})
}
/// Returns a reference to the currently selected [`Column`].
#[must_use]
pub fn get_selected_column(&self) -> &Column {
&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 {
&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.columns[self.selected_column_idx]
self.db_conn.set_selected_column(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]
/// 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)
}
fn move_task_to_column(&mut self, move_next: bool) {
let col_idx = self.selected_column_idx;
let cols_len = self.columns.len();
/// Returns a reference to the currently selected [`Task`].
/// Returns `None` if the current [`Column::tasks`] is empty.
#[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 cond = if move_next {
col_idx < cols_len - 1
} else {
col_idx > 0
};
if cond && column.tasks.len() > 0 {
let t = column.tasks.remove(column.selected_task_idx);
column.select_previous_task();
if move_next {
self.select_next_column();
} else {
self.select_previous_column();
}
let col = self.get_selected_column_mut();
col.tasks.push(t);
col.select_last_task();
self.save();
}
column.tasks.get_mut(column.selected_task_idx)
}
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)
}
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)
}
pub fn move_task_up(&mut self) {
let column = self.get_selected_column_mut();
if column.selected_task_idx > 0 {
column.tasks.swap(column.selected_task_idx, column.selected_task_idx - 1);
column.selected_task_idx = column.selected_task_idx - 1;
self.save();
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()?;
}
pub fn move_task_down(&mut self) {
let column = self.get_selected_column_mut();
if column.selected_task_idx < column.tasks.len() - 1 {
column.tasks.swap(column.selected_task_idx, column.selected_task_idx + 1);
column.selected_task_idx = column.selected_task_idx + 1;
self.save();
if move_right {
self.select_column_right()?;
} else {
self.select_column_left()?;
}
let col = self.get_selected_column_mut();
col.tasks.push(t);
self.select_last_task()?;
if let Some(task) = self.get_selected_task() {
let col = self.get_selected_column();
self.db_conn.move_task_to_column(task, col)?;
self.db_conn.set_selected_column(self.selected_column_idx)?;
}
Ok(())
}
/// Inserts a new [`Task`] into [`Column::tasks`] at the bottom of
/// the list and saves the state to the DB.
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn add_new_task(&mut self, title: String, description: String) -> Result<(), Error> {
let col_id = self.get_selected_column().id;
let task = self.db_conn.create_new_task(title, description, col_id)?;
self.select_last_task()?;
let selected_task_idx = self.get_selected_column().selected_task_idx;
self.db_conn
.set_selected_task_for_column(selected_task_idx, col_id)?;
self.get_selected_column_mut().tasks.push(task);
self.select_last_task()?;
Ok(())
}
/// Edits the selected [`Task`] changing only it's title and/or
/// description. Does nothing if the [`Column`] is empty.
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn edit_task(&mut self, title: String, description: String) -> Result<(), Error> {
if let Some(selected_task) = self.get_selected_task_mut() {
selected_task.title = title;
selected_task.description = description;
}
if let Some(task) = self.get_selected_task() {
self.db_conn.update_task_text(task)?;
}
Ok(())
}
/// Deletes the selected [`Task`] from the list. Does nothing if
/// the [`Column`] is empty.
///
/// # Errors
///
/// SQL related errors get bubbled up to here.
pub fn delete_task(&mut self) -> Result<(), Error> {
if let Some(task) = self.get_selected_task() {
let task_id = task.id;
let column = self.get_selected_column_mut();
let mut task_idx = column.selected_task_idx;
let col_id = column.id;
column.tasks.remove(task_idx);
if column.selected_task_idx >= column.tasks.len() {
self.select_task_above()?;
task_idx = task_idx.saturating_sub(1);
}
self.db_conn.delete_task(task_id)?;
self.db_conn
.set_selected_task_for_column(task_idx, col_id)?;
}
Ok(())
}
}
-1
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))
}
}
+94 -28
View File
@@ -1,36 +1,102 @@
use crate::app::{State, TaskEditFocus, TaskState, EDIT_WINDOW_FOCUS_STATES};
use anyhow::Error;
use crossterm::event;
use crossterm::event::{Event, KeyCode};
use crate::app::{AppState};
use int_enum::IntEnum;
pub fn handle_input(state: &mut AppState) -> Result<(), std::io::Error> {
let project = &mut state.project;
let column = project.get_selected_column_mut();
if let Event::Key(key) = event::read()? {
pub fn cycle_focus(task: &mut TaskState<'_>, forward: bool) -> Result<(), Error> {
let cycle = if forward {
(task.focus.int_value() + 1) % EDIT_WINDOW_FOCUS_STATES
} else {
(task.focus.int_value() - 1) % EDIT_WINDOW_FOCUS_STATES
};
task.focus = TaskEditFocus::from_int(cycle)?;
Ok(())
}
pub fn handle_task_edit(state: &mut State<'_>, key: event::KeyEvent) -> Result<(), Error> {
// .take() the option so we can avoid borrow checker issues when
// we try to edit the task since that mutably borrows State, then
// assign later to task_edit_state
let updated_task = if let Some(mut task) = state.task_edit_state.take() {
match (key.code, task.focus) {
(KeyCode::Tab, _) => {
cycle_focus(&mut task, true)?;
Some(task)
}
(KeyCode::BackTab, _) => {
cycle_focus(&mut task, false)?;
Some(task)
}
(KeyCode::Enter, TaskEditFocus::ConfirmBtn) => {
// The structure of this function is so we avoid an
// unncessary clone() here. We can just transfer
// ownership of these strings right into the task
// that's going to be created/updated
let title = task.title.into_lines().join("\n");
let description = task.description.into_lines().join("\n");
if task.is_edit {
state.edit_task(title, description)?;
} else {
state.add_new_task(title, description)?;
}
None
}
(KeyCode::Enter, TaskEditFocus::CancelBtn) => None,
// Ignore enter on the title bar to effectively make it single line
(KeyCode::Enter, TaskEditFocus::Title) => Some(task),
(_, TaskEditFocus::Title) => {
task.title.input(key);
Some(task)
}
(_, TaskEditFocus::Description) => {
task.description.input(key);
Some(task)
}
_ => Some(task),
}
} else {
None
};
state.task_edit_state = updated_task;
Ok(())
}
#[allow(clippy::unit_arg)]
pub fn handle_main(state: &mut State<'_>, key: event::KeyEvent) -> Result<(), Error> {
match key.code {
KeyCode::Char('q') => state.quit = true,
KeyCode::Char('h') |
KeyCode::Left => { project.select_previous_column(); },
KeyCode::Char('j') |
KeyCode::Down => column.select_next_task(),
KeyCode::Char('k') |
KeyCode::Up => column.select_previous_task(),
KeyCode::Char('l') |
KeyCode::Right => { project.select_next_column(); },
KeyCode::Char('<') |
KeyCode::Char('H') => project.move_task_previous_column(),
KeyCode::Char('>') |
KeyCode::Char('L') => project.move_task_next_column(),
KeyCode::Char('=') |
KeyCode::Char('J') => project.move_task_down(),
KeyCode::Char('-') |
KeyCode::Char('K') => project.move_task_up(),
KeyCode::Char('p') => {
match state.popup_text {
None => state.popup_text = Some("".to_string()),
Some(_) => state.popup_text = None,
KeyCode::Char('q') => Ok(state.quit = true),
KeyCode::Char('h') | KeyCode::Left => state.select_column_left(),
KeyCode::Char('j') | KeyCode::Down => state.select_task_below(),
KeyCode::Char('k') | KeyCode::Up => state.select_task_above(),
KeyCode::Char('l') | KeyCode::Right => state.select_column_right(),
KeyCode::Char('g') => state.select_first_task(),
KeyCode::Char('G') => state.select_last_task(),
KeyCode::Char('H') => state.move_task_column_left(),
KeyCode::Char('L') => state.move_task_column_right(),
KeyCode::Char('J') => state.move_task_down(),
KeyCode::Char('K') => state.move_task_up(),
KeyCode::Char('n') => Ok(state.task_edit_state = Some(TaskState::default())),
KeyCode::Char('e') => Ok(state.task_edit_state = state.get_task_state_from_current()),
KeyCode::Char('D') => state.delete_task(),
_ => Ok(()),
}
}
_ => {}
}
/// Takes the app's [`State`] and uses [`event::read`] to get the current keypress.
///
/// # Errors
///
/// 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(())
+20 -5
View File
@@ -1,9 +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 ui;
mod db;
mod input;
mod treesitter;
mod ui;
pub use app::*;
pub use ui::draw;
pub use input::handle_input;
pub use treesitter::compile_md_grammar;
pub use db::*;
pub use input::handle_user_keypress;
pub use ui::draw_ui_from_state;
+40 -12
View File
@@ -1,30 +1,58 @@
#![allow(dead_code)]
use kanban_tui::*;
#![deny(rust_2018_idioms)]
use clap::{Parser, ValueHint::FilePath};
use crossterm::{
event::*,
event::{DisableMouseCapture, EnableMouseCapture},
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::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()?;
let mut stdout = io::stdout();
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut state = AppState::new(Project::load()?);
while !state.quit {
terminal.draw(|f| kanban_tui::draw(f, &mut state))?;
kanban_tui::handle_input(&mut state)?;
terminal.draw(|f| kanban_tui::draw_ui_from_state(f, &mut state))?;
kanban_tui::handle_user_keypress(&mut state)?;
}
state.project.save();
// restore terminal
disable_raw_mode()?;
crossterm::execute!(
-6
View File
@@ -1,6 +0,0 @@
use tree_sitter::{Parser, Language};
use std::path::PathBuf;
pub fn compile_md_grammar() {
}
+174 -62
View File
@@ -1,67 +1,77 @@
use crate::app::*;
use crate::app::{State, TaskEditFocus};
use tui::backend::Backend;
use tui::layout::*;
use tui::style::{Color, Modifier, Style};
use tui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use tui::style::{Modifier, Style};
use tui::text::{Span, Spans};
use tui::widgets::*;
use tui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
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()
.direction(Direction::Horizontal)
.constraints(
vec![
Constraint::Percentage(100 / state.project.columns.len() as u16);
state.project.columns.len()
Constraint::Percentage(100 / u16::try_from(state.columns.len()).unwrap_or(4));
state.columns.len()
]
.as_ref(),
)
.split(*area);
.split(area);
for (i, column) in state.project.columns.iter().enumerate() {
let items: Vec<ListItem> = column.tasks
for (i, column) in state.columns.iter().enumerate() {
let items: Vec<ListItem<'_>> = column
.tasks
.iter()
.enumerate()
.map(|(j, task)| {
let mut style = Style::default();
let col_idx = state.project.selected_column_idx;
let task_idx = state.project.get_selected_column().selected_task_idx;
let col_idx = state.selected_column_idx;
let task_idx = state.get_selected_column().selected_task_idx;
let mut span;
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 {
style = style.fg(Color::White);
span = Span::raw(&task.title);
}
let mut s = Span::raw(task.title.as_str());
s.style = style;
ListItem::new(vec![Spans::from(s)])
span.style = style;
ListItem::new(vec![Spans::from(span)])
})
.collect();
let mut style = Style::default();
if i == state.project.selected_column_idx {
style = style.fg(Color::Green);
if i == state.selected_column_idx {
style = style.add_modifier(Modifier::REVERSED);
};
let mut s = Span::raw(format!("{:?}", column.name));
s.style = Style::default()
.add_modifier(Modifier::BOLD | Modifier::ITALIC | Modifier::UNDERLINED)
.fg(Color::White);
let block = Block::default().style(style).title(s).borders(Borders::ALL);
let list = List::new(items).block(block);
f.render_widget(list, columns[i])
let mut s = Span::raw(column.name.as_str());
let mods = Modifier::BOLD | Modifier::ITALIC | Modifier::UNDERLINED;
s.style = Style::default().add_modifier(mods);
let block = Block::default().title(s).borders(Borders::ALL);
let inner_area = block.inner(columns[i]);
let inner_block = Block::default().style(style);
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);
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())
.block(block)
.wrap(Wrap { trim: true });
f.render_widget(p, *area);
f.render_widget(p, area);
} else {
let p = Paragraph::new("No tasks for this column").block(block);
f.render_widget(p, *area);
f.render_widget(p, area);
}
}
fn centered_rect_for_popup(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
@@ -88,76 +98,178 @@ fn centered_rect_for_popup(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
.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());
f.render_widget(Clear, area);
match &state.popup_text {
None => {}
Some(s) => {
let block = Block::default()
.title("Add Task")
.title(popup_title)
.title_alignment(Alignment::Center)
.borders(Borders::ALL);
let block_inner = block.inner(area);
let main = Paragraph::new(s.as_ref()).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()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3),
Constraint::Max(100),
Constraint::Length(1),
Constraint::Length(2),
]
.as_ref(),
)
.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 title = Paragraph::new("Hello I am text")
// .style(Style::default().fg(Color::Yellow))
.block(b1);
let b2 = Block::default().title("Description").borders(Borders::ALL);
let description = Paragraph::new("Fill this out")
// .style(Style::default().fg(Color::Yellow))
.block(b2);
let b3 = Block::default().title("Keys").borders(Borders::TOP);
let footer = Paragraph::new("p : Cancel").block(b3);
f.render_widget(main, area);
f.render_widget(title, layout[0]);
f.render_widget(description, layout[1]);
f.render_widget(footer, layout[2]);
task.title.set_cursor_line_style(Style::default());
task.description.set_cursor_line_style(Style::default());
task.title.set_block(b1);
if let TaskEditFocus::Title = task.focus {
task.title
.set_style(Style::default().add_modifier(Modifier::BOLD));
task.title
.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()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage(10),
Constraint::Percentage(65),
Constraint::Percentage(20),
Constraint::Length(3),
Constraint::Length(2),
Constraint::Min(10),
Constraint::Max(10),
Constraint::Length(2),
]
.as_ref(),
)
.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]);
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 foot_txt =
Paragraph::new("q : Quit | ⏪🔽🔼⏩ or hjkl : Navigation | < > or H L : Shift task left/right | = - or J K : Shift task up/down")
.block(block);
f.render_widget(foot_txt, main_layout[3]);
let foot_txt = unroll![
("quit", "q"),
("navigation", "hjkl"),
("move task", "HJKL"),
("new task", "n"),
("edit task", "e"),
("cycle edit fields", "Tab"),
("column top", "g"),
("column bottom", "G")
];
if state.popup_text.is_some() {
draw_new_task_popup(f, state);
let footer = Paragraph::new(foot_txt).block(block);
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(())
}
}