diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 7eb1343..586e578 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -12,10 +12,10 @@ jobs: - name: Build run: cargo build --release - - uses: actions/upload-artifact@v1 + - uses: actions/upload-artifact@v2 with: - name: thumbs-macos.zip - path: ./target/release/thumbs + name: copyrat-macos.zip + path: ./target/release/*copyrat build-linux: runs-on: ubuntu-latest @@ -31,7 +31,7 @@ jobs: - name: Build run: cargo build --release - - uses: actions/upload-artifact@v1 + - uses: actions/upload-artifact@v2 with: - name: thumbs-linux.zip - path: ./target/release/thumbs + name: copyrat-linux.zip + path: ./target/release/*copyrat diff --git a/.rustfmt.toml b/.rustfmt.toml index 44f8e3a..4fbc017 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,2 +1,14 @@ -tab_spaces = 2 -max_width = 120 +use_field_init_shorthand = true +# width_heuristics = "Max" +use_try_shorthand = true +newline_style = "Unix" + +# - nightly features +# wrap_comments = true +# format_code_in_doc_comments = true +# normalize_comments = true +# reorder_impl_items = true +# overflow_delimited_expr = true +# match_block_trailing_comma = true +# condense_wildcard_suffixes = true +# format_strings = true diff --git a/Cargo.lock b/Cargo.lock index 3a95911..51c7756 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,14 +8,6 @@ dependencies = [ "memchr 2.3.3 (registry+https://github.com/rust-lang/crates.io-index)", ] -[[package]] -name = "ansi_term" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -dependencies = [ - "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "atty" version = "0.2.14" @@ -26,6 +18,11 @@ dependencies = [ "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "autocfg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "bitflags" version = "1.2.1" @@ -33,18 +30,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] name = "clap" -version = "2.33.0" +version = "3.0.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "ansi_term 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)", "atty 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", "bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "strsim 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", + "clap_derive 3.0.0-beta.1 (registry+https://github.com/rust-lang/crates.io-index)", + "indexmap 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)", + "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "os_str_bytes 2.3.1 (registry+https://github.com/rust-lang/crates.io-index)", + "strsim 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "term_size 1.0.0-beta1 (registry+https://github.com/rust-lang/crates.io-index)", + "termcolor 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "textwrap 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)", "unicode-width 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)", "vec_map 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "clap_derive" +version = "3.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "heck 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro-error 0.4.12 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro2 1.0.14 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.23 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "copyrat" +version = "0.1.0" +dependencies = [ + "clap 3.0.0-beta.1 (registry+https://github.com/rust-lang/crates.io-index)", + "regex 1.3.7 (registry+https://github.com/rust-lang/crates.io-index)", + "sequence_trie 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)", + "termion 1.5.5 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "heck" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "unicode-segmentation 1.6.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "hermit-abi" version = "0.1.12" @@ -53,6 +85,23 @@ dependencies = [ "libc 0.2.69 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "indexmap" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "autocfg 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "kernel32-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -73,6 +122,51 @@ name = "numtoa" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "os_str_bytes" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "proc-macro-error" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro-error-attr 0.4.12 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro2 1.0.14 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.23 (registry+https://github.com/rust-lang/crates.io-index)", + "version_check 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "proc-macro-error-attr" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 1.0.14 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.23 (registry+https://github.com/rust-lang/crates.io-index)", + "syn-mid 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "version_check 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "proc-macro2" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "unicode-xid 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "quote" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 1.0.14 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "redox_syscall" version = "0.1.56" @@ -103,10 +197,62 @@ version = "0.6.17" source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] -name = "strsim" -version = "0.8.0" +name = "sequence_trie" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "syn" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 1.0.14 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-xid 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "syn-mid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 1.0.14 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.23 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "term_size" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "libc 0.2.69 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "term_size" +version = "1.0.0-beta1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.69 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "termcolor" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "winapi-util 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "termion" version = "1.5.5" @@ -123,6 +269,7 @@ name = "textwrap" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ + "term_size 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)", "unicode-width 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -135,24 +282,35 @@ dependencies = [ ] [[package]] -name = "thumbs" -version = "0.4.1" -dependencies = [ - "clap 2.33.0 (registry+https://github.com/rust-lang/crates.io-index)", - "regex 1.3.7 (registry+https://github.com/rust-lang/crates.io-index)", - "termion 1.5.5 (registry+https://github.com/rust-lang/crates.io-index)", -] +name = "unicode-segmentation" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] name = "unicode-width" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "unicode-xid" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "vec_map" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "version_check" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "winapi" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "winapi" version = "0.3.8" @@ -162,11 +320,24 @@ dependencies = [ "winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -174,25 +345,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [metadata] "checksum aho-corasick 0.7.10 (registry+https://github.com/rust-lang/crates.io-index)" = "8716408b8bc624ed7f65d223ddb9ac2d044c0547b6fa4b0d554f3a9540496ada" -"checksum ansi_term 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" "checksum atty 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)" = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +"checksum autocfg 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" "checksum bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" -"checksum clap 2.33.0 (registry+https://github.com/rust-lang/crates.io-index)" = "5067f5bb2d80ef5d68b4c87db81601f0b75bca627bc2ef76b141d7b846a3c6d9" +"checksum clap 3.0.0-beta.1 (registry+https://github.com/rust-lang/crates.io-index)" = "860643c53f980f0d38a5e25dfab6c3c93b2cb3aa1fe192643d17a293c6c41936" +"checksum clap_derive 3.0.0-beta.1 (registry+https://github.com/rust-lang/crates.io-index)" = "fb51c9e75b94452505acd21d929323f5a5c6c4735a852adbd39ef5fb1b014f30" +"checksum heck 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" "checksum hermit-abi 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)" = "61565ff7aaace3525556587bd2dc31d4a07071957be715e63ce7b1eccf51a8f4" +"checksum indexmap 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "076f042c5b7b98f31d205f1249267e12a6518c1481e9dae9764af19b707d2292" +"checksum kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" "checksum lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" "checksum libc 0.2.69 (registry+https://github.com/rust-lang/crates.io-index)" = "99e85c08494b21a9054e7fe1374a732aeadaff3980b6990b94bfd3a70f690005" "checksum memchr 2.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400" "checksum numtoa 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" +"checksum os_str_bytes 2.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "06de47b848347d8c4c94219ad8ecd35eb90231704b067e67e6ae2e36ee023510" +"checksum proc-macro-error 0.4.12 (registry+https://github.com/rust-lang/crates.io-index)" = "18f33027081eba0a6d8aba6d1b1c3a3be58cbb12106341c2d5759fcd9b5277e7" +"checksum proc-macro-error-attr 0.4.12 (registry+https://github.com/rust-lang/crates.io-index)" = "8a5b4b77fdb63c1eca72173d68d24501c54ab1269409f6b672c85deb18af69de" +"checksum proc-macro2 1.0.14 (registry+https://github.com/rust-lang/crates.io-index)" = "de40dd4ff82d9c9bab6dae29dbab1167e515f8df9ed17d2987cb6012db206933" +"checksum quote 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)" = "54a21852a652ad6f610c9510194f398ff6f8692e334fd1145fed931f7fbe44ea" "checksum redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)" = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84" "checksum redox_termios 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "7e891cfe48e9100a70a3b6eb652fef28920c117d366339687bd5576160db0f76" "checksum regex 1.3.7 (registry+https://github.com/rust-lang/crates.io-index)" = "a6020f034922e3194c711b82a627453881bc4682166cabb07134a10c26ba7692" "checksum regex-syntax 0.6.17 (registry+https://github.com/rust-lang/crates.io-index)" = "7fe5bd57d1d7414c6b5ed48563a2c855d995ff777729dcd91c369ec7fea395ae" -"checksum strsim 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +"checksum sequence_trie 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)" = "1ee22067b7ccd072eeb64454b9c6e1b33b61cd0d49e895fd48676a184580e0c3" +"checksum strsim 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)" = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +"checksum syn 1.0.23 (registry+https://github.com/rust-lang/crates.io-index)" = "95b5f192649e48a5302a13f2feb224df883b98933222369e4b3b0fe2a5447269" +"checksum syn-mid 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7be3539f6c128a931cf19dcee741c1af532c7fd387baa739c03dd2e96479338a" +"checksum term_size 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9" +"checksum term_size 1.0.0-beta1 (registry+https://github.com/rust-lang/crates.io-index)" = "a8a17d8699e154863becdf18e4fd28bd0be27ca72856f54daf75c00f2566898f" +"checksum termcolor 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bb6bfa289a4d7c5766392812c0a1f4c1ba45afa1ad47803c11e1f407d846d75f" "checksum termion 1.5.5 (registry+https://github.com/rust-lang/crates.io-index)" = "c22cec9d8978d906be5ac94bceb5a010d885c626c4c8855721a4dbd20e3ac905" "checksum textwrap 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" "checksum thread_local 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14" +"checksum unicode-segmentation 1.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0" "checksum unicode-width 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "caaa9d531767d1ff2150b9332433f32a24622147e5ebb1f26409d5da67afd479" +"checksum unicode-xid 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c" "checksum vec_map 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "05c78687fb1a80548ae3250346c3db86a80a7cdd77bda190189f2d0a0987c81a" +"checksum version_check 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)" = "078775d0255232fb988e6fccf26ddc9d1ac274299aaedcedce21c6f72cc533ce" +"checksum winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" "checksum winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)" = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" +"checksum winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" "checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +"checksum winapi-util 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" "checksum winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml index 5ffbe36..ab11bcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,22 +1,23 @@ [package] -name = "thumbs" -version = "0.4.1" -authors = ["Ferran Basora "] +name = "copyrat" +version = "0.1.0" +authors = ["Ferran Basora ", "u0xy "] edition = "2018" -description = "A lightning fast version copy/pasting like vimium/vimperator" +description = "This is tmux-copycat on Rust steroids." repository = "https://github.com/fcsonline/tmux-thumbs" -keywords = ["rust", "tmux", "tmux-plugin", "vimium", "vimperator"] +keywords = ["rust", "tmux", "tmux-plugin", "tmux-copycat"] license = "MIT" [dependencies] termion = "1.5" regex = "1.3.1" -clap = "2.33.0" +clap = { version = "3.0.0-beta.1", features = ["suggestions", "color", "wrap_help", "term_size"]} +sequence_trie = "0.3.6" [[bin]] -name = "thumbs" +name = "copyrat" path = "src/main.rs" [[bin]] -name = "tmux-thumbs" -path = "src/swapper.rs" +name = "tmux-copyrat" +path = "src/bridge.rs" diff --git a/src/alphabets.rs b/src/alphabets.rs index 25c1040..e1e7aaa 100644 --- a/src/alphabets.rs +++ b/src/alphabets.rs @@ -1,108 +1,197 @@ -use std::collections::HashMap; +use crate::error; -const ALPHABETS: [(&'static str, &'static str); 22] = [ - ("numeric", "1234567890"), - ("abcd", "abcd"), - ("qwerty", "asdfqwerzxcvjklmiuopghtybn"), - ("qwerty-homerow", "asdfjklgh"), - ("qwerty-left-hand", "asdfqwerzcxv"), - ("qwerty-right-hand", "jkluiopmyhn"), - ("azerty", "qsdfazerwxcvjklmuiopghtybn"), - ("azerty-homerow", "qsdfjkmgh"), - ("azerty-left-hand", "qsdfazerwxcv"), - ("azerty-right-hand", "jklmuiophyn"), - ("qwertz", "asdfqweryxcvjkluiopmghtzbn"), - ("qwertz-homerow", "asdfghjkl"), - ("qwertz-left-hand", "asdfqweryxcv"), - ("qwertz-right-hand", "jkluiopmhzn"), - ("dvorak", "aoeuqjkxpyhtnsgcrlmwvzfidb"), - ("dvorak-homerow", "aoeuhtnsid"), - ("dvorak-left-hand", "aoeupqjkyix"), - ("dvorak-right-hand", "htnsgcrlmwvz"), - ("colemak", "arstqwfpzxcvneioluymdhgjbk"), - ("colemak-homerow", "arstneiodh"), - ("colemak-left-hand", "arstqwfpzxcv"), - ("colemak-right-hand", "neioluymjhk"), +/// Catalog of available alphabets. +/// +/// # Note +/// +/// Keep in mind letters 'n' and 'y' are systematically removed at runtime to +/// prevent conflict with navigation and yank/copy keys. +const ALPHABETS: [(&'static str, &'static str); 21] = [ + // ("abcd", "abcd"), + ("qwerty", "asdfqwerzxcvjklmiuopghtybn"), + ("qwerty-homerow", "asdfjklgh"), + ("qwerty-left-hand", "asdfqwerzcxv"), + ("qwerty-right-hand", "jkluiopmyhn"), + ("azerty", "qsdfazerwxcvjklmuiopghtybn"), + ("azerty-homerow", "qsdfjkmgh"), + ("azerty-left-hand", "qsdfazerwxcv"), + ("azerty-right-hand", "jklmuiophyn"), + ("qwertz", "asdfqweryxcvjkluiopmghtzbn"), + ("qwertz-homerow", "asdfghjkl"), + ("qwertz-left-hand", "asdfqweryxcv"), + ("qwertz-right-hand", "jkluiopmhzn"), + ("dvorak", "aoeuqjkxpyhtnsgcrlmwvzfidb"), + ("dvorak-homerow", "aoeuhtnsid"), + ("dvorak-left-hand", "aoeupqjkyix"), + ("dvorak-right-hand", "htnsgcrlmwvz"), + ("colemak", "arstqwfpzxcvneioluymdhgjbk"), + ("colemak-homerow", "arstneiodh"), + ("colemak-left-hand", "arstqwfpzxcv"), + ("colemak-right-hand", "neioluymjhk"), + ( + "longest", + "aoeuqjkxpyhtnsgcrlmwvzfidb-;,~<>'@!#$%^&*~1234567890", + ), ]; -pub struct Alphabet<'a> { - letters: &'a str, -} +/// Parse a name string into `Alphabet`, used during CLI parsing. +/// +/// # Note +/// +/// Letters 'n' and 'N' are systematically removed to prevent conflict with +/// navigation keys (arrows and 'n' 'N'). Letters 'y' and 'Y' are also removed +/// to prevent conflict with yank/copy. +pub fn parse_alphabet(src: &str) -> Result { + let alphabet_pair = ALPHABETS.iter().find(|&(name, _letters)| name == &src); -impl<'a> Alphabet<'a> { - fn new(letters: &'a str) -> Alphabet { - Alphabet { letters } - } - - pub fn hints(&self, matches: usize) -> Vec { - let letters: Vec = self.letters.chars().map(|s| s.to_string()).collect(); - - let mut expansion = letters.clone(); - let mut expanded: Vec = Vec::new(); - - loop { - if expansion.len() + expanded.len() >= matches { - break; - } - if expansion.is_empty() { - break; - } - - let prefix = expansion.pop().expect("Ouch!"); - let sub_expansion: Vec = letters - .iter() - .take(matches - expansion.len() - expanded.len()) - .map(|s| prefix.clone() + s) - .collect(); - - expanded.splice(0..0, sub_expansion); + match alphabet_pair { + Some((_name, letters)) => { + let letters = letters.replace(&['n', 'N', 'y', 'Y'][..], ""); + Ok(Alphabet(letters.to_string())) + } + None => Err(error::ParseError::UnknownAlphabet), } - - expansion = expansion.iter().take(matches - expanded.len()).cloned().collect(); - expansion.append(&mut expanded); - expansion - } } -pub fn get_alphabet(alphabet_name: &str) -> Alphabet { - let alphabets: HashMap<&str, &str> = ALPHABETS.iter().cloned().collect(); +/// Type-safe string alphabet (newtype). +#[derive(Debug)] +pub struct Alphabet(pub String); - alphabets - .get(alphabet_name) - .expect(format!("Unknown alphabet: {}", alphabet_name).as_str()); // FIXME +impl Alphabet { + /// Create `n` hints from the Alphabet. + /// + /// An Alphabet of `m` letters can produce at most `m^2` hints. In case + /// this limit is exceeded, this function will generate the `n` hints from + /// an Alphabet which has more letters (50). This will ensure 2500 hints + /// can be generated, which should cover all use cases (I think even + /// easymotion has less). + /// + /// If more hints are needed, unfortunately, this will keep producing + /// empty (`""`) hints. + /// + /// ``` + /// // The algorithm works as follows: + /// // --- lead ---- + /// // initial state | a b c d + /// + /// // along as we need more hints, and still have capacity, do the following + /// + /// // --- lead ---- --- gen --- -------------- prev --------------- + /// // pick d, generate da db dc dd | a b c (d) da db dc dd + /// // pick c, generate ca cb cc cd | a b (c) (d) ca cb cc cd da db dc dd + /// // pick b, generate ba bb bc bd | a (b) (c) (d) ba bb bc bd ca cb cc cd da db dc dd + /// // pick a, generate aa ab ac ad | (a) (b) (c) (d) aa ab ac ad ba bb bc bd ca cb cc cd da db dc dd + /// ``` + pub fn make_hints(&self, n: usize) -> Vec { + // Shortcut if we have enough letters in the Alphabet. + if self.0.len() >= n { + return self.0.chars().take(n).map(|c| c.to_string()).collect(); + } - Alphabet::new(alphabets[alphabet_name]) + // Use the "longest" alphabet if the current alphabet cannot produce as + // many hints as asked. + let letters: Vec = if self.0.len().pow(2) >= n { + self.0.chars().collect() + } else { + let alt_alphabet = parse_alphabet("longest").unwrap(); + alt_alphabet.0.chars().collect() + }; + + let mut lead = letters.clone(); + let mut prev: Vec = Vec::new(); + + loop { + if lead.len() + prev.len() >= n { + break; + } + + if lead.is_empty() { + break; + } + let prefix = lead.pop().unwrap(); + + // generate characters pairs + let gen: Vec = letters + .iter() + .take(n - lead.len() - prev.len()) + .map(|c| format!("{}{}", prefix, c)) + .collect(); + + // Insert gen in front of prev + prev.splice(..0, gen); + } + + // Finalize by concatenating the lead and prev components, filling + // with "" as necessary. + let lead: Vec = lead.iter().map(|c| c.to_string()).collect(); + + let filler: Vec = std::iter::repeat("") + .take(n - lead.len() - prev.len()) + .map(|s| s.to_string()) + .collect(); + + [lead, prev, filler].concat() + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn simple_matches() { - let alphabet = Alphabet::new("abcd"); - let hints = alphabet.hints(3); - assert_eq!(hints, ["a", "b", "c"]); - } + #[test] + fn simple_matches() { + let alphabet = Alphabet("abcd".to_string()); + let hints = alphabet.make_hints(3); + assert_eq!(hints, ["a", "b", "c"]); + } - #[test] - fn composed_matches() { - let alphabet = Alphabet::new("abcd"); - let hints = alphabet.hints(6); - assert_eq!(hints, ["a", "b", "c", "da", "db", "dc"]); - } + #[test] + fn composed_matches() { + let alphabet = Alphabet("abcd".to_string()); + let hints = alphabet.make_hints(6); + assert_eq!(hints, ["a", "b", "c", "da", "db", "dc"]); + } - #[test] - fn composed_matches_multiple() { - let alphabet = Alphabet::new("abcd"); - let hints = alphabet.hints(8); - assert_eq!(hints, ["a", "b", "ca", "cb", "da", "db", "dc", "dd"]); - } + #[test] + fn composed_matches_multiple() { + let alphabet = Alphabet("abcd".to_string()); + let hints = alphabet.make_hints(8); + assert_eq!(hints, ["a", "b", "ca", "cb", "da", "db", "dc", "dd"]); + } - #[test] - fn composed_matches_max() { - let alphabet = Alphabet::new("ab"); - let hints = alphabet.hints(8); - assert_eq!(hints, ["aa", "ab", "ba", "bb"]); - } + #[test] + fn composed_matches_max_2() { + let alphabet = Alphabet("ab".to_string()); + let hints = alphabet.make_hints(4); + assert_eq!(hints, ["aa", "ab", "ba", "bb"]); + } + + #[test] + fn composed_matches_max_4() { + let alphabet = Alphabet("abcd".to_string()); + let hints = alphabet.make_hints(13); + assert_eq!( + hints, + ["a", "ba", "bb", "bc", "bd", "ca", "cb", "cc", "cd", "da", "db", "dc", "dd"] + ); + } + + #[test] + fn matches_with_longest_alphabet() { + let alphabet = Alphabet("ab".to_string()); + let hints = alphabet.make_hints(2500); + assert_eq!(hints.len(), 2500); + assert_eq!(&hints[..3], ["aa", "ao", "ae"]); + assert_eq!(&hints[2497..], ["08", "09", "00"]); + } + + #[test] + fn matches_exceed_longest_alphabet() { + let alphabet = Alphabet("ab".to_string()); + let hints = alphabet.make_hints(10000); + // 2500 unique hints are produced from the longest alphabet + // The 7500 last ones come from the filler ("" empty hints). + assert_eq!(hints.len(), 10000); + assert!(&hints[2500..].iter().all(|s| s == "")); + } } diff --git a/src/bridge.rs b/src/bridge.rs new file mode 100644 index 0000000..45e4304 --- /dev/null +++ b/src/bridge.rs @@ -0,0 +1,135 @@ +use clap::Clap; +use std::collections::HashMap; +use std::str::FromStr; + +use copyrat::{error, process, CliOpt}; + +mod tmux; + +/// Main configuration, parsed from command line. +#[derive(Clap, Debug)] +#[clap(author, about, version)] +struct BridgeOpt { + /// Don't read options from Tmux. + /// + /// By default, options formatted like `copyrat-*` are read from tmux. + /// However, you should consider reading them from the config file (the + /// default option) as this saves both a command call (about 10ms) and a + /// Regex compilation. + #[clap(long)] + ignore_options_from_tmux: bool, + + /// Name of the copyrat temporary window. + /// + /// Copyrat is launched in a temporary window of that name. The only pane + /// in this temp window gets swapped with the current active one for + /// in-place searching, then swapped back and killed after we exit. + #[clap(long, default_value = "[copyrat]")] + window_name: String, + + /// Capture visible area or entire pane history. + #[clap(long, arg_enum, default_value = "visible-area")] + capture_region: tmux::CaptureRegion, + + // Include CLI Options + #[clap(flatten)] + cli_options: CliOpt, +} + +impl BridgeOpt { + /// Try parsing provided options, and update self with the valid values. + /// Unknown options are simply ignored. + pub fn merge_map( + &mut self, + options: &HashMap, + ) -> Result<(), error::ParseError> { + for (name, value) in options { + match name.as_ref() { + "@copyrat-capture" => { + self.capture_region = tmux::CaptureRegion::from_str(&value)?; + } + _ => (), + } + } + + // Pass the call to cli_options. + self.cli_options.merge_map(options)?; + + Ok(()) + } +} + +/// +fn main() -> Result<(), error::ParseError> { + let mut opt = BridgeOpt::parse(); + + if !opt.ignore_options_from_tmux { + let tmux_options: HashMap = tmux::get_options("@copyrat-")?; + + // Override default values with those coming from tmux. + opt.merge_map(&tmux_options)?; + } + + // Identify active pane and capture its content. + let panes: Vec = tmux::list_panes()?; + + let active_pane = panes + .into_iter() + .find(|p| p.is_active) + .expect("Exactly one tmux pane should be active in the current window."); + + let buffer = tmux::capture_pane(&active_pane, &opt.capture_region)?; + + // We have to dance a little with Panes, because this process i/o streams + // are connected to the pane in the window newly created for us, instead + // of the active current pane. + let temp_pane_spec = format!("{}.0", opt.window_name); + tmux::swap_pane_with(&temp_pane_spec)?; + + let selections = copyrat::run(buffer, &opt.cli_options); + + tmux::swap_pane_with(&temp_pane_spec)?; + + // Finally copy selection to a tmux buffer, and paste it to the active + // buffer if it was uppercased. + // TODO: consider getting rid of multi-selection mode. + + // Execute a command on each group of selections (normal and uppercased). + let (normal_selections, uppercased_selections): (Vec<(String, bool)>, Vec<(String, bool)>) = + selections + .into_iter() + .partition(|(_text, uppercased)| !*uppercased); + + let buffer_selections: String = normal_selections + .into_iter() + .map(|(text, _)| text) + .collect::>() + .join("\n"); + + if buffer_selections.len() > 0 { + let args = vec!["set-buffer", &buffer_selections]; + // Simply execute the command as is, and let the program crash on + // potential errors because it is not our responsibility. + process::execute("tmux", &args).unwrap(); + } + + let buffer_selections: String = uppercased_selections + .into_iter() + .map(|(text, _)| text) + .collect::>() + .join("\n"); + + if buffer_selections.len() > 0 { + let args = vec!["set-buffer", &buffer_selections]; + // Simply execute the command as is, and let the program crash on + // potential errors because it is not our responsibility. + process::execute("tmux", &args).unwrap(); + + let args = vec!["paste-buffer", "-t", active_pane.id.as_str()]; + // Simply execute the command as is, and let the program crash on + // potential errors because it is not our responsibility. + process::execute("tmux", &args).unwrap(); + } + + Ok(()) +} diff --git a/src/colors.rs b/src/colors.rs index be5f92d..c3614db 100644 --- a/src/colors.rs +++ b/src/colors.rs @@ -1,35 +1,47 @@ +use crate::error; use termion::color; -pub fn get_color(color_name: &str) -> Box<&dyn color::Color> { - match color_name { - "black" => Box::new(&color::Black), - "red" => Box::new(&color::Red), - "green" => Box::new(&color::Green), - "yellow" => Box::new(&color::Yellow), - "blue" => Box::new(&color::Blue), - "magenta" => Box::new(&color::Magenta), - "cyan" => Box::new(&color::Cyan), - "white" => Box::new(&color::White), - "default" => Box::new(&color::Reset), - _ => panic!("Unknown color: {}", color_name), - } +pub fn parse_color(src: &str) -> Result, error::ParseError> { + match src { + "black" => Ok(Box::new(color::Black)), + "red" => Ok(Box::new(color::Red)), + "green" => Ok(Box::new(color::Green)), + "yellow" => Ok(Box::new(color::Yellow)), + "blue" => Ok(Box::new(color::Blue)), + "magenta" => Ok(Box::new(color::Magenta)), + "cyan" => Ok(Box::new(color::Cyan)), + "white" => Ok(Box::new(color::White)), + "bright-black" => Ok(Box::new(color::LightBlack)), + "bright-red" => Ok(Box::new(color::LightRed)), + "bright-green" => Ok(Box::new(color::LightGreen)), + "bright-yellow" => Ok(Box::new(color::LightYellow)), + "bright-blue" => Ok(Box::new(color::LightBlue)), + "bright-magenta" => Ok(Box::new(color::LightMagenta)), + "bright-cyan" => Ok(Box::new(color::LightCyan)), + "bright-white" => Ok(Box::new(color::LightWhite)), + // "default" => Ok(Box::new(color::Reset)), + _ => Err(error::ParseError::UnknownColor), + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn match_color() { - let text1 = println!("{}{}", color::Fg(*get_color("green")), "foo"); - let text2 = println!("{}{}", color::Fg(color::Green), "foo"); + #[test] + fn match_color() { + let text1 = format!( + "{}{}", + color::Fg(parse_color("green").unwrap().as_ref()), + "foo" + ); + let text2 = format!("{}{}", color::Fg(color::Green), "foo"); - assert_eq!(text1, text2); - } + assert_eq!(text1, text2); + } - #[test] - #[should_panic] - fn no_match_color() { - println!("{}{}", color::Fg(*get_color("wat")), "foo"); - } + #[test] + fn no_match_color() { + assert!(parse_color("wat").is_err(), "this color should not exist"); + } } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..89b7398 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,50 @@ +use std::fmt; + +#[derive(Debug)] +pub enum ParseError { + ExpectedSurroundingPair, + UnknownAlphabet, + UnknownColor, + UnknownPatternName, + ExpectedPaneIdMarker, + ExpectedInt(std::num::ParseIntError), + ExpectedBool(std::str::ParseBoolError), + ExpectedString(String), + ProcessFailure(String), +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ParseError::ExpectedSurroundingPair => write!(f, "Expected 2 chars"), + ParseError::UnknownAlphabet => write!(f, "Expected a known alphabet"), + ParseError::UnknownColor => { + write!(f, "Expected ANSI color name (magenta, cyan, black, ...)") + } + ParseError::UnknownPatternName => write!(f, "Expected a known pattern name"), + ParseError::ExpectedPaneIdMarker => write!(f, "Expected pane id marker"), + ParseError::ExpectedInt(msg) => write!(f, "Expected an int: {}", msg), + ParseError::ExpectedBool(msg) => write!(f, "Expected a bool: {}", msg), + ParseError::ExpectedString(msg) => write!(f, "Expected {}", msg), + ParseError::ProcessFailure(msg) => write!(f, "{}", msg), + } + } +} + +impl From for ParseError { + fn from(error: std::num::ParseIntError) -> Self { + ParseError::ExpectedInt(error) + } +} + +impl From for ParseError { + fn from(error: std::str::ParseBoolError) -> Self { + ParseError::ExpectedBool(error) + } +} + +impl From for ParseError { + fn from(error: std::io::Error) -> Self { + ParseError::ProcessFailure(error.to_string()) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..742ba30 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,197 @@ +use clap::Clap; +use std::collections::HashMap; +use std::path; +use std::str::FromStr; + +pub mod alphabets; +pub mod colors; +pub mod error; +pub mod model; +pub mod process; +pub mod regexes; +pub mod ui; + +/// Run copyrat on an input string `buffer`, configured by `Opt`. +/// +/// # Note +/// +/// Maybe the decision to take ownership of the buffer is a bit bold. +pub fn run(buffer: String, opt: &CliOpt) -> Option<(String, bool)> { + let mut model = model::Model::new( + &buffer, + &opt.alphabet, + &opt.named_pattern, + &opt.custom_regex, + opt.reverse, + ); + + let hint_style = match &opt.hint_style { + None => None, + Some(style) => match style { + HintStyleCli::Bold => Some(ui::HintStyle::Bold), + HintStyleCli::Italic => Some(ui::HintStyle::Italic), + HintStyleCli::Underline => Some(ui::HintStyle::Underline), + HintStyleCli::Surround => { + let (open, close) = opt.hint_surroundings; + Some(ui::HintStyle::Surround(open, close)) + } + }, + }; + + let selection: Option<(String, bool)> = { + let mut ui = ui::Ui::new( + &mut model, + opt.unique_hint, + opt.focus_wrap_around, + &opt.colors, + &opt.hint_alignment, + hint_style, + ); + + ui.present() + }; + + selection +} + +/// Main configuration, parsed from command line. +#[derive(Clap, Debug)] +#[clap(author, about, version)] +pub struct CliOpt { + /// Alphabet to draw hints from. + /// + /// Possible values are "{A}", "{A}-homerow", "{A}-left-hand", + /// "{A}-right-hand", where "{A}" is one of "qwerty", "azerty", "qwertz" + /// "dvorak", "colemak". + /// + /// # Examples + /// + /// "qwerty", "dvorak-homerow", "azerty-right-hand". + #[clap(short = "k", long, default_value = "dvorak", + parse(try_from_str = alphabets::parse_alphabet))] + alphabet: alphabets::Alphabet, + + /// Pattern names to use (all if not specified). + #[clap(short = "x", long = "--pattern-name", parse(try_from_str = regexes::parse_pattern_name))] + named_pattern: Vec, + + /// Additional regex patterns. + #[clap(short = "X", long)] + custom_regex: Vec, + + /// Assign hints starting from the bottom of the screen. + #[clap(short, long)] + reverse: bool, + + /// Keep the same hint for identical matches. + #[clap(short, long)] + unique_hint: bool, + + #[clap(flatten)] + colors: ui::UiColors, + + /// Align hint with its match. + #[clap(short = "a", long, arg_enum, default_value = "leading")] + hint_alignment: ui::HintAlignment, + + /// Move focus back to first/last match. + #[clap(long)] + focus_wrap_around: bool, + + /// Optional hint styling. + /// + /// Underline or surround the hint for increased visibility. + /// If not provided, only the hint colors will be used. + #[clap(short = "s", long, arg_enum)] + hint_style: Option, + + /// Chars surrounding each hint, used with `Surround` style. + #[clap(long, default_value = "{}", + parse(try_from_str = parse_chars))] + hint_surroundings: (char, char), + + /// Optional target path where to store the selected matches. + #[clap(short = "o", long = "output", parse(from_os_str))] + pub target_path: Option, + + /// Describes if the uppercased marker should be added to the output, + /// indicating if hint key was uppercased. This is only used by + /// tmux-copyrat, so it is hidden (skipped) from the CLI. + #[clap(skip)] + pub uppercased_marker: bool, +} + +/// Type introduced due to parsing limitation, +/// as we cannot directly parse into ui::HintStyle. +#[derive(Debug, Clap)] +enum HintStyleCli { + Bold, + Italic, + Underline, + Surround, +} + +impl FromStr for HintStyleCli { + type Err = error::ParseError; + + fn from_str(s: &str) -> Result { + match s { + "leading" => Ok(HintStyleCli::Underline), + "trailing" => Ok(HintStyleCli::Surround), + _ => Err(error::ParseError::ExpectedString(String::from( + "underline or surround", + ))), + } + } +} + +/// Try to parse a `&str` into a tuple of `char`s. +fn parse_chars(src: &str) -> Result<(char, char), error::ParseError> { + if src.len() != 2 { + return Err(error::ParseError::ExpectedSurroundingPair); + } + + let chars: Vec = src.chars().collect(); + Ok((chars[0], chars[1])) +} + +impl CliOpt { + /// Try parsing provided options, and update self with the valid values. + pub fn merge_map( + &mut self, + options: &HashMap, + ) -> Result<(), error::ParseError> { + for (name, value) in options { + match name.as_ref() { + "@copyrat-alphabet" => { + self.alphabet = alphabets::parse_alphabet(value)?; + } + "@copyrat-regex-id" => (), // TODO + "@copyrat-custom-regex" => self.custom_regex = vec![String::from(value)], + "@copyrat-reverse" => { + self.reverse = value.parse::()?; + } + "@copyrat-unique-hint" => { + self.unique_hint = value.parse::()?; + } + + "@copyrat-match-fg" => self.colors.match_fg = colors::parse_color(value)?, + "@copyrat-match-bg" => self.colors.match_bg = colors::parse_color(value)?, + "@copyrat-focused-fg" => self.colors.focused_fg = colors::parse_color(value)?, + "@copyrat-focused-bg" => self.colors.focused_bg = colors::parse_color(value)?, + "@copyrat-hint-fg" => self.colors.hint_fg = colors::parse_color(value)?, + "@copyrat-hint-bg" => self.colors.hint_bg = colors::parse_color(value)?, + + "@copyrat-hint-alignment" => { + self.hint_alignment = ui::HintAlignment::from_str(&value)? + } + "@copyrat-hint-style" => self.hint_style = Some(HintStyleCli::from_str(&value)?), + + // Ignore unknown options. + _ => (), + } + } + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index eda20eb..2cee8da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,200 +1,43 @@ -extern crate clap; -extern crate termion; - -mod alphabets; -mod colors; -mod state; -mod view; - -use self::clap::{App, Arg}; -use clap::crate_version; +use clap::Clap; use std::fs::OpenOptions; use std::io::prelude::*; use std::io::{self, Read}; -fn app_args<'a>() -> clap::ArgMatches<'a> { - App::new("thumbs") - .version(crate_version!()) - .about("A lightning fast version copy/pasting like vimium/vimperator") - .arg( - Arg::with_name("alphabet") - .help("Sets the alphabet") - .long("alphabet") - .short("a") - .default_value("qwerty"), - ) - .arg( - Arg::with_name("format") - .help("Specifies the out format for the picked hint. (%U: Upcase, %H: Hint)") - .long("format") - .short("f") - .default_value("%H"), - ) - .arg( - Arg::with_name("foreground_color") - .help("Sets the foregroud color for matches") - .long("fg-color") - .default_value("green"), - ) - .arg( - Arg::with_name("background_color") - .help("Sets the background color for matches") - .long("bg-color") - .default_value("black"), - ) - .arg( - Arg::with_name("hint_foreground_color") - .help("Sets the foregroud color for hints") - .long("hint-fg-color") - .default_value("yellow"), - ) - .arg( - Arg::with_name("hint_background_color") - .help("Sets the background color for hints") - .long("hint-bg-color") - .default_value("black"), - ) - .arg( - Arg::with_name("select_foreground_color") - .help("Sets the foreground color for selection") - .long("select-fg-color") - .default_value("blue"), - ) - .arg( - Arg::with_name("select_background_color") - .help("Sets the background color for selection") - .long("select-bg-color") - .default_value("black"), - ) - .arg( - Arg::with_name("multi") - .help("Enable multi-selection") - .long("multi") - .short("m"), - ) - .arg( - Arg::with_name("reverse") - .help("Reverse the order for assigned hints") - .long("reverse") - .short("r"), - ) - .arg( - Arg::with_name("unique") - .help("Don't show duplicated hints for the same match") - .long("unique") - .short("u"), - ) - .arg( - Arg::with_name("position") - .help("Hint position") - .long("position") - .default_value("left") - .short("p"), - ) - .arg( - Arg::with_name("regexp") - .help("Use this regexp as extra pattern to match") - .long("regexp") - .short("x") - .takes_value(true) - .multiple(true), - ) - .arg( - Arg::with_name("contrast") - .help("Put square brackets around hint for visibility") - .long("contrast") - .short("c"), - ) - .arg( - Arg::with_name("target") - .help("Stores the hint in the specified path") - .long("target") - .short("t") - .takes_value(true), - ) - .get_matches() -} +use copyrat::{run, CliOpt}; fn main() { - let args = app_args(); - let format = args.value_of("format").unwrap(); - let alphabet = args.value_of("alphabet").unwrap(); - let position = args.value_of("position").unwrap(); - let target = args.value_of("target"); - let multi = args.is_present("multi"); - let reverse = args.is_present("reverse"); - let unique = args.is_present("unique"); - let contrast = args.is_present("contrast"); - let regexp = if let Some(items) = args.values_of("regexp") { - items.collect::>() - } else { - [].to_vec() - }; + let opt = CliOpt::parse(); - let foreground_color = colors::get_color(args.value_of("foreground_color").unwrap()); - let background_color = colors::get_color(args.value_of("background_color").unwrap()); - let hint_foreground_color = colors::get_color(args.value_of("hint_foreground_color").unwrap()); - let hint_background_color = colors::get_color(args.value_of("hint_background_color").unwrap()); - let select_foreground_color = colors::get_color(args.value_of("select_foreground_color").unwrap()); - let select_background_color = colors::get_color(args.value_of("select_background_color").unwrap()); + // Copy the pane contents (piped in via stdin) into a buffer, and split lines. + let stdin = io::stdin(); + let mut handle = stdin.lock(); - let stdin = io::stdin(); - let mut handle = stdin.lock(); - let mut output = String::new(); + let mut buffer = String::new(); + handle.read_to_string(&mut buffer).unwrap(); - handle.read_to_string(&mut output).unwrap(); + // Execute copyrat over the buffer (will take control over stdout). + // This returns the selected matches. + let selection: Option<(String, bool)> = run(buffer, &opt); - let lines = output.split('\n').collect::>(); - - let mut state = state::State::new(&lines, alphabet, ®exp); - - let selected = { - let mut viewbox = view::View::new( - &mut state, - multi, - reverse, - unique, - contrast, - position, - select_foreground_color, - select_background_color, - foreground_color, - background_color, - hint_foreground_color, - hint_background_color, - ); - - viewbox.present() - }; - - if !selected.is_empty() { - let output = selected - .iter() - .map(|(text, upcase)| { - let upcase_value = if *upcase { "true" } else { "false" }; - - let mut output = format.to_string(); - - output = str::replace(&output, "%U", upcase_value); - output = str::replace(&output, "%H", text.as_str()); - output - }) - .collect::>() - .join("\n"); - - if let Some(target) = target { - let mut file = OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(target) - .expect("Unable to open the target file"); - - file.write(output.as_bytes()).unwrap(); - } else { - print!("{}", output); + // Early exit, signaling no selections were found. + if selection.is_none() { + std::process::exit(1); + } + + let (text, _) = selection.unwrap(); + + // Write output to a target_path if provided, else print to original stdout. + match opt.target_path { + None => println!("{}", text), + Some(target) => { + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(target) + .expect("Unable to open the target file"); + + file.write(text.as_bytes()).unwrap(); + } } - } else { - ::std::process::exit(1); - } } diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..4334fdc --- /dev/null +++ b/src/model.rs @@ -0,0 +1,559 @@ +use std::collections; + +use regex::Regex; +use sequence_trie::SequenceTrie; + +use crate::alphabets::Alphabet; +use crate::regexes::{NamedPattern, EXCLUDE_PATTERNS, PATTERNS}; + +/// Holds data for the `Ui`. +pub struct Model<'a> { + // buffer: &'a str, + pub lines: Vec<&'a str>, + alphabet: &'a Alphabet, + named_patterns: &'a Vec, + custom_regexes: &'a Vec, + pub reverse: bool, +} + +impl<'a> Model<'a> { + pub fn new( + buffer: &'a str, + alphabet: &'a Alphabet, + named_patterns: &'a Vec, + custom_regexes: &'a Vec, + reverse: bool, + ) -> Model<'a> { + let lines = buffer.split('\n').collect(); + + Model { + // buffer, + lines, + alphabet, + named_patterns, + custom_regexes, + reverse, + } + } + + /// Returns a vector of `Match`es, each corresponding to a pattern match + /// in the lines, its location (x, y), and associated hint. + pub fn matches(&self, unique: bool) -> Vec> { + let mut raw_matches = self.raw_matches(); + + if self.reverse { + raw_matches.reverse(); + } + + let mut matches = self.associate_hints(&raw_matches, unique); + + if self.reverse { + matches.reverse(); + } + + matches + } + + /// Internal function that searches the model's lines for pattern matches. + /// Returns a vector of `RawMatch`es (text, location, pattern id) without + /// an associated hint. The hint is attached to `Match`, not to `RawMatch`. + /// + /// # Notes + /// + /// Custom regexes have priority over other regexes. + /// + /// If no named patterns were specified, it will search for all available + /// patterns from the `PATTERNS` catalog. + fn raw_matches(&self) -> Vec> { + let mut matches = Vec::new(); + + let exclude_regexes = EXCLUDE_PATTERNS + .iter() + .map(|&(name, pattern)| (name, Regex::new(pattern).unwrap())) + .collect::>(); + + let custom_regexes = self + .custom_regexes + .iter() + .map(|pattern| { + ( + "custom", + Regex::new(pattern).expect("Invalid custom regexp"), + ) + }) + .collect::>(); + + let regexes = if self.named_patterns.is_empty() { + PATTERNS + .iter() + .map(|&(name, pattern)| (name, Regex::new(pattern).unwrap())) + .collect::>() + } else { + self.named_patterns + .iter() + .map(|NamedPattern(name, pattern)| (name.as_str(), Regex::new(pattern).unwrap())) + .collect::>() + }; + + let all_regexes = [exclude_regexes, custom_regexes, regexes].concat(); + + for (index, line) in self.lines.iter().enumerate() { + // Remainder of the line to be searched for matches. + // This advances iteratively, until no matches can be found. + let mut chunk: &str = line; + let mut offset: i32 = 0; + + // Use all avail regexes to match the chunk and select the match + // occuring the earliest on the chunk. Save its matched text and + // position in a `Match` struct. + loop { + let chunk_matches = all_regexes + .iter() + .filter_map(|(&ref name, regex)| match regex.find_iter(chunk).nth(0) { + Some(m) => Some((name, regex, m)), + None => None, + }) + .collect::>(); + + if chunk_matches.is_empty() { + break; + } + + // First match on the chunk. + let (name, pattern, matching) = chunk_matches + .iter() + .min_by(|x, y| x.2.start().cmp(&y.2.start())) + .unwrap(); + + let text = matching.as_str(); + + let captures = pattern + .captures(text) + .expect("At this stage the regex must have matched."); + + // Handle both capturing and non-capturing patterns. + let (subtext, substart) = if let Some(capture) = captures.get(1) { + (capture.as_str(), capture.start()) + } else { + (text, 0) + }; + + // Never hint or break ansi color sequences. + if *name != "ansi_colors" { + matches.push(RawMatch { + x: offset + matching.start() as i32 + substart as i32, + y: index as i32, + pattern: name, + text: subtext, + }); + } + + chunk = chunk.get(matching.end()..).expect("Unknown chunk"); + offset += matching.end() as i32; + } + } + + matches + } + + /// Associate a hint to each `RawMatch`, returning a vector of `Match`es. + /// + /// If `unique` is `true`, all duplicate matches will have the same hint. + /// For copying matched text, this seems easier and more natural. + /// If `unique` is `false`, duplicate matches will have their own hint. + fn associate_hints(&self, raw_matches: &Vec>, unique: bool) -> Vec> { + let hints = self.alphabet.make_hints(raw_matches.len()); + let mut hints_iter = hints.iter(); + + let mut result: Vec> = vec![]; + + if unique { + // Map (text, hint) + let mut known: collections::HashMap<&str, &str> = collections::HashMap::new(); + + for raw_mat in raw_matches { + let hint: &str = known.entry(raw_mat.text).or_insert( + hints_iter + .next() + .expect("We should have as many hints as necessary, even invisible ones."), + ); + + result.push(Match { + x: raw_mat.x, + y: raw_mat.y, + pattern: raw_mat.pattern, + text: raw_mat.text, + hint: hint.to_string(), + }); + } + } else { + for raw_mat in raw_matches { + let hint = hints_iter + .next() + .expect("We should have as many hints as necessary, even invisible ones."); + + result.push(Match { + x: raw_mat.x, + y: raw_mat.y, + pattern: raw_mat.pattern, + text: raw_mat.text, + hint: hint.to_string(), + }); + } + } + + result + } + + /// Builds a `SequenceTrie` that helps determine if a sequence of keys + /// entered by the user corresponds to a match. This kind of lookup + /// directly returns a reference to the corresponding `Match` if any. + pub fn build_lookup_trie(matches: &'a Vec>) -> SequenceTrie { + let mut trie = SequenceTrie::new(); + + for (index, mat) in matches.iter().enumerate() { + let hint_chars = mat.hint.chars().collect::>(); + + // no need to insert twice the same hint + if trie.get(&hint_chars).is_none() { + trie.insert_owned(hint_chars, index); + } + } + + trie + } +} + +/// Represents matched text, its location on screen, the pattern that created +/// it, and the associated hint. +#[derive(Debug)] +pub struct Match<'a> { + pub x: i32, + pub y: i32, + pub pattern: &'a str, + pub text: &'a str, + pub hint: String, +} + +/// Internal surrogate for `Match`, before a Hint has been associated. +#[derive(Debug)] +struct RawMatch<'a> { + pub x: i32, + pub y: i32, + pub pattern: &'a str, + pub text: &'a str, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::alphabets::Alphabet; + + #[test] + fn match_reverse() { + let buffer = "lorem 127.0.0.1 lorem 255.255.255.255 lorem 127.0.0.1 lorem"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 3); + assert_eq!(results.first().unwrap().hint, "a"); + assert_eq!(results.last().unwrap().hint, "c"); + } + + #[test] + fn match_unique() { + let buffer = "lorem 127.0.0.1 lorem 255.255.255.255 lorem 127.0.0.1 lorem"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(true); + + assert_eq!(results.len(), 3); + assert_eq!(results.first().unwrap().hint, "a"); + assert_eq!(results.last().unwrap().hint, "a"); + } + + #[test] + fn match_docker() { + let buffer = "latest sha256:30557a29d5abc51e5f1d5b472e79b7e296f595abcf19fe6b9199dbbc809c6ff4 20 hours ago"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 1); + assert_eq!( + results.get(0).unwrap().text, + "30557a29d5abc51e5f1d5b472e79b7e296f595abcf19fe6b9199dbbc809c6ff4" + ); + } + + #[test] + fn match_ansi_colors() { + let buffer = "path: /var/log/nginx.log\npath: test/log/nginx-2.log:32folder/.nginx@4df2.log"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 3); + assert_eq!(results.get(0).unwrap().text, "/var/log/nginx.log"); + assert_eq!(results.get(1).unwrap().text, "test/log/nginx-2.log"); + assert_eq!(results.get(2).unwrap().text, "folder/.nginx@4df2.log"); + } + + #[test] + fn match_paths() { + let buffer = "Lorem /tmp/foo/bar_lol, lorem\n Lorem /var/log/boot-strap.log lorem ../log/kern.log lorem"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 3); + assert_eq!(results.get(0).unwrap().text, "/tmp/foo/bar_lol"); + assert_eq!(results.get(1).unwrap().text, "/var/log/boot-strap.log"); + assert_eq!(results.get(2).unwrap().text, "../log/kern.log"); + } + + #[test] + fn match_home() { + let buffer = "Lorem ~/.gnu/.config.txt, lorem"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 1); + assert_eq!(results.get(0).unwrap().text, "~/.gnu/.config.txt"); + } + + #[test] + fn match_uuids() { + let buffer = "Lorem ipsum 123e4567-e89b-12d3-a456-426655440000 lorem\n Lorem lorem lorem"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 1); + } + + #[test] + fn match_shas() { + let buffer = "Lorem fd70b5695 5246ddf f924213 lorem\n Lorem 973113963b491874ab2e372ee60d4b4cb75f717c lorem"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 4); + assert_eq!(results.get(0).unwrap().text, "fd70b5695"); + assert_eq!(results.get(1).unwrap().text, "5246ddf"); + assert_eq!(results.get(2).unwrap().text, "f924213"); + assert_eq!( + results.get(3).unwrap().text, + "973113963b491874ab2e372ee60d4b4cb75f717c" + ); + } + + #[test] + fn match_ips() { + let buffer = "Lorem ipsum 127.0.0.1 lorem\n Lorem 255.255.10.255 lorem 127.0.0.1 lorem"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 3); + assert_eq!(results.get(0).unwrap().text, "127.0.0.1"); + assert_eq!(results.get(1).unwrap().text, "255.255.10.255"); + assert_eq!(results.get(2).unwrap().text, "127.0.0.1"); + } + + #[test] + fn match_ipv6s() { + let buffer = "Lorem ipsum fe80::2:202:fe4 lorem\n Lorem 2001:67c:670:202:7ba8:5e41:1591:d723 lorem fe80::2:1 lorem ipsum fe80:22:312:fe::1%eth0"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 4); + assert_eq!(results.get(0).unwrap().text, "fe80::2:202:fe4"); + assert_eq!( + results.get(1).unwrap().text, + "2001:67c:670:202:7ba8:5e41:1591:d723" + ); + assert_eq!(results.get(2).unwrap().text, "fe80::2:1"); + assert_eq!(results.get(3).unwrap().text, "fe80:22:312:fe::1%eth0"); + } + + #[test] + fn match_markdown_urls() { + let buffer = + "Lorem ipsum [link](https://github.io?foo=bar) ![](http://cdn.com/img.jpg) lorem"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 2); + assert_eq!(results.get(0).unwrap().pattern, "markdown_url"); + assert_eq!(results.get(0).unwrap().text, "https://github.io?foo=bar"); + assert_eq!(results.get(1).unwrap().pattern, "markdown_url"); + assert_eq!(results.get(1).unwrap().text, "http://cdn.com/img.jpg"); + } + + #[test] + fn match_urls() { + let buffer = "Lorem ipsum https://www.rust-lang.org/tools lorem\n Lorem ipsumhttps://crates.io lorem https://github.io?foo=bar lorem ssh://github.io"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 4); + assert_eq!( + results.get(0).unwrap().text, + "https://www.rust-lang.org/tools" + ); + assert_eq!(results.get(0).unwrap().pattern, "url"); + assert_eq!(results.get(1).unwrap().text, "https://crates.io"); + assert_eq!(results.get(1).unwrap().pattern, "url"); + assert_eq!(results.get(2).unwrap().text, "https://github.io?foo=bar"); + assert_eq!(results.get(2).unwrap().pattern, "url"); + assert_eq!(results.get(3).unwrap().text, "ssh://github.io"); + assert_eq!(results.get(3).unwrap().pattern, "url"); + } + + #[test] + fn match_addresses() { + let buffer = "Lorem 0xfd70b5695 0x5246ddf lorem\n Lorem 0x973113tlorem"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 3); + assert_eq!(results.get(0).unwrap().text, "0xfd70b5695"); + assert_eq!(results.get(1).unwrap().text, "0x5246ddf"); + assert_eq!(results.get(2).unwrap().text, "0x973113"); + } + + #[test] + fn match_hex_colors() { + let buffer = "Lorem #fd7b56 lorem #FF00FF\n Lorem #00fF05 lorem #abcd00 lorem #afRR00"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 4); + assert_eq!(results.get(0).unwrap().text, "#fd7b56"); + assert_eq!(results.get(1).unwrap().text, "#FF00FF"); + assert_eq!(results.get(2).unwrap().text, "#00fF05"); + assert_eq!(results.get(3).unwrap().text, "#abcd00"); + } + + #[test] + fn match_ipfs() { + let buffer = "Lorem QmRdbNSxDJBXmssAc9fvTtux4duptMvfSGiGuq6yHAQVKQ lorem Qmfoobar"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 1); + assert_eq!( + results.get(0).unwrap().text, + "QmRdbNSxDJBXmssAc9fvTtux4duptMvfSGiGuq6yHAQVKQ" + ); + } + + #[test] + fn match_process_port() { + let buffer = "Lorem 5695 52463 lorem\n Lorem 973113 lorem 99999 lorem 8888 lorem\n 23456 lorem 5432 lorem 23444"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 8); + } + + #[test] + fn match_diff_a() { + let buffer = "Lorem lorem\n--- a/src/main.rs"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 1); + assert_eq!(results.get(0).unwrap().text, "src/main.rs"); + } + + #[test] + fn match_diff_b() { + let buffer = "Lorem lorem\n+++ b/src/main.rs"; + let named_pat = vec![]; + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 1); + assert_eq!(results.get(0).unwrap().text, "src/main.rs"); + } + + #[test] + fn priority() { + let buffer = "Lorem [link](http://foo.bar) ipsum CUSTOM-52463 lorem ISSUE-123 lorem\nLorem /var/fd70b569/9999.log 52463 lorem\n Lorem 973113 lorem 123e4567-e89b-12d3-a456-426655440000 lorem 8888 lorem\n https://crates.io/23456/fd70b569 lorem"; + + let named_pat = vec![]; + let custom: Vec = ["CUSTOM-[0-9]{4,}", "ISSUE-[0-9]{3}"] + .iter() + .map(|&s| s.to_string()) + .collect(); + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 9); + assert_eq!(results.get(0).unwrap().text, "http://foo.bar"); + assert_eq!(results.get(1).unwrap().text, "CUSTOM-52463"); + assert_eq!(results.get(2).unwrap().text, "ISSUE-123"); + assert_eq!(results.get(3).unwrap().text, "/var/fd70b569/9999.log"); + assert_eq!(results.get(4).unwrap().text, "52463"); + assert_eq!(results.get(5).unwrap().text, "973113"); + assert_eq!( + results.get(6).unwrap().text, + "123e4567-e89b-12d3-a456-426655440000" + ); + assert_eq!(results.get(7).unwrap().text, "8888"); + assert_eq!( + results.get(8).unwrap().text, + "https://crates.io/23456/fd70b569" + ); + } + + #[test] + fn named_patterns() { + let buffer = "Lorem [link](http://foo.bar) ipsum CUSTOM-52463 lorem ISSUE-123 lorem\nLorem /var/fd70b569/9999.log 52463 lorem\n Lorem 973113 lorem 123e4567-e89b-12d3-a456-426655440000 lorem 8888 lorem\n https://crates.io/23456/fd70b569 lorem"; + + use crate::regexes::parse_pattern_name; + let named_pat = vec![parse_pattern_name("url").unwrap()]; + + let custom = vec![]; + let alphabet = Alphabet("abcd".to_string()); + let results = Model::new(buffer, &alphabet, &named_pat, &custom, false).matches(false); + + assert_eq!(results.len(), 2); + assert_eq!(results.get(0).unwrap().text, "http://foo.bar"); + assert_eq!( + results.get(1).unwrap().text, + "https://crates.io/23456/fd70b569" + ); + } +} diff --git a/src/process.rs b/src/process.rs new file mode 100644 index 0000000..af6a5f1 --- /dev/null +++ b/src/process.rs @@ -0,0 +1,21 @@ +use std::process::Command; + +use crate::error::ParseError; + +/// Execute an arbitrary Unix command and return the stdout as a `String` if +/// successful. +pub fn execute(command: &str, args: &Vec<&str>) -> Result { + let output = Command::new(command).args(args).output()?; + + if !output.status.success() { + let msg = String::from_utf8_lossy(&output.stderr); + return Err(ParseError::ProcessFailure(format!( + "Process failure: {} {}, error {}", + command, + args.join(" "), + msg + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} diff --git a/src/regexes.rs b/src/regexes.rs new file mode 100644 index 0000000..cca6679 --- /dev/null +++ b/src/regexes.rs @@ -0,0 +1,39 @@ +use crate::error; + +pub const EXCLUDE_PATTERNS: [(&'static str, &'static str); 1] = + [("ansi_colors", r"[[:cntrl:]]\[([0-9]{1,2};)?([0-9]{1,2})?m")]; + +pub const PATTERNS: [(&'static str, &'static str); 14] = [ + ("markdown_url", r"\[[^]]*\]\(([^)]+)\)"), + ( + "url", + r"((https?://|git@|git://|ssh://|ftp://|file:///)[^ \(\)\[\]\{\}]+)", + ), + ("diff_a", r"--- a/([^ ]+)"), + ("diff_b", r"\+\+\+ b/([^ ]+)"), + ("docker", r"sha256:([0-9a-f]{64})"), + ("path", r"(([.\w\-@~]+)?(/[.\w\-@]+)+)"), + ("hexcolor", r"#[0-9a-fA-F]{6}"), + ( + "uuid", + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + ), + ("ipfs", r"Qm[0-9a-zA-Z]{44}"), + ("sha", r"[0-9a-f]{7,40}"), + ("ip", r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"), + ("ipv6", r"[A-f0-9:]+:+[A-f0-9:]+[%\w\d]+"), + ("address", r"0x[0-9a-fA-F]+"), + ("number", r"[0-9]{4,}"), +]; + +/// Type-safe string Pattern Name (newtype). +#[derive(Debug)] +pub struct NamedPattern(pub String, pub String); + +/// Parse a name string into `NamedPattern`, used during CLI parsing. +pub fn parse_pattern_name(src: &str) -> Result { + match PATTERNS.iter().find(|&(name, _pattern)| name == &src) { + Some((name, pattern)) => Ok(NamedPattern(name.to_string(), pattern.to_string())), + None => Err(error::ParseError::UnknownPatternName), + } +} diff --git a/src/state.rs b/src/state.rs deleted file mode 100644 index 022c61f..0000000 --- a/src/state.rs +++ /dev/null @@ -1,422 +0,0 @@ -use regex::Regex; -use std::collections::HashMap; -use std::fmt; - -const EXCLUDE_PATTERNS: [(&'static str, &'static str); 1] = [("bash", r"[[:cntrl:]]\[([0-9]{1,2};)?([0-9]{1,2})?m")]; - -const PATTERNS: [(&'static str, &'static str); 14] = [ - ("markdown_url", r"\[[^]]*\]\(([^)]+)\)"), - ("url", r"((https?://|git@|git://|ssh://|ftp://|file:///)[^ ]+)"), - ("diff_a", r"--- a/([^ ]+)"), - ("diff_b", r"\+\+\+ b/([^ ]+)"), - ("docker", r"sha256:([0-9a-f]{64})"), - ("path", r"(([.\w\-@~]+)?(/[.\w\-@]+)+)"), - ("color", r"#[0-9a-fA-F]{6}"), - ("uid", r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"), - ("ipfs", r"Qm[0-9a-zA-Z]{44}"), - ("sha", r"[0-9a-f]{7,40}"), - ("ip", r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"), - ("ipv6", r"[A-f0-9:]+:+[A-f0-9:]+[%\w\d]+"), - ("address", r"0x[0-9a-fA-F]+"), - ("number", r"[0-9]{4,}"), -]; - -#[derive(Clone)] -pub struct Match<'a> { - pub x: i32, - pub y: i32, - pub pattern: &'a str, - pub text: &'a str, - pub hint: Option, -} - -impl<'a> fmt::Debug for Match<'a> { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "Match {{ x: {}, y: {}, pattern: {}, text: {}, hint: <{}> }}", - self.x, - self.y, - self.pattern, - self.text, - self.hint.clone().unwrap_or("".to_string()) - ) - } -} - -impl<'a> PartialEq for Match<'a> { - fn eq(&self, other: &Match) -> bool { - self.x == other.x && self.y == other.y - } -} - -pub struct State<'a> { - pub lines: &'a Vec<&'a str>, - alphabet: &'a str, - regexp: &'a Vec<&'a str>, -} - -impl<'a> State<'a> { - pub fn new(lines: &'a Vec<&'a str>, alphabet: &'a str, regexp: &'a Vec<&'a str>) -> State<'a> { - State { - lines, - alphabet, - regexp, - } - } - - pub fn matches(&self, reverse: bool, unique: bool) -> Vec> { - let mut matches = Vec::new(); - - let exclude_patterns = EXCLUDE_PATTERNS - .iter() - .map(|tuple| (tuple.0, Regex::new(tuple.1).unwrap())) - .collect::>(); - - let custom_patterns = self - .regexp - .iter() - .map(|regexp| ("custom", Regex::new(regexp).expect("Invalid custom regexp"))) - .collect::>(); - - let patterns = PATTERNS - .iter() - .map(|tuple| (tuple.0, Regex::new(tuple.1).unwrap())) - .collect::>(); - - let all_patterns = [exclude_patterns, custom_patterns, patterns].concat(); - - for (index, line) in self.lines.iter().enumerate() { - let mut chunk: &str = line; - let mut offset: i32 = 0; - - loop { - let submatches = all_patterns - .iter() - .filter_map(|tuple| match tuple.1.find_iter(chunk).nth(0) { - Some(m) => Some((tuple.0, tuple.1.clone(), m)), - None => None, - }) - .collect::>(); - let first_match_option = submatches.iter().min_by(|x, y| x.2.start().cmp(&y.2.start())); - - if let Some(first_match) = first_match_option { - let (name, pattern, matching) = first_match; - let text = matching.as_str(); - - if let Some(captures) = pattern.captures(text) { - let (subtext, substart) = if let Some(capture) = captures.get(1) { - (capture.as_str(), capture.start()) - } else { - (matching.as_str(), 0) - }; - - // Never hint or broke bash color sequences - if *name != "bash" { - matches.push(Match { - x: offset + matching.start() as i32 + substart as i32, - y: index as i32, - pattern: name, - text: subtext, - hint: None, - }); - } - - chunk = chunk.get(matching.end()..).expect("Unknown chunk"); - offset += matching.end() as i32; - } else { - panic!("No matching?"); - } - } else { - break; - } - } - } - - let alphabet = super::alphabets::get_alphabet(self.alphabet); - let mut hints = alphabet.hints(matches.len()); - - // This looks wrong but we do a pop after - if !reverse { - hints.reverse(); - } else { - matches.reverse(); - hints.reverse(); - } - - if unique { - let mut previous: HashMap<&str, String> = HashMap::new(); - - for mat in &mut matches { - if let Some(previous_hint) = previous.get(mat.text) { - mat.hint = Some(previous_hint.clone()); - } else if let Some(hint) = hints.pop() { - mat.hint = Some(hint.to_string().clone()); - previous.insert(mat.text, hint.to_string().clone()); - } - } - } else { - for mat in &mut matches { - if let Some(hint) = hints.pop() { - mat.hint = Some(hint.to_string().clone()); - } - } - } - - if reverse { - matches.reverse(); - } - - matches - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn split(output: &str) -> Vec<&str> { - output.split("\n").collect::>() - } - - #[test] - fn match_reverse() { - let lines = split("lorem 127.0.0.1 lorem 255.255.255.255 lorem 127.0.0.1 lorem"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 3); - assert_eq!(results.first().unwrap().hint.clone().unwrap(), "a"); - assert_eq!(results.last().unwrap().hint.clone().unwrap(), "c"); - } - - #[test] - fn match_unique() { - let lines = split("lorem 127.0.0.1 lorem 255.255.255.255 lorem 127.0.0.1 lorem"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, true); - - assert_eq!(results.len(), 3); - assert_eq!(results.first().unwrap().hint.clone().unwrap(), "a"); - assert_eq!(results.last().unwrap().hint.clone().unwrap(), "a"); - } - - #[test] - fn match_docker() { - let lines = split("latest sha256:30557a29d5abc51e5f1d5b472e79b7e296f595abcf19fe6b9199dbbc809c6ff4 20 hours ago"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 1); - assert_eq!( - results.get(0).unwrap().text, - "30557a29d5abc51e5f1d5b472e79b7e296f595abcf19fe6b9199dbbc809c6ff4" - ); - } - - #[test] - fn match_bash() { - let lines = split("path: /var/log/nginx.log\npath: test/log/nginx-2.log:32folder/.nginx@4df2.log"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 3); - assert_eq!(results.get(0).unwrap().text, "/var/log/nginx.log"); - assert_eq!(results.get(1).unwrap().text, "test/log/nginx-2.log"); - assert_eq!(results.get(2).unwrap().text, "folder/.nginx@4df2.log"); - } - - #[test] - fn match_paths() { - let lines = split("Lorem /tmp/foo/bar_lol, lorem\n Lorem /var/log/boot-strap.log lorem ../log/kern.log lorem"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 3); - assert_eq!(results.get(0).unwrap().text.clone(), "/tmp/foo/bar_lol"); - assert_eq!(results.get(1).unwrap().text.clone(), "/var/log/boot-strap.log"); - assert_eq!(results.get(2).unwrap().text.clone(), "../log/kern.log"); - } - - #[test] - fn match_home() { - let lines = split("Lorem ~/.gnu/.config.txt, lorem"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 1); - assert_eq!(results.get(0).unwrap().text.clone(), "~/.gnu/.config.txt"); - } - - #[test] - fn match_uids() { - let lines = split("Lorem ipsum 123e4567-e89b-12d3-a456-426655440000 lorem\n Lorem lorem lorem"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 1); - } - - #[test] - fn match_shas() { - let lines = split("Lorem fd70b5695 5246ddf f924213 lorem\n Lorem 973113963b491874ab2e372ee60d4b4cb75f717c lorem"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 4); - assert_eq!(results.get(0).unwrap().text.clone(), "fd70b5695"); - assert_eq!(results.get(1).unwrap().text.clone(), "5246ddf"); - assert_eq!(results.get(2).unwrap().text.clone(), "f924213"); - assert_eq!( - results.get(3).unwrap().text.clone(), - "973113963b491874ab2e372ee60d4b4cb75f717c" - ); - } - - #[test] - fn match_ips() { - let lines = split("Lorem ipsum 127.0.0.1 lorem\n Lorem 255.255.10.255 lorem 127.0.0.1 lorem"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 3); - assert_eq!(results.get(0).unwrap().text.clone(), "127.0.0.1"); - assert_eq!(results.get(1).unwrap().text.clone(), "255.255.10.255"); - assert_eq!(results.get(2).unwrap().text.clone(), "127.0.0.1"); - } - - #[test] - fn match_ipv6s() { - let lines = split("Lorem ipsum fe80::2:202:fe4 lorem\n Lorem 2001:67c:670:202:7ba8:5e41:1591:d723 lorem fe80::2:1 lorem ipsum fe80:22:312:fe::1%eth0"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 4); - assert_eq!(results.get(0).unwrap().text.clone(), "fe80::2:202:fe4"); - assert_eq!( - results.get(1).unwrap().text.clone(), - "2001:67c:670:202:7ba8:5e41:1591:d723" - ); - assert_eq!(results.get(2).unwrap().text.clone(), "fe80::2:1"); - assert_eq!(results.get(3).unwrap().text.clone(), "fe80:22:312:fe::1%eth0"); - } - - #[test] - fn match_markdown_urls() { - let lines = split("Lorem ipsum [link](https://github.io?foo=bar) ![](http://cdn.com/img.jpg) lorem"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 2); - assert_eq!(results.get(0).unwrap().pattern.clone(), "markdown_url"); - assert_eq!(results.get(0).unwrap().text.clone(), "https://github.io?foo=bar"); - assert_eq!(results.get(1).unwrap().pattern.clone(), "markdown_url"); - assert_eq!(results.get(1).unwrap().text.clone(), "http://cdn.com/img.jpg"); - } - - #[test] - fn match_urls() { - let lines = split("Lorem ipsum https://www.rust-lang.org/tools lorem\n Lorem ipsumhttps://crates.io lorem https://github.io?foo=bar lorem ssh://github.io"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 4); - assert_eq!(results.get(0).unwrap().text.clone(), "https://www.rust-lang.org/tools"); - assert_eq!(results.get(0).unwrap().pattern.clone(), "url"); - assert_eq!(results.get(1).unwrap().text.clone(), "https://crates.io"); - assert_eq!(results.get(1).unwrap().pattern.clone(), "url"); - assert_eq!(results.get(2).unwrap().text.clone(), "https://github.io?foo=bar"); - assert_eq!(results.get(2).unwrap().pattern.clone(), "url"); - assert_eq!(results.get(3).unwrap().text.clone(), "ssh://github.io"); - assert_eq!(results.get(3).unwrap().pattern.clone(), "url"); - } - - #[test] - fn match_addresses() { - let lines = split("Lorem 0xfd70b5695 0x5246ddf lorem\n Lorem 0x973113tlorem"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 3); - assert_eq!(results.get(0).unwrap().text.clone(), "0xfd70b5695"); - assert_eq!(results.get(1).unwrap().text.clone(), "0x5246ddf"); - assert_eq!(results.get(2).unwrap().text.clone(), "0x973113"); - } - - #[test] - fn match_hex_colors() { - let lines = split("Lorem #fd7b56 lorem #FF00FF\n Lorem #00fF05 lorem #abcd00 lorem #afRR00"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 4); - assert_eq!(results.get(0).unwrap().text.clone(), "#fd7b56"); - assert_eq!(results.get(1).unwrap().text.clone(), "#FF00FF"); - assert_eq!(results.get(2).unwrap().text.clone(), "#00fF05"); - assert_eq!(results.get(3).unwrap().text.clone(), "#abcd00"); - } - - #[test] - fn match_ipfs() { - let lines = split("Lorem QmRdbNSxDJBXmssAc9fvTtux4duptMvfSGiGuq6yHAQVKQ lorem Qmfoobar"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 1); - assert_eq!( - results.get(0).unwrap().text.clone(), - "QmRdbNSxDJBXmssAc9fvTtux4duptMvfSGiGuq6yHAQVKQ" - ); - } - - #[test] - fn match_process_port() { - let lines = - split("Lorem 5695 52463 lorem\n Lorem 973113 lorem 99999 lorem 8888 lorem\n 23456 lorem 5432 lorem 23444"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 8); - } - - #[test] - fn match_diff_a() { - let lines = split("Lorem lorem\n--- a/src/main.rs"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 1); - assert_eq!(results.get(0).unwrap().text.clone(), "src/main.rs"); - } - - #[test] - fn match_diff_b() { - let lines = split("Lorem lorem\n+++ b/src/main.rs"); - let custom = [].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 1); - assert_eq!(results.get(0).unwrap().text.clone(), "src/main.rs"); - } - - #[test] - fn priority() { - let lines = split("Lorem [link](http://foo.bar) ipsum CUSTOM-52463 lorem ISSUE-123 lorem\nLorem /var/fd70b569/9999.log 52463 lorem\n Lorem 973113 lorem 123e4567-e89b-12d3-a456-426655440000 lorem 8888 lorem\n https://crates.io/23456/fd70b569 lorem"); - let custom = ["CUSTOM-[0-9]{4,}", "ISSUE-[0-9]{3}"].to_vec(); - let results = State::new(&lines, "abcd", &custom).matches(false, false); - - assert_eq!(results.len(), 9); - assert_eq!(results.get(0).unwrap().text.clone(), "http://foo.bar"); - assert_eq!(results.get(1).unwrap().text.clone(), "CUSTOM-52463"); - assert_eq!(results.get(2).unwrap().text.clone(), "ISSUE-123"); - assert_eq!(results.get(3).unwrap().text.clone(), "/var/fd70b569/9999.log"); - assert_eq!(results.get(4).unwrap().text.clone(), "52463"); - assert_eq!(results.get(5).unwrap().text.clone(), "973113"); - assert_eq!( - results.get(6).unwrap().text.clone(), - "123e4567-e89b-12d3-a456-426655440000" - ); - assert_eq!(results.get(7).unwrap().text.clone(), "8888"); - assert_eq!(results.get(8).unwrap().text.clone(), "https://crates.io/23456/fd70b569"); - } -} diff --git a/src/swapper.rs b/src/swapper.rs deleted file mode 100644 index f5994fa..0000000 --- a/src/swapper.rs +++ /dev/null @@ -1,384 +0,0 @@ -extern crate clap; - -use self::clap::{App, Arg}; -use clap::crate_version; -use regex::Regex; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; - -trait Executor { - fn execute(&mut self, args: Vec) -> String; - fn last_executed(&self) -> Option>; -} - -struct RealShell { - executed: Option>, -} - -impl RealShell { - fn new() -> RealShell { - RealShell { executed: None } - } -} - -impl Executor for RealShell { - fn execute(&mut self, args: Vec) -> String { - let execution = Command::new(args[0].as_str()) - .args(&args[1..]) - .output() - .expect("Couldn't run it"); - - self.executed = Some(args); - - let output: String = String::from_utf8_lossy(&execution.stdout).into(); - - output.trim_end().to_string() - } - - fn last_executed(&self) -> Option> { - self.executed.clone() - } -} - -const TMP_FILE: &str = "/tmp/thumbs-last"; - -pub struct Swapper<'a> { - executor: Box<&'a mut dyn Executor>, - dir: String, - command: String, - upcase_command: String, - active_pane_id: Option, - active_pane_height: Option, - active_pane_scroll_position: Option, - active_pane_in_copy_mode: Option, - thumbs_pane_id: Option, - content: Option, - signal: String, -} - -impl<'a> Swapper<'a> { - fn new(executor: Box<&'a mut dyn Executor>, dir: String, command: String, upcase_command: String) -> Swapper { - let since_the_epoch = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards"); - let signal = format!("thumbs-finished-{}", since_the_epoch.as_secs()); - - Swapper { - executor, - dir, - command, - upcase_command, - active_pane_id: None, - active_pane_height: None, - active_pane_scroll_position: None, - active_pane_in_copy_mode: None, - thumbs_pane_id: None, - content: None, - signal, - } - } - - pub fn capture_active_pane(&mut self) { - let active_command = vec![ - "tmux", - "list-panes", - "-F", - "#{pane_id}:#{?pane_in_mode,1,0}:#{pane_height}:#{scroll_position}:#{?pane_active,active,nope}", - ]; - - let output = self - .executor - .execute(active_command.iter().map(|arg| arg.to_string()).collect()); - - let lines: Vec<&str> = output.split('\n').collect(); - let chunks: Vec> = lines.into_iter().map(|line| line.split(':').collect()).collect(); - - let active_pane = chunks - .iter() - .find(|&chunks| *chunks.get(4).unwrap() == "active") - .expect("Unable to find active pane"); - - let pane_id = active_pane.get(0).unwrap(); - let pane_in_copy_mode = active_pane.get(1).unwrap().to_string(); - - self.active_pane_id = Some(pane_id.to_string()); - self.active_pane_in_copy_mode = Some(pane_in_copy_mode); - - if self.active_pane_in_copy_mode.clone().unwrap() == "1" { - let pane_height = active_pane - .get(2) - .unwrap() - .parse() - .expect("Unable to retrieve pane height"); - let pane_scroll_position = active_pane - .get(3) - .unwrap() - .parse() - .expect("Unable to retrieve pane scroll"); - - self.active_pane_height = Some(pane_height); - self.active_pane_scroll_position = Some(pane_scroll_position); - } - } - - pub fn execute_thumbs(&mut self) { - let options_command = vec!["tmux", "show", "-g"]; - let params: Vec = options_command.iter().map(|arg| arg.to_string()).collect(); - let options = self.executor.execute(params); - let lines: Vec<&str> = options.split('\n').collect(); - - let pattern = Regex::new(r#"@thumbs-([\w\-0-9]+) "?(\w+)"?"#).unwrap(); - - let args = lines - .iter() - .flat_map(|line| { - if let Some(captures) = pattern.captures(line) { - let name = captures.get(1).unwrap().as_str(); - let value = captures.get(2).unwrap().as_str(); - - let boolean_params = vec!["reverse", "unique", "contrast"]; - - if boolean_params.iter().any(|&x| x == name) { - return vec![format!("--{}", name)]; - } - - let string_params = vec![ - "position", - "fg-color", - "bg-color", - "hint-bg-color", - "hint-fg-color", - "select-fg-color", - "select-bg-color", - ]; - - if string_params.iter().any(|&x| x == name) { - return vec![format!("--{}", name), format!("'{}'", value)]; - } - - if name.starts_with("regexp") { - return vec!["--regexp".to_string(), format!("'{}'", value)]; - } - - vec![] - } else { - vec![] - } - }) - .collect::>(); - - let active_pane_id = self.active_pane_id.as_mut().unwrap().clone(); - - let scroll_params = if self.active_pane_in_copy_mode.is_some() { - if let (Some(pane_height), Some(scroll_position)) = - (self.active_pane_scroll_position, self.active_pane_scroll_position) - { - format!(" -S {} -E {}", -scroll_position, pane_height - scroll_position - 1) - } else { - "".to_string() - } - } else { - "".to_string() - }; - - // NOTE: For debugging add echo $PWD && sleep 5 after tee - let pane_command = format!( - "tmux capture-pane -t {} -p{} | {}/target/release/thumbs -f '%U:%H' -t {} {}; tmux swap-pane -t {}; tmux wait-for -S {}", - active_pane_id, - scroll_params, - self.dir, - TMP_FILE, - args.join(" "), - active_pane_id, - self.signal - ); - - let thumbs_command = vec![ - "tmux", - "new-window", - "-P", - "-d", - "-n", - "[thumbs]", - pane_command.as_str(), - ]; - - let params: Vec = thumbs_command.iter().map(|arg| arg.to_string()).collect(); - - self.thumbs_pane_id = Some(self.executor.execute(params)); - } - - pub fn swap_panes(&mut self) { - let active_pane_id = self.active_pane_id.as_mut().unwrap().clone(); - let thumbs_pane_id = self.thumbs_pane_id.as_mut().unwrap().clone(); - - let swap_command = vec![ - "tmux", - "swap-pane", - "-d", - "-s", - active_pane_id.as_str(), - "-t", - thumbs_pane_id.as_str(), - ]; - let params = swap_command.iter().map(|arg| arg.to_string()).collect(); - - self.executor.execute(params); - } - - pub fn wait_thumbs(&mut self) { - let wait_command = vec!["tmux", "wait-for", self.signal.as_str()]; - let params = wait_command.iter().map(|arg| arg.to_string()).collect(); - - self.executor.execute(params); - } - - pub fn retrieve_content(&mut self) { - let retrieve_command = vec!["cat", TMP_FILE]; - let params = retrieve_command.iter().map(|arg| arg.to_string()).collect(); - - self.content = Some(self.executor.execute(params)); - } - - pub fn destroy_content(&mut self) { - let retrieve_command = vec!["rm", TMP_FILE]; - let params = retrieve_command.iter().map(|arg| arg.to_string()).collect(); - - self.executor.execute(params); - } - - pub fn execute_command(&mut self) { - let content = self.content.clone().unwrap(); - let mut splitter = content.splitn(2, ':'); - - if let Some(upcase) = splitter.next() { - if let Some(text) = splitter.next() { - let execute_command = if upcase.trim_end() == "true" { - self.upcase_command.clone() - } else { - self.command.clone() - }; - - let final_command = str::replace(execute_command.as_str(), "{}", text.trim_end()); - let retrieve_command = vec!["bash", "-c", final_command.as_str()]; - let params = retrieve_command.iter().map(|arg| arg.to_string()).collect(); - - self.executor.execute(params); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - struct TestShell { - outputs: Vec, - executed: Option>, - } - - impl TestShell { - fn new(outputs: Vec) -> TestShell { - TestShell { - executed: None, - outputs, - } - } - } - - impl Executor for TestShell { - fn execute(&mut self, args: Vec) -> String { - self.executed = Some(args); - self.outputs.pop().unwrap() - } - - fn last_executed(&self) -> Option> { - self.executed.clone() - } - } - - #[test] - fn retrieve_active_pane() { - let last_command_outputs = vec!["%97:100:24:1:active\n%106:100:24:1:nope\n%107:100:24:1:nope\n".to_string()]; - let mut executor = TestShell::new(last_command_outputs); - let mut swapper = Swapper::new(Box::new(&mut executor), "".to_string(), "".to_string(), "".to_string()); - - swapper.capture_active_pane(); - - assert_eq!(swapper.active_pane_id.unwrap(), "%97"); - } - - #[test] - fn swap_panes() { - let last_command_outputs = vec![ - "".to_string(), - "%100".to_string(), - "".to_string(), - "%106:100:24:1:nope\n%98:100:24:1:active\n%107:100:24:1:nope\n".to_string(), - ]; - let mut executor = TestShell::new(last_command_outputs); - let mut swapper = Swapper::new(Box::new(&mut executor), "".to_string(), "".to_string(), "".to_string()); - - swapper.capture_active_pane(); - swapper.execute_thumbs(); - swapper.swap_panes(); - - let expectation = vec!["tmux", "swap-pane", "-d", "-s", "%98", "-t", "%100"]; - - assert_eq!(executor.last_executed().unwrap(), expectation); - } -} - -fn app_args<'a>() -> clap::ArgMatches<'a> { - App::new("tmux-thumbs") - .version(crate_version!()) - .about("A lightning fast version of tmux-fingers, copy/pasting tmux like vimium/vimperator") - .arg( - Arg::with_name("dir") - .help("Directory where to execute thumbs") - .long("dir") - .default_value(""), - ) - .arg( - Arg::with_name("command") - .help("Pick command") - .long("command") - .default_value("tmux set-buffer {}"), - ) - .arg( - Arg::with_name("upcase_command") - .help("Upcase command") - .long("upcase-command") - .default_value("tmux set-buffer {} && tmux paste-buffer"), - ) - .get_matches() -} - -fn main() -> std::io::Result<()> { - let args = app_args(); - let dir = args.value_of("dir").unwrap(); - let command = args.value_of("command").unwrap(); - let upcase_command = args.value_of("upcase_command").unwrap(); - - if dir.is_empty() { - panic!("Invalid tmux-thumbs execution. Are you trying to execute tmux-thumbs directly?") - } - - let mut executor = RealShell::new(); - let mut swapper = Swapper::new( - Box::new(&mut executor), - dir.to_string(), - command.to_string(), - upcase_command.to_string(), - ); - - swapper.capture_active_pane(); - swapper.execute_thumbs(); - swapper.swap_panes(); - swapper.wait_thumbs(); - swapper.retrieve_content(); - swapper.destroy_content(); - swapper.execute_command(); - Ok(()) -} diff --git a/src/tmux.rs b/src/tmux.rs new file mode 100644 index 0000000..acff280 --- /dev/null +++ b/src/tmux.rs @@ -0,0 +1,274 @@ +use clap::Clap; +use regex::Regex; +use std::collections::HashMap; +use std::fmt; +use std::str::FromStr; + +use copyrat::error::ParseError; +use copyrat::process; + +#[derive(Debug, PartialEq)] +pub struct Pane { + /// Pane identifier, e.g. `%37`. + pub id: PaneId, + /// Describes if the pane is in some mode. + pub in_mode: bool, + /// Number of lines in the pane. + pub height: u32, + /// Optional offset from the bottom if the pane is in some mode. + /// + /// When a pane is in copy mode, scrolling up changes the + /// `scroll_position`. If the pane is in normal mode, or unscrolled, + /// then `0` is returned. + pub scroll_position: u32, + /// Describes if the pane is currently active (focused). + pub is_active: bool, +} + +impl FromStr for Pane { + type Err = ParseError; + + /// Parse a string containing tmux panes status into a new `Pane`. + /// + /// This returns a `Result` as this call can obviously + /// fail if provided an invalid format. + /// + /// The expected format of the tmux status is "%52:false:62:3:false", + /// or "%53:false:23::true". + /// + /// This status line is obtained with `tmux list-panes -F '#{pane_id}:#{?pane_in_mode,true,false}:#{pane_height}:#{scroll_position}:#{?pane_active,true,false}'`. + /// + /// For definitions, look at `Pane` type, + /// and at the tmux man page for definitions. + fn from_str(src: &str) -> Result { + let items: Vec<&str> = src.split(':').collect(); + assert_eq!(items.len(), 5, "tmux should have returned 5 items per line"); + + let mut iter = items.iter(); + + // Pane id must be start with '%' followed by a `u32` + let id_str = iter.next().unwrap(); + let id = PaneId::from_str(id_str)?; + // if !id_str.starts_with('%') { + // return Err(ParseError::ExpectedPaneIdMarker); + // } + // let id = id_str[1..].parse::()?; + // let id = format!("%{}", id); + + let in_mode = iter.next().unwrap().parse::()?; + + let height = iter.next().unwrap().parse::()?; + + let scroll_position = iter.next().unwrap(); + let scroll_position = if scroll_position.is_empty() { + "0" + } else { + scroll_position + }; + let scroll_position = scroll_position.parse::()?; + + let is_active = iter.next().unwrap().parse::()?; + + Ok(Pane { + id, + in_mode, + height, + scroll_position, + is_active, + }) + } +} + +#[derive(Debug, PartialEq)] +pub struct PaneId(String); + +impl FromStr for PaneId { + type Err = ParseError; + + /// Parse into PaneId. The `&str` must be start with '%' + /// followed by a `u32`. + fn from_str(src: &str) -> Result { + if !src.starts_with('%') { + return Err(ParseError::ExpectedPaneIdMarker); + } + let id = src[1..].parse::()?; + let id = format!("%{}", id); + Ok(PaneId(id)) + } +} + +impl PaneId { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for PaneId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Clap, Debug)] +pub enum CaptureRegion { + /// The entire history. + /// + /// This will end up sending `-S - -E -` to `tmux capture-pane`. + EntireHistory, + /// The visible area. + VisibleArea, + ///// Region from start line to end line + ///// + ///// This works as defined in tmux's docs (order does not matter). + //Region(i32, i32), +} + +impl FromStr for CaptureRegion { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s { + "leading" => Ok(CaptureRegion::EntireHistory), + "trailing" => Ok(CaptureRegion::VisibleArea), + _ => Err(ParseError::ExpectedString(String::from( + "entire-history or visible-area", + ))), + } + } +} + +/// Returns a list of `Pane` from the current tmux session. +pub fn list_panes() -> Result, ParseError> { + let args = vec![ + "list-panes", + "-F", + "#{pane_id}:#{?pane_in_mode,true,false}:#{pane_height}:#{scroll_position}:#{?pane_active,true,false}", + ]; + + let output = process::execute("tmux", &args)?; + + // Each call to `Pane::parse` returns a `Result`. All results + // are collected into a Result, _>, thanks to `collect()`. + let result: Result, ParseError> = output + .trim_end() // trim last '\n' as it would create an empty line + .split('\n') + .map(|line| Pane::from_str(line)) + .collect(); + + result +} + +/// Returns tmux global options as a `HashMap`. The prefix argument is for +/// convenience, in order to target only some of our options. For instance, +/// `get_options("@copyrat-")` will return a `HashMap` which keys are tmux options names like `@copyrat-command`, and associated values. +/// +/// # Example +/// ```get_options("@copyrat-")``` +pub fn get_options(prefix: &str) -> Result, ParseError> { + let args = vec!["show", "-g"]; + + let output = process::execute("tmux", &args)?; + let lines: Vec<&str> = output.split('\n').collect(); + + let pattern = format!(r#"{prefix}([\w\-0-9]+) "?(\w+)"?"#, prefix = prefix); + let re = Regex::new(&pattern).unwrap(); + + let args: HashMap = lines + .iter() + .flat_map(|line| match re.captures(line) { + None => None, + Some(captures) => { + let key = captures[1].to_string(); + let value = captures[2].to_string(); + Some((key, value)) + } + }) + .collect(); + + Ok(args) +} + +/// Returns the entire Pane content as a `String`. +/// +/// `CaptureRegion` specifies if the visible area is captured, or the entire +/// history. +/// +/// # TODO +/// +/// Capture with `capture-pane -J` joins wrapped lines. +/// +/// # Note +/// +/// If the pane is in normal mode, capturing the visible area can be done +/// without extra arguments (default behavior of `capture-pane`), but if the +/// pane is in copy mode, we need to take into account the current scroll +/// position. To support both cases, the implementation always provides those +/// parameters to tmux. +pub fn capture_pane(pane: &Pane, region: &CaptureRegion) -> Result { + let mut args = format!("capture-pane -t {pane_id} -J -p", pane_id = pane.id); + + let region_str = match region { + CaptureRegion::VisibleArea => { + // Providing start/end helps support both copy and normal modes. + format!( + " -S {start} -E {end}", + start = pane.scroll_position, + end = pane.height - pane.scroll_position - 1 + ) + } + CaptureRegion::EntireHistory => String::from(" -S - -E -"), + }; + + args.push_str(®ion_str); + + let args: Vec<&str> = args.split(' ').collect(); + + let output = process::execute("tmux", &args)?; + Ok(output) +} + +/// Ask tmux to swap the current Pane with the target_pane (uses Tmux format). +pub fn swap_pane_with(target_pane: &str) -> Result<(), ParseError> { + // -Z: keep the window zoomed if it was zoomed. + let args = vec!["swap-pane", "-Z", "-s", target_pane]; + + process::execute("tmux", &args)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::Pane; + use super::PaneId; + use copyrat::error; + use std::str::FromStr; + + #[test] + fn test_parse_pass() { + let output = vec!["%52:false:62:3:false", "%53:false:23::true"]; + let panes: Result, error::ParseError> = + output.iter().map(|&line| Pane::from_str(line)).collect(); + let panes = panes.expect("Could not parse tmux panes"); + + let expected = vec![ + Pane { + id: PaneId::from_str("%52").unwrap(), + in_mode: false, + height: 62, + scroll_position: 3, + is_active: false, + }, + Pane { + // id: PaneId::from_str("%53").unwrap(), + id: PaneId(String::from("%53")), + in_mode: false, + height: 23, + scroll_position: 0, + is_active: true, + }, + ]; + + assert_eq!(panes, expected); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..f2e6574 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,1109 @@ +use std::char; +use std::io; +use std::str::FromStr; + +use clap::Clap; +use sequence_trie::SequenceTrie; +use termion::{self, color, cursor, event, style}; + +use crate::error::ParseError; +use crate::{colors, model}; + +pub struct Ui<'a> { + model: &'a mut model::Model<'a>, + term_width: u16, + line_offsets: Vec, + matches: Vec>, + lookup_trie: SequenceTrie, + focus_index: usize, + focus_wrap_around: bool, + rendering_colors: &'a UiColors, + hint_alignment: &'a HintAlignment, + hint_style: Option, +} + +impl<'a> Ui<'a> { + pub fn new( + model: &'a mut model::Model<'a>, + unique_hint: bool, + focus_wrap_around: bool, + rendering_colors: &'a UiColors, + hint_alignment: &'a HintAlignment, + hint_style: Option, + ) -> Ui<'a> { + let matches = model.matches(unique_hint); + let lookup_trie = model::Model::build_lookup_trie(&matches); + let focus_index = if model.reverse { matches.len() - 1 } else { 0 }; + + let (term_width, _) = termion::terminal_size().expect("Cannot read the terminal size."); + let line_offsets = get_line_offsets(&model.lines, term_width); + + Ui { + model, + term_width, + line_offsets, + matches, + lookup_trie, + focus_index, + focus_wrap_around, + rendering_colors, + hint_alignment, + hint_style, + } + } + + /// Convert the `Match` text into the coordinates of the wrapped lines. + /// + /// Compute the new x offset of the text as the remainder of the line width + /// (e.g. the match could start at offset 120 in a 80-width terminal, the new + /// offset being 40). + /// + /// Compute the new y offset of the text as the initial y offset plus any + /// additional offset due to previous split lines. This is obtained thanks to + /// the `offset_per_line` member. + pub fn map_coords_to_wrapped_space(&self, offset_x: usize, offset_y: usize) -> (usize, usize) { + let line_width = self.term_width as usize; + + let new_offset_x = offset_x % line_width; + let new_offset_y = + self.line_offsets.get(offset_y as usize).unwrap() + offset_x / line_width; + + (new_offset_x, new_offset_y) + } + + /// Move focus onto the previous hint, returning both the index of the + /// previously focused match, and the index of the newly focused one. + fn prev_focus_index(&mut self) -> (usize, usize) { + let old_index = self.focus_index; + if self.focus_wrap_around { + if self.focus_index == 0 { + self.focus_index = self.matches.len() - 1; + } else { + self.focus_index -= 1; + } + } else { + if self.focus_index > 0 { + self.focus_index -= 1; + } + } + let new_index = self.focus_index; + (old_index, new_index) + } + + /// Move focus onto the next hint, returning both the index of the + /// previously focused match, and the index of the newly focused one. + fn next_focus_index(&mut self) -> (usize, usize) { + let old_index = self.focus_index; + if self.focus_wrap_around { + if self.focus_index == self.matches.len() - 1 { + self.focus_index = 0; + } else { + self.focus_index += 1; + } + } else { + if self.focus_index < self.matches.len() - 1 { + self.focus_index += 1; + } + } + let new_index = self.focus_index; + (old_index, new_index) + } + + /// Returns screen offset of a given `Match`. + /// + /// If multibyte characters occur before the hint (in the "prefix"), then + /// their compouding takes less space on screen when printed: for + /// instance ´ + e = é. Consequently the hint offset has to be adjusted + /// to the left. + fn match_offsets(&self, mat: &model::Match<'a>) -> (usize, usize) { + let offset_x = { + let line = &self.model.lines[mat.y as usize]; + let prefix = &line[0..mat.x as usize]; + let adjust = prefix.len() - prefix.chars().count(); + (mat.x as usize) - (adjust) + }; + let offset_y = mat.y as usize; + + (offset_x, offset_y) + } + + /// Render entire model lines on provided writer. + /// + /// This renders the basic content on which matches and hints can be rendered. + /// + /// # Notes + /// - All trailing whitespaces are trimmed, empty lines are skipped. + /// - This writes directly on the writer, avoiding extra allocation. + fn render_base_text( + stdout: &mut dyn io::Write, + lines: &Vec<&str>, + line_offsets: &Vec, + colors: &UiColors, + ) -> () { + write!( + stdout, + "{bg_color}{fg_color}", + fg_color = color::Fg(colors.text_fg.as_ref()), + bg_color = color::Bg(colors.text_bg.as_ref()), + ) + .unwrap(); + + for (line_index, line) in lines.iter().enumerate() { + let trimmed_line = line.trim_end(); + + if !trimmed_line.is_empty() { + let offset_y: usize = + *(line_offsets.get(line_index)).expect("Cannot get offset_per_line."); + + write!( + stdout, + "{goto}{text}", + goto = cursor::Goto(1, offset_y as u16 + 1), + text = &trimmed_line, + ) + .unwrap(); + } + } + + write!( + stdout, + "{fg_reset}{bg_reset}", + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset), + ) + .unwrap(); + } + + /// Render the Match's `text` field on provided writer using the `match_*g` color. + /// + /// If a Mach is "focused", it is then rendered with the `focused_*g` colors. + /// + /// # Note + /// + /// This writes directly on the writer, avoiding extra allocation. + fn render_matched_text( + stdout: &mut dyn io::Write, + text: &str, + focused: bool, + offset: (usize, usize), + colors: &UiColors, + ) { + // To help identify it, the match thas has focus is rendered with a dedicated color. + let (fg_color, bg_color) = if focused { + (&colors.focused_fg, &colors.focused_bg) + } else { + (&colors.match_fg, &colors.match_bg) + }; + + // Render just the Match's text on top of existing content. + write!( + stdout, + "{goto}{bg_color}{fg_color}{text}{fg_reset}{bg_reset}", + goto = cursor::Goto(offset.0 as u16 + 1, offset.1 as u16 + 1), + fg_color = color::Fg(fg_color.as_ref()), + bg_color = color::Bg(bg_color.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset), + text = &text, + ) + .unwrap(); + } + + /// Render a Match's `hint` field on the provided writer. + /// + /// This renders the hint according to some provided style: + /// - just colors + /// - underlined with colors + /// - surrounding the hint's text with some delimiters, see + /// `HintStyle::Delimited`. + /// + /// # Note + /// This writes directly on the writer, avoiding extra allocation. + fn render_matched_hint( + stdout: &mut dyn io::Write, + hint_text: &str, + offset: (usize, usize), + colors: &UiColors, + hint_style: &Option, + ) { + let fg_color = color::Fg(colors.hint_fg.as_ref()); + let bg_color = color::Bg(colors.hint_bg.as_ref()); + let fg_reset = color::Fg(color::Reset); + let bg_reset = color::Bg(color::Reset); + let goto = cursor::Goto(offset.0 as u16 + 1, offset.1 as u16 + 1); + + match hint_style { + None => { + write!( + stdout, + "{goto}{bg_color}{fg_color}{hint}{fg_reset}{bg_reset}", + goto = goto, + fg_color = fg_color, + bg_color = bg_color, + fg_reset = fg_reset, + bg_reset = bg_reset, + hint = hint_text, + ) + .unwrap(); + } + Some(hint_style) => match hint_style { + HintStyle::Bold => { + write!( + stdout, + "{goto}{bg_color}{fg_color}{sty}{hint}{sty_reset}{fg_reset}{bg_reset}", + goto = goto, + fg_color = fg_color, + bg_color = bg_color, + fg_reset = fg_reset, + bg_reset = bg_reset, + sty = style::Bold, + sty_reset = style::NoBold, + hint = hint_text, + ) + .unwrap(); + } + HintStyle::Italic => { + write!( + stdout, + "{goto}{bg_color}{fg_color}{sty}{hint}{sty_reset}{fg_reset}{bg_reset}", + goto = goto, + fg_color = fg_color, + bg_color = bg_color, + fg_reset = fg_reset, + bg_reset = bg_reset, + sty = style::Italic, + sty_reset = style::NoItalic, + hint = hint_text, + ) + .unwrap(); + } + HintStyle::Underline => { + write!( + stdout, + "{goto}{bg_color}{fg_color}{sty}{hint}{sty_reset}{fg_reset}{bg_reset}", + goto = goto, + fg_color = fg_color, + bg_color = bg_color, + fg_reset = fg_reset, + bg_reset = bg_reset, + sty = style::Underline, + sty_reset = style::NoUnderline, + hint = hint_text, + ) + .unwrap(); + } + HintStyle::Surround(opening, closing) => { + write!( + stdout, + "{goto}{bg_color}{fg_color}{bra}{hint}{bra_close}{fg_reset}{bg_reset}", + goto = goto, + fg_color = fg_color, + bg_color = bg_color, + fg_reset = fg_reset, + bg_reset = bg_reset, + bra = opening, + bra_close = closing, + hint = hint_text, + ) + .unwrap(); + } + }, + } + } + + /// Convenience function that renders both the matched text and its hint, + /// if focused. + fn render_match(&self, stdout: &mut dyn io::Write, mat: &model::Match<'a>, focused: bool) { + let text = mat.text; + + let (offset_x, offset_y) = self.match_offsets(mat); + let (offset_x, offset_y) = self.map_coords_to_wrapped_space(offset_x, offset_y); + + Ui::render_matched_text( + stdout, + text, + focused, + (offset_x, offset_y), + &self.rendering_colors, + ); + + if !focused { + // If not focused, render the hint (e.g. "eo") as an overlay on + // top of the rendered matched text, aligned at its leading or the + // trailing edge. + let extra_offset = match self.hint_alignment { + HintAlignment::Leading => 0, + HintAlignment::Trailing => text.len() - mat.hint.len(), + }; + + Ui::render_matched_hint( + stdout, + &mat.hint, + (offset_x + extra_offset, offset_y), + &self.rendering_colors, + &self.hint_style, + ); + } + } + + /// Full nender the Ui on the provided writer. + /// + /// This renders in 3 phases: + /// - all lines are rendered verbatim + /// - each Match's `text` is rendered as an overlay on top of it + /// - each Match's `hint` text is rendered as a final overlay + /// + /// Depending on the value of `self.hint_alignment`, the hint can be rendered + /// on the leading edge of the underlying Match's `text`, + /// or on the trailing edge. + /// + /// # Note + /// Multibyte characters are taken into account, so that the Match's `text` + /// and `hint` are rendered in their proper position. + fn full_render(&self, stdout: &mut dyn io::Write) -> () { + // 1. Trim all lines and render non-empty ones. + Ui::render_base_text( + stdout, + &self.model.lines, + &self.line_offsets, + &self.rendering_colors, + ); + + for (index, mat) in self.matches.iter().enumerate() { + let focused = index == self.focus_index; + self.render_match(stdout, mat, focused); + } + + stdout.flush().unwrap(); + } + + /// Render the previous match with its hint, and render the newly focused + /// match without its hint. This is more efficient than a full render. + fn diff_render( + &self, + stdout: &mut dyn io::Write, + old_focus_index: usize, + new_focus_index: usize, + ) { + // Render the previously focused match as non-focused + let mat = self.matches.get(old_focus_index).unwrap(); + let focused = false; + self.render_match(stdout, mat, focused); + + // Render the previously focused match as non-focused + let mat = self.matches.get(new_focus_index).unwrap(); + let focused = true; + self.render_match(stdout, mat, focused); + + stdout.flush().unwrap(); + } + + /// Listen to keys entered on stdin, moving focus accordingly, or + /// selecting one match. + /// + /// # Panics + /// + /// - This function panics if termion cannot read the entered keys on stdin. + fn listen(&mut self, reader: &mut dyn io::Read, writer: &mut dyn io::Write) -> Event { + use termion::input::TermRead; // Trait for `reader.keys().next()`. + + if self.matches.is_empty() { + return Event::Exit; + } + + let mut typed_hint = String::new(); + + self.full_render(writer); + + loop { + // This is an option of a result of a key... Let's pop error cases first. + let next_key = reader.keys().next(); + + if next_key.is_none() { + // Nothing in the buffer. Wait for a bit... + std::thread::sleep(std::time::Duration::from_millis(25)); + continue; + } + + let key_res = next_key.unwrap(); + if let Err(err) = key_res { + // Termion not being able to read from stdin is an unrecoverable error. + panic!(err); + } + + match key_res.unwrap() { + event::Key::Esc => { + break; + } + + // Move focus to next/prev match. + event::Key::Up => { + let (old_index, focused_index) = self.prev_focus_index(); + self.diff_render(writer, old_index, focused_index); + } + event::Key::Down => { + let (old_index, focused_index) = self.next_focus_index(); + self.diff_render(writer, old_index, focused_index); + } + event::Key::Left => { + let (old_index, focused_index) = self.prev_focus_index(); + self.diff_render(writer, old_index, focused_index); + } + event::Key::Right => { + let (old_index, focused_index) = self.next_focus_index(); + self.diff_render(writer, old_index, focused_index); + } + event::Key::Char(_ch @ 'n') => { + let (old_index, focused_index) = if self.model.reverse { + self.prev_focus_index() + } else { + self.next_focus_index() + }; + self.diff_render(writer, old_index, focused_index); + } + event::Key::Char(_ch @ 'N') => { + let (old_index, focused_index) = if self.model.reverse { + self.next_focus_index() + } else { + self.prev_focus_index() + }; + self.diff_render(writer, old_index, focused_index); + } + + // Yank/copy + event::Key::Char(_ch @ 'y') => { + let text = self.matches.get(self.focus_index).unwrap().text; + return Event::Match((text.to_string(), false)); + } + event::Key::Char(_ch @ 'Y') => { + let text = self.matches.get(self.focus_index).unwrap().text; + return Event::Match((text.to_string(), true)); + } + + // TODO: use a Trie or another data structure to determine + // if the entered key belongs to a longer hint. + // Attempts at finding a match with a corresponding hint. + event::Key::Char(ch) => { + let key = ch.to_string(); + let lower_key = key.to_lowercase(); + + typed_hint.push_str(&lower_key); + + let node = self + .lookup_trie + .get_node(&typed_hint.chars().collect::>()); + + if node.is_none() { + // An unknown key was entered. + return Event::Exit; + } + + let node = node.unwrap(); + if node.is_leaf() { + // The last key of a hint was entered. + let match_index = node.value().expect( + "By construction, the Lookup Trie should have a value for each leaf.", + ); + let mat = self.matches.get(*match_index).expect("By construction, the value in a leaf should correspond to an existing hint."); + let text = mat.text.to_string(); + let uppercased = key != lower_key; + return Event::Match((text, uppercased)); + } else { + // The prefix of a hint was entered, but we + // still need more keys. + continue; + } + } + + // Unknown keys are ignored. + _ => (), + } + + // End of event processing loop. + } + + Event::Exit + } + + /// Configure the terminal and display the `Ui`. + /// + /// - Setup steps: switch to alternate screen, switch to raw mode, hide the cursor. + /// - Teardown steps: show cursor, back to main screen. + pub fn present(&mut self) -> Option<(String, bool)> { + use std::io::Write; + use termion::raw::IntoRawMode; + use termion::screen::AlternateScreen; + + let mut stdin = termion::async_stdin(); + let mut stdout = AlternateScreen::from( + io::stdout() + .into_raw_mode() + .expect("Cannot access alternate screen."), + ); + + write!(stdout, "{}", cursor::Hide).unwrap(); + + let selection = match self.listen(&mut stdin, &mut stdout) { + Event::Exit => None, + Event::Match((text, uppercased)) => Some((text, uppercased)), + }; + + write!(stdout, "{}", cursor::Show).unwrap(); + + selection + } +} + +/// Compute each line's actual y offset if displayed in a terminal of width +/// `term_width`. +fn get_line_offsets(lines: &Vec<&str>, term_width: u16) -> Vec { + lines + .iter() + .scan(0, |offset, &line| { + let value = *offset; + // amount of extra y space taken by this line + let extra = line.trim_end().len() / term_width as usize; + + *offset = *offset + 1 + extra; + + Some(value) + }) + .collect() +} + +/// Describes if, during rendering, a hint should aligned to the leading edge of +/// the matched text, or to its trailing edge. +#[derive(Debug, Clap)] +pub enum HintAlignment { + Leading, + Trailing, +} + +impl FromStr for HintAlignment { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s { + "leading" => Ok(HintAlignment::Leading), + "trailing" => Ok(HintAlignment::Trailing), + _ => Err(ParseError::ExpectedString(String::from( + "leading or trailing", + ))), + } + } +} + +/// Describes the style of contrast to be used during rendering of the hint's +/// text. +/// +/// # Note +/// In practice, this is wrapped in an `Option`, so that the hint's text can be rendered with no style. +pub enum HintStyle { + /// The hint's text will be bold (leveraging `termion::style::Bold`). + Bold, + /// The hint's text will be italicized (leveraging `termion::style::Italic`). + Italic, + /// The hint's text will be underlined (leveraging `termion::style::Underline`). + Underline, + /// The hint's text will be surrounded by these chars. + Surround(char, char), +} + +/// Returned value after the `Ui` has finished listening to events. +enum Event { + /// Exit with no selected matches, + Exit, + /// A vector of matched text and whether it was selected with uppercase. + Match((String, bool)), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::alphabets; + + #[test] + fn test_render_all_lines() { + let content = "some text +* e006b06 - (12 days ago) swapper: Make quotes +path: /usr/local/bin/git + + +path: /usr/local/bin/cargo"; + let lines: Vec<&str> = content.split('\n').collect(); + let line_offsets: Vec = (0..lines.len()).collect(); + + let colors = UiColors { + text_fg: Box::new(color::Black), + text_bg: Box::new(color::White), + focused_fg: Box::new(color::Red), + focused_bg: Box::new(color::Blue), + match_fg: Box::new(color::Green), + match_bg: Box::new(color::Magenta), + hint_fg: Box::new(color::Yellow), + hint_bg: Box::new(color::Cyan), + }; + + let mut writer = vec![]; + Ui::render_base_text(&mut writer, &lines, &line_offsets, &colors); + + let goto1 = cursor::Goto(1, 1); + let goto2 = cursor::Goto(1, 2); + let goto3 = cursor::Goto(1, 3); + let goto6 = cursor::Goto(1, 6); + assert_eq!( + writer, + format!( + "{bg}{fg}{g1}some text{g2}* e006b06 - (12 days ago) swapper: Make quotes{g3}path: /usr/local/bin/git{g6}path: /usr/local/bin/cargo{fg_reset}{bg_reset}", + g1 = goto1, g2 = goto2, g3 = goto3, g6 = goto6, + fg = color::Fg(colors.text_fg.as_ref()), + bg = color::Bg(colors.text_bg.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset), + ) + .as_bytes() + ); + } + + #[test] + fn test_render_focused_matched_text() { + let mut writer = vec![]; + let text = "https://en.wikipedia.org/wiki/Barcelona"; + let focused = true; + let offset: (usize, usize) = (3, 1); + let colors = UiColors { + text_fg: Box::new(color::Black), + text_bg: Box::new(color::White), + focused_fg: Box::new(color::Red), + focused_bg: Box::new(color::Blue), + match_fg: Box::new(color::Green), + match_bg: Box::new(color::Magenta), + hint_fg: Box::new(color::Yellow), + hint_bg: Box::new(color::Cyan), + }; + + Ui::render_matched_text(&mut writer, text, focused, offset, &colors); + + assert_eq!( + writer, + format!( + "{goto}{bg}{fg}{text}{fg_reset}{bg_reset}", + goto = cursor::Goto(4, 2), + fg = color::Fg(colors.focused_fg.as_ref()), + bg = color::Bg(colors.focused_bg.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset), + text = &text, + ) + .as_bytes() + ); + } + + #[test] + fn test_render_matched_text() { + let mut writer = vec![]; + let text = "https://en.wikipedia.org/wiki/Barcelona"; + let focused = false; + let offset: (usize, usize) = (3, 1); + let colors = UiColors { + text_fg: Box::new(color::Black), + text_bg: Box::new(color::White), + focused_fg: Box::new(color::Red), + focused_bg: Box::new(color::Blue), + match_fg: Box::new(color::Green), + match_bg: Box::new(color::Magenta), + hint_fg: Box::new(color::Yellow), + hint_bg: Box::new(color::Cyan), + }; + + Ui::render_matched_text(&mut writer, text, focused, offset, &colors); + + assert_eq!( + writer, + format!( + "{goto}{bg}{fg}{text}{fg_reset}{bg_reset}", + goto = cursor::Goto(4, 2), + fg = color::Fg(colors.match_fg.as_ref()), + bg = color::Bg(colors.match_bg.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset), + text = &text, + ) + .as_bytes() + ); + } + + #[test] + fn test_render_unstyled_matched_hint() { + let mut writer = vec![]; + let hint_text = "eo"; + let offset: (usize, usize) = (3, 1); + let colors = UiColors { + text_fg: Box::new(color::Black), + text_bg: Box::new(color::White), + focused_fg: Box::new(color::Red), + focused_bg: Box::new(color::Blue), + match_fg: Box::new(color::Green), + match_bg: Box::new(color::Magenta), + hint_fg: Box::new(color::Yellow), + hint_bg: Box::new(color::Cyan), + }; + + let extra_offset = 0; + let hint_style = None; + + Ui::render_matched_hint( + &mut writer, + hint_text, + (offset.0 + extra_offset, offset.1), + &colors, + &hint_style, + ); + + assert_eq!( + writer, + format!( + "{goto}{bg}{fg}{text}{fg_reset}{bg_reset}", + goto = cursor::Goto(4, 2), + fg = color::Fg(colors.hint_fg.as_ref()), + bg = color::Bg(colors.hint_bg.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset), + text = "eo", + ) + .as_bytes() + ); + } + + #[test] + fn test_render_underlined_matched_hint() { + let mut writer = vec![]; + let hint_text = "eo"; + let offset: (usize, usize) = (3, 1); + let colors = UiColors { + text_fg: Box::new(color::Black), + text_bg: Box::new(color::White), + focused_fg: Box::new(color::Red), + focused_bg: Box::new(color::Blue), + match_fg: Box::new(color::Green), + match_bg: Box::new(color::Magenta), + hint_fg: Box::new(color::Yellow), + hint_bg: Box::new(color::Cyan), + }; + + let extra_offset = 0; + let hint_style = Some(HintStyle::Underline); + + Ui::render_matched_hint( + &mut writer, + hint_text, + (offset.0 + extra_offset, offset.1), + &colors, + &hint_style, + ); + + assert_eq!( + writer, + format!( + "{goto}{bg}{fg}{sty}{text}{sty_reset}{fg_reset}{bg_reset}", + goto = cursor::Goto(4, 2), + fg = color::Fg(colors.hint_fg.as_ref()), + bg = color::Bg(colors.hint_bg.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset), + sty = style::Underline, + sty_reset = style::NoUnderline, + text = "eo", + ) + .as_bytes() + ); + } + + #[test] + fn test_render_bracketed_matched_hint() { + let mut writer = vec![]; + let hint_text = "eo"; + let offset: (usize, usize) = (3, 1); + let colors = UiColors { + text_fg: Box::new(color::Black), + text_bg: Box::new(color::White), + focused_fg: Box::new(color::Red), + focused_bg: Box::new(color::Blue), + match_fg: Box::new(color::Green), + match_bg: Box::new(color::Magenta), + hint_fg: Box::new(color::Yellow), + hint_bg: Box::new(color::Cyan), + }; + + let extra_offset = 0; + let hint_style = Some(HintStyle::Surround('{', '}')); + + Ui::render_matched_hint( + &mut writer, + hint_text, + (offset.0 + extra_offset, offset.1), + &colors, + &hint_style, + ); + + assert_eq!( + writer, + format!( + "{goto}{bg}{fg}{bra}{text}{bra_close}{fg_reset}{bg_reset}", + goto = cursor::Goto(4, 2), + fg = color::Fg(colors.hint_fg.as_ref()), + bg = color::Bg(colors.hint_bg.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset), + bra = '{', + bra_close = '}', + text = "eo", + ) + .as_bytes() + ); + } + + #[test] + /// Simulates rendering without any match. + fn test_render_full_without_matches() { + let content = "lorem 127.0.0.1 lorem + +Barcelona https://en.wikipedia.org/wiki/Barcelona - "; + + let named_pat = vec![]; + let custom_regexes = vec![]; + let alphabet = alphabets::Alphabet("abcd".to_string()); + let mut model = model::Model::new(content, &alphabet, &named_pat, &custom_regexes, false); + let term_width: u16 = 80; + let line_offsets = get_line_offsets(&model.lines, term_width); + let rendering_colors = UiColors { + text_fg: Box::new(color::Black), + text_bg: Box::new(color::White), + focused_fg: Box::new(color::Red), + focused_bg: Box::new(color::Blue), + match_fg: Box::new(color::Green), + match_bg: Box::new(color::Magenta), + hint_fg: Box::new(color::Yellow), + hint_bg: Box::new(color::Cyan), + }; + let hint_alignment = HintAlignment::Leading; + + // create a Ui without any match + let ui = Ui { + model: &mut model, + term_width, + line_offsets, + matches: vec![], // no matches + lookup_trie: SequenceTrie::new(), + focus_index: 0, + focus_wrap_around: false, + rendering_colors: &rendering_colors, + hint_alignment: &hint_alignment, + hint_style: None, + }; + + let mut writer = vec![]; + ui.full_render(&mut writer); + + let goto1 = cursor::Goto(1, 1); + let goto3 = cursor::Goto(1, 3); + + let expected = format!( + "{bg}{fg}{goto1}lorem 127.0.0.1 lorem\ + {goto3}Barcelona https://en.wikipedia.org/wiki/Barcelona -{fg_reset}{bg_reset}", + goto1 = goto1, + goto3 = goto3, + fg = color::Fg(rendering_colors.text_fg.as_ref()), + bg = color::Bg(rendering_colors.text_bg.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset), + ); + + // println!("{:?}", writer); + // println!("{:?}", expected.as_bytes()); + + // println!("matches: {}", ui.matches.len()); + // println!("lines: {}", lines.len()); + + assert_eq!(writer, expected.as_bytes()); + } + + #[test] + /// Simulates rendering with matches. + fn test_render_full_with_matches() { + let content = "lorem 127.0.0.1 lorem + +Barcelona https://en.wikipedia.org/wiki/Barcelona - "; + + let named_pat = vec![]; + let custom_regexes = vec![]; + let alphabet = alphabets::Alphabet("abcd".to_string()); + let reverse = true; + let mut model = model::Model::new(content, &alphabet, &named_pat, &custom_regexes, reverse); + let unique_hint = false; + let wrap_around = false; + + let rendering_colors = UiColors { + text_fg: Box::new(color::Black), + text_bg: Box::new(color::White), + focused_fg: Box::new(color::Red), + focused_bg: Box::new(color::Blue), + match_fg: Box::new(color::Green), + match_bg: Box::new(color::Magenta), + hint_fg: Box::new(color::Yellow), + hint_bg: Box::new(color::Cyan), + }; + let hint_alignment = HintAlignment::Leading; + let hint_style = None; + + let ui = Ui::new( + &mut model, + unique_hint, + wrap_around, + &rendering_colors, + &hint_alignment, + hint_style, + ); + + let mut writer = vec![]; + ui.full_render(&mut writer); + + let expected_content = { + let goto1 = cursor::Goto(1, 1); + let goto3 = cursor::Goto(1, 3); + + format!( + "{bg}{fg}{goto1}lorem 127.0.0.1 lorem\ + {goto3}Barcelona https://en.wikipedia.org/wiki/Barcelona -{fg_reset}{bg_reset}", + goto1 = goto1, + goto3 = goto3, + fg = color::Fg(rendering_colors.text_fg.as_ref()), + bg = color::Bg(rendering_colors.text_bg.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset) + ) + }; + + let expected_match1_text = { + let goto7_1 = cursor::Goto(7, 1); + format!( + "{goto7_1}{match_bg}{match_fg}127.0.0.1{fg_reset}{bg_reset}", + goto7_1 = goto7_1, + match_fg = color::Fg(rendering_colors.match_fg.as_ref()), + match_bg = color::Bg(rendering_colors.match_bg.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset) + ) + }; + + let expected_match1_hint = { + let goto7_1 = cursor::Goto(7, 1); + + format!( + "{goto7_1}{hint_bg}{hint_fg}b{fg_reset}{bg_reset}", + goto7_1 = goto7_1, + hint_fg = color::Fg(rendering_colors.hint_fg.as_ref()), + hint_bg = color::Bg(rendering_colors.hint_bg.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset) + ) + }; + + let expected_match2_text = { + let goto11_3 = cursor::Goto(11, 3); + format!( + "{goto11_3}{focus_bg}{focus_fg}https://en.wikipedia.org/wiki/Barcelona{fg_reset}{bg_reset}", + goto11_3 = goto11_3, + focus_fg = color::Fg(rendering_colors.focused_fg.as_ref()), + focus_bg = color::Bg(rendering_colors.focused_bg.as_ref()), + fg_reset = color::Fg(color::Reset), + bg_reset = color::Bg(color::Reset) + ) + }; + + // Because reverse is true, this second match is focused, + // then the hint should not be rendered. + + // let expected_match2_hint = { + // let goto11_3 = cursor::Goto(11, 3); + + // format!( + // "{goto11_3}{hint_bg}{hint_fg}a{fg_reset}{bg_reset}", + // goto11_3 = goto11_3, + // hint_fg = color::Fg(rendering_colors.hint_fg.as_ref()), + // hint_bg = color::Bg(rendering_colors.hint_bg.as_ref()), + // fg_reset = color::Fg(color::Reset), + // bg_reset = color::Bg(color::Reset) + // ) + // }; + + let expected = [ + expected_content, + expected_match1_text, + expected_match1_hint, + expected_match2_text, + // expected_match2_hint, + ] + .concat(); + + // println!("{:?}", writer); + // println!("{:?}", expected.as_bytes()); + + // let diff_point = writer + // .iter() + // .zip(expected.as_bytes().iter()) + // .enumerate() + // .find(|(_idx, (&l, &r))| l != r); + // println!("{:?}", diff_point); + + assert_eq!(2, ui.matches.len()); + + assert_eq!(writer, expected.as_bytes()); + } +} + +/// Holds color-related data, for clarity. +/// +/// - `focus_*` colors are used to render the currently focused matched text. +/// - `normal_*` colors are used to render other matched text. +/// - `hint_*` colors are used to render the hints. +#[derive(Clap, Debug)] +pub struct UiColors { + /// Foreground color for base text. + #[clap(long, default_value = "bright-cyan", parse(try_from_str = colors::parse_color))] + pub text_fg: Box, + + /// Background color for base text. + #[clap(long, default_value = "bright-white", parse(try_from_str = colors::parse_color))] + pub text_bg: Box, + + /// Foreground color for matches. + #[clap(long, default_value = "yellow", + parse(try_from_str = colors::parse_color))] + pub match_fg: Box, + + /// Background color for matches. + #[clap(long, default_value = "bright-white", + parse(try_from_str = colors::parse_color))] + pub match_bg: Box, + + /// Foreground color for the focused match. + #[clap(long, default_value = "magenta", + parse(try_from_str = colors::parse_color))] + pub focused_fg: Box, + + /// Background color for the focused match. + #[clap(long, default_value = "bright-white", + parse(try_from_str = colors::parse_color))] + pub focused_bg: Box, + + /// Foreground color for hints. + #[clap(long, default_value = "white", + parse(try_from_str = colors::parse_color))] + pub hint_fg: Box, + + /// Background color for hints. + #[clap(long, default_value = "magenta", + parse(try_from_str = colors::parse_color))] + pub hint_bg: Box, +} diff --git a/src/view.rs b/src/view.rs deleted file mode 100644 index 40bc3b8..0000000 --- a/src/view.rs +++ /dev/null @@ -1,304 +0,0 @@ -use super::*; -use std::char; -use std::io::{stdout, Read, Write}; -use termion::async_stdin; -use termion::event::Key; -use termion::input::TermRead; -use termion::raw::IntoRawMode; -use termion::screen::AlternateScreen; -use termion::{color, cursor}; - -pub struct View<'a> { - state: &'a mut state::State<'a>, - skip: usize, - multi: bool, - contrast: bool, - position: &'a str, - matches: Vec>, - select_foreground_color: Box<&'a dyn color::Color>, - select_background_color: Box<&'a dyn color::Color>, - foreground_color: Box<&'a dyn color::Color>, - background_color: Box<&'a dyn color::Color>, - hint_background_color: Box<&'a dyn color::Color>, - hint_foreground_color: Box<&'a dyn color::Color>, -} - -enum CaptureEvent { - Exit, - Hint(Vec<(String, bool)>), -} - -impl<'a> View<'a> { - pub fn new( - state: &'a mut state::State<'a>, - multi: bool, - reverse: bool, - unique: bool, - contrast: bool, - position: &'a str, - select_foreground_color: Box<&'a dyn color::Color>, - select_background_color: Box<&'a dyn color::Color>, - foreground_color: Box<&'a dyn color::Color>, - background_color: Box<&'a dyn color::Color>, - hint_foreground_color: Box<&'a dyn color::Color>, - hint_background_color: Box<&'a dyn color::Color>, - ) -> View<'a> { - let matches = state.matches(reverse, unique); - let skip = if reverse { matches.len() - 1 } else { 0 }; - - View { - state, - skip, - multi, - contrast, - position, - matches, - select_foreground_color, - select_background_color, - foreground_color, - background_color, - hint_foreground_color, - hint_background_color, - } - } - - pub fn prev(&mut self) { - if self.skip > 0 { - self.skip -= 1; - } - } - - pub fn next(&mut self) { - if self.skip < self.matches.len() - 1 { - self.skip += 1; - } - } - - fn make_hint_text(&self, hint: &str) -> String { - if self.contrast { - format!("[{}]", hint) - } else { - hint.to_string() - } - } - - fn render(&self, stdout: &mut dyn Write) -> () { - write!(stdout, "{}", cursor::Hide).unwrap(); - - for (index, line) in self.state.lines.iter().enumerate() { - let clean = line.trim_end_matches(|c: char| c.is_whitespace()); - - if !clean.is_empty() { - let text = self.make_hint_text(line); - - print!("{goto}{text}", goto = cursor::Goto(1, index as u16 + 1), text = &text); - } - } - - let selected = self.matches.get(self.skip); - - for mat in self.matches.iter() { - let selected_color = if selected == Some(mat) { - &self.select_foreground_color - } else { - &self.foreground_color - }; - let selected_background_color = if selected == Some(mat) { - &self.select_background_color - } else { - &self.background_color - }; - - // Find long utf sequences and extract it from mat.x - let line = &self.state.lines[mat.y as usize]; - let prefix = &line[0..mat.x as usize]; - let extra = prefix.len() - prefix.chars().count(); - let offset = (mat.x as u16) - (extra as u16); - let text = self.make_hint_text(mat.text); - - print!( - "{goto}{background}{foregroud}{text}{resetf}{resetb}", - goto = cursor::Goto(offset + 1, mat.y as u16 + 1), - foregroud = color::Fg(**selected_color), - background = color::Bg(**selected_background_color), - resetf = color::Fg(color::Reset), - resetb = color::Bg(color::Reset), - text = &text - ); - - if let Some(ref hint) = mat.hint { - let extra_position = if self.position == "left" { - 0 - } else { - text.len() - mat.hint.clone().unwrap().len() - }; - - let text = self.make_hint_text(hint.as_str()); - - print!( - "{goto}{background}{foregroud}{text}{resetf}{resetb}", - goto = cursor::Goto(offset + extra_position as u16 + 1, mat.y as u16 + 1), - foregroud = color::Fg(*self.hint_foreground_color), - background = color::Bg(*self.hint_background_color), - resetf = color::Fg(color::Reset), - resetb = color::Bg(color::Reset), - text = &text - ); - } - } - - stdout.flush().unwrap(); - } - - fn listen(&mut self, stdin: &mut dyn Read, stdout: &mut dyn Write) -> CaptureEvent { - if self.matches.is_empty() { - return CaptureEvent::Exit - } - - let mut chosen = vec![]; - let mut typed_hint: String = "".to_owned(); - let longest_hint = self - .matches - .iter() - .filter_map(|m| m.hint.clone()) - .max_by(|x, y| x.len().cmp(&y.len())) - .unwrap() - .clone(); - - self.render(stdout); - - loop { - match stdin.keys().next() { - Some(key) => { - match key { - Ok(key) => { - match key { - Key::Esc => { - if self.multi && !typed_hint.is_empty() { - typed_hint.clear(); - } else { - break; - } - } - Key::Insert => match self.matches.iter().enumerate().find(|&h| h.0 == self.skip) { - Some(hm) => { - chosen.push((hm.1.text.to_string(), false)); - - if !self.multi { - return CaptureEvent::Hint(chosen); - } - } - _ => panic!("Match not found?"), - }, - Key::Up => { - self.prev(); - } - Key::Down => { - self.next(); - } - Key::Left => { - self.prev(); - } - Key::Right => { - self.next(); - } - Key::Char(ch) => { - if ch == ' ' && self.multi { - return CaptureEvent::Hint(chosen); - } - - let key = ch.to_string(); - let lower_key = key.to_lowercase(); - - typed_hint.push_str(lower_key.as_str()); - - let selection = self.matches.iter().find(|mat| mat.hint == Some(typed_hint.clone())); - - match selection { - Some(mat) => { - chosen.push((mat.text.to_string(), key != lower_key)); - - if self.multi { - typed_hint.clear(); - } else { - return CaptureEvent::Hint(chosen); - } - } - None => { - if !self.multi && typed_hint.len() >= longest_hint.len() { - break; - } - } - } - } - _ => { - // Unknown key - } - } - } - Err(err) => panic!(err), - } - } - _ => { - // Nothing in the buffer. Wait for a bit... - std::thread::sleep(std::time::Duration::from_millis(100)); - } - } - - self.render(stdout); - } - - CaptureEvent::Exit - } - - pub fn present(&mut self) -> Vec<(String, bool)> { - let mut stdin = async_stdin(); - let mut stdout = AlternateScreen::from(stdout().into_raw_mode().unwrap()); - - let hints = match self.listen(&mut stdin, &mut stdout) { - CaptureEvent::Exit => vec![], - CaptureEvent::Hint(chosen) => chosen, - }; - - write!(stdout, "{}", cursor::Show).unwrap(); - - hints - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn split(output: &str) -> Vec<&str> { - output.split("\n").collect::>() - } - - #[test] - fn hint_text() { - let lines = split("lorem 127.0.0.1 lorem"); - let custom = [].to_vec(); - let mut state = state::State::new(&lines, "abcd", &custom); - let mut view = View { - state: &mut state, - skip: 0, - multi: false, - contrast: false, - position: &"", - matches: vec![], - select_foreground_color: colors::get_color("default"), - select_background_color: colors::get_color("default"), - foreground_color: colors::get_color("default"), - background_color: colors::get_color("default"), - hint_background_color: colors::get_color("default"), - hint_foreground_color: colors::get_color("default"), - }; - - let result = view.make_hint_text("a"); - assert_eq!(result, "a".to_string()); - - view.contrast = true; - let result = view.make_hint_text("a"); - assert_eq!(result, "[a]".to_string()); - } -} diff --git a/tmux-copyrat.tmux b/tmux-copyrat.tmux new file mode 100755 index 0000000..a7b7c1f --- /dev/null +++ b/tmux-copyrat.tmux @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +DEFAULT_COPYRAT_KEY="space" +COPYRAT_KEY=$(tmux show-option -gqv @copyrat-key) +COPYRAT_KEY=${COPYRAT_KEY:-$DEFAULT_COPYRAT_KEY} + +DEFAULT_COPYRAT_WINDOW_NAME="[copyrat]" +COPYRAT_WINDOW_NAME=$(tmux show-option -gqv @copyrat-window-name) +COPYRAT_WINDOW_NAME=${COPYRAT_WINDOW_NAME:-$DEFAULT_COPYRAT_WINDOW_NAME} + +BINARY="${CURRENT_DIR}/target/release/tmux-copyrat" + +tmux bind-key ${COPYRAT_KEY} new-window -d -n ${COPYRAT_WINDOW_NAME} "${BINARY} --window-name ${COPYRAT_WINDOW_NAME} --reverse --unique" + +if [ ! -f "$BINARY" ]; then + cd "${CURRENT_DIR}" && cargo build --release +fi diff --git a/tmux-thumbs.sh b/tmux-thumbs.sh deleted file mode 100755 index ec6b736..0000000 --- a/tmux-thumbs.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash - -[ -f ~/.bash_profile ] && source ~/.bash_profile - -PARAMS=() - -function add-option-param { - VALUE=$(tmux show -vg @thumbs-$1 2> /dev/null) - - if [[ ${VALUE} ]]; then - PARAMS+=("--$1=${VALUE}") - fi -} - -add-option-param "command" -add-option-param "upcase-command" - -# Remove empty arguments from PARAMS. -# Otherwise, they would choke up tmux-thumbs when passed to it. -for i in "${!PARAMS[@]}"; do - [ -n "${PARAMS[$i]}" ] || unset "PARAMS[$i]" -done - -CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -${CURRENT_DIR}/target/release/tmux-thumbs --dir "${CURRENT_DIR}" "${PARAMS[@]}" - -true diff --git a/tmux-thumbs.tmux b/tmux-thumbs.tmux deleted file mode 100755 index d879bdc..0000000 --- a/tmux-thumbs.tmux +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -DEFAULT_THUMBS_KEY="space" -THUMBS_KEY=$(tmux show-option -gqv @thumbs-key) -THUMBS_KEY=${THUMBS_KEY:-$DEFAULT_THUMBS_KEY} - -tmux bind-key $THUMBS_KEY run-shell -b "${CURRENT_DIR}/tmux-thumbs.sh" - -BINARY="${CURRENT_DIR}/target/release/thumbs" - -if [ ! -f "$BINARY" ]; then - cd "${CURRENT_DIR}" && cargo build --release -fi