├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── config.yml ├── demo.gif ├── demo.tape └── src ├── commands.rs ├── config.rs ├── data.rs ├── main.rs ├── month_view.rs ├── task.rs ├── task_edit.rs ├── undo.rs └── utils.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | /out 25 | /taskim/target 26 | /ratatui 27 | taskim/task_manager_data.json 28 | taskim/task-manager-data.json 29 | /target 30 | task_manager_data.json 31 | task_manager_data copy.json 32 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "allocator-api2" 22 | version = "0.2.21" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 25 | 26 | [[package]] 27 | name = "android-tzdata" 28 | version = "0.1.1" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 31 | 32 | [[package]] 33 | name = "android_system_properties" 34 | version = "0.1.5" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 37 | dependencies = [ 38 | "libc", 39 | ] 40 | 41 | [[package]] 42 | name = "autocfg" 43 | version = "1.4.0" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 46 | 47 | [[package]] 48 | name = "backtrace" 49 | version = "0.3.75" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" 52 | dependencies = [ 53 | "addr2line", 54 | "cfg-if", 55 | "libc", 56 | "miniz_oxide", 57 | "object", 58 | "rustc-demangle", 59 | "windows-targets", 60 | ] 61 | 62 | [[package]] 63 | name = "bitflags" 64 | version = "2.9.1" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" 67 | 68 | [[package]] 69 | name = "bumpalo" 70 | version = "3.17.0" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 73 | 74 | [[package]] 75 | name = "cassowary" 76 | version = "0.3.0" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 79 | 80 | [[package]] 81 | name = "castaway" 82 | version = "0.2.3" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" 85 | dependencies = [ 86 | "rustversion", 87 | ] 88 | 89 | [[package]] 90 | name = "cc" 91 | version = "1.2.25" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "d0fc897dc1e865cc67c0e05a836d9d3f1df3cbe442aa4a9473b18e12624a4951" 94 | dependencies = [ 95 | "shlex", 96 | ] 97 | 98 | [[package]] 99 | name = "cfg-if" 100 | version = "1.0.0" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 103 | 104 | [[package]] 105 | name = "chrono" 106 | version = "0.4.41" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" 109 | dependencies = [ 110 | "android-tzdata", 111 | "iana-time-zone", 112 | "js-sys", 113 | "num-traits", 114 | "serde", 115 | "wasm-bindgen", 116 | "windows-link", 117 | ] 118 | 119 | [[package]] 120 | name = "color-eyre" 121 | version = "0.6.5" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" 124 | dependencies = [ 125 | "backtrace", 126 | "color-spantrace", 127 | "eyre", 128 | "indenter", 129 | "once_cell", 130 | "owo-colors", 131 | "tracing-error", 132 | ] 133 | 134 | [[package]] 135 | name = "color-spantrace" 136 | version = "0.3.0" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" 139 | dependencies = [ 140 | "once_cell", 141 | "owo-colors", 142 | "tracing-core", 143 | "tracing-error", 144 | ] 145 | 146 | [[package]] 147 | name = "compact_str" 148 | version = "0.8.1" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 151 | dependencies = [ 152 | "castaway", 153 | "cfg-if", 154 | "itoa", 155 | "rustversion", 156 | "ryu", 157 | "static_assertions", 158 | ] 159 | 160 | [[package]] 161 | name = "core-foundation-sys" 162 | version = "0.8.7" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 165 | 166 | [[package]] 167 | name = "crossterm" 168 | version = "0.28.1" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 171 | dependencies = [ 172 | "bitflags", 173 | "crossterm_winapi", 174 | "mio", 175 | "parking_lot", 176 | "rustix", 177 | "signal-hook", 178 | "signal-hook-mio", 179 | "winapi", 180 | ] 181 | 182 | [[package]] 183 | name = "crossterm_winapi" 184 | version = "0.9.1" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 187 | dependencies = [ 188 | "winapi", 189 | ] 190 | 191 | [[package]] 192 | name = "darling" 193 | version = "0.20.11" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 196 | dependencies = [ 197 | "darling_core", 198 | "darling_macro", 199 | ] 200 | 201 | [[package]] 202 | name = "darling_core" 203 | version = "0.20.11" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 206 | dependencies = [ 207 | "fnv", 208 | "ident_case", 209 | "proc-macro2", 210 | "quote", 211 | "strsim", 212 | "syn", 213 | ] 214 | 215 | [[package]] 216 | name = "darling_macro" 217 | version = "0.20.11" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 220 | dependencies = [ 221 | "darling_core", 222 | "quote", 223 | "syn", 224 | ] 225 | 226 | [[package]] 227 | name = "deranged" 228 | version = "0.4.0" 229 | source = "registry+https://github.com/rust-lang/crates.io-index" 230 | checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" 231 | dependencies = [ 232 | "powerfmt", 233 | ] 234 | 235 | [[package]] 236 | name = "either" 237 | version = "1.15.0" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 240 | 241 | [[package]] 242 | name = "equivalent" 243 | version = "1.0.2" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 246 | 247 | [[package]] 248 | name = "errno" 249 | version = "0.3.12" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" 252 | dependencies = [ 253 | "libc", 254 | "windows-sys", 255 | ] 256 | 257 | [[package]] 258 | name = "eyre" 259 | version = "0.6.12" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" 262 | dependencies = [ 263 | "indenter", 264 | "once_cell", 265 | ] 266 | 267 | [[package]] 268 | name = "fnv" 269 | version = "1.0.7" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 272 | 273 | [[package]] 274 | name = "foldhash" 275 | version = "0.1.5" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 278 | 279 | [[package]] 280 | name = "getrandom" 281 | version = "0.3.3" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 284 | dependencies = [ 285 | "cfg-if", 286 | "libc", 287 | "r-efi", 288 | "wasi 0.14.2+wasi-0.2.4", 289 | ] 290 | 291 | [[package]] 292 | name = "gimli" 293 | version = "0.31.1" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 296 | 297 | [[package]] 298 | name = "hashbrown" 299 | version = "0.15.3" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" 302 | dependencies = [ 303 | "allocator-api2", 304 | "equivalent", 305 | "foldhash", 306 | ] 307 | 308 | [[package]] 309 | name = "heck" 310 | version = "0.5.0" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 313 | 314 | [[package]] 315 | name = "iana-time-zone" 316 | version = "0.1.63" 317 | source = "registry+https://github.com/rust-lang/crates.io-index" 318 | checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" 319 | dependencies = [ 320 | "android_system_properties", 321 | "core-foundation-sys", 322 | "iana-time-zone-haiku", 323 | "js-sys", 324 | "log", 325 | "wasm-bindgen", 326 | "windows-core", 327 | ] 328 | 329 | [[package]] 330 | name = "iana-time-zone-haiku" 331 | version = "0.1.2" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 334 | dependencies = [ 335 | "cc", 336 | ] 337 | 338 | [[package]] 339 | name = "ident_case" 340 | version = "1.0.1" 341 | source = "registry+https://github.com/rust-lang/crates.io-index" 342 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 343 | 344 | [[package]] 345 | name = "indenter" 346 | version = "0.3.3" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" 349 | 350 | [[package]] 351 | name = "indexmap" 352 | version = "2.9.0" 353 | source = "registry+https://github.com/rust-lang/crates.io-index" 354 | checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" 355 | dependencies = [ 356 | "equivalent", 357 | "hashbrown", 358 | ] 359 | 360 | [[package]] 361 | name = "indoc" 362 | version = "2.0.6" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" 365 | 366 | [[package]] 367 | name = "instability" 368 | version = "0.3.7" 369 | source = "registry+https://github.com/rust-lang/crates.io-index" 370 | checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" 371 | dependencies = [ 372 | "darling", 373 | "indoc", 374 | "proc-macro2", 375 | "quote", 376 | "syn", 377 | ] 378 | 379 | [[package]] 380 | name = "itertools" 381 | version = "0.13.0" 382 | source = "registry+https://github.com/rust-lang/crates.io-index" 383 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 384 | dependencies = [ 385 | "either", 386 | ] 387 | 388 | [[package]] 389 | name = "itoa" 390 | version = "1.0.15" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 393 | 394 | [[package]] 395 | name = "js-sys" 396 | version = "0.3.77" 397 | source = "registry+https://github.com/rust-lang/crates.io-index" 398 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 399 | dependencies = [ 400 | "once_cell", 401 | "wasm-bindgen", 402 | ] 403 | 404 | [[package]] 405 | name = "lazy_static" 406 | version = "1.5.0" 407 | source = "registry+https://github.com/rust-lang/crates.io-index" 408 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 409 | 410 | [[package]] 411 | name = "libc" 412 | version = "0.2.172" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 415 | 416 | [[package]] 417 | name = "linux-raw-sys" 418 | version = "0.4.15" 419 | source = "registry+https://github.com/rust-lang/crates.io-index" 420 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 421 | 422 | [[package]] 423 | name = "lock_api" 424 | version = "0.4.13" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" 427 | dependencies = [ 428 | "autocfg", 429 | "scopeguard", 430 | ] 431 | 432 | [[package]] 433 | name = "log" 434 | version = "0.4.27" 435 | source = "registry+https://github.com/rust-lang/crates.io-index" 436 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 437 | 438 | [[package]] 439 | name = "lru" 440 | version = "0.12.5" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 443 | dependencies = [ 444 | "hashbrown", 445 | ] 446 | 447 | [[package]] 448 | name = "memchr" 449 | version = "2.7.4" 450 | source = "registry+https://github.com/rust-lang/crates.io-index" 451 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 452 | 453 | [[package]] 454 | name = "miniz_oxide" 455 | version = "0.8.8" 456 | source = "registry+https://github.com/rust-lang/crates.io-index" 457 | checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" 458 | dependencies = [ 459 | "adler2", 460 | ] 461 | 462 | [[package]] 463 | name = "mio" 464 | version = "1.0.4" 465 | source = "registry+https://github.com/rust-lang/crates.io-index" 466 | checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 467 | dependencies = [ 468 | "libc", 469 | "log", 470 | "wasi 0.11.0+wasi-snapshot-preview1", 471 | "windows-sys", 472 | ] 473 | 474 | [[package]] 475 | name = "num-conv" 476 | version = "0.1.0" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 479 | 480 | [[package]] 481 | name = "num-traits" 482 | version = "0.2.19" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 485 | dependencies = [ 486 | "autocfg", 487 | ] 488 | 489 | [[package]] 490 | name = "num_threads" 491 | version = "0.1.7" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 494 | dependencies = [ 495 | "libc", 496 | ] 497 | 498 | [[package]] 499 | name = "object" 500 | version = "0.36.7" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 503 | dependencies = [ 504 | "memchr", 505 | ] 506 | 507 | [[package]] 508 | name = "once_cell" 509 | version = "1.21.3" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 512 | 513 | [[package]] 514 | name = "owo-colors" 515 | version = "4.2.1" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec" 518 | 519 | [[package]] 520 | name = "parking_lot" 521 | version = "0.12.4" 522 | source = "registry+https://github.com/rust-lang/crates.io-index" 523 | checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" 524 | dependencies = [ 525 | "lock_api", 526 | "parking_lot_core", 527 | ] 528 | 529 | [[package]] 530 | name = "parking_lot_core" 531 | version = "0.9.11" 532 | source = "registry+https://github.com/rust-lang/crates.io-index" 533 | checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" 534 | dependencies = [ 535 | "cfg-if", 536 | "libc", 537 | "redox_syscall", 538 | "smallvec", 539 | "windows-targets", 540 | ] 541 | 542 | [[package]] 543 | name = "paste" 544 | version = "1.0.15" 545 | source = "registry+https://github.com/rust-lang/crates.io-index" 546 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 547 | 548 | [[package]] 549 | name = "pin-project-lite" 550 | version = "0.2.16" 551 | source = "registry+https://github.com/rust-lang/crates.io-index" 552 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 553 | 554 | [[package]] 555 | name = "powerfmt" 556 | version = "0.2.0" 557 | source = "registry+https://github.com/rust-lang/crates.io-index" 558 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 559 | 560 | [[package]] 561 | name = "proc-macro2" 562 | version = "1.0.95" 563 | source = "registry+https://github.com/rust-lang/crates.io-index" 564 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 565 | dependencies = [ 566 | "unicode-ident", 567 | ] 568 | 569 | [[package]] 570 | name = "quote" 571 | version = "1.0.40" 572 | source = "registry+https://github.com/rust-lang/crates.io-index" 573 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 574 | dependencies = [ 575 | "proc-macro2", 576 | ] 577 | 578 | [[package]] 579 | name = "r-efi" 580 | version = "5.2.0" 581 | source = "registry+https://github.com/rust-lang/crates.io-index" 582 | checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 583 | 584 | [[package]] 585 | name = "ratatui" 586 | version = "0.28.1" 587 | source = "registry+https://github.com/rust-lang/crates.io-index" 588 | checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" 589 | dependencies = [ 590 | "bitflags", 591 | "cassowary", 592 | "compact_str", 593 | "crossterm", 594 | "instability", 595 | "itertools", 596 | "lru", 597 | "paste", 598 | "strum", 599 | "strum_macros", 600 | "unicode-segmentation", 601 | "unicode-truncate", 602 | "unicode-width", 603 | ] 604 | 605 | [[package]] 606 | name = "redox_syscall" 607 | version = "0.5.12" 608 | source = "registry+https://github.com/rust-lang/crates.io-index" 609 | checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" 610 | dependencies = [ 611 | "bitflags", 612 | ] 613 | 614 | [[package]] 615 | name = "rustc-demangle" 616 | version = "0.1.24" 617 | source = "registry+https://github.com/rust-lang/crates.io-index" 618 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 619 | 620 | [[package]] 621 | name = "rustix" 622 | version = "0.38.44" 623 | source = "registry+https://github.com/rust-lang/crates.io-index" 624 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 625 | dependencies = [ 626 | "bitflags", 627 | "errno", 628 | "libc", 629 | "linux-raw-sys", 630 | "windows-sys", 631 | ] 632 | 633 | [[package]] 634 | name = "rustversion" 635 | version = "1.0.21" 636 | source = "registry+https://github.com/rust-lang/crates.io-index" 637 | checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" 638 | 639 | [[package]] 640 | name = "ryu" 641 | version = "1.0.20" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 644 | 645 | [[package]] 646 | name = "scopeguard" 647 | version = "1.2.0" 648 | source = "registry+https://github.com/rust-lang/crates.io-index" 649 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 650 | 651 | [[package]] 652 | name = "serde" 653 | version = "1.0.219" 654 | source = "registry+https://github.com/rust-lang/crates.io-index" 655 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 656 | dependencies = [ 657 | "serde_derive", 658 | ] 659 | 660 | [[package]] 661 | name = "serde_derive" 662 | version = "1.0.219" 663 | source = "registry+https://github.com/rust-lang/crates.io-index" 664 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 665 | dependencies = [ 666 | "proc-macro2", 667 | "quote", 668 | "syn", 669 | ] 670 | 671 | [[package]] 672 | name = "serde_json" 673 | version = "1.0.140" 674 | source = "registry+https://github.com/rust-lang/crates.io-index" 675 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 676 | dependencies = [ 677 | "itoa", 678 | "memchr", 679 | "ryu", 680 | "serde", 681 | ] 682 | 683 | [[package]] 684 | name = "serde_yaml" 685 | version = "0.9.34+deprecated" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 688 | dependencies = [ 689 | "indexmap", 690 | "itoa", 691 | "ryu", 692 | "serde", 693 | "unsafe-libyaml", 694 | ] 695 | 696 | [[package]] 697 | name = "sharded-slab" 698 | version = "0.1.7" 699 | source = "registry+https://github.com/rust-lang/crates.io-index" 700 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 701 | dependencies = [ 702 | "lazy_static", 703 | ] 704 | 705 | [[package]] 706 | name = "shlex" 707 | version = "1.3.0" 708 | source = "registry+https://github.com/rust-lang/crates.io-index" 709 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 710 | 711 | [[package]] 712 | name = "signal-hook" 713 | version = "0.3.18" 714 | source = "registry+https://github.com/rust-lang/crates.io-index" 715 | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 716 | dependencies = [ 717 | "libc", 718 | "signal-hook-registry", 719 | ] 720 | 721 | [[package]] 722 | name = "signal-hook-mio" 723 | version = "0.2.4" 724 | source = "registry+https://github.com/rust-lang/crates.io-index" 725 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 726 | dependencies = [ 727 | "libc", 728 | "mio", 729 | "signal-hook", 730 | ] 731 | 732 | [[package]] 733 | name = "signal-hook-registry" 734 | version = "1.4.5" 735 | source = "registry+https://github.com/rust-lang/crates.io-index" 736 | checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" 737 | dependencies = [ 738 | "libc", 739 | ] 740 | 741 | [[package]] 742 | name = "smallvec" 743 | version = "1.15.0" 744 | source = "registry+https://github.com/rust-lang/crates.io-index" 745 | checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" 746 | 747 | [[package]] 748 | name = "static_assertions" 749 | version = "1.1.0" 750 | source = "registry+https://github.com/rust-lang/crates.io-index" 751 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 752 | 753 | [[package]] 754 | name = "strsim" 755 | version = "0.11.1" 756 | source = "registry+https://github.com/rust-lang/crates.io-index" 757 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 758 | 759 | [[package]] 760 | name = "strum" 761 | version = "0.26.3" 762 | source = "registry+https://github.com/rust-lang/crates.io-index" 763 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 764 | dependencies = [ 765 | "strum_macros", 766 | ] 767 | 768 | [[package]] 769 | name = "strum_macros" 770 | version = "0.26.4" 771 | source = "registry+https://github.com/rust-lang/crates.io-index" 772 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 773 | dependencies = [ 774 | "heck", 775 | "proc-macro2", 776 | "quote", 777 | "rustversion", 778 | "syn", 779 | ] 780 | 781 | [[package]] 782 | name = "syn" 783 | version = "2.0.101" 784 | source = "registry+https://github.com/rust-lang/crates.io-index" 785 | checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" 786 | dependencies = [ 787 | "proc-macro2", 788 | "quote", 789 | "unicode-ident", 790 | ] 791 | 792 | [[package]] 793 | name = "taskim" 794 | version = "0.1.0" 795 | dependencies = [ 796 | "chrono", 797 | "color-eyre", 798 | "crossterm", 799 | "ratatui", 800 | "serde", 801 | "serde_json", 802 | "serde_yaml", 803 | "time", 804 | "uuid", 805 | ] 806 | 807 | [[package]] 808 | name = "thread_local" 809 | version = "1.1.8" 810 | source = "registry+https://github.com/rust-lang/crates.io-index" 811 | checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 812 | dependencies = [ 813 | "cfg-if", 814 | "once_cell", 815 | ] 816 | 817 | [[package]] 818 | name = "time" 819 | version = "0.3.41" 820 | source = "registry+https://github.com/rust-lang/crates.io-index" 821 | checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" 822 | dependencies = [ 823 | "deranged", 824 | "itoa", 825 | "libc", 826 | "num-conv", 827 | "num_threads", 828 | "powerfmt", 829 | "serde", 830 | "time-core", 831 | "time-macros", 832 | ] 833 | 834 | [[package]] 835 | name = "time-core" 836 | version = "0.1.4" 837 | source = "registry+https://github.com/rust-lang/crates.io-index" 838 | checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" 839 | 840 | [[package]] 841 | name = "time-macros" 842 | version = "0.2.22" 843 | source = "registry+https://github.com/rust-lang/crates.io-index" 844 | checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" 845 | dependencies = [ 846 | "num-conv", 847 | "time-core", 848 | ] 849 | 850 | [[package]] 851 | name = "tracing" 852 | version = "0.1.41" 853 | source = "registry+https://github.com/rust-lang/crates.io-index" 854 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 855 | dependencies = [ 856 | "pin-project-lite", 857 | "tracing-core", 858 | ] 859 | 860 | [[package]] 861 | name = "tracing-core" 862 | version = "0.1.33" 863 | source = "registry+https://github.com/rust-lang/crates.io-index" 864 | checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 865 | dependencies = [ 866 | "once_cell", 867 | "valuable", 868 | ] 869 | 870 | [[package]] 871 | name = "tracing-error" 872 | version = "0.2.1" 873 | source = "registry+https://github.com/rust-lang/crates.io-index" 874 | checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" 875 | dependencies = [ 876 | "tracing", 877 | "tracing-subscriber", 878 | ] 879 | 880 | [[package]] 881 | name = "tracing-subscriber" 882 | version = "0.3.19" 883 | source = "registry+https://github.com/rust-lang/crates.io-index" 884 | checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 885 | dependencies = [ 886 | "sharded-slab", 887 | "thread_local", 888 | "tracing-core", 889 | ] 890 | 891 | [[package]] 892 | name = "unicode-ident" 893 | version = "1.0.18" 894 | source = "registry+https://github.com/rust-lang/crates.io-index" 895 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 896 | 897 | [[package]] 898 | name = "unicode-segmentation" 899 | version = "1.12.0" 900 | source = "registry+https://github.com/rust-lang/crates.io-index" 901 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 902 | 903 | [[package]] 904 | name = "unicode-truncate" 905 | version = "1.1.0" 906 | source = "registry+https://github.com/rust-lang/crates.io-index" 907 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 908 | dependencies = [ 909 | "itertools", 910 | "unicode-segmentation", 911 | "unicode-width", 912 | ] 913 | 914 | [[package]] 915 | name = "unicode-width" 916 | version = "0.1.14" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 919 | 920 | [[package]] 921 | name = "unsafe-libyaml" 922 | version = "0.2.11" 923 | source = "registry+https://github.com/rust-lang/crates.io-index" 924 | checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 925 | 926 | [[package]] 927 | name = "uuid" 928 | version = "1.17.0" 929 | source = "registry+https://github.com/rust-lang/crates.io-index" 930 | checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" 931 | dependencies = [ 932 | "getrandom", 933 | "js-sys", 934 | "serde", 935 | "wasm-bindgen", 936 | ] 937 | 938 | [[package]] 939 | name = "valuable" 940 | version = "0.1.1" 941 | source = "registry+https://github.com/rust-lang/crates.io-index" 942 | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 943 | 944 | [[package]] 945 | name = "wasi" 946 | version = "0.11.0+wasi-snapshot-preview1" 947 | source = "registry+https://github.com/rust-lang/crates.io-index" 948 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 949 | 950 | [[package]] 951 | name = "wasi" 952 | version = "0.14.2+wasi-0.2.4" 953 | source = "registry+https://github.com/rust-lang/crates.io-index" 954 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 955 | dependencies = [ 956 | "wit-bindgen-rt", 957 | ] 958 | 959 | [[package]] 960 | name = "wasm-bindgen" 961 | version = "0.2.100" 962 | source = "registry+https://github.com/rust-lang/crates.io-index" 963 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 964 | dependencies = [ 965 | "cfg-if", 966 | "once_cell", 967 | "rustversion", 968 | "wasm-bindgen-macro", 969 | ] 970 | 971 | [[package]] 972 | name = "wasm-bindgen-backend" 973 | version = "0.2.100" 974 | source = "registry+https://github.com/rust-lang/crates.io-index" 975 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 976 | dependencies = [ 977 | "bumpalo", 978 | "log", 979 | "proc-macro2", 980 | "quote", 981 | "syn", 982 | "wasm-bindgen-shared", 983 | ] 984 | 985 | [[package]] 986 | name = "wasm-bindgen-macro" 987 | version = "0.2.100" 988 | source = "registry+https://github.com/rust-lang/crates.io-index" 989 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 990 | dependencies = [ 991 | "quote", 992 | "wasm-bindgen-macro-support", 993 | ] 994 | 995 | [[package]] 996 | name = "wasm-bindgen-macro-support" 997 | version = "0.2.100" 998 | source = "registry+https://github.com/rust-lang/crates.io-index" 999 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 1000 | dependencies = [ 1001 | "proc-macro2", 1002 | "quote", 1003 | "syn", 1004 | "wasm-bindgen-backend", 1005 | "wasm-bindgen-shared", 1006 | ] 1007 | 1008 | [[package]] 1009 | name = "wasm-bindgen-shared" 1010 | version = "0.2.100" 1011 | source = "registry+https://github.com/rust-lang/crates.io-index" 1012 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 1013 | dependencies = [ 1014 | "unicode-ident", 1015 | ] 1016 | 1017 | [[package]] 1018 | name = "winapi" 1019 | version = "0.3.9" 1020 | source = "registry+https://github.com/rust-lang/crates.io-index" 1021 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1022 | dependencies = [ 1023 | "winapi-i686-pc-windows-gnu", 1024 | "winapi-x86_64-pc-windows-gnu", 1025 | ] 1026 | 1027 | [[package]] 1028 | name = "winapi-i686-pc-windows-gnu" 1029 | version = "0.4.0" 1030 | source = "registry+https://github.com/rust-lang/crates.io-index" 1031 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1032 | 1033 | [[package]] 1034 | name = "winapi-x86_64-pc-windows-gnu" 1035 | version = "0.4.0" 1036 | source = "registry+https://github.com/rust-lang/crates.io-index" 1037 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1038 | 1039 | [[package]] 1040 | name = "windows-core" 1041 | version = "0.61.2" 1042 | source = "registry+https://github.com/rust-lang/crates.io-index" 1043 | checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" 1044 | dependencies = [ 1045 | "windows-implement", 1046 | "windows-interface", 1047 | "windows-link", 1048 | "windows-result", 1049 | "windows-strings", 1050 | ] 1051 | 1052 | [[package]] 1053 | name = "windows-implement" 1054 | version = "0.60.0" 1055 | source = "registry+https://github.com/rust-lang/crates.io-index" 1056 | checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 1057 | dependencies = [ 1058 | "proc-macro2", 1059 | "quote", 1060 | "syn", 1061 | ] 1062 | 1063 | [[package]] 1064 | name = "windows-interface" 1065 | version = "0.59.1" 1066 | source = "registry+https://github.com/rust-lang/crates.io-index" 1067 | checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 1068 | dependencies = [ 1069 | "proc-macro2", 1070 | "quote", 1071 | "syn", 1072 | ] 1073 | 1074 | [[package]] 1075 | name = "windows-link" 1076 | version = "0.1.1" 1077 | source = "registry+https://github.com/rust-lang/crates.io-index" 1078 | checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" 1079 | 1080 | [[package]] 1081 | name = "windows-result" 1082 | version = "0.3.4" 1083 | source = "registry+https://github.com/rust-lang/crates.io-index" 1084 | checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 1085 | dependencies = [ 1086 | "windows-link", 1087 | ] 1088 | 1089 | [[package]] 1090 | name = "windows-strings" 1091 | version = "0.4.2" 1092 | source = "registry+https://github.com/rust-lang/crates.io-index" 1093 | checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 1094 | dependencies = [ 1095 | "windows-link", 1096 | ] 1097 | 1098 | [[package]] 1099 | name = "windows-sys" 1100 | version = "0.59.0" 1101 | source = "registry+https://github.com/rust-lang/crates.io-index" 1102 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1103 | dependencies = [ 1104 | "windows-targets", 1105 | ] 1106 | 1107 | [[package]] 1108 | name = "windows-targets" 1109 | version = "0.52.6" 1110 | source = "registry+https://github.com/rust-lang/crates.io-index" 1111 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1112 | dependencies = [ 1113 | "windows_aarch64_gnullvm", 1114 | "windows_aarch64_msvc", 1115 | "windows_i686_gnu", 1116 | "windows_i686_gnullvm", 1117 | "windows_i686_msvc", 1118 | "windows_x86_64_gnu", 1119 | "windows_x86_64_gnullvm", 1120 | "windows_x86_64_msvc", 1121 | ] 1122 | 1123 | [[package]] 1124 | name = "windows_aarch64_gnullvm" 1125 | version = "0.52.6" 1126 | source = "registry+https://github.com/rust-lang/crates.io-index" 1127 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1128 | 1129 | [[package]] 1130 | name = "windows_aarch64_msvc" 1131 | version = "0.52.6" 1132 | source = "registry+https://github.com/rust-lang/crates.io-index" 1133 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1134 | 1135 | [[package]] 1136 | name = "windows_i686_gnu" 1137 | version = "0.52.6" 1138 | source = "registry+https://github.com/rust-lang/crates.io-index" 1139 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1140 | 1141 | [[package]] 1142 | name = "windows_i686_gnullvm" 1143 | version = "0.52.6" 1144 | source = "registry+https://github.com/rust-lang/crates.io-index" 1145 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1146 | 1147 | [[package]] 1148 | name = "windows_i686_msvc" 1149 | version = "0.52.6" 1150 | source = "registry+https://github.com/rust-lang/crates.io-index" 1151 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1152 | 1153 | [[package]] 1154 | name = "windows_x86_64_gnu" 1155 | version = "0.52.6" 1156 | source = "registry+https://github.com/rust-lang/crates.io-index" 1157 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1158 | 1159 | [[package]] 1160 | name = "windows_x86_64_gnullvm" 1161 | version = "0.52.6" 1162 | source = "registry+https://github.com/rust-lang/crates.io-index" 1163 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1164 | 1165 | [[package]] 1166 | name = "windows_x86_64_msvc" 1167 | version = "0.52.6" 1168 | source = "registry+https://github.com/rust-lang/crates.io-index" 1169 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1170 | 1171 | [[package]] 1172 | name = "wit-bindgen-rt" 1173 | version = "0.39.0" 1174 | source = "registry+https://github.com/rust-lang/crates.io-index" 1175 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 1176 | dependencies = [ 1177 | "bitflags", 1178 | ] 1179 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "taskim" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["Rohan Adwankar "] 6 | description = "TUI Task Manager with vim-ish motions" 7 | repository = "https://github.com/RohanAdwankar/taskim" 8 | license = "MIT" 9 | license-file = "LICENSE" 10 | keywords = ["task","vim","neovim","todo"] 11 | 12 | [dependencies] 13 | ratatui = "0.28" 14 | crossterm = "0.28" 15 | color-eyre = "0.6" 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_json = "1.0" 18 | time = { version = "0.3", features = ["local-offset", "macros", "formatting", "parsing"] } 19 | chrono = { version = "0.4", features = ["serde"] } 20 | uuid = { version = "1.0", features = ["v4", "serde"] } 21 | serde_yaml = "0.9.34" 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Rohan Adwankar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Taskim 2 | 3 | ![TUI Demo](demo.gif) 4 | 5 | Taskim is a terminal-based task manager built with Rust and [ratatui](https://github.com/ratatui-org/ratatui). It provides a Vim-inspired interface for managing tasks, navigating months, and customizing your workflow. 6 | 7 | ## Features 8 | 9 | - **Monthly Calendar View:** 10 | Visualize your tasks in a month grid, with navigation for days, weeks, months, and years. 11 | - **Task Management:** 12 | - Add, edit, and delete tasks for any date. 13 | - Tasks can have titles and optional content/comments. 14 | - Mark tasks as complete/incomplete. 15 | - Reorder tasks within a day. 16 | - **Vim-style Keybindings:** 17 | - Navigate with `h`, `j`, `k`, `l` or arrow keys. 18 | - Insert tasks above/below (`O`/`o`), delete (`dd`/`x`), yank/copy (`y`), paste (`p`/`P`), and undo/redo (`u/control-r`). 19 | - Command mode (`:`) for advanced actions (e.g., go to date, toggle wrap, show/hide keybinds). 20 | - **Scramble Mode:** 21 | Toggle (`s`) to obscure task names for privacy. 22 | - **Customizable UI:** 23 | - Colors and keybindings are configurable via `config.yml`. 24 | - Toggle keybind help bar and UI wrap mode. 25 | 26 | ## Getting Started 27 | 28 | 1. **Build and Run:** 29 | For this you can either clone the repo and use: 30 | ```sh 31 | cargo run --release 32 | ``` 33 | Or, you can run 34 | ``` 35 | cargo install taskim 36 | taskim 37 | ``` 38 | 3. **Configuration:** 39 | - Copy or edit config.yml in the project root to customize appearance and controls. 40 | 4. **Exit** 41 | - Quit with `q` or command mode `:wq` 42 | 43 | ## Motivation / Next Steps 44 | The goal of this TUI was to replicate the features of the previous [task manager](https://github.com/RohanAdwankar/task-js) I have been using but be fully usable without a mouse using VIM motions. 45 | 46 | At this point, the TUI is usable for me, but if there is some feature you would like to see, please let me know! (open an issue or PR) 47 | 48 | That being said, here are some goals for the future: 49 | 50 | - Full vim motions 51 | 52 | Right now the traversal is just what i ended up needing. 53 | 54 | Thereby it doesnt support '3j' in task traversal for example. 55 | 56 | It also doesnt support vim motions in the task edit view. 57 | 58 | Since I don't need it, I will add it if someone asks for it. 59 | 60 | - Migrate JS App Features 61 | 62 | There are several features missing currently like: 63 | * Search Bar 64 | * Activity Tracker 65 | * Alternative Task Views 66 | 67 | ### Command Mode (`:`) Reference 68 | 69 | - `:q`, `:quit`, `:wq`, `:x` 70 | Quit the application. 71 | 72 | - `:help`, `:help ` 73 | Show help for command mode. 74 | 75 | - `:seekeys`, `:set seekeys` 76 | Show keybindings bar. 77 | 78 | - `:nokeys`, `:set nokeys` 79 | Hide keybindings bar. 80 | 81 | - `:wrap`, `:set wrap` 82 | Enable UI text wrapping. 83 | 84 | - `:nowrap`, `:set nowrap` 85 | Disable UI text wrapping. 86 | 87 | - `:MM/DD/YYYY`, `:YYYY-MM-DD`, `:DD`, `:YYYY` 88 | Jump to a specific date in the calendar. 89 | 90 | ### Config Reference 91 | - For the color customization options outside of the named colors, I use the Ratatui indexed colors. You can see how the numbers correspond to the colors [here](https://github.com/ratatui/ratatui/blob/main/examples/README.md#color-explorer). 92 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | # Taskim Configuration File 2 | # Edit this file to customize your Taskim experience 3 | 4 | # UI settings 5 | show_keybinds: false # Whether to show keybinds in the UI by default 6 | wrap_text: false # Whether to wrap text by default 7 | 8 | # UI Colors (use ratatui color names or indexed colors (16-231)) 9 | colors: 10 | selected_task_bg: "Gray" 11 | selected_task_fg: "Black" 12 | selected_task_bold: true 13 | selected_completed_task_bg: "DarkGray" 14 | selected_completed_task_fg: "Green" 15 | completed_task_fg: "Green" 16 | default_task_fg: "White" 17 | day_number_fg: "White" 18 | default_bg: "Black" 19 | default_fg: "White" 20 | 21 | # Task Edit Popup Colors (use ratatui color names) 22 | task_edit_colors: 23 | popup_bg: "Black" 24 | popup_fg: "White" 25 | border_fg: "DarkGray" 26 | border_selected_fg: "Gray" 27 | title_fg: "White" 28 | title_selected_fg: "White" 29 | content_fg: "White" 30 | content_selected_fg: "White" 31 | instructions_fg: "Gray" 32 | instructions_key_fg: "White" 33 | 34 | # Keybindings (use string format, e.g. 'h', 'j', 'k', 'l', 'Ctrl+r', 'Esc') 35 | keybindings: 36 | move_left: "h" 37 | move_down: "j" 38 | move_up: "k" 39 | move_right: "l" 40 | insert_edit: "i" 41 | insert_above: "O" 42 | insert_below: "o" 43 | delete: "x" 44 | delete_line: "d" 45 | toggle_complete: "c" 46 | yank: "y" 47 | paste: "p" 48 | paste_above: "P" 49 | undo: "u" 50 | redo: "Ctrl+r" 51 | next_month: "L" 52 | prev_month: "H" 53 | next_year: "G" 54 | prev_year: "g" 55 | next_week: "w" 56 | prev_week: "b" 57 | first_day_of_month: "0" 58 | last_day_of_month: "$" 59 | go_to_today: "t" 60 | save_task: "Enter" 61 | cancel_edit: "Esc" 62 | switch_field: "Tab" 63 | backspace: "Backspace" 64 | quit: "q" 65 | quit_alt: "Esc" 66 | force_quit: "Ctrl+c" 67 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RohanAdwankar/taskim/b5c077561c61407535f0fbd73d483922a55ebb45/demo.gif -------------------------------------------------------------------------------- /demo.tape: -------------------------------------------------------------------------------- 1 | Output demo.gif 2 | Set FontSize 16 3 | Set Width 1200 4 | Set Height 800 5 | Set Shell bash 6 | 7 | Type "cargo run --release" 8 | Enter 9 | Sleep 3.5s 10 | 11 | Type ": use hjkl to traverse around" 12 | Sleep 1.5s 13 | Enter 14 | Enter 15 | Sleep 0.5s 16 | Type "l" 17 | Sleep 0.5s 18 | Type "j" 19 | Sleep 0.2s 20 | Type "j" 21 | Sleep 0.3s 22 | Type "h" 23 | Sleep 0.5s 24 | Type "k" 25 | Sleep 1s 26 | 27 | Type ": use H,L,:DD,t for fast traversal" 28 | Sleep 1.5s 29 | Enter 30 | Enter 31 | Sleep 1.5s 32 | Type "H" 33 | Sleep 1.5s 34 | Type "L" 35 | Sleep 1.5s 36 | Type "t" 37 | Sleep 1.5s 38 | Type ":20" 39 | Sleep 1.5s 40 | Enter 41 | 42 | Type ": press i to add a task" 43 | Sleep 2s 44 | Enter 45 | Enter 46 | Sleep 1s 47 | Type "i" 48 | Sleep 2s 49 | Type "Hi, new task!" 50 | Sleep 1s 51 | Tab@500ms 52 | Sleep 2s 53 | Type "Put any comments in here" 54 | Sleep 2s 55 | Enter 56 | Sleep 2s 57 | Type "j" 58 | 59 | Type ": use x or dd to delete tasks" 60 | Sleep 2s 61 | Enter 62 | Enter 63 | Sleep 1s 64 | Type "x" 65 | Sleep 1s 66 | 67 | Type ":press u to undo previous action" 68 | Sleep 2s 69 | Enter 70 | Enter 71 | Sleep 1s 72 | Type "u" 73 | Sleep 1s 74 | 75 | Type ": press c to toggle task completion" 76 | Sleep 2s 77 | Enter 78 | Enter 79 | Type "c" 80 | Sleep 1s 81 | Type "x" 82 | Sleep 0.5s 83 | 84 | Type ":seekeys" 85 | Sleep 2s 86 | Enter 87 | Sleep 1s 88 | 89 | Type ":nokeys" 90 | Sleep 2s 91 | Enter 92 | Sleep 1s 93 | 94 | Type ":wrap" 95 | Sleep 2s 96 | Enter 97 | Sleep 1s 98 | 99 | Type ":nowrap" 100 | Sleep 2s 101 | Enter 102 | Sleep 1s 103 | 104 | Type ": use the s key to scramble the text" 105 | Sleep 2s 106 | Enter 107 | Enter 108 | Type "s" 109 | Sleep 2s 110 | Type "s" 111 | Sleep 1s 112 | Type "s" 113 | Sleep 0.5s 114 | Type "s" 115 | Sleep 0.2s 116 | Type "s" 117 | Sleep 1s 118 | 119 | Type ":q" 120 | Sleep 1s 121 | Enter 122 | Sleep 1s 123 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | pub struct CommandInfo { 4 | pub description: &'static str, 5 | pub exec: fn(&mut crate::App, &str) -> Result<(), String>, 6 | } 7 | 8 | pub fn get_command_registry() -> HashMap<&'static str, CommandInfo> { 9 | let mut map = HashMap::new(); 10 | // Quit commands 11 | map.insert("q", CommandInfo { 12 | description: "Quit the application.", 13 | exec: |app, _| { app.should_exit = true; Ok(()) }, 14 | }); 15 | map.insert("quit", CommandInfo { 16 | description: "Quit the application.", 17 | exec: |app, _| { app.should_exit = true; Ok(()) }, 18 | }); 19 | map.insert("wq", CommandInfo { 20 | description: "Quit the application.", 21 | exec: |app, _| { app.should_exit = true; Ok(()) }, 22 | }); 23 | map.insert("x", CommandInfo { 24 | description: "Quit the application.", 25 | exec: |app, _| { app.should_exit = true; Ok(()) }, 26 | }); 27 | // Help command is handled specially in main.rs 28 | map.insert("seekeys", CommandInfo { 29 | description: "Show keybindings bar.", 30 | exec: |app, _| { app.show_keybinds = true; Ok(()) }, 31 | }); 32 | map.insert("set seekeys", CommandInfo { 33 | description: "Show keybindings bar.", 34 | exec: |app, _| { app.show_keybinds = true; Ok(()) }, 35 | }); 36 | map.insert("nokeys", CommandInfo { 37 | description: "Hide keybindings bar.", 38 | exec: |app, _| { app.show_keybinds = false; Ok(()) }, 39 | }); 40 | map.insert("set nokeys", CommandInfo { 41 | description: "Hide keybindings bar.", 42 | exec: |app, _| { app.show_keybinds = false; Ok(()) }, 43 | }); 44 | map.insert("wrap", CommandInfo { 45 | description: "Enable UI text wrapping.", 46 | exec: |app, _| { app.month_view.set_wrap(true); Ok(()) }, 47 | }); 48 | map.insert("set wrap", CommandInfo { 49 | description: "Enable UI text wrapping.", 50 | exec: |app, _| { app.month_view.set_wrap(true); Ok(()) }, 51 | }); 52 | map.insert("nowrap", CommandInfo { 53 | description: "Disable UI text wrapping.", 54 | exec: |app, _| { app.month_view.set_wrap(false); Ok(()) }, 55 | }); 56 | map.insert("set nowrap", CommandInfo { 57 | description: "Disable UI text wrapping.", 58 | exec: |app, _| { app.month_view.set_wrap(false); Ok(()) }, 59 | }); 60 | // Date navigation commands are handled in main.rs 61 | map.insert( 62 | "YYYY", 63 | CommandInfo { 64 | description: "Jump to a specific year (e.g., :2025).", 65 | exec: |_, _| Ok(()), // Handled in main.rs parse_date_command 66 | }, 67 | ); 68 | map.insert( 69 | "MM/DD/YYYY", 70 | CommandInfo { 71 | description: "Jump to a specific date (e.g., :06/15/2025).", 72 | exec: |_, _| Ok(()), // Handled in main.rs parse_date_command 73 | }, 74 | ); 75 | map.insert( 76 | "YYYY-MM-DD", 77 | CommandInfo { 78 | description: "Jump to a specific date (e.g., :2025-06-15).", 79 | exec: |_, _| Ok(()), // Handled in main.rs parse_date_command 80 | }, 81 | ); 82 | map.insert( 83 | "DD", 84 | CommandInfo { 85 | description: "Jump to a specific day in the current month (e.g., :15).", 86 | exec: |_, _| Ok(()), // Handled in main.rs parse_date_command 87 | }, 88 | ); 89 | map 90 | } 91 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | // Taskim Configuration 2 | // Edit this file to customize your keybindings 3 | 4 | use crossterm::event::{KeyCode, KeyModifiers}; 5 | use ratatui::style::Color; 6 | use serde::Deserialize; 7 | use serde_yaml::Value; 8 | use std::collections::HashMap; 9 | use std::fs; 10 | use std::path::Path; 11 | 12 | // --- YAML config file struct --- 13 | #[derive(Debug, Clone, Deserialize)] 14 | pub struct ConfigFile { 15 | pub show_keybinds: Option, 16 | pub colors: Option>, 17 | pub task_edit_colors: Option>, 18 | pub keybindings: Option>, 19 | } 20 | 21 | // --- Runtime keybinding struct --- 22 | #[derive(Debug, Clone, PartialEq)] 23 | pub struct KeyBinding { 24 | pub key: KeyCode, 25 | pub modifiers: KeyModifiers, 26 | pub description: String, 27 | pub color: Color, 28 | } 29 | 30 | impl KeyBinding { 31 | pub fn matches(&self, key: KeyCode, modifiers: KeyModifiers) -> bool { 32 | self.key == key && self.modifiers == modifiers 33 | } 34 | } 35 | 36 | // --- Runtime config struct --- 37 | #[derive(Debug, Clone)] 38 | pub struct UiColors { 39 | pub default_fg: Color, 40 | pub default_bg: Color, 41 | pub default_task_fg: Color, 42 | pub day_number_fg: Color, 43 | pub selected_task_fg: Color, 44 | pub selected_task_bg: Color, 45 | pub completed_task_fg: Color, 46 | pub selected_completed_task_bg: Color, 47 | pub selected_completed_task_fg: Color, 48 | pub selected_task_bold: bool, 49 | // Add more fields as needed 50 | } 51 | 52 | #[derive(Debug, Clone)] 53 | pub struct TaskEditColors { 54 | pub popup_bg: Color, 55 | pub popup_fg: Color, 56 | pub border_fg: Color, 57 | pub border_selected_fg: Color, 58 | pub title_fg: Color, 59 | pub title_selected_fg: Color, 60 | pub content_fg: Color, 61 | pub content_selected_fg: Color, 62 | pub instructions_fg: Color, 63 | pub instructions_key_fg: Color, 64 | } 65 | 66 | #[derive(Debug, Clone)] 67 | pub struct Config { 68 | // Navigation 69 | pub move_left: KeyBinding, 70 | pub move_down: KeyBinding, 71 | pub move_up: KeyBinding, 72 | pub move_right: KeyBinding, 73 | // Task operations 74 | pub insert_edit: KeyBinding, 75 | pub insert_above: KeyBinding, 76 | pub insert_below: KeyBinding, 77 | pub delete: KeyBinding, 78 | pub delete_line: KeyBinding, 79 | pub toggle_complete: KeyBinding, 80 | pub yank: KeyBinding, 81 | pub paste: KeyBinding, 82 | pub paste_above: KeyBinding, 83 | // Undo/Redo 84 | pub undo: KeyBinding, 85 | pub redo: KeyBinding, 86 | // Month/Year navigation 87 | pub next_month: KeyBinding, 88 | pub prev_month: KeyBinding, 89 | pub next_year: KeyBinding, 90 | pub prev_year: KeyBinding, 91 | // Week navigation 92 | pub next_week: KeyBinding, 93 | pub prev_week: KeyBinding, 94 | // Day navigation 95 | pub first_day_of_month: KeyBinding, 96 | pub last_day_of_month: KeyBinding, 97 | // Go to today 98 | pub go_to_today: KeyBinding, 99 | // Task editing 100 | pub save_task: KeyBinding, 101 | pub cancel_edit: KeyBinding, 102 | pub switch_field: KeyBinding, 103 | pub backspace: KeyBinding, 104 | // App control 105 | pub quit: KeyBinding, 106 | pub quit_alt: KeyBinding, 107 | pub force_quit: KeyBinding, 108 | // New config fields 109 | pub show_keybinds: bool, 110 | pub ui_colors: UiColors, 111 | pub task_edit_colors: TaskEditColors, 112 | } 113 | 114 | impl Config { 115 | pub fn from_file_or_default>(path: P) -> Self { 116 | let file = ConfigFile::load_from_yaml(&path); 117 | Self::from_config_file(file) 118 | } 119 | pub fn from_config_file(file: Option) -> Self { 120 | let show_keybinds = file.as_ref().and_then(|f| f.show_keybinds).unwrap_or(true); 121 | let colors = file.as_ref().and_then(|f| f.colors.as_ref()).cloned(); 122 | let task_edit_colors_map = file 123 | .as_ref() 124 | .and_then(|f| f.task_edit_colors.as_ref()) 125 | .cloned(); 126 | let keybindings_yaml = file.as_ref().and_then(|f| f.keybindings.as_ref()); 127 | // Build default keybindings as a HashMap 128 | let default_keybindings = default_keybindings(); 129 | let mut keybindings_map = std::collections::HashMap::new(); 130 | for (name, default) in default_keybindings.iter() { 131 | let yaml_val = keybindings_yaml.and_then(|m| m.get(*name)); 132 | keybindings_map.insert(*name, parse_keybinding(yaml_val, default)); 133 | } 134 | let ui_colors = UiColors { 135 | default_fg: parse_color(&colors, "default_fg", Color::White), 136 | default_bg: parse_color(&colors, "default_bg", Color::Black), 137 | default_task_fg: parse_color(&colors, "default_task_fg", Color::White), 138 | day_number_fg: parse_color(&colors, "day_number_fg", Color::White), 139 | selected_task_fg: parse_color(&colors, "selected_task_fg", Color::Black), 140 | selected_task_bg: parse_color(&colors, "selected_task_bg", Color::Gray), 141 | completed_task_fg: parse_color(&colors, "completed_task_fg", Color::Green), 142 | selected_completed_task_bg: parse_color( 143 | &colors, 144 | "selected_completed_task_bg", 145 | Color::DarkGray, 146 | ), 147 | selected_completed_task_fg: parse_color( 148 | &colors, 149 | "selected_completed_task_fg", 150 | Color::Green, 151 | ), 152 | selected_task_bold: parse_bool(&(&colors), "selected_task_bold", true), 153 | }; 154 | let task_edit_colors = TaskEditColors { 155 | popup_bg: parse_color(&task_edit_colors_map, "popup_bg", Color::Black), 156 | popup_fg: parse_color(&task_edit_colors_map, "popup_fg", Color::White), 157 | border_fg: parse_color(&task_edit_colors_map, "border_fg", Color::White), 158 | border_selected_fg: parse_color( 159 | &task_edit_colors_map, 160 | "border_selected_fg", 161 | Color::Blue, 162 | ), 163 | title_fg: parse_color(&task_edit_colors_map, "title_fg", Color::White), 164 | title_selected_fg: parse_color(&task_edit_colors_map, "title_selected_fg", Color::Blue), 165 | content_fg: parse_color(&task_edit_colors_map, "content_fg", Color::White), 166 | content_selected_fg: parse_color( 167 | &task_edit_colors_map, 168 | "content_selected_fg", 169 | Color::Blue, 170 | ), 171 | instructions_fg: parse_color(&task_edit_colors_map, "instructions_fg", Color::Gray), 172 | instructions_key_fg: parse_color( 173 | &task_edit_colors_map, 174 | "instructions_key_fg", 175 | Color::Blue, 176 | ), 177 | }; 178 | Config { 179 | move_left: keybindings_map["move_left"].clone(), 180 | move_down: keybindings_map["move_down"].clone(), 181 | move_up: keybindings_map["move_up"].clone(), 182 | move_right: keybindings_map["move_right"].clone(), 183 | insert_edit: keybindings_map["insert_edit"].clone(), 184 | insert_above: keybindings_map["insert_above"].clone(), 185 | insert_below: keybindings_map["insert_below"].clone(), 186 | delete: keybindings_map["delete"].clone(), 187 | delete_line: keybindings_map["delete_line"].clone(), 188 | toggle_complete: keybindings_map["toggle_complete"].clone(), 189 | yank: keybindings_map["yank"].clone(), 190 | paste: keybindings_map["paste"].clone(), 191 | paste_above: keybindings_map["paste_above"].clone(), 192 | undo: keybindings_map["undo"].clone(), 193 | redo: keybindings_map["redo"].clone(), 194 | next_month: keybindings_map["next_month"].clone(), 195 | prev_month: keybindings_map["prev_month"].clone(), 196 | next_year: keybindings_map["next_year"].clone(), 197 | prev_year: keybindings_map["prev_year"].clone(), 198 | next_week: keybindings_map["next_week"].clone(), 199 | prev_week: keybindings_map["prev_week"].clone(), 200 | first_day_of_month: keybindings_map["first_day_of_month"].clone(), 201 | last_day_of_month: keybindings_map["last_day_of_month"].clone(), 202 | go_to_today: keybindings_map["go_to_today"].clone(), 203 | save_task: keybindings_map["save_task"].clone(), 204 | cancel_edit: keybindings_map["cancel_edit"].clone(), 205 | switch_field: keybindings_map["switch_field"].clone(), 206 | backspace: keybindings_map["backspace"].clone(), 207 | quit: keybindings_map["quit"].clone(), 208 | quit_alt: keybindings_map["quit_alt"].clone(), 209 | force_quit: keybindings_map["force_quit"].clone(), 210 | show_keybinds, 211 | ui_colors, 212 | task_edit_colors, 213 | } 214 | } 215 | } 216 | 217 | // Helper functions for UI 218 | impl Config { 219 | pub fn get_normal_mode_help_spans( 220 | &self, 221 | can_undo: bool, 222 | can_redo: bool, 223 | ) -> Vec> { 224 | use ratatui::{style::Style, text::Span}; 225 | 226 | let mut spans = Vec::new(); 227 | 228 | // Movement keys (show as combined) 229 | spans.push(Span::styled("hjkl", Style::default().fg(Color::Green))); 230 | spans.push(Span::raw(": Move | ")); 231 | 232 | // Task operations 233 | spans.push(Span::styled( 234 | "i", 235 | Style::default().fg(self.insert_edit.color), 236 | )); 237 | spans.push(Span::raw(": Insert/Edit | ")); 238 | spans.push(Span::styled("x", Style::default().fg(self.delete.color))); 239 | spans.push(Span::raw(": Delete | ")); 240 | spans.push(Span::styled( 241 | "c", 242 | Style::default().fg(self.toggle_complete.color), 243 | )); 244 | spans.push(Span::raw(": Toggle Complete | ")); 245 | 246 | // Yank/Paste 247 | spans.push(Span::styled("y", Style::default().fg(self.yank.color))); 248 | spans.push(Span::raw(": Yank | ")); 249 | spans.push(Span::styled("p", Style::default().fg(self.paste.color))); 250 | spans.push(Span::raw(": Paste | ")); 251 | 252 | // Undo/Redo (only show if available) 253 | if can_undo { 254 | spans.push(Span::styled("u", Style::default().fg(self.undo.color))); 255 | spans.push(Span::raw(": Undo | ")); 256 | } 257 | if can_redo { 258 | spans.push(Span::styled("Ctrl+r", Style::default().fg(self.redo.color))); 259 | spans.push(Span::raw(": Redo | ")); 260 | } 261 | 262 | // Month/Year navigation (vim-style) 263 | spans.push(Span::styled("H/L", Style::default().fg(Color::Cyan))); 264 | spans.push(Span::raw(": Month | ")); 265 | spans.push(Span::styled("gg/G", Style::default().fg(Color::Cyan))); 266 | spans.push(Span::raw(": Year | ")); 267 | 268 | // Week navigation 269 | spans.push(Span::styled( 270 | "w/b", 271 | Style::default().fg(self.next_week.color), 272 | )); 273 | spans.push(Span::raw(": Week | ")); 274 | 275 | // Day navigation 276 | spans.push(Span::styled( 277 | "0/$", 278 | Style::default().fg(self.first_day_of_month.color), 279 | )); 280 | spans.push(Span::raw(": Day | ")); 281 | 282 | // Quit 283 | spans.push(Span::styled("q", Style::default().fg(self.quit.color))); 284 | spans.push(Span::raw(": Quit")); 285 | 286 | spans 287 | } 288 | 289 | pub fn get_edit_mode_help_spans(&self) -> Vec> { 290 | use ratatui::{style::Style, text::Span}; 291 | 292 | vec![ 293 | Span::styled("Tab", Style::default().fg(self.switch_field.color)), 294 | Span::raw(": Switch field | "), 295 | Span::styled("Enter", Style::default().fg(self.save_task.color)), 296 | Span::raw(": Save | "), 297 | Span::styled("Esc", Style::default().fg(self.cancel_edit.color)), 298 | Span::raw(": Cancel"), 299 | ] 300 | } 301 | } 302 | 303 | impl ConfigFile { 304 | pub fn load_from_yaml>(path: P) -> Option { 305 | let content = fs::read_to_string(path).ok()?; 306 | serde_yaml::from_str(&content).ok() 307 | } 308 | } 309 | 310 | fn parse_color(map: &Option>, key: &str, default: Color) -> Color { 311 | map.as_ref() 312 | .and_then(|m| m.get(key)) 313 | .map(|s| parse_color_name(s)) 314 | .unwrap_or(default) 315 | } 316 | 317 | fn parse_color_name(name: &str) -> Color { 318 | // Try to parse as integer for indexed color 319 | if let Ok(idx) = name.parse::() { 320 | return Color::Indexed(idx); 321 | } 322 | match name.to_lowercase().as_str() { 323 | "black" => Color::Black, 324 | "red" => Color::Red, 325 | "green" => Color::Green, 326 | "yellow" => Color::Yellow, 327 | "blue" => Color::Blue, 328 | "magenta" => Color::Magenta, 329 | "cyan" => Color::Cyan, 330 | "gray" => Color::Gray, 331 | "darkgray" => Color::DarkGray, 332 | "white" => Color::White, 333 | _ => Color::White, 334 | } 335 | } 336 | 337 | fn parse_bool(map: &&Option>, key: &str, default: bool) -> bool { 338 | map.as_ref() 339 | .and_then(|m| m.get(key)) 340 | .and_then(|s| s.parse::().ok()) 341 | .unwrap_or(default) 342 | } 343 | 344 | fn parse_keybinding(yaml: Option<&Value>, default: &KeyBinding) -> KeyBinding { 345 | match yaml { 346 | Some(Value::String(s)) => { 347 | // Only key is overridden 348 | KeyBinding { 349 | key: parse_key_code(s), 350 | ..default.clone() 351 | } 352 | } 353 | Some(Value::Sequence(seq)) => { 354 | let key = seq 355 | .get(0) 356 | .and_then(|v| v.as_str()) 357 | .map(parse_key_code) 358 | .unwrap_or(default.key); 359 | let modifiers = seq 360 | .get(1) 361 | .and_then(|v| v.as_str()) 362 | .map(parse_modifiers) 363 | .unwrap_or(default.modifiers); 364 | let description = seq 365 | .get(2) 366 | .and_then(|v| v.as_str()) 367 | .map(|s| s.to_string()) 368 | .unwrap_or_else(|| default.description.clone()); 369 | let color = seq 370 | .get(3) 371 | .and_then(|v| v.as_str()) 372 | .map(|c| parse_color_name(c)) 373 | .unwrap_or(default.color); 374 | KeyBinding { 375 | key, 376 | modifiers, 377 | description, 378 | color, 379 | } 380 | } 381 | _ => default.clone(), 382 | } 383 | } 384 | 385 | fn parse_key_code(s: &str) -> KeyCode { 386 | match s.to_lowercase().as_str() { 387 | "enter" => KeyCode::Enter, 388 | "esc" | "escape" => KeyCode::Esc, 389 | "tab" => KeyCode::Tab, 390 | "backspace" => KeyCode::Backspace, 391 | "$" => KeyCode::Char('$'), 392 | _ if s.len() == 1 => KeyCode::Char(s.chars().next().unwrap()), 393 | _ => KeyCode::Null, 394 | } 395 | } 396 | 397 | fn parse_modifiers(s: &str) -> KeyModifiers { 398 | match s.to_uppercase().as_str() { 399 | "SHIFT" => KeyModifiers::SHIFT, 400 | "CTRL" | "CONTROL" => KeyModifiers::CONTROL, 401 | "ALT" => KeyModifiers::ALT, 402 | _ => KeyModifiers::NONE, 403 | } 404 | } 405 | 406 | fn default_keybindings() -> std::collections::HashMap<&'static str, KeyBinding> { 407 | let mut map = std::collections::HashMap::new(); 408 | map.insert( 409 | "move_left", 410 | KeyBinding { 411 | key: KeyCode::Char('h'), 412 | modifiers: KeyModifiers::NONE, 413 | description: String::from("Move"), 414 | color: Color::Green, 415 | }, 416 | ); 417 | map.insert( 418 | "move_down", 419 | KeyBinding { 420 | key: KeyCode::Char('j'), 421 | modifiers: KeyModifiers::NONE, 422 | description: String::from("Move"), 423 | color: Color::Green, 424 | }, 425 | ); 426 | map.insert( 427 | "move_up", 428 | KeyBinding { 429 | key: KeyCode::Char('k'), 430 | modifiers: KeyModifiers::NONE, 431 | description: String::from("Move"), 432 | color: Color::Green, 433 | }, 434 | ); 435 | map.insert( 436 | "move_right", 437 | KeyBinding { 438 | key: KeyCode::Char('l'), 439 | modifiers: KeyModifiers::NONE, 440 | description: String::from("Move"), 441 | color: Color::Green, 442 | }, 443 | ); 444 | map.insert( 445 | "insert_edit", 446 | KeyBinding { 447 | key: KeyCode::Char('i'), 448 | modifiers: KeyModifiers::NONE, 449 | description: String::from("Insert/Edit"), 450 | color: Color::Green, 451 | }, 452 | ); 453 | map.insert( 454 | "insert_above", 455 | KeyBinding { 456 | key: KeyCode::Char('O'), 457 | modifiers: KeyModifiers::SHIFT, 458 | description: String::from("Insert Above"), 459 | color: Color::Green, 460 | }, 461 | ); 462 | map.insert( 463 | "insert_below", 464 | KeyBinding { 465 | key: KeyCode::Char('o'), 466 | modifiers: KeyModifiers::NONE, 467 | description: String::from("Insert Below"), 468 | color: Color::Green, 469 | }, 470 | ); 471 | map.insert( 472 | "delete", 473 | KeyBinding { 474 | key: KeyCode::Char('x'), 475 | modifiers: KeyModifiers::NONE, 476 | description: String::from("Delete"), 477 | color: Color::Red, 478 | }, 479 | ); 480 | map.insert( 481 | "delete_line", 482 | KeyBinding { 483 | key: KeyCode::Char('d'), 484 | modifiers: KeyModifiers::NONE, 485 | description: String::from("Cut Task (dd)"), 486 | color: Color::Red, 487 | }, 488 | ); 489 | map.insert( 490 | "toggle_complete", 491 | KeyBinding { 492 | key: KeyCode::Char('c'), 493 | modifiers: KeyModifiers::NONE, 494 | description: String::from("Toggle Complete"), 495 | color: Color::Blue, 496 | }, 497 | ); 498 | map.insert( 499 | "yank", 500 | KeyBinding { 501 | key: KeyCode::Char('y'), 502 | modifiers: KeyModifiers::NONE, 503 | description: String::from("Yank (Copy)"), 504 | color: Color::Yellow, 505 | }, 506 | ); 507 | map.insert( 508 | "paste", 509 | KeyBinding { 510 | key: KeyCode::Char('p'), 511 | modifiers: KeyModifiers::NONE, 512 | description: String::from("Paste"), 513 | color: Color::Yellow, 514 | }, 515 | ); 516 | map.insert( 517 | "paste_above", 518 | KeyBinding { 519 | key: KeyCode::Char('P'), 520 | modifiers: KeyModifiers::SHIFT, 521 | description: String::from("Paste Above"), 522 | color: Color::Yellow, 523 | }, 524 | ); 525 | map.insert( 526 | "undo", 527 | KeyBinding { 528 | key: KeyCode::Char('u'), 529 | modifiers: KeyModifiers::NONE, 530 | description: String::from("Undo"), 531 | color: Color::Magenta, 532 | }, 533 | ); 534 | map.insert( 535 | "redo", 536 | KeyBinding { 537 | key: KeyCode::Char('r'), 538 | modifiers: KeyModifiers::CONTROL, 539 | description: String::from("Redo"), 540 | color: Color::Magenta, 541 | }, 542 | ); 543 | map.insert( 544 | "next_month", 545 | KeyBinding { 546 | key: KeyCode::Char('L'), 547 | modifiers: KeyModifiers::SHIFT, 548 | description: String::from("Next Month"), 549 | color: Color::Cyan, 550 | }, 551 | ); 552 | map.insert( 553 | "prev_month", 554 | KeyBinding { 555 | key: KeyCode::Char('H'), 556 | modifiers: KeyModifiers::SHIFT, 557 | description: String::from("Prev Month"), 558 | color: Color::Cyan, 559 | }, 560 | ); 561 | map.insert( 562 | "next_year", 563 | KeyBinding { 564 | key: KeyCode::Char('G'), 565 | modifiers: KeyModifiers::SHIFT, 566 | description: String::from("Last Year"), 567 | color: Color::Cyan, 568 | }, 569 | ); 570 | map.insert( 571 | "prev_year", 572 | KeyBinding { 573 | key: KeyCode::Char('g'), 574 | modifiers: KeyModifiers::NONE, 575 | description: String::from("First Year (gg)"), 576 | color: Color::Cyan, 577 | }, 578 | ); 579 | map.insert( 580 | "next_week", 581 | KeyBinding { 582 | key: KeyCode::Char('w'), 583 | modifiers: KeyModifiers::NONE, 584 | description: String::from("Next Week"), 585 | color: Color::Blue, 586 | }, 587 | ); 588 | map.insert( 589 | "prev_week", 590 | KeyBinding { 591 | key: KeyCode::Char('b'), 592 | modifiers: KeyModifiers::NONE, 593 | description: String::from("Previous Week"), 594 | color: Color::Blue, 595 | }, 596 | ); 597 | map.insert( 598 | "first_day_of_month", 599 | KeyBinding { 600 | key: KeyCode::Char('0'), 601 | modifiers: KeyModifiers::NONE, 602 | description: String::from("First Day"), 603 | color: Color::Blue, 604 | }, 605 | ); 606 | map.insert( 607 | "last_day_of_month", 608 | KeyBinding { 609 | key: KeyCode::Char('$'), 610 | modifiers: KeyModifiers::SHIFT, 611 | description: String::from("Last Day"), 612 | color: Color::Blue, 613 | }, 614 | ); 615 | map.insert( 616 | "go_to_today", 617 | KeyBinding { 618 | key: KeyCode::Char('t'), 619 | modifiers: KeyModifiers::NONE, 620 | description: String::from("Go to Today"), 621 | color: Color::Magenta, 622 | }, 623 | ); 624 | map.insert( 625 | "save_task", 626 | KeyBinding { 627 | key: KeyCode::Enter, 628 | modifiers: KeyModifiers::NONE, 629 | description: String::from("Save"), 630 | color: Color::Green, 631 | }, 632 | ); 633 | map.insert( 634 | "cancel_edit", 635 | KeyBinding { 636 | key: KeyCode::Esc, 637 | modifiers: KeyModifiers::NONE, 638 | description: String::from("Cancel"), 639 | color: Color::Red, 640 | }, 641 | ); 642 | map.insert( 643 | "switch_field", 644 | KeyBinding { 645 | key: KeyCode::Tab, 646 | modifiers: KeyModifiers::NONE, 647 | description: String::from("Switch Field"), 648 | color: Color::Green, 649 | }, 650 | ); 651 | map.insert( 652 | "backspace", 653 | KeyBinding { 654 | key: KeyCode::Backspace, 655 | modifiers: KeyModifiers::NONE, 656 | description: String::from("Delete Char"), 657 | color: Color::Gray, 658 | }, 659 | ); 660 | map.insert( 661 | "quit", 662 | KeyBinding { 663 | key: KeyCode::Char('q'), 664 | modifiers: KeyModifiers::NONE, 665 | description: String::from("Quit"), 666 | color: Color::Red, 667 | }, 668 | ); 669 | map.insert( 670 | "quit_alt", 671 | KeyBinding { 672 | key: KeyCode::Esc, 673 | modifiers: KeyModifiers::NONE, 674 | description: String::from("Quit"), 675 | color: Color::Red, 676 | }, 677 | ); 678 | map.insert( 679 | "force_quit", 680 | KeyBinding { 681 | key: KeyCode::Char('c'), 682 | modifiers: KeyModifiers::CONTROL, 683 | description: String::from("Force Quit"), 684 | color: Color::Red, 685 | }, 686 | ); 687 | map 688 | } 689 | -------------------------------------------------------------------------------- /src/data.rs: -------------------------------------------------------------------------------- 1 | use crate::task::TaskData; 2 | use std::fs; 3 | use std::path::Path; 4 | 5 | const DATA_FILE: &str = "task_manager_data.json"; 6 | 7 | pub fn load_data() -> TaskData { 8 | if Path::new(DATA_FILE).exists() { 9 | match fs::read_to_string(DATA_FILE) { 10 | Ok(content) => { 11 | match serde_json::from_str(&content) { 12 | Ok(data) => data, 13 | Err(e) => { 14 | eprintln!("Error parsing data file: {}", e); 15 | TaskData::default() 16 | } 17 | } 18 | } 19 | Err(e) => { 20 | eprintln!("Error reading data file: {}", e); 21 | TaskData::default() 22 | } 23 | } 24 | } else { 25 | TaskData::default() 26 | } 27 | } 28 | 29 | pub fn save_data(data: &TaskData) -> Result<(), color_eyre::eyre::Error> { 30 | let content = serde_json::to_string_pretty(data)?; 31 | fs::write(DATA_FILE, content)?; 32 | Ok(()) 33 | } 34 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod data; 3 | mod month_view; 4 | mod task; 5 | mod task_edit; 6 | mod undo; 7 | mod utils; 8 | mod commands; 9 | 10 | use crate::data::{load_data, save_data}; 11 | use crate::month_view::{render_month_view, MonthView, SelectionType}; 12 | use crate::task::TaskData; 13 | use crate::task_edit::{render_task_edit_popup, TaskEditState}; 14 | use crate::undo::{Operation, UndoStack}; 15 | use crate::utils::days_in_month; 16 | use commands::get_command_registry; 17 | 18 | use chrono::{Datelike, Local, Timelike}; 19 | use color_eyre::Result; 20 | use crossterm::event::{self, Event, KeyCode, KeyModifiers}; 21 | use ratatui::{ 22 | layout::{Constraint, Layout, Rect}, 23 | style::Style, 24 | text::{Line, Span}, 25 | widgets::Paragraph, 26 | DefaultTerminal, Frame, 27 | }; 28 | 29 | #[derive(Debug, Clone, PartialEq)] 30 | enum AppMode { 31 | Normal, 32 | TaskEdit(TaskEditState), 33 | Command(CommandState), 34 | } 35 | 36 | #[derive(Debug, Clone, PartialEq)] 37 | struct CommandState { 38 | input: String, 39 | cursor_position: usize, 40 | show_help: bool, 41 | last_error: Option, 42 | } 43 | 44 | impl CommandState { 45 | fn new() -> Self { 46 | Self { 47 | input: String::new(), 48 | cursor_position: 0, 49 | show_help: false, 50 | last_error: None, 51 | } 52 | } 53 | 54 | fn add_char(&mut self, ch: char) { 55 | self.input.insert(self.cursor_position, ch); 56 | self.cursor_position += 1; 57 | } 58 | 59 | fn remove_char(&mut self) { 60 | if self.cursor_position > 0 { 61 | self.cursor_position -= 1; 62 | self.input.remove(self.cursor_position); 63 | } 64 | } 65 | 66 | fn move_cursor_left(&mut self) { 67 | self.cursor_position = self.cursor_position.saturating_sub(1); 68 | } 69 | 70 | fn move_cursor_right(&mut self) { 71 | if self.cursor_position < self.input.len() { 72 | self.cursor_position += 1; 73 | } 74 | } 75 | } 76 | 77 | struct App { 78 | mode: AppMode, 79 | data: TaskData, 80 | month_view: MonthView, 81 | should_exit: bool, 82 | undo_stack: UndoStack, 83 | yanked_task: Option, // Store yanked task for paste operation 84 | pending_key: Option, // For handling multi-key sequences like 'gg' 85 | pending_insert_order: Option, // For tracking task insertion order 86 | scramble_mode: bool, // Toggle for scrambling task names with numbers 87 | config: crate::config::Config, // <-- add config field 88 | show_keybinds: bool, // runtime toggle for keybind help 89 | } 90 | 91 | impl App { 92 | fn new() -> Self { 93 | let data = load_data(); 94 | let current_date = Local::now().date_naive(); 95 | let month_view = MonthView::new(current_date); 96 | let config = crate::config::Config::from_file_or_default("config.yml"); 97 | let show_keybinds = config.show_keybinds; 98 | Self { 99 | mode: AppMode::Normal, 100 | data, 101 | month_view, 102 | should_exit: false, 103 | undo_stack: UndoStack::new(50), // Allow up to 50 undo operations 104 | yanked_task: None, 105 | pending_key: None, 106 | pending_insert_order: None, 107 | scramble_mode: false, 108 | config, 109 | show_keybinds, 110 | } 111 | } 112 | 113 | fn save(&self) -> Result<()> { 114 | save_data(&self.data).map_err(|e| color_eyre::eyre::eyre!(e))?; 115 | Ok(()) 116 | } 117 | 118 | fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 119 | match &self.mode { 120 | AppMode::Normal => self.handle_normal_mode_key(key)?, 121 | AppMode::Command(state) => { 122 | let mut new_state = state.clone(); 123 | if self.handle_command_mode_key(key, &mut new_state)? { 124 | // Command completed or cancelled 125 | self.mode = AppMode::Normal; 126 | } else { 127 | self.mode = AppMode::Command(new_state); 128 | } 129 | } 130 | AppMode::TaskEdit(state) => { 131 | let mut new_state = state.clone(); 132 | if self.handle_task_edit_key(key, &mut new_state)? { 133 | // Task edit completed 134 | let mut task = new_state.to_task(); 135 | if new_state.is_new_task { 136 | // Use pending insert order if set (for 'o' and 'O' commands) 137 | if let Some(insert_order) = self.pending_insert_order.take() { 138 | self.data.insert_task_at_order(task.clone(), insert_order); 139 | 140 | // Select the new task by its order 141 | let task_date = task.start.date_naive(); 142 | self.month_view.select_task_by_order( 143 | task_date, 144 | insert_order, 145 | &self.data.events, 146 | ); 147 | } else { 148 | // Regular insertion (for 'i' command) - add to end 149 | let task_date = task.start.date_naive(); 150 | task.order = self.data.max_order_for_date(task_date) + 1; 151 | self.data.events.push(task.clone()); 152 | } 153 | 154 | // Track task creation 155 | self.undo_stack 156 | .push(Operation::CreateTask { task: task.clone() }); 157 | } else { 158 | // Track task edit 159 | if let Some(existing) = self 160 | .data 161 | .events 162 | .iter_mut() 163 | .find(|t| Some(&t.id) == new_state.task_id.as_ref()) 164 | { 165 | let old_task = existing.clone(); 166 | *existing = task.clone(); 167 | 168 | self.undo_stack.push(Operation::EditTask { 169 | task_id: task.id.clone(), 170 | old_task, 171 | new_task: task, 172 | }); 173 | } 174 | } 175 | self.mode = AppMode::Normal; 176 | self.save()?; 177 | } else { 178 | self.mode = AppMode::TaskEdit(new_state); 179 | } 180 | } 181 | } 182 | Ok(()) 183 | } 184 | 185 | fn handle_normal_mode_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 186 | // Handle keybindings 187 | if self.config.force_quit.matches(key.code, key.modifiers) { 188 | self.should_exit = true; 189 | return Ok(()); 190 | } 191 | 192 | // Handle multi-key sequences first 193 | if let Some(pending) = self.pending_key { 194 | if pending == 'g' 195 | && key.code == KeyCode::Char('g') 196 | && key.modifiers == KeyModifiers::NONE 197 | { 198 | // Handle 'gg' - go to previous year 199 | self.month_view.prev_year(); 200 | self.pending_key = None; 201 | return Ok(()); 202 | } else if pending == 'd' 203 | && key.code == KeyCode::Char('d') 204 | && key.modifiers == KeyModifiers::NONE 205 | { 206 | // Handle 'dd' - cut the selected task (vim-style) 207 | if let Some(task_id) = self.month_view.get_selected_task_id() { 208 | if let Some(task) = self.data.remove_task_and_reorder(&task_id) { 209 | let task_date = task.start.date_naive(); 210 | 211 | // Store the cut task for pasting 212 | self.yanked_task = Some(task.clone()); 213 | 214 | // Track deletion for undo functionality 215 | self.undo_stack.push(Operation::DeleteTask { task }); 216 | 217 | // Check if there are any remaining tasks on the same date 218 | let remaining_tasks = self.data.get_tasks_for_date(task_date); 219 | 220 | if remaining_tasks.is_empty() { 221 | // No more tasks on this day, select the day itself 222 | self.month_view.selection = month_view::Selection { 223 | selection_type: month_view::SelectionType::Day(task_date), 224 | }; 225 | } else { 226 | // Select the first remaining task 227 | self.month_view.selection = month_view::Selection { 228 | selection_type: month_view::SelectionType::Task( 229 | remaining_tasks[0].id.clone(), 230 | ), 231 | }; 232 | } 233 | 234 | self.save()?; 235 | } 236 | } 237 | self.pending_key = None; 238 | return Ok(()); 239 | } 240 | // If we have a pending key but don't match, clear it and continue with normal processing 241 | self.pending_key = None; 242 | } 243 | 244 | if self.config.quit.matches(key.code, key.modifiers) 245 | || self.config.quit_alt.matches(key.code, key.modifiers) 246 | { 247 | self.should_exit = true; 248 | } else if self.config.move_left.matches(key.code, key.modifiers) { 249 | self.month_view.move_left(&self.data.events); 250 | } else if self.config.move_down.matches(key.code, key.modifiers) { 251 | self.month_view.move_down(&self.data.events); 252 | } else if self.config.move_up.matches(key.code, key.modifiers) { 253 | self.month_view.move_up(&self.data.events); 254 | } else if self.config.move_right.matches(key.code, key.modifiers) { 255 | self.month_view.move_right(&self.data.events); 256 | } else if self.config.insert_edit.matches(key.code, key.modifiers) { 257 | match &self.month_view.selection.selection_type { 258 | SelectionType::Day(date) => { 259 | // Create new task 260 | let edit_state = TaskEditState::new_task(*date); 261 | self.mode = AppMode::TaskEdit(edit_state); 262 | } 263 | SelectionType::Task(task_id) => { 264 | // Edit existing task 265 | if let Some(task) = self.data.events.iter().find(|t| &t.id == task_id) { 266 | let edit_state = TaskEditState::edit_task(task); 267 | self.mode = AppMode::TaskEdit(edit_state); 268 | } 269 | } 270 | } 271 | } else if self.config.save_task.matches(key.code, key.modifiers) { 272 | match &self.month_view.selection.selection_type { 273 | SelectionType::Task(task_id) => { 274 | // Edit existing task (same as insert_edit for task) 275 | if let Some(task) = self.data.events.iter().find(|t| &t.id == task_id) { 276 | let edit_state = TaskEditState::edit_task(task); 277 | self.mode = AppMode::TaskEdit(edit_state); 278 | } 279 | } 280 | _ => {} 281 | } 282 | } else if self.config.insert_below.matches(key.code, key.modifiers) { 283 | // Insert task below current position (vim-style: o) 284 | let selected_date = self.month_view.get_selected_date(&self.data.events); 285 | let edit_state = TaskEditState::new_task(selected_date); 286 | 287 | // Store the insertion order for when the task is created 288 | let insert_order = if let Some(current_order) = 289 | self.month_view.get_current_task_order(&self.data.events) 290 | { 291 | current_order + 1 292 | } else { 293 | self.data.max_order_for_date(selected_date) + 1 294 | }; 295 | 296 | // We'll need to track this order for when the task gets created 297 | // For now, set up the task edit state 298 | self.pending_insert_order = Some(insert_order); 299 | self.mode = AppMode::TaskEdit(edit_state); 300 | } else if self.config.insert_above.matches(key.code, key.modifiers) { 301 | // Insert task above current position (vim-style: O) 302 | let selected_date = self.month_view.get_selected_date(&self.data.events); 303 | let edit_state = TaskEditState::new_task(selected_date); 304 | 305 | // Store the insertion order for when the task is created 306 | let insert_order = if let Some(current_order) = 307 | self.month_view.get_current_task_order(&self.data.events) 308 | { 309 | current_order 310 | } else { 311 | 0 312 | }; 313 | 314 | // We'll need to track this order for when the task gets created 315 | self.pending_insert_order = Some(insert_order); 316 | self.mode = AppMode::TaskEdit(edit_state); 317 | } else if self.config.delete_line.matches(key.code, key.modifiers) { 318 | // Handle first 'd' for 'dd' sequence 319 | self.pending_key = Some('d'); 320 | } else if self.config.delete.matches(key.code, key.modifiers) { 321 | // Delete/cut the selected task (vim-style 'x') - same as 'dd' 322 | if let Some(task_id) = self.month_view.get_selected_task_id() { 323 | if let Some(deleted_task) = self.data.remove_task_and_reorder(&task_id) { 324 | let task_date = deleted_task.start.date_naive(); 325 | 326 | // Store the cut task for pasting (copy functionality) 327 | self.yanked_task = Some(deleted_task.clone()); 328 | 329 | // Track deletion for undo functionality 330 | self.undo_stack 331 | .push(Operation::DeleteTask { task: deleted_task }); 332 | 333 | // Check if there are any remaining tasks on the same date 334 | let remaining_tasks = self.data.get_tasks_for_date(task_date); 335 | 336 | if remaining_tasks.is_empty() { 337 | // No more tasks on this day, select the day itself 338 | self.month_view.selection = month_view::Selection { 339 | selection_type: month_view::SelectionType::Day(task_date), 340 | }; 341 | } else { 342 | // Select the first remaining task (ordered) 343 | self.month_view.selection = month_view::Selection { 344 | selection_type: month_view::SelectionType::Task( 345 | remaining_tasks[0].id.clone(), 346 | ), 347 | }; 348 | } 349 | 350 | self.save()?; 351 | } 352 | } 353 | } else if self.config.undo.matches(key.code, key.modifiers) { 354 | // Undo last operation 355 | if let Some(operation) = self.undo_stack.undo() { 356 | match operation { 357 | Operation::DeleteTask { task } => { 358 | // Restore deleted task 359 | self.data.events.push(task.clone()); 360 | 361 | // Select the restored task 362 | self.month_view.selection = month_view::Selection { 363 | selection_type: month_view::SelectionType::Task(task.id), 364 | }; 365 | } 366 | Operation::EditTask { 367 | task_id, 368 | old_task, 369 | new_task: _, 370 | } => { 371 | // Revert task edit 372 | if let Some(existing) = 373 | self.data.events.iter_mut().find(|t| t.id == task_id) 374 | { 375 | *existing = old_task; 376 | } 377 | } 378 | Operation::CreateTask { task } => { 379 | // Remove created task 380 | self.data.events.retain(|t| t.id != task.id); 381 | 382 | // Select the day where the task was 383 | let task_date = task.start.date_naive(); 384 | self.month_view.selection = month_view::Selection { 385 | selection_type: month_view::SelectionType::Day(task_date), 386 | }; 387 | } 388 | } 389 | self.save()?; 390 | } 391 | } else if self.config.redo.matches(key.code, key.modifiers) { 392 | // Redo last undone operation 393 | if let Some(operation) = self.undo_stack.redo() { 394 | match operation { 395 | Operation::DeleteTask { task } => { 396 | // Re-delete the task 397 | self.data.events.retain(|t| t.id != task.id); 398 | 399 | // Select the day where the task was 400 | let task_date = task.start.date_naive(); 401 | self.month_view.selection = month_view::Selection { 402 | selection_type: month_view::SelectionType::Day(task_date), 403 | }; 404 | } 405 | Operation::EditTask { 406 | task_id, 407 | old_task: _, 408 | new_task, 409 | } => { 410 | // Re-apply task edit 411 | if let Some(existing) = 412 | self.data.events.iter_mut().find(|t| t.id == task_id) 413 | { 414 | *existing = new_task; 415 | } 416 | } 417 | Operation::CreateTask { task } => { 418 | // Re-create task 419 | self.data.events.push(task.clone()); 420 | 421 | // Select the restored task 422 | self.month_view.selection = month_view::Selection { 423 | selection_type: month_view::SelectionType::Task(task.id), 424 | // task_index_in_day: Some(0), 425 | }; 426 | } 427 | } 428 | self.save()?; 429 | } 430 | } else if self.config.toggle_complete.matches(key.code, key.modifiers) { 431 | // Toggle task completion 432 | if let Some(task_id) = self.month_view.get_selected_task_id() { 433 | if let Some(task) = self.data.events.iter_mut().find(|t| t.id == task_id) { 434 | task.completed = !task.completed; 435 | self.save()?; 436 | } 437 | } 438 | } else if self.config.yank.matches(key.code, key.modifiers) { 439 | // Yank (copy) task 440 | if let Some(task_id) = self.month_view.get_selected_task_id() { 441 | if let Some(task) = self.data.events.iter().find(|t| t.id == task_id) { 442 | self.yanked_task = Some(task.clone()); 443 | } 444 | } 445 | } else if self.config.paste.matches(key.code, key.modifiers) { 446 | // Paste task below current position 447 | if let Some(yanked_task) = &self.yanked_task { 448 | let selected_date = self.month_view.get_selected_date(&self.data.events); 449 | let mut new_task = yanked_task.clone(); 450 | 451 | // Generate new ID for the pasted task 452 | new_task.id = format!( 453 | "task_{}", 454 | chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() 455 | ); 456 | 457 | // Set new start/end times for the selected date 458 | let duration = new_task.end - new_task.start; 459 | let new_start = selected_date 460 | .and_hms_opt( 461 | new_task.start.time().hour(), 462 | new_task.start.time().minute(), 463 | new_task.start.time().second(), 464 | ) 465 | .unwrap() 466 | .and_utc(); 467 | new_task.start = new_start; 468 | new_task.end = new_start + duration; 469 | 470 | // Insert task with proper ordering 471 | let insert_order = if let Some(current_order) = 472 | self.month_view.get_current_task_order(&self.data.events) 473 | { 474 | current_order + 1 475 | } else { 476 | self.data.max_order_for_date(selected_date) + 1 477 | }; 478 | 479 | self.data 480 | .insert_task_at_order(new_task.clone(), insert_order); 481 | 482 | // Track the paste operation for undo 483 | self.undo_stack.push(Operation::CreateTask { 484 | task: new_task.clone(), 485 | }); 486 | 487 | // Select the new task 488 | self.month_view.select_task_by_order( 489 | selected_date, 490 | insert_order, 491 | &self.data.events, 492 | ); 493 | self.save()?; 494 | } 495 | } else if self.config.paste_above.matches(key.code, key.modifiers) { 496 | // Paste task above current position 497 | if let Some(yanked_task) = &self.yanked_task { 498 | let selected_date = self.month_view.get_selected_date(&self.data.events); 499 | let mut new_task = yanked_task.clone(); 500 | 501 | // Generate new ID for the pasted task 502 | new_task.id = format!( 503 | "task_{}", 504 | chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() 505 | ); 506 | 507 | // Set new start/end times for the selected date 508 | let duration = new_task.end - new_task.start; 509 | let new_start = selected_date 510 | .and_hms_opt( 511 | new_task.start.time().hour(), 512 | new_task.start.time().minute(), 513 | new_task.start.time().second(), 514 | ) 515 | .unwrap() 516 | .and_utc(); 517 | new_task.start = new_start; 518 | new_task.end = new_start + duration; 519 | 520 | // Insert task with proper ordering (above current) 521 | let insert_order = if let Some(current_order) = 522 | self.month_view.get_current_task_order(&self.data.events) 523 | { 524 | current_order 525 | } else { 526 | 0 527 | }; 528 | 529 | self.data 530 | .insert_task_at_order(new_task.clone(), insert_order); 531 | 532 | // Track the paste operation for undo 533 | self.undo_stack.push(Operation::CreateTask { 534 | task: new_task.clone(), 535 | }); 536 | 537 | // Select the new task 538 | self.month_view.select_task_by_order( 539 | selected_date, 540 | insert_order, 541 | &self.data.events, 542 | ); 543 | self.save()?; 544 | } 545 | } else if self.config.next_month.matches(key.code, key.modifiers) { 546 | // Next month (vim-style: L) - preserve day 547 | self.month_view.next_month_preserve_day(); 548 | } else if self.config.prev_month.matches(key.code, key.modifiers) { 549 | // Previous month (vim-style: H) - preserve day 550 | self.month_view.prev_month_preserve_day(); 551 | } else if self.config.next_year.matches(key.code, key.modifiers) { 552 | // Next year (vim-style: G) 553 | self.month_view.next_year(); 554 | } else if self.config.prev_year.matches(key.code, key.modifiers) { 555 | // Handle first 'g' for 'gg' sequence 556 | self.pending_key = Some('g'); 557 | } else if self.config.go_to_today.matches(key.code, key.modifiers) { 558 | // Go to today (vim-style: t) 559 | self.month_view.go_to_today(); 560 | } else if self.config.next_week.matches(key.code, key.modifiers) { 561 | // Next week (vim-style: w) 562 | self.month_view.next_week(&self.data.events); 563 | } else if self.config.prev_week.matches(key.code, key.modifiers) { 564 | // Previous week (vim-style: b) 565 | self.month_view.prev_week(&self.data.events); 566 | } else if self 567 | .config 568 | .first_day_of_month 569 | .matches(key.code, key.modifiers) 570 | { 571 | // First day of month (vim-style: 0) 572 | self.month_view.first_day_of_month(); 573 | } else if self 574 | .config 575 | .last_day_of_month 576 | .matches(key.code, key.modifiers) 577 | || (key.code == KeyCode::Char('$') && key.modifiers == KeyModifiers::NONE) 578 | { 579 | // Last day of month (vim-style: $) - handle both shift+4 and direct $ 580 | self.month_view.last_day_of_month(); 581 | } else if key.code == KeyCode::Char(':') && key.modifiers == KeyModifiers::NONE { 582 | // Enter command mode (vim-style: :) 583 | self.mode = AppMode::Command(CommandState::new()); 584 | } else if key.code == KeyCode::Char('s') && key.modifiers == KeyModifiers::NONE { 585 | // Toggle scramble mode 586 | self.scramble_mode = !self.scramble_mode; 587 | } 588 | Ok(()) 589 | } 590 | 591 | fn handle_task_edit_key( 592 | &mut self, 593 | key: crossterm::event::KeyEvent, 594 | state: &mut TaskEditState, 595 | ) -> Result { 596 | if self.config.cancel_edit.matches(key.code, key.modifiers) { 597 | // Cancel edit 598 | return Ok(true); 599 | } else if self.config.save_task.matches(key.code, key.modifiers) { 600 | // Save task 601 | if !state.title.trim().is_empty() { 602 | return Ok(true); 603 | } 604 | } else if self.config.switch_field.matches(key.code, key.modifiers) { 605 | state.switch_field(); 606 | } else if self.config.backspace.matches(key.code, key.modifiers) { 607 | state.remove_char(); 608 | } else if let KeyCode::Char(ch) = key.code { 609 | state.add_char(ch); 610 | } 611 | Ok(false) 612 | } 613 | 614 | fn handle_command_mode_key( 615 | &mut self, 616 | key: crossterm::event::KeyEvent, 617 | state: &mut CommandState, 618 | ) -> Result { 619 | match key.code { 620 | KeyCode::Esc => { 621 | // Cancel command mode 622 | return Ok(true); 623 | } 624 | KeyCode::Enter => { 625 | // Execute command 626 | let command = state.input.trim(); 627 | 628 | if command == "help" { 629 | // Toggle help display 630 | state.show_help = !state.show_help; 631 | state.input.clear(); 632 | state.cursor_position = 0; 633 | return Ok(false); // Stay in command mode to show help 634 | } else if !command.is_empty() { 635 | match self.execute_command(&state.input) { 636 | Ok(_) => { 637 | state.last_error = None; 638 | } 639 | Err(e) => { 640 | state.last_error = Some(e); 641 | } 642 | } 643 | state.input.clear(); 644 | state.cursor_position = 0; 645 | return Ok(state.last_error.is_none()); 646 | } else { 647 | // Empty command, just exit 648 | return Ok(true); 649 | } 650 | } 651 | KeyCode::Backspace => { 652 | state.remove_char(); 653 | // Hide help when user starts typing 654 | state.show_help = false; 655 | } 656 | KeyCode::Left => { 657 | state.move_cursor_left(); 658 | } 659 | KeyCode::Right => { 660 | state.move_cursor_right(); 661 | } 662 | KeyCode::Char(ch) => { 663 | state.add_char(ch); 664 | // Hide help when user starts typing 665 | state.show_help = false; 666 | } 667 | _ => {} 668 | } 669 | Ok(false) 670 | } 671 | 672 | fn execute_command(&mut self, command: &str) -> Result<(), String> { 673 | let trimmed = command.trim(); 674 | let registry = get_command_registry(); 675 | // Special handling for help command 676 | if trimmed.starts_with("help") { 677 | let parts: Vec<&str> = trimmed.split_whitespace().collect(); 678 | if parts.len() == 1 { 679 | let mut help_text = String::from("Available commands:\n"); 680 | for (cmd, info) in ®istry { 681 | help_text.push_str(&format!(":{:<15} - {}\n", cmd, info.description)); 682 | } 683 | return Err(help_text); 684 | } else if parts.len() == 2 { 685 | let query = parts[1].trim_start_matches(':'); 686 | if let Some(info) = registry.get(query) { 687 | return Err(format!(":{} - {}", query, info.description)); 688 | } else { 689 | return Err(format!("No help found for :{}", query)); 690 | } 691 | } 692 | } 693 | if trimmed.is_empty() { 694 | return Ok(()); 695 | } 696 | // Try registry 697 | if let Some(cmd) = registry.get(trimmed) { 698 | (cmd.exec)(self, trimmed)?; 699 | return Ok(()); 700 | } 701 | // Try to parse as a date in various formats 702 | if let Some(date) = self.parse_date_command(trimmed) { 703 | if date.month() != self.month_view.current_date.month() 704 | || date.year() != self.month_view.current_date.year() 705 | { 706 | self.month_view.current_date = date.with_day(1).unwrap(); 707 | self.month_view.weeks = 708 | MonthView::build_weeks_for_date(self.month_view.current_date); 709 | } 710 | self.month_view.selection = month_view::Selection { 711 | selection_type: month_view::SelectionType::Day(date), 712 | }; 713 | return Ok(()); 714 | } 715 | Err(format!("Unknown command: {}. Type ':help' for available commands.", trimmed)) 716 | } 717 | 718 | fn parse_date_command(&self, input: &str) -> Option { 719 | use chrono::NaiveDate; 720 | 721 | // Try parsing as YYYY (year only) 722 | if let Ok(year) = input.parse::() { 723 | if year >= 1900 && year <= 2050 { 724 | let current_month = self.month_view.current_date.month(); 725 | let current_day = self.month_view.get_selected_date(&self.data.events).day(); 726 | 727 | // Calculate days in the target month for the specified year 728 | let days_in_month = days_in_month(year, current_month); 729 | 730 | let safe_day = std::cmp::min(current_day, days_in_month); 731 | return NaiveDate::from_ymd_opt(year, current_month, safe_day); 732 | } 733 | } 734 | 735 | // Try parsing as MM/DD/YYYY (simple manual parsing) 736 | let parts: Vec<&str> = input.split('/').collect(); 737 | if parts.len() == 3 { 738 | if let (Ok(month), Ok(day), Ok(year)) = ( 739 | parts[0].parse::(), 740 | parts[1].parse::(), 741 | parts[2].parse::(), 742 | ) { 743 | return NaiveDate::from_ymd_opt(year, month, day); 744 | } 745 | } 746 | 747 | // Try parsing as DD (day only) 748 | if let Ok(day) = input.parse::() { 749 | if day >= 1 && day <= 31 { 750 | let current_year = self.month_view.current_date.year(); 751 | let current_month = self.month_view.current_date.month(); 752 | 753 | // Check if the day is valid for the current month 754 | let days_in_month = days_in_month(current_year, current_month); 755 | 756 | if day <= days_in_month { 757 | return NaiveDate::from_ymd_opt(current_year, current_month, day); 758 | } 759 | } 760 | } 761 | 762 | None 763 | } 764 | 765 | fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { 766 | loop { 767 | terminal.draw(|frame| self.render(frame))?; 768 | 769 | if self.should_exit { 770 | break; 771 | } 772 | 773 | if let Ok(event) = event::read() { 774 | if let Event::Key(key_event) = event { 775 | self.handle_key_event(key_event)?; 776 | } 777 | } 778 | } 779 | Ok(()) 780 | } 781 | 782 | fn render(&self, frame: &mut Frame) { 783 | let area = frame.area(); 784 | 785 | // Create main layout - adjust footer size based on command mode 786 | let footer_height = match &self.mode { 787 | AppMode::Command(state) if state.show_help => 7, // More space for help (added wrap commands) 788 | _ => 2, // Normal footer size 789 | }; 790 | 791 | let layout = Layout::vertical([ 792 | Constraint::Min(0), // Main content 793 | Constraint::Length(footer_height), // Footer 794 | ]) 795 | .split(area); 796 | 797 | // Render main content 798 | render_month_view( 799 | frame, 800 | layout[0], 801 | &self.month_view, 802 | &self.data.events, 803 | self.scramble_mode, 804 | &self.config, 805 | ); 806 | 807 | // Render footer 808 | self.render_footer(frame, layout[1]); 809 | 810 | // Render mode-specific overlays 811 | match &self.mode { 812 | AppMode::TaskEdit(state) => { 813 | render_task_edit_popup(frame, area, state, &self.config); 814 | } 815 | AppMode::Command(_) => { 816 | // Command mode is handled in the footer 817 | } 818 | AppMode::Normal => {} 819 | } 820 | } 821 | 822 | fn render_footer(&self, frame: &mut Frame, area: Rect) { 823 | match &self.mode { 824 | AppMode::Command(state) => { 825 | let mut lines = vec![]; 826 | let has_error_or_help = state.last_error.is_some() || state.show_help; 827 | if let Some(err) = &state.last_error { 828 | lines.push(Line::from(vec![Span::styled( 829 | err, 830 | Style::default().fg(self.config.ui_colors.selected_completed_task_bg), 831 | )])); 832 | } 833 | if state.show_help { 834 | let help_lines = vec![ 835 | Line::from(vec![Span::styled( 836 | "Date Navigation Commands:", 837 | Style::default().fg(self.config.ui_colors.selected_task_fg), 838 | )]), 839 | Line::from(vec![ 840 | Span::styled( 841 | "YYYY", 842 | Style::default().fg(self.config.ui_colors.selected_task_bg), 843 | ), 844 | Span::raw(" - Go to year (e.g., 2024) | "), 845 | Span::styled( 846 | "DD", 847 | Style::default().fg(self.config.ui_colors.selected_task_bg), 848 | ), 849 | Span::raw(" - Go to day in current month (e.g., 15)"), 850 | ]), 851 | Line::from(vec![ 852 | Span::styled( 853 | "MM/DD/YYYY", 854 | Style::default().fg(self.config.ui_colors.selected_task_bg), 855 | ), 856 | Span::raw(" - Go to specific date (e.g., 06/15/2024)"), 857 | ]), 858 | Line::from(vec![ 859 | Span::styled( 860 | "Quit Commands:", 861 | Style::default().fg(self.config.ui_colors.completed_task_fg), 862 | ), 863 | Span::raw(" "), 864 | Span::styled( 865 | ":q", 866 | Style::default().fg(self.config.ui_colors.selected_task_bg), 867 | ), 868 | Span::raw(" - Quit | "), 869 | ]), 870 | Line::from(vec![ 871 | Span::styled( 872 | "Display Commands:", 873 | Style::default().fg(self.config.ui_colors.completed_task_fg), 874 | ), 875 | Span::raw(" "), 876 | Span::styled( 877 | ":set wrap", 878 | Style::default().fg(self.config.ui_colors.selected_task_bg), 879 | ), 880 | Span::raw(" - Enable text wrapping | "), 881 | Span::styled( 882 | ":set nowrap", 883 | Style::default().fg(self.config.ui_colors.selected_task_bg), 884 | ), 885 | Span::raw(" - Disable text wrapping"), 886 | ]), 887 | Line::from(vec![ 888 | Span::styled( 889 | ":help", 890 | Style::default() 891 | .fg(self.config.ui_colors.selected_completed_task_fg), 892 | ), 893 | Span::raw(" - Toggle this help | "), 894 | Span::styled( 895 | "Esc", 896 | Style::default() 897 | .fg(self.config.ui_colors.selected_completed_task_bg), 898 | ), 899 | Span::raw(" - Exit command mode"), 900 | ]), 901 | ]; 902 | lines.extend(help_lines) 903 | } 904 | if !has_error_or_help { 905 | let command_line = format!(":{}", state.input); 906 | lines.push(Line::from(vec![Span::raw(command_line)])); 907 | } 908 | let help_paragraph = Paragraph::new(lines).style( 909 | Style::default() 910 | .fg(self.config.ui_colors.default_fg) 911 | .bg(self.config.ui_colors.default_bg), 912 | ); 913 | frame.render_widget(help_paragraph, area); 914 | } 915 | AppMode::Normal => { 916 | if self.show_keybinds { 917 | let spans = self.config.get_normal_mode_help_spans( 918 | self.undo_stack.can_undo(), 919 | self.undo_stack.can_redo(), 920 | ); 921 | let help_text = vec![Line::from(spans)]; 922 | let footer = Paragraph::new(help_text) 923 | .style(Style::default().fg(self.config.ui_colors.default_fg)); 924 | frame.render_widget(footer, area); 925 | } else { 926 | let footer = Paragraph::new("") 927 | .style(Style::default().fg(self.config.ui_colors.default_fg)); 928 | frame.render_widget(footer, area); 929 | } 930 | } 931 | AppMode::TaskEdit(_) => { 932 | let spans = self.config.get_edit_mode_help_spans(); 933 | let help_text = vec![Line::from(spans)]; 934 | let footer = Paragraph::new(help_text) 935 | .style(Style::default().fg(self.config.ui_colors.default_fg)); 936 | frame.render_widget(footer, area); 937 | } 938 | } 939 | } 940 | } 941 | 942 | fn main() -> Result<()> { 943 | color_eyre::install()?; 944 | let terminal = ratatui::init(); 945 | let app = App::new(); 946 | let result = app.run(terminal); 947 | ratatui::restore(); 948 | result 949 | } 950 | -------------------------------------------------------------------------------- /src/month_view.rs: -------------------------------------------------------------------------------- 1 | use crate::task::Task; 2 | use crate::utils::days_in_month; 3 | use chrono::{Datelike, NaiveDate}; 4 | use ratatui::{ 5 | layout::{Constraint, Layout, Rect}, 6 | style::{Modifier, Style}, 7 | widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, 8 | Frame, 9 | }; 10 | 11 | #[derive(Debug, Clone, PartialEq)] 12 | pub enum SelectionType { 13 | Day(NaiveDate), 14 | Task(String), // task id 15 | } 16 | 17 | #[derive(Debug, Clone)] 18 | pub struct Selection { 19 | pub selection_type: SelectionType, 20 | } 21 | 22 | pub struct MonthView { 23 | pub current_date: NaiveDate, 24 | pub selection: Selection, 25 | pub weeks: Vec>, 26 | pub wrap_enabled: bool, 27 | } 28 | 29 | impl MonthView { 30 | pub fn new(current_date: NaiveDate) -> Self { 31 | let weeks = Self::build_weeks(current_date); 32 | let selection = Self::create_day_selection(current_date); 33 | 34 | Self { 35 | current_date, 36 | selection, 37 | weeks, 38 | wrap_enabled: false, 39 | } 40 | } 41 | 42 | // Helper method to create a day selection 43 | fn create_day_selection(date: NaiveDate) -> Selection { 44 | Selection { 45 | selection_type: SelectionType::Day(date), 46 | } 47 | } 48 | 49 | // Add methods to control wrap setting 50 | pub fn set_wrap(&mut self, enabled: bool) { 51 | self.wrap_enabled = enabled; 52 | } 53 | 54 | // Helper method to create a task selection 55 | fn create_task_selection(task_id: String) -> Selection { 56 | Selection { 57 | selection_type: SelectionType::Task(task_id), 58 | } 59 | } 60 | 61 | // Helper method to select a day 62 | fn select_day(&mut self, date: NaiveDate) { 63 | self.selection = Self::create_day_selection(date); 64 | } 65 | 66 | // Helper method to select a task 67 | fn select_task(&mut self, task_id: String) { 68 | self.selection = Self::create_task_selection(task_id); 69 | } 70 | 71 | // Helper method to transition to a new month and update everything 72 | fn transition_to_month(&mut self, new_date: NaiveDate) { 73 | self.current_date = new_date; 74 | self.weeks = Self::build_weeks(self.current_date); 75 | self.select_day(self.current_date); 76 | } 77 | 78 | // Helper method to navigate to a date, handling month transitions if needed 79 | fn navigate_to_date(&mut self, target_date: NaiveDate) { 80 | // Check if we need to change months 81 | if target_date.month() != self.current_date.month() 82 | || target_date.year() != self.current_date.year() 83 | { 84 | self.current_date = target_date.with_day(1).unwrap(); 85 | self.weeks = Self::build_weeks(self.current_date); 86 | } 87 | self.select_day(target_date); 88 | } 89 | 90 | // Public method to rebuild weeks for a given date 91 | pub fn build_weeks_for_date(date: NaiveDate) -> Vec> { 92 | Self::build_weeks(date) 93 | } 94 | 95 | fn build_weeks(date: NaiveDate) -> Vec> { 96 | let first_of_month = date.with_day(1).unwrap(); 97 | let last_of_month = date 98 | .with_day(days_in_month(date.year(), date.month())) 99 | .unwrap(); 100 | 101 | // Start from the first Sunday of the month view 102 | let mut start_date = first_of_month; 103 | while start_date.weekday().num_days_from_sunday() != 0 { 104 | start_date = start_date.pred_opt().unwrap(); 105 | } 106 | 107 | let mut weeks = Vec::new(); 108 | let mut current_date = start_date; 109 | 110 | // Build 6 weeks to ensure we cover the entire month 111 | for _ in 0..6 { 112 | let mut week = Vec::new(); 113 | for _ in 0..7 { 114 | week.push(current_date); 115 | current_date = current_date.succ_opt().unwrap(); 116 | } 117 | weeks.push(week); 118 | 119 | // If we've passed the end of the month and filled at least 4 weeks, we can stop 120 | if current_date > last_of_month && weeks.len() >= 4 { 121 | break; 122 | } 123 | } 124 | 125 | weeks 126 | } 127 | 128 | pub fn prev_month(&mut self) { 129 | let new_date = if self.current_date.month() == 1 { 130 | NaiveDate::from_ymd_opt(self.current_date.year() - 1, 12, 1).unwrap() 131 | } else { 132 | NaiveDate::from_ymd_opt(self.current_date.year(), self.current_date.month() - 1, 1) 133 | .unwrap() 134 | }; 135 | self.transition_to_month(new_date); 136 | } 137 | 138 | pub fn next_year(&mut self) { 139 | let new_date = 140 | NaiveDate::from_ymd_opt(self.current_date.year() + 1, self.current_date.month(), 1) 141 | .unwrap(); 142 | self.transition_to_month(new_date); 143 | } 144 | 145 | pub fn prev_year(&mut self) { 146 | let new_date = 147 | NaiveDate::from_ymd_opt(self.current_date.year() - 1, self.current_date.month(), 1) 148 | .unwrap(); 149 | self.transition_to_month(new_date); 150 | } 151 | 152 | pub fn move_up(&mut self, tasks: &[Task]) { 153 | match &self.selection.selection_type { 154 | SelectionType::Day(date) => { 155 | let current_date = *date; 156 | // Check if moving up a week would go outside current month 157 | if let Some(new_date) = current_date.checked_sub_signed(chrono::Duration::weeks(1)) 158 | { 159 | if new_date.month() != self.current_date.month() 160 | || new_date.year() != self.current_date.year() 161 | { 162 | // Go to previous month 163 | let target_day = current_date.day(); 164 | self.prev_month(); 165 | // Try to find a similar date in the new month 166 | let days_in_month = 167 | days_in_month(self.current_date.year(), self.current_date.month()); 168 | let safe_day = std::cmp::min(target_day, days_in_month); 169 | if let Some(target_date) = NaiveDate::from_ymd_opt( 170 | self.current_date.year(), 171 | self.current_date.month(), 172 | safe_day, 173 | ) { 174 | self.select_day(target_date); 175 | 176 | // Auto-select first task if available 177 | let day_tasks: Vec<_> = 178 | tasks.iter().filter(|t| t.is_on_date(target_date)).collect(); 179 | if !day_tasks.is_empty() { 180 | let mut sorted_tasks = day_tasks; 181 | sorted_tasks.sort_by_key(|t| t.order); 182 | self.select_task(sorted_tasks[0].id.clone()); 183 | } 184 | } 185 | } else { 186 | self.select_day(new_date); 187 | 188 | // Auto-select first task if available 189 | let day_tasks: Vec<_> = 190 | tasks.iter().filter(|t| t.is_on_date(new_date)).collect(); 191 | if !day_tasks.is_empty() { 192 | let mut sorted_tasks = day_tasks; 193 | sorted_tasks.sort_by_key(|t| t.order); 194 | self.select_task(sorted_tasks[0].id.clone()); 195 | } 196 | } 197 | } 198 | } 199 | SelectionType::Task(task_id) => { 200 | let task_id = task_id.clone(); 201 | // Find the current task and move to previous task in the same day 202 | if let Some(task) = tasks.iter().find(|t| t.id == task_id) { 203 | let task_date = task.start.date_naive(); 204 | let mut day_tasks: Vec<_> = 205 | tasks.iter().filter(|t| t.is_on_date(task_date)).collect(); 206 | day_tasks.sort_by_key(|t| t.order); // Sort by order 207 | 208 | if let Some(current_index) = day_tasks.iter().position(|t| t.id == task_id) { 209 | if current_index > 0 { 210 | // Move to previous task 211 | let prev_task = &day_tasks[current_index - 1]; 212 | self.select_task(prev_task.id.clone()); 213 | } else { 214 | // Move to day selection 215 | self.select_day(task_date); 216 | } 217 | } 218 | } 219 | } 220 | } 221 | } 222 | 223 | pub fn move_down(&mut self, tasks: &[Task]) { 224 | match &self.selection.selection_type { 225 | SelectionType::Day(date) => { 226 | let current_date = *date; 227 | // Check if there are tasks on this day 228 | let mut day_tasks: Vec<_> = tasks 229 | .iter() 230 | .filter(|t| t.is_on_date(current_date)) 231 | .collect(); 232 | day_tasks.sort_by_key(|t| t.order); // Sort by order 233 | 234 | if !day_tasks.is_empty() { 235 | // Move to first task (ordered) 236 | self.select_task(day_tasks[0].id.clone()); 237 | } else { 238 | // Move down one week 239 | if let Some(new_date) = 240 | current_date.checked_add_signed(chrono::Duration::weeks(1)) 241 | { 242 | self.navigate_to_date(new_date); 243 | 244 | // After navigating, check if the new day has tasks and auto-select first task 245 | let new_day_tasks: Vec<_> = 246 | tasks.iter().filter(|t| t.is_on_date(new_date)).collect(); 247 | if !new_day_tasks.is_empty() { 248 | let mut sorted_tasks = new_day_tasks; 249 | sorted_tasks.sort_by_key(|t| t.order); 250 | self.select_task(sorted_tasks[0].id.clone()); 251 | } 252 | } 253 | } 254 | } 255 | SelectionType::Task(task_id) => { 256 | let task_id = task_id.clone(); 257 | // Find the current task and move to next task in the same day or to next week 258 | if let Some(task) = tasks.iter().find(|t| t.id == task_id) { 259 | let task_date = task.start.date_naive(); 260 | let mut day_tasks: Vec<_> = 261 | tasks.iter().filter(|t| t.is_on_date(task_date)).collect(); 262 | day_tasks.sort_by_key(|t| t.order); // Sort by order 263 | 264 | if let Some(current_index) = day_tasks.iter().position(|t| t.id == task_id) { 265 | if current_index < day_tasks.len() - 1 { 266 | // Move to next task 267 | let next_task = &day_tasks[current_index + 1]; 268 | self.select_task(next_task.id.clone()); 269 | } else { 270 | // Move to next week same day 271 | if let Some(new_date) = 272 | task_date.checked_add_signed(chrono::Duration::weeks(1)) 273 | { 274 | self.navigate_to_date(new_date); 275 | 276 | // Check if new day has tasks and auto-select first task 277 | let new_day_tasks: Vec<_> = 278 | tasks.iter().filter(|t| t.is_on_date(new_date)).collect(); 279 | if !new_day_tasks.is_empty() { 280 | let mut sorted_tasks = new_day_tasks; 281 | sorted_tasks.sort_by_key(|t| t.order); 282 | self.select_task(sorted_tasks[0].id.clone()); 283 | } 284 | } 285 | } 286 | } 287 | } 288 | } 289 | } 290 | } 291 | 292 | pub fn move_left(&mut self, _tasks: &[Task]) { 293 | match &self.selection.selection_type { 294 | SelectionType::Day(date) => { 295 | if let Some(new_date) = date.checked_sub_signed(chrono::Duration::days(1)) { 296 | self.navigate_to_date(new_date); 297 | } 298 | } 299 | SelectionType::Task(task_id) => { 300 | // Move to the day containing this task 301 | if let Some(task) = _tasks.iter().find(|t| &t.id == task_id) { 302 | let task_date = task.start.date_naive(); 303 | self.select_day(task_date); 304 | } 305 | } 306 | } 307 | } 308 | 309 | pub fn move_right(&mut self, _tasks: &[Task]) { 310 | match &self.selection.selection_type { 311 | SelectionType::Day(date) => { 312 | if let Some(new_date) = date.checked_add_signed(chrono::Duration::days(1)) { 313 | self.navigate_to_date(new_date); 314 | } 315 | } 316 | SelectionType::Task(task_id) => { 317 | // Move to the day containing this task 318 | if let Some(task) = _tasks.iter().find(|t| &t.id == task_id) { 319 | let task_date = task.start.date_naive(); 320 | self.select_day(task_date); 321 | } 322 | } 323 | } 324 | } 325 | 326 | pub fn get_selected_task_id(&self) -> Option { 327 | match &self.selection.selection_type { 328 | SelectionType::Task(task_id) => Some(task_id.clone()), 329 | _ => None, 330 | } 331 | } 332 | 333 | // Get the currently selected date 334 | pub fn get_selected_date(&self, tasks: &[Task]) -> NaiveDate { 335 | match &self.selection.selection_type { 336 | SelectionType::Day(date) => *date, 337 | SelectionType::Task(task_id) => { 338 | // Find the actual task and return its date 339 | if let Some(task) = tasks.iter().find(|t| &t.id == task_id) { 340 | task.start.date_naive() 341 | } else { 342 | // Fallback to current date if task not found 343 | self.current_date 344 | } 345 | } 346 | } 347 | } 348 | 349 | // Move to next week (same day of week) 350 | pub fn next_week(&mut self, tasks: &[Task]) { 351 | let current_selected = self.get_selected_date(tasks); 352 | if let Some(new_date) = current_selected.checked_add_signed(chrono::Duration::weeks(1)) { 353 | self.navigate_to_date(new_date); 354 | } 355 | } 356 | 357 | // Move to previous week (same day of week) 358 | pub fn prev_week(&mut self, tasks: &[Task]) { 359 | let current_selected = self.get_selected_date(tasks); 360 | if let Some(new_date) = current_selected.checked_sub_signed(chrono::Duration::weeks(1)) { 361 | self.navigate_to_date(new_date); 362 | } 363 | } 364 | 365 | // Move to first day of current month 366 | pub fn first_day_of_month(&mut self) { 367 | let first_day = self.current_date.with_day(1).unwrap(); 368 | self.select_day(first_day); 369 | } 370 | 371 | // Move to last day of current month 372 | pub fn last_day_of_month(&mut self) { 373 | let days_in_month = days_in_month(self.current_date.year(), self.current_date.month()); 374 | 375 | if let Some(last_day) = NaiveDate::from_ymd_opt( 376 | self.current_date.year(), 377 | self.current_date.month(), 378 | days_in_month, 379 | ) { 380 | self.select_day(last_day); 381 | } 382 | } 383 | 384 | // Helper method to preserve day when changing months 385 | fn navigate_to_month_preserve_day(&mut self, new_year: i32, new_month: u32) { 386 | // Get the currently selected date from the selection 387 | let current_selected = match &self.selection.selection_type { 388 | SelectionType::Day(date) => *date, 389 | SelectionType::Task(_) => { 390 | // For task selections, we'll use the current date as fallback 391 | // since we don't have task data here 392 | self.current_date 393 | } 394 | }; 395 | let target_day = current_selected.day(); 396 | 397 | // Calculate days in the target month 398 | let days_in_month = days_in_month(new_year, new_month); 399 | 400 | // Preserve day or use last day of month if target day doesn't exist 401 | let safe_day = std::cmp::min(target_day, days_in_month); 402 | 403 | self.current_date = NaiveDate::from_ymd_opt(new_year, new_month, 1).unwrap(); 404 | self.weeks = Self::build_weeks(self.current_date); 405 | 406 | if let Some(target_date) = NaiveDate::from_ymd_opt(new_year, new_month, safe_day) { 407 | self.select_day(target_date); 408 | } 409 | } 410 | 411 | // Navigate to previous month while preserving the current day when possible 412 | pub fn prev_month_preserve_day(&mut self) { 413 | let (new_year, new_month) = if self.current_date.month() == 1 { 414 | (self.current_date.year() - 1, 12) 415 | } else { 416 | (self.current_date.year(), self.current_date.month() - 1) 417 | }; 418 | 419 | self.navigate_to_month_preserve_day(new_year, new_month); 420 | } 421 | 422 | // Navigate to next month while preserving the current day when possible 423 | pub fn next_month_preserve_day(&mut self) { 424 | let (new_year, new_month) = if self.current_date.month() == 12 { 425 | (self.current_date.year() + 1, 1) 426 | } else { 427 | (self.current_date.year(), self.current_date.month() + 1) 428 | }; 429 | 430 | self.navigate_to_month_preserve_day(new_year, new_month); 431 | } 432 | 433 | // Navigate to today's date 434 | pub fn go_to_today(&mut self) { 435 | use chrono::Local; 436 | 437 | let today = Local::now().date_naive(); 438 | self.navigate_to_date(today); 439 | } 440 | 441 | // Helper method to get the current task's order within its day 442 | pub fn get_current_task_order(&self, tasks: &[Task]) -> Option { 443 | match &self.selection.selection_type { 444 | SelectionType::Task(task_id) => { 445 | tasks.iter().find(|t| &t.id == task_id).map(|t| t.order) 446 | } 447 | _ => None, 448 | } 449 | } 450 | 451 | // Helper method to select a task by its order within a day 452 | pub fn select_task_by_order(&mut self, date: NaiveDate, order: u32, tasks: &[Task]) { 453 | let mut day_tasks: Vec<_> = tasks.iter().filter(|t| t.is_on_date(date)).collect(); 454 | day_tasks.sort_by_key(|t| t.order); 455 | 456 | if let Some(task) = day_tasks.iter().find(|t| t.order == order) { 457 | self.select_task(task.id.clone()); 458 | } 459 | } 460 | } 461 | 462 | // Helper function to scramble text with numbers while preserving length 463 | fn scramble_text(text: &str, scramble_mode: bool) -> String { 464 | if !scramble_mode { 465 | return text.to_string(); 466 | } 467 | 468 | // Convert each character to a random digit, preserving spaces and length 469 | text.chars() 470 | .enumerate() 471 | .map(|(i, ch)| { 472 | if ch.is_whitespace() { 473 | ch // Preserve whitespace 474 | } else { 475 | // Use a simple hash of character position and character value to generate consistent random-looking digits 476 | let hash = (i.wrapping_mul(31).wrapping_add(ch as usize)).wrapping_mul(17); 477 | char::from_digit((hash % 10) as u32, 10).unwrap_or('0') 478 | } 479 | }) 480 | .collect() 481 | } 482 | 483 | // Helper function to calculate wrapped text height 484 | fn calculate_wrapped_text_height(text: &str, width: usize) -> usize { 485 | if width == 0 { 486 | return 1; 487 | } 488 | 489 | let lines = text.lines().collect::>(); 490 | if lines.is_empty() { 491 | return 1; 492 | } 493 | 494 | let mut total_height = 0; 495 | for line in lines { 496 | if line.is_empty() { 497 | total_height += 1; 498 | } else { 499 | total_height += (line.len() + width - 1) / width; // Ceiling division 500 | } 501 | } 502 | 503 | std::cmp::max(1, total_height) 504 | } 505 | 506 | pub fn render_month_view( 507 | frame: &mut Frame, 508 | area: Rect, 509 | month_view: &MonthView, 510 | tasks: &[Task], 511 | scramble_mode: bool, 512 | config: &crate::config::Config, 513 | ) { 514 | let title = format!( 515 | "{} {}", 516 | month_view.current_date.format("%B"), 517 | month_view.current_date.year() 518 | ); 519 | 520 | let block = Block::default() 521 | .title(title) 522 | .borders(Borders::ALL) 523 | .style(Style::default().fg(config.ui_colors.selected_task_bg).bg(config.ui_colors.default_bg)); 524 | 525 | let inner_area = block.inner(area); 526 | frame.render_widget(block, area); 527 | 528 | // Calculate constraints for each week based on max tasks and wrap setting 529 | let week_constraints: Vec = month_view 530 | .weeks 531 | .iter() 532 | .map(|week| { 533 | if month_view.wrap_enabled { 534 | // Calculate height based on wrapped text 535 | let max_height_in_week = week 536 | .iter() 537 | .map(|&date| { 538 | let day_tasks: Vec<_> = 539 | tasks.iter().filter(|t| t.is_on_date(date)).collect(); 540 | if day_tasks.is_empty() { 541 | 4 // Minimum height: day + borders + padding 542 | } else { 543 | // Calculate available width for tasks (day cell width - borders - padding) 544 | let day_width = (inner_area.width / 7).saturating_sub(2); // subtract borders 545 | let task_width = day_width.saturating_sub(1) as usize; // subtract padding 546 | 547 | let total_task_height: usize = day_tasks 548 | .iter() 549 | .map(|task| { 550 | // For height calculation, check if this task is selected 551 | let is_selected_task = matches!( 552 | month_view.selection.selection_type, 553 | SelectionType::Task(ref task_id) if task_id == &task.id 554 | ); 555 | let title_to_measure = if is_selected_task { 556 | task.title.clone() 557 | } else { 558 | scramble_text(&task.title, scramble_mode) 559 | }; 560 | calculate_wrapped_text_height(&title_to_measure, task_width) 561 | }) 562 | .sum(); 563 | 564 | 1 + total_task_height + 3 // day_number(1) + tasks + borders+padding(3) 565 | } 566 | }) 567 | .max() 568 | .unwrap_or(4); 569 | 570 | Constraint::Length(max_height_in_week as u16) 571 | } else { 572 | // Original logic for nowrap mode 573 | let max_tasks_in_week = week 574 | .iter() 575 | .map(|&date| tasks.iter().filter(|t| t.is_on_date(date)).count()) 576 | .max() 577 | .unwrap_or(0); 578 | 579 | let week_height = if max_tasks_in_week == 0 { 580 | 4 // Minimum height when no tasks: day + 581 | } else { 582 | 1 + max_tasks_in_week + 3 // day_number(1) + tasks(N) + borders+padding(3) 583 | }; 584 | 585 | Constraint::Length(week_height as u16) 586 | } 587 | }) 588 | .collect(); 589 | 590 | let week_layout = Layout::vertical(week_constraints).split(inner_area); 591 | 592 | for (week_index, week) in month_view.weeks.iter().enumerate() { 593 | if week_index >= week_layout.len() { 594 | break; 595 | } 596 | 597 | let week_area = week_layout[week_index]; 598 | 599 | // Render days directly 600 | let day_constraints: Vec = 601 | (0..7).map(|_| Constraint::Percentage(100 / 7)).collect(); 602 | 603 | let day_layout = Layout::horizontal(day_constraints).split(week_area); 604 | 605 | for (day_index, &date) in week.iter().enumerate() { 606 | if day_index >= day_layout.len() { 607 | break; 608 | } 609 | 610 | let day_area = day_layout[day_index]; 611 | render_day_cell(frame, day_area, date, month_view, tasks, scramble_mode, config); 612 | } 613 | } 614 | } 615 | 616 | fn render_day_cell( 617 | frame: &mut Frame, 618 | area: Rect, 619 | date: NaiveDate, 620 | month_view: &MonthView, 621 | tasks: &[Task], 622 | scramble_mode: bool, 623 | config: &crate::config::Config, 624 | ) { 625 | let is_current_month = date.month() == month_view.current_date.month(); 626 | let is_selected_day = matches!(month_view.selection.selection_type, SelectionType::Day(selected_date) if selected_date == date); 627 | 628 | // Get tasks for this day, sorted by order 629 | let mut day_tasks: Vec<_> = tasks.iter().filter(|t| t.is_on_date(date)).collect(); 630 | day_tasks.sort_by_key(|t| t.order); 631 | 632 | // Day style 633 | let day_style = if is_selected_day { 634 | Style::default().bg(config.ui_colors.selected_task_bg).fg(config.ui_colors.selected_task_fg) 635 | } else if !is_current_month { 636 | Style::default().fg(config.ui_colors.selected_completed_task_bg) 637 | } else { 638 | Style::default().fg(config.ui_colors.day_number_fg) 639 | }; 640 | 641 | let border_style = if is_selected_day { 642 | Style::default().fg(config.ui_colors.selected_task_bg) 643 | } else { 644 | Style::default().fg(config.ui_colors.selected_completed_task_bg) 645 | }; 646 | 647 | let block = Block::default() 648 | .borders(Borders::ALL) 649 | .border_style(border_style); 650 | 651 | let inner_area = block.inner(area); 652 | frame.render_widget(block, area); 653 | 654 | // Always render day number, ensuring it gets proper space 655 | let day_number = format!("{}", date.day()); 656 | let day_paragraph = Paragraph::new(day_number).style(day_style); 657 | 658 | if inner_area.height == 0 || inner_area.width == 0 { 659 | // If no inner space, just return - the border should still be visible 660 | return; 661 | } 662 | 663 | // FIXED: Day number gets top line, tasks get remaining space if available 664 | if day_tasks.is_empty() { 665 | // No tasks: just render day number in available space 666 | frame.render_widget(day_paragraph, inner_area); 667 | } else { 668 | // With tasks: day number gets exactly 1 line at top, tasks get rest 669 | let day_layout = Layout::vertical([ 670 | Constraint::Length(1), // Day number - exactly 1 line 671 | Constraint::Min(1), // Tasks - all remaining space 672 | ]) 673 | .split(inner_area); 674 | 675 | // Render day number in top line 676 | if day_layout.len() > 0 && day_layout[0].height > 0 { 677 | frame.render_widget(day_paragraph, day_layout[0]); 678 | } 679 | 680 | // Render tasks in remaining space 681 | if day_layout.len() > 1 && day_layout[1].height > 0 { 682 | let task_area = day_layout[1]; 683 | 684 | if month_view.wrap_enabled { 685 | render_tasks_wrapped(frame, task_area, &day_tasks, month_view, scramble_mode, config); 686 | } else { 687 | render_tasks_nowrap(frame, task_area, &day_tasks, month_view, scramble_mode, config); 688 | } 689 | } 690 | } 691 | } 692 | 693 | fn render_tasks_nowrap( 694 | frame: &mut Frame, 695 | area: Rect, 696 | day_tasks: &[&Task], 697 | month_view: &MonthView, 698 | scramble_mode: bool, 699 | config: &crate::config::Config, 700 | ) { 701 | let task_items: Vec = day_tasks 702 | .iter() 703 | .enumerate() 704 | .map(|(_index, task)| { 705 | let is_selected_task = matches!( 706 | month_view.selection.selection_type, 707 | SelectionType::Task(ref task_id) if task_id == &task.id 708 | ); 709 | 710 | let style = if is_selected_task && task.completed { 711 | Style::default() 712 | .bg(config.ui_colors.selected_completed_task_bg) 713 | .fg(config.ui_colors.selected_completed_task_fg) 714 | } else if is_selected_task { 715 | let mut s = Style::default() 716 | .bg(config.ui_colors.selected_task_bg) 717 | .fg(config.ui_colors.selected_task_fg); 718 | if config.ui_colors.selected_task_bold { 719 | s = s.add_modifier(Modifier::BOLD); 720 | } 721 | s 722 | } else if task.completed && !is_selected_task { 723 | Style::default().fg(config.ui_colors.completed_task_fg) 724 | } else { 725 | Style::default().fg(config.ui_colors.default_task_fg) 726 | }; 727 | 728 | let max_width = area.width.saturating_sub(2) as usize; // Account for list padding 729 | let title = if task.title.len() > max_width && max_width > 3 { 730 | // Show unscrambled text for selected task, scrambled for others 731 | let display_title = if is_selected_task { 732 | task.title.clone() 733 | } else { 734 | scramble_text(&task.title, scramble_mode) 735 | }; 736 | format!("{}...", &display_title[..max_width.saturating_sub(3)]) 737 | } else { 738 | // Show unscrambled text for selected task, scrambled for others 739 | if is_selected_task { 740 | task.title.clone() 741 | } else { 742 | scramble_text(&task.title, scramble_mode) 743 | } 744 | }; 745 | 746 | ListItem::new(title).style(style) 747 | }) 748 | .collect(); 749 | 750 | let task_list = List::new(task_items).style(Style::default()); 751 | 752 | frame.render_widget(task_list, area); 753 | } 754 | 755 | fn render_tasks_wrapped( 756 | frame: &mut Frame, 757 | area: Rect, 758 | day_tasks: &[&Task], 759 | month_view: &MonthView, 760 | scramble_mode: bool, 761 | config: &crate::config::Config, 762 | ) { 763 | if day_tasks.is_empty() { 764 | return; 765 | } 766 | 767 | // Calculate height for each task based on wrapping 768 | let task_width = area.width.saturating_sub(1) as usize; // Account for padding 769 | let task_heights: Vec = day_tasks 770 | .iter() 771 | .map(|task| { 772 | // For height calculation, we need to check if this task is selected 773 | let is_selected_task = matches!( 774 | month_view.selection.selection_type, 775 | SelectionType::Task(ref task_id) if task_id == &task.id 776 | ); 777 | let title_to_measure = if is_selected_task { 778 | task.title.clone() 779 | } else { 780 | scramble_text(&task.title, scramble_mode) 781 | }; 782 | calculate_wrapped_text_height(&title_to_measure, task_width) as u16 783 | }) 784 | .collect(); 785 | 786 | // If we have more tasks than can fit, truncate the constraints 787 | let available_height = area.height as usize; 788 | let mut constraints_to_use = Vec::new(); 789 | let mut used_height = 0; 790 | 791 | for &height in task_heights.iter() { 792 | if used_height + height as usize <= available_height { 793 | constraints_to_use.push(Constraint::Length(height)); 794 | used_height += height as usize; 795 | } else if used_height < available_height { 796 | // Use remaining space for the last task 797 | constraints_to_use.push(Constraint::Length((available_height - used_height) as u16)); 798 | break; 799 | } else { 800 | break; 801 | } 802 | } 803 | 804 | if constraints_to_use.is_empty() { 805 | return; 806 | } 807 | 808 | let task_layout = Layout::vertical(constraints_to_use).split(area); 809 | 810 | // Render each task as a paragraph 811 | for (i, task) in day_tasks.iter().enumerate() { 812 | if i >= task_layout.len() { 813 | break; 814 | } 815 | 816 | let is_selected_task = matches!( 817 | month_view.selection.selection_type, 818 | SelectionType::Task(ref task_id) if task_id == &task.id 819 | ); 820 | 821 | let style = if is_selected_task && task.completed { 822 | Style::default() 823 | .bg(config.ui_colors.selected_completed_task_bg) 824 | .fg(config.ui_colors.selected_completed_task_fg) 825 | } else if is_selected_task { 826 | let mut s = Style::default() 827 | .bg(config.ui_colors.selected_task_bg) 828 | .fg(config.ui_colors.selected_task_fg); 829 | if config.ui_colors.selected_task_bold { 830 | s = s.add_modifier(Modifier::BOLD); 831 | } 832 | s 833 | } else if task.completed && !is_selected_task { 834 | Style::default().fg(config.ui_colors.completed_task_fg) 835 | } else { 836 | Style::default() 837 | }; 838 | 839 | let paragraph = Paragraph::new( 840 | // Show unscrambled text for selected task, scrambled for others 841 | if is_selected_task { 842 | task.title.clone() 843 | } else { 844 | scramble_text(&task.title, scramble_mode) 845 | }, 846 | ) 847 | .style(style) 848 | .wrap(Wrap { trim: true }); 849 | 850 | frame.render_widget(paragraph, task_layout[i]); 851 | } 852 | } 853 | -------------------------------------------------------------------------------- /src/task.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | use uuid::Uuid; 4 | 5 | #[derive(Debug, Clone, Serialize, Deserialize)] 6 | pub struct Task { 7 | pub id: String, 8 | pub title: String, 9 | pub start: DateTime, 10 | pub end: DateTime, 11 | pub comments: Vec, 12 | pub completed: bool, 13 | pub order: u32, // Task ordering within a day (0-based) 14 | } 15 | 16 | #[derive(Debug, Clone, Serialize, Deserialize)] 17 | pub struct TaskComment { 18 | pub id: String, 19 | pub text: String, 20 | } 21 | 22 | #[derive(Debug, Clone, Serialize, Deserialize)] 23 | pub struct TaskData { 24 | pub events: Vec, 25 | } 26 | 27 | impl Task { 28 | pub fn new(title: String, start: DateTime) -> Self { 29 | let id = Uuid::new_v4().to_string(); 30 | let end = start + chrono::Duration::hours(1); 31 | 32 | Self { 33 | id, 34 | title, 35 | start, 36 | end, 37 | comments: vec![], 38 | completed: false, 39 | order: 0, // Default order, will be set when inserting 40 | } 41 | } 42 | 43 | pub fn add_comment(&mut self, text: String) { 44 | let comment = TaskComment { 45 | id: Uuid::new_v4().to_string(), 46 | text, 47 | }; 48 | self.comments.push(comment); 49 | } 50 | 51 | pub fn is_on_date(&self, date: chrono::NaiveDate) -> bool { 52 | let task_date = self.start.date_naive(); 53 | task_date == date 54 | } 55 | } 56 | 57 | impl TaskData { 58 | /// Get all tasks for a specific date, sorted by order 59 | pub fn get_tasks_for_date(&self, date: chrono::NaiveDate) -> Vec<&Task> { 60 | let mut tasks: Vec<_> = self.events.iter() 61 | .filter(|t| t.is_on_date(date)) 62 | .collect(); 63 | tasks.sort_by_key(|t| t.order); 64 | tasks 65 | } 66 | 67 | /// Get the maximum order for tasks on a specific date 68 | pub fn max_order_for_date(&self, date: chrono::NaiveDate) -> u32 { 69 | self.events.iter() 70 | .filter(|t| t.is_on_date(date)) 71 | .map(|t| t.order) 72 | .max() 73 | .unwrap_or(0) 74 | } 75 | 76 | /// Insert a task at a specific order, shifting other tasks down 77 | pub fn insert_task_at_order(&mut self, mut task: Task, target_order: u32) { 78 | let date = task.start.date_naive(); 79 | 80 | // Shift existing tasks at and after target_order down by 1 81 | for existing_task in self.events.iter_mut() { 82 | if existing_task.is_on_date(date) && existing_task.order >= target_order { 83 | existing_task.order += 1; 84 | } 85 | } 86 | 87 | task.order = target_order; 88 | self.events.push(task); 89 | } 90 | 91 | /// Remove a task and close the gap in ordering 92 | pub fn remove_task_and_reorder(&mut self, task_id: &str) -> Option { 93 | if let Some(pos) = self.events.iter().position(|t| t.id == task_id) { 94 | let removed_task = self.events.remove(pos); 95 | let date = removed_task.start.date_naive(); 96 | 97 | // Shift tasks after the removed task up by 1 98 | for task in self.events.iter_mut() { 99 | if task.is_on_date(date) && task.order > removed_task.order { 100 | task.order -= 1; 101 | } 102 | } 103 | 104 | Some(removed_task) 105 | } else { 106 | None 107 | } 108 | } 109 | } 110 | 111 | impl Default for TaskData { 112 | fn default() -> Self { 113 | Self { 114 | events: vec![], 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/task_edit.rs: -------------------------------------------------------------------------------- 1 | use crate::task::Task; 2 | use chrono::NaiveDate; 3 | use ratatui::{ 4 | layout::{Constraint, Layout, Rect}, 5 | style::{Modifier, Style}, 6 | text::{Line, Span}, 7 | widgets::{Block, Borders, Clear, Paragraph, Wrap}, 8 | Frame, 9 | }; 10 | 11 | #[derive(Debug, Clone, PartialEq)] 12 | pub struct TaskEditState { 13 | pub task_id: Option, 14 | pub title: String, 15 | pub content: String, 16 | pub editing_field: EditingField, 17 | pub is_new_task: bool, 18 | pub date: NaiveDate, 19 | } 20 | 21 | #[derive(Debug, Clone, PartialEq)] 22 | pub enum EditingField { 23 | Title, 24 | Content, 25 | } 26 | 27 | impl TaskEditState { 28 | pub fn new_task(date: NaiveDate) -> Self { 29 | Self { 30 | task_id: None, 31 | title: String::new(), 32 | content: String::new(), 33 | editing_field: EditingField::Title, 34 | is_new_task: true, 35 | date, 36 | } 37 | } 38 | 39 | pub fn edit_task(task: &Task) -> Self { 40 | let content = task.comments.first() 41 | .map(|c| c.text.clone()) 42 | .unwrap_or_default(); 43 | 44 | Self { 45 | task_id: Some(task.id.clone()), 46 | title: task.title.clone(), 47 | content, 48 | editing_field: EditingField::Title, 49 | is_new_task: false, 50 | date: task.start.date_naive(), 51 | } 52 | } 53 | 54 | pub fn add_char(&mut self, ch: char) { 55 | match self.editing_field { 56 | EditingField::Title => self.title.push(ch), 57 | EditingField::Content => self.content.push(ch), 58 | } 59 | } 60 | 61 | pub fn remove_char(&mut self) { 62 | match self.editing_field { 63 | EditingField::Title => { self.title.pop(); }, 64 | EditingField::Content => { self.content.pop(); }, 65 | } 66 | } 67 | 68 | pub fn switch_field(&mut self) { 69 | self.editing_field = match self.editing_field { 70 | EditingField::Title => EditingField::Content, 71 | EditingField::Content => EditingField::Title, 72 | }; 73 | } 74 | 75 | pub fn to_task(&self) -> Task { 76 | let start = self.date.and_hms_opt(9, 0, 0).unwrap() 77 | .and_local_timezone(chrono::Local) 78 | .single() 79 | .unwrap() 80 | .to_utc(); 81 | 82 | let mut task = Task::new(self.title.clone(), start); 83 | 84 | if !self.content.is_empty() { 85 | task.add_comment(self.content.clone()); 86 | } 87 | 88 | if let Some(ref task_id) = self.task_id { 89 | task.id = task_id.clone(); 90 | } 91 | 92 | task 93 | } 94 | } 95 | 96 | pub fn render_task_edit_popup( 97 | frame: &mut Frame, 98 | area: Rect, 99 | state: &TaskEditState, 100 | config: &crate::config::Config, 101 | ) { 102 | let colors = &config.task_edit_colors; 103 | 104 | // Calculate popup area (centered, 60% width, 40% height) 105 | let popup_area = centered_rect(60, 40, area); 106 | 107 | // Clear the area 108 | frame.render_widget(Clear, popup_area); 109 | 110 | // Create the block 111 | let title = if state.is_new_task { "New Task" } else { "Edit Task" }; 112 | let block = Block::default() 113 | .title(title) 114 | .borders(Borders::ALL) 115 | .style(Style::default().fg(colors.popup_fg).bg(colors.popup_bg)) 116 | .border_style(Style::default().fg(colors.border_fg)); 117 | let inner_area = block.inner(popup_area); 118 | frame.render_widget(block, popup_area); 119 | 120 | // Split the inner area for title, content, and instructions 121 | let layout = Layout::vertical([ 122 | Constraint::Length(3), // Title field 123 | Constraint::Min(3), // Content field 124 | Constraint::Length(2), // Instructions 125 | ]).split(inner_area); 126 | 127 | // Render title field 128 | let title_selected = state.editing_field == EditingField::Title; 129 | let title_style = if title_selected { 130 | Style::default().fg(colors.title_selected_fg).add_modifier(Modifier::BOLD) 131 | } else { 132 | Style::default().fg(colors.title_fg) 133 | }; 134 | let title_border_style = if title_selected { 135 | Style::default().fg(colors.border_selected_fg) 136 | } else { 137 | Style::default().fg(colors.border_fg) 138 | }; 139 | let title_block = Block::default() 140 | .title("Title") 141 | .borders(Borders::ALL) 142 | .border_style(title_border_style); 143 | let title_paragraph = Paragraph::new(state.title.as_str()) 144 | .block(title_block) 145 | .style(title_style); 146 | frame.render_widget(title_paragraph, layout[0]); 147 | 148 | // Render content field 149 | let content_selected = state.editing_field == EditingField::Content; 150 | let content_style = if content_selected { 151 | Style::default().fg(colors.content_selected_fg).add_modifier(Modifier::BOLD) 152 | } else { 153 | Style::default().fg(colors.content_fg) 154 | }; 155 | let content_border_style = if content_selected { 156 | Style::default().fg(colors.border_selected_fg) 157 | } else { 158 | Style::default().fg(colors.border_fg) 159 | }; 160 | let content_block = Block::default() 161 | .title("Content") 162 | .borders(Borders::ALL) 163 | .border_style(content_border_style); 164 | let content_paragraph = Paragraph::new(state.content.as_str()) 165 | .block(content_block) 166 | .style(content_style) 167 | .wrap(Wrap { trim: true }); 168 | 169 | frame.render_widget(content_paragraph, layout[1]); 170 | 171 | // Render instructions 172 | let instructions = vec![ 173 | Line::from(vec![ 174 | Span::styled("Tab", Style::default().fg(colors.instructions_key_fg)), 175 | Span::raw(": Switch field | "), 176 | Span::styled("Enter", Style::default().fg(colors.instructions_key_fg)), 177 | Span::raw(": Save | "), 178 | Span::styled("Esc", Style::default().fg(colors.instructions_key_fg)), 179 | Span::raw(": Cancel"), 180 | ]) 181 | ]; 182 | 183 | let instructions_paragraph = Paragraph::new(instructions) 184 | .style(Style::default().fg(colors.instructions_fg)); 185 | 186 | frame.render_widget(instructions_paragraph, layout[2]); 187 | } 188 | 189 | // Helper function to create a centered rectangle 190 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 191 | let popup_layout = Layout::vertical([ 192 | Constraint::Percentage((100 - percent_y) / 2), 193 | Constraint::Percentage(percent_y), 194 | Constraint::Percentage((100 - percent_y) / 2), 195 | ]).split(r); 196 | 197 | Layout::horizontal([ 198 | Constraint::Percentage((100 - percent_x) / 2), 199 | Constraint::Percentage(percent_x), 200 | Constraint::Percentage((100 - percent_x) / 2), 201 | ]).split(popup_layout[1])[1] 202 | } 203 | -------------------------------------------------------------------------------- /src/undo.rs: -------------------------------------------------------------------------------- 1 | use crate::task::Task; 2 | 3 | #[derive(Debug, Clone)] 4 | pub enum Operation { 5 | DeleteTask { 6 | task: Task, 7 | }, 8 | EditTask { 9 | task_id: String, 10 | old_task: Task, 11 | new_task: Task, 12 | }, 13 | CreateTask { 14 | task: Task, 15 | }, 16 | } 17 | 18 | #[derive(Debug, Clone)] 19 | pub struct UndoStack { 20 | undo_operations: Vec, 21 | redo_operations: Vec, 22 | max_size: usize, 23 | } 24 | 25 | impl UndoStack { 26 | pub fn new(max_size: usize) -> Self { 27 | Self { 28 | undo_operations: Vec::new(), 29 | redo_operations: Vec::new(), 30 | max_size, 31 | } 32 | } 33 | 34 | pub fn push(&mut self, operation: Operation) { 35 | self.undo_operations.push(operation); 36 | 37 | // Clear redo stack when new operation is added 38 | self.redo_operations.clear(); 39 | 40 | // Keep stack size under control 41 | if self.undo_operations.len() > self.max_size { 42 | self.undo_operations.remove(0); 43 | } 44 | } 45 | 46 | pub fn undo(&mut self) -> Option { 47 | if let Some(operation) = self.undo_operations.pop() { 48 | self.redo_operations.push(operation.clone()); 49 | Some(operation) 50 | } else { 51 | None 52 | } 53 | } 54 | 55 | pub fn redo(&mut self) -> Option { 56 | if let Some(operation) = self.redo_operations.pop() { 57 | self.undo_operations.push(operation.clone()); 58 | Some(operation) 59 | } else { 60 | None 61 | } 62 | } 63 | 64 | pub fn can_undo(&self) -> bool { 65 | !self.undo_operations.is_empty() 66 | } 67 | 68 | pub fn can_redo(&self) -> bool { 69 | !self.redo_operations.is_empty() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | /// Calculate the number of days in a given month and year 2 | pub fn days_in_month(year: i32, month: u32) -> u32 { 3 | match month { 4 | 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 5 | 4 | 6 | 9 | 11 => 30, 6 | 2 => if is_leap_year(year) { 29 } else { 28 }, 7 | _ => 31, // Default fallback, though this should never be reached with valid months 8 | } 9 | } 10 | 11 | /// Check if a given year is a leap year 12 | pub fn is_leap_year(year: i32) -> bool { 13 | year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) 14 | } 15 | --------------------------------------------------------------------------------