├── .github └── workflows │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── rustfmt.toml └── src ├── app.rs ├── args.rs ├── command.rs ├── config ├── mod.rs ├── profile_matching.rs └── storage.rs ├── event_loop.rs ├── main.rs ├── os_commands.rs ├── parse ├── char_pos_iter.rs └── mod.rs ├── stateful_table.rs ├── template.rs ├── terminal_manager.rs └── ui ├── keybindings.rs └── mod.rs /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Adapted from Shaun Zhu's comment on https://mateuscosta.me/rust-releases-with-github-actions 2 | 3 | name: release 4 | 5 | on: 6 | push: 7 | tags: 'v*' 8 | 9 | jobs: 10 | create_release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: create release 14 | id: create_release 15 | uses: actions/create-release@v1 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | with: 19 | tag_name: ${{ github.ref }} 20 | release_name: release ${{ github.ref }} 21 | draft: false 22 | prerelease: false 23 | outputs: 24 | upload_url: ${{ steps.create_release.outputs.upload_url }} 25 | upload_assets: 26 | needs: create_release 27 | strategy: 28 | matrix: 29 | include: 30 | - os: ubuntu-latest 31 | artifact_name: lazycli 32 | asset_name: lazycli-linux-x64.tar.gz 33 | - os: macos-latest 34 | artifact_name: lazycli 35 | asset_name: lazycli-darwin-x64.tar.gz 36 | - os: windows-latest 37 | artifact_name: lazycli.exe 38 | asset_name: lazycli-win-x64.tar.gz 39 | runs-on: ${{ matrix.os }} 40 | steps: 41 | - name: checkout the source code 42 | uses: actions/checkout@v2 43 | - name: build (release) 44 | run: cargo build --verbose --release 45 | - name: compress 46 | uses: master-atul/tar-action@v1.0.2 47 | with: 48 | command: c 49 | cwd: ./target/release 50 | files: | 51 | ${{ matrix.artifact_name }} 52 | outPath: ./target/release/${{ matrix.asset_name }} 53 | - name: upload release assets 54 | id: upload-release-assets 55 | uses: actions/upload-release-asset@v1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | upload_url: ${{ needs.create_release.outputs.upload_url }} 60 | asset_path: ./target/release/${{ matrix.asset_name }} 61 | asset_name: ${{ matrix.asset_name }} 62 | asset_content_type: application/tar+gzip 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | NOTES.txt -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "aho-corasick" 5 | version = "0.7.15" 6 | source = "registry+https://github.com/rust-lang/crates.io-index" 7 | checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5" 8 | dependencies = [ 9 | "memchr", 10 | ] 11 | 12 | [[package]] 13 | name = "ansi_term" 14 | version = "0.11.0" 15 | source = "registry+https://github.com/rust-lang/crates.io-index" 16 | checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" 17 | dependencies = [ 18 | "winapi", 19 | ] 20 | 21 | [[package]] 22 | name = "arrayref" 23 | version = "0.3.6" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" 26 | 27 | [[package]] 28 | name = "arrayvec" 29 | version = "0.5.2" 30 | source = "registry+https://github.com/rust-lang/crates.io-index" 31 | checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" 32 | 33 | [[package]] 34 | name = "atty" 35 | version = "0.2.14" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 38 | dependencies = [ 39 | "hermit-abi", 40 | "libc", 41 | "winapi", 42 | ] 43 | 44 | [[package]] 45 | name = "autocfg" 46 | version = "1.0.1" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 49 | 50 | [[package]] 51 | name = "base64" 52 | version = "0.13.0" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" 55 | 56 | [[package]] 57 | name = "bitflags" 58 | version = "1.2.1" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 61 | 62 | [[package]] 63 | name = "blake2b_simd" 64 | version = "0.5.11" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" 67 | dependencies = [ 68 | "arrayref", 69 | "arrayvec", 70 | "constant_time_eq", 71 | ] 72 | 73 | [[package]] 74 | name = "cassowary" 75 | version = "0.3.0" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 78 | 79 | [[package]] 80 | name = "cfg-if" 81 | version = "0.1.10" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 84 | 85 | [[package]] 86 | name = "cfg-if" 87 | version = "1.0.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 90 | 91 | [[package]] 92 | name = "clap" 93 | version = "3.0.0-beta.2" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "4bd1061998a501ee7d4b6d449020df3266ca3124b941ec56cf2005c3779ca142" 96 | dependencies = [ 97 | "atty", 98 | "bitflags", 99 | "clap_derive", 100 | "indexmap", 101 | "lazy_static", 102 | "os_str_bytes", 103 | "strsim", 104 | "termcolor", 105 | "textwrap", 106 | "unicode-width", 107 | "vec_map", 108 | ] 109 | 110 | [[package]] 111 | name = "clap_derive" 112 | version = "3.0.0-beta.2" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "370f715b81112975b1b69db93e0b56ea4cd4e5002ac43b2da8474106a54096a1" 115 | dependencies = [ 116 | "heck", 117 | "proc-macro-error", 118 | "proc-macro2", 119 | "quote", 120 | "syn", 121 | ] 122 | 123 | [[package]] 124 | name = "constant_time_eq" 125 | version = "0.1.5" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" 128 | 129 | [[package]] 130 | name = "crossbeam-utils" 131 | version = "0.8.3" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "e7e9d99fa91428effe99c5c6d4634cdeba32b8cf784fc428a2a687f61a952c49" 134 | dependencies = [ 135 | "autocfg", 136 | "cfg-if 1.0.0", 137 | "lazy_static", 138 | ] 139 | 140 | [[package]] 141 | name = "crossterm" 142 | version = "0.18.2" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "4e86d73f2a0b407b5768d10a8c720cf5d2df49a9efc10ca09176d201ead4b7fb" 145 | dependencies = [ 146 | "bitflags", 147 | "crossterm_winapi 0.6.2", 148 | "lazy_static", 149 | "libc", 150 | "mio", 151 | "parking_lot", 152 | "signal-hook", 153 | "winapi", 154 | ] 155 | 156 | [[package]] 157 | name = "crossterm" 158 | version = "0.19.0" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "7c36c10130df424b2f3552fcc2ddcd9b28a27b1e54b358b45874f88d1ca6888c" 161 | dependencies = [ 162 | "bitflags", 163 | "crossterm_winapi 0.7.0", 164 | "lazy_static", 165 | "libc", 166 | "mio", 167 | "parking_lot", 168 | "serde", 169 | "signal-hook", 170 | "winapi", 171 | ] 172 | 173 | [[package]] 174 | name = "crossterm_winapi" 175 | version = "0.6.2" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "c2265c3f8e080075d9b6417aa72293fc71662f34b4af2612d8d1b074d29510db" 178 | dependencies = [ 179 | "winapi", 180 | ] 181 | 182 | [[package]] 183 | name = "crossterm_winapi" 184 | version = "0.7.0" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "0da8964ace4d3e4a044fd027919b2237000b24315a37c916f61809f1ff2140b9" 187 | dependencies = [ 188 | "winapi", 189 | ] 190 | 191 | [[package]] 192 | name = "ctor" 193 | version = "0.1.17" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "373c88d9506e2e9230f6107701b7d8425f4cb3f6df108ec3042a26e936666da5" 196 | dependencies = [ 197 | "quote", 198 | "syn", 199 | ] 200 | 201 | [[package]] 202 | name = "difference" 203 | version = "2.0.0" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" 206 | 207 | [[package]] 208 | name = "directories" 209 | version = "3.0.1" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "f8fed639d60b58d0f53498ab13d26f621fd77569cc6edb031f4cc36a2ad9da0f" 212 | dependencies = [ 213 | "dirs-sys", 214 | ] 215 | 216 | [[package]] 217 | name = "dirs-sys" 218 | version = "0.3.5" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "8e93d7f5705de3e49895a2b5e0b8855a1c27f080192ae9c32a6432d50741a57a" 221 | dependencies = [ 222 | "libc", 223 | "redox_users", 224 | "winapi", 225 | ] 226 | 227 | [[package]] 228 | name = "dtoa" 229 | version = "0.4.7" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "88d7ed2934d741c6b37e33e3832298e8850b53fd2d2bea03873375596c7cea4e" 232 | 233 | [[package]] 234 | name = "either" 235 | version = "1.6.1" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" 238 | 239 | [[package]] 240 | name = "getrandom" 241 | version = "0.1.16" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" 244 | dependencies = [ 245 | "cfg-if 1.0.0", 246 | "libc", 247 | "wasi", 248 | ] 249 | 250 | [[package]] 251 | name = "hashbrown" 252 | version = "0.9.1" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" 255 | 256 | [[package]] 257 | name = "heck" 258 | version = "0.3.2" 259 | source = "registry+https://github.com/rust-lang/crates.io-index" 260 | checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac" 261 | dependencies = [ 262 | "unicode-segmentation", 263 | ] 264 | 265 | [[package]] 266 | name = "hermit-abi" 267 | version = "0.1.17" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "5aca5565f760fb5b220e499d72710ed156fdb74e631659e99377d9ebfbd13ae8" 270 | dependencies = [ 271 | "libc", 272 | ] 273 | 274 | [[package]] 275 | name = "indexmap" 276 | version = "1.6.1" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "4fb1fa934250de4de8aef298d81c729a7d33d8c239daa3a7575e6b92bfc7313b" 279 | dependencies = [ 280 | "autocfg", 281 | "hashbrown", 282 | ] 283 | 284 | [[package]] 285 | name = "instant" 286 | version = "0.1.9" 287 | source = "registry+https://github.com/rust-lang/crates.io-index" 288 | checksum = "61124eeebbd69b8190558df225adf7e4caafce0d743919e5d6b19652314ec5ec" 289 | dependencies = [ 290 | "cfg-if 1.0.0", 291 | ] 292 | 293 | [[package]] 294 | name = "itertools" 295 | version = "0.10.0" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "37d572918e350e82412fe766d24b15e6682fb2ed2bbe018280caa810397cb319" 298 | dependencies = [ 299 | "either", 300 | ] 301 | 302 | [[package]] 303 | name = "lazy_static" 304 | version = "1.4.0" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 307 | 308 | [[package]] 309 | name = "lazycli" 310 | version = "0.1.15" 311 | dependencies = [ 312 | "clap", 313 | "crossterm 0.19.0", 314 | "directories", 315 | "itertools", 316 | "pretty_assertions", 317 | "regex", 318 | "serde", 319 | "serde_yaml", 320 | "ticker", 321 | "tui", 322 | ] 323 | 324 | [[package]] 325 | name = "libc" 326 | version = "0.2.81" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "1482821306169ec4d07f6aca392a4681f66c75c9918aa49641a2595db64053cb" 329 | 330 | [[package]] 331 | name = "linked-hash-map" 332 | version = "0.5.4" 333 | source = "registry+https://github.com/rust-lang/crates.io-index" 334 | checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" 335 | 336 | [[package]] 337 | name = "lock_api" 338 | version = "0.4.2" 339 | source = "registry+https://github.com/rust-lang/crates.io-index" 340 | checksum = "dd96ffd135b2fd7b973ac026d28085defbe8983df057ced3eb4f2130b0831312" 341 | dependencies = [ 342 | "scopeguard", 343 | ] 344 | 345 | [[package]] 346 | name = "log" 347 | version = "0.4.11" 348 | source = "registry+https://github.com/rust-lang/crates.io-index" 349 | checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b" 350 | dependencies = [ 351 | "cfg-if 0.1.10", 352 | ] 353 | 354 | [[package]] 355 | name = "memchr" 356 | version = "2.3.4" 357 | source = "registry+https://github.com/rust-lang/crates.io-index" 358 | checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" 359 | 360 | [[package]] 361 | name = "mio" 362 | version = "0.7.7" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "e50ae3f04d169fcc9bde0b547d1c205219b7157e07ded9c5aff03e0637cb3ed7" 365 | dependencies = [ 366 | "libc", 367 | "log", 368 | "miow", 369 | "ntapi", 370 | "winapi", 371 | ] 372 | 373 | [[package]] 374 | name = "miow" 375 | version = "0.3.6" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "5a33c1b55807fbed163481b5ba66db4b2fa6cde694a5027be10fb724206c5897" 378 | dependencies = [ 379 | "socket2", 380 | "winapi", 381 | ] 382 | 383 | [[package]] 384 | name = "ntapi" 385 | version = "0.3.6" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" 388 | dependencies = [ 389 | "winapi", 390 | ] 391 | 392 | [[package]] 393 | name = "os_str_bytes" 394 | version = "2.4.0" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "afb2e1c3ee07430c2cf76151675e583e0f19985fa6efae47d6848a3e2c824f85" 397 | 398 | [[package]] 399 | name = "output_vt100" 400 | version = "0.1.2" 401 | source = "registry+https://github.com/rust-lang/crates.io-index" 402 | checksum = "53cdc5b785b7a58c5aad8216b3dfa114df64b0b06ae6e1501cef91df2fbdf8f9" 403 | dependencies = [ 404 | "winapi", 405 | ] 406 | 407 | [[package]] 408 | name = "parking_lot" 409 | version = "0.11.1" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb" 412 | dependencies = [ 413 | "instant", 414 | "lock_api", 415 | "parking_lot_core", 416 | ] 417 | 418 | [[package]] 419 | name = "parking_lot_core" 420 | version = "0.8.2" 421 | source = "registry+https://github.com/rust-lang/crates.io-index" 422 | checksum = "9ccb628cad4f84851442432c60ad8e1f607e29752d0bf072cbd0baf28aa34272" 423 | dependencies = [ 424 | "cfg-if 1.0.0", 425 | "instant", 426 | "libc", 427 | "redox_syscall", 428 | "smallvec", 429 | "winapi", 430 | ] 431 | 432 | [[package]] 433 | name = "pretty_assertions" 434 | version = "0.6.1" 435 | source = "registry+https://github.com/rust-lang/crates.io-index" 436 | checksum = "3f81e1644e1b54f5a68959a29aa86cde704219254669da328ecfdf6a1f09d427" 437 | dependencies = [ 438 | "ansi_term", 439 | "ctor", 440 | "difference", 441 | "output_vt100", 442 | ] 443 | 444 | [[package]] 445 | name = "proc-macro-error" 446 | version = "1.0.4" 447 | source = "registry+https://github.com/rust-lang/crates.io-index" 448 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 449 | dependencies = [ 450 | "proc-macro-error-attr", 451 | "proc-macro2", 452 | "quote", 453 | "syn", 454 | "version_check", 455 | ] 456 | 457 | [[package]] 458 | name = "proc-macro-error-attr" 459 | version = "1.0.4" 460 | source = "registry+https://github.com/rust-lang/crates.io-index" 461 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 462 | dependencies = [ 463 | "proc-macro2", 464 | "quote", 465 | "version_check", 466 | ] 467 | 468 | [[package]] 469 | name = "proc-macro2" 470 | version = "1.0.24" 471 | source = "registry+https://github.com/rust-lang/crates.io-index" 472 | checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" 473 | dependencies = [ 474 | "unicode-xid", 475 | ] 476 | 477 | [[package]] 478 | name = "quote" 479 | version = "1.0.8" 480 | source = "registry+https://github.com/rust-lang/crates.io-index" 481 | checksum = "991431c3519a3f36861882da93630ce66b52918dcf1b8e2fd66b397fc96f28df" 482 | dependencies = [ 483 | "proc-macro2", 484 | ] 485 | 486 | [[package]] 487 | name = "redox_syscall" 488 | version = "0.1.57" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" 491 | 492 | [[package]] 493 | name = "redox_users" 494 | version = "0.3.5" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" 497 | dependencies = [ 498 | "getrandom", 499 | "redox_syscall", 500 | "rust-argon2", 501 | ] 502 | 503 | [[package]] 504 | name = "regex" 505 | version = "1.4.2" 506 | source = "registry+https://github.com/rust-lang/crates.io-index" 507 | checksum = "38cf2c13ed4745de91a5eb834e11c00bcc3709e773173b2ce4c56c9fbde04b9c" 508 | dependencies = [ 509 | "aho-corasick", 510 | "memchr", 511 | "regex-syntax", 512 | "thread_local", 513 | ] 514 | 515 | [[package]] 516 | name = "regex-syntax" 517 | version = "0.6.21" 518 | source = "registry+https://github.com/rust-lang/crates.io-index" 519 | checksum = "3b181ba2dcf07aaccad5448e8ead58db5b742cf85dfe035e2227f137a539a189" 520 | 521 | [[package]] 522 | name = "rust-argon2" 523 | version = "0.8.3" 524 | source = "registry+https://github.com/rust-lang/crates.io-index" 525 | checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" 526 | dependencies = [ 527 | "base64", 528 | "blake2b_simd", 529 | "constant_time_eq", 530 | "crossbeam-utils", 531 | ] 532 | 533 | [[package]] 534 | name = "scopeguard" 535 | version = "1.1.0" 536 | source = "registry+https://github.com/rust-lang/crates.io-index" 537 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" 538 | 539 | [[package]] 540 | name = "serde" 541 | version = "1.0.118" 542 | source = "registry+https://github.com/rust-lang/crates.io-index" 543 | checksum = "06c64263859d87aa2eb554587e2d23183398d617427327cf2b3d0ed8c69e4800" 544 | dependencies = [ 545 | "serde_derive", 546 | ] 547 | 548 | [[package]] 549 | name = "serde_derive" 550 | version = "1.0.118" 551 | source = "registry+https://github.com/rust-lang/crates.io-index" 552 | checksum = "c84d3526699cd55261af4b941e4e725444df67aa4f9e6a3564f18030d12672df" 553 | dependencies = [ 554 | "proc-macro2", 555 | "quote", 556 | "syn", 557 | ] 558 | 559 | [[package]] 560 | name = "serde_yaml" 561 | version = "0.8.15" 562 | source = "registry+https://github.com/rust-lang/crates.io-index" 563 | checksum = "971be8f6e4d4a47163b405a3df70d14359186f9ab0f3a3ec37df144ca1ce089f" 564 | dependencies = [ 565 | "dtoa", 566 | "linked-hash-map", 567 | "serde", 568 | "yaml-rust", 569 | ] 570 | 571 | [[package]] 572 | name = "signal-hook" 573 | version = "0.1.17" 574 | source = "registry+https://github.com/rust-lang/crates.io-index" 575 | checksum = "7e31d442c16f047a671b5a71e2161d6e68814012b7f5379d269ebd915fac2729" 576 | dependencies = [ 577 | "libc", 578 | "mio", 579 | "signal-hook-registry", 580 | ] 581 | 582 | [[package]] 583 | name = "signal-hook-registry" 584 | version = "1.3.0" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "16f1d0fef1604ba8f7a073c7e701f213e056707210e9020af4528e0101ce11a6" 587 | dependencies = [ 588 | "libc", 589 | ] 590 | 591 | [[package]] 592 | name = "smallvec" 593 | version = "1.6.0" 594 | source = "registry+https://github.com/rust-lang/crates.io-index" 595 | checksum = "1a55ca5f3b68e41c979bf8c46a6f1da892ca4db8f94023ce0bd32407573b1ac0" 596 | 597 | [[package]] 598 | name = "socket2" 599 | version = "0.3.19" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "122e570113d28d773067fab24266b66753f6ea915758651696b6e35e49f88d6e" 602 | dependencies = [ 603 | "cfg-if 1.0.0", 604 | "libc", 605 | "winapi", 606 | ] 607 | 608 | [[package]] 609 | name = "strsim" 610 | version = "0.10.0" 611 | source = "registry+https://github.com/rust-lang/crates.io-index" 612 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 613 | 614 | [[package]] 615 | name = "syn" 616 | version = "1.0.57" 617 | source = "registry+https://github.com/rust-lang/crates.io-index" 618 | checksum = "4211ce9909eb971f111059df92c45640aad50a619cf55cd76476be803c4c68e6" 619 | dependencies = [ 620 | "proc-macro2", 621 | "quote", 622 | "unicode-xid", 623 | ] 624 | 625 | [[package]] 626 | name = "termcolor" 627 | version = "1.1.2" 628 | source = "registry+https://github.com/rust-lang/crates.io-index" 629 | checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" 630 | dependencies = [ 631 | "winapi-util", 632 | ] 633 | 634 | [[package]] 635 | name = "textwrap" 636 | version = "0.12.1" 637 | source = "registry+https://github.com/rust-lang/crates.io-index" 638 | checksum = "203008d98caf094106cfaba70acfed15e18ed3ddb7d94e49baec153a2b462789" 639 | dependencies = [ 640 | "unicode-width", 641 | ] 642 | 643 | [[package]] 644 | name = "thread_local" 645 | version = "1.0.1" 646 | source = "registry+https://github.com/rust-lang/crates.io-index" 647 | checksum = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14" 648 | dependencies = [ 649 | "lazy_static", 650 | ] 651 | 652 | [[package]] 653 | name = "ticker" 654 | version = "0.1.1" 655 | source = "registry+https://github.com/rust-lang/crates.io-index" 656 | checksum = "3f6821a2afe2700471d4572a25bcbfc091d6d596902a279ed41af139f350b76e" 657 | 658 | [[package]] 659 | name = "tui" 660 | version = "0.14.0" 661 | source = "registry+https://github.com/rust-lang/crates.io-index" 662 | checksum = "9ced152a8e9295a5b168adc254074525c17ac4a83c90b2716274cc38118bddc9" 663 | dependencies = [ 664 | "bitflags", 665 | "cassowary", 666 | "crossterm 0.18.2", 667 | "serde", 668 | "unicode-segmentation", 669 | "unicode-width", 670 | ] 671 | 672 | [[package]] 673 | name = "unicode-segmentation" 674 | version = "1.7.1" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" 677 | 678 | [[package]] 679 | name = "unicode-width" 680 | version = "0.1.8" 681 | source = "registry+https://github.com/rust-lang/crates.io-index" 682 | checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" 683 | 684 | [[package]] 685 | name = "unicode-xid" 686 | version = "0.2.1" 687 | source = "registry+https://github.com/rust-lang/crates.io-index" 688 | checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" 689 | 690 | [[package]] 691 | name = "vec_map" 692 | version = "0.8.2" 693 | source = "registry+https://github.com/rust-lang/crates.io-index" 694 | checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" 695 | 696 | [[package]] 697 | name = "version_check" 698 | version = "0.9.2" 699 | source = "registry+https://github.com/rust-lang/crates.io-index" 700 | checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" 701 | 702 | [[package]] 703 | name = "wasi" 704 | version = "0.9.0+wasi-snapshot-preview1" 705 | source = "registry+https://github.com/rust-lang/crates.io-index" 706 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" 707 | 708 | [[package]] 709 | name = "winapi" 710 | version = "0.3.9" 711 | source = "registry+https://github.com/rust-lang/crates.io-index" 712 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 713 | dependencies = [ 714 | "winapi-i686-pc-windows-gnu", 715 | "winapi-x86_64-pc-windows-gnu", 716 | ] 717 | 718 | [[package]] 719 | name = "winapi-i686-pc-windows-gnu" 720 | version = "0.4.0" 721 | source = "registry+https://github.com/rust-lang/crates.io-index" 722 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 723 | 724 | [[package]] 725 | name = "winapi-util" 726 | version = "0.1.5" 727 | source = "registry+https://github.com/rust-lang/crates.io-index" 728 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 729 | dependencies = [ 730 | "winapi", 731 | ] 732 | 733 | [[package]] 734 | name = "winapi-x86_64-pc-windows-gnu" 735 | version = "0.4.0" 736 | source = "registry+https://github.com/rust-lang/crates.io-index" 737 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 738 | 739 | [[package]] 740 | name = "yaml-rust" 741 | version = "0.4.5" 742 | source = "registry+https://github.com/rust-lang/crates.io-index" 743 | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" 744 | dependencies = [ 745 | "linked-hash-map", 746 | ] 747 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lazycli" 3 | version = "0.1.15" 4 | authors = ["Jesse Duffield "] 5 | license = "MIT" 6 | edition = "2018" 7 | description = "A tool to instantly wrap your CLI commands in TUIs" 8 | readme = "README.md" 9 | homepage = "https://github.com/jesseduffield/lazycli" 10 | repository = "https://github.com/jesseduffield/lazycli" 11 | keywords = ["cli", "lazy", "terminal", "tools"] 12 | categories = ["command-line-utilities"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | tui = { version = "0.14", default-features = false, features = ['crossterm', 'serde'] } 18 | crossterm = { version = "0.19", features = [ "serde" ] } 19 | clap = "3.0.0-beta.2" 20 | regex = "1" 21 | serde = "1.0" 22 | serde_yaml = "0.8.15" 23 | directories = "3.0" 24 | ticker = "0.1.1" 25 | itertools = "0.10.0" 26 | 27 | [dev-dependencies] 28 | pretty_assertions = "0.6.1" 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jesse Duffield 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 | # lazycli 2 | 3 | Turn static CLI commands into TUIs with ease 4 | 5 | ![Demo Animation](../assets/demo.gif?raw=true) 6 | 7 | Demo: 8 | 9 | [](https://www.youtube.com/watch?v=CRzcOpjuYSs&ab_channel=JesseDuffield) 10 | 11 | ## Usage 12 | 13 | Pick a command that spits out either a list or table of content, like `ls`, `docker ps`, `git branch`, or `git status --short`. Then run `lazycli -- ` 14 | ``` 15 | lazycli -- ls 16 | ``` 17 | 18 | If you find yourself often using lazycli with a specific command, you can easily alias it like so: 19 | 20 | ``` 21 | echo "alias lcd=\"lazycli -- docker ps\"" >> ~/.zshrc 22 | source ~/.zshrc 23 | lcd 24 | ``` 25 | 26 | Right now some default keybindings are defined for common commands like `ls`, `docker ps`, `git branch`, `git status --short`, etc. But you can customise it for any commands you like! Just open the config file from within the program with `$` and start playing around. 27 | 28 | lazycli is best suited towards any command-line program that spits out a list of items or a table. In your commands, simply refer to the column number by $0 for the first column, $1 for the second column, etc, and lazycli will do the rest. There are plenty of starting examples in the config that you'll be able to draw from. 29 | 30 | ## Installation 31 | 32 | ### Via Cargo 33 | 34 | ``` 35 | cargo install --locked lazycli 36 | ``` 37 | 38 | 39 | ### Via binary 40 | 41 | Download the binary from the [Releases Page](https://github.com/jesseduffield/lazycli/releases) 42 | 43 | 44 | ### Building from source 45 | 46 | 1) clone the repo: 47 | ``` 48 | git clone https://github.com/jesseduffield/lazycli.git 49 | ``` 50 | 2) install 51 | ``` 52 | cargo install --locked --path . 53 | ``` 54 | 3) run 55 | ``` 56 | lazycli -- ls 57 | ``` 58 | 59 | ## QandA 60 | * Q: Isn't this what fzf does? 61 | * A: Not quite: fzf requires you to know the command ahead of time whereas lazycli lets you run commands after presenting you the data, and the content is refreshed after you run the command rather than the program closing (admittedly I haven't used fzf but I'm pretty sure that's all correct). 62 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | tab_spaces = 2 2 | merge_imports = true 3 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::{ 4 | args::Args, 5 | command, 6 | config::{Config, Profile}, 7 | parse::Row, 8 | stateful_table::StatefulTable, 9 | template, 10 | }; 11 | 12 | #[derive(PartialEq)] 13 | pub enum FocusedPanel { 14 | Table, 15 | Search, 16 | // TODO: should I store the error here in the enum, given 17 | // it isn't needed anywhere else, and only applies to that panel? 18 | ErrorPopup(String), 19 | ConfirmationPopup(String), 20 | } 21 | 22 | pub struct App<'a> { 23 | pub rows: Vec, 24 | pub table: StatefulTable, 25 | pub config: &'a Config, 26 | pub profile: Option<&'a Profile>, 27 | pub args: Args, 28 | pub status_text: Option, 29 | pub filter_text: String, 30 | pub focused_panel: FocusedPanel, 31 | pub selected_item_content: String, 32 | pub config_path: PathBuf, 33 | } 34 | 35 | impl<'a> App<'a> { 36 | // TODO: do we really need a reference to the config? We should probably move it in here. But then we need to still work out how to have a profile field. We could either make that a function or make it an immutable reference 37 | pub fn new(config: &'a Config, config_path: PathBuf, args: Args) -> App<'a> { 38 | let profile = config.find_profile_for_command(args.command.as_ref()); 39 | 40 | App { 41 | table: StatefulTable::new(0), 42 | rows: vec![], 43 | config, 44 | profile, 45 | args, 46 | status_text: None, 47 | filter_text: String::from(""), 48 | focused_panel: FocusedPanel::Table, 49 | selected_item_content: String::from(""), 50 | config_path, 51 | } 52 | } 53 | 54 | pub fn on_select(&mut self) -> Option<()> { 55 | let selected_row = self.get_selected_row()?; 56 | let command_template = self.profile?.display_command.as_ref()?; 57 | let command = template::resolve_command(command_template, selected_row); 58 | 59 | let output = command::run_command(&command).unwrap(); 60 | self.selected_item_content = output; 61 | 62 | Some(()) 63 | } 64 | 65 | pub fn filtered_rows(&self) -> Vec<&Row> { 66 | let lc_filter_text = self.filter_text.to_ascii_lowercase(); 67 | 68 | match self.filter_text.as_ref() { 69 | // TODO: ask if this is idiomatic rust: i.e. converting a Vec to Vec<&Row> 70 | "" => self.rows.iter().collect(), 71 | _ => self 72 | .rows 73 | .iter() 74 | .filter(|row| { 75 | row 76 | .original_line 77 | .to_ascii_lowercase() 78 | .contains(&lc_filter_text) 79 | }) 80 | .collect(), 81 | } 82 | } 83 | 84 | pub fn get_selected_row(&self) -> Option<&Row> { 85 | let selected_index = self.table.state.selected().unwrap(); 86 | 87 | Some(*self.filtered_rows().get(selected_index)?) 88 | } 89 | 90 | pub fn adjust_cursor(&mut self) { 91 | let filtered_rows = self.filtered_rows(); 92 | let length = filtered_rows.len(); 93 | self.table.row_count = length; 94 | // if our cursor is too far we need to correct it 95 | if length == 0 { 96 | self.table.state.select(Some(0)); 97 | } else if self.table.state.selected().unwrap() > length - 1 { 98 | self.table.state.select(Some(length - 1)); 99 | } 100 | } 101 | 102 | pub fn update_rows(&mut self, rows: Vec) { 103 | self.rows = rows; 104 | self.adjust_cursor(); 105 | } 106 | 107 | pub fn on_tick(&mut self) { 108 | // do nothing for now 109 | } 110 | 111 | pub fn push_filter_text_char(&mut self, c: char) { 112 | self.filter_text.push(c); 113 | self.adjust_cursor(); 114 | } 115 | 116 | pub fn pop_filter_text_char(&mut self) { 117 | self.filter_text.pop(); 118 | self.adjust_cursor(); 119 | } 120 | 121 | pub fn reset_filter_text(&mut self) { 122 | self.filter_text = String::from(""); 123 | self.adjust_cursor(); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use clap::{App as ClapApp, Arg}; 2 | 3 | pub struct Args { 4 | pub command: String, 5 | pub lines_to_skip: usize, 6 | pub refresh_frequency: f64, 7 | } 8 | 9 | impl Args { 10 | pub fn new() -> Args { 11 | let matches = ClapApp::new("lazycli") 12 | .version("0.1") 13 | .author("Jesse Duffield ") 14 | .about("Lets you run custom commands on a list/table returned by another program") 15 | .arg( 16 | Arg::new("ignore") 17 | .short('i') 18 | .long("ignore") 19 | .value_name("IGNORE") 20 | .about("ignores the first `n` lines of output") 21 | .takes_value(true), 22 | ) 23 | .arg( 24 | Arg::new("refresh") 25 | .short('r') 26 | .long("refresh") 27 | .value_name("REFRESH") 28 | .about("frequency of refreshing the content in seconds (values like 0.1 are permitted. Values like 0.001? Also permitted, but you need to seriously look yourself in the eyes and ask whether that's something you want. Be careful, if you stare into your own eyes long enough in the mirror, a moment eventually comes when you realise that you truly exist and are aware that you exist. A revelation you might not want to inflict on yourself, especially if it's just for the sake of knowing deep down whether you want to push the limits of a command line argument)") 29 | .takes_value(true), 30 | ) 31 | .arg(Arg::new("command").multiple(true)) 32 | .get_matches(); 33 | 34 | let command = match matches.values_of("command") { 35 | Some(matches) => matches.collect::>().join(" "), 36 | None => { 37 | eprintln!("Usage: Command must be supplied, e.g.: `lazycli -- ls -l`"); 38 | std::process::exit(1); 39 | } 40 | }; 41 | 42 | let lines_to_skip = match matches.value_of("ignore") { 43 | None => 0, 44 | Some(s) => match s.parse::() { 45 | Ok(n) => n, 46 | Err(_) => { 47 | eprintln!("ignore argument must be a number"); 48 | std::process::exit(1); 49 | } 50 | }, 51 | }; 52 | 53 | let refresh_frequency = match matches.value_of("refresh") { 54 | None => 0.0, 55 | Some(s) => match s.parse::() { 56 | Ok(n) => n, 57 | Err(_) => { 58 | eprintln!("refresh argument must be a number"); 59 | std::process::exit(1); 60 | } 61 | }, 62 | }; 63 | 64 | Args { 65 | command, 66 | lines_to_skip, 67 | refresh_frequency, 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/command.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | pub fn run_command(command: &str) -> Result { 4 | let output = Command::new("bash") 5 | .args(&["-c", command]) 6 | .output() 7 | .expect(&format!("failed to run command {}", command)); 8 | 9 | if !output.status.success() { 10 | return Err(String::from_utf8(output.stderr).unwrap()); 11 | } 12 | 13 | Ok(String::from_utf8(output.stdout).unwrap()) 14 | } 15 | 16 | #[cfg(test)] 17 | #[test] 18 | fn test_run_command() { 19 | let result = run_command("echo 1"); 20 | assert_eq!(result, Ok(String::from("1\n"))); 21 | } 22 | 23 | #[test] 24 | fn test_run_command_fail() { 25 | let result = run_command("asldfkjh test"); 26 | assert!(matches!( 27 | result, 28 | Err(e) if e.contains("command not found") && e.contains("asldfkjh"), 29 | )); 30 | } 31 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | mod profile_matching; 2 | pub mod storage; 3 | 4 | use profile_matching::command_matches; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 8 | pub struct Config { 9 | pub profiles: Vec, 10 | } 11 | 12 | pub trait IsZero { 13 | fn is_zero(&self) -> bool; 14 | } 15 | 16 | impl IsZero for usize { 17 | fn is_zero(&self) -> bool { 18 | *self == 0 19 | } 20 | } 21 | 22 | pub trait IsFalse { 23 | fn is_false(&self) -> bool; 24 | } 25 | 26 | impl IsFalse for bool { 27 | fn is_false(&self) -> bool { 28 | !*self 29 | } 30 | } 31 | 32 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 33 | pub struct Profile { 34 | pub name: String, 35 | pub registered_commands: Vec, 36 | pub key_bindings: Vec, 37 | #[serde(default = "usize::default")] 38 | #[serde(skip_serializing_if = "IsZero::is_zero")] 39 | pub lines_to_skip: usize, 40 | #[serde(skip_serializing_if = "Option::is_none")] 41 | pub refresh_frequency: Option, 42 | #[serde(skip_serializing_if = "Option::is_none")] 43 | pub display_command: Option, 44 | } 45 | 46 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 47 | pub struct KeyBinding { 48 | pub key: char, 49 | pub command: String, 50 | #[serde(default = "bool::default")] 51 | #[serde(skip_serializing_if = "IsFalse::is_false")] 52 | pub confirm: bool, 53 | #[serde(skip_serializing_if = "Option::is_none")] 54 | pub regex: Option, 55 | } 56 | 57 | impl Default for KeyBinding { 58 | fn default() -> KeyBinding { 59 | KeyBinding { 60 | key: ' ', 61 | command: String::from(""), 62 | confirm: false, 63 | regex: None, 64 | } 65 | } 66 | } 67 | 68 | pub trait Command { 69 | fn command(&self) -> &str; 70 | fn regex(&self) -> Option<&str>; 71 | } 72 | 73 | // TODO: is there a better way to do this? 74 | impl Command for KeyBinding { 75 | fn command(&self) -> &str { 76 | return &self.command; 77 | } 78 | fn regex(&self) -> Option<&str> { 79 | return self.regex.as_deref(); 80 | } 81 | } 82 | 83 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 84 | pub struct DisplayCommand { 85 | pub command: String, 86 | pub regex: Option, 87 | } 88 | 89 | // TODO: is there a better way to do this? 90 | impl Command for DisplayCommand { 91 | fn command(&self) -> &str { 92 | return &self.command; 93 | } 94 | fn regex(&self) -> Option<&str> { 95 | return self.regex.as_deref(); 96 | } 97 | } 98 | 99 | impl Config { 100 | pub fn to_yaml(&self) -> Result { 101 | serde_yaml::to_string(self) 102 | } 103 | 104 | pub fn from_yaml(yaml: String) -> Result { 105 | serde_yaml::from_str(&yaml) 106 | } 107 | 108 | pub fn find_profile_for_command(&self, command: &str) -> Option<&Profile> { 109 | self.profiles.iter().find(|p| { 110 | p.registered_commands 111 | .iter() 112 | .any(|c| command_matches(command, c)) 113 | }) 114 | } 115 | 116 | pub fn new() -> Config { 117 | // just doing a dummy one for now 118 | Config { 119 | profiles: vec![ 120 | Profile { 121 | name: String::from("ls"), 122 | registered_commands: vec![ 123 | String::from("ls -1"), 124 | String::from("ls -a"), 125 | String::from("ls"), 126 | ], 127 | key_bindings: vec![ 128 | KeyBinding { 129 | key: 'd', 130 | command: String::from("rm -rf $0"), 131 | confirm: true, 132 | ..Default::default() 133 | }, 134 | KeyBinding { 135 | key: 'o', 136 | command: String::from("open $0"), 137 | ..Default::default() 138 | }, 139 | KeyBinding { 140 | key: 'u', 141 | command: String::from("cd $0"), 142 | ..Default::default() 143 | }, 144 | ], 145 | lines_to_skip: 0, 146 | refresh_frequency: None, 147 | // display_command: Some(DisplayCommand { 148 | // command: String::from("cat $0"), 149 | // regex: None, 150 | // }), 151 | display_command: None, 152 | }, 153 | Profile { 154 | name: String::from("ls -l"), 155 | registered_commands: vec![String::from("ls -l")], 156 | key_bindings: vec![ 157 | KeyBinding { 158 | key: 'd', 159 | command: String::from("rm -rf $8"), 160 | confirm: true, 161 | ..Default::default() 162 | }, 163 | KeyBinding { 164 | key: 'o', 165 | command: String::from("open $8"), 166 | ..Default::default() 167 | }, 168 | KeyBinding { 169 | key: 'u', 170 | command: String::from("cd $8"), 171 | ..Default::default() 172 | }, 173 | ], 174 | lines_to_skip: 1, 175 | refresh_frequency: None, 176 | display_command: None, 177 | }, 178 | Profile { 179 | name: String::from("git status --short"), 180 | registered_commands: vec![String::from("git status --short")], 181 | key_bindings: vec![ 182 | KeyBinding { 183 | key: 'A', 184 | command: String::from("git add $1"), 185 | ..Default::default() 186 | }, 187 | KeyBinding { 188 | key: 'a', 189 | command: String::from("git reset $1"), 190 | confirm: false, 191 | ..Default::default() 192 | }, 193 | KeyBinding { 194 | key: 'd', 195 | command: String::from("rm -rf $1"), 196 | confirm: true, 197 | ..Default::default() 198 | }, 199 | ], 200 | lines_to_skip: 0, 201 | refresh_frequency: None, 202 | // display_command: Some(DisplayCommand { 203 | // command: String::from("git diff $1"), 204 | // regex: None, 205 | // }), 206 | display_command: None, 207 | }, 208 | Profile { 209 | name: String::from("git status"), 210 | registered_commands: vec![String::from("git status")], 211 | key_bindings: vec![ 212 | KeyBinding { 213 | key: 'A', 214 | command: String::from("git add $0"), 215 | ..Default::default() 216 | }, 217 | KeyBinding { 218 | key: 'a', 219 | command: String::from("git reset $1"), 220 | confirm: true, 221 | regex: Some(String::from(".*:\\s+([^\\s]+)")), 222 | ..Default::default() 223 | }, 224 | KeyBinding { 225 | key: 'd', 226 | command: String::from("rm -rf $1"), 227 | confirm: true, 228 | ..Default::default() 229 | }, 230 | ], 231 | lines_to_skip: 0, 232 | refresh_frequency: None, 233 | // display_command: Some(DisplayCommand { 234 | // command: String::from("git diff $1"), 235 | // regex: None, 236 | // }), 237 | display_command: None, 238 | }, 239 | Profile { 240 | name: String::from("docker ps"), 241 | registered_commands: vec![String::from("docker ps")], 242 | key_bindings: vec![ 243 | KeyBinding { 244 | key: 's', 245 | command: String::from("docker stop $0"), 246 | confirm: true, 247 | ..Default::default() 248 | }, 249 | KeyBinding { 250 | key: 'r', 251 | command: String::from("docker restart $0"), 252 | confirm: false, 253 | ..Default::default() 254 | }, 255 | KeyBinding { 256 | key: 'd', 257 | command: String::from("docker kill $0"), 258 | confirm: true, 259 | ..Default::default() 260 | }, 261 | ], 262 | lines_to_skip: 0, 263 | refresh_frequency: None, 264 | display_command: None, 265 | // display_command: Some(DisplayCommand { 266 | // command: String::from("docker inspect $0"), 267 | // regex: None, 268 | // }), 269 | }, 270 | Profile { 271 | name: String::from("git branch"), 272 | registered_commands: vec![String::from("git branch")], 273 | key_bindings: vec![KeyBinding { 274 | key: 'c', 275 | command: String::from("git checkout $1"), 276 | ..Default::default() 277 | }], 278 | lines_to_skip: 0, 279 | refresh_frequency: None, 280 | // display_command: Some(DisplayCommand { 281 | // command: String::from("git log --oneline $0"), 282 | // regex: None, 283 | // }), 284 | display_command: None, 285 | }, 286 | Profile { 287 | name: String::from("git log"), 288 | registered_commands: vec![String::from("git log --oneline")], 289 | key_bindings: vec![KeyBinding { 290 | key: 'c', 291 | command: String::from("git checkout $0"), 292 | ..Default::default() 293 | }], 294 | lines_to_skip: 0, 295 | refresh_frequency: None, 296 | // display_command: Some(DisplayCommand { 297 | // command: String::from("git show $0"), 298 | // regex: None, 299 | // }), 300 | display_command: None, 301 | }, 302 | Profile { 303 | name: String::from("lsof -iTCP | grep LISTEN"), 304 | registered_commands: vec![ 305 | String::from("lsof -iTCP | grep LISTEN"), 306 | String::from("lsof -iTCP"), 307 | ], 308 | key_bindings: vec![KeyBinding { 309 | key: 'd', 310 | command: String::from("kill -9 $1"), 311 | confirm: true, 312 | ..Default::default() 313 | }], 314 | lines_to_skip: 0, 315 | refresh_frequency: None, 316 | display_command: None, 317 | }, 318 | ], 319 | } 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/config/profile_matching.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | 3 | // this function tells us whether the entered command matches a given command pattern 4 | // associated with a profile of keybindings 5 | pub fn command_matches(command: &str, pattern: &str) -> bool { 6 | let regex_str_inner = pattern 7 | .split("*") 8 | .map(|chunk| regex::escape(chunk)) 9 | .collect::>() 10 | .join(".*"); 11 | 12 | let regex_str = ["^", regex_str_inner.as_ref(), "$"].join(""); 13 | 14 | let re = Regex::new(regex_str.as_ref()).unwrap(); 15 | 16 | re.is_match(command) 17 | } 18 | 19 | #[cfg(test)] 20 | mod tests { 21 | use super::*; 22 | 23 | #[test] 24 | fn test_command_matches_exact_match() { 25 | assert!( 26 | command_matches("ls", "ls"), 27 | "should have returned true for exact match on pattern 'ls'", 28 | ) 29 | } 30 | 31 | #[test] 32 | fn test_command_matches_non_exact_match() { 33 | assert!( 34 | !command_matches("ls", "ls blah"), 35 | "should have returned false for mismatch", 36 | ) 37 | } 38 | 39 | #[test] 40 | fn test_command_matches_with_wildcard() { 41 | assert!( 42 | command_matches("ls -l", "ls *"), 43 | "should have matched due to wildcard", 44 | ) 45 | } 46 | 47 | #[test] 48 | fn test_command_does_not_match_with_wildcard() { 49 | assert!( 50 | !command_matches("git status", "ls *"), 51 | "should not have matched despite wildcard", 52 | ) 53 | } 54 | 55 | #[test] 56 | fn test_with_escaped_characters() { 57 | assert!( 58 | !command_matches("blah", "(blah())/.txt*.blah"), 59 | "should not have matched despite wildcard", 60 | ) 61 | } 62 | 63 | #[test] 64 | fn test_with_escaped_characters_when_matched() { 65 | assert!( 66 | command_matches("ls blah/blah.txt", "ls *"), 67 | "should have matched", 68 | ) 69 | } 70 | 71 | #[test] 72 | fn test_with_multiple_wildcards() { 73 | assert!( 74 | command_matches("blah hehe haha hmm lol", "blah*haha*lol"), 75 | "should not have matched due to multiple wildcards", 76 | ) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/config/storage.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, fs::File, io::prelude::*, path::PathBuf}; 2 | 3 | use super::Config; 4 | 5 | use std::{fs, io, path::Path}; 6 | 7 | extern crate directories; 8 | use directories::ProjectDirs; 9 | 10 | pub const CONFIG_DIR_ENV_VAR: &str = "LAZYCLI_CONFIG_DIR"; 11 | 12 | // adapted from xdg crate 13 | fn write_file

(home: &PathBuf, path: P) -> io::Result 14 | where 15 | P: AsRef, 16 | { 17 | match path.as_ref().parent() { 18 | Some(parent) => (fs::create_dir_all(home.join(parent)))?, 19 | None => (fs::create_dir_all(home))?, 20 | } 21 | Ok(PathBuf::from(home.join(path.as_ref()))) 22 | } 23 | 24 | pub fn config_path(dir_env_var: Option) -> Result> { 25 | let config_dir = config_dir(dir_env_var); 26 | 27 | Ok(write_file(&PathBuf::from(config_dir), "config.yml")?) 28 | } 29 | 30 | fn config_dir(dir_env_var: Option) -> PathBuf { 31 | match dir_env_var { 32 | Some(value) => PathBuf::from(value), 33 | None => ProjectDirs::from("", "", "lazycli") 34 | .unwrap() 35 | .config_dir() 36 | .to_owned(), 37 | } 38 | } 39 | 40 | pub fn prepare_config(config_path: &PathBuf) -> Result> { 41 | if config_path.exists() { 42 | let mut file = File::open(config_path)?; 43 | let mut contents = String::new(); 44 | file.read_to_string(&mut contents)?; 45 | Ok(Config::from_yaml(contents)?) 46 | } else { 47 | let default_config_yml = Config::new().to_yaml().unwrap(); 48 | let config = Config::new(); 49 | let mut file = File::create(config_path)?; 50 | file.write_all(default_config_yml.as_bytes())?; 51 | Ok(config) 52 | } 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use super::*; 58 | use pretty_assertions::assert_eq; 59 | 60 | #[test] 61 | fn test_use_config_dir_when_provided() { 62 | assert_eq!( 63 | config_dir(Some(String::from("/mydir"))), 64 | PathBuf::from("/mydir") 65 | ) 66 | } 67 | 68 | #[test] 69 | fn test_fallback_to_default_config_dir() { 70 | let result = config_dir(None); 71 | println!("{:?}", result); 72 | assert!( 73 | // not asserting on the whole path given that it's platform-dependent 74 | result.ends_with("lazycli"), 75 | "should end in 'lazycli'!" 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/event_loop.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{self, Event as CEvent, KeyCode, KeyEvent, KeyModifiers}; 2 | use std::{ 3 | error::Error, 4 | sync::mpsc::{self, Receiver, Sender}, 5 | thread, 6 | time::{Duration, Instant}, 7 | }; 8 | 9 | use ticker::Ticker; 10 | 11 | use crate::{ 12 | app::{App, FocusedPanel}, 13 | command, os_commands, 14 | parse::{self, Row}, 15 | template, 16 | terminal_manager::TerminalManager, 17 | ui, 18 | }; 19 | 20 | enum Event { 21 | Input(I), 22 | Tick, 23 | RefetchData(bool), // the bool here is true if it's a background refetch 24 | RowsLoaded(Vec), 25 | Error(String), 26 | } 27 | 28 | pub fn run(mut app: App) -> Result<(), Box> { 29 | // select the first row (no rows will be loaded at this point but that's okay) 30 | app.table.next(); 31 | 32 | let lines_to_skip = if app.args.lines_to_skip != 0 { 33 | app.args.lines_to_skip 34 | } else { 35 | match app.profile { 36 | Some(profile) => profile.lines_to_skip, 37 | None => 0, 38 | } 39 | }; 40 | 41 | // comparing two floating points directly: probably not advisable? 42 | let refresh_frequency = if app.args.refresh_frequency != 0.0 { 43 | app.args.refresh_frequency 44 | } else { 45 | match app.profile { 46 | Some(profile) => profile.refresh_frequency.unwrap_or(0.0), 47 | None => 0.0, 48 | } 49 | }; 50 | 51 | let mut terminal_manager = TerminalManager::new()?; 52 | 53 | let (tx, rx) = mpsc::channel(); 54 | let (loading_tx, loading_rx) = mpsc::channel(); 55 | 56 | poll_events(&tx); 57 | poll_refetches(&tx, refresh_frequency); 58 | poll_loading(&tx, loading_rx); 59 | 60 | tx.send(Event::RefetchData(false)).unwrap(); 61 | 62 | loop { 63 | terminal_manager 64 | .terminal 65 | .draw(|frame| ui::draw(frame, &mut app))?; 66 | 67 | let mut on_event = |event: Event| -> Result> { 68 | handle_event( 69 | event, 70 | &mut app, 71 | &mut terminal_manager, 72 | &tx, 73 | lines_to_skip, 74 | &loading_tx, 75 | ) 76 | }; 77 | 78 | // You might be wondering, what's going on here? As it so happens, we're blocking until the first event is received, and then processing any other events in the buffer before continuing. If we only handle one event per iteration of the loop, that's a lot of unnecessary drawing. On the other hand, if we don't block on any events, we'll end up drawing constantly while waiting for the next event to be received, causing CPU to go through the roof. 79 | if !on_event(rx.recv()?)? { 80 | break; 81 | } 82 | 83 | for backlogged_event in rx.try_iter() { 84 | if !on_event(backlogged_event)? { 85 | break; 86 | } 87 | } 88 | } 89 | 90 | Ok(()) 91 | } 92 | 93 | fn poll_events(tx: &Sender>) { 94 | let tick_rate = Duration::from_millis(10000); // TODO: do we actually need this? 95 | let tx_clone = tx.clone(); 96 | 97 | thread::spawn(move || { 98 | let mut last_tick = Instant::now(); 99 | loop { 100 | // poll for tick rate duration, if no events, sent tick event. 101 | let timeout = tick_rate 102 | .checked_sub(last_tick.elapsed()) 103 | .unwrap_or_else(|| Duration::from_secs(0)); 104 | if event::poll(timeout).unwrap() { 105 | if let CEvent::Key(key) = event::read().unwrap() { 106 | tx_clone.send(Event::Input(key)).unwrap(); 107 | } 108 | } 109 | 110 | if last_tick.elapsed() >= tick_rate { 111 | last_tick = Instant::now(); 112 | } 113 | } 114 | }); 115 | } 116 | 117 | fn poll_refetches(tx: &Sender>, refresh_frequency: f64) { 118 | if refresh_frequency == 0.0 { 119 | return; 120 | } 121 | 122 | let tick_rate = Duration::from_millis((refresh_frequency * 1000.0).round() as u64); 123 | let tx_clone = tx.clone(); 124 | 125 | thread::spawn(move || { 126 | let ticker = Ticker::new(0.., tick_rate); 127 | for _ in ticker { 128 | tx_clone.send(Event::RefetchData(true)).ok(); 129 | } 130 | }); 131 | } 132 | 133 | fn poll_loading(tx: &Sender>, loading_rx: Receiver) { 134 | let tx_clone = tx.clone(); 135 | 136 | thread::spawn(move || { 137 | let interval = Duration::from_millis(100); 138 | let mut is_loading = false; 139 | 140 | loop { 141 | thread::sleep(interval); 142 | 143 | is_loading = if is_loading { 144 | match loading_rx.try_recv() { 145 | Ok(v) => v, 146 | Err(mpsc::TryRecvError::Empty) => is_loading, 147 | Err(e) => panic!("Unexpected error: {:?}", e), 148 | } 149 | } else { 150 | match loading_rx.recv() { 151 | Ok(v) => v, 152 | // we get this error when we quit the application so we're just returning false for now 153 | Err(_) => false, 154 | } 155 | }; 156 | 157 | if is_loading { 158 | tx_clone.send(Event::Tick).unwrap(); 159 | } 160 | } 161 | }); 162 | } 163 | 164 | fn handle_event( 165 | event: Event, 166 | app: &mut App, 167 | terminal_manager: &mut TerminalManager, 168 | tx: &Sender>, 169 | lines_to_skip: usize, 170 | loading_tx: &Sender, 171 | ) -> Result> { 172 | fn navigate_down(app: &mut App) { 173 | app.table.next(); 174 | app.on_select(); 175 | } 176 | fn navigate_up(app: &mut App) { 177 | app.table.previous(); 178 | app.on_select(); 179 | } 180 | match event { 181 | Event::Input(event) => { 182 | if event.code == KeyCode::Char('c') && event.modifiers == KeyModifiers::CONTROL { 183 | terminal_manager.teardown()?; 184 | return Ok(false); 185 | } 186 | 187 | match app.focused_panel { 188 | FocusedPanel::Table => match event.code { 189 | KeyCode::Char('q') => { 190 | terminal_manager.teardown()?; 191 | return Ok(false); 192 | } 193 | KeyCode::Esc => { 194 | app.reset_filter_text(); 195 | } 196 | KeyCode::Down | KeyCode::Char('j') => navigate_down(app), 197 | KeyCode::Char('n') if event.modifiers == KeyModifiers::CONTROL => navigate_down(app), 198 | KeyCode::Up | KeyCode::Char('k') => navigate_up(app), 199 | KeyCode::Char('p') if event.modifiers == KeyModifiers::CONTROL => navigate_up(app), 200 | KeyCode::Char('/') => { 201 | app.focused_panel = FocusedPanel::Search; 202 | } 203 | KeyCode::Char('$') => { 204 | // TODO: wonder if the typical user would prefer opening the file or switching to vim to edit it? If they do want to open it, we probably need an OS-specific command to be entered here. 205 | run_command( 206 | app, 207 | loading_tx, 208 | tx, 209 | os_commands::open_command(app.config_path.to_str().unwrap()), 210 | ); 211 | } 212 | KeyCode::Char(c) => { 213 | handle_keybinding_press(app, loading_tx, tx, c); 214 | } 215 | _ => (), 216 | }, 217 | FocusedPanel::Search => match event.code { 218 | KeyCode::Backspace => { 219 | app.pop_filter_text_char(); 220 | } 221 | KeyCode::Esc => { 222 | app.reset_filter_text(); 223 | app.focused_panel = FocusedPanel::Table; 224 | } 225 | KeyCode::Enter => { 226 | app.focused_panel = FocusedPanel::Table; 227 | } 228 | KeyCode::Char(c) => { 229 | app.push_filter_text_char(c); 230 | } 231 | _ => (), 232 | }, 233 | FocusedPanel::ErrorPopup(_) => match event.code { 234 | KeyCode::Char('q') => { 235 | terminal_manager.teardown()?; 236 | return Ok(false); 237 | } 238 | KeyCode::Esc => { 239 | app.focused_panel = FocusedPanel::Table; 240 | } 241 | _ => {} 242 | }, 243 | FocusedPanel::ConfirmationPopup(ref command) => match event.code { 244 | KeyCode::Enter => { 245 | // interesting lesson here: if I have command.clone() in the call to run_command itself (i.e. no intermediate variable) I get an error for borrowing app twice because I borrow it once to get the command and then I pass it as a mutable reference into the run_command function. With this intermediate variable, rust knows we no longer need the reference to app so I'm okay to go ahead and get the mutable reference. 246 | let cloned_command = command.clone(); 247 | run_command(app, loading_tx, tx, cloned_command); 248 | app.focused_panel = FocusedPanel::Table; 249 | } 250 | KeyCode::Char('q') => { 251 | terminal_manager.teardown()?; 252 | return Ok(false); 253 | } 254 | KeyCode::Esc => { 255 | app.focused_panel = FocusedPanel::Table; 256 | } 257 | _ => {} 258 | }, 259 | } 260 | } 261 | 262 | Event::Tick => { 263 | app.on_tick(); 264 | } 265 | Event::RefetchData(background) => { 266 | refetch_data(app, tx, lines_to_skip, loading_tx, background); 267 | } 268 | Event::RowsLoaded(rows) => { 269 | on_rows_loaded(app, loading_tx, rows); 270 | } 271 | Event::Error(error) => { 272 | app.focused_panel = FocusedPanel::ErrorPopup(error); 273 | app.status_text = None; 274 | } 275 | } 276 | 277 | Ok(true) 278 | } 279 | 280 | fn handle_keybinding_press( 281 | app: &mut App, 282 | loading_tx: &Sender, 283 | tx: &Sender>, 284 | c: char, 285 | ) -> Option<()> { 286 | let binding = app.profile?.key_bindings.iter().find(|&kb| kb.key == c)?; 287 | 288 | let command = template::resolve_command(binding, app.get_selected_row()?); 289 | 290 | if binding.confirm { 291 | app.focused_panel = FocusedPanel::ConfirmationPopup(command); 292 | } else { 293 | run_command(app, loading_tx, tx, command); 294 | } 295 | 296 | Some(()) 297 | } 298 | 299 | fn run_command( 300 | app: &mut App, 301 | loading_tx: &Sender, 302 | tx: &Sender>, 303 | command: String, 304 | ) { 305 | app.status_text = Some(format!("Running command: {}", command)); 306 | loading_tx.send(true).unwrap(); 307 | 308 | let tx_clone = tx.clone(); 309 | thread::spawn(move || match command::run_command(&command) { 310 | Ok(_) => tx_clone.send(Event::RefetchData(false)).unwrap(), 311 | Err(error) => tx_clone.send(Event::Error(error)).unwrap(), 312 | }); 313 | } 314 | 315 | fn refetch_data( 316 | app: &mut App, 317 | tx: &Sender>, 318 | lines_to_skip: usize, 319 | loading_tx: &Sender, 320 | background: bool, 321 | ) { 322 | let command = app.args.command.clone(); 323 | app.status_text = Some(if background { 324 | String::from("") 325 | } else { 326 | format!("Running command: {} (if this is taking a while the program might be continuously streaming data which is not yet supported)", command) 327 | }); 328 | loading_tx.send(true).unwrap(); 329 | 330 | let tx_clone = tx.clone(); 331 | thread::spawn(move || { 332 | let rows = get_rows_from_command(&command, lines_to_skip); 333 | 334 | match rows { 335 | Ok(rows) => tx_clone.send(Event::RowsLoaded(rows)).unwrap(), 336 | Err(error) => tx_clone.send(Event::Error(error)).unwrap(), 337 | } 338 | }); 339 | } 340 | 341 | fn get_rows_from_command(command: &str, skip_lines: usize) -> Result, String> { 342 | let output = command::run_command(command)?; 343 | 344 | let trimmed_output = output 345 | .lines() 346 | .skip(skip_lines) 347 | .collect::>() 348 | .join("\n"); 349 | 350 | Ok(parse::parse(trimmed_output)) 351 | } 352 | 353 | fn on_rows_loaded(app: &mut App, loading_tx: &Sender, rows: Vec) { 354 | app.update_rows(rows); 355 | 356 | app.status_text = None; 357 | loading_tx.send(false).unwrap(); 358 | } 359 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | #[allow(dead_code)] 3 | use std::error::Error; 4 | 5 | mod app; 6 | mod args; 7 | mod command; 8 | mod config; 9 | mod event_loop; 10 | mod os_commands; 11 | mod parse; 12 | mod stateful_table; 13 | mod template; 14 | mod terminal_manager; 15 | mod ui; 16 | 17 | use app::App; 18 | use args::Args; 19 | use config::storage; 20 | use storage::CONFIG_DIR_ENV_VAR; 21 | 22 | fn main() -> Result<(), Box> { 23 | let args = Args::new(); 24 | let config_path = storage::config_path(env::var(CONFIG_DIR_ENV_VAR).ok())?; 25 | let config = storage::prepare_config(&config_path)?; 26 | 27 | let app = App::new(&config, config_path, args); 28 | 29 | event_loop::run(app)?; 30 | 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /src/os_commands.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "linux")] 2 | pub fn open_command(path: &str) -> String { 3 | format!("xdg-open \"{}\"", path) 4 | } 5 | 6 | #[cfg(target_os = "macos")] 7 | pub fn open_command(path: &str) -> String { 8 | format!("open \"{}\"", path) 9 | } 10 | 11 | #[cfg(target_os = "windows")] 12 | pub fn open_command(path: &str) -> String { 13 | format!("start \"\" \"{}\"", path) 14 | } 15 | -------------------------------------------------------------------------------- /src/parse/char_pos_iter.rs: -------------------------------------------------------------------------------- 1 | use std::str::Chars; 2 | 3 | // going my_string.char_indices actually returns an iterator where the index 4 | // is the byte offset rather than the actual index of the char. So we've got our 5 | // custom CharPosIter struct here to get the behaviour we want. 6 | pub struct CharPosIter<'a> { 7 | s: Chars<'a>, 8 | index: usize, 9 | } 10 | 11 | impl<'a> CharPosIter<'a> { 12 | pub fn new(s: &'a str) -> CharPosIter { 13 | CharPosIter { 14 | s: s.chars(), 15 | index: 0, 16 | } 17 | } 18 | } 19 | 20 | impl<'a> Iterator for CharPosIter<'a> { 21 | type Item = (usize, char); 22 | 23 | fn next(&mut self) -> Option<(usize, char)> { 24 | let val = self.s.next()?; 25 | 26 | let result = Some((self.index, val)); 27 | 28 | self.index += 1; 29 | result 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/parse/mod.rs: -------------------------------------------------------------------------------- 1 | mod char_pos_iter; 2 | 3 | use char_pos_iter::CharPosIter; 4 | use itertools::Itertools; 5 | use std::{ 6 | collections::HashSet, 7 | iter::{once, FromIterator}, 8 | }; 9 | 10 | #[derive(PartialEq, Debug)] 11 | pub struct Row { 12 | pub original_line: String, 13 | pub cells: Vec, 14 | } 15 | 16 | impl Row { 17 | pub fn new(original_line: String, cells: Vec) -> Row { 18 | Row { 19 | original_line, 20 | cells, 21 | } 22 | } 23 | 24 | pub fn cells_as_strs(&self) -> Vec<&str> { 25 | self.cells.iter().map(|c| c.as_ref()).collect() 26 | } 27 | } 28 | 29 | pub fn parse(text: String) -> Vec { 30 | let column_sizes = get_column_indices(&text) 31 | .into_iter() 32 | .tuple_windows() 33 | .map(|(i0, i1)| i1 - i0) 34 | .chain(once(usize::MAX)) 35 | .collect::>(); 36 | 37 | text 38 | .lines() 39 | .map(|line| { 40 | // I want to get the chars as an array, then slice that up. 41 | let cells = column_sizes 42 | .iter() 43 | .scan(line.chars(), |chars, column_size| { 44 | Some( 45 | chars 46 | .take(*column_size) 47 | .collect::() 48 | .trim_end() 49 | .to_owned(), 50 | ) 51 | }) 52 | .collect(); 53 | 54 | Row::new(line.to_owned(), cells) 55 | }) 56 | .collect() 57 | } 58 | 59 | fn get_column_indices(text: &String) -> Vec { 60 | let mut lines = text.trim_end().lines(); 61 | 62 | let first_line = lines.next().unwrap_or_default(); 63 | 64 | let spaces_iter = CharPosIter::new(first_line) 65 | // ignoring index 0 for the sake of something like git status --short with a single line i.e. ` M myfile.txt`. 66 | .filter(|&(index, char)| index != 0 && char == ' ') 67 | .map(|(index, _char)| index); 68 | 69 | let mut spaces_set: HashSet = HashSet::from_iter(spaces_iter); 70 | 71 | for line in lines { 72 | // TODO consider how to remove the .clone() here 73 | for s_index in spaces_set.clone() { 74 | for (index, char) in CharPosIter::new(line) { 75 | if index == s_index && char != ' ' { 76 | spaces_set.remove(&s_index); 77 | } 78 | } 79 | } 80 | } 81 | 82 | let mut spaces = spaces_set.into_iter().collect::>(); 83 | spaces.sort(); 84 | 85 | let mut result = spaces 86 | .iter() 87 | .enumerate() 88 | .filter(|(index, position)| { 89 | *index == spaces.len() - 1 || spaces[*index + 1] != (**position + 1) 90 | }) 91 | .map(|(_index, position)| *position + 1) 92 | .collect::>(); 93 | 94 | result.insert(0, 0); 95 | 96 | result 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use super::*; 102 | use pretty_assertions::assert_eq; 103 | 104 | #[test] 105 | fn test_one_line_cut_short() { 106 | let text = "col1 col2 col3\n\ 107 | col1 col2 col3\n\ 108 | col1\n"; 109 | 110 | assert_eq!( 111 | parse(String::from(text)), 112 | vec![ 113 | Row { 114 | original_line: String::from("col1 col2 col3"), 115 | cells: vec![ 116 | String::from("col1"), 117 | String::from("col2"), 118 | String::from("col3"), 119 | ], 120 | }, 121 | Row { 122 | original_line: String::from("col1 col2 col3"), 123 | cells: vec![ 124 | String::from("col1"), 125 | String::from("col2"), 126 | String::from("col3"), 127 | ], 128 | }, 129 | Row { 130 | original_line: String::from("col1"), 131 | cells: vec![String::from("col1"), String::from(""), String::from(""),], 132 | }, 133 | ], 134 | ) 135 | } 136 | 137 | #[test] 138 | fn test_parse_docker_ps() { 139 | let text = "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n\ 140 | 17c523089229 aa \"./ops/dev/api\" 2 weeks ago Up 43 seconds 0.0.0.0:20->80/tcp blah\n\ 141 | dcddf219bb2b bb \"./ops/dev/sid…\" 2 weeks ago Up 44 seconds blah-sidekiq_2\n\ 142 | 43484e7c2774 dd:latest \"ops/dev/proxy…\" 2 weeks ago Up 46 seconds 0.0.0.0:80->80/tcp, 9400/tcp blah-proxy_4\n\ 143 | 8a61b6cc2d3b aaaaa:4.0.3-alpine \"docker.s…\" 2 weeks ago Up 46 seconds 0.0.0.0:6300->6322/tcp blah.99_1\n"; 144 | 145 | assert_eq!(parse(String::from(text)), vec![ 146 | Row { 147 | original_line: String::from("CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES"), 148 | cells: vec![ 149 | String::from("CONTAINER ID"), 150 | String::from("IMAGE"), 151 | String::from("COMMAND"), 152 | String::from("CREATED"), 153 | String::from(""), 154 | String::from("STATUS"), 155 | String::from("PORTS"), 156 | String::from("NAMES"), 157 | ], 158 | }, 159 | Row { 160 | original_line: String::from("17c523089229 aa \"./ops/dev/api\" 2 weeks ago Up 43 seconds 0.0.0.0:20->80/tcp blah"), 161 | cells: vec![ 162 | String::from("17c523089229"), 163 | String::from("aa"), 164 | String::from("\"./ops/dev/api\""), 165 | String::from("2 weeks"), 166 | String::from("ago"), 167 | String::from("Up 43 seconds"), 168 | String::from("0.0.0.0:20->80/tcp"), 169 | String::from("blah"), 170 | ], 171 | }, 172 | Row { 173 | original_line: String::from("dcddf219bb2b bb \"./ops/dev/sid…\" 2 weeks ago Up 44 seconds blah-sidekiq_2"), 174 | cells: vec![ 175 | String::from("dcddf219bb2b"), 176 | String::from("bb"), 177 | String::from("\"./ops/dev/sid…\""), 178 | String::from("2 weeks"), 179 | String::from("ago"), 180 | String::from("Up 44 seconds"), 181 | String::from(""), 182 | String::from("blah-sidekiq_2"), 183 | ], 184 | }, 185 | Row { 186 | original_line: String::from("43484e7c2774 dd:latest \"ops/dev/proxy…\" 2 weeks ago Up 46 seconds 0.0.0.0:80->80/tcp, 9400/tcp blah-proxy_4"), 187 | cells: vec![ 188 | String::from("43484e7c2774"), 189 | String::from("dd:latest"), 190 | String::from("\"ops/dev/proxy…\""), 191 | String::from("2 weeks"), 192 | String::from("ago"), 193 | String::from("Up 46 seconds"), 194 | String::from("0.0.0.0:80->80/tcp, 9400/tcp"), 195 | String::from("blah-proxy_4"), 196 | ], 197 | }, 198 | 199 | Row { 200 | original_line: String::from("8a61b6cc2d3b aaaaa:4.0.3-alpine \"docker.s…\" 2 weeks ago Up 46 seconds 0.0.0.0:6300->6322/tcp blah.99_1"), 201 | cells: vec![ 202 | String::from("8a61b6cc2d3b"), 203 | String::from("aaaaa:4.0.3-alpine"), 204 | String::from("\"docker.s…\""), 205 | String::from("2 weeks"), 206 | String::from("ago"), 207 | String::from("Up 46 seconds"), 208 | String::from("0.0.0.0:6300->6322/tcp"), 209 | String::from("blah.99_1"), 210 | ], 211 | }, 212 | 213 | 214 | ]) 215 | } 216 | 217 | #[test] 218 | fn test_parse_git_status() { 219 | let text = " M src/main.rs\n\ 220 | ?? src/parse/\n"; 221 | 222 | assert_eq!( 223 | parse(String::from(text)), 224 | vec![ 225 | Row { 226 | original_line: String::from(" M src/main.rs"), 227 | cells: vec![String::from(" M"), String::from("src/main.rs"),], 228 | }, 229 | Row { 230 | original_line: String::from("?? src/parse/"), 231 | cells: vec![String::from("??"), String::from("src/parse/"),], 232 | }, 233 | ], 234 | ) 235 | } 236 | 237 | #[test] 238 | fn test_parse_git_status_one_line() { 239 | let text = " M src/main.rs\n"; 240 | 241 | assert_eq!( 242 | parse(String::from(text)), 243 | vec![Row { 244 | original_line: String::from(" M src/main.rs"), 245 | cells: vec![String::from(" M"), String::from("src/main.rs"),], 246 | },], 247 | ) 248 | } 249 | 250 | #[test] 251 | fn test_parse_ls() { 252 | let text = "-rw-r--r-- 1 user staff 159 28 Apr 2020 Dockerfile\n\ 253 | -rw-r--r-- 1 user staff 7910 21 Sep 15:19 Readme.md\n\ 254 | drwxr-xr-x 3 user staff 96 11 Apr 2020 docs"; 255 | 256 | assert_eq!( 257 | parse(String::from(text)), 258 | vec![ 259 | Row { 260 | original_line: String::from( 261 | "-rw-r--r-- 1 user staff 159 28 Apr 2020 Dockerfile" 262 | ), 263 | cells: vec![ 264 | String::from("-rw-r--r--"), 265 | String::from("1"), 266 | String::from("user"), 267 | String::from("staff"), 268 | String::from(" 159"), 269 | String::from("28"), 270 | String::from("Apr"), 271 | String::from(" 2020"), 272 | String::from("Dockerfile"), 273 | ], 274 | }, 275 | Row { 276 | original_line: String::from( 277 | "-rw-r--r-- 1 user staff 7910 21 Sep 15:19 Readme.md" 278 | ), 279 | cells: vec![ 280 | String::from("-rw-r--r--"), 281 | String::from("1"), 282 | String::from("user"), 283 | String::from("staff"), 284 | String::from("7910"), 285 | String::from("21"), 286 | String::from("Sep"), 287 | String::from("15:19"), 288 | String::from("Readme.md"), 289 | ], 290 | }, 291 | Row { 292 | original_line: String::from("drwxr-xr-x 3 user staff 96 11 Apr 2020 docs"), 293 | cells: vec![ 294 | String::from("drwxr-xr-x"), 295 | String::from("3"), 296 | String::from("user"), 297 | String::from("staff"), 298 | String::from(" 96"), 299 | String::from("11"), 300 | String::from("Apr"), 301 | String::from(" 2020"), 302 | String::from("docs"), 303 | ], 304 | }, 305 | ], 306 | ) 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/stateful_table.rs: -------------------------------------------------------------------------------- 1 | use tui::widgets::TableState; 2 | 3 | pub struct StatefulTable { 4 | pub state: TableState, 5 | pub row_count: usize, 6 | } 7 | 8 | impl StatefulTable { 9 | pub fn new(row_count: usize) -> StatefulTable { 10 | StatefulTable { 11 | state: TableState::default(), 12 | row_count, 13 | } 14 | } 15 | 16 | pub fn next(&mut self) { 17 | let i = match self.state.selected() { 18 | Some(i) => { 19 | if self.row_count == 0 || i >= self.row_count - 1 { 20 | i 21 | } else { 22 | i + 1 23 | } 24 | } 25 | None => 0, 26 | }; 27 | self.state.select(Some(i)); 28 | } 29 | 30 | pub fn previous(&mut self) { 31 | let i = match self.state.selected() { 32 | Some(i) => { 33 | if i == 0 { 34 | i 35 | } else { 36 | i - 1 37 | } 38 | } 39 | None => 0, 40 | }; 41 | self.state.select(Some(i)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/template.rs: -------------------------------------------------------------------------------- 1 | use regex::{Captures, Regex}; 2 | 3 | use crate::{config::Command, parse::Row}; 4 | 5 | pub fn resolve_command(command: &dyn Command, row: &Row) -> String { 6 | // if keybinding has a regex we need to use that, otherwise we generate the regex ourselves 7 | let matches = match &command.regex() { 8 | Some(regex) => { 9 | let regex = Regex::new(regex).unwrap(); // TODO: handle malformed regex 10 | match regex.captures(&row.original_line) { 11 | None => vec![], 12 | Some(captures) => captures 13 | .iter() 14 | .map(|capture| match capture { 15 | Some(capture) => capture.as_str(), 16 | None => "", 17 | }) 18 | .collect::>(), 19 | } 20 | } 21 | None => row.cells_as_strs(), 22 | }; 23 | 24 | template_replace(&command.command(), &matches) 25 | } 26 | 27 | // adapted from https://stackoverflow.com/questions/53974404/replacing-numbered-placeholders-with-elements-of-a-vector-in-rust 28 | pub fn template_replace(template: &str, values: &[&str]) -> String { 29 | let regex = Regex::new(r#"\$(\d+)"#).unwrap(); 30 | regex 31 | .replace_all(template, |captures: &Captures| { 32 | values.get(index(captures)).unwrap_or(&"") 33 | }) 34 | .to_string() 35 | } 36 | 37 | fn index(captures: &Captures) -> usize { 38 | captures.get(1).unwrap().as_str().parse().unwrap() 39 | } 40 | -------------------------------------------------------------------------------- /src/terminal_manager.rs: -------------------------------------------------------------------------------- 1 | use crossterm::{ 2 | event::{DisableMouseCapture, EnableMouseCapture}, 3 | execute, 4 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 5 | }; 6 | use std::{error::Error, io::stdout}; 7 | use tui::{backend::CrosstermBackend, Terminal}; 8 | 9 | pub struct TerminalManager { 10 | pub terminal: tui::Terminal>, 11 | } 12 | 13 | // TODO: see if this is the right approach. Perhaps our perhaps we should have a prepare() function pulled out of the new() function 14 | impl TerminalManager { 15 | pub fn new() -> Result> { 16 | enable_raw_mode()?; 17 | let mut stdout = stdout(); 18 | execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 19 | let backend = CrosstermBackend::new(stdout); 20 | let mut terminal = Terminal::new(backend)?; 21 | terminal.clear()?; 22 | 23 | Ok(TerminalManager { terminal }) 24 | } 25 | 26 | pub fn teardown(&mut self) -> Result<(), Box> { 27 | disable_raw_mode()?; 28 | execute!( 29 | self.terminal.backend_mut(), 30 | LeaveAlternateScreen, 31 | DisableMouseCapture 32 | )?; 33 | // TODO: understand why this works 34 | Ok(self.terminal.show_cursor()?) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/keybindings.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{App, FocusedPanel}, 3 | config::Profile, 4 | template, 5 | }; 6 | 7 | // TODO: derive keybinding menu from our actual key handlers in event_loop.rs 8 | pub fn display_keybindings(profile: Option<&Profile>, app: &App) -> String { 9 | let panel_keybindings = match app.focused_panel { 10 | FocusedPanel::Table => { 11 | let mut keybindings = vec![format!( 12 | "▲/▼/j/k: navigate, /: filter, esc: clear filter, q: quit, $: open config file (open {})", 13 | app.config_path.to_str().unwrap() 14 | )]; 15 | 16 | keybindings.extend(profile_keybindings(profile, app)); 17 | keybindings 18 | } 19 | FocusedPanel::Search => vec![String::from("enter: apply filter, esc: cancel filter")], 20 | FocusedPanel::ErrorPopup(_) => vec![String::from("esc: close popup, q: quit")], 21 | FocusedPanel::ConfirmationPopup(_) => { 22 | vec![String::from("enter: run command, esc: cancel, q: quit")] 23 | } 24 | }; 25 | 26 | panel_keybindings 27 | .into_iter() 28 | .collect::>() 29 | .join("\n") 30 | } 31 | 32 | fn profile_keybindings(profile: Option<&Profile>, app: &App) -> Vec { 33 | match profile { 34 | Some(profile) => match profile.key_bindings.len() { 35 | 0 => vec![format!("No keybindings set for profile '{}'", profile.name)], 36 | _ => match app.get_selected_row() { 37 | Some(row) => { 38 | let mut result = vec![format!("Keybindings for profile '{}':", profile.name)]; 39 | 40 | result.extend( 41 | profile 42 | .key_bindings 43 | .iter() 44 | .map(|kb| format!("{}: {}", kb.key, template::resolve_command(kb, &row))) 45 | .collect::>(), 46 | ); 47 | 48 | result 49 | } 50 | None => { 51 | vec![String::from("No item selected")] 52 | } 53 | }, 54 | }, 55 | None => vec![String::from("No profile selected")], 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | mod keybindings; 2 | 3 | use crate::{ 4 | app::{App, FocusedPanel}, 5 | parse, 6 | }; 7 | use std::{cmp, time::SystemTime}; 8 | use tui::{ 9 | backend::Backend, 10 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 11 | style::{Color, Modifier, Style}, 12 | widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap}, 13 | Frame, 14 | }; 15 | 16 | pub fn draw(frame: &mut Frame, app: &mut App) { 17 | let formatted_bindings = keybindings::display_keybindings(app.profile, &app); 18 | let formatted_keybindings_height = formatted_bindings.lines().count() as u16; 19 | 20 | let rects = Layout::default() 21 | .constraints( 22 | [ 23 | Constraint::Length(frame.size().height - 1), 24 | Constraint::Length(1), 25 | ] 26 | .as_ref(), 27 | ) 28 | .split(frame.size()); 29 | 30 | if app.focused_panel == FocusedPanel::Search { 31 | draw_search_bar(app, rects[1], frame); 32 | } else { 33 | draw_status_bar(app, rects[1], frame); 34 | } 35 | 36 | draw_error_popup(app, frame); 37 | draw_confirmation_popup(app, frame); 38 | 39 | let right_panel_percentage_width = 40 | if app.profile.is_some() && app.profile.unwrap().display_command.is_some() { 41 | 50 42 | } else { 43 | 0 44 | }; 45 | 46 | { 47 | let rects = Layout::default() 48 | .direction(Direction::Horizontal) 49 | .constraints( 50 | [ 51 | Constraint::Percentage(100 - right_panel_percentage_width), 52 | Constraint::Percentage(right_panel_percentage_width), 53 | ] 54 | .as_ref(), 55 | ) 56 | .split(rects[0]); 57 | 58 | draw_item_render(app, rects[1], frame); 59 | 60 | { 61 | let rects = Layout::default() 62 | .constraints([ 63 | Constraint::Length(rects[0].height - formatted_keybindings_height - 1), 64 | Constraint::Length(1), 65 | Constraint::Length(formatted_keybindings_height), 66 | ]) 67 | .split(rects[0]); 68 | 69 | draw_table(app, rects[0], frame); 70 | draw_keybindings(rects[2], frame, formatted_bindings); 71 | } 72 | } 73 | } 74 | 75 | fn draw_error_popup(app: &mut App, frame: &mut tui::Frame) { 76 | if let FocusedPanel::ErrorPopup(error) = &app.focused_panel { 77 | let popup = centered_rect(60, 60, frame.size()); 78 | let paragraph = Paragraph::new(error.to_owned()) 79 | .style( 80 | Style::default() 81 | .fg(Color::LightRed) 82 | .add_modifier(Modifier::BOLD), 83 | ) 84 | .block( 85 | Block::default() 86 | .title("Error") 87 | .borders(Borders::ALL) 88 | .style(Style::default().fg(Color::Reset)), 89 | ) 90 | .alignment(Alignment::Left) 91 | .wrap(Wrap { trim: true }); 92 | frame.render_widget(paragraph, popup); 93 | } 94 | } 95 | 96 | fn draw_confirmation_popup(app: &mut App, frame: &mut tui::Frame) { 97 | if let FocusedPanel::ConfirmationPopup(command) = &app.focused_panel { 98 | let popup = centered_rect(60, 20, frame.size()); 99 | let paragraph = Paragraph::new(format!( 100 | "Are you sure you want to run command: `{}`?", 101 | command 102 | )) 103 | .style( 104 | Style::default() 105 | .fg(Color::Reset) 106 | .add_modifier(Modifier::BOLD), 107 | ) 108 | .block( 109 | Block::default() 110 | .title("Confirm") 111 | .borders(Borders::ALL) 112 | .style(Style::default().fg(Color::Reset)), 113 | ) 114 | .alignment(Alignment::Left) 115 | .wrap(Wrap { trim: true }); 116 | frame.render_widget(paragraph, popup); 117 | } 118 | } 119 | 120 | fn draw_table(app: &mut App, rect: Rect, frame: &mut tui::Frame) { 121 | let selected_style = if app.focused_panel == FocusedPanel::Table { 122 | Style::default() 123 | .bg(Color::Blue) 124 | .add_modifier(Modifier::BOLD) 125 | } else { 126 | Style::default() 127 | }; 128 | 129 | let filtered_rows = app.filtered_rows(); 130 | let rows = filtered_rows.iter().map(|row| { 131 | let cells = row.cells.iter().map(|c| Cell::from(c.clone())); 132 | Row::new(cells).height(1) 133 | }); 134 | 135 | let widths = get_column_widths(&filtered_rows); 136 | 137 | let table = Table::new(rows) 138 | .highlight_style(selected_style) 139 | .highlight_symbol("> ") 140 | .widths(&widths) 141 | .column_spacing(2); 142 | 143 | frame.render_stateful_widget(table, rect, &mut app.table.state); 144 | } 145 | 146 | fn draw_keybindings(rect: Rect, frame: &mut tui::Frame, formatted_bindings: String) { 147 | let keybindings_list = 148 | Paragraph::new(formatted_bindings).style(Style::default().fg(Color::Yellow)); 149 | 150 | frame.render_widget(keybindings_list, rect); 151 | } 152 | 153 | fn draw_status_bar(app: &mut App, rect: Rect, frame: &mut tui::Frame) { 154 | let status_text = match app.status_text.as_ref() { 155 | Some(text) => match text { 156 | _ => format!("{} {}", spinner_frame(), text), 157 | }, 158 | None => String::from(""), 159 | }; 160 | 161 | let status_bar = Paragraph::new(status_text).style(Style::default().fg(Color::Cyan)); 162 | 163 | frame.render_widget(status_bar, rect); 164 | } 165 | 166 | fn draw_search_bar(app: &mut App, rect: Rect, frame: &mut tui::Frame) { 167 | let prefix = "Search: "; 168 | 169 | // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering 170 | frame.set_cursor( 171 | // Put cursor past the end of the input text 172 | rect.x + app.filter_text.len() as u16 + prefix.len() as u16, 173 | // Move one line down, from the border to the input line 174 | rect.y, 175 | ); 176 | 177 | let search_text = String::from(prefix) + &app.filter_text; 178 | let search_bar = Paragraph::new(search_text).style(Style::default().fg(Color::Green)); 179 | 180 | frame.render_widget(search_bar, rect); 181 | } 182 | 183 | fn draw_item_render(app: &mut App, rect: Rect, frame: &mut tui::Frame) { 184 | let paragraph = 185 | Paragraph::new(app.selected_item_content.as_ref()).style(Style::default().fg(Color::Reset)); 186 | 187 | frame.render_widget(paragraph, rect); 188 | } 189 | 190 | fn get_column_widths(rows: &Vec<&parse::Row>) -> std::vec::Vec { 191 | if rows.len() == 0 { 192 | return vec![]; 193 | } 194 | 195 | rows 196 | .iter() 197 | .map(|row| row.cells.iter().map(|cell| cell.len()).collect()) 198 | .fold( 199 | std::iter::repeat(0) 200 | .take(rows[0].cells.len()) 201 | .collect::>(), 202 | |acc: Vec, curr: Vec| { 203 | acc 204 | .into_iter() 205 | .zip(curr.into_iter()) 206 | .map(|(a, b)| cmp::max(a, b)) 207 | .collect() 208 | }, 209 | ) 210 | .into_iter() 211 | .map(|width| Constraint::Length(width as u16)) 212 | .collect::>() 213 | } 214 | 215 | static SPINNER_STATES: &[char] = &['⣾', '⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽']; 216 | 217 | fn spinner_frame() -> String { 218 | let now = SystemTime::now() 219 | .duration_since(SystemTime::UNIX_EPOCH) 220 | .unwrap() 221 | .as_millis() 222 | / 100; 223 | 224 | let index = (now as usize) % (SPINNER_STATES.len() - 1); 225 | SPINNER_STATES[index].to_string() 226 | } 227 | 228 | // from https://github.com/fdehau/tui-rs/pull/251/files 229 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 230 | let popup_layout = Layout::default() 231 | .direction(Direction::Vertical) 232 | .constraints( 233 | [ 234 | Constraint::Percentage((100 - percent_y) / 2), 235 | Constraint::Percentage(percent_y), 236 | Constraint::Percentage((100 - percent_y) / 2), 237 | ] 238 | .as_ref(), 239 | ) 240 | .split(r); 241 | 242 | Layout::default() 243 | .direction(Direction::Horizontal) 244 | .constraints( 245 | [ 246 | Constraint::Percentage((100 - percent_x) / 2), 247 | Constraint::Percentage(percent_x), 248 | Constraint::Percentage((100 - percent_x) / 2), 249 | ] 250 | .as_ref(), 251 | ) 252 | .split(popup_layout[1])[1] 253 | } 254 | --------------------------------------------------------------------------------