├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── HomebrewFormula └── igrep.rb ├── LICENSE ├── README.md ├── assets ├── demo.gif └── v1_0_0.gif ├── build.rs └── src ├── app.rs ├── args.rs ├── editor.rs ├── ig.rs ├── ig ├── file_entry.rs ├── grep_match.rs ├── search_config.rs ├── searcher.rs └── sink.rs ├── lib.rs ├── main.rs ├── ui.rs └── ui ├── bottom_bar.rs ├── context_viewer.rs ├── input_handler.rs ├── keymap_popup.rs ├── result_list.rs ├── scroll_offset_list.rs ├── search_popup.rs ├── theme.rs └── theme ├── dark.rs └── light.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: CI 4 | 5 | jobs: 6 | check: 7 | name: Check 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout sources 11 | uses: actions/checkout@v2 12 | 13 | - name: Install stable toolchain 14 | uses: actions-rs/toolchain@v1 15 | with: 16 | profile: minimal 17 | toolchain: stable 18 | override: true 19 | 20 | - name: Run cargo check 21 | uses: actions-rs/cargo@v1 22 | with: 23 | command: check 24 | 25 | test: 26 | name: Test Suite 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout sources 30 | uses: actions/checkout@v2 31 | 32 | - name: Install stable toolchain 33 | uses: actions-rs/toolchain@v1 34 | with: 35 | profile: minimal 36 | toolchain: stable 37 | override: true 38 | 39 | - name: Run cargo test 40 | uses: actions-rs/cargo@v1 41 | with: 42 | command: test 43 | 44 | lints: 45 | name: Lints 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout sources 49 | uses: actions/checkout@v2 50 | 51 | - name: Install stable toolchain 52 | uses: actions-rs/toolchain@v1 53 | with: 54 | profile: minimal 55 | toolchain: stable 56 | override: true 57 | components: rustfmt, clippy 58 | 59 | - name: Run cargo fmt 60 | uses: actions-rs/cargo@v1 61 | with: 62 | command: fmt 63 | args: --all -- --check 64 | 65 | - name: Run cargo clippy 66 | uses: actions-rs/cargo@v1 67 | with: 68 | command: clippy 69 | args: --all-targets -- -D warnings 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.3.0 (2024-09-08) 2 | *** 3 | - locate editor executable using `which` crate 4 | - add `-w`/`--word-regexp` arg 5 | - add option to open Context Viewer at startup 6 | - add follow symlinks option 7 | - ability to specify custom command 8 | - read custom editor from environment 9 | - add `less` as an editor option 10 | - add a keybindings popup 11 | - add keybindings for changing context viewer size 12 | - fix flushing infinitely when opening nvim with no results found 13 | 14 | ## v1.2.0 (2023-08-08) 15 | *** 16 | - support multiple search paths 17 | - Ctrl+c closes an application 18 | - allow to change search pattern without closing an app 19 | 20 | ## v1.1.0 (2023-01-29) 21 | *** 22 | - add error handling in case of editor process spawning failure 23 | - improve performance by handling multiple file entries every redraw 24 | - add support for Sublime Text, Micro, Intellij, Goland, Pycharm 25 | - use `helix` as a binary name when `helix` is set as an editor of choice 26 | - prefer $VISUAL variable over $EDITOR when determining text editor to use 27 | 28 | ## v1.0.0 (2023-01-08) 29 | *** 30 | - add context viewer 31 | - add support for opening files in Helix 32 | 33 | ## v0.5.1 (2022-08-01) 34 | *** 35 | - add support for opening files in VS Code Insiders 36 | 37 | ## v0.5.0 (2022-04-24) 38 | *** 39 | - add theme for light environments 40 | - support for ripgrep's configuration file 41 | - add Scoop package 42 | 43 | ## v0.4.0 (2022-03-16) 44 | *** 45 | - improve clarity of using multi character input 46 | - add support for opening files in VS Code 47 | - add support for opening files in emacs and emacsclient 48 | 49 | ## v0.3.0 (2022-03-08) 50 | *** 51 | - use $EDITOR as a fallback variable 52 | - fix Initial console modes not set error on Windows 53 | - make igrep available on Homebrew 54 | 55 | ## v0.2.0 (2022-03-02) 56 | *** 57 | - allow to specify editor using `IGREP_EDITOR` environment variable 58 | - add `nvim` as an alias for `neovim` 59 | - support for searching hidden files/directories via `-.`/`--hidden` options 60 | 61 | ## v0.1.2 (2022-02-19) 62 | *** 63 | Initial release. Provides basic set of functionalities. 64 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "adler" 7 | version = "1.0.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 10 | 11 | [[package]] 12 | name = "ahash" 13 | version = "0.8.11" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" 16 | dependencies = [ 17 | "cfg-if", 18 | "once_cell", 19 | "version_check", 20 | "zerocopy", 21 | ] 22 | 23 | [[package]] 24 | name = "aho-corasick" 25 | version = "1.1.3" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 28 | dependencies = [ 29 | "memchr", 30 | ] 31 | 32 | [[package]] 33 | name = "allocator-api2" 34 | version = "0.2.18" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" 37 | 38 | [[package]] 39 | name = "anstream" 40 | version = "0.6.14" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" 43 | dependencies = [ 44 | "anstyle", 45 | "anstyle-parse", 46 | "anstyle-query", 47 | "anstyle-wincon", 48 | "colorchoice", 49 | "is_terminal_polyfill", 50 | "utf8parse", 51 | ] 52 | 53 | [[package]] 54 | name = "anstyle" 55 | version = "1.0.7" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" 58 | 59 | [[package]] 60 | name = "anstyle-parse" 61 | version = "0.2.4" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" 64 | dependencies = [ 65 | "utf8parse", 66 | ] 67 | 68 | [[package]] 69 | name = "anstyle-query" 70 | version = "1.0.3" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" 73 | dependencies = [ 74 | "windows-sys 0.52.0", 75 | ] 76 | 77 | [[package]] 78 | name = "anstyle-wincon" 79 | version = "3.0.3" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" 82 | dependencies = [ 83 | "anstyle", 84 | "windows-sys 0.52.0", 85 | ] 86 | 87 | [[package]] 88 | name = "anyhow" 89 | version = "1.0.83" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "25bdb32cbbdce2b519a9cd7df3a678443100e265d5e25ca763b7572a5104f5f3" 92 | 93 | [[package]] 94 | name = "autocfg" 95 | version = "1.3.0" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" 98 | 99 | [[package]] 100 | name = "base64" 101 | version = "0.21.7" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" 104 | 105 | [[package]] 106 | name = "bincode" 107 | version = "1.3.3" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" 110 | dependencies = [ 111 | "serde", 112 | ] 113 | 114 | [[package]] 115 | name = "bitflags" 116 | version = "1.3.2" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 119 | 120 | [[package]] 121 | name = "bitflags" 122 | version = "2.5.0" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" 125 | 126 | [[package]] 127 | name = "bstr" 128 | version = "1.9.1" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" 131 | dependencies = [ 132 | "memchr", 133 | "regex-automata", 134 | "serde", 135 | ] 136 | 137 | [[package]] 138 | name = "cassowary" 139 | version = "0.3.0" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 142 | 143 | [[package]] 144 | name = "castaway" 145 | version = "0.2.2" 146 | source = "registry+https://github.com/rust-lang/crates.io-index" 147 | checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" 148 | dependencies = [ 149 | "rustversion", 150 | ] 151 | 152 | [[package]] 153 | name = "cc" 154 | version = "1.0.97" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4" 157 | 158 | [[package]] 159 | name = "cfg-if" 160 | version = "1.0.0" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 163 | 164 | [[package]] 165 | name = "clap" 166 | version = "4.5.4" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" 169 | dependencies = [ 170 | "clap_builder", 171 | "clap_derive", 172 | ] 173 | 174 | [[package]] 175 | name = "clap_builder" 176 | version = "4.5.2" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" 179 | dependencies = [ 180 | "anstream", 181 | "anstyle", 182 | "clap_lex", 183 | "strsim", 184 | ] 185 | 186 | [[package]] 187 | name = "clap_derive" 188 | version = "4.5.4" 189 | source = "registry+https://github.com/rust-lang/crates.io-index" 190 | checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" 191 | dependencies = [ 192 | "heck 0.5.0", 193 | "proc-macro2", 194 | "quote", 195 | "syn", 196 | ] 197 | 198 | [[package]] 199 | name = "clap_lex" 200 | version = "0.7.0" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" 203 | 204 | [[package]] 205 | name = "colorchoice" 206 | version = "1.0.1" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" 209 | 210 | [[package]] 211 | name = "compact_str" 212 | version = "0.7.1" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" 215 | dependencies = [ 216 | "castaway", 217 | "cfg-if", 218 | "itoa", 219 | "ryu", 220 | "static_assertions", 221 | ] 222 | 223 | [[package]] 224 | name = "crc32fast" 225 | version = "1.4.0" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" 228 | dependencies = [ 229 | "cfg-if", 230 | ] 231 | 232 | [[package]] 233 | name = "crossbeam-deque" 234 | version = "0.8.5" 235 | source = "registry+https://github.com/rust-lang/crates.io-index" 236 | checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" 237 | dependencies = [ 238 | "crossbeam-epoch", 239 | "crossbeam-utils", 240 | ] 241 | 242 | [[package]] 243 | name = "crossbeam-epoch" 244 | version = "0.9.18" 245 | source = "registry+https://github.com/rust-lang/crates.io-index" 246 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 247 | dependencies = [ 248 | "crossbeam-utils", 249 | ] 250 | 251 | [[package]] 252 | name = "crossbeam-utils" 253 | version = "0.8.19" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" 256 | 257 | [[package]] 258 | name = "crossterm" 259 | version = "0.27.0" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" 262 | dependencies = [ 263 | "bitflags 2.5.0", 264 | "crossterm_winapi", 265 | "libc", 266 | "mio", 267 | "parking_lot", 268 | "signal-hook", 269 | "signal-hook-mio", 270 | "winapi", 271 | ] 272 | 273 | [[package]] 274 | name = "crossterm_winapi" 275 | version = "0.9.1" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 278 | dependencies = [ 279 | "winapi", 280 | ] 281 | 282 | [[package]] 283 | name = "deranged" 284 | version = "0.3.11" 285 | source = "registry+https://github.com/rust-lang/crates.io-index" 286 | checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" 287 | dependencies = [ 288 | "powerfmt", 289 | ] 290 | 291 | [[package]] 292 | name = "downcast" 293 | version = "0.11.0" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" 296 | 297 | [[package]] 298 | name = "either" 299 | version = "1.11.0" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2" 302 | 303 | [[package]] 304 | name = "encoding_rs" 305 | version = "0.8.34" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" 308 | dependencies = [ 309 | "cfg-if", 310 | ] 311 | 312 | [[package]] 313 | name = "encoding_rs_io" 314 | version = "0.1.7" 315 | source = "registry+https://github.com/rust-lang/crates.io-index" 316 | checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" 317 | dependencies = [ 318 | "encoding_rs", 319 | ] 320 | 321 | [[package]] 322 | name = "equivalent" 323 | version = "1.0.1" 324 | source = "registry+https://github.com/rust-lang/crates.io-index" 325 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 326 | 327 | [[package]] 328 | name = "errno" 329 | version = "0.3.9" 330 | source = "registry+https://github.com/rust-lang/crates.io-index" 331 | checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" 332 | dependencies = [ 333 | "libc", 334 | "windows-sys 0.52.0", 335 | ] 336 | 337 | [[package]] 338 | name = "flate2" 339 | version = "1.0.30" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" 342 | dependencies = [ 343 | "crc32fast", 344 | "miniz_oxide", 345 | ] 346 | 347 | [[package]] 348 | name = "fnv" 349 | version = "1.0.7" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 352 | 353 | [[package]] 354 | name = "fragile" 355 | version = "2.0.0" 356 | source = "registry+https://github.com/rust-lang/crates.io-index" 357 | checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" 358 | 359 | [[package]] 360 | name = "globset" 361 | version = "0.4.14" 362 | source = "registry+https://github.com/rust-lang/crates.io-index" 363 | checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" 364 | dependencies = [ 365 | "aho-corasick", 366 | "bstr", 367 | "log", 368 | "regex-automata", 369 | "regex-syntax", 370 | ] 371 | 372 | [[package]] 373 | name = "grep" 374 | version = "0.3.1" 375 | source = "registry+https://github.com/rust-lang/crates.io-index" 376 | checksum = "6e2b024ec1e686cb64d78beb852030b0e632af93817f1ed25be0173af0e94939" 377 | dependencies = [ 378 | "grep-cli", 379 | "grep-matcher", 380 | "grep-printer", 381 | "grep-regex", 382 | "grep-searcher", 383 | ] 384 | 385 | [[package]] 386 | name = "grep-cli" 387 | version = "0.1.10" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "ea40788c059ab8b622c4d074732750bfb3bd2912e2dd58eabc11798a4d5ad725" 390 | dependencies = [ 391 | "bstr", 392 | "globset", 393 | "libc", 394 | "log", 395 | "termcolor", 396 | "winapi-util", 397 | ] 398 | 399 | [[package]] 400 | name = "grep-matcher" 401 | version = "0.1.7" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "47a3141a10a43acfedc7c98a60a834d7ba00dfe7bec9071cbfc19b55b292ac02" 404 | dependencies = [ 405 | "memchr", 406 | ] 407 | 408 | [[package]] 409 | name = "grep-printer" 410 | version = "0.2.1" 411 | source = "registry+https://github.com/rust-lang/crates.io-index" 412 | checksum = "743c12a03c8aee38b6e5bd0168d8ebb09345751323df4a01c56e792b1f38ceb2" 413 | dependencies = [ 414 | "bstr", 415 | "grep-matcher", 416 | "grep-searcher", 417 | "log", 418 | "serde", 419 | "serde_json", 420 | "termcolor", 421 | ] 422 | 423 | [[package]] 424 | name = "grep-regex" 425 | version = "0.1.12" 426 | source = "registry+https://github.com/rust-lang/crates.io-index" 427 | checksum = "f748bb135ca835da5cbc67ca0e6955f968db9c5df74ca4f56b18e1ddbc68230d" 428 | dependencies = [ 429 | "bstr", 430 | "grep-matcher", 431 | "log", 432 | "regex-automata", 433 | "regex-syntax", 434 | ] 435 | 436 | [[package]] 437 | name = "grep-searcher" 438 | version = "0.1.13" 439 | source = "registry+https://github.com/rust-lang/crates.io-index" 440 | checksum = "ba536ae4f69bec62d8839584dd3153d3028ef31bb229f04e09fb5a9e5a193c54" 441 | dependencies = [ 442 | "bstr", 443 | "encoding_rs", 444 | "encoding_rs_io", 445 | "grep-matcher", 446 | "log", 447 | "memchr", 448 | "memmap2", 449 | ] 450 | 451 | [[package]] 452 | name = "hashbrown" 453 | version = "0.14.5" 454 | source = "registry+https://github.com/rust-lang/crates.io-index" 455 | checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 456 | dependencies = [ 457 | "ahash", 458 | "allocator-api2", 459 | ] 460 | 461 | [[package]] 462 | name = "heck" 463 | version = "0.4.1" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 466 | 467 | [[package]] 468 | name = "heck" 469 | version = "0.5.0" 470 | source = "registry+https://github.com/rust-lang/crates.io-index" 471 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 472 | 473 | [[package]] 474 | name = "home" 475 | version = "0.5.9" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" 478 | dependencies = [ 479 | "windows-sys 0.52.0", 480 | ] 481 | 482 | [[package]] 483 | name = "ignore" 484 | version = "0.4.22" 485 | source = "registry+https://github.com/rust-lang/crates.io-index" 486 | checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" 487 | dependencies = [ 488 | "crossbeam-deque", 489 | "globset", 490 | "log", 491 | "memchr", 492 | "regex-automata", 493 | "same-file", 494 | "walkdir", 495 | "winapi-util", 496 | ] 497 | 498 | [[package]] 499 | name = "igrep" 500 | version = "1.3.0" 501 | dependencies = [ 502 | "anyhow", 503 | "clap", 504 | "crossterm", 505 | "grep", 506 | "ignore", 507 | "itertools 0.13.0", 508 | "lazy_static", 509 | "mockall", 510 | "ratatui", 511 | "strum", 512 | "syntect", 513 | "test-case", 514 | "unicode-width", 515 | "which", 516 | ] 517 | 518 | [[package]] 519 | name = "indexmap" 520 | version = "2.2.6" 521 | source = "registry+https://github.com/rust-lang/crates.io-index" 522 | checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" 523 | dependencies = [ 524 | "equivalent", 525 | "hashbrown", 526 | ] 527 | 528 | [[package]] 529 | name = "indoc" 530 | version = "2.0.5" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" 533 | 534 | [[package]] 535 | name = "is_terminal_polyfill" 536 | version = "1.70.0" 537 | source = "registry+https://github.com/rust-lang/crates.io-index" 538 | checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" 539 | 540 | [[package]] 541 | name = "itertools" 542 | version = "0.12.1" 543 | source = "registry+https://github.com/rust-lang/crates.io-index" 544 | checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" 545 | dependencies = [ 546 | "either", 547 | ] 548 | 549 | [[package]] 550 | name = "itertools" 551 | version = "0.13.0" 552 | source = "registry+https://github.com/rust-lang/crates.io-index" 553 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 554 | dependencies = [ 555 | "either", 556 | ] 557 | 558 | [[package]] 559 | name = "itoa" 560 | version = "1.0.11" 561 | source = "registry+https://github.com/rust-lang/crates.io-index" 562 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 563 | 564 | [[package]] 565 | name = "lazy_static" 566 | version = "1.4.0" 567 | source = "registry+https://github.com/rust-lang/crates.io-index" 568 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 569 | 570 | [[package]] 571 | name = "libc" 572 | version = "0.2.154" 573 | source = "registry+https://github.com/rust-lang/crates.io-index" 574 | checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" 575 | 576 | [[package]] 577 | name = "line-wrap" 578 | version = "0.2.0" 579 | source = "registry+https://github.com/rust-lang/crates.io-index" 580 | checksum = "dd1bc4d24ad230d21fb898d1116b1801d7adfc449d42026475862ab48b11e70e" 581 | 582 | [[package]] 583 | name = "linked-hash-map" 584 | version = "0.5.6" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 587 | 588 | [[package]] 589 | name = "linux-raw-sys" 590 | version = "0.4.14" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" 593 | 594 | [[package]] 595 | name = "lock_api" 596 | version = "0.4.12" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 599 | dependencies = [ 600 | "autocfg", 601 | "scopeguard", 602 | ] 603 | 604 | [[package]] 605 | name = "log" 606 | version = "0.4.21" 607 | source = "registry+https://github.com/rust-lang/crates.io-index" 608 | checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" 609 | 610 | [[package]] 611 | name = "lru" 612 | version = "0.12.3" 613 | source = "registry+https://github.com/rust-lang/crates.io-index" 614 | checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" 615 | dependencies = [ 616 | "hashbrown", 617 | ] 618 | 619 | [[package]] 620 | name = "memchr" 621 | version = "2.7.2" 622 | source = "registry+https://github.com/rust-lang/crates.io-index" 623 | checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" 624 | 625 | [[package]] 626 | name = "memmap2" 627 | version = "0.9.4" 628 | source = "registry+https://github.com/rust-lang/crates.io-index" 629 | checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322" 630 | dependencies = [ 631 | "libc", 632 | ] 633 | 634 | [[package]] 635 | name = "miniz_oxide" 636 | version = "0.7.2" 637 | source = "registry+https://github.com/rust-lang/crates.io-index" 638 | checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" 639 | dependencies = [ 640 | "adler", 641 | ] 642 | 643 | [[package]] 644 | name = "mio" 645 | version = "0.8.11" 646 | source = "registry+https://github.com/rust-lang/crates.io-index" 647 | checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" 648 | dependencies = [ 649 | "libc", 650 | "log", 651 | "wasi", 652 | "windows-sys 0.48.0", 653 | ] 654 | 655 | [[package]] 656 | name = "mockall" 657 | version = "0.12.1" 658 | source = "registry+https://github.com/rust-lang/crates.io-index" 659 | checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" 660 | dependencies = [ 661 | "cfg-if", 662 | "downcast", 663 | "fragile", 664 | "lazy_static", 665 | "mockall_derive", 666 | "predicates", 667 | "predicates-tree", 668 | ] 669 | 670 | [[package]] 671 | name = "mockall_derive" 672 | version = "0.12.1" 673 | source = "registry+https://github.com/rust-lang/crates.io-index" 674 | checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" 675 | dependencies = [ 676 | "cfg-if", 677 | "proc-macro2", 678 | "quote", 679 | "syn", 680 | ] 681 | 682 | [[package]] 683 | name = "num-conv" 684 | version = "0.1.0" 685 | source = "registry+https://github.com/rust-lang/crates.io-index" 686 | checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 687 | 688 | [[package]] 689 | name = "once_cell" 690 | version = "1.19.0" 691 | source = "registry+https://github.com/rust-lang/crates.io-index" 692 | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 693 | 694 | [[package]] 695 | name = "onig" 696 | version = "6.4.0" 697 | source = "registry+https://github.com/rust-lang/crates.io-index" 698 | checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f" 699 | dependencies = [ 700 | "bitflags 1.3.2", 701 | "libc", 702 | "once_cell", 703 | "onig_sys", 704 | ] 705 | 706 | [[package]] 707 | name = "onig_sys" 708 | version = "69.8.1" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7" 711 | dependencies = [ 712 | "cc", 713 | "pkg-config", 714 | ] 715 | 716 | [[package]] 717 | name = "parking_lot" 718 | version = "0.12.2" 719 | source = "registry+https://github.com/rust-lang/crates.io-index" 720 | checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" 721 | dependencies = [ 722 | "lock_api", 723 | "parking_lot_core", 724 | ] 725 | 726 | [[package]] 727 | name = "parking_lot_core" 728 | version = "0.9.10" 729 | source = "registry+https://github.com/rust-lang/crates.io-index" 730 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 731 | dependencies = [ 732 | "cfg-if", 733 | "libc", 734 | "redox_syscall", 735 | "smallvec", 736 | "windows-targets 0.52.5", 737 | ] 738 | 739 | [[package]] 740 | name = "paste" 741 | version = "1.0.15" 742 | source = "registry+https://github.com/rust-lang/crates.io-index" 743 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 744 | 745 | [[package]] 746 | name = "pkg-config" 747 | version = "0.3.30" 748 | source = "registry+https://github.com/rust-lang/crates.io-index" 749 | checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" 750 | 751 | [[package]] 752 | name = "plist" 753 | version = "1.6.1" 754 | source = "registry+https://github.com/rust-lang/crates.io-index" 755 | checksum = "d9d34169e64b3c7a80c8621a48adaf44e0cf62c78a9b25dd9dd35f1881a17cf9" 756 | dependencies = [ 757 | "base64", 758 | "indexmap", 759 | "line-wrap", 760 | "quick-xml", 761 | "serde", 762 | "time", 763 | ] 764 | 765 | [[package]] 766 | name = "powerfmt" 767 | version = "0.2.0" 768 | source = "registry+https://github.com/rust-lang/crates.io-index" 769 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 770 | 771 | [[package]] 772 | name = "predicates" 773 | version = "3.1.0" 774 | source = "registry+https://github.com/rust-lang/crates.io-index" 775 | checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" 776 | dependencies = [ 777 | "anstyle", 778 | "predicates-core", 779 | ] 780 | 781 | [[package]] 782 | name = "predicates-core" 783 | version = "1.0.6" 784 | source = "registry+https://github.com/rust-lang/crates.io-index" 785 | checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" 786 | 787 | [[package]] 788 | name = "predicates-tree" 789 | version = "1.0.9" 790 | source = "registry+https://github.com/rust-lang/crates.io-index" 791 | checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" 792 | dependencies = [ 793 | "predicates-core", 794 | "termtree", 795 | ] 796 | 797 | [[package]] 798 | name = "proc-macro2" 799 | version = "1.0.82" 800 | source = "registry+https://github.com/rust-lang/crates.io-index" 801 | checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" 802 | dependencies = [ 803 | "unicode-ident", 804 | ] 805 | 806 | [[package]] 807 | name = "quick-xml" 808 | version = "0.31.0" 809 | source = "registry+https://github.com/rust-lang/crates.io-index" 810 | checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" 811 | dependencies = [ 812 | "memchr", 813 | ] 814 | 815 | [[package]] 816 | name = "quote" 817 | version = "1.0.36" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 820 | dependencies = [ 821 | "proc-macro2", 822 | ] 823 | 824 | [[package]] 825 | name = "ratatui" 826 | version = "0.26.2" 827 | source = "registry+https://github.com/rust-lang/crates.io-index" 828 | checksum = "a564a852040e82671dc50a37d88f3aa83bbc690dfc6844cfe7a2591620206a80" 829 | dependencies = [ 830 | "bitflags 2.5.0", 831 | "cassowary", 832 | "compact_str", 833 | "crossterm", 834 | "indoc", 835 | "itertools 0.12.1", 836 | "lru", 837 | "paste", 838 | "stability", 839 | "strum", 840 | "unicode-segmentation", 841 | "unicode-width", 842 | ] 843 | 844 | [[package]] 845 | name = "redox_syscall" 846 | version = "0.5.1" 847 | source = "registry+https://github.com/rust-lang/crates.io-index" 848 | checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" 849 | dependencies = [ 850 | "bitflags 2.5.0", 851 | ] 852 | 853 | [[package]] 854 | name = "regex-automata" 855 | version = "0.4.6" 856 | source = "registry+https://github.com/rust-lang/crates.io-index" 857 | checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" 858 | dependencies = [ 859 | "aho-corasick", 860 | "memchr", 861 | "regex-syntax", 862 | ] 863 | 864 | [[package]] 865 | name = "regex-syntax" 866 | version = "0.8.3" 867 | source = "registry+https://github.com/rust-lang/crates.io-index" 868 | checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" 869 | 870 | [[package]] 871 | name = "rustix" 872 | version = "0.38.34" 873 | source = "registry+https://github.com/rust-lang/crates.io-index" 874 | checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" 875 | dependencies = [ 876 | "bitflags 2.5.0", 877 | "errno", 878 | "libc", 879 | "linux-raw-sys", 880 | "windows-sys 0.52.0", 881 | ] 882 | 883 | [[package]] 884 | name = "rustversion" 885 | version = "1.0.17" 886 | source = "registry+https://github.com/rust-lang/crates.io-index" 887 | checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" 888 | 889 | [[package]] 890 | name = "ryu" 891 | version = "1.0.18" 892 | source = "registry+https://github.com/rust-lang/crates.io-index" 893 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 894 | 895 | [[package]] 896 | name = "same-file" 897 | version = "1.0.6" 898 | source = "registry+https://github.com/rust-lang/crates.io-index" 899 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 900 | dependencies = [ 901 | "winapi-util", 902 | ] 903 | 904 | [[package]] 905 | name = "scopeguard" 906 | version = "1.2.0" 907 | source = "registry+https://github.com/rust-lang/crates.io-index" 908 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 909 | 910 | [[package]] 911 | name = "serde" 912 | version = "1.0.202" 913 | source = "registry+https://github.com/rust-lang/crates.io-index" 914 | checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" 915 | dependencies = [ 916 | "serde_derive", 917 | ] 918 | 919 | [[package]] 920 | name = "serde_derive" 921 | version = "1.0.202" 922 | source = "registry+https://github.com/rust-lang/crates.io-index" 923 | checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" 924 | dependencies = [ 925 | "proc-macro2", 926 | "quote", 927 | "syn", 928 | ] 929 | 930 | [[package]] 931 | name = "serde_json" 932 | version = "1.0.117" 933 | source = "registry+https://github.com/rust-lang/crates.io-index" 934 | checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" 935 | dependencies = [ 936 | "itoa", 937 | "ryu", 938 | "serde", 939 | ] 940 | 941 | [[package]] 942 | name = "signal-hook" 943 | version = "0.3.17" 944 | source = "registry+https://github.com/rust-lang/crates.io-index" 945 | checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" 946 | dependencies = [ 947 | "libc", 948 | "signal-hook-registry", 949 | ] 950 | 951 | [[package]] 952 | name = "signal-hook-mio" 953 | version = "0.2.3" 954 | source = "registry+https://github.com/rust-lang/crates.io-index" 955 | checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" 956 | dependencies = [ 957 | "libc", 958 | "mio", 959 | "signal-hook", 960 | ] 961 | 962 | [[package]] 963 | name = "signal-hook-registry" 964 | version = "1.4.2" 965 | source = "registry+https://github.com/rust-lang/crates.io-index" 966 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 967 | dependencies = [ 968 | "libc", 969 | ] 970 | 971 | [[package]] 972 | name = "smallvec" 973 | version = "1.13.2" 974 | source = "registry+https://github.com/rust-lang/crates.io-index" 975 | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" 976 | 977 | [[package]] 978 | name = "stability" 979 | version = "0.2.0" 980 | source = "registry+https://github.com/rust-lang/crates.io-index" 981 | checksum = "2ff9eaf853dec4c8802325d8b6d3dffa86cc707fd7a1a4cdbf416e13b061787a" 982 | dependencies = [ 983 | "quote", 984 | "syn", 985 | ] 986 | 987 | [[package]] 988 | name = "static_assertions" 989 | version = "1.1.0" 990 | source = "registry+https://github.com/rust-lang/crates.io-index" 991 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 992 | 993 | [[package]] 994 | name = "strsim" 995 | version = "0.11.1" 996 | source = "registry+https://github.com/rust-lang/crates.io-index" 997 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 998 | 999 | [[package]] 1000 | name = "strum" 1001 | version = "0.26.2" 1002 | source = "registry+https://github.com/rust-lang/crates.io-index" 1003 | checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" 1004 | dependencies = [ 1005 | "strum_macros", 1006 | ] 1007 | 1008 | [[package]] 1009 | name = "strum_macros" 1010 | version = "0.26.2" 1011 | source = "registry+https://github.com/rust-lang/crates.io-index" 1012 | checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" 1013 | dependencies = [ 1014 | "heck 0.4.1", 1015 | "proc-macro2", 1016 | "quote", 1017 | "rustversion", 1018 | "syn", 1019 | ] 1020 | 1021 | [[package]] 1022 | name = "syn" 1023 | version = "2.0.64" 1024 | source = "registry+https://github.com/rust-lang/crates.io-index" 1025 | checksum = "7ad3dee41f36859875573074334c200d1add8e4a87bb37113ebd31d926b7b11f" 1026 | dependencies = [ 1027 | "proc-macro2", 1028 | "quote", 1029 | "unicode-ident", 1030 | ] 1031 | 1032 | [[package]] 1033 | name = "syntect" 1034 | version = "5.2.0" 1035 | source = "registry+https://github.com/rust-lang/crates.io-index" 1036 | checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1" 1037 | dependencies = [ 1038 | "bincode", 1039 | "bitflags 1.3.2", 1040 | "flate2", 1041 | "fnv", 1042 | "once_cell", 1043 | "onig", 1044 | "plist", 1045 | "regex-syntax", 1046 | "serde", 1047 | "serde_derive", 1048 | "serde_json", 1049 | "thiserror", 1050 | "walkdir", 1051 | "yaml-rust", 1052 | ] 1053 | 1054 | [[package]] 1055 | name = "termcolor" 1056 | version = "1.4.1" 1057 | source = "registry+https://github.com/rust-lang/crates.io-index" 1058 | checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" 1059 | dependencies = [ 1060 | "winapi-util", 1061 | ] 1062 | 1063 | [[package]] 1064 | name = "termtree" 1065 | version = "0.4.1" 1066 | source = "registry+https://github.com/rust-lang/crates.io-index" 1067 | checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" 1068 | 1069 | [[package]] 1070 | name = "test-case" 1071 | version = "3.3.1" 1072 | source = "registry+https://github.com/rust-lang/crates.io-index" 1073 | checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" 1074 | dependencies = [ 1075 | "test-case-macros", 1076 | ] 1077 | 1078 | [[package]] 1079 | name = "test-case-core" 1080 | version = "3.3.1" 1081 | source = "registry+https://github.com/rust-lang/crates.io-index" 1082 | checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" 1083 | dependencies = [ 1084 | "cfg-if", 1085 | "proc-macro2", 1086 | "quote", 1087 | "syn", 1088 | ] 1089 | 1090 | [[package]] 1091 | name = "test-case-macros" 1092 | version = "3.3.1" 1093 | source = "registry+https://github.com/rust-lang/crates.io-index" 1094 | checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" 1095 | dependencies = [ 1096 | "proc-macro2", 1097 | "quote", 1098 | "syn", 1099 | "test-case-core", 1100 | ] 1101 | 1102 | [[package]] 1103 | name = "thiserror" 1104 | version = "1.0.60" 1105 | source = "registry+https://github.com/rust-lang/crates.io-index" 1106 | checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18" 1107 | dependencies = [ 1108 | "thiserror-impl", 1109 | ] 1110 | 1111 | [[package]] 1112 | name = "thiserror-impl" 1113 | version = "1.0.60" 1114 | source = "registry+https://github.com/rust-lang/crates.io-index" 1115 | checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524" 1116 | dependencies = [ 1117 | "proc-macro2", 1118 | "quote", 1119 | "syn", 1120 | ] 1121 | 1122 | [[package]] 1123 | name = "time" 1124 | version = "0.3.36" 1125 | source = "registry+https://github.com/rust-lang/crates.io-index" 1126 | checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" 1127 | dependencies = [ 1128 | "deranged", 1129 | "itoa", 1130 | "num-conv", 1131 | "powerfmt", 1132 | "serde", 1133 | "time-core", 1134 | "time-macros", 1135 | ] 1136 | 1137 | [[package]] 1138 | name = "time-core" 1139 | version = "0.1.2" 1140 | source = "registry+https://github.com/rust-lang/crates.io-index" 1141 | checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" 1142 | 1143 | [[package]] 1144 | name = "time-macros" 1145 | version = "0.2.18" 1146 | source = "registry+https://github.com/rust-lang/crates.io-index" 1147 | checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" 1148 | dependencies = [ 1149 | "num-conv", 1150 | "time-core", 1151 | ] 1152 | 1153 | [[package]] 1154 | name = "unicode-ident" 1155 | version = "1.0.12" 1156 | source = "registry+https://github.com/rust-lang/crates.io-index" 1157 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 1158 | 1159 | [[package]] 1160 | name = "unicode-segmentation" 1161 | version = "1.11.0" 1162 | source = "registry+https://github.com/rust-lang/crates.io-index" 1163 | checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" 1164 | 1165 | [[package]] 1166 | name = "unicode-width" 1167 | version = "0.1.12" 1168 | source = "registry+https://github.com/rust-lang/crates.io-index" 1169 | checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" 1170 | 1171 | [[package]] 1172 | name = "utf8parse" 1173 | version = "0.2.1" 1174 | source = "registry+https://github.com/rust-lang/crates.io-index" 1175 | checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 1176 | 1177 | [[package]] 1178 | name = "version_check" 1179 | version = "0.9.4" 1180 | source = "registry+https://github.com/rust-lang/crates.io-index" 1181 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 1182 | 1183 | [[package]] 1184 | name = "walkdir" 1185 | version = "2.5.0" 1186 | source = "registry+https://github.com/rust-lang/crates.io-index" 1187 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 1188 | dependencies = [ 1189 | "same-file", 1190 | "winapi-util", 1191 | ] 1192 | 1193 | [[package]] 1194 | name = "wasi" 1195 | version = "0.11.0+wasi-snapshot-preview1" 1196 | source = "registry+https://github.com/rust-lang/crates.io-index" 1197 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1198 | 1199 | [[package]] 1200 | name = "which" 1201 | version = "6.0.3" 1202 | source = "registry+https://github.com/rust-lang/crates.io-index" 1203 | checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" 1204 | dependencies = [ 1205 | "either", 1206 | "home", 1207 | "rustix", 1208 | "winsafe", 1209 | ] 1210 | 1211 | [[package]] 1212 | name = "winapi" 1213 | version = "0.3.9" 1214 | source = "registry+https://github.com/rust-lang/crates.io-index" 1215 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1216 | dependencies = [ 1217 | "winapi-i686-pc-windows-gnu", 1218 | "winapi-x86_64-pc-windows-gnu", 1219 | ] 1220 | 1221 | [[package]] 1222 | name = "winapi-i686-pc-windows-gnu" 1223 | version = "0.4.0" 1224 | source = "registry+https://github.com/rust-lang/crates.io-index" 1225 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1226 | 1227 | [[package]] 1228 | name = "winapi-util" 1229 | version = "0.1.8" 1230 | source = "registry+https://github.com/rust-lang/crates.io-index" 1231 | checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" 1232 | dependencies = [ 1233 | "windows-sys 0.52.0", 1234 | ] 1235 | 1236 | [[package]] 1237 | name = "winapi-x86_64-pc-windows-gnu" 1238 | version = "0.4.0" 1239 | source = "registry+https://github.com/rust-lang/crates.io-index" 1240 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1241 | 1242 | [[package]] 1243 | name = "windows-sys" 1244 | version = "0.48.0" 1245 | source = "registry+https://github.com/rust-lang/crates.io-index" 1246 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 1247 | dependencies = [ 1248 | "windows-targets 0.48.5", 1249 | ] 1250 | 1251 | [[package]] 1252 | name = "windows-sys" 1253 | version = "0.52.0" 1254 | source = "registry+https://github.com/rust-lang/crates.io-index" 1255 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1256 | dependencies = [ 1257 | "windows-targets 0.52.5", 1258 | ] 1259 | 1260 | [[package]] 1261 | name = "windows-targets" 1262 | version = "0.48.5" 1263 | source = "registry+https://github.com/rust-lang/crates.io-index" 1264 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 1265 | dependencies = [ 1266 | "windows_aarch64_gnullvm 0.48.5", 1267 | "windows_aarch64_msvc 0.48.5", 1268 | "windows_i686_gnu 0.48.5", 1269 | "windows_i686_msvc 0.48.5", 1270 | "windows_x86_64_gnu 0.48.5", 1271 | "windows_x86_64_gnullvm 0.48.5", 1272 | "windows_x86_64_msvc 0.48.5", 1273 | ] 1274 | 1275 | [[package]] 1276 | name = "windows-targets" 1277 | version = "0.52.5" 1278 | source = "registry+https://github.com/rust-lang/crates.io-index" 1279 | checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" 1280 | dependencies = [ 1281 | "windows_aarch64_gnullvm 0.52.5", 1282 | "windows_aarch64_msvc 0.52.5", 1283 | "windows_i686_gnu 0.52.5", 1284 | "windows_i686_gnullvm", 1285 | "windows_i686_msvc 0.52.5", 1286 | "windows_x86_64_gnu 0.52.5", 1287 | "windows_x86_64_gnullvm 0.52.5", 1288 | "windows_x86_64_msvc 0.52.5", 1289 | ] 1290 | 1291 | [[package]] 1292 | name = "windows_aarch64_gnullvm" 1293 | version = "0.48.5" 1294 | source = "registry+https://github.com/rust-lang/crates.io-index" 1295 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 1296 | 1297 | [[package]] 1298 | name = "windows_aarch64_gnullvm" 1299 | version = "0.52.5" 1300 | source = "registry+https://github.com/rust-lang/crates.io-index" 1301 | checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" 1302 | 1303 | [[package]] 1304 | name = "windows_aarch64_msvc" 1305 | version = "0.48.5" 1306 | source = "registry+https://github.com/rust-lang/crates.io-index" 1307 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 1308 | 1309 | [[package]] 1310 | name = "windows_aarch64_msvc" 1311 | version = "0.52.5" 1312 | source = "registry+https://github.com/rust-lang/crates.io-index" 1313 | checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" 1314 | 1315 | [[package]] 1316 | name = "windows_i686_gnu" 1317 | version = "0.48.5" 1318 | source = "registry+https://github.com/rust-lang/crates.io-index" 1319 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 1320 | 1321 | [[package]] 1322 | name = "windows_i686_gnu" 1323 | version = "0.52.5" 1324 | source = "registry+https://github.com/rust-lang/crates.io-index" 1325 | checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" 1326 | 1327 | [[package]] 1328 | name = "windows_i686_gnullvm" 1329 | version = "0.52.5" 1330 | source = "registry+https://github.com/rust-lang/crates.io-index" 1331 | checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" 1332 | 1333 | [[package]] 1334 | name = "windows_i686_msvc" 1335 | version = "0.48.5" 1336 | source = "registry+https://github.com/rust-lang/crates.io-index" 1337 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 1338 | 1339 | [[package]] 1340 | name = "windows_i686_msvc" 1341 | version = "0.52.5" 1342 | source = "registry+https://github.com/rust-lang/crates.io-index" 1343 | checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" 1344 | 1345 | [[package]] 1346 | name = "windows_x86_64_gnu" 1347 | version = "0.48.5" 1348 | source = "registry+https://github.com/rust-lang/crates.io-index" 1349 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 1350 | 1351 | [[package]] 1352 | name = "windows_x86_64_gnu" 1353 | version = "0.52.5" 1354 | source = "registry+https://github.com/rust-lang/crates.io-index" 1355 | checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" 1356 | 1357 | [[package]] 1358 | name = "windows_x86_64_gnullvm" 1359 | version = "0.48.5" 1360 | source = "registry+https://github.com/rust-lang/crates.io-index" 1361 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 1362 | 1363 | [[package]] 1364 | name = "windows_x86_64_gnullvm" 1365 | version = "0.52.5" 1366 | source = "registry+https://github.com/rust-lang/crates.io-index" 1367 | checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" 1368 | 1369 | [[package]] 1370 | name = "windows_x86_64_msvc" 1371 | version = "0.48.5" 1372 | source = "registry+https://github.com/rust-lang/crates.io-index" 1373 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 1374 | 1375 | [[package]] 1376 | name = "windows_x86_64_msvc" 1377 | version = "0.52.5" 1378 | source = "registry+https://github.com/rust-lang/crates.io-index" 1379 | checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" 1380 | 1381 | [[package]] 1382 | name = "winsafe" 1383 | version = "0.0.19" 1384 | source = "registry+https://github.com/rust-lang/crates.io-index" 1385 | checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" 1386 | 1387 | [[package]] 1388 | name = "yaml-rust" 1389 | version = "0.4.5" 1390 | source = "registry+https://github.com/rust-lang/crates.io-index" 1391 | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" 1392 | dependencies = [ 1393 | "linked-hash-map", 1394 | ] 1395 | 1396 | [[package]] 1397 | name = "zerocopy" 1398 | version = "0.7.34" 1399 | source = "registry+https://github.com/rust-lang/crates.io-index" 1400 | checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" 1401 | dependencies = [ 1402 | "zerocopy-derive", 1403 | ] 1404 | 1405 | [[package]] 1406 | name = "zerocopy-derive" 1407 | version = "0.7.34" 1408 | source = "registry+https://github.com/rust-lang/crates.io-index" 1409 | checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" 1410 | dependencies = [ 1411 | "proc-macro2", 1412 | "quote", 1413 | "syn", 1414 | ] 1415 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "igrep" 3 | version = "1.3.0" 4 | authors = ["Konrad Szymoniak "] 5 | license = "MIT" 6 | description = "Interactive Grep" 7 | homepage = "https://github.com/konradsz/igrep" 8 | documentation = "https://github.com/konradsz/igrep" 9 | repository = "https://github.com/konradsz/igrep" 10 | keywords = ["cli", "tui", "grep"] 11 | categories = ["command-line-utilities"] 12 | edition = "2021" 13 | 14 | [[bin]] 15 | name = "ig" 16 | path = "src/main.rs" 17 | 18 | [dependencies] 19 | grep = "0.3.1" 20 | ignore = "0.4.22" 21 | clap = { version = "4.5.4", features = ["derive", "env"] } 22 | crossterm = "0.27.0" 23 | ratatui = { version = "0.26.2", default-features = false, features = [ 24 | 'crossterm', 25 | ] } 26 | unicode-width = "0.1.12" 27 | itertools = "0.13.0" 28 | anyhow = "1.0.83" 29 | strum = { version = "0.26.2", features = ["derive"] } 30 | syntect = "5.2.0" 31 | which = "6.0.3" 32 | 33 | [dev-dependencies] 34 | lazy_static = "1.4.0" 35 | test-case = "3.3.1" 36 | mockall = "0.12.1" 37 | 38 | [build-dependencies] 39 | anyhow = "1.0.83" 40 | -------------------------------------------------------------------------------- /HomebrewFormula/igrep.rb: -------------------------------------------------------------------------------- 1 | class Igrep < Formula 2 | version "1.3.0" 3 | desc "Interactive Grep" 4 | homepage "https://github.com/konradsz/igrep" 5 | url "https://github.com/konradsz/igrep/releases/download/v#{version}/igrep-v#{version}-x86_64-apple-darwin.tar.gz" 6 | sha256 "33908e25d904d7652f2bc749a16beaae86b9529f83aeae8ca6834dddfc2b0a9d" 7 | 8 | def install 9 | bin.install "ig" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Konrad Szymoniak 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 | # igrep - Interactive Grep 2 | Runs [grep](https://crates.io/crates/grep) ([ripgrep's](https://github.com/BurntSushi/ripgrep/) library) in the background, allows interactively pick its results and open selected match in text editor of choice (vim by default). 3 | 4 | `igrep` supports macOS and Linux. Reportedly it works on Windows as well. 5 | 6 | 7 | 8 | ## Usage 9 | `ig [OPTIONS] [PATHS]...` 10 | 11 | ### Args 12 | ``` 13 | Regular expression used for searching. 14 | ... Files or directories to search. Directories are searched recursively. 15 | If not specified, searching starts from current directory. 16 | ``` 17 | 18 | ### Options 19 | ``` 20 | -., --hidden Search hidden files and directories. By default, hidden files and 21 | directories are skipped. 22 | --editor Text editor used to open selected match. 23 | [possible values: check supported text editors section] 24 | --context-viewer Context viewer position at startup [default: none] 25 | [possible values: none, vertical, horizontal] 26 | --custom-command Custom command used to open selected match. 27 | Must contain {file_name} and {line_number} tokens (check Custom Command section). 28 | -g, --glob Include files and directories for searching that match the given glob. 29 | Multiple globs may be provided. 30 | -h, --help Print help information 31 | -i, --ignore-case Searches case insensitively. 32 | -L, --follow Follow symbolic links while traversing directories 33 | -S, --smart-case Searches case insensitively if the pattern is all lowercase. 34 | Search case sensitively otherwise. 35 | -t, --type Only search files matching TYPE. 36 | Multiple types may be provided. 37 | -T, --type-not Do not search files matching TYPE-NOT. 38 | Multiple types-not may be provided. 39 | --theme UI color theme [default: dark] [possible values: light, dark] 40 | --type-list Show all supported file types and their corresponding globs. 41 | -V, --version Print version information. 42 | -w, --word-regexp Only show matches surrounded by word boundaries 43 | --sort Sort results by [path, modified, accessed, created], see ripgrep for details 44 | --sortr Sort results reverse by [path, modified, accessed, created], see ripgrep for details 45 | ``` 46 | NOTE: `ig` respects `ripgrep`'s [configuration file](https://github.com/BurntSushi/ripgrep/blob/master/GUIDE.md#configuration-file) if `RIPGREP_CONFIG_PATH` environment variable is set and reads all supported options from it. 47 | 48 | ## Keybindings 49 | 50 | 51 | | Key | Action | 52 | | ------------------------ | -------------------------------------- | 53 | | `q`, `Esc`, `Ctrl+c` | Quit | 54 | | | | 55 | | `?`, `F1` | Open/close the keymap popup | 56 | | `Down`, `j` | Scroll down in the keymap popup | 57 | | `Up`, `k` | Scroll up in the keymap popup | 58 | | `Right`, `l` | Scroll right in the keymap popup | 59 | | `Left`, `h` | Scroll left in the keymap popup | 60 | | | | 61 | | `Down`, `j` | Select next match | 62 | | `Up`,`k` | Select previous match | 63 | | `Right`, `l`, `PageDown` | Select match in next file | 64 | | `Left`, `h`, `PageUp` | Select match in previous file | 65 | | `gg`, `Home` | Jump to the first match | 66 | | `Shift-g`, `End` | Jump to the last match | 67 | | `Enter` | Open current file | 68 | | `dd`, `Delete` | Filter out selected match | 69 | | `dw` | Filter out all matches in current file | 70 | | `v` | Toggle vertical context viewer | 71 | | `s` | Toggle horizontal context viewer | 72 | | `+` | Increase context viewer size | 73 | | `-` | Decrease context viewer size | 74 | | `F5`, `/` | Open search pattern popup | 75 | | `n` | Sort search results by name | 76 | | `m` | Sort search results by time modified | 77 | | `c` | Sort search results by time created | 78 | | `a` | Sort search results by time accessed | 79 | 80 | 81 | ## Supported text editors 82 | `igrep` supports Vim, Neovim, nano, VS Code (stable and insiders), Emacs, EmacsClient, Helix, SublimeText, Micro, Intellij, Goland, Pycharm and Less. If your beloved editor is missing on this list and you still want to use `igrep` please file an issue or use [custom command](#custom-command). 83 | 84 | ## Specifying text editor 85 | ### Builtin editors 86 | To specify builtin editor, use one of the following (listed in order of their precedence): 87 | - `--editor` option, 88 | - `$IGREP_EDITOR` variable, 89 | - `$VISUAL` variable, 90 | - `$EDITOR` variable. 91 | 92 | Higher priority option overrides lower one. If neither of these options is set, vim is used as a default. 93 | 94 | ### Custom Command 95 | Users can provide their own command used to open selected match using `--custom-command` option. It must contain {file_name} and {line_number} tokens. Example command used to open file in Vim looks as follows: 96 | 97 | `--custom-command "vim +{line_number} {file_name}"` 98 | 99 | The same argument can also be passed via the `$IGREP_CUSTOM_EDITOR` environment variable. Example: 100 | 101 | `IGREP_CUSTOM_EDITOR="vim +{line_number} {file_name}"` 102 | 103 | ## Installation 104 | ### Prebuilt binaries 105 | `igrep` binaries can be downloaded from [GitHub](https://github.com/konradsz/igrep/releases). 106 | ### Homebrew 107 | ``` 108 | brew tap konradsz/igrep https://github.com/konradsz/igrep.git 109 | brew install igrep 110 | ``` 111 | ### Scoop 112 | ``` 113 | scoop install igrep 114 | ``` 115 | ### Arch Linux 116 | ``` 117 | pacman -S igrep 118 | ``` 119 | ### Alpine Linux 120 | 121 | `igrep` is available for [Alpine Edge](https://pkgs.alpinelinux.org/packages?name=igrep&branch=edge). It can be installed via [apk](https://wiki.alpinelinux.org/wiki/Alpine_Package_Keeper) after enabling the [testing repository](https://wiki.alpinelinux.org/wiki/Repositories). 122 | 123 | ``` 124 | apk add igrep 125 | ``` 126 | 127 | ### Build from source 128 | Build and install from source using Rust toolchain by running: `cargo install igrep`. 129 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/konradsz/igrep/9cbe0522e9bfe7c11e03ab539c9ba2976a51c334/assets/demo.gif -------------------------------------------------------------------------------- /assets/v1_0_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/konradsz/igrep/9cbe0522e9bfe7c11e03ab539c9ba2976a51c334/assets/v1_0_0.gif -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::File, 3 | io::{BufRead, BufReader, BufWriter, Write}, 4 | }; 5 | 6 | use anyhow::{ensure, Context, Result}; 7 | 8 | fn main() -> Result<()> { 9 | keybindings_table().context("failed to prepare keybindings table")?; 10 | 11 | Ok(()) 12 | } 13 | 14 | fn keybindings_table() -> Result<()> { 15 | let readme = File::open("README.md").context("failed to open README.md")?; 16 | let readme = BufReader::new(readme); 17 | 18 | let table: Vec = readme 19 | .lines() 20 | .skip_while(|line| !matches!(line, Ok(line) if line == "")) 21 | .skip(3) // begin marker, header, separator line 22 | .take_while(|line| !matches!(line, Ok(line) if line == "")) 23 | .collect::>() 24 | .context("failed to read table")?; 25 | 26 | ensure!( 27 | table 28 | .iter() 29 | .all(|line| line.starts_with('|') && line.ends_with('|') && line.contains(" | ")), 30 | "table is not a table" 31 | ); 32 | 33 | let content: Vec<(String, String)> = table 34 | .into_iter() 35 | .map(|line| { 36 | line.strip_prefix('|') 37 | .unwrap() 38 | .strip_suffix('|') 39 | .unwrap() 40 | .to_string() 41 | }) 42 | .map(|line| { 43 | let (keys, description) = line.split_once('|').unwrap(); 44 | 45 | let keys = keys.trim().chars().filter(|c| c != &'`').collect(); 46 | 47 | let description = description.trim().to_string(); 48 | 49 | (keys, description) 50 | }) 51 | .collect(); 52 | 53 | let max_key = content 54 | .iter() 55 | .map(|(key, _)| key.len()) 56 | .max() 57 | .context("no max key length")? 58 | .max("Key(s)".len()); 59 | let max_description = content 60 | .iter() 61 | .map(|(_, description)| description.len()) 62 | .max() 63 | .context("no max description length")? 64 | .max("Action".len()); 65 | let len = content.len(); 66 | 67 | let out_dir = std::env::var("OUT_DIR").context("no $OUT_DIR")?; 68 | 69 | let table_file = File::create(format!("{out_dir}/keybindings.txt")) 70 | .context("failed to create table file")?; 71 | let mut table_file = BufWriter::new(table_file); 72 | writeln!( 73 | table_file, 74 | "{0:<1$} │ {2:<3$}", 75 | "Key(s)", max_key, "Action", max_description 76 | ) 77 | .context("failed to write table file: header")?; 78 | writeln!( 79 | table_file, 80 | "{}┼{}", 81 | "─".repeat(max_key + 1), 82 | "─".repeat(max_description + 1) 83 | ) 84 | .context("failed to write table file: separator")?; 85 | for (key, description) in content { 86 | writeln!( 87 | table_file, 88 | "{key:<0$} │ {description:<1$}", 89 | max_key, max_description 90 | ) 91 | .context("failed to write table file: content")?; 92 | } 93 | writeln!(table_file, "\nPress any key to close…") 94 | .context("failed to write table file: close")?; 95 | 96 | let data_file = 97 | File::create(format!("{out_dir}/keybindings.rs")).context("failed to create data file")?; 98 | let mut data_file = BufWriter::new(data_file); 99 | writeln!( 100 | data_file, 101 | r#"const KEYBINDINGS_TABLE: &str = include_str!(concat!(env!("OUT_DIR"), "/keybindings.txt"));"# 102 | ) 103 | .context("failed to write data file: table")?; 104 | writeln!(data_file, "const KEYBINDINGS_LEN: u16 = {};", len + 4) 105 | .context("failed to write data file: length")?; 106 | writeln!( 107 | data_file, 108 | "const KEYBINDINGS_LINE_LEN: u16 = {};", 109 | max_key + 3 + max_description 110 | ) 111 | .context("failed to write data file: line length")?; 112 | 113 | Ok(()) 114 | } 115 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | editor::EditorCommand, 3 | ig::{Ig, SearchConfig, SortKey}, 4 | ui::{ 5 | bottom_bar, context_viewer::ContextViewer, input_handler::InputHandler, 6 | keymap_popup::KeymapPopup, result_list::ResultList, search_popup::SearchPopup, 7 | theme::Theme, 8 | }, 9 | }; 10 | use anyhow::Result; 11 | use crossterm::{ 12 | event::{DisableMouseCapture, EnableMouseCapture}, 13 | execute, 14 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 15 | }; 16 | 17 | use ratatui::{ 18 | backend::CrosstermBackend, 19 | layout::{Constraint, Direction, Layout}, 20 | Frame, Terminal, 21 | }; 22 | use std::path::PathBuf; 23 | 24 | pub struct App { 25 | search_config: SearchConfig, 26 | ig: Ig, 27 | theme: Box, 28 | result_list: ResultList, 29 | context_viewer: ContextViewer, 30 | search_popup: SearchPopup, 31 | keymap_popup: KeymapPopup, 32 | } 33 | 34 | impl App { 35 | pub fn new( 36 | search_config: SearchConfig, 37 | editor_command: EditorCommand, 38 | context_viewer: ContextViewer, 39 | theme: Box, 40 | ) -> Self { 41 | let theme = theme; 42 | Self { 43 | search_config, 44 | ig: Ig::new(editor_command), 45 | theme, 46 | context_viewer, 47 | result_list: ResultList::default(), 48 | search_popup: SearchPopup::default(), 49 | keymap_popup: KeymapPopup::default(), 50 | } 51 | } 52 | 53 | pub fn run(&mut self) -> Result<()> { 54 | let mut input_handler = InputHandler::default(); 55 | self.ig 56 | .search(self.search_config.clone(), &mut self.result_list); 57 | 58 | loop { 59 | let backend = CrosstermBackend::new(std::io::stdout()); 60 | let mut terminal = Terminal::new(backend)?; 61 | terminal.hide_cursor()?; 62 | 63 | enable_raw_mode()?; 64 | execute!( 65 | terminal.backend_mut(), 66 | // NOTE: This is necessary due to upstream `crossterm` requiring that we "enable" 67 | // mouse handling first, which saves some state that necessary for _disabling_ 68 | // mouse events. 69 | EnableMouseCapture, 70 | EnterAlternateScreen, 71 | DisableMouseCapture 72 | )?; 73 | 74 | while self.ig.is_searching() || self.ig.last_error().is_some() || self.ig.is_idle() { 75 | terminal.draw(|f| Self::draw(f, self, &input_handler))?; 76 | 77 | while let Some(entry) = self.ig.handle_searcher_event() { 78 | self.result_list.add_entry(entry); 79 | } 80 | 81 | input_handler.handle_input(self)?; 82 | 83 | if let Some((file_name, _)) = self.result_list.get_selected_entry() { 84 | self.context_viewer 85 | .update_if_needed(PathBuf::from(file_name), self.theme.as_ref()); 86 | } 87 | } 88 | 89 | self.ig 90 | .open_file_if_requested(self.result_list.get_selected_entry()); 91 | 92 | if self.ig.exit_requested() { 93 | execute!(terminal.backend_mut(), LeaveAlternateScreen)?; 94 | disable_raw_mode()?; 95 | break; 96 | } 97 | } 98 | 99 | Ok(()) 100 | } 101 | 102 | fn draw(frame: &mut Frame, app: &mut App, input_handler: &InputHandler) { 103 | let chunks = Layout::default() 104 | .direction(Direction::Vertical) 105 | .constraints([Constraint::Min(1), Constraint::Length(1)].as_ref()) 106 | .split(frame.size()); 107 | 108 | let (view_area, bottom_bar_area) = (chunks[0], chunks[1]); 109 | let (list_area, context_viewer_area) = app.context_viewer.split_view(view_area); 110 | 111 | app.result_list.draw(frame, list_area, app.theme.as_ref()); 112 | 113 | if let Some(cv_area) = context_viewer_area { 114 | app.context_viewer 115 | .draw(frame, cv_area, &app.result_list, app.theme.as_ref()); 116 | } 117 | 118 | bottom_bar::draw( 119 | frame, 120 | bottom_bar_area, 121 | &app.result_list, 122 | &app.ig, 123 | input_handler, 124 | app.theme.as_ref(), 125 | ); 126 | 127 | app.search_popup.draw(frame, app.theme.as_ref()); 128 | app.keymap_popup.draw(frame, app.theme.as_ref()); 129 | } 130 | } 131 | 132 | impl Application for App { 133 | fn is_searching(&self) -> bool { 134 | self.ig.is_searching() 135 | } 136 | 137 | fn on_next_match(&mut self) { 138 | self.result_list.next_match(); 139 | } 140 | 141 | fn on_previous_match(&mut self) { 142 | self.result_list.previous_match(); 143 | } 144 | 145 | fn on_next_file(&mut self) { 146 | self.result_list.next_file(); 147 | } 148 | 149 | fn on_previous_file(&mut self) { 150 | self.result_list.previous_file(); 151 | } 152 | 153 | fn on_top(&mut self) { 154 | self.result_list.top(); 155 | } 156 | 157 | fn on_bottom(&mut self) { 158 | self.result_list.bottom(); 159 | } 160 | 161 | fn on_remove_current_entry(&mut self) { 162 | self.result_list.remove_current_entry(); 163 | } 164 | 165 | fn on_remove_current_file(&mut self) { 166 | self.result_list.remove_current_file(); 167 | } 168 | 169 | fn on_toggle_context_viewer_vertical(&mut self) { 170 | self.context_viewer.toggle_vertical(); 171 | } 172 | 173 | fn on_toggle_context_viewer_horizontal(&mut self) { 174 | self.context_viewer.toggle_horizontal(); 175 | } 176 | 177 | fn on_increase_context_viewer_size(&mut self) { 178 | self.context_viewer.increase_size(); 179 | } 180 | 181 | fn on_decrease_context_viewer_size(&mut self) { 182 | self.context_viewer.decrease_size(); 183 | } 184 | 185 | fn on_toggle_sort_name(&mut self) { 186 | if self.search_config.sort_by.is_some() { 187 | self.search_config.sort_by_reversed = Some(SortKey::Path); 188 | self.search_config.sort_by = None; 189 | } else { 190 | self.search_config.sort_by = Some(SortKey::Path); 191 | self.search_config.sort_by_reversed = None; 192 | } 193 | self.ig 194 | .search(self.search_config.clone(), &mut self.result_list); 195 | } 196 | 197 | fn on_toggle_sort_mtime(&mut self) { 198 | if self.search_config.sort_by.is_some() { 199 | self.search_config.sort_by_reversed = Some(SortKey::Modified); 200 | self.search_config.sort_by = None; 201 | } else { 202 | self.search_config.sort_by = Some(SortKey::Modified); 203 | self.search_config.sort_by_reversed = None; 204 | } 205 | self.ig 206 | .search(self.search_config.clone(), &mut self.result_list); 207 | } 208 | 209 | fn on_toggle_sort_ctime(&mut self) { 210 | if self.search_config.sort_by.is_some() { 211 | self.search_config.sort_by_reversed = Some(SortKey::Created); 212 | self.search_config.sort_by = None; 213 | } else { 214 | self.search_config.sort_by = Some(SortKey::Created); 215 | self.search_config.sort_by_reversed = None; 216 | } 217 | self.ig 218 | .search(self.search_config.clone(), &mut self.result_list); 219 | } 220 | 221 | fn on_toggle_sort_atime(&mut self) { 222 | if self.search_config.sort_by.is_some() { 223 | self.search_config.sort_by_reversed = Some(SortKey::Accessed); 224 | self.search_config.sort_by = None; 225 | } else { 226 | self.search_config.sort_by = Some(SortKey::Accessed); 227 | self.search_config.sort_by_reversed = None; 228 | } 229 | self.ig 230 | .search(self.search_config.clone(), &mut self.result_list); 231 | } 232 | 233 | fn on_open_file(&mut self) { 234 | self.ig.open_file(); 235 | } 236 | 237 | fn on_search(&mut self) { 238 | let pattern = self.search_popup.get_pattern(); 239 | self.search_config.pattern = pattern; 240 | self.ig 241 | .search(self.search_config.clone(), &mut self.result_list); 242 | } 243 | 244 | fn on_exit(&mut self) { 245 | self.ig.exit(); 246 | } 247 | 248 | fn on_toggle_popup(&mut self) { 249 | self.search_popup 250 | .set_pattern(self.search_config.pattern.clone()); 251 | self.search_popup.toggle(); 252 | } 253 | 254 | fn on_char_inserted(&mut self, c: char) { 255 | self.search_popup.insert_char(c); 256 | } 257 | 258 | fn on_char_removed(&mut self) { 259 | self.search_popup.remove_char(); 260 | } 261 | 262 | fn on_char_deleted(&mut self) { 263 | self.search_popup.delete_char(); 264 | } 265 | 266 | fn on_char_left(&mut self) { 267 | self.search_popup.move_cursor_left(); 268 | } 269 | 270 | fn on_char_right(&mut self) { 271 | self.search_popup.move_cursor_right(); 272 | } 273 | 274 | fn on_toggle_keymap(&mut self) { 275 | self.keymap_popup.toggle(); 276 | } 277 | 278 | fn on_keymap_up(&mut self) { 279 | self.keymap_popup.go_up(); 280 | } 281 | 282 | fn on_keymap_down(&mut self) { 283 | self.keymap_popup.go_down(); 284 | } 285 | 286 | fn on_keymap_left(&mut self) { 287 | self.keymap_popup.go_left(); 288 | } 289 | 290 | fn on_keymap_right(&mut self) { 291 | self.keymap_popup.go_right(); 292 | } 293 | } 294 | 295 | #[cfg_attr(test, mockall::automock)] 296 | pub trait Application { 297 | fn is_searching(&self) -> bool; 298 | fn on_next_match(&mut self); 299 | fn on_previous_match(&mut self); 300 | fn on_next_file(&mut self); 301 | fn on_previous_file(&mut self); 302 | fn on_top(&mut self); 303 | fn on_bottom(&mut self); 304 | fn on_remove_current_entry(&mut self); 305 | fn on_remove_current_file(&mut self); 306 | fn on_toggle_context_viewer_vertical(&mut self); 307 | fn on_toggle_context_viewer_horizontal(&mut self); 308 | fn on_increase_context_viewer_size(&mut self); 309 | fn on_decrease_context_viewer_size(&mut self); 310 | fn on_toggle_sort_name(&mut self); 311 | fn on_toggle_sort_mtime(&mut self); 312 | fn on_toggle_sort_ctime(&mut self); 313 | fn on_toggle_sort_atime(&mut self); 314 | fn on_open_file(&mut self); 315 | fn on_search(&mut self); 316 | fn on_exit(&mut self); 317 | fn on_toggle_popup(&mut self); 318 | fn on_char_inserted(&mut self, c: char); 319 | fn on_char_removed(&mut self); 320 | fn on_char_deleted(&mut self); 321 | fn on_char_left(&mut self); 322 | fn on_char_right(&mut self); 323 | fn on_toggle_keymap(&mut self); 324 | fn on_keymap_up(&mut self); 325 | fn on_keymap_down(&mut self); 326 | fn on_keymap_left(&mut self); 327 | fn on_keymap_right(&mut self); 328 | } 329 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | editor::Editor, 3 | ig::search_config::SortKey, 4 | ui::{context_viewer::ContextViewerPosition, theme::ThemeVariant}, 5 | }; 6 | use clap::{CommandFactory, Parser}; 7 | use std::{ 8 | ffi::OsString, 9 | fs::File, 10 | io::{self, BufRead, BufReader}, 11 | iter::once, 12 | path::PathBuf, 13 | }; 14 | 15 | pub const IGREP_CUSTOM_EDITOR_ENV: &str = "IGREP_CUSTOM_EDITOR"; 16 | pub const IGREP_EDITOR_ENV: &str = "IGREP_EDITOR"; 17 | pub const EDITOR_ENV: &str = "EDITOR"; 18 | pub const RIPGREP_CONFIG_PATH_ENV: &str = "RIPGREP_CONFIG_PATH"; 19 | pub const VISUAL_ENV: &str = "VISUAL"; 20 | 21 | #[derive(Parser, Debug)] 22 | #[clap(author, version, about, long_about = None)] 23 | pub struct Args { 24 | /// Regular expression used for searching. 25 | #[arg(group = "pattern_or_type", required = true)] 26 | pub pattern: Option, 27 | /// Files or directories to search. Directories are searched recursively. 28 | /// If not specified, searching starts from current directory. 29 | pub paths: Vec, 30 | #[clap(flatten)] 31 | pub editor: EditorOpt, 32 | /// UI color theme. 33 | #[clap(long, default_value_t = ThemeVariant::Dark)] 34 | pub theme: ThemeVariant, 35 | /// Searches case insensitively. 36 | #[clap(short = 'i', long)] 37 | pub ignore_case: bool, 38 | /// Searches case insensitively if the pattern is all lowercase. 39 | /// Search case sensitively otherwise. 40 | #[clap(short = 'S', long)] 41 | pub smart_case: bool, 42 | /// Search hidden files and directories. 43 | /// By default, hidden files and directories are skipped. 44 | #[clap(short = '.', long = "hidden")] 45 | pub search_hidden: bool, 46 | /// Follow symbolic links while traversing directories. 47 | #[clap(short = 'L', long = "follow")] 48 | pub follow_links: bool, 49 | /// Only show matches surrounded by word boundaries. 50 | #[clap(short = 'w', long = "word-regexp")] 51 | pub word_regexp: bool, 52 | /// Include files and directories for searching that match the given glob. 53 | /// Multiple globs may be provided. 54 | #[clap(short, long)] 55 | pub glob: Vec, 56 | /// Show all supported file types and their corresponding globs. 57 | #[arg(group = "pattern_or_type", required = true)] 58 | #[clap(long)] 59 | pub type_list: bool, 60 | /// Only search files matching TYPE. Multiple types may be provided. 61 | #[clap(short = 't', long = "type")] 62 | pub type_matching: Vec, 63 | /// Do not search files matching TYPE-NOT. Multiple types-not may be provided. 64 | #[clap(short = 'T', long)] 65 | pub type_not: Vec, 66 | /// Context viewer position at startup 67 | #[clap(long, value_enum, default_value_t = ContextViewerPosition::None)] 68 | pub context_viewer: ContextViewerPosition, 69 | /// Sort results, see ripgrep for details 70 | #[clap(long = "sort")] 71 | pub sort_by: Option, 72 | /// Sort results reverse, see ripgrep for details 73 | #[clap(long = "sortr")] 74 | pub sort_by_reverse: Option, 75 | } 76 | 77 | #[derive(Parser, Debug)] 78 | pub struct EditorOpt { 79 | /// Text editor used to open selected match. 80 | #[arg(group = "editor_command")] 81 | #[clap(long)] 82 | pub editor: Option, 83 | 84 | /// Custom command used to open selected match. Must contain {file_name} and {line_number} tokens. 85 | #[arg(group = "editor_command")] 86 | #[clap(long, env = IGREP_CUSTOM_EDITOR_ENV)] 87 | pub custom_command: Option, 88 | } 89 | 90 | impl Args { 91 | pub fn parse_cli_and_config_file() -> Self { 92 | // first validate if CLI arguments are valid 93 | Args::parse_from(std::env::args_os()); 94 | 95 | let mut args_os: Vec<_> = std::env::args_os().collect(); 96 | let to_ignore = args_os 97 | .iter() 98 | .filter_map(|arg| { 99 | let arg = arg.to_str().expect("Not valid UTF-8"); 100 | arg.starts_with('-') 101 | .then(|| arg.trim_start_matches('-').to_owned()) 102 | }) 103 | .collect::>(); 104 | 105 | // then extend them with those from config file 106 | args_os.extend(Self::parse_config_file(to_ignore)); 107 | 108 | Args::parse_from(args_os) 109 | } 110 | 111 | fn parse_config_file(to_ignore: Vec) -> Vec { 112 | match std::env::var_os(RIPGREP_CONFIG_PATH_ENV) { 113 | None => Vec::default(), 114 | Some(config_path) => match File::open(config_path) { 115 | Ok(file) => { 116 | let supported_arguments = Self::collect_supported_arguments(); 117 | let to_ignore = Self::pair_ignored(to_ignore, &supported_arguments); 118 | Self::parse_from_reader(file, supported_arguments, to_ignore) 119 | } 120 | Err(_) => Vec::default(), 121 | }, 122 | } 123 | } 124 | 125 | fn pair_ignored( 126 | to_ignore: Vec, 127 | supported_arguments: &[(Option, Option)], 128 | ) -> Vec { 129 | to_ignore 130 | .iter() 131 | .filter(|i| { 132 | supported_arguments 133 | .iter() 134 | .any(|arg| (arg.0.as_ref() == Some(i) || arg.1.as_ref() == Some(i))) 135 | }) 136 | .flat_map(|i| { 137 | match supported_arguments 138 | .iter() 139 | .find(|arg| arg.0.as_ref() == Some(i) || arg.1.as_ref() == Some(i)) 140 | { 141 | Some(arg) => Box::new(once(arg.0.clone()).chain(once(arg.1.clone()))) 142 | as Box>, 143 | None => Box::new(once(None)), 144 | } 145 | }) 146 | .flatten() 147 | .collect() 148 | } 149 | 150 | fn collect_supported_arguments() -> Vec<(Option, Option)> { 151 | Args::command() 152 | .get_arguments() 153 | .filter_map(|arg| match (arg.get_long(), arg.get_short()) { 154 | (None, None) => None, 155 | (l, s) => Some((l.map(|l| l.to_string()), s.map(|s| s.to_string()))), 156 | }) 157 | .collect::>() 158 | } 159 | 160 | fn parse_from_reader( 161 | reader: R, 162 | supported: Vec<(Option, Option)>, 163 | to_ignore: Vec, 164 | ) -> Vec { 165 | let reader = BufReader::new(reader); 166 | let mut ignore_next_line = false; 167 | 168 | reader 169 | .lines() 170 | .filter_map(|line| { 171 | let line = line.expect("Not valid UTF-8"); 172 | let line = line.trim(); 173 | if line.is_empty() || line.starts_with('#') { 174 | return None; 175 | } 176 | 177 | if let Some(long) = line.strip_prefix("--") { 178 | ignore_next_line = false; 179 | let long = long.split_terminator('=').next().expect("Empty line"); 180 | if supported.iter().any(|el| el.0 == Some(long.to_string())) 181 | && !to_ignore.contains(&long.to_owned()) 182 | { 183 | return Some(OsString::from(line)); 184 | } 185 | if !line.contains('=') { 186 | ignore_next_line = true; 187 | } 188 | None 189 | } else if let Some(short) = line.strip_prefix('-') { 190 | ignore_next_line = false; 191 | let short = short.split_terminator('=').next().expect("Empty line"); 192 | if supported.iter().any(|el| el.1 == Some(short.to_string())) 193 | && !to_ignore.contains(&short.to_owned()) 194 | { 195 | return Some(OsString::from(line)); 196 | } 197 | if !line.contains('=') { 198 | ignore_next_line = true; 199 | } 200 | None 201 | } else { 202 | if ignore_next_line { 203 | ignore_next_line = false; 204 | return None; 205 | } 206 | Some(OsString::from(line)) 207 | } 208 | }) 209 | .collect() 210 | } 211 | } 212 | 213 | #[cfg(test)] 214 | mod tests { 215 | use super::*; 216 | use std::collections::HashSet; 217 | 218 | #[test] 219 | fn ripgrep_example_config() { 220 | let supported_args = vec![ 221 | (Some("glob".to_owned()), Some("g".to_owned())), 222 | (Some("smart-case".to_owned()), None), 223 | ]; 224 | let input = "\ 225 | # Don't let ripgrep vomit really long lines to my terminal, and show a preview. 226 | --max-columns=150 227 | --max-columns-preview 228 | 229 | # Add my 'web' type. 230 | --type-add 231 | web:*.{html,css,js}* 232 | 233 | # Using glob patterns to include/exclude files or folders 234 | -g=!git/* 235 | 236 | # or 237 | --glob 238 | !git/* 239 | 240 | # Set the colors. 241 | --colors=line:none 242 | --colors=line:style:bold 243 | 244 | # Because who cares about case!? 245 | --smart-case"; 246 | 247 | let args = Args::parse_from_reader(input.as_bytes(), supported_args, vec![]) 248 | .into_iter() 249 | .map(|s| s.into_string().unwrap()) 250 | .collect::>(); 251 | assert_eq!(args, ["-g=!git/*", "--glob", "!git/*", "--smart-case"]); 252 | } 253 | 254 | #[test] 255 | fn trim_whitespaces() { 256 | let supported_args = vec![(Some("sup".to_owned()), Some("s".to_owned()))]; 257 | 258 | let input = "\ 259 | # comment 260 | --sup=value\n\r\ 261 | -s \n\ 262 | value 263 | --unsup 264 | 265 | # --comment 266 | value 267 | -s"; 268 | let args = Args::parse_from_reader(input.as_bytes(), supported_args, vec![]) 269 | .into_iter() 270 | .map(|s| s.into_string().unwrap()) 271 | .collect::>(); 272 | assert_eq!(args, ["--sup=value", "-s", "value", "-s"]); 273 | } 274 | 275 | #[test] 276 | fn skip_line_after_ignored_option() { 277 | let supported_args = vec![ 278 | (Some("aaa".to_owned()), Some("a".to_owned())), 279 | (Some("bbb".to_owned()), Some("b".to_owned())), 280 | ]; 281 | 282 | let input = "\ 283 | --aaa 284 | value 285 | --bbb 286 | value 287 | "; 288 | let args = Args::parse_from_reader( 289 | input.as_bytes(), 290 | supported_args.clone(), 291 | vec!["aaa".to_owned()], 292 | ) 293 | .into_iter() 294 | .map(|s| s.into_string().unwrap()) 295 | .collect::>(); 296 | assert_eq!(args, ["--bbb", "value"]); 297 | 298 | let input = "\ 299 | -a 300 | value 301 | -b 302 | value 303 | "; 304 | let args = Args::parse_from_reader(input.as_bytes(), supported_args, vec!["a".to_owned()]) 305 | .into_iter() 306 | .map(|s| s.into_string().unwrap()) 307 | .collect::>(); 308 | assert_eq!(args, ["-b", "value"]); 309 | } 310 | 311 | #[test] 312 | fn do_not_skip_line_after_ignored_option_if_value_inline() { 313 | let supported_args = vec![ 314 | (Some("aaa".to_owned()), Some("a".to_owned())), 315 | (Some("bbb".to_owned()), Some("b".to_owned())), 316 | ]; 317 | 318 | let input = "\ 319 | --aaa=value 320 | --bbb 321 | value 322 | "; 323 | let args = Args::parse_from_reader( 324 | input.as_bytes(), 325 | supported_args.clone(), 326 | vec!["aaa".to_owned()], 327 | ) 328 | .into_iter() 329 | .map(|s| s.into_string().unwrap()) 330 | .collect::>(); 331 | assert_eq!(args, ["--bbb", "value"]); 332 | 333 | let input = "\ 334 | -a=value 335 | -b 336 | value 337 | "; 338 | let args = Args::parse_from_reader(input.as_bytes(), supported_args, vec!["a".to_owned()]) 339 | .into_iter() 340 | .map(|s| s.into_string().unwrap()) 341 | .collect::>(); 342 | assert_eq!(args, ["-b", "value"]); 343 | } 344 | 345 | #[test] 346 | fn do_not_skip_line_after_ignored_flag() { 347 | let supported_args = vec![ 348 | (Some("aaa".to_owned()), Some("a".to_owned())), 349 | (Some("bbb".to_owned()), Some("b".to_owned())), 350 | ]; 351 | 352 | let input = "\ 353 | --aaa 354 | --bbb 355 | value 356 | "; 357 | let args = Args::parse_from_reader( 358 | input.as_bytes(), 359 | supported_args.clone(), 360 | vec!["aaa".to_owned()], 361 | ) 362 | .into_iter() 363 | .map(|s| s.into_string().unwrap()) 364 | .collect::>(); 365 | assert_eq!(args, ["--bbb", "value"]); 366 | 367 | let input = "\ 368 | -a 369 | -b 370 | value 371 | "; 372 | let args = Args::parse_from_reader(input.as_bytes(), supported_args, vec!["a".to_owned()]) 373 | .into_iter() 374 | .map(|s| s.into_string().unwrap()) 375 | .collect::>(); 376 | assert_eq!(args, ["-b", "value"]); 377 | } 378 | 379 | #[test] 380 | fn pair_ignored() { 381 | let to_ignore = Args::pair_ignored( 382 | vec![ 383 | "a".to_owned(), 384 | "bbb".to_owned(), 385 | "ddd".to_owned(), 386 | "e".to_owned(), 387 | ], 388 | &vec![ 389 | (Some("aaa".to_owned()), Some("a".to_owned())), 390 | (Some("bbb".to_owned()), Some("b".to_owned())), 391 | (Some("ccc".to_owned()), Some("c".to_owned())), 392 | (Some("ddd".to_owned()), None), 393 | (None, Some("e".to_owned())), 394 | ], 395 | ); 396 | 397 | let extended: HashSet = HashSet::from_iter(to_ignore); 398 | let expected: HashSet = HashSet::from([ 399 | "aaa".to_owned(), 400 | "a".to_owned(), 401 | "bbb".to_owned(), 402 | "b".to_owned(), 403 | "ddd".to_owned(), 404 | "e".to_owned(), 405 | ]); 406 | 407 | assert_eq!(extended, expected); 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /src/editor.rs: -------------------------------------------------------------------------------- 1 | use crate::args::{EDITOR_ENV, IGREP_EDITOR_ENV, VISUAL_ENV}; 2 | use anyhow::{anyhow, Result}; 3 | use clap::ValueEnum; 4 | use itertools::Itertools; 5 | use std::{ 6 | fmt::{self, Debug, Display, Formatter}, 7 | process::{Child, Command}, 8 | }; 9 | use strum::Display; 10 | 11 | #[derive(Display, Default, PartialEq, Eq, Copy, Clone, Debug, ValueEnum)] 12 | #[strum(serialize_all = "lowercase")] 13 | pub enum Editor { 14 | #[default] 15 | Vim, 16 | Neovim, 17 | Nvim, 18 | Nano, 19 | Code, 20 | Vscode, 21 | CodeInsiders, 22 | Emacs, 23 | Emacsclient, 24 | Hx, 25 | Helix, 26 | Subl, 27 | SublimeText, 28 | Micro, 29 | Intellij, 30 | Goland, 31 | Pycharm, 32 | Less, 33 | } 34 | 35 | #[derive(Debug)] 36 | pub enum EditorCommand { 37 | Builtin(Editor), 38 | Custom(String, String), 39 | } 40 | 41 | impl EditorCommand { 42 | pub fn new(custom_command: Option, editor_cli: Option) -> Result { 43 | if let Some(custom_command) = custom_command { 44 | let (program, args) = custom_command.split_once(' ').ok_or( 45 | anyhow!("Expected program and its arguments") 46 | .context(format!("Incorrect editor command: '{custom_command}'")), 47 | )?; 48 | 49 | if args.matches("{file_name}").count() != 1 { 50 | return Err(anyhow!("Expected one occurence of '{{file_name}}'.") 51 | .context(format!("Incorrect editor command: '{custom_command}'"))); 52 | } 53 | 54 | if args.matches("{line_number}").count() != 1 { 55 | return Err(anyhow!("Expected one occurence of '{{line_number}}'.") 56 | .context(format!("Incorrect editor command: '{custom_command}'"))); 57 | } 58 | 59 | return Ok(EditorCommand::Custom(program.into(), args.into())); 60 | } 61 | 62 | let add_error_context = |e: String, env_value: String, env_name: &str| { 63 | let possible_variants = Editor::value_variants() 64 | .iter() 65 | .map(Editor::to_string) 66 | .join(", "); 67 | anyhow!(e).context(format!( 68 | "\"{env_value}\" read from ${env_name}, possible variants: [{possible_variants}]", 69 | )) 70 | }; 71 | 72 | let read_from_env = |name| { 73 | std::env::var(name).ok().map(|value| { 74 | Editor::from_str(&extract_editor_name(&value), false) 75 | .map_err(|error| add_error_context(error, value, name)) 76 | }) 77 | }; 78 | 79 | Ok(EditorCommand::Builtin( 80 | editor_cli 81 | .map(Ok) 82 | .or_else(|| read_from_env(IGREP_EDITOR_ENV)) 83 | .or_else(|| read_from_env(VISUAL_ENV)) 84 | .or_else(|| read_from_env(EDITOR_ENV)) 85 | .unwrap_or(Ok(Editor::default()))?, 86 | )) 87 | } 88 | 89 | pub fn spawn(&self, file_name: &str, line_number: u64) -> Result { 90 | let path = which::which(self.program())?; 91 | let mut command = Command::new(path); 92 | command.args(self.args(file_name, line_number)); 93 | command.spawn().map_err(anyhow::Error::from) 94 | } 95 | 96 | fn program(&self) -> &str { 97 | match self { 98 | EditorCommand::Builtin(editor) => match editor { 99 | Editor::Vim => "vim", 100 | Editor::Neovim | Editor::Nvim => "nvim", 101 | Editor::Nano => "nano", 102 | Editor::Code | Editor::Vscode => "code", 103 | Editor::CodeInsiders => "code-insiders", 104 | Editor::Emacs => "emacs", 105 | Editor::Emacsclient => "emacsclient", 106 | Editor::Hx => "hx", 107 | Editor::Helix => "helix", 108 | Editor::Subl | Editor::SublimeText => "subl", 109 | Editor::Micro => "micro", 110 | Editor::Intellij => "idea", 111 | Editor::Goland => "goland", 112 | Editor::Pycharm => "pycharm", 113 | Editor::Less => "less", 114 | }, 115 | EditorCommand::Custom(program, _) => program, 116 | } 117 | } 118 | 119 | fn args(&self, file_name: &str, line_number: u64) -> Box> { 120 | match self { 121 | EditorCommand::Builtin(editor) => match editor { 122 | Editor::Vim 123 | | Editor::Neovim 124 | | Editor::Nvim 125 | | Editor::Nano 126 | | Editor::Micro 127 | | Editor::Less => { 128 | Box::new([format!("+{line_number}"), file_name.into()].into_iter()) 129 | } 130 | Editor::Code | Editor::Vscode | Editor::CodeInsiders => { 131 | Box::new(["-g".into(), format!("{file_name}:{line_number}")].into_iter()) 132 | } 133 | Editor::Emacs | Editor::Emacsclient => Box::new( 134 | ["-nw".into(), format!("+{line_number}"), file_name.into()].into_iter(), 135 | ), 136 | Editor::Hx | Editor::Helix | Editor::Subl | Editor::SublimeText => { 137 | Box::new([format!("{file_name}:{line_number}")].into_iter()) 138 | } 139 | Editor::Intellij | Editor::Goland | Editor::Pycharm => Box::new( 140 | ["--line".into(), format!("{line_number}"), file_name.into()].into_iter(), 141 | ), 142 | }, 143 | EditorCommand::Custom(_, args) => { 144 | let args = args.replace("{file_name}", file_name); 145 | let args = args.replace("{line_number}", &line_number.to_string()); 146 | 147 | let args = args.split_whitespace().map(ToOwned::to_owned).collect_vec(); 148 | Box::new(args.into_iter()) 149 | } 150 | } 151 | } 152 | } 153 | 154 | impl Display for EditorCommand { 155 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 156 | write!(f, "{}", self.program()) 157 | } 158 | } 159 | 160 | fn extract_editor_name(input: &str) -> String { 161 | let mut split = input.rsplit('/'); 162 | split.next().unwrap().into() 163 | } 164 | 165 | #[cfg(test)] 166 | mod tests { 167 | use super::EditorCommand::Builtin; 168 | use super::*; 169 | use crate::args::EditorOpt; 170 | use clap::Parser; 171 | use lazy_static::lazy_static; 172 | use test_case::test_case; 173 | 174 | lazy_static! { 175 | static ref SERIAL_TEST: std::sync::Mutex<()> = Default::default(); 176 | } 177 | 178 | #[test_case("non_builtin_editor" => matches Err(_); "editor name only")] 179 | #[test_case("non_builtin_editor {file_name}" => matches Err(_); "no line number")] 180 | #[test_case("non_builtin_editor {line_number}" => matches Err(_); "no file name")] 181 | #[test_case("non_builtin_editor {file_name} {file_name} {line_number}" => matches Err(_); "file name twice")] 182 | #[test_case("non_builtin_editor {file_name} {line_number} {line_number}" => matches Err(_); "line number twice")] 183 | #[test_case("non_builtin_editor{file_name} {line_number}" => matches Err(_); "program not separated from arg")] 184 | #[test_case("non_builtin_editor {file_name}:{line_number}" => matches Ok(_); "correct command with one arg")] 185 | #[test_case("non_builtin_editor {file_name} {line_number}" => matches Ok(_); "correct command with two args")] 186 | fn parsing_custom_command(command: &str) -> Result { 187 | EditorCommand::new(Some(command.into()), None) 188 | } 189 | 190 | #[test_case(Some("nano"), Some("vim"), None, Some("neovim") => matches Ok(Builtin(Editor::Nano)); "cli")] 191 | #[test_case(None, Some("nano"), None, Some("neovim") => matches Ok(Builtin(Editor::Nano)); "igrep env")] 192 | #[test_case(None, None, Some("nano"), Some("helix") => matches Ok(Builtin(Editor::Nano)); "visual env")] 193 | #[test_case(None, None, None, Some("nano") => matches Ok(Builtin(Editor::Nano)); "editor env")] 194 | #[test_case(Some("unsupported-editor"), None, None, None => matches Err(_); "unsupported cli")] 195 | #[test_case(None, Some("unsupported-editor"), None, None => matches Err(_); "unsupported igrep env")] 196 | #[test_case(None, None, None, Some("unsupported-editor") => matches Err(_); "unsupported editor env")] 197 | #[test_case(None, None, None, None => matches Ok(Builtin(Editor::Vim)); "default editor")] 198 | #[test_case(None, Some("/usr/bin/nano"), None, None => matches Ok(Builtin(Editor::Nano)); "igrep env path")] 199 | #[test_case(None, None, None, Some("/usr/bin/nano") => matches Ok(Builtin(Editor::Nano)); "editor env path")] 200 | fn editor_options_precedence( 201 | cli_option: Option<&str>, 202 | igrep_editor_env: Option<&str>, 203 | visual_env: Option<&str>, 204 | editor_env: Option<&str>, 205 | ) -> Result { 206 | let _guard = SERIAL_TEST.lock().unwrap(); 207 | std::env::remove_var(IGREP_EDITOR_ENV); 208 | std::env::remove_var(VISUAL_ENV); 209 | std::env::remove_var(EDITOR_ENV); 210 | 211 | let opt = if let Some(cli_option) = cli_option { 212 | EditorOpt::try_parse_from(["test", "--editor", cli_option]) 213 | } else { 214 | EditorOpt::try_parse_from(["test"]) 215 | }; 216 | 217 | if let Some(igrep_editor_env) = igrep_editor_env { 218 | std::env::set_var(IGREP_EDITOR_ENV, igrep_editor_env); 219 | } 220 | 221 | if let Some(visual_env) = visual_env { 222 | std::env::set_var(VISUAL_ENV, visual_env); 223 | } 224 | 225 | if let Some(editor_env) = editor_env { 226 | std::env::set_var(EDITOR_ENV, editor_env); 227 | } 228 | 229 | EditorCommand::new(None, opt?.editor) 230 | } 231 | 232 | const FILE_NAME: &str = "file_name"; 233 | const LINE_NUMBER: u64 = 123; 234 | 235 | #[test] 236 | fn custom_command() { 237 | let editor_command = EditorCommand::new( 238 | Some("non_builtin_editor -@{file_name} {line_number}".into()), 239 | None, 240 | ) 241 | .unwrap(); 242 | 243 | assert_eq!(editor_command.program(), "non_builtin_editor"); 244 | assert_eq!( 245 | editor_command.args(FILE_NAME, LINE_NUMBER).collect_vec(), 246 | vec![format!("-@{FILE_NAME}"), LINE_NUMBER.to_string()] 247 | ) 248 | } 249 | 250 | #[test_case(Editor::Vim => format!("vim +{LINE_NUMBER} {FILE_NAME}"); "vim command")] 251 | #[test_case(Editor::Neovim => format!("nvim +{LINE_NUMBER} {FILE_NAME}"); "neovim command")] 252 | #[test_case(Editor::Nvim => format!("nvim +{LINE_NUMBER} {FILE_NAME}"); "nvim command")] 253 | #[test_case(Editor::Nano => format!("nano +{LINE_NUMBER} {FILE_NAME}"); "nano command")] 254 | #[test_case(Editor::Code => format!("code -g {FILE_NAME}:{LINE_NUMBER}"); "code command")] 255 | #[test_case(Editor::Vscode => format!("code -g {FILE_NAME}:{LINE_NUMBER}"); "vscode command")] 256 | #[test_case(Editor::CodeInsiders => format!("code-insiders -g {FILE_NAME}:{LINE_NUMBER}"); "code-insiders command")] 257 | #[test_case(Editor::Emacs => format!("emacs -nw +{LINE_NUMBER} {FILE_NAME}"); "emacs command")] 258 | #[test_case(Editor::Emacsclient => format!("emacsclient -nw +{LINE_NUMBER} {FILE_NAME}"); "emacsclient command")] 259 | #[test_case(Editor::Hx => format!("hx {FILE_NAME}:{LINE_NUMBER}"); "hx command")] 260 | #[test_case(Editor::Helix => format!("helix {FILE_NAME}:{LINE_NUMBER}"); "helix command")] 261 | #[test_case(Editor::Subl => format!("subl {FILE_NAME}:{LINE_NUMBER}"); "subl command")] 262 | #[test_case(Editor::SublimeText => format!("subl {FILE_NAME}:{LINE_NUMBER}"); "sublime text command")] 263 | #[test_case(Editor::Micro => format!("micro +{LINE_NUMBER} {FILE_NAME}"); "micro command")] 264 | #[test_case(Editor::Intellij => format!("idea --line {LINE_NUMBER} {FILE_NAME}"); "intellij command")] 265 | #[test_case(Editor::Goland => format!("goland --line {LINE_NUMBER} {FILE_NAME}"); "goland command")] 266 | #[test_case(Editor::Pycharm => format!("pycharm --line {LINE_NUMBER} {FILE_NAME}"); "pycharm command")] 267 | #[test_case(Editor::Less => format!("less +{LINE_NUMBER} {FILE_NAME}"); "less command")] 268 | fn builtin_editor_command(editor: Editor) -> String { 269 | let editor_command = EditorCommand::new(None, Some(editor)).unwrap(); 270 | format!( 271 | "{} {}", 272 | editor_command.program(), 273 | editor_command.args(FILE_NAME, LINE_NUMBER).join(" ") 274 | ) 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/ig.rs: -------------------------------------------------------------------------------- 1 | pub mod file_entry; 2 | pub mod grep_match; 3 | pub mod search_config; 4 | mod searcher; 5 | mod sink; 6 | 7 | use std::process::ExitStatus; 8 | use std::sync::mpsc; 9 | 10 | use crate::editor::EditorCommand; 11 | use crate::ui::result_list::ResultList; 12 | pub use search_config::SearchConfig; 13 | pub use search_config::SortKey; 14 | use searcher::Event; 15 | 16 | use self::file_entry::FileEntry; 17 | 18 | #[derive(PartialEq, Eq)] 19 | pub enum State { 20 | Idle, 21 | Searching, 22 | OpenFile(bool), 23 | Error(String), 24 | Exit, 25 | } 26 | 27 | pub struct Ig { 28 | tx: mpsc::Sender, 29 | rx: mpsc::Receiver, 30 | state: State, 31 | editor_command: EditorCommand, 32 | } 33 | 34 | impl Ig { 35 | pub fn new(editor_command: EditorCommand) -> Self { 36 | let (tx, rx) = mpsc::channel(); 37 | 38 | Self { 39 | tx, 40 | rx, 41 | state: State::Idle, 42 | editor_command, 43 | } 44 | } 45 | 46 | fn try_spawn_editor(&self, file_name: &str, line_number: u64) -> anyhow::Result { 47 | let mut editor_process = self.editor_command.spawn(file_name, line_number)?; 48 | editor_process.wait().map_err(anyhow::Error::from) 49 | } 50 | 51 | pub fn open_file_if_requested(&mut self, selected_entry: Option<(String, u64)>) { 52 | if let State::OpenFile(idle) = self.state { 53 | if let Some((ref file_name, line_number)) = selected_entry { 54 | match self.try_spawn_editor(file_name, line_number) { 55 | Ok(_) => self.state = if idle { State::Idle } else { State::Searching }, 56 | Err(_) => { 57 | self.state = State::Error(format!( 58 | "Failed to open editor '{}'. Is it installed?", 59 | self.editor_command, 60 | )) 61 | } 62 | } 63 | } else { 64 | self.state = if idle { State::Idle } else { State::Searching }; 65 | } 66 | } 67 | } 68 | 69 | pub fn handle_searcher_event(&mut self) -> Option { 70 | while let Ok(event) = self.rx.try_recv() { 71 | match event { 72 | Event::NewEntry(e) => return Some(e), 73 | Event::SearchingFinished => self.state = State::Idle, 74 | Event::Error => self.state = State::Exit, 75 | } 76 | } 77 | 78 | None 79 | } 80 | 81 | pub fn search(&mut self, search_config: SearchConfig, result_list: &mut ResultList) { 82 | if self.state == State::Idle { 83 | *result_list = ResultList::default(); 84 | self.state = State::Searching; 85 | searcher::search(search_config, self.tx.clone()); 86 | } 87 | } 88 | 89 | pub fn open_file(&mut self) { 90 | self.state = State::OpenFile(self.state == State::Idle); 91 | } 92 | 93 | pub fn exit(&mut self) { 94 | self.state = State::Exit; 95 | } 96 | 97 | pub fn is_idle(&self) -> bool { 98 | self.state == State::Idle 99 | } 100 | 101 | pub fn is_searching(&self) -> bool { 102 | self.state == State::Searching 103 | } 104 | 105 | pub fn last_error(&self) -> Option<&str> { 106 | if let State::Error(err) = &self.state { 107 | Some(err) 108 | } else { 109 | None 110 | } 111 | } 112 | 113 | pub fn exit_requested(&self) -> bool { 114 | self.state == State::Exit 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/ig/file_entry.rs: -------------------------------------------------------------------------------- 1 | use super::grep_match::GrepMatch; 2 | 3 | pub enum EntryType { 4 | Header(String), 5 | Match(u64, String, Vec<(usize, usize)>), 6 | } 7 | 8 | pub struct FileEntry(Vec); 9 | 10 | impl FileEntry { 11 | pub fn new(name: String, matches: Vec) -> Self { 12 | Self( 13 | std::iter::once(EntryType::Header(name)) 14 | .chain(matches.into_iter().map(|m| { 15 | let mut text = String::new(); 16 | let mut ofs = m.match_offsets; 17 | let mut pos = 0; 18 | for c in m.text.chars() { 19 | pos += 1; 20 | if c != '\t' { 21 | text.push(c); 22 | } else { 23 | text.push_str(" "); 24 | for p in &mut ofs { 25 | if p.0 >= pos { 26 | p.0 += 1; 27 | p.1 += 1; 28 | } 29 | } 30 | } 31 | } 32 | EntryType::Match(m.line_number, text, ofs) 33 | })) 34 | .collect(), 35 | ) 36 | } 37 | 38 | pub fn get_matches_count(&self) -> usize { 39 | self.0 40 | .iter() 41 | .filter(|&e| matches!(e, EntryType::Match(_, _, _))) 42 | .count() 43 | } 44 | 45 | pub fn get_entries(self) -> Vec { 46 | self.0 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ig/grep_match.rs: -------------------------------------------------------------------------------- 1 | pub struct GrepMatch { 2 | pub line_number: u64, 3 | pub text: String, 4 | pub match_offsets: Vec<(usize, usize)>, 5 | } 6 | 7 | impl GrepMatch { 8 | pub fn new(line_number: u64, text: String, match_offsets: Vec<(usize, usize)>) -> Self { 9 | Self { 10 | line_number, 11 | text, 12 | match_offsets, 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/ig/search_config.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::ValueEnum; 3 | use ignore::{ 4 | overrides::{Override, OverrideBuilder}, 5 | types::{Types, TypesBuilder}, 6 | }; 7 | use std::path::PathBuf; 8 | use strum::Display; 9 | 10 | #[derive(Clone, ValueEnum, Display, Debug, PartialEq)] 11 | pub enum SortKey { 12 | Path, 13 | Modified, 14 | Created, 15 | Accessed, 16 | } 17 | 18 | #[derive(Clone)] 19 | pub struct SearchConfig { 20 | pub pattern: String, 21 | pub paths: Vec, 22 | pub case_insensitive: bool, 23 | pub case_smart: bool, 24 | pub overrides: Override, 25 | pub types: Types, 26 | pub search_hidden: bool, 27 | pub follow_links: bool, 28 | pub word_regexp: bool, 29 | pub sort_by: Option, 30 | pub sort_by_reversed: Option, 31 | } 32 | 33 | impl SearchConfig { 34 | pub fn from(pattern: String, paths: Vec) -> Result { 35 | let mut builder = TypesBuilder::new(); 36 | builder.add_defaults(); 37 | let types = builder.build()?; 38 | 39 | Ok(Self { 40 | pattern, 41 | paths, 42 | case_insensitive: false, 43 | case_smart: false, 44 | overrides: Override::empty(), 45 | types, 46 | search_hidden: false, 47 | follow_links: false, 48 | word_regexp: false, 49 | sort_by: None, 50 | sort_by_reversed: None, 51 | }) 52 | } 53 | 54 | pub fn case_insensitive(mut self, case_insensitive: bool) -> Self { 55 | self.case_insensitive = case_insensitive; 56 | self 57 | } 58 | 59 | pub fn case_smart(mut self, case_smart: bool) -> Self { 60 | self.case_smart = case_smart; 61 | self 62 | } 63 | 64 | pub fn globs(mut self, globs: Vec) -> Result { 65 | let mut builder = OverrideBuilder::new(std::env::current_dir()?); 66 | for glob in globs { 67 | builder.add(&glob)?; 68 | } 69 | self.overrides = builder.build()?; 70 | Ok(self) 71 | } 72 | 73 | pub fn file_types( 74 | mut self, 75 | file_types: Vec, 76 | file_types_not: Vec, 77 | ) -> Result { 78 | let mut builder = TypesBuilder::new(); 79 | builder.add_defaults(); 80 | for file_type in file_types { 81 | builder.select(&file_type); 82 | } 83 | for file_type in file_types_not { 84 | builder.negate(&file_type); 85 | } 86 | self.types = builder.build()?; 87 | Ok(self) 88 | } 89 | 90 | pub fn sort_by( 91 | mut self, 92 | sort_by: Option, 93 | sort_by_reversed: Option, 94 | ) -> Result { 95 | self.sort_by = sort_by; 96 | self.sort_by_reversed = sort_by_reversed; 97 | Ok(self) 98 | } 99 | 100 | pub fn search_hidden(mut self, search_hidden: bool) -> Self { 101 | self.search_hidden = search_hidden; 102 | self 103 | } 104 | 105 | pub fn follow_links(mut self, follow_links: bool) -> Self { 106 | self.follow_links = follow_links; 107 | self 108 | } 109 | 110 | pub fn word_regexp(mut self, word_regexp: bool) -> Self { 111 | self.word_regexp = word_regexp; 112 | self 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/ig/searcher.rs: -------------------------------------------------------------------------------- 1 | use super::{file_entry::FileEntry, sink::MatchesSink, SearchConfig}; 2 | use crate::ig::SortKey; 3 | use grep::{ 4 | matcher::LineTerminator, 5 | regex::RegexMatcherBuilder, 6 | searcher::{BinaryDetection, SearcherBuilder}, 7 | }; 8 | use ignore::WalkBuilder; 9 | use std::cmp::Ordering; 10 | use std::{path::Path, sync::mpsc}; 11 | 12 | pub enum Event { 13 | NewEntry(FileEntry), 14 | SearchingFinished, 15 | Error, 16 | } 17 | 18 | pub fn search(config: SearchConfig, tx: mpsc::Sender) { 19 | std::thread::spawn(move || { 20 | let path_searchers = config 21 | .paths 22 | .clone() 23 | .into_iter() 24 | .map(|path| { 25 | let config = config.clone(); 26 | let tx = tx.clone(); 27 | std::thread::spawn(move || run(&path, config, tx)) 28 | }) 29 | .collect::>(); 30 | 31 | for searcher in path_searchers { 32 | if searcher.join().is_err() { 33 | tx.send(Event::Error).ok(); 34 | return; 35 | } 36 | } 37 | 38 | tx.send(Event::SearchingFinished).ok(); 39 | }); 40 | } 41 | 42 | fn run(path: &Path, config: SearchConfig, tx: mpsc::Sender) { 43 | let grep_searcher = SearcherBuilder::new() 44 | .binary_detection(BinaryDetection::quit(b'\x00')) 45 | .line_terminator(LineTerminator::byte(b'\n')) 46 | .line_number(true) 47 | .multi_line(false) 48 | .build(); 49 | 50 | let matcher = RegexMatcherBuilder::new() 51 | .line_terminator(Some(b'\n')) 52 | .case_insensitive(config.case_insensitive) 53 | .case_smart(config.case_smart) 54 | .word(config.word_regexp) 55 | .build(&config.pattern) 56 | .expect("Cannot build RegexMatcher"); 57 | 58 | let mut builder = WalkBuilder::new(path); 59 | let walker = builder 60 | .overrides(config.overrides.clone()) 61 | .types(config.types.clone()) 62 | .hidden(!config.search_hidden) 63 | .follow_links(config.follow_links); 64 | 65 | // if no sort is specified the faster parallel search is used 66 | if config.sort_by.is_none() && config.sort_by_reversed.is_none() { 67 | let walk_parallel = walker.build_parallel(); 68 | 69 | walk_parallel.run(move || { 70 | let tx = tx.clone(); 71 | let matcher = matcher.clone(); 72 | let mut grep_searcher = grep_searcher.clone(); 73 | 74 | Box::new(move |result| { 75 | let dir_entry = match result { 76 | Ok(entry) => { 77 | if !entry.file_type().is_some_and(|ft| ft.is_file()) { 78 | return ignore::WalkState::Continue; 79 | } 80 | entry 81 | } 82 | Err(_) => return ignore::WalkState::Continue, 83 | }; 84 | let mut matches_in_entry = Vec::new(); 85 | let sr = MatchesSink::new(&matcher, &mut matches_in_entry); 86 | grep_searcher 87 | .search_path(&matcher, dir_entry.path(), sr) 88 | .ok(); 89 | 90 | if !matches_in_entry.is_empty() { 91 | tx.send(Event::NewEntry(FileEntry::new( 92 | dir_entry.path().to_string_lossy().into_owned(), 93 | matches_in_entry, 94 | ))) 95 | .ok(); 96 | } 97 | 98 | ignore::WalkState::Continue 99 | }) 100 | }); 101 | } else { 102 | let mut walk_sorted = walker; 103 | let reversed = config.sort_by_reversed.is_some(); 104 | let local_sort_key = if config.sort_by.is_some() { 105 | config.sort_by 106 | } else { 107 | config.sort_by_reversed 108 | }; 109 | 110 | match local_sort_key { 111 | Some(SortKey::Path) => { 112 | walk_sorted = 113 | walk_sorted.sort_by_file_name( 114 | move |a, b| { 115 | if !reversed { 116 | a.cmp(b) 117 | } else { 118 | b.cmp(a) 119 | } 120 | }, 121 | ); 122 | } 123 | Some(SortKey::Modified) => { 124 | let compare_modified = move |a: &Path, b: &Path| -> Ordering { 125 | let ma = a.metadata().expect("cannot get metadata from file"); 126 | let mb = b.metadata().expect("cannot get metadata from file"); 127 | 128 | let ta = ma.modified().expect("cannot get time of file"); 129 | let tb = mb.modified().expect("cannot get time of file"); 130 | 131 | if !reversed { 132 | ta.cmp(&tb) 133 | } else { 134 | tb.cmp(&ta) 135 | } 136 | }; 137 | 138 | walk_sorted = walk_sorted.sort_by_file_path(compare_modified); 139 | } 140 | Some(SortKey::Created) => { 141 | let compare_created = move |a: &Path, b: &Path| -> Ordering { 142 | let ma = a.metadata().expect("cannot get metadata from file"); 143 | let mb = b.metadata().expect("cannot get metadata from file"); 144 | 145 | let ta = ma.created().expect("cannot get time of file"); 146 | let tb = mb.created().expect("cannot get time of file"); 147 | 148 | if !reversed { 149 | ta.cmp(&tb) 150 | } else { 151 | tb.cmp(&ta) 152 | } 153 | }; 154 | 155 | walk_sorted = walk_sorted.sort_by_file_path(compare_created); 156 | } 157 | Some(SortKey::Accessed) => { 158 | let compare_accessed = move |a: &Path, b: &Path| -> Ordering { 159 | let ma = a.metadata().expect("cannot get metadata from file"); 160 | let mb = b.metadata().expect("cannot get metadata from file"); 161 | 162 | let ta = ma.accessed().expect("cannot get time of file"); 163 | let tb = mb.accessed().expect("cannot get time of file"); 164 | 165 | if !reversed { 166 | ta.cmp(&tb) 167 | } else { 168 | tb.cmp(&ta) 169 | } 170 | }; 171 | 172 | walk_sorted = walk_sorted.sort_by_file_path(compare_accessed); 173 | } 174 | _ => { 175 | // unknown order specified 176 | panic!("unknown sort order specified"); 177 | } 178 | } 179 | 180 | for result in walk_sorted.build() { 181 | let tx = tx.clone(); 182 | let matcher = matcher.clone(); 183 | let mut grep_searcher = grep_searcher.clone(); 184 | 185 | let dir_entry = match result { 186 | Ok(entry) => { 187 | if !entry.file_type().is_some_and(|ft| ft.is_file()) { 188 | continue; 189 | } 190 | entry 191 | } 192 | Err(_) => continue, 193 | }; 194 | let mut matches_in_entry = Vec::new(); 195 | let sr = MatchesSink::new(&matcher, &mut matches_in_entry); 196 | grep_searcher 197 | .search_path(&matcher, dir_entry.path(), sr) 198 | .ok(); 199 | 200 | if !matches_in_entry.is_empty() { 201 | tx.send(Event::NewEntry(FileEntry::new( 202 | dir_entry.path().to_string_lossy().into_owned(), 203 | matches_in_entry, 204 | ))) 205 | .ok(); 206 | } 207 | 208 | continue; 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/ig/sink.rs: -------------------------------------------------------------------------------- 1 | use grep::{ 2 | matcher::Matcher, 3 | searcher::{Searcher, Sink, SinkMatch}, 4 | }; 5 | 6 | use super::grep_match::GrepMatch; 7 | 8 | pub(crate) struct MatchesSink<'a, M> 9 | where 10 | M: Matcher, 11 | { 12 | matcher: M, 13 | matches_in_entry: &'a mut Vec, 14 | } 15 | 16 | impl<'a, M> MatchesSink<'a, M> 17 | where 18 | M: Matcher, 19 | { 20 | pub(crate) fn new(matcher: M, matches_in_entry: &'a mut Vec) -> Self { 21 | Self { 22 | matcher, 23 | matches_in_entry, 24 | } 25 | } 26 | } 27 | 28 | impl Sink for MatchesSink<'_, M> 29 | where 30 | M: Matcher, 31 | { 32 | type Error = std::io::Error; 33 | 34 | fn matched(&mut self, _: &Searcher, sink_match: &SinkMatch) -> Result { 35 | let line_number = sink_match 36 | .line_number() 37 | .ok_or(std::io::ErrorKind::InvalidData)?; 38 | let text = std::str::from_utf8(sink_match.bytes()); 39 | 40 | let mut offsets = vec![]; 41 | self.matcher 42 | .find_iter(sink_match.bytes(), |m| { 43 | offsets.push((m.start(), m.end())); 44 | true 45 | }) 46 | .ok(); 47 | 48 | if let Ok(t) = text { 49 | self.matches_in_entry 50 | .push(GrepMatch::new(line_number, t.into(), offsets)); 51 | }; 52 | 53 | Ok(true) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod args; 3 | pub mod editor; 4 | pub mod ig; 5 | pub mod ui; 6 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use igrep::{ 3 | app::App, 4 | args::Args, 5 | editor::EditorCommand, 6 | ig, 7 | ui::{ 8 | context_viewer::ContextViewer, 9 | theme::{dark::Dark, light::Light, Theme, ThemeVariant}, 10 | }, 11 | }; 12 | use std::io::Write; 13 | 14 | fn main() -> Result<()> { 15 | let args = Args::parse_cli_and_config_file(); 16 | 17 | if args.type_list { 18 | use itertools::Itertools; 19 | let mut builder = ignore::types::TypesBuilder::new(); 20 | builder.add_defaults(); 21 | for definition in builder.definitions() { 22 | writeln!( 23 | std::io::stdout(), 24 | "{}: {}", 25 | definition.name(), 26 | definition.globs().iter().format(", "), 27 | )?; 28 | } 29 | return Ok(()); 30 | } 31 | 32 | let paths = if args.paths.is_empty() { 33 | vec!["./".into()] 34 | } else { 35 | args.paths 36 | }; 37 | 38 | let search_config = ig::SearchConfig::from(args.pattern.unwrap(), paths)? 39 | .case_insensitive(args.ignore_case) 40 | .case_smart(args.smart_case) 41 | .search_hidden(args.search_hidden) 42 | .follow_links(args.follow_links) 43 | .word_regexp(args.word_regexp) 44 | .globs(args.glob)? 45 | .file_types(args.type_matching, args.type_not)? 46 | .sort_by(args.sort_by, args.sort_by_reverse)?; 47 | 48 | let theme: Box = match args.theme { 49 | ThemeVariant::Light => Box::new(Light), 50 | ThemeVariant::Dark => Box::new(Dark), 51 | }; 52 | let mut app = App::new( 53 | search_config, 54 | EditorCommand::new(args.editor.custom_command, args.editor.editor)?, 55 | ContextViewer::new(args.context_viewer), 56 | theme, 57 | ); 58 | app.run()?; 59 | 60 | Ok(()) 61 | } 62 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | pub mod bottom_bar; 2 | pub mod context_viewer; 3 | pub mod input_handler; 4 | pub mod keymap_popup; 5 | pub mod result_list; 6 | pub mod search_popup; 7 | pub mod theme; 8 | 9 | mod scroll_offset_list; 10 | -------------------------------------------------------------------------------- /src/ui/bottom_bar.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 3 | style::Style, 4 | text::Span, 5 | widgets::Paragraph, 6 | Frame, 7 | }; 8 | 9 | use crate::ig::Ig; 10 | 11 | use super::{ 12 | input_handler::{InputHandler, InputState}, 13 | result_list::ResultList, 14 | theme::Theme, 15 | }; 16 | 17 | pub fn draw( 18 | frame: &mut Frame, 19 | area: Rect, 20 | result_list: &ResultList, 21 | ig: &Ig, 22 | input_handler: &InputHandler, 23 | theme: &dyn Theme, 24 | ) { 25 | let selected_info_text = render_selected_info_text(result_list); 26 | 27 | let hsplit = Layout::default() 28 | .direction(Direction::Horizontal) 29 | .constraints( 30 | [ 31 | Constraint::Length(12), 32 | Constraint::Min(1), 33 | Constraint::Length(2), 34 | Constraint::Length(selected_info_text.len() as u16), 35 | ] 36 | .as_ref(), 37 | ) 38 | .split(area); 39 | 40 | draw_app_status(frame, hsplit[0], ig, theme); 41 | draw_search_result_summary(frame, hsplit[1], ig, result_list, theme); 42 | draw_current_input(frame, hsplit[2], input_handler, theme); 43 | draw_selected_info(frame, hsplit[3], selected_info_text, theme); 44 | } 45 | 46 | fn draw_app_status(frame: &mut Frame, area: Rect, ig: &Ig, theme: &dyn Theme) { 47 | let (app_status_text, app_status_style) = if ig.is_searching() { 48 | ("SEARCHING", theme.searching_state_style()) 49 | } else if ig.last_error().is_some() { 50 | ("ERROR", theme.error_state_style()) 51 | } else { 52 | ("FINISHED", theme.finished_state_style()) 53 | }; 54 | let app_status = Span::styled(app_status_text, app_status_style); 55 | 56 | frame.render_widget( 57 | Paragraph::new(app_status) 58 | .style(Style::default().bg(app_status_style.bg.expect("Background not set"))) 59 | .alignment(Alignment::Center), 60 | area, 61 | ); 62 | } 63 | 64 | fn draw_search_result_summary( 65 | frame: &mut Frame, 66 | area: Rect, 67 | ig: &Ig, 68 | result_list: &ResultList, 69 | theme: &dyn Theme, 70 | ) { 71 | let search_result = Span::raw(if ig.is_searching() { 72 | "".into() 73 | } else if let Some(err) = ig.last_error() { 74 | format!(" {err}") 75 | } else { 76 | let total_no_of_matches = result_list.get_total_number_of_matches(); 77 | if total_no_of_matches == 0 { 78 | " No matches found.".into() 79 | } else { 80 | let no_of_files = result_list.get_total_number_of_file_entries(); 81 | 82 | let matches_str = if total_no_of_matches == 1 { 83 | "match" 84 | } else { 85 | "matches" 86 | }; 87 | let files_str = if no_of_files == 1 { "file" } else { "files" }; 88 | 89 | let filtered_count = result_list.get_filtered_matches_count(); 90 | let filtered_str = if filtered_count != 0 { 91 | format!(" ({filtered_count} filtered out)") 92 | } else { 93 | String::default() 94 | }; 95 | 96 | format!(" Found {total_no_of_matches} {matches_str} in {no_of_files} {files_str}{filtered_str}.") 97 | } 98 | }); 99 | 100 | frame.render_widget( 101 | Paragraph::new(search_result) 102 | .style(theme.bottom_bar_style()) 103 | .alignment(Alignment::Left), 104 | area, 105 | ); 106 | } 107 | 108 | fn draw_current_input( 109 | frame: &mut Frame, 110 | area: Rect, 111 | input_handler: &InputHandler, 112 | theme: &dyn Theme, 113 | ) { 114 | let (current_input_content, current_input_color) = match input_handler.get_state() { 115 | InputState::Valid => (String::default(), theme.bottom_bar_font_color()), 116 | InputState::Incomplete(input) => (input.to_owned(), theme.bottom_bar_font_color()), 117 | InputState::Invalid(input) => (input.to_owned(), theme.invalid_input_color()), 118 | }; 119 | let current_input = Span::styled( 120 | current_input_content, 121 | Style::default() 122 | .bg(theme.bottom_bar_color()) 123 | .fg(current_input_color), 124 | ); 125 | 126 | frame.render_widget( 127 | Paragraph::new(current_input) 128 | .style(theme.bottom_bar_style()) 129 | .alignment(Alignment::Right), 130 | area, 131 | ); 132 | } 133 | 134 | fn render_selected_info_text(result_list: &ResultList) -> String { 135 | let current_no_of_matches = result_list.get_current_number_of_matches(); 136 | let current_match_index = result_list.get_current_match_index(); 137 | let width = current_no_of_matches.to_string().len(); 138 | format!(" | {current_match_index: >width$}/{current_no_of_matches} ") 139 | } 140 | 141 | fn draw_selected_info( 142 | frame: &mut Frame, 143 | area: Rect, 144 | selected_info_text: String, 145 | theme: &dyn Theme, 146 | ) { 147 | let selected_info = Span::styled(selected_info_text, theme.bottom_bar_style()); 148 | 149 | frame.render_widget( 150 | Paragraph::new(selected_info) 151 | .style(theme.bottom_bar_style()) 152 | .alignment(Alignment::Right), 153 | area, 154 | ); 155 | } 156 | -------------------------------------------------------------------------------- /src/ui/context_viewer.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::BorrowMut, 3 | cmp::max, 4 | io::BufRead, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | use clap::ValueEnum; 9 | use itertools::Itertools; 10 | use ratatui::{ 11 | layout::{Constraint, Direction, Layout, Rect}, 12 | style::{Color, Style}, 13 | text::{Line, Span}, 14 | widgets::{Block, BorderType, Borders, Paragraph}, 15 | Frame, 16 | }; 17 | use syntect::{ 18 | easy::HighlightFile, 19 | highlighting::{self, ThemeSet}, 20 | parsing::SyntaxSet, 21 | }; 22 | 23 | use super::{result_list::ResultList, theme::Theme}; 24 | 25 | #[derive(Default, Clone, Debug, PartialEq, Eq, ValueEnum)] 26 | pub enum ContextViewerPosition { 27 | #[default] 28 | None, 29 | Vertical, 30 | Horizontal, 31 | } 32 | 33 | #[derive(Debug)] 34 | pub struct ContextViewer { 35 | highlighted_file_path: PathBuf, 36 | file_highlighted: Vec>, 37 | syntax_set: SyntaxSet, 38 | theme_set: ThemeSet, 39 | position: ContextViewerPosition, 40 | size: u16, 41 | } 42 | 43 | impl ContextViewer { 44 | const MIN_SIZE: u16 = 20; 45 | const MAX_SIZE: u16 = 80; 46 | const SIZE_CHANGE_DELTA: u16 = 5; 47 | 48 | pub fn new(position: ContextViewerPosition) -> Self { 49 | Self { 50 | highlighted_file_path: Default::default(), 51 | file_highlighted: Default::default(), 52 | syntax_set: SyntaxSet::load_defaults_newlines(), 53 | theme_set: highlighting::ThemeSet::load_defaults(), 54 | position, 55 | size: 50, 56 | } 57 | } 58 | 59 | pub fn toggle_vertical(&mut self) { 60 | match self.position { 61 | ContextViewerPosition::None => self.position = ContextViewerPosition::Vertical, 62 | ContextViewerPosition::Vertical => self.position = ContextViewerPosition::None, 63 | ContextViewerPosition::Horizontal => self.position = ContextViewerPosition::Vertical, 64 | } 65 | } 66 | 67 | pub fn toggle_horizontal(&mut self) { 68 | match self.position { 69 | ContextViewerPosition::None => self.position = ContextViewerPosition::Horizontal, 70 | ContextViewerPosition::Vertical => self.position = ContextViewerPosition::Horizontal, 71 | ContextViewerPosition::Horizontal => self.position = ContextViewerPosition::None, 72 | } 73 | } 74 | 75 | pub fn increase_size(&mut self) { 76 | self.size = (self.size + Self::SIZE_CHANGE_DELTA).min(Self::MAX_SIZE); 77 | } 78 | 79 | pub fn decrease_size(&mut self) { 80 | self.size = (self.size - Self::SIZE_CHANGE_DELTA).max(Self::MIN_SIZE); 81 | } 82 | 83 | pub fn update_if_needed(&mut self, file_path: impl AsRef, theme: &dyn Theme) { 84 | if self.position == ContextViewerPosition::None 85 | || self.highlighted_file_path == file_path.as_ref() 86 | { 87 | return; 88 | } 89 | 90 | self.highlighted_file_path = file_path.as_ref().into(); 91 | self.file_highlighted.clear(); 92 | 93 | let mut highlighter = HighlightFile::new( 94 | file_path, 95 | &self.syntax_set, 96 | &self.theme_set.themes[theme.context_viewer_theme()], 97 | ) 98 | .expect("Failed to create line highlighter"); 99 | let mut line = String::new(); 100 | 101 | while highlighter 102 | .reader 103 | .read_line(&mut line) 104 | .expect("Not valid UTF-8") 105 | > 0 106 | { 107 | let regions: Vec<(highlighting::Style, &str)> = highlighter 108 | .highlight_lines 109 | .highlight_line(&line, &self.syntax_set) 110 | .expect("Failed to highlight line"); 111 | 112 | let span_vec = regions 113 | .into_iter() 114 | .map(|(style, substring)| (style, substring.to_string())) 115 | .collect(); 116 | 117 | self.file_highlighted.push(span_vec); 118 | line.clear(); // read_line appends so we need to clear between lines 119 | } 120 | } 121 | 122 | pub fn split_view(&self, view_area: Rect) -> (Rect, Option) { 123 | match self.position { 124 | ContextViewerPosition::None => (view_area, None), 125 | ContextViewerPosition::Vertical => { 126 | let chunks = Layout::default() 127 | .direction(Direction::Horizontal) 128 | .constraints([ 129 | Constraint::Percentage(100 - self.size), 130 | Constraint::Percentage(self.size), 131 | ]) 132 | .split(view_area); 133 | 134 | let (left, right) = (chunks[0], chunks[1]); 135 | (left, Some(right)) 136 | } 137 | ContextViewerPosition::Horizontal => { 138 | let chunks = Layout::default() 139 | .direction(Direction::Vertical) 140 | .constraints([ 141 | Constraint::Percentage(100 - self.size), 142 | Constraint::Percentage(self.size), 143 | ]) 144 | .split(view_area); 145 | 146 | let (top, bottom) = (chunks[0], chunks[1]); 147 | (top, Some(bottom)) 148 | } 149 | } 150 | } 151 | 152 | pub fn draw(&self, frame: &mut Frame, area: Rect, result_list: &ResultList, theme: &dyn Theme) { 153 | let block_widget = Block::default() 154 | .borders(Borders::ALL) 155 | .border_type(BorderType::Rounded); 156 | 157 | if let Some((_, line_number)) = result_list.get_selected_entry() { 158 | let height = area.height as u64; 159 | let first_line_index = line_number.saturating_sub(height / 2); 160 | 161 | let paragraph_widget = Paragraph::new(self.get_styled_spans( 162 | first_line_index as usize, 163 | height as usize, 164 | area.width as usize, 165 | line_number as usize, 166 | theme, 167 | )) 168 | .block(block_widget); 169 | 170 | frame.render_widget(paragraph_widget, area); 171 | } else { 172 | frame.render_widget(block_widget, area); 173 | } 174 | } 175 | 176 | fn get_styled_spans( 177 | &self, 178 | first_line_index: usize, 179 | height: usize, 180 | width: usize, 181 | match_index: usize, 182 | theme: &dyn Theme, 183 | ) -> Vec> { 184 | let mut styled_spans = self 185 | .file_highlighted 186 | .iter() 187 | .skip(first_line_index.saturating_sub(1)) 188 | .take(height) 189 | .map(|line| { 190 | line.iter() 191 | .map(|(highlight_style, substring)| { 192 | let fg = highlight_style.foreground; 193 | let substring_without_tab = substring.replace('\t', " "); 194 | Span::styled( 195 | substring_without_tab, 196 | Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b)), 197 | ) 198 | }) 199 | .collect_vec() 200 | }) 201 | .map(Line::from) 202 | .collect_vec(); 203 | 204 | let match_offset = match_index - max(first_line_index, 1); 205 | let styled_line = &mut styled_spans[match_offset]; 206 | let line_width = styled_line.width(); 207 | let span_vec = &mut styled_line.spans; 208 | 209 | if line_width < width { 210 | span_vec.push(Span::raw(" ".repeat(width - line_width))); 211 | } 212 | 213 | for span in span_vec.iter_mut() { 214 | let current_style = span.style; 215 | span.borrow_mut().style = current_style.bg(theme.highlight_color()); 216 | } 217 | 218 | styled_spans 219 | } 220 | } 221 | 222 | #[cfg(test)] 223 | mod tests { 224 | use super::*; 225 | use test_case::test_case; 226 | 227 | #[test_case(ContextViewerPosition::None => ContextViewerPosition::Vertical)] 228 | #[test_case(ContextViewerPosition::Vertical => ContextViewerPosition::None)] 229 | #[test_case(ContextViewerPosition::Horizontal => ContextViewerPosition::Vertical)] 230 | fn toggle_vertical(initial_position: ContextViewerPosition) -> ContextViewerPosition { 231 | let mut context_viewer = ContextViewer::new(initial_position); 232 | context_viewer.toggle_vertical(); 233 | context_viewer.position 234 | } 235 | 236 | #[test_case(ContextViewerPosition::None => ContextViewerPosition::Horizontal)] 237 | #[test_case(ContextViewerPosition::Vertical => ContextViewerPosition::Horizontal)] 238 | #[test_case(ContextViewerPosition::Horizontal => ContextViewerPosition::None)] 239 | fn toggle_horizontal(initial_position: ContextViewerPosition) -> ContextViewerPosition { 240 | let mut context_viewer = ContextViewer::new(initial_position); 241 | context_viewer.toggle_horizontal(); 242 | context_viewer.position 243 | } 244 | 245 | #[test] 246 | fn increase_size() { 247 | let mut context_viewer = ContextViewer::new(ContextViewerPosition::None); 248 | let default_size = context_viewer.size; 249 | context_viewer.increase_size(); 250 | assert_eq!( 251 | context_viewer.size, 252 | default_size + ContextViewer::SIZE_CHANGE_DELTA 253 | ); 254 | 255 | context_viewer.size = ContextViewer::MAX_SIZE; 256 | context_viewer.increase_size(); 257 | assert_eq!(context_viewer.size, ContextViewer::MAX_SIZE); 258 | } 259 | 260 | #[test] 261 | fn decrease_size() { 262 | let mut context_viewer = ContextViewer::new(ContextViewerPosition::None); 263 | let default_size = context_viewer.size; 264 | context_viewer.decrease_size(); 265 | assert_eq!( 266 | context_viewer.size, 267 | default_size - ContextViewer::SIZE_CHANGE_DELTA 268 | ); 269 | 270 | context_viewer.size = ContextViewer::MIN_SIZE; 271 | context_viewer.decrease_size(); 272 | assert_eq!(context_viewer.size, ContextViewer::MIN_SIZE); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/ui/input_handler.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use crossterm::event::{poll, read, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; 3 | use std::time::Duration; 4 | 5 | use crate::app::Application; 6 | 7 | #[derive(Default)] 8 | pub struct InputHandler { 9 | input_buffer: String, 10 | input_state: InputState, 11 | input_mode: InputMode, 12 | } 13 | 14 | #[derive(Clone, Debug, Default, PartialEq, Eq)] 15 | pub enum InputState { 16 | #[default] 17 | Valid, 18 | Incomplete(String), 19 | Invalid(String), 20 | } 21 | 22 | #[derive(Clone, Debug, Default, PartialEq, Eq)] 23 | pub enum InputMode { 24 | #[default] 25 | Normal, 26 | TextInsertion, 27 | Keymap, 28 | } 29 | 30 | impl InputHandler { 31 | pub fn handle_input(&mut self, app: &mut A) -> Result<()> { 32 | let poll_timeout = if app.is_searching() { 33 | Duration::from_millis(1) 34 | } else { 35 | Duration::from_millis(100) 36 | }; 37 | 38 | if poll(poll_timeout)? { 39 | let read_event = read()?; 40 | if let Event::Key(key_event) = read_event { 41 | // The following line needs to be amended if and when enabling the 42 | // `KeyboardEnhancementFlags::REPORT_EVENT_TYPES` flag on unix. 43 | let event_kind_enabled = cfg!(target_family = "windows"); 44 | let process_event = !event_kind_enabled || key_event.kind != KeyEventKind::Release; 45 | 46 | if process_event { 47 | match self.input_mode { 48 | InputMode::Normal => self.handle_key_in_normal_mode(key_event, app), 49 | InputMode::TextInsertion => { 50 | self.handle_key_in_text_insertion_mode(key_event, app) 51 | } 52 | InputMode::Keymap => self.handle_key_in_keymap_mode(key_event, app), 53 | } 54 | } 55 | } 56 | } 57 | 58 | Ok(()) 59 | } 60 | 61 | fn handle_key_in_normal_mode(&mut self, key_event: KeyEvent, app: &mut A) { 62 | match key_event { 63 | KeyEvent { 64 | code: KeyCode::Char('c'), 65 | modifiers: KeyModifiers::CONTROL, 66 | .. 67 | } => app.on_exit(), 68 | KeyEvent { 69 | code: KeyCode::Char(character), 70 | .. 71 | } => self.handle_char_input(character, app), 72 | _ => self.handle_non_char_input(key_event.code, app), 73 | } 74 | } 75 | 76 | fn handle_key_in_text_insertion_mode( 77 | &mut self, 78 | key_event: KeyEvent, 79 | app: &mut A, 80 | ) { 81 | match key_event { 82 | KeyEvent { 83 | code: KeyCode::Esc, .. 84 | } 85 | | KeyEvent { 86 | code: KeyCode::Char('c'), 87 | modifiers: KeyModifiers::CONTROL, 88 | .. 89 | } 90 | | KeyEvent { 91 | code: KeyCode::F(5), 92 | .. 93 | } => { 94 | self.input_mode = InputMode::Normal; 95 | app.on_toggle_popup(); 96 | } 97 | KeyEvent { 98 | code: KeyCode::Char(c), 99 | modifiers: modifier, 100 | .. 101 | } => { 102 | if modifier == KeyModifiers::SHIFT { 103 | app.on_char_inserted(c.to_ascii_uppercase()); 104 | } else if modifier == KeyModifiers::NONE { 105 | app.on_char_inserted(c); 106 | } 107 | } 108 | KeyEvent { 109 | code: KeyCode::Backspace, 110 | .. 111 | } => app.on_char_removed(), 112 | KeyEvent { 113 | code: KeyCode::Delete, 114 | .. 115 | } => app.on_char_deleted(), 116 | KeyEvent { 117 | code: KeyCode::Left, 118 | .. 119 | } => app.on_char_left(), 120 | KeyEvent { 121 | code: KeyCode::Right, 122 | .. 123 | } => app.on_char_right(), 124 | KeyEvent { 125 | code: KeyCode::Enter, 126 | .. 127 | } => { 128 | self.input_mode = InputMode::Normal; 129 | app.on_search(); 130 | app.on_toggle_popup(); 131 | } 132 | _ => (), 133 | } 134 | } 135 | 136 | fn handle_key_in_keymap_mode(&mut self, key_event: KeyEvent, app: &mut A) { 137 | match key_event { 138 | KeyEvent { 139 | code: KeyCode::Up, .. 140 | } 141 | | KeyEvent { 142 | code: KeyCode::Char('k'), 143 | .. 144 | } => app.on_keymap_up(), 145 | KeyEvent { 146 | code: KeyCode::Down, 147 | .. 148 | } 149 | | KeyEvent { 150 | code: KeyCode::Char('j'), 151 | .. 152 | } => app.on_keymap_down(), 153 | KeyEvent { 154 | code: KeyCode::Left, 155 | .. 156 | } 157 | | KeyEvent { 158 | code: KeyCode::Char('h'), 159 | .. 160 | } => app.on_keymap_left(), 161 | KeyEvent { 162 | code: KeyCode::Right, 163 | .. 164 | } 165 | | KeyEvent { 166 | code: KeyCode::Char('l'), 167 | .. 168 | } => app.on_keymap_right(), 169 | _ => { 170 | self.input_mode = InputMode::Normal; 171 | app.on_toggle_keymap(); 172 | } 173 | } 174 | } 175 | 176 | fn handle_char_input(&mut self, character: char, app: &mut A) { 177 | self.input_buffer.push(character); 178 | self.input_state = InputState::Valid; 179 | 180 | let consume_buffer_and_execute = |buffer: &mut String, op: &mut dyn FnMut()| { 181 | buffer.clear(); 182 | op(); 183 | }; 184 | 185 | match self.input_buffer.as_str() { 186 | // navigation 187 | "j" => consume_buffer_and_execute(&mut self.input_buffer, &mut || app.on_next_match()), 188 | "k" => { 189 | consume_buffer_and_execute(&mut self.input_buffer, &mut || app.on_previous_match()) 190 | } 191 | "l" => consume_buffer_and_execute(&mut self.input_buffer, &mut || app.on_next_file()), 192 | "h" => { 193 | consume_buffer_and_execute(&mut self.input_buffer, &mut || app.on_previous_file()) 194 | } 195 | "gg" => consume_buffer_and_execute(&mut self.input_buffer, &mut || app.on_top()), 196 | "G" => consume_buffer_and_execute(&mut self.input_buffer, &mut || app.on_bottom()), 197 | // deletion 198 | "dd" => consume_buffer_and_execute(&mut self.input_buffer, &mut || { 199 | app.on_remove_current_entry() 200 | }), 201 | "dw" => consume_buffer_and_execute(&mut self.input_buffer, &mut || { 202 | app.on_remove_current_file() 203 | }), 204 | // viewer 205 | "v" => consume_buffer_and_execute(&mut self.input_buffer, &mut || { 206 | app.on_toggle_context_viewer_vertical() 207 | }), 208 | "s" => consume_buffer_and_execute(&mut self.input_buffer, &mut || { 209 | app.on_toggle_context_viewer_horizontal() 210 | }), 211 | "+" => consume_buffer_and_execute(&mut self.input_buffer, &mut || { 212 | app.on_increase_context_viewer_size() 213 | }), 214 | "-" => consume_buffer_and_execute(&mut self.input_buffer, &mut || { 215 | app.on_decrease_context_viewer_size() 216 | }), 217 | // sort 218 | "n" => consume_buffer_and_execute(&mut self.input_buffer, &mut || { 219 | app.on_toggle_sort_name() 220 | }), 221 | "m" => consume_buffer_and_execute(&mut self.input_buffer, &mut || { 222 | app.on_toggle_sort_mtime() 223 | }), 224 | "a" => consume_buffer_and_execute(&mut self.input_buffer, &mut || { 225 | app.on_toggle_sort_atime() 226 | }), 227 | "c" => consume_buffer_and_execute(&mut self.input_buffer, &mut || { 228 | app.on_toggle_sort_ctime() 229 | }), 230 | // misc 231 | "q" => consume_buffer_and_execute(&mut self.input_buffer, &mut || app.on_exit()), 232 | "?" => { 233 | consume_buffer_and_execute(&mut self.input_buffer, &mut || app.on_toggle_keymap()) 234 | } 235 | "/" => { 236 | self.input_mode = InputMode::TextInsertion; 237 | consume_buffer_and_execute(&mut self.input_buffer, &mut || app.on_toggle_popup()) 238 | } 239 | // buffer for multikey inputs 240 | "g" => self.input_state = InputState::Incomplete("g…".into()), 241 | "d" => self.input_state = InputState::Incomplete("d…".into()), 242 | buf => { 243 | self.input_state = InputState::Invalid(buf.into()); 244 | self.input_buffer.clear(); 245 | } 246 | } 247 | } 248 | 249 | fn handle_non_char_input(&mut self, key_code: KeyCode, app: &mut A) { 250 | self.input_buffer.clear(); 251 | 252 | match key_code { 253 | KeyCode::Down => app.on_next_match(), 254 | KeyCode::Up => app.on_previous_match(), 255 | KeyCode::Right | KeyCode::PageDown => app.on_next_file(), 256 | KeyCode::Left | KeyCode::PageUp => app.on_previous_file(), 257 | KeyCode::Home => app.on_top(), 258 | KeyCode::End => app.on_bottom(), 259 | KeyCode::Delete => app.on_remove_current_entry(), 260 | KeyCode::Enter => app.on_open_file(), 261 | KeyCode::F(1) => { 262 | self.input_mode = InputMode::Keymap; 263 | app.on_toggle_keymap(); 264 | } 265 | KeyCode::F(5) => { 266 | self.input_mode = InputMode::TextInsertion; 267 | app.on_toggle_popup(); 268 | } 269 | KeyCode::Esc => { 270 | if matches!(self.input_state, InputState::Valid) 271 | || matches!(self.input_state, InputState::Invalid(_)) 272 | { 273 | app.on_exit(); 274 | } 275 | } 276 | _ => (), 277 | } 278 | 279 | self.input_state = InputState::Valid; 280 | } 281 | 282 | pub fn get_state(&self) -> &InputState { 283 | &self.input_state 284 | } 285 | } 286 | 287 | #[cfg(test)] 288 | mod tests { 289 | use crate::app::MockApplication; 290 | 291 | use super::*; 292 | use crossterm::event::KeyCode::{Char, Esc}; 293 | use test_case::test_case; 294 | 295 | fn handle_key(key_code: KeyCode, app: &mut A) { 296 | let mut input_handler = InputHandler::default(); 297 | handle(&mut input_handler, key_code, app); 298 | } 299 | 300 | fn handle_key_series(key_codes: &[KeyCode], app: &mut A) { 301 | let mut input_handler = InputHandler::default(); 302 | for key_code in key_codes { 303 | handle(&mut input_handler, *key_code, app); 304 | } 305 | } 306 | 307 | fn handle(input_handler: &mut InputHandler, key_code: KeyCode, app: &mut A) { 308 | match key_code { 309 | Char(character) => input_handler.handle_char_input(character, app), 310 | _ => input_handler.handle_non_char_input(key_code, app), 311 | } 312 | } 313 | 314 | fn handle_key_keymap_mode(key_event: KeyEvent, app: &mut A) { 315 | let mut input_handler = InputHandler { 316 | input_mode: InputMode::Keymap, 317 | ..Default::default() 318 | }; 319 | input_handler.handle_key_in_keymap_mode(key_event, app); 320 | } 321 | 322 | #[test_case(KeyCode::Down; "down")] 323 | #[test_case(Char('j'); "j")] 324 | fn next_match(key_code: KeyCode) { 325 | let mut app_mock = MockApplication::default(); 326 | app_mock.expect_on_next_match().once().return_const(()); 327 | handle_key(key_code, &mut app_mock); 328 | } 329 | 330 | #[test_case(KeyCode::Up; "up")] 331 | #[test_case(Char('k'); "k")] 332 | fn previous_match(key_code: KeyCode) { 333 | let mut app_mock = MockApplication::default(); 334 | app_mock.expect_on_previous_match().once().return_const(()); 335 | handle_key(key_code, &mut app_mock); 336 | } 337 | 338 | #[test_case(KeyCode::Right; "right")] 339 | #[test_case(KeyCode::PageDown; "page down")] 340 | #[test_case(Char('l'); "l")] 341 | fn next_file(key_code: KeyCode) { 342 | let mut app_mock = MockApplication::default(); 343 | app_mock.expect_on_next_file().once().return_const(()); 344 | handle_key(key_code, &mut app_mock); 345 | } 346 | 347 | #[test_case(KeyCode::Left; "left")] 348 | #[test_case(KeyCode::PageUp; "page up")] 349 | #[test_case(Char('h'); "h")] 350 | fn previous_file(key_code: KeyCode) { 351 | let mut app_mock = MockApplication::default(); 352 | app_mock.expect_on_previous_file().once().return_const(()); 353 | handle_key(key_code, &mut app_mock); 354 | } 355 | 356 | #[test_case(&[KeyCode::Home]; "home")] 357 | #[test_case(&[Char('g'), Char('g')]; "gg")] 358 | fn top(key_codes: &[KeyCode]) { 359 | let mut app_mock = MockApplication::default(); 360 | app_mock.expect_on_top().once().return_const(()); 361 | handle_key_series(key_codes, &mut app_mock); 362 | } 363 | 364 | #[test_case(KeyCode::End; "end")] 365 | #[test_case(Char('G'); "G")] 366 | fn bottom(key_code: KeyCode) { 367 | let mut app_mock = MockApplication::default(); 368 | app_mock.expect_on_bottom().once().return_const(()); 369 | handle_key(key_code, &mut app_mock); 370 | } 371 | 372 | #[test_case(&[KeyCode::Delete]; "delete")] 373 | #[test_case(&[Char('d'), Char('d')]; "dd")] 374 | #[test_case(&[Char('g'), Char('d'), Char('w'), Char('d'), Char('d')]; "gdwdd")] 375 | fn remove_current_entry(key_codes: &[KeyCode]) { 376 | let mut app_mock = MockApplication::default(); 377 | app_mock 378 | .expect_on_remove_current_entry() 379 | .once() 380 | .return_const(()); 381 | handle_key_series(key_codes, &mut app_mock); 382 | } 383 | 384 | #[test_case(&[Char('d'), Char('w')]; "dw")] 385 | #[test_case(&[Char('w'), Char('d'), Char('w')]; "wdw")] 386 | fn remove_current_file(key_codes: &[KeyCode]) { 387 | let mut app_mock = MockApplication::default(); 388 | app_mock 389 | .expect_on_remove_current_file() 390 | .once() 391 | .return_const(()); 392 | handle_key_series(key_codes, &mut app_mock); 393 | } 394 | 395 | #[test] 396 | fn toggle_vertical_context_viewer() { 397 | let mut app_mock = MockApplication::default(); 398 | app_mock 399 | .expect_on_toggle_context_viewer_vertical() 400 | .once() 401 | .return_const(()); 402 | handle_key(KeyCode::Char('v'), &mut app_mock); 403 | } 404 | 405 | #[test] 406 | fn toggle_horizontal_context_viewer() { 407 | let mut app_mock = MockApplication::default(); 408 | app_mock 409 | .expect_on_toggle_context_viewer_horizontal() 410 | .once() 411 | .return_const(()); 412 | handle_key(KeyCode::Char('s'), &mut app_mock); 413 | } 414 | 415 | #[test] 416 | fn open_file() { 417 | let mut app_mock = MockApplication::default(); 418 | app_mock.expect_on_open_file().once().return_const(()); 419 | handle_key(KeyCode::Enter, &mut app_mock); 420 | } 421 | 422 | #[test_case(KeyCode::F(5))] 423 | #[test_case(KeyCode::Char('/'))] 424 | fn search(key_code: KeyCode) { 425 | let mut app_mock = MockApplication::default(); 426 | app_mock.expect_on_toggle_popup().once().return_const(()); 427 | handle_key(key_code, &mut app_mock); 428 | } 429 | 430 | #[test_case(KeyCode::F(1))] 431 | #[test_case(KeyCode::Char('?'))] 432 | fn keymap_open(key_code: KeyCode) { 433 | let mut app_mock = MockApplication::default(); 434 | app_mock.expect_on_toggle_keymap().once().return_const(()); 435 | handle_key(key_code, &mut app_mock); 436 | } 437 | 438 | #[test_case(KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE))] 439 | #[test_case(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE))] 440 | #[test_case(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))] 441 | #[test_case(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))] 442 | #[test_case(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))] 443 | fn keymap_close(event: KeyEvent) { 444 | let mut app_mock = MockApplication::default(); 445 | app_mock.expect_on_toggle_keymap().once().return_const(()); 446 | handle_key_keymap_mode(event, &mut app_mock); 447 | } 448 | 449 | #[test_case(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))] 450 | #[test_case(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE))] 451 | fn keymap_up(event: KeyEvent) { 452 | let mut app_mock = MockApplication::default(); 453 | app_mock.expect_on_keymap_up().once().return_const(()); 454 | handle_key_keymap_mode(event, &mut app_mock); 455 | } 456 | 457 | #[test_case(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))] 458 | #[test_case(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE))] 459 | fn keymap_down(event: KeyEvent) { 460 | let mut app_mock = MockApplication::default(); 461 | app_mock.expect_on_keymap_down().once().return_const(()); 462 | handle_key_keymap_mode(event, &mut app_mock); 463 | } 464 | 465 | #[test_case(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE))] 466 | #[test_case(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))] 467 | fn keymap_left(event: KeyEvent) { 468 | let mut app_mock = MockApplication::default(); 469 | app_mock.expect_on_keymap_left().once().return_const(()); 470 | handle_key_keymap_mode(event, &mut app_mock); 471 | } 472 | 473 | #[test_case(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))] 474 | #[test_case(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))] 475 | fn keymap_right(event: KeyEvent) { 476 | let mut app_mock = MockApplication::default(); 477 | app_mock.expect_on_keymap_right().once().return_const(()); 478 | handle_key_keymap_mode(event, &mut app_mock); 479 | } 480 | 481 | #[test_case(&[Char('q')]; "q")] 482 | #[test_case(&[Esc]; "empty input state")] 483 | #[test_case(&[Char('b'), Char('e'), Esc]; "invalid input state")] 484 | #[test_case(&[Char('d'), Esc, Esc]; "clear incomplete state first")] 485 | fn exit(key_codes: &[KeyCode]) { 486 | let mut app_mock = MockApplication::default(); 487 | app_mock.expect_on_exit().once().return_const(()); 488 | handle_key_series(key_codes, &mut app_mock); 489 | } 490 | } 491 | -------------------------------------------------------------------------------- /src/ui/keymap_popup.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Alignment, Rect}, 3 | text::Text, 4 | widgets::{Block, Borders, Clear, Padding, Paragraph}, 5 | Frame, 6 | }; 7 | 8 | use super::theme::Theme; 9 | 10 | include!(concat!(env!("OUT_DIR"), "/keybindings.rs")); 11 | 12 | pub struct KeymapPopup { 13 | visible: bool, 14 | scroll_y: u16, 15 | scroll_x: u16, 16 | content: Text<'static>, 17 | } 18 | 19 | impl KeymapPopup { 20 | pub fn new() -> Self { 21 | Self { 22 | visible: false, 23 | scroll_y: 0, 24 | scroll_x: 0, 25 | content: Text::from(KEYBINDINGS_TABLE), 26 | } 27 | } 28 | 29 | pub fn toggle(&mut self) { 30 | self.visible = !self.visible; 31 | if self.visible { 32 | self.scroll_y = 0; 33 | self.scroll_x = 0; 34 | } 35 | } 36 | 37 | pub fn go_down(&mut self) { 38 | self.scroll_y = self.scroll_y.saturating_add(1); 39 | } 40 | 41 | pub fn go_up(&mut self) { 42 | self.scroll_y = self.scroll_y.saturating_sub(1); 43 | } 44 | 45 | pub fn go_right(&mut self) { 46 | self.scroll_x = self.scroll_x.saturating_add(1); 47 | } 48 | 49 | pub fn go_left(&mut self) { 50 | self.scroll_x = self.scroll_x.saturating_sub(1); 51 | } 52 | 53 | pub fn draw(&mut self, frame: &mut Frame, theme: &dyn Theme) { 54 | if !self.visible { 55 | return; 56 | } 57 | 58 | let popup_area = Self::get_popup_area(frame.size()); 59 | 60 | let max_y = KEYBINDINGS_LEN.saturating_sub(popup_area.height - 4); 61 | self.scroll_y = self.scroll_y.min(max_y); 62 | let max_x = KEYBINDINGS_LINE_LEN.saturating_sub(popup_area.width - 4); 63 | self.scroll_x = self.scroll_x.min(max_x); 64 | 65 | let paragraph = Paragraph::new(self.content.clone()) 66 | .block( 67 | Block::default() 68 | .borders(Borders::ALL) 69 | .border_style(theme.search_popup_border()) 70 | .title(concat!( 71 | " ", 72 | env!("CARGO_PKG_NAME"), 73 | " ", 74 | env!("CARGO_PKG_VERSION"), 75 | " " 76 | )) 77 | .title_alignment(Alignment::Center) 78 | .padding(Padding::uniform(1)), 79 | ) 80 | .scroll((self.scroll_y, self.scroll_x)); 81 | 82 | frame.render_widget(Clear, popup_area); 83 | frame.render_widget(paragraph, popup_area); 84 | } 85 | 86 | fn get_popup_area(frame_size: Rect) -> Rect { 87 | let height = (KEYBINDINGS_LEN + 4).min((frame_size.height as f64 * 0.8) as u16); 88 | let y = (frame_size.height - height) / 2; 89 | 90 | let width = (KEYBINDINGS_LINE_LEN + 4).min((frame_size.width as f64 * 0.8) as u16); 91 | let x = (frame_size.width - width) / 2; 92 | 93 | Rect { 94 | x, 95 | y, 96 | width, 97 | height, 98 | } 99 | } 100 | } 101 | 102 | impl Default for KeymapPopup { 103 | fn default() -> Self { 104 | Self::new() 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/ui/result_list.rs: -------------------------------------------------------------------------------- 1 | use std::cmp; 2 | 3 | use ratatui::{ 4 | layout::Rect, 5 | style::Style, 6 | text::{Line, Span}, 7 | widgets::{Block, BorderType, Borders}, 8 | Frame, 9 | }; 10 | 11 | use crate::ig::file_entry::{EntryType, FileEntry}; 12 | 13 | use super::{ 14 | scroll_offset_list::{List, ListItem, ListState, ScrollOffset}, 15 | theme::Theme, 16 | }; 17 | 18 | #[derive(Default)] 19 | pub struct ResultList { 20 | entries: Vec, 21 | state: ListState, 22 | file_entries_count: usize, 23 | matches_count: usize, 24 | filtered_matches_count: usize, 25 | } 26 | 27 | impl ResultList { 28 | pub fn add_entry(&mut self, entry: FileEntry) { 29 | self.file_entries_count += 1; 30 | self.matches_count += entry.get_matches_count(); 31 | 32 | self.entries.append(&mut entry.get_entries()); 33 | 34 | if self.state.selected().is_none() { 35 | self.next_match(); 36 | } 37 | } 38 | 39 | pub fn iter(&self) -> std::slice::Iter { 40 | self.entries.iter() 41 | } 42 | 43 | pub fn is_empty(&self) -> bool { 44 | self.entries.is_empty() 45 | } 46 | 47 | pub fn next_match(&mut self) { 48 | if self.is_empty() { 49 | return; 50 | } 51 | 52 | let index = match self.state.selected() { 53 | Some(i) => { 54 | if i == self.entries.len() - 1 { 55 | i 56 | } else { 57 | match self.entries[i + 1] { 58 | EntryType::Header(_) => i + 2, 59 | EntryType::Match(_, _, _) => i + 1, 60 | } 61 | } 62 | } 63 | None => 1, 64 | }; 65 | 66 | self.state.select(Some(index)); 67 | } 68 | 69 | pub fn previous_match(&mut self) { 70 | if self.is_empty() { 71 | return; 72 | } 73 | 74 | let index = match self.state.selected() { 75 | Some(i) => { 76 | if i == 1 { 77 | 1 78 | } else { 79 | match self.entries[i - 1] { 80 | EntryType::Header(_) => i - 2, 81 | EntryType::Match(_, _, _) => i - 1, 82 | } 83 | } 84 | } 85 | None => 1, 86 | }; 87 | 88 | self.state.select(Some(index)); 89 | } 90 | 91 | pub fn next_file(&mut self) { 92 | if self.is_empty() { 93 | return; 94 | } 95 | 96 | let index = match self.state.selected() { 97 | Some(i) => { 98 | let mut next_index = i; 99 | loop { 100 | if next_index == self.entries.len() - 1 { 101 | next_index = i; 102 | break; 103 | } 104 | 105 | next_index += 1; 106 | match self.entries[next_index] { 107 | EntryType::Header(_) => { 108 | next_index += 1; 109 | break; 110 | } 111 | EntryType::Match(_, _, _) => continue, 112 | } 113 | } 114 | next_index 115 | } 116 | None => 1, 117 | }; 118 | 119 | self.state.select(Some(index)); 120 | } 121 | 122 | pub fn previous_file(&mut self) { 123 | if self.is_empty() { 124 | return; 125 | } 126 | 127 | let index = match self.state.selected() { 128 | Some(i) => { 129 | let mut next_index = i; 130 | let mut first_header_visited = false; 131 | loop { 132 | if next_index == 1 { 133 | break; 134 | } 135 | 136 | next_index -= 1; 137 | match self.entries[next_index] { 138 | EntryType::Header(_) => { 139 | if !first_header_visited { 140 | first_header_visited = true; 141 | next_index -= 1; 142 | } else { 143 | next_index += 1; 144 | break; 145 | } 146 | } 147 | EntryType::Match(_, _, _) => continue, 148 | } 149 | } 150 | next_index 151 | } 152 | None => 1, 153 | }; 154 | 155 | self.state.select(Some(index)); 156 | } 157 | 158 | pub fn top(&mut self) { 159 | if self.is_empty() { 160 | return; 161 | } 162 | 163 | self.state.select(Some(1)); 164 | } 165 | 166 | pub fn bottom(&mut self) { 167 | if self.is_empty() { 168 | return; 169 | } 170 | 171 | self.state.select(Some(self.entries.len() - 1)); 172 | } 173 | 174 | pub fn remove_current_entry(&mut self) { 175 | if self.is_empty() { 176 | return; 177 | } 178 | 179 | if self.is_last_match_in_file() { 180 | self.remove_current_file(); 181 | } else { 182 | self.remove_current_entry_and_select_previous(); 183 | } 184 | } 185 | 186 | pub fn remove_current_file(&mut self) { 187 | if self.is_empty() { 188 | return; 189 | } 190 | 191 | let selected_index = self.state.selected().expect("Nothing selected"); 192 | 193 | let mut current_file_header_index = 0; 194 | for index in (0..selected_index).rev() { 195 | if self.is_header(index) { 196 | current_file_header_index = index; 197 | break; 198 | } 199 | } 200 | 201 | let mut next_file_header_index = self.entries.len(); 202 | for index in selected_index..self.entries.len() { 203 | if self.is_header(index) { 204 | next_file_header_index = index; 205 | break; 206 | } 207 | } 208 | 209 | let span = next_file_header_index - current_file_header_index; 210 | for _ in 0..span { 211 | self.entries.remove(current_file_header_index); 212 | } 213 | 214 | self.filtered_matches_count += span - 1; 215 | 216 | if self.entries.is_empty() { 217 | self.state.select(None); 218 | } else if selected_index != 1 { 219 | self.state.select(Some(cmp::max( 220 | current_file_header_index.saturating_sub(1), 221 | 1, 222 | ))); 223 | } 224 | } 225 | 226 | fn is_header(&self, index: usize) -> bool { 227 | matches!(self.entries[index], EntryType::Header(_)) 228 | } 229 | 230 | fn is_last_match_in_file(&self) -> bool { 231 | let current_index = self.state.selected().expect("Nothing selected"); 232 | 233 | self.is_header(current_index - 1) 234 | && (current_index == self.entries.len() - 1 || self.is_header(current_index + 1)) 235 | } 236 | 237 | fn remove_current_entry_and_select_previous(&mut self) { 238 | let selected_index = self.state.selected().expect("Nothing selected"); 239 | self.entries.remove(selected_index); 240 | self.filtered_matches_count += 1; 241 | 242 | if selected_index >= self.entries.len() || self.is_header(selected_index) { 243 | self.state.select(Some(selected_index - 1)); 244 | } 245 | } 246 | 247 | pub fn get_selected_entry(&self) -> Option<(String, u64)> { 248 | match self.state.selected() { 249 | Some(i) => { 250 | let mut line_number: Option = None; 251 | for index in (0..=i).rev() { 252 | match &self.entries[index] { 253 | EntryType::Header(name) => { 254 | return Some(( 255 | name.to_owned(), 256 | line_number.expect("Line number not specified"), 257 | )); 258 | } 259 | EntryType::Match(number, _, _) => { 260 | if line_number.is_none() { 261 | line_number = Some(*number); 262 | } 263 | } 264 | } 265 | } 266 | None 267 | } 268 | None => None, 269 | } 270 | } 271 | 272 | pub fn get_current_match_index(&self) -> usize { 273 | match self.state.selected() { 274 | Some(selected) => { 275 | self.entries 276 | .iter() 277 | .take(selected) 278 | .filter(|&e| matches!(e, EntryType::Match(_, _, _))) 279 | .count() 280 | + 1 281 | } 282 | None => 0, 283 | } 284 | } 285 | 286 | pub fn get_current_number_of_matches(&self) -> usize { 287 | self.entries 288 | .iter() 289 | .filter(|&e| matches!(e, EntryType::Match(_, _, _))) 290 | .count() 291 | } 292 | 293 | pub fn get_total_number_of_matches(&self) -> usize { 294 | self.matches_count 295 | } 296 | 297 | pub fn get_total_number_of_file_entries(&self) -> usize { 298 | self.file_entries_count 299 | } 300 | 301 | pub fn get_filtered_matches_count(&self) -> usize { 302 | self.filtered_matches_count 303 | } 304 | 305 | pub fn draw(&mut self, frame: &mut Frame, area: Rect, theme: &dyn Theme) { 306 | let files_list: Vec = self 307 | .iter() 308 | .map(|e| match e { 309 | EntryType::Header(h) => { 310 | let h = h.trim_start_matches("./"); 311 | ListItem::new(Span::styled(h, theme.file_path_color())) 312 | } 313 | EntryType::Match(n, t, offsets) => { 314 | let line_number = Span::styled(format!(" {n}: "), theme.line_number_color()); 315 | 316 | let mut spans = vec![line_number]; 317 | 318 | let mut current_position = 0; 319 | for offset in offsets { 320 | let before_match = 321 | Span::styled(&t[current_position..offset.0], theme.list_font_color()); 322 | let actual_match = 323 | Span::styled(&t[offset.0..offset.1], theme.match_color()); 324 | 325 | // set current position to the end of current match 326 | current_position = offset.1; 327 | 328 | spans.push(before_match); 329 | spans.push(actual_match); 330 | } 331 | 332 | // push remaining text of a line 333 | spans.push(Span::styled( 334 | &t[current_position..], 335 | theme.list_font_color(), 336 | )); 337 | 338 | ListItem::new(Line::from(spans)) 339 | } 340 | }) 341 | .collect(); 342 | 343 | let list_widget = List::new(files_list) 344 | .block( 345 | Block::default() 346 | .borders(Borders::ALL) 347 | .border_type(BorderType::Rounded), 348 | ) 349 | .style(theme.background_color()) 350 | .highlight_style(Style::default().bg(theme.highlight_color())) 351 | .scroll_offset(ScrollOffset::default().top(1).bottom(0)); 352 | 353 | let mut state = self.state; 354 | frame.render_stateful_widget(list_widget, area, &mut state); 355 | self.state = state; 356 | } 357 | } 358 | 359 | #[cfg(test)] 360 | mod tests { 361 | use crate::ig::grep_match::GrepMatch; 362 | 363 | use super::*; 364 | 365 | #[test] 366 | fn test_empty_list() { 367 | let mut list = ResultList::default(); 368 | assert_eq!(list.state.selected(), None); 369 | list.next_match(); 370 | assert_eq!(list.state.selected(), None); 371 | list.previous_match(); 372 | assert_eq!(list.state.selected(), None); 373 | } 374 | 375 | #[test] 376 | fn test_add_entry() { 377 | let mut list = ResultList::default(); 378 | list.add_entry(FileEntry::new( 379 | "entry1".into(), 380 | vec![GrepMatch::new(0, "e1m1".into(), vec![])], 381 | )); 382 | assert_eq!(list.entries.len(), 2); 383 | assert_eq!(list.state.selected(), Some(1)); 384 | 385 | list.add_entry(FileEntry::new( 386 | "entry2".into(), 387 | vec![ 388 | GrepMatch::new(0, "e1m2".into(), vec![]), 389 | GrepMatch::new(0, "e2m2".into(), vec![]), 390 | ], 391 | )); 392 | assert_eq!(list.entries.len(), 5); 393 | assert_eq!(list.state.selected(), Some(1)); 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /src/ui/scroll_offset_list.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::{Corner, Rect}, 4 | style::Style, 5 | text::Text, 6 | widgets::{Block, StatefulWidget, Widget}, 7 | }; 8 | use std::iter::Iterator; 9 | use unicode_width::UnicodeWidthStr; 10 | 11 | #[derive(Default, Debug, Copy, Clone)] 12 | pub struct ListState { 13 | offset: usize, 14 | selected: Option, 15 | } 16 | 17 | impl ListState { 18 | pub fn select(&mut self, index: Option) { 19 | self.selected = index; 20 | if index.is_none() { 21 | self.offset = 0; 22 | } 23 | } 24 | 25 | pub fn selected(&self) -> Option { 26 | self.selected 27 | } 28 | } 29 | 30 | #[derive(Debug, Clone)] 31 | pub struct ListItem<'a> { 32 | content: Text<'a>, 33 | style: Style, 34 | } 35 | 36 | impl<'a> ListItem<'a> { 37 | pub fn new(content: T) -> ListItem<'a> 38 | where 39 | T: Into>, 40 | { 41 | ListItem { 42 | content: content.into(), 43 | style: Style::default(), 44 | } 45 | } 46 | 47 | pub fn height(&self) -> usize { 48 | self.content.height() 49 | } 50 | } 51 | 52 | #[derive(Default, Debug, Clone)] 53 | pub struct ScrollOffset { 54 | top: usize, 55 | bottom: usize, 56 | } 57 | 58 | impl ScrollOffset { 59 | pub fn top(mut self, offset: usize) -> Self { 60 | self.top = offset; 61 | self 62 | } 63 | 64 | pub fn bottom(mut self, offset: usize) -> Self { 65 | self.bottom = offset; 66 | self 67 | } 68 | } 69 | 70 | #[derive(Debug, Clone)] 71 | pub struct List<'a> { 72 | block: Option>, 73 | items: Vec>, 74 | /// Style used as a base style for the widget 75 | style: Style, 76 | start_corner: Corner, 77 | /// Style used to render selected item 78 | highlight_style: Style, 79 | /// Symbol in front of the selected item (Shift all items to the right) 80 | highlight_symbol: Option<&'a str>, 81 | scroll_offset: ScrollOffset, 82 | } 83 | 84 | impl<'a> List<'a> { 85 | pub fn new(items: T) -> List<'a> 86 | where 87 | T: Into>>, 88 | { 89 | List { 90 | block: None, 91 | style: Style::default(), 92 | items: items.into(), 93 | start_corner: Corner::TopLeft, 94 | highlight_style: Style::default(), 95 | highlight_symbol: None, 96 | scroll_offset: ScrollOffset::default(), 97 | } 98 | } 99 | 100 | pub fn block(mut self, block: Block<'a>) -> List<'a> { 101 | self.block = Some(block); 102 | self 103 | } 104 | 105 | pub fn style(mut self, style: Style) -> List<'a> { 106 | self.style = style; 107 | self 108 | } 109 | 110 | pub fn highlight_style(mut self, style: Style) -> List<'a> { 111 | self.highlight_style = style; 112 | self 113 | } 114 | 115 | pub fn scroll_offset(mut self, scroll_offset: ScrollOffset) -> List<'a> { 116 | self.scroll_offset = scroll_offset; 117 | self 118 | } 119 | } 120 | 121 | impl StatefulWidget for List<'_> { 122 | type State = ListState; 123 | 124 | fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 125 | buf.set_style(area, self.style); 126 | let list_area = match self.block.take() { 127 | Some(b) => { 128 | let inner_area = b.inner(area); 129 | b.render(area, buf); 130 | inner_area 131 | } 132 | None => area, 133 | }; 134 | 135 | if list_area.width < 1 || list_area.height < 1 { 136 | return; 137 | } 138 | 139 | if self.items.is_empty() { 140 | return; 141 | } 142 | let list_height = list_area.height as usize; 143 | 144 | let mut start = state.offset; 145 | let mut end = state.offset; 146 | let mut height = 0; 147 | for item in self.items.iter().skip(state.offset) { 148 | if height + item.height() > list_height { 149 | break; 150 | } 151 | height += item.height(); 152 | end += 1; 153 | } 154 | 155 | let selected = state.selected.unwrap_or(0).min(self.items.len() - 1); 156 | while selected >= end { 157 | height = height.saturating_add(self.items[end].height()); 158 | end += 1; 159 | while height > list_height { 160 | height = height.saturating_sub(self.items[start].height()); 161 | start += 1; 162 | } 163 | } 164 | while selected < start { 165 | start -= 1; 166 | height = height.saturating_add(self.items[start].height()); 167 | while height > list_height { 168 | end -= 1; 169 | height = height.saturating_sub(self.items[end].height()); 170 | } 171 | } 172 | state.offset = start; 173 | 174 | if selected - state.offset < self.scroll_offset.top { 175 | state.offset = state.offset.saturating_sub(1); 176 | } 177 | 178 | if selected >= list_height + state.offset - self.scroll_offset.bottom 179 | && selected < height - self.scroll_offset.bottom 180 | { 181 | state.offset += 1; 182 | } 183 | 184 | let highlight_symbol = self.highlight_symbol.unwrap_or(""); 185 | let blank_symbol = " ".repeat(highlight_symbol.width()); 186 | 187 | let mut current_height = 0; 188 | let has_selection = state.selected.is_some(); 189 | for (i, item) in self 190 | .items 191 | .iter_mut() 192 | .enumerate() 193 | .skip(state.offset) 194 | .take(end - start) 195 | { 196 | let (x, y) = match self.start_corner { 197 | Corner::BottomLeft => { 198 | current_height += item.height() as u16; 199 | (list_area.left(), list_area.bottom() - current_height) 200 | } 201 | _ => { 202 | let pos = (list_area.left(), list_area.top() + current_height); 203 | current_height += item.height() as u16; 204 | pos 205 | } 206 | }; 207 | let area = Rect { 208 | x, 209 | y, 210 | width: list_area.width, 211 | height: item.height() as u16, 212 | }; 213 | let item_style = self.style.patch(item.style); 214 | buf.set_style(area, item_style); 215 | 216 | let is_selected = state.selected.map(|s| s == i).unwrap_or(false); 217 | let elem_x = if has_selection { 218 | let symbol = if is_selected { 219 | highlight_symbol 220 | } else { 221 | &blank_symbol 222 | }; 223 | let (x, _) = buf.set_stringn(x, y, symbol, list_area.width as usize, item_style); 224 | x 225 | } else { 226 | x 227 | }; 228 | let max_element_width = (list_area.width - (elem_x - x)) as usize; 229 | for (j, line) in item.content.lines.iter().enumerate() { 230 | buf.set_line(elem_x, y + j as u16, line, max_element_width as u16); 231 | } 232 | if is_selected { 233 | buf.set_style(area, self.highlight_style); 234 | } 235 | } 236 | } 237 | } 238 | 239 | impl Widget for List<'_> { 240 | fn render(self, area: Rect, buf: &mut Buffer) { 241 | let mut state = ListState::default(); 242 | StatefulWidget::render(self, area, buf, &mut state); 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/ui/search_popup.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 3 | style::Stylize, 4 | text::{Line, Text}, 5 | widgets::{Block, Borders, Clear, Paragraph}, 6 | Frame, 7 | }; 8 | 9 | use super::theme::Theme; 10 | 11 | #[derive(Default)] 12 | pub struct SearchPopup { 13 | visible: bool, 14 | pattern: String, 15 | cursor_position: usize, 16 | } 17 | 18 | impl SearchPopup { 19 | pub fn toggle(&mut self) { 20 | self.visible = !self.visible; 21 | } 22 | 23 | pub fn set_pattern(&mut self, pattern: String) { 24 | self.pattern = pattern; 25 | self.cursor_position = self.pattern.len(); 26 | } 27 | 28 | pub fn get_pattern(&self) -> String { 29 | self.pattern.clone() 30 | } 31 | 32 | pub fn insert_char(&mut self, c: char) { 33 | self.pattern.insert(self.cursor_position, c); 34 | self.move_cursor_right(); 35 | } 36 | 37 | pub fn remove_char(&mut self) { 38 | self.move_cursor_left(); 39 | if !self.pattern.is_empty() { 40 | self.pattern.remove(self.cursor_position); 41 | } 42 | } 43 | 44 | pub fn delete_char(&mut self) { 45 | if self.cursor_position < self.pattern.len() { 46 | self.pattern.remove(self.cursor_position); 47 | } 48 | } 49 | 50 | pub fn move_cursor_left(&mut self) { 51 | if self.cursor_position > 0 { 52 | self.cursor_position -= 1; 53 | } 54 | } 55 | 56 | pub fn move_cursor_right(&mut self) { 57 | if self.cursor_position < self.pattern.len() { 58 | self.cursor_position += 1; 59 | } 60 | } 61 | 62 | pub fn draw(&self, frame: &mut Frame, theme: &dyn Theme) { 63 | if !self.visible { 64 | return; 65 | } 66 | 67 | let block = Block::default() 68 | .borders(Borders::ALL) 69 | .border_style(theme.search_popup_border()) 70 | .bold() 71 | .title(" Regex Pattern ") 72 | .title_alignment(Alignment::Center); 73 | let popup_area = Self::get_popup_area(frame.size(), 50); 74 | frame.render_widget(Clear, popup_area); 75 | 76 | frame.render_widget(block, popup_area); 77 | 78 | let mut text_area = popup_area; 79 | text_area.y += 1; // one line below the border 80 | text_area.x += 2; // two chars to the right 81 | 82 | let max_text_width = text_area.width as usize - 4; 83 | let pattern = if self.pattern.len() > max_text_width { 84 | format!( 85 | "…{}", 86 | &self.pattern[self.pattern.len() - max_text_width + 1..] 87 | ) 88 | } else { 89 | self.pattern.clone() 90 | }; 91 | 92 | let text = Text::from(Line::from(pattern.as_str())); 93 | let pattern_text = Paragraph::new(text); 94 | frame.render_widget(pattern_text, text_area); 95 | frame.set_cursor( 96 | std::cmp::min( 97 | text_area.x + self.cursor_position as u16, 98 | text_area.x + text_area.width - 4, 99 | ), 100 | text_area.y, 101 | ); 102 | } 103 | 104 | fn get_popup_area(frame_size: Rect, width_percent: u16) -> Rect { 105 | const POPUP_HEIGHT: u16 = 3; 106 | let top_bottom_margin = (frame_size.height - POPUP_HEIGHT) / 2; 107 | let popup_layout = Layout::default() 108 | .direction(Direction::Vertical) 109 | .constraints( 110 | [ 111 | Constraint::Length(top_bottom_margin), 112 | Constraint::Length(POPUP_HEIGHT), 113 | Constraint::Length(top_bottom_margin), 114 | ] 115 | .as_ref(), 116 | ) 117 | .split(frame_size); 118 | 119 | Layout::default() 120 | .direction(Direction::Horizontal) 121 | .constraints( 122 | [ 123 | Constraint::Percentage((100 - width_percent) / 2), 124 | Constraint::Percentage(width_percent), 125 | Constraint::Percentage((100 - width_percent) / 2), 126 | ] 127 | .as_ref(), 128 | ) 129 | .split(popup_layout[1])[1] 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/ui/theme.rs: -------------------------------------------------------------------------------- 1 | pub mod dark; 2 | pub mod light; 3 | 4 | use clap::ValueEnum; 5 | use ratatui::style::{Color, Modifier, Style}; 6 | use strum::Display; 7 | 8 | #[derive(Display, Copy, Clone, Debug, ValueEnum)] 9 | #[strum(serialize_all = "lowercase")] 10 | pub enum ThemeVariant { 11 | Light, 12 | Dark, 13 | } 14 | 15 | pub trait Theme { 16 | // Matches list styles 17 | fn background_color(&self) -> Style { 18 | Style::default() 19 | } 20 | 21 | fn list_font_color(&self) -> Style { 22 | Style::default() 23 | } 24 | 25 | fn file_path_color(&self) -> Style { 26 | Style::default().fg(Color::LightMagenta) 27 | } 28 | 29 | fn line_number_color(&self) -> Style { 30 | Style::default().fg(Color::Green) 31 | } 32 | 33 | fn match_color(&self) -> Style { 34 | Style::default().fg(Color::Red) 35 | } 36 | 37 | fn highlight_color(&self) -> Color; 38 | 39 | // Context viewer styles 40 | fn context_viewer_theme(&self) -> &str; 41 | 42 | // Bottom bar styles 43 | fn bottom_bar_color(&self) -> Color { 44 | Color::Reset 45 | } 46 | 47 | fn bottom_bar_font_color(&self) -> Color { 48 | Color::Reset 49 | } 50 | 51 | fn bottom_bar_style(&self) -> Style { 52 | Style::default() 53 | .bg(self.bottom_bar_color()) 54 | .fg(self.bottom_bar_font_color()) 55 | } 56 | 57 | fn searching_state_style(&self) -> Style { 58 | Style::default() 59 | .add_modifier(Modifier::BOLD) 60 | .bg(Color::Rgb(255, 165, 0)) 61 | .fg(Color::Black) 62 | } 63 | 64 | fn error_state_style(&self) -> Style { 65 | Style::default() 66 | .add_modifier(Modifier::BOLD) 67 | .bg(Color::Red) 68 | .fg(Color::Black) 69 | } 70 | 71 | fn finished_state_style(&self) -> Style { 72 | Style::default() 73 | .add_modifier(Modifier::BOLD) 74 | .bg(Color::Green) 75 | .fg(Color::Black) 76 | } 77 | 78 | fn invalid_input_color(&self) -> Color { 79 | Color::Red 80 | } 81 | 82 | // Search popup style 83 | fn search_popup_border(&self) -> Style { 84 | Style::default().fg(Color::Green) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/ui/theme/dark.rs: -------------------------------------------------------------------------------- 1 | use super::Theme; 2 | use ratatui::style::Color; 3 | 4 | pub struct Dark; 5 | 6 | impl Theme for Dark { 7 | fn highlight_color(&self) -> Color { 8 | Color::Rgb(58, 58, 58) 9 | } 10 | 11 | fn context_viewer_theme(&self) -> &str { 12 | "base16-ocean.dark" 13 | } 14 | 15 | fn bottom_bar_color(&self) -> Color { 16 | Color::Rgb(58, 58, 58) 17 | } 18 | 19 | fn bottom_bar_font_color(&self) -> Color { 20 | Color::Rgb(147, 147, 147) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/theme/light.rs: -------------------------------------------------------------------------------- 1 | use super::Theme; 2 | use ratatui::style::Color; 3 | 4 | pub struct Light; 5 | 6 | impl Theme for Light { 7 | fn highlight_color(&self) -> Color { 8 | Color::Rgb(220, 220, 220) 9 | } 10 | 11 | fn context_viewer_theme(&self) -> &str { 12 | "base16-ocean.light" 13 | } 14 | } 15 | --------------------------------------------------------------------------------