diff --git a/.config/config.json5 b/.config/config.json5 index c746239..3e85dc4 100644 --- a/.config/config.json5 +++ b/.config/config.json5 @@ -1,10 +1,24 @@ { "keybindings": { - "Home": { + "Input": { "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", // Quit the application + "": "Quit", // Yet another way to quit + "": "Suspend", // Suspend the application + "": "GoToNextPane", // Move to the next pane + "": "GoToPrevPane", // Move to the previous pane + // "": "GoToPaneUp", // Move to the pane above + // "": "GoToPaneDown", // Move to the pane below + "": "GoToPaneLeft", // Move to the pane to the left + "": "GoToPaneRight", // Move to the pane to the right + "": "SelectNextEntry", // Move to the next entry + "": "SelectPrevEntry", // Move to the previous entry + "": "SelectNextEntry", // Move to the next entry + "": "SelectPrevEntry", // Move to the previous entry + "": "ScrollPreviewDown", // Scroll the preview down + "": "ScrollPreviewUp", // Scroll the preview up + "": "ScrollPreviewHalfPageDown", // Scroll the preview half a page down + "": "ScrollPreviewHalfPageUp", // Scroll the preview half a page up }, } } diff --git a/.config/config.toml b/.config/config.toml new file mode 100644 index 0000000..2e286e0 --- /dev/null +++ b/.config/config.toml @@ -0,0 +1,18 @@ +[keybindings.Input] +q = "Quit" +esc = "Quit" +ctrl-c = "Quit" +ctrl-z = "Suspend" +tab = "GoToNextPane" +backtab = "GoToPrevPane" +ctrl-left = "GoToPaneLeft" +ctrl-right = "GoToPaneRight" +down = "SelectNextEntry" +up = "SelectPrevEntry" +ctrl-n = "SelectNextEntry" +ctrl-p = "SelectPrevEntry" +ctrl-down = "ScrollPreviewDown" +ctrl-up = "ScrollPreviewUp" +ctrl-d = "ScrollPreviewHalfPageDown" +ctrl-u = "ScrollPreviewHalfPageUp" +enter = "SelectEntry" diff --git a/.gitignore b/.gitignore index 8d5254c..ce27fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,20 @@ -/target +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# RustRover +.idea/ + .data/*.log + diff --git a/Cargo.lock b/Cargo.lock index 9989fd4..0e257e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned-vec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" + [[package]] name = "allocator-api2" version = "0.2.18" @@ -105,6 +111,12 @@ version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" + [[package]] name = "arc-swap" version = "1.7.1" @@ -112,21 +124,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" [[package]] -name = "async-trait" -version = "0.1.82" +name = "arg_enum_proc_macro" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", ] [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "av1-grain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876c75a42f6364451a033496a14c44bffe41f5f4a8236f697391f11024e596d2" +dependencies = [ + "arrayvec", +] [[package]] name = "backtrace" @@ -149,6 +201,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "better-panic" version = "0.3.0" @@ -159,6 +217,27 @@ dependencies = [ "console", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit_field" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.6.0" @@ -168,6 +247,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitstream-io" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b81e1519b0d82120d2fd469d5bfb2919a9361c48b02d82d04befc1cdd2002452" + [[package]] name = "block-buffer" version = "0.10.4" @@ -184,15 +269,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" dependencies = [ "memchr", - "regex-automata 0.4.7", + "regex-automata 0.4.8", "serde", ] [[package]] -name = "bytes" -version = "1.7.1" +name = "built" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" +checksum = "236e6289eda5a812bc6b53c3b024039382a2895fbbeef2d748b2931546d392c4" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytemuck" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" [[package]] name = "camino" @@ -243,13 +358,36 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.19" +version = "1.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d74707dde2ba56f86ae90effb3b43ddd369504387e718014de010cec7959800" +checksum = "b16803a61b81d9eabb7eae2588776c4c1e584b738ede45fdbb4c972cec1e9945" dependencies = [ + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -258,9 +396,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.5.17" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac" +checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" dependencies = [ "clap_builder", "clap_derive", @@ -268,9 +406,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.17" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73" +checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" dependencies = [ "anstream", "anstyle", @@ -278,19 +416,19 @@ dependencies = [ "strsim", "terminal_size", "unicase", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] name = "clap_derive" -version = "4.5.13" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -332,6 +470,12 @@ dependencies = [ "tracing-error", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.2" @@ -432,15 +576,40 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.6.0", "crossterm_winapi", - "futures-core", + "filedescriptor", "mio", "parking_lot", "rustix", @@ -496,7 +665,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -507,7 +676,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -521,33 +690,33 @@ dependencies = [ [[package]] name = "derive_builder" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd33f37ee6a119146a1781d3356a7c26028f83d779b2e04ecd45fdc75c76877b" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ "derive_builder_macro", ] [[package]] name = "derive_builder_core" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7431fa049613920234f22c47fdc33e6cf3ee83067091ea4277a3f8c4587aae38" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] name = "derive_builder_macro" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4abae7035bf79b9877b779505d8cf3749285b80c43941eda66604841889451dc" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -561,6 +730,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "devicons" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4a83fa60a6fd75c04a70ccf71daf61a96eb324f4008627907d232a621641e3" +dependencies = [ + "lazy_static", +] + [[package]] name = "diff" version = "0.1.13" @@ -613,6 +791,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" + [[package]] name = "either" version = "1.13.0" @@ -641,6 +825,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "exr" +version = "1.72.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "887d93f60543e9a9362ef8a21beedd0a833c5d9610e18c67abe15a5963dcb1a4" +dependencies = [ + "bit_field", + "flume", + "half", + "lebe", + "miniz_oxide 0.7.4", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "eyre" version = "0.6.12" @@ -663,6 +863,26 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +[[package]] +name = "fdeflate" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8090f921a24b04994d9929e204f50b498a33ea6ba559ffaa05e04f7ee7fb5ab" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filedescriptor" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + [[package]] name = "filetime" version = "0.2.25" @@ -677,20 +897,35 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", "miniz_oxide 0.8.0", ] +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "spin", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -702,9 +937,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -717,9 +952,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -727,15 +962,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -744,38 +979,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -789,6 +1024,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -811,15 +1055,13 @@ dependencies = [ ] [[package]] -name = "getset" -version = "0.1.3" +name = "gif" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f636605b743120a8d32ed92fc27b6cde1a769f8f936c065151eb66f88ded513c" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" dependencies = [ - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.77", + "color_quant", + "weezl", ] [[package]] @@ -830,9 +1072,9 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "gix" -version = "0.63.0" +version = "0.66.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "984c5018adfa7a4536ade67990b3ebc6e11ab57b3d6cd9968de0947ca99b4b06" +checksum = "9048b8d1ae2104f045cb37e5c450fc49d5d8af22609386bfc739c11ba88995eb" dependencies = [ "gix-actor", "gix-commitgraph", @@ -847,7 +1089,6 @@ dependencies = [ "gix-hashtable", "gix-index", "gix-lock", - "gix-macros", "gix-object", "gix-odb", "gix-pack", @@ -872,9 +1113,9 @@ dependencies = [ [[package]] name = "gix-actor" -version = "0.31.5" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0e454357e34b833cc3a00b6efbbd3dd4d18b24b9fb0c023876ec2645e8aa3f2" +checksum = "fc19e312cd45c4a66cd003f909163dc2f8e1623e30a0c0c6df3776e89b308665" dependencies = [ "bstr", "gix-date", @@ -918,9 +1159,9 @@ dependencies = [ [[package]] name = "gix-config" -version = "0.37.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fafe42957e11d98e354a66b6bd70aeea00faf2f62dd11164188224a507c840" +checksum = "78e797487e6ca3552491de1131b4f72202f282fb33f198b1c34406d765b42bb0" dependencies = [ "bstr", "gix-config-value", @@ -943,7 +1184,7 @@ version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03f76169faa0dec598eac60f83d7fcdd739ec16596eca8fb144c88973dbe6f8c" dependencies = [ - "bitflags", + "bitflags 2.6.0", "bstr", "gix-path", "libc", @@ -952,21 +1193,21 @@ dependencies = [ [[package]] name = "gix-date" -version = "0.8.7" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eed6931f21491ee0aeb922751bd7ec97b4b2fe8fbfedcb678e2a2dce5f3b8c0" +checksum = "35c84b7af01e68daf7a6bb8bb909c1ff5edb3ce4326f1f43063a5a96d3c3c8a5" dependencies = [ "bstr", "itoa", + "jiff", "thiserror", - "time", ] [[package]] name = "gix-diff" -version = "0.44.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1996d5c8a305b59709467d80617c9fde48d9d75fd1f4179ea970912630886c9d" +checksum = "92c9afd80fff00f8b38b1c1928442feb4cd6d2232a6ed806b6b193151a3d336c" dependencies = [ "bstr", "gix-hash", @@ -976,9 +1217,9 @@ dependencies = [ [[package]] name = "gix-discover" -version = "0.32.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc27c699b63da66b50d50c00668bc0b7e90c3a382ef302865e891559935f3dbf" +checksum = "0577366b9567376bc26e815fd74451ebd0e6218814e242f8e5b7072c58d956d2" dependencies = [ "bstr", "dunce", @@ -1026,7 +1267,7 @@ version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74908b4bbc0a0a40852737e5d7889f676f081e340d5451a16e5b4c50d592f111" dependencies = [ - "bitflags", + "bitflags 2.6.0", "bstr", "gix-features", "gix-path", @@ -1055,11 +1296,11 @@ dependencies = [ [[package]] name = "gix-index" -version = "0.33.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a9a44eb55bd84bb48f8a44980e951968ced21e171b22d115d1cdcef82a7d73f" +checksum = "0cd4203244444017682176e65fd0180be9298e58ed90bd4a8489a357795ed22d" dependencies = [ - "bitflags", + "bitflags 2.6.0", "bstr", "filetime", "fnv", @@ -1092,22 +1333,11 @@ dependencies = [ "thiserror", ] -[[package]] -name = "gix-macros" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "999ce923619f88194171a67fb3e6d613653b8d4d6078b529b15a765da0edcc17" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.77", -] - [[package]] name = "gix-object" -version = "0.42.3" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25da2f46b4e7c2fa7b413ce4dffb87f69eaf89c2057e386491f4c55cadbfe386" +checksum = "2f5b801834f1de7640731820c2df6ba88d95480dc4ab166a5882f8ff12b88efa" dependencies = [ "bstr", "gix-actor", @@ -1124,9 +1354,9 @@ dependencies = [ [[package]] name = "gix-odb" -version = "0.61.1" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20d384fe541d93d8a3bb7d5d5ef210780d6df4f50c4e684ccba32665a5e3bc9b" +checksum = "a3158068701c17df54f0ab2adda527f5a6aca38fd5fd80ceb7e3c0a2717ec747" dependencies = [ "arc-swap", "gix-date", @@ -1144,9 +1374,9 @@ dependencies = [ [[package]] name = "gix-pack" -version = "0.51.1" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0594491fffe55df94ba1c111a6566b7f56b3f8d2e1efc750e77d572f5f5229" +checksum = "3223aa342eee21e1e0e403cad8ae9caf9edca55ef84c347738d10681676fd954" dependencies = [ "clru", "gix-chunk", @@ -1186,12 +1416,11 @@ dependencies = [ [[package]] name = "gix-ref" -version = "0.44.1" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3394a2997e5bc6b22ebc1e1a87b41eeefbcfcff3dbfa7c4bd73cb0ac8f1f3e2e" +checksum = "ae0d8406ebf9aaa91f55a57f053c5a1ad1a39f60fdf0303142b7be7ea44311e5" dependencies = [ "gix-actor", - "gix-date", "gix-features", "gix-fs", "gix-hash", @@ -1208,9 +1437,9 @@ dependencies = [ [[package]] name = "gix-refspec" -version = "0.23.1" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6868f8cd2e62555d1f7c78b784bece43ace40dd2a462daf3b588d5416e603f37" +checksum = "ebb005f82341ba67615ffdd9f7742c87787544441c88090878393d0682869ca6" dependencies = [ "bstr", "gix-hash", @@ -1222,9 +1451,9 @@ dependencies = [ [[package]] name = "gix-revision" -version = "0.27.2" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b13e43c2118c4b0537ddac7d0821ae0dfa90b7b8dbf20c711e153fb749adce" +checksum = "ba4621b219ac0cdb9256883030c3d56a6c64a6deaa829a92da73b9a576825e1e" dependencies = [ "bstr", "gix-date", @@ -1238,9 +1467,9 @@ dependencies = [ [[package]] name = "gix-revwalk" -version = "0.13.2" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b030ccaab71af141f537e0225f19b9e74f25fefdba0372246b844491cab43e0" +checksum = "b41e72544b93084ee682ef3d5b31b1ba4d8fa27a017482900e5e044d5b1b3984" dependencies = [ "gix-commitgraph", "gix-date", @@ -1257,7 +1486,7 @@ version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fe4d52f30a737bbece5276fab5d3a8b276dc2650df963e293d0673be34e7a5f" dependencies = [ - "bitflags", + "bitflags 2.6.0", "gix-path", "libc", "windows-sys 0.52.0", @@ -1286,11 +1515,11 @@ checksum = "6cae0e8661c3ff92688ce1c8b8058b3efb312aba9492bbe93661a21705ab431b" [[package]] name = "gix-traverse" -version = "0.39.2" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e499a18c511e71cf4a20413b743b9f5bcf64b3d9e81e9c3c6cd399eae55a8840" +checksum = "030da39af94e4df35472e9318228f36530989327906f38e27807df305fccb780" dependencies = [ - "bitflags", + "bitflags 2.6.0", "gix-commitgraph", "gix-date", "gix-hash", @@ -1327,14 +1556,37 @@ dependencies = [ [[package]] name = "gix-validate" -version = "0.8.5" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82c27dd34a49b1addf193c92070bcbf3beaf6e10f16a78544de6372e146a0acf" +checksum = "81f2badbb64e57b404593ee26b752c26991910fd0d81fe6f9a71c1a8309b6c86" dependencies = [ "bstr", "thiserror", ] +[[package]] +name = "globset" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f1ce686646e7f1e19bf7d5533fe443a45dbfb990e00629110797578b42fb19" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.8", + "regex-syntax 0.8.5", +] + +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "hashbrown" version = "0.13.2" @@ -1351,6 +1603,23 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -1374,9 +1643,9 @@ dependencies = [ [[package]] name = "human-panic" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c5a08ed290eac04006e21e63d32e90086b6182c7cd0452d10f4264def1fec9a" +checksum = "80b84a66a325082740043a6c28bbea400c129eac0d3a27673a1de971e44bf1f7" dependencies = [ "anstream", "anstyle", @@ -1388,6 +1657,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "icy_sixel" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86858ae800284d596cfdefcb0ad435c3493c12f35367431bbe9b2b3858c1155b" + [[package]] name = "ident_case" version = "1.0.1" @@ -1404,6 +1679,73 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.8", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44feda355f4159a7c757171a77de25daf6411e217b4cabd03bd6650690468126" + +[[package]] +name = "impl-enum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd48df611bdfc457ea8c24c8c26e5c9d64857454fa8221da7db463d134371f3" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.79", +] + [[package]] name = "indenter" version = "0.3.3" @@ -1412,12 +1754,21 @@ checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" [[package]] name = "indexmap" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.14.5", + "hashbrown 0.15.0", +] + +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", ] [[package]] @@ -1427,7 +1778,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" dependencies = [ "quote", - "syn 2.0.77", + "syn 2.0.79", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", ] [[package]] @@ -1436,6 +1798,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1451,6 +1822,46 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "jiff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a45489186a6123c128fdf6016183fcfab7113e1820eb813127e036e287233fb" +dependencies = [ + "jiff-tzdb-platform", + "windows-sys 0.59.0", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91335e575850c5c4c673b9bd467b0e025f164ca59d0564f69d0c2ee0ffad4653" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9835f0060a626fe59f160437bc725491a6af23133ea906500027d1bd2f8f4329" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + [[package]] name = "json5" version = "0.4.1" @@ -1469,10 +1880,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "libc" -version = "0.2.158" +name = "lebe" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" + +[[package]] +name = "libc" +version = "0.2.159" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96cfd5557eb82f2b83fed4955246c988d331975a002961b07c81584d107e7f7" +dependencies = [ + "arbitrary", + "cc", + "once_cell", +] [[package]] name = "libredox" @@ -1480,7 +1908,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags", + "bitflags 2.6.0", "libc", "redox_syscall", ] @@ -1514,12 +1942,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] -name = "lru" -version = "0.12.4" +name = "loop9" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37ee39891760e7d94734f6f63fedc29a2e4a152f836120753a72503f09fcf904" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" dependencies = [ - "hashbrown 0.14.5", + "imgref", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.0", ] [[package]] @@ -1531,6 +1968,15 @@ dependencies = [ "regex-automata 0.1.10", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1568,6 +2014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1583,6 +2030,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nom" version = "7.1.3" @@ -1593,6 +2046,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1603,12 +2062,83 @@ dependencies = [ "winapi", ] +[[package]] +name = "nucleo" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" +dependencies = [ + "nucleo-matcher", + "parking_lot", + "rayon", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1629,9 +2159,31 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.0" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ea5043e58958ee56f3e15a90aee535795cd7dfd319846288d93c5b57d85cbe" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "onig" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f" +dependencies = [ + "bitflags 1.3.2", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7" +dependencies = [ + "cc", + "pkg-config", +] [[package]] name = "option-ext" @@ -1703,9 +2255,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pathdiff" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361" [[package]] name = "percent-encoding" @@ -1715,9 +2267,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pest" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c73c26c01b8c87956cea613c907c9d6ecffd8d18a2a5908e5de0adfaa185cea" +checksum = "fdbef9d1d47087a895abd220ed25eb4ad973a5e26f6a4367b038c25e28dfc2d9" dependencies = [ "memchr", "thiserror", @@ -1726,9 +2278,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "664d22978e2815783adbdd2c588b455b1bd625299ce36b2a99881ac9627e6d8d" +checksum = "4d3a6e3394ec80feb3b6393c725571754c6188490265c61aaf260810d6b95aa0" dependencies = [ "pest", "pest_generator", @@ -1736,22 +2288,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d5487022d5d33f4c30d91c22afa240ce2a644e87fe08caad974d4eab6badbe" +checksum = "94429506bde1ca69d1b5601962c73f4172ab4726571a59ea95931218cb0e930e" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] name = "pest_meta" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0091754bbd0ea592c4deb3a122ce8ecbb0753b738aa82bc055fcc2eccc8d8174" +checksum = "ac8a071862e93690b6e34e9a5fb8e33ff3734473ac0245b27232222c4906a33f" dependencies = [ "once_cell", "pest", @@ -1770,12 +2322,53 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "plist" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f9d46a34a05a6a57566bc2bfae066ef07585a6e3fa30fbbdff5936380623f0" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.0", +] + [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -1786,33 +2379,11 @@ dependencies = [ "yansi", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.77", -] - [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" dependencies = [ "unicode-ident", ] @@ -1823,6 +2394,49 @@ version = "28.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "744a264d26b88a6a7e37cbad97953fa233b94d585236310bcbc88474b4092d79" +[[package]] +name = "profiling" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d84d1d7a6ac92673717f9f6d1518374ef257669c24ebc5ac25d5033828be58" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd" +dependencies = [ + "quote", + "syn 2.0.79", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.37" @@ -1832,18 +2446,48 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "ratatui" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" dependencies = [ - "bitflags", + "bitflags 2.6.0", "cassowary", "compact_str", "crossterm", "instability", - "itertools", + "itertools 0.13.0", "lru", "paste", "serde", @@ -1851,16 +2495,100 @@ dependencies = [ "strum_macros", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.1.14", +] + +[[package]] +name = "ratatui-image" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de94276254cb20fb7431726875bd2ac6391a6ffc26f4b8e3d23f79d1286b491e" +dependencies = [ + "base64 0.22.1", + "dyn-clone", + "icy_sixel", + "image", + "rand", + "ratatui", + "rustix", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.12.1", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "system-deps", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f0bfd976333248de2078d350bfdf182ff96e168a24d23d2436cef320dd4bdd" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", ] [[package]] name = "redox_syscall" -version = "0.5.4" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ - "bitflags", + "bitflags 2.6.0", ] [[package]] @@ -1876,14 +2604,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.6" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata 0.4.8", + "regex-syntax 0.8.5", ] [[package]] @@ -1897,13 +2625,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.4", + "regex-syntax 0.8.5", ] [[package]] @@ -1914,9 +2642,18 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rgb" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" +dependencies = [ + "bytemuck", +] [[package]] name = "ron" @@ -1924,8 +2661,8 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ - "base64", - "bitflags", + "base64 0.21.7", + "bitflags 2.6.0", "serde", "serde_derive", ] @@ -1952,7 +2689,7 @@ version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ - "bitflags", + "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", @@ -2012,7 +2749,7 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2029,9 +2766,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] @@ -2098,6 +2835,21 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "slab" version = "0.4.9" @@ -2123,6 +2875,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -2159,11 +2920,11 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2179,15 +2940,65 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.77" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "syntect" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1" +dependencies = [ + "bincode", + "bitflags 1.3.2", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax 0.8.5", + "serde", + "serde_derive", + "serde_json", + "thiserror", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "television-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + [[package]] name = "television-rs" version = "0.1.0" @@ -2199,32 +3010,48 @@ dependencies = [ "config", "crossterm", "derive_deref", + "devicons", "directories", "futures", + "fuzzy-matcher", "human-panic", + "ignore", + "image", + "impl-enum", + "infer", "json5", "lazy_static", "libc", + "nucleo", + "nucleo-matcher", + "parking_lot", "pretty_assertions", "ratatui", + "ratatui-image", + "regex", "serde", "serde_json", "signal-hook", "strip-ansi-escapes", "strum", + "syntect", + "television-derive", "tokio", + "tokio-stream", "tokio-util", + "toml", "tracing", "tracing-error", "tracing-subscriber", + "unicode-width 0.2.0", "vergen-gix", ] [[package]] name = "tempfile" -version = "3.12.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" dependencies = [ "cfg-if", "fastrand", @@ -2235,32 +3062,32 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" dependencies = [ "rustix", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2273,6 +3100,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.36" @@ -2356,7 +3194,18 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", +] + +[[package]] +name = "tokio-stream" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", ] [[package]] @@ -2395,9 +3244,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.20" +version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ "indexmap", "serde", @@ -2425,7 +3274,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2486,9 +3335,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "ucd-trie" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicase" @@ -2501,9 +3350,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" [[package]] name = "unicode-bom" @@ -2519,9 +3368,9 @@ checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "unicode-normalization" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] @@ -2538,16 +3387,22 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] name = "unicode-width" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "url" @@ -2575,6 +3430,17 @@ dependencies = [ "getrandom", ] +[[package]] +name = "v_frame" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.0" @@ -2583,14 +3449,13 @@ checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" [[package]] name = "vergen" -version = "9.0.0" +version = "9.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c32e7318e93a9ac53693b6caccfb05ff22e04a44c7cf8a279051f24c09da286f" +checksum = "349ed9e45296a581f455bc18039878f409992999bc1d5da12a6800eb18c8752f" dependencies = [ "anyhow", "cargo_metadata", "derive_builder", - "getset", "regex", "rustversion", "time", @@ -2599,9 +3464,9 @@ dependencies = [ [[package]] name = "vergen-gix" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e353ce10596ac5821aaf76e4952360f6d08d3ece9498564d066167bb0f437c94" +checksum = "02ef5d49e57c96e025770171c1c7ee0e30cd6f712f21a1fe501a58be6d069192" dependencies = [ "anyhow", "derive_builder", @@ -2614,16 +3479,21 @@ dependencies = [ [[package]] name = "vergen-lib" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e06bee42361e43b60f363bad49d63798d0f42fb1768091812270eca00c784720" +checksum = "229eaddb0050920816cf051e619affaf18caa3dd512de8de5839ccbc8e53abb0" dependencies = [ "anyhow", "derive_builder", - "getset", "rustversion", ] +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + [[package]] name = "version_check" version = "0.9.5" @@ -2666,6 +3536,67 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.79", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + [[package]] name = "winapi" version = "0.3.9" @@ -2847,9 +3778,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.18" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] @@ -2875,6 +3806,7 @@ version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ + "byteorder", "zerocopy-derive", ] @@ -2886,5 +3818,29 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16099418600b4d8f028622f73ff6e3deaabdff330fb9a2a131dea781ee8b0768" +dependencies = [ + "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index 52b96af..a58168b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,45 +3,94 @@ name = "television-rs" version = "0.1.0" edition = "2021" description = "The revolution will be televised" +license = "Apache-2.0" authors = ["Alexandre Pasmantier "] build = "build.rs" repository = "https://github.com/alexpasmantier/television" +keywords = ["search", "fuzzy", "preview", "tui", "terminal"] +categories = [ + "command-line-utilities", + "command-line-interface", + "concurrency", + "development-tools", +] + + +[[bin]] +bench = false +path = "crates/television/main.rs" +name = "tv" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[workspace] +members = [ + "crates/television_derive", +] [dependencies] +television-derive = { path = "crates/television_derive" } better-panic = "0.3.0" clap = { version = "4.4.5", features = [ - "derive", - "cargo", - "wrap_help", - "unicode", - "string", - "unstable-styles", + "derive", + "cargo", + "wrap_help", + "unicode", + "string", + "unstable-styles", ] } color-eyre = "0.6.3" config = "0.14.0" -crossterm = { version = "0.28.1", features = ["serde", "event-stream"] } +crossterm = { version = "0.28.1", features = ["serde"] } derive_deref = "1.1.1" +devicons = "0.5.4" directories = "5.0.1" futures = "0.3.30" +fuzzy-matcher = "0.3.7" human-panic = "2.0.1" +ignore = "0.4.23" +image = "0.25.2" +impl-enum = "0.3.1" +infer = "0.16.0" json5 = "0.4.1" lazy_static = "1.5.0" libc = "0.2.158" +nucleo = "0.5.0" +nucleo-matcher = "0.3.1" +parking_lot = "0.12.3" pretty_assertions = "1.4.0" ratatui = { version = "0.28.1", features = ["serde", "macros"] } +ratatui-image = "1.0.5" +regex = "1.10.6" serde = { version = "1.0.208", features = ["derive"] } serde_json = "1.0.125" signal-hook = "0.3.17" strip-ansi-escapes = "0.2.0" strum = { version = "0.26.3", features = ["derive"] } +syntect = "5.2.0" tokio = { version = "1.39.3", features = ["full"] } +tokio-stream = "0.1.16" tokio-util = "0.7.11" +toml = "0.8.19" tracing = "0.1.40" tracing-error = "0.2.0" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } +unicode-width = "0.2.0" + [build-dependencies] anyhow = "1.0.86" vergen-gix = { version = "1.0.0", features = ["build", "cargo"] } + + + +[profile.release] +opt-level = 3 +debug = "none" +strip = "symbols" +debug-assertions = false +overflow-checks = false +lto = "thin" +panic = "abort" + +[target.'cfg(target_os = "macos")'.dependencies] +crossterm = { version = "0.28.1", features = ["serde", "use-dev-tty"] } diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..5519f74 --- /dev/null +++ b/TODO.md @@ -0,0 +1,39 @@ +# tasks +- [x] preview navigation +- [ ] add a way to open the selected file in the default editor +- [x] maybe filter out image types etc. for now +- [x] return selected entry on exit +- [x] piping output to another command +- [x] piping custom entries from stdin (e.g. `ls | tv`, what bout choosing previewers in that case? Some AUTO mode?) + +## bugs +- [x] sanitize input (tabs, \0, etc) (see https://github.com/autobib/nucleo-picker/blob/d51dec9efd523e88842c6eda87a19c0a492f4f36/src/lib.rs#L212-L227) + +## improvements +- [x] async finder initialization +- [x] async finder search +- [x] use nucleo for env +- [ ] better keymaps +- [ ] mutualize placeholder previews in cache (really not a priority) +- [ ] better abstractions for channels / separation / isolation so that others can contribute new ones easily +- [ ] channel selection in the UI (separate menu or top panel or something) +- [x] only render highlighted lines that are visible +- [x] only ever read a portion of the file for the temp preview +- [ ] make layout an attribute of the channel? +- [ ] I feel like the finder abstraction is a superfluous layer, maybe just use the channel directly? + +## feature ideas +- [ ] some sort of iterative fuzzy file explorer (preview contents of folders on the right, enter to go in etc.) maybe + with mixed previews of files and folders +- [x] environment variables +- [ ] aliases +- [ ] shell history +- [x] text +- [ ] text in documents (pdfs, archives, ...) (rga, adapters) https://github.com/jrmuizel/pdf-extract +- [x] fd +- [ ] recent directories +- [ ] git (commits, branches, status, diff, ...) +- [ ] makefile commands +- [ ] remote files (s3, ...) +- [ ] custom actions as part of a channel (mappable) + diff --git a/crates/television/action.rs b/crates/television/action.rs new file mode 100644 index 0000000..ded6fe9 --- /dev/null +++ b/crates/television/action.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; +use strum::Display; + +#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)] +pub enum Action { + // input actions + AddInputChar(char), + DeletePrevChar, + DeleteNextChar, + GoToPrevChar, + GoToNextChar, + GoToInputStart, + GoToInputEnd, + // rendering actions + Render, + Resize(u16, u16), + ClearScreen, + // results actions + SelectEntry, + SelectNextEntry, + SelectPrevEntry, + // navigation actions + GoToPaneUp, + GoToPaneDown, + GoToPaneLeft, + GoToPaneRight, + GoToNextPane, + GoToPrevPane, + // preview actions + ScrollPreviewUp, + ScrollPreviewDown, + ScrollPreviewHalfPageUp, + ScrollPreviewHalfPageDown, + OpenEntry, + // application actions + Tick, + Suspend, + Resume, + Quit, + Help, + Error(String), + NoOp, + // channel actions + SyncFinderResults, +} diff --git a/crates/television/app.rs b/crates/television/app.rs new file mode 100644 index 0000000..c430ffc --- /dev/null +++ b/crates/television/app.rs @@ -0,0 +1,265 @@ +/// NOTE: outdated +/// +/// The general idea +/// ┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐ +/// │ │ +/// │ rendering thread event thread main thread │ +/// │ │ +/// │ │ │ │ │ +/// │ │ +/// │ │ │ │ │ +/// │ │ +/// │ │ │ │ │ +/// │ ┌───────┴───────┐ │ +/// │ │ │ │ │ │ +/// │ │ receive event │ │ +/// │ │ │ │ │ │ +/// │ └───────┬───────┘ │ +/// │ │ │ │ │ +/// │ ▼ │ +/// │ │ ┌──────────────────┐ ┌──────────┴─────────┐ │ +/// │ │ │ │ │ │ +/// │ │ │ send on event_rx ├────────────►│ receive event_rx │ │ +/// │ │ │ │ │ │ +/// │ │ └──────────────────┘ └──────────┬─────────┘ │ +/// │ │ │ +/// │ │ ▼ │ +/// │ ┌────────────────────┐ │ +/// │ │ │ map to action │ │ +/// │ └──────────┬─────────┘ │ +/// │ │ ▼ │ +/// │ ┌────────────────────┐ │ +/// │ │ │ send on action_tx │ │ +/// │ └──────────┬─────────┘ │ +/// │ │ │ +/// │ │ +/// │ │ ┌──────────┴─────────┐ │ +/// │ │ receive action_rx │ │ +/// │ │ └──────────┬─────────┘ │ +/// │ ┌───────────┴────────────┐ ▼ │ +/// │ │ │ ┌────────────────────┐ │ +/// │ │ receive render_rx │◄────────────────────────────────────────────────┤ dispatch action │ │ +/// │ │ │ └──────────┬─────────┘ │ +/// │ └───────────┬────────────┘ │ │ +/// │ │ │ │ +/// │ ▼ ▼ │ +/// │ ┌────────────────────────┐ ┌────────────────────┐ │ +/// │ │ render components │ │ update components │ │ +/// │ └────────────────────────┘ └────────────────────┘ │ +/// │ │ +/// └──────────────────────────────────────────────────────────────────────────────────────────────────────┘ +use std::sync::Arc; + + +use color_eyre::Result; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, Mutex}; +use tracing::{debug, info}; + +use crate::channels::CliTvChannel; +use crate::television::Television; +use crate::{ + action::Action, + config::Config, + entry::Entry, + event::{Event, EventLoop, Key}, + render::{render, RenderingTask}, +}; + +pub struct App { + config: Config, + // maybe move these two into config instead of passing them + // via the cli? + tick_rate: f64, + frame_rate: f64, + television: Arc>, + should_quit: bool, + should_suspend: bool, + mode: Mode, + action_tx: mpsc::UnboundedSender, + action_rx: mpsc::UnboundedReceiver, + event_rx: mpsc::UnboundedReceiver>, + event_abort_tx: mpsc::UnboundedSender<()>, + render_tx: mpsc::UnboundedSender, +} + +#[derive( + Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, +)] +pub enum Mode { + #[default] + Help, + Input, + Preview, + Results, +} + +impl App { + pub fn new( + channel: CliTvChannel, + tick_rate: f64, + frame_rate: f64, + ) -> Result { + let (action_tx, action_rx) = mpsc::unbounded_channel(); + let (render_tx, _) = mpsc::unbounded_channel(); + let (_, event_rx) = mpsc::unbounded_channel(); + let (event_abort_tx, _) = mpsc::unbounded_channel(); + let television = Arc::new(Mutex::new(Television::new(channel))); + + Ok(Self { + tick_rate, + frame_rate, + television, + should_quit: false, + should_suspend: false, + config: Config::new()?, + mode: Mode::Input, + action_tx, + action_rx, + event_rx, + event_abort_tx, + render_tx, + }) + } + + pub async fn run(&mut self, is_output_tty: bool) -> Result> { + info!("Starting backend event loop"); + let event_loop = EventLoop::new(self.tick_rate, true); + self.event_rx = event_loop.rx; + self.event_abort_tx = event_loop.abort_tx; + + // Rendering loop + debug!("Starting rendering loop"); + let (render_tx, render_rx) = mpsc::unbounded_channel(); + self.render_tx = render_tx.clone(); + let action_tx_r = self.action_tx.clone(); + let config_r = self.config.clone(); + let television_r = self.television.clone(); + let frame_rate = self.frame_rate; + let rendering_task = tokio::spawn(async move { + render( + render_rx, + //render_tx, + action_tx_r, + config_r, + television_r, + frame_rate, + is_output_tty, + ) + .await + }); + + // event handling loop + debug!("Starting event handling loop"); + let action_tx = self.action_tx.clone(); + loop { + // handle event and convert to action + if let Some(event) = self.event_rx.recv().await { + let action = self.convert_event_to_action(event).await; + action_tx.send(action)?; + } + + let maybe_selected = self.handle_actions().await?; + + if self.should_quit { + // send a termination signal to the event loop + self.event_abort_tx.send(())?; + + // wait for the rendering task to finish + rendering_task.await??; + + return Ok(maybe_selected); + } + } + } + + async fn convert_event_to_action(&self, event: Event) -> Action { + match event { + Event::Input(keycode) => { + info!("{:?}", keycode); + // if the current component is the television + // and the mode is input, automatically handle + // (these mappings aren't exposed to the user) + if self.television.lock().await.is_input_focused() { + match keycode { + Key::Backspace => return Action::DeletePrevChar, + Key::Delete => return Action::DeleteNextChar, + Key::Left => return Action::GoToPrevChar, + Key::Right => return Action::GoToNextChar, + Key::Home | Key::Ctrl('a') => { + return Action::GoToInputStart + } + Key::End | Key::Ctrl('e') => { + return Action::GoToInputEnd + } + Key::Char(c) => return Action::AddInputChar(c), + _ => {} + } + } + return self + .config + .keybindings + .get(&self.mode) + .and_then(|keymap| keymap.get(&keycode).cloned()) + .unwrap_or(if let Key::Char(c) = keycode { + Action::AddInputChar(c) + } else { + Action::NoOp + }); + } + // terminal events + Event::Tick => Action::Tick, + Event::Resize(x, y) => Action::Resize(x, y), + Event::FocusGained => Action::Resume, + Event::FocusLost => Action::Suspend, + _ => Action::NoOp, + } + } + + async fn handle_actions(&mut self) -> Result> { + while let Ok(action) = self.action_rx.try_recv() { + if action != Action::Tick && action != Action::Render { + debug!("{action:?}"); + } + match action { + Action::Quit => { + self.should_quit = true; + self.render_tx.send(RenderingTask::Quit)?; + } + Action::Suspend => { + self.should_suspend = true; + self.render_tx.send(RenderingTask::Suspend)?; + } + Action::Resume => { + self.should_suspend = false; + self.render_tx.send(RenderingTask::Resume)?; + } + Action::SelectEntry => { + self.should_quit = true; + self.render_tx.send(RenderingTask::Quit)?; + return Ok(self + .television + .lock() + .await + .get_selected_entry()); + } + Action::ClearScreen => { + self.render_tx.send(RenderingTask::ClearScreen)? + } + Action::Resize(w, h) => { + self.render_tx.send(RenderingTask::Resize(w, h))? + } + Action::Render => { + self.render_tx.send(RenderingTask::Render)? + } + _ => {} + } + if let Some(action) = + self.television.lock().await.update(action.clone()).await? + { + self.action_tx.send(action)?; + }; + } + Ok(None) + } +} diff --git a/crates/television/channels.rs b/crates/television/channels.rs new file mode 100644 index 0000000..1aad3ed --- /dev/null +++ b/crates/television/channels.rs @@ -0,0 +1,93 @@ +use crate::entry::Entry; +use television_derive::CliChannel; + +mod alias; +mod env; +mod files; +mod grep; +mod stdin; + +/// The interface that all television channels must implement. +/// +/// # Important +/// The `TelevisionChannel` requires the `Send` trait to be implemented as +/// well. This is necessary to allow the channels to be used in a +/// multi-threaded environment. +/// +/// # Methods +/// - `find`: Find entries that match the given pattern. This method does not +/// return anything and instead typically stores the results internally for +/// later retrieval allowing to perform the search in the background while +/// incrementally polling the results. +/// ```rust +/// fn find(&mut self, pattern: &str); +/// ``` +/// - `results`: Get the results of the search (at a given point in time, see +/// above). This method returns a specific portion of entries that match the +/// search pattern. The `num_entries` parameter specifies the number of +/// entries to return and the `offset` parameter specifies the starting index +/// of the entries to return. +/// ```rust +/// fn results(&mut self, num_entries: u32, offset: u32) -> Vec; +/// ``` +/// - `get_result`: Get a specific result by its index. +/// ```rust +/// fn get_result(&self, index: u32) -> Option; +/// ``` +/// - `result_count`: Get the number of results currently available. +/// ```rust +/// fn result_count(&self) -> u32; +/// ``` +/// - `total_count`: Get the total number of entries currently available (e.g. +/// the haystack). +/// ```rust +/// fn total_count(&self) -> u32; +/// ``` +/// +pub trait TelevisionChannel: Send { + /// Find entries that match the given pattern. + /// + /// This method does not return anything and instead typically stores the + /// results internally for later retrieval allowing to perform the search + /// in the background while incrementally polling the results with + /// `results`. + fn find(&mut self, pattern: &str); + + /// Get the results of the search (that are currently available). + fn results(&mut self, num_entries: u32, offset: u32) -> Vec; + + /// Get a specific result by its index. + fn get_result(&self, index: u32) -> Option; + + /// Get the number of results currently available. + fn result_count(&self) -> u32; + + /// Get the total number of entries currently available. + fn total_count(&self) -> u32; +} + +/// The available television channels. +/// +/// Each channel is represented by a variant of the enum and should implement +/// the `TelevisionChannel` trait. +/// +/// # Important +/// When adding a new channel, make sure to add a new variant to this enum and +/// implement the `TelevisionChannel` trait for it. +/// +/// # Derive +/// The `CliChannel` derive macro generates the necessary glue code to +/// automatically create the corresponding `CliTvChannel` enum with unit +/// variants that can be used to select the channel from the command line. +/// It also generates the necessary glue code to automatically create a channel +/// instance from the selected CLI enum variant. +/// +#[allow(dead_code, clippy::module_name_repetitions)] +#[derive(CliChannel)] +pub enum AvailableChannels { + Env(env::Channel), + Files(files::Channel), + Grep(grep::Channel), + Stdin(stdin::Channel), + Alias(alias::Channel), +} diff --git a/crates/television/channels/alias.rs b/crates/television/channels/alias.rs new file mode 100644 index 0000000..7bca95b --- /dev/null +++ b/crates/television/channels/alias.rs @@ -0,0 +1,203 @@ +use std::sync::Arc; + +use devicons::FileIcon; +use nucleo::{Config, Nucleo}; +use tracing::debug; + +use crate::channels::TelevisionChannel; +use crate::entry::Entry; +use crate::fuzzy::MATCHER; +use crate::previewers::PreviewType; +use crate::utils::indices::sep_name_and_value_indices; + +#[derive(Debug, Clone)] +struct Alias { + name: String, + value: String, +} + +pub struct Channel { + matcher: Nucleo, + last_pattern: String, + file_icon: FileIcon, + result_count: u32, + total_count: u32, +} + +const NUM_THREADS: usize = 1; + +const FILE_ICON_STR: &str = "nu"; +const SHELL_ENV_VAR: &str = "SHELL"; + +fn get_current_shell() -> Option { + std::env::var(SHELL_ENV_VAR).ok() +} + +fn get_raw_aliases(shell: &str) -> Vec { + match shell { + "bash" => { + let output = std::process::Command::new("bash") + .arg("-i") + .arg("-c") + .arg("alias") + .output() + .expect("failed to execute process"); + let aliases = String::from_utf8(output.stdout).unwrap(); + aliases + .lines() + .map(std::string::ToString::to_string) + .collect() + } + "zsh" => { + let output = std::process::Command::new("zsh") + .arg("-i") + .arg("-c") + .arg("alias") + .output() + .expect("failed to execute process"); + let aliases = String::from_utf8(output.stdout).unwrap(); + aliases + .lines() + .map(std::string::ToString::to_string) + .collect() + } + _ => Vec::new(), + } +} + +impl Channel { + pub fn new() -> Self { + let raw_shell = get_current_shell().unwrap_or("bash".to_string()); + let shell = raw_shell.split('/').last().unwrap(); + debug!("Current shell: {}", shell); + let raw_aliases = get_raw_aliases(shell); + debug!("Aliases: {:?}", raw_aliases); + + let parsed_aliases = raw_aliases + .iter() + .map(|alias| { + let mut parts = alias.split('='); + let name = parts.next().unwrap().to_string(); + let value = parts.next().unwrap().to_string(); + Alias { name, value } + }) + .collect::>(); + + let matcher = Nucleo::new( + Config::DEFAULT, + Arc::new(|| {}), + Some(NUM_THREADS), + 1, + ); + let injector = matcher.injector(); + + for alias in parsed_aliases { + let _ = injector.push(alias.clone(), |_, cols| { + cols[0] = (alias.name.clone() + &alias.value).into(); + }); + } + + Self { + matcher, + last_pattern: String::new(), + file_icon: FileIcon::from(FILE_ICON_STR), + result_count: 0, + total_count: 0, + } + } + + const MATCHER_TICK_TIMEOUT: u64 = 10; +} + +impl TelevisionChannel for Channel { + fn find(&mut self, pattern: &str) { + if pattern != self.last_pattern { + self.matcher.pattern.reparse( + 0, + pattern, + nucleo::pattern::CaseMatching::Smart, + nucleo::pattern::Normalization::Smart, + pattern.starts_with(&self.last_pattern), + ); + self.last_pattern = pattern.to_string(); + } + } + + fn results(&mut self, num_entries: u32, offset: u32) -> Vec { + let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); + let snapshot = self.matcher.snapshot(); + if status.changed { + self.result_count = snapshot.matched_item_count(); + self.total_count = snapshot.item_count(); + } + let mut col_indices = Vec::new(); + let mut matcher = MATCHER.lock(); + let icon = self.file_icon; + + snapshot + .matched_items( + offset + ..(num_entries + offset) + .min(snapshot.matched_item_count()), + ) + .map(move |item| { + snapshot.pattern().column_pattern(0).indices( + item.matcher_columns[0].slice(..), + &mut matcher, + &mut col_indices, + ); + col_indices.sort_unstable(); + col_indices.dedup(); + + let ( + name_indices, + value_indices, + should_add_name_indices, + should_add_value_indices, + ) = sep_name_and_value_indices( + &mut col_indices, + u32::try_from(item.data.name.len()).unwrap(), + ); + + let mut entry = + Entry::new(item.data.name.clone(), PreviewType::EnvVar) + .with_value(item.data.value.clone()) + .with_icon(icon); + + if should_add_name_indices { + entry = entry.with_name_match_ranges( + name_indices.into_iter().map(|i| (i, i + 1)).collect(), + ); + } + + if should_add_value_indices { + entry = entry.with_value_match_ranges( + value_indices + .into_iter() + .map(|i| (i, i + 1)) + .collect(), + ); + } + + entry + }) + .collect() + } + + fn get_result(&self, index: u32) -> Option { + let snapshot = self.matcher.snapshot(); + snapshot.get_matched_item(index).map(|item| { + Entry::new(item.data.name.clone(), PreviewType::EnvVar) + .with_value(item.data.value.clone()) + .with_icon(self.file_icon) + }) + } + + fn result_count(&self) -> u32 { + self.result_count + } + + fn total_count(&self) -> u32 { + self.total_count + } +} diff --git a/crates/television/channels/env.rs b/crates/television/channels/env.rs new file mode 100644 index 0000000..7b65fa3 --- /dev/null +++ b/crates/television/channels/env.rs @@ -0,0 +1,150 @@ +use devicons::FileIcon; +use nucleo::{ + pattern::{CaseMatching, Normalization}, + Config, Nucleo, +}; +use std::sync::Arc; + +use super::TelevisionChannel; +use crate::entry::Entry; +use crate::fuzzy::MATCHER; +use crate::previewers::PreviewType; +use crate::utils::indices::sep_name_and_value_indices; + +struct EnvVar { + name: String, + value: String, +} + +#[allow(clippy::module_name_repetitions)] +pub struct Channel { + matcher: Nucleo, + last_pattern: String, + file_icon: FileIcon, + result_count: u32, + total_count: u32, +} + +const NUM_THREADS: usize = 1; +const FILE_ICON_STR: &str = "config"; + +impl Channel { + pub fn new() -> Self { + let matcher = Nucleo::new( + Config::DEFAULT, + Arc::new(|| {}), + Some(NUM_THREADS), + 1, + ); + let injector = matcher.injector(); + for (name, value) in std::env::vars() { + let _ = injector.push(EnvVar { name, value }, |e, cols| { + cols[0] = (e.name.clone() + &e.value).into(); + }); + } + Channel { + matcher, + last_pattern: String::new(), + file_icon: FileIcon::from(FILE_ICON_STR), + result_count: 0, + total_count: 0, + } + } + + const MATCHER_TICK_TIMEOUT: u64 = 10; +} + +impl TelevisionChannel for Channel { + fn find(&mut self, pattern: &str) { + if pattern != self.last_pattern { + self.matcher.pattern.reparse( + 0, + pattern, + CaseMatching::Smart, + Normalization::Smart, + pattern.starts_with(&self.last_pattern), + ); + self.last_pattern = pattern.to_string(); + } + } + + fn result_count(&self) -> u32 { + self.result_count + } + + fn total_count(&self) -> u32 { + self.total_count + } + + fn results(&mut self, num_entries: u32, offset: u32) -> Vec { + let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); + let snapshot = self.matcher.snapshot(); + if status.changed { + self.result_count = snapshot.matched_item_count(); + self.total_count = snapshot.item_count(); + } + let mut col_indices = Vec::new(); + let mut matcher = MATCHER.lock(); + let icon = self.file_icon; + + snapshot + .matched_items( + offset + ..(num_entries + offset) + .min(snapshot.matched_item_count()), + ) + .map(move |item| { + snapshot.pattern().column_pattern(0).indices( + item.matcher_columns[0].slice(..), + &mut matcher, + &mut col_indices, + ); + col_indices.sort_unstable(); + col_indices.dedup(); + + let ( + name_indices, + value_indices, + should_add_name_indices, + should_add_value_indices, + ) = sep_name_and_value_indices( + &mut col_indices, + u32::try_from(item.data.name.len()).unwrap(), + ); + + let mut entry = + Entry::new(item.data.name.clone(), PreviewType::EnvVar) + .with_value(item.data.value.clone()) + .with_icon(icon); + + if should_add_name_indices { + entry = entry.with_name_match_ranges( + name_indices.into_iter().map(|i| (i, i + 1)).collect(), + ); + } + + if should_add_value_indices { + entry = entry.with_value_match_ranges( + value_indices + .into_iter() + .map(|i| (i, i + 1)) + .collect(), + ); + } + + entry + }) + .collect() + } + + fn get_result(&self, index: u32) -> Option { + let snapshot = self.matcher.snapshot(); + snapshot.get_matched_item(index).map(|item| { + let name = item.data.name.clone(); + let value = item.data.value.clone(); + Entry::new(name, PreviewType::EnvVar) + .with_value(value) + .with_icon(self.file_icon) + }) + } +} diff --git a/crates/television/channels/files.rs b/crates/television/channels/files.rs new file mode 100644 index 0000000..288de3a --- /dev/null +++ b/crates/television/channels/files.rs @@ -0,0 +1,140 @@ +use devicons::FileIcon; +use nucleo::{ + pattern::{CaseMatching, Normalization}, + Config, Injector, Nucleo, +}; +use std::{path::PathBuf, sync::Arc}; + +use ignore::DirEntry; + +use super::TelevisionChannel; +use crate::entry::Entry; +use crate::fuzzy::MATCHER; +use crate::previewers::PreviewType; +use crate::utils::files::{walk_builder, DEFAULT_NUM_THREADS}; + +pub struct Channel { + matcher: Nucleo, + last_pattern: String, + result_count: u32, + total_count: u32, +} + +impl Channel { + pub fn new() -> Self { + let matcher = Nucleo::new( + Config::DEFAULT.match_paths(), + Arc::new(|| {}), + None, + 1, + ); + // start loading files in the background + tokio::spawn(load_files( + std::env::current_dir().unwrap(), + matcher.injector(), + )); + Channel { + matcher, + last_pattern: String::new(), + result_count: 0, + total_count: 0, + } + } + + const MATCHER_TICK_TIMEOUT: u64 = 10; +} + +impl TelevisionChannel for Channel { + fn find(&mut self, pattern: &str) { + if pattern != self.last_pattern { + self.matcher.pattern.reparse( + 0, + pattern, + CaseMatching::Smart, + Normalization::Smart, + pattern.starts_with(&self.last_pattern), + ); + self.last_pattern = pattern.to_string(); + } + } + + fn result_count(&self) -> u32 { + self.result_count + } + + fn total_count(&self) -> u32 { + self.total_count + } + + fn results(&mut self, num_entries: u32, offset: u32) -> Vec { + let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); + let snapshot = self.matcher.snapshot(); + if status.changed { + self.result_count = snapshot.matched_item_count(); + self.total_count = snapshot.item_count(); + } + let mut indices = Vec::new(); + let mut matcher = MATCHER.lock(); + + snapshot + .matched_items( + offset + ..(num_entries + offset) + .min(snapshot.matched_item_count()), + ) + .map(move |item| { + snapshot.pattern().column_pattern(0).indices( + item.matcher_columns[0].slice(..), + &mut matcher, + &mut indices, + ); + indices.sort_unstable(); + indices.dedup(); + let indices = indices.drain(..); + + let path = item.matcher_columns[0].to_string(); + Entry::new(path.clone(), PreviewType::Files) + .with_name_match_ranges( + indices.map(|i| (i, i + 1)).collect(), + ) + .with_icon(FileIcon::from(&path)) + }) + .collect() + } + + fn get_result(&self, index: u32) -> Option { + let snapshot = self.matcher.snapshot(); + snapshot.get_matched_item(index).map(|item| { + let path = item.matcher_columns[0].to_string(); + Entry::new(path.clone(), PreviewType::Files) + .with_icon(FileIcon::from(&path)) + }) + } +} + +#[allow(clippy::unused_async)] +async fn load_files(path: PathBuf, injector: Injector) { + let current_dir = std::env::current_dir().unwrap(); + let walker = walk_builder(&path, *DEFAULT_NUM_THREADS).build_parallel(); + + walker.run(|| { + let injector = injector.clone(); + let current_dir = current_dir.clone(); + Box::new(move |result| { + if let Ok(entry) = result { + if entry.file_type().unwrap().is_file() { + // Send the path via the async channel + let _ = injector.push(entry, |e, cols| { + cols[0] = e + .path() + .strip_prefix(¤t_dir) + .unwrap() + .to_string_lossy() + .into(); + }); + } + } + ignore::WalkState::Continue + }) + }); +} diff --git a/crates/television/channels/grep.rs b/crates/television/channels/grep.rs new file mode 100644 index 0000000..3f8b0f9 --- /dev/null +++ b/crates/television/channels/grep.rs @@ -0,0 +1,221 @@ +use devicons::FileIcon; +use nucleo::{ + pattern::{CaseMatching, Normalization}, + Config, Injector, Nucleo, +}; +use std::{ + fs::File, + io::{BufRead, Read, Seek}, + path::PathBuf, + sync::Arc, +}; + +use tracing::info; + +use super::TelevisionChannel; +use crate::entry::Entry; +use crate::fuzzy::MATCHER; +use crate::previewers::PreviewType; +use crate::utils::{ + files::{ + is_not_text, is_valid_utf8, walk_builder, DEFAULT_NUM_THREADS, + }, + strings::preprocess_line, +}; + +#[derive(Debug)] +struct CandidateLine { + path: PathBuf, + line: String, + line_number: usize, +} + +impl CandidateLine { + fn new(path: PathBuf, line: String, line_number: usize) -> Self { + CandidateLine { + path, + line, + line_number, + } + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct Channel { + matcher: Nucleo, + last_pattern: String, + result_count: u32, + total_count: u32, +} + +impl Channel { + pub fn new() -> Self { + let matcher = Nucleo::new(Config::DEFAULT, Arc::new(|| {}), None, 1); + // start loading files in the background + tokio::spawn(load_candidates( + std::env::current_dir().unwrap(), + matcher.injector(), + )); + Channel { + matcher, + last_pattern: String::new(), + result_count: 0, + total_count: 0, + } + } + + const MATCHER_TICK_TIMEOUT: u64 = 10; +} + +impl TelevisionChannel for Channel { + fn find(&mut self, pattern: &str) { + if pattern != self.last_pattern { + self.matcher.pattern.reparse( + 0, + pattern, + CaseMatching::Smart, + Normalization::Smart, + pattern.starts_with(&self.last_pattern), + ); + self.last_pattern = pattern.to_string(); + } + } + + fn result_count(&self) -> u32 { + self.result_count + } + + fn total_count(&self) -> u32 { + self.total_count + } + + fn results(&mut self, num_entries: u32, offset: u32) -> Vec { + let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); + let snapshot = self.matcher.snapshot(); + if status.changed { + self.result_count = snapshot.matched_item_count(); + self.total_count = snapshot.item_count(); + } + let mut indices = Vec::new(); + let mut matcher = MATCHER.lock(); + + snapshot + .matched_items( + offset + ..(num_entries + offset) + .min(snapshot.matched_item_count()), + ) + .map(move |item| { + snapshot.pattern().column_pattern(0).indices( + item.matcher_columns[0].slice(..), + &mut matcher, + &mut indices, + ); + indices.sort_unstable(); + indices.dedup(); + let indices = indices.drain(..); + + let line = item.matcher_columns[0].to_string(); + let display_path = + item.data.path.to_string_lossy().to_string(); + Entry::new( + display_path.clone() + &item.data.line_number.to_string(), + PreviewType::Files, + ) + .with_display_name(display_path) + .with_value(line) + .with_value_match_ranges(indices.map(|i| (i, i + 1)).collect()) + .with_icon(FileIcon::from(item.data.path.as_path())) + .with_line_number(item.data.line_number) + }) + .collect() + } + + fn get_result(&self, index: u32) -> Option { + let snapshot = self.matcher.snapshot(); + snapshot.get_matched_item(index).map(|item| { + let display_path = item.data.path.to_string_lossy().to_string(); + Entry::new(display_path.clone(), PreviewType::Files) + .with_display_name( + display_path.clone() + + ":" + + &item.data.line_number.to_string(), + ) + .with_line_number(item.data.line_number) + }) + } +} + +#[allow(clippy::unused_async)] +async fn load_candidates(path: PathBuf, injector: Injector) { + let current_dir = std::env::current_dir().unwrap(); + let walker = walk_builder(&path, *DEFAULT_NUM_THREADS).build_parallel(); + + walker.run(|| { + let injector = injector.clone(); + let current_dir = current_dir.clone(); + Box::new(move |result| { + if let Ok(entry) = result { + if entry.file_type().unwrap().is_file() { + // iterate over the lines of the file + match File::open(entry.path()) { + Ok(file) => { + let mut reader = std::io::BufReader::new(&file); + let mut buffer = [0u8; 128]; + match reader.read(&mut buffer) { + Ok(bytes_read) => { + if (bytes_read == 0) + || is_not_text(&buffer) + .unwrap_or(false) + || !is_valid_utf8(&buffer) + { + return ignore::WalkState::Continue; + } + reader + .seek(std::io::SeekFrom::Start(0)) + .unwrap(); + } + Err(_) => { + return ignore::WalkState::Continue; + } + } + let mut line_number = 0; + for maybe_line in reader.lines() { + match maybe_line { + Ok(l) => { + line_number += 1; + let candidate = CandidateLine::new( + entry + .path() + .strip_prefix(¤t_dir) + .unwrap() + .to_path_buf(), + preprocess_line(&l), + line_number, + ); + // Send the line via the async channel + let _ = injector.push( + candidate, + |c, cols| { + cols[0] = + c.line.clone().into(); + }, + ); + } + Err(e) => { + info!("Error reading line: {:?}", e); + break; + } + } + } + } + Err(e) => { + info!("Error opening file: {:?}", e); + } + } + } + } + ignore::WalkState::Continue + }) + }); +} diff --git a/crates/television/channels/stdin.rs b/crates/television/channels/stdin.rs new file mode 100644 index 0000000..463b43a --- /dev/null +++ b/crates/television/channels/stdin.rs @@ -0,0 +1,123 @@ +use std::{io::BufRead, sync::Arc}; + +use devicons::FileIcon; +use nucleo::{Config, Nucleo}; +use tracing::debug; + +use crate::entry::Entry; +use crate::fuzzy::MATCHER; +use crate::previewers::PreviewType; + +use super::TelevisionChannel; + +pub struct Channel { + matcher: Nucleo, + last_pattern: String, + result_count: u32, + total_count: u32, + icon: FileIcon, +} + +const NUM_THREADS: usize = 2; + +impl Channel { + pub fn new() -> Self { + let mut lines = Vec::new(); + for line in std::io::stdin().lock().lines().map_while(Result::ok) { + debug!("Read line: {:?}", line); + lines.push(line); + } + let matcher = Nucleo::new( + Config::DEFAULT, + Arc::new(|| {}), + Some(NUM_THREADS), + 1, + ); + let injector = matcher.injector(); + for line in &lines { + let _ = injector.push(line.clone(), |e, cols| { + cols[0] = e.clone().into(); + }); + } + Self { + matcher, + last_pattern: String::new(), + result_count: 0, + total_count: 0, + icon: FileIcon::from("nu"), + } + } + + const MATCHER_TICK_TIMEOUT: u64 = 10; +} + +impl TelevisionChannel for Channel { + // maybe this could be sort of automatic with a blanket impl (making Finder generic over + // its matcher type or something) + fn find(&mut self, pattern: &str) { + if pattern != self.last_pattern { + self.matcher.pattern.reparse( + 0, + pattern, + nucleo::pattern::CaseMatching::Smart, + nucleo::pattern::Normalization::Smart, + pattern.starts_with(&self.last_pattern), + ); + self.last_pattern = pattern.to_string(); + } + } + + fn results(&mut self, num_entries: u32, offset: u32) -> Vec { + let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT); + let snapshot = self.matcher.snapshot(); + if status.changed { + self.result_count = snapshot.matched_item_count(); + self.total_count = snapshot.item_count(); + } + let mut indices = Vec::new(); + let mut matcher = MATCHER.lock(); + let icon = self.icon; + + snapshot + .matched_items( + offset + ..(num_entries + offset) + .min(snapshot.matched_item_count()), + ) + .map(move |item| { + snapshot.pattern().column_pattern(0).indices( + item.matcher_columns[0].slice(..), + &mut matcher, + &mut indices, + ); + indices.sort_unstable(); + indices.dedup(); + let indices = indices.drain(..); + + let content = item.matcher_columns[0].to_string(); + Entry::new(content.clone(), PreviewType::Basic) + .with_name_match_ranges( + indices.map(|i| (i, i + 1)).collect(), + ) + .with_icon(icon) + }) + .collect() + } + + fn get_result(&self, index: u32) -> Option { + let snapshot = self.matcher.snapshot(); + snapshot.get_matched_item(index).map(|item| { + let content = item.matcher_columns[0].to_string(); + Entry::new(content.clone(), PreviewType::Basic) + .with_icon(self.icon) + }) + } + + fn result_count(&self) -> u32 { + self.result_count + } + + fn total_count(&self) -> u32 { + self.total_count + } +} diff --git a/src/cli.rs b/crates/television/cli.rs similarity index 81% rename from src/cli.rs rename to crates/television/cli.rs index 8696a0a..3432ba3 100644 --- a/src/cli.rs +++ b/crates/television/cli.rs @@ -1,12 +1,17 @@ use clap::Parser; +use crate::channels::CliTvChannel; use crate::config::{get_config_dir, get_data_dir}; #[derive(Parser, Debug)] #[command(author, version = version(), about)] pub struct Cli { + /// Which channel shall we watch? + #[arg(value_enum, default_value = "files")] + pub channel: CliTvChannel, + /// Tick rate, i.e. number of ticks per second - #[arg(short, long, value_name = "FLOAT", default_value_t = 4.0)] + #[arg(short, long, value_name = "FLOAT", default_value_t = 50.0)] pub tick_rate: f64, /// Frame rate, i.e. number of frames per second diff --git a/crates/television/components.rs b/crates/television/components.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/television/components.rs @@ -0,0 +1 @@ + diff --git a/src/config.rs b/crates/television/config.rs similarity index 86% rename from src/config.rs rename to crates/television/config.rs index 09b3d2c..e227df6 100644 --- a/src/config.rs +++ b/crates/television/config.rs @@ -1,6 +1,4 @@ -#![allow(dead_code)] // Remove this once you start using the code - -use std::{collections::HashMap, env, path::PathBuf}; +use std::{collections::HashMap, env, num::NonZeroUsize, path::PathBuf}; use color_eyre::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -11,9 +9,14 @@ use ratatui::style::{Color, Modifier, Style}; use serde::{de::Deserializer, Deserialize}; use tracing::error; -use crate::{action::Action, app::Mode}; +use crate::{ + action::Action, + app::Mode, + event::{convert_raw_event_to_key, Key}, +}; -const CONFIG: &str = include_str!("../.config/config.json5"); +//const CONFIG: &str = include_str!("../.config/config.json5"); +const CONFIG: &str = include_str!("../../.config/config.toml"); #[derive(Clone, Debug, Deserialize, Default)] pub struct AppConfig { @@ -34,7 +37,8 @@ pub struct Config { } lazy_static! { - pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); + pub static ref PROJECT_NAME: String = + env!("CARGO_CRATE_NAME").to_uppercase().to_string(); pub static ref DATA_FOLDER: Option = env::var(format!("{}_DATA", PROJECT_NAME.clone())) .ok() @@ -47,7 +51,8 @@ lazy_static! { impl Config { pub fn new() -> Result { - let default_config: Config = json5::from_str(CONFIG).unwrap(); + //let default_config: Config = json5::from_str(CONFIG).unwrap(); + let default_config: Config = toml::from_str(CONFIG).unwrap(); let data_dir = get_data_dir(); let config_dir = get_config_dir(); let mut builder = config::Config::builder() @@ -80,9 +85,7 @@ impl Config { for (mode, default_bindings) in default_config.keybindings.iter() { let user_bindings = cfg.keybindings.entry(*mode).or_default(); for (key, cmd) in default_bindings.iter() { - user_bindings - .entry(key.clone()) - .or_insert_with(|| cmd.clone()); + user_bindings.entry(*key).or_insert_with(|| cmd.clone()); } } for (mode, default_styles) in default_config.styles.iter() { @@ -119,25 +122,28 @@ pub fn get_config_dir() -> PathBuf { } fn project_directory() -> Option { - ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME")) + ProjectDirs::from("com", "alexpasmantier", env!("CARGO_PKG_NAME")) } #[derive(Clone, Debug, Default, Deref, DerefMut)] -pub struct KeyBindings(pub HashMap, Action>>); +pub struct KeyBindings(pub HashMap>); impl<'de> Deserialize<'de> for KeyBindings { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - let parsed_map = HashMap::>::deserialize(deserializer)?; + let parsed_map = + HashMap::>::deserialize( + deserializer, + )?; let keybindings = parsed_map .into_iter() .map(|(mode, inner_map)| { let converted_inner_map = inner_map .into_iter() - .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)) + .map(|(key_str, cmd)| (parse_key(&key_str).unwrap(), cmd)) .collect(); (mode, converted_inner_map) }) @@ -291,31 +297,32 @@ pub fn key_event_to_string(key_event: &KeyEvent) -> String { key } -pub fn parse_key_sequence(raw: &str) -> Result, String> { - if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { +pub fn parse_key(raw: &str) -> Result { + if raw.chars().filter(|c| *c == '>').count() + != raw.chars().filter(|c| *c == '<').count() + { return Err(format!("Unable to parse `{}`", raw)); } let raw = if !raw.contains("><") { let raw = raw.strip_prefix('<').unwrap_or(raw); - let raw = raw.strip_prefix('>').unwrap_or(raw); + let raw = raw.strip_suffix('>').unwrap_or(raw); raw } else { raw }; - let sequences = raw - .split("><") - .map(|seq| { - if let Some(s) = seq.strip_prefix('<') { - s - } else if let Some(s) = seq.strip_suffix('>') { - s - } else { - seq - } - }) - .collect::>(); + let key_event = parse_key_event(raw)?; + Ok(convert_raw_event_to_key(key_event)) +} - sequences.into_iter().map(parse_key_event).collect() +pub fn default_num_threads() -> NonZeroUsize { + // default to 1 thread if we can't determine the number of available threads + let default = NonZeroUsize::MIN; + // never use more than 32 threads to avoid startup overhead + let limit = NonZeroUsize::new(32).unwrap(); + + std::thread::available_parallelism() + .unwrap_or(default) + .min(limit) } #[derive(Clone, Debug, Default, Deref, DerefMut)] @@ -326,7 +333,10 @@ impl<'de> Deserialize<'de> for Styles { where D: Deserializer<'de>, { - let parsed_map = HashMap::>::deserialize(deserializer)?; + let parsed_map = + HashMap::>::deserialize( + deserializer, + )?; let styles = parsed_map .into_iter() @@ -405,9 +415,12 @@ fn parse_color(s: &str) -> Option { .unwrap_or_default(); Some(Color::Indexed(c)) } else if s.contains("rgb") { - let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; - let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8; - let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8; + let red = + (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; + let green = + (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8; + let blue = + (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8; let c = 16 + red * 36 + green * 6 + blue; Some(Color::Indexed(c)) } else if s == "bold black" { @@ -480,7 +493,8 @@ mod tests { #[test] fn test_process_color_string() { - let (color, modifiers) = process_color_string("underline bold inverse gray"); + let (color, modifiers) = + process_color_string("underline bold inverse gray"); assert_eq!(color, "gray"); assert!(modifiers.contains(Modifier::UNDERLINED)); assert!(modifiers.contains(Modifier::BOLD)); @@ -505,9 +519,9 @@ mod tests { let c = Config::new()?; assert_eq!( c.keybindings - .get(&Mode::Home) + .get(&Mode::Input) .unwrap() - .get(&parse_key_sequence("").unwrap_or_default()) + .get(&parse_key("").unwrap()) .unwrap(), &Action::Quit ); @@ -562,7 +576,10 @@ mod tests { assert_eq!( parse_key_event("ctrl-shift-enter").unwrap(), - KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) + KeyEvent::new( + KeyCode::Enter, + KeyModifiers::CONTROL | KeyModifiers::SHIFT + ) ); } diff --git a/crates/television/entry.rs b/crates/television/entry.rs new file mode 100644 index 0000000..93be66e --- /dev/null +++ b/crates/television/entry.rs @@ -0,0 +1,92 @@ +use devicons::FileIcon; + +use crate::previewers::PreviewType; + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct Entry { + pub name: String, + display_name: Option, + pub value: Option, + pub name_match_ranges: Option>, + pub value_match_ranges: Option>, + pub icon: Option, + pub line_number: Option, + pub preview_type: PreviewType, +} + +impl Entry { + pub fn new(name: String, preview_type: PreviewType) -> Self { + Self { + name, + display_name: None, + value: None, + name_match_ranges: None, + value_match_ranges: None, + icon: None, + line_number: None, + preview_type, + } + } + + pub fn with_display_name(mut self, display_name: String) -> Self { + self.display_name = Some(display_name); + self + } + + pub fn with_value(mut self, value: String) -> Self { + self.value = Some(value); + self + } + + pub fn with_name_match_ranges( + mut self, + name_match_ranges: Vec<(u32, u32)>, + ) -> Self { + self.name_match_ranges = Some(name_match_ranges); + self + } + + pub fn with_value_match_ranges( + mut self, + value_match_ranges: Vec<(u32, u32)>, + ) -> Self { + self.value_match_ranges = Some(value_match_ranges); + self + } + + pub fn with_icon(mut self, icon: FileIcon) -> Self { + self.icon = Some(icon); + self + } + + pub fn with_line_number(mut self, line_number: usize) -> Self { + self.line_number = Some(line_number); + self + } + + pub fn display_name(&self) -> &str { + self.display_name.as_ref().unwrap_or(&self.name) + } + + pub fn stdout_repr(&self) -> String { + let mut repr = self.name.clone(); + if let Some(line_number) = self.line_number { + repr.push_str(&format!(":{line_number}")); + } + if let Some(preview) = &self.value { + repr.push_str(&format!("\n{preview}")); + } + repr + } +} + +pub const ENTRY_PLACEHOLDER: Entry = Entry { + name: String::new(), + display_name: None, + value: None, + name_match_ranges: None, + value_match_ranges: None, + icon: None, + line_number: None, + preview_type: PreviewType::EnvVar, +}; diff --git a/src/errors.rs b/crates/television/errors.rs similarity index 92% rename from src/errors.rs rename to crates/television/errors.rs index c9dfbfd..e5259fa 100644 --- a/src/errors.rs +++ b/crates/television/errors.rs @@ -15,7 +15,7 @@ pub fn init() -> Result<()> { .into_hooks(); eyre_hook.install()?; std::panic::set_hook(Box::new(move |panic_info| { - if let Ok(mut t) = crate::tui::Tui::new() { + if let Ok(mut t) = crate::tui::Tui::new(std::io::stderr()) { if let Err(r) = t.exit() { error!("Unable to exit Terminal: {:?}", r); } @@ -27,8 +27,9 @@ pub fn init() -> Result<()> { let metadata = metadata!(); let file_path = handle_dump(&metadata, panic_info); // prints human-panic message - print_msg(file_path, &metadata) - .expect("human-panic: printing error message to console failed"); + print_msg(file_path, &metadata).expect( + "human-panic: printing error message to console failed", + ); eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr } let msg = format!("{}", panic_hook.panic_report(panic_info)); diff --git a/crates/television/event.rs b/crates/television/event.rs new file mode 100644 index 0000000..3d9686e --- /dev/null +++ b/crates/television/event.rs @@ -0,0 +1,214 @@ +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll as TaskPoll}, + time::Duration, +}; + +use crossterm::event::{ + KeyCode::{ + BackTab, Backspace, Char, Delete, Down, End, Enter, Esc, Home, Insert, + Left, Null, PageDown, PageUp, Right, Tab, Up, F, + }, + KeyEvent, KeyModifiers, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; +use tracing::warn; + +#[derive(Debug, Clone, Copy)] +pub enum Event { + Closed, + Input(I), + FocusLost, + FocusGained, + Resize(u16, u16), + Tick, +} + +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Hash, +)] +pub enum Key { + Backspace, + Enter, + Left, + Right, + Up, + Down, + CtrlBackspace, + CtrlEnter, + CtrlLeft, + CtrlRight, + CtrlUp, + CtrlDown, + CtrlDelete, + AltBackspace, + AltDelete, + Home, + End, + PageUp, + PageDown, + BackTab, + Delete, + Insert, + F(u8), + Char(char), + Alt(char), + Ctrl(char), + Null, + Esc, + Tab, +} + +pub struct EventLoop { + pub rx: mpsc::UnboundedReceiver>, + //tx: mpsc::UnboundedSender>, + pub abort_tx: mpsc::UnboundedSender<()>, + //tick_rate: std::time::Duration, +} + +struct PollFuture { + timeout: Duration, +} + +impl Future for PollFuture { + type Output = bool; + + fn poll( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> TaskPoll { + // Polling crossterm::event::poll, which is a blocking call + // Spawn it in a separate task, to avoid blocking async runtime + match crossterm::event::poll(self.timeout) { + Ok(true) => TaskPoll::Ready(true), + Ok(false) => { + // Register the task to be polled again after a delay to avoid busy-looping + cx.waker().wake_by_ref(); + TaskPoll::Pending + } + Err(_) => TaskPoll::Ready(false), + } + } +} + +async fn poll_event(timeout: Duration) -> bool { + PollFuture { timeout }.await +} + +impl EventLoop { + pub fn new(tick_rate: f64, init: bool) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let _tx = tx.clone(); + let tick_interval = + tokio::time::Duration::from_secs_f64(1.0 / tick_rate); + + let (abort, mut abort_recv) = mpsc::unbounded_channel(); + + if init { + //let mut reader = crossterm::event::EventStream::new(); + tokio::spawn(async move { + loop { + //let event = reader.next(); + let delay = tokio::time::sleep(tick_interval); + let event_available = poll_event(tick_interval); + + tokio::select! { + // if we receive a message on the abort channel, stop the event loop + _ = abort_recv.recv() => { + _tx.send(Event::Closed).unwrap_or_else(|_| warn!("Unable to send Closed event")); + _tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event")); + break; + }, + // if `delay` completes, pass to the next event "frame" + _ = delay => { + _tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event")); + }, + // if the receiver dropped the channel, stop the event loop + _ = _tx.closed() => break, + // if an event was received, process it + _ = event_available => { + let maybe_event = crossterm::event::read(); + match maybe_event { + Ok(crossterm::event::Event::Key(key)) => { + let key = convert_raw_event_to_key(key); + _tx.send(Event::Input(key)).unwrap_or_else(|_| warn!("Unable to send {:?} event", key)); + }, + Ok(crossterm::event::Event::FocusLost) => { + _tx.send(Event::FocusLost).unwrap_or_else(|_| warn!("Unable to send FocusLost event")); + }, + Ok(crossterm::event::Event::FocusGained) => { + _tx.send(Event::FocusGained).unwrap_or_else(|_| warn!("Unable to send FocusGained event")); + }, + Ok(crossterm::event::Event::Resize(x, y)) => { + _tx.send(Event::Resize(x, y)).unwrap_or_else(|_| warn!("Unable to send Resize event")); + }, + _ => {} + } + } + } + } + }); + } + + Self { + //tx, + rx, + //tick_rate, + abort_tx: abort, + } + } +} + +pub fn convert_raw_event_to_key(event: KeyEvent) -> Key { + match event.code { + Backspace => match event.modifiers { + KeyModifiers::CONTROL => Key::CtrlBackspace, + KeyModifiers::ALT => Key::AltBackspace, + _ => Key::Backspace, + }, + Delete => match event.modifiers { + KeyModifiers::CONTROL => Key::CtrlDelete, + KeyModifiers::ALT => Key::AltDelete, + _ => Key::Delete, + }, + Enter => match event.modifiers { + KeyModifiers::CONTROL => Key::CtrlEnter, + _ => Key::Enter, + }, + Up => match event.modifiers { + KeyModifiers::CONTROL => Key::CtrlUp, + _ => Key::Up, + }, + Down => match event.modifiers { + KeyModifiers::CONTROL => Key::CtrlDown, + _ => Key::Down, + }, + Left => match event.modifiers { + KeyModifiers::CONTROL => Key::CtrlLeft, + _ => Key::Left, + }, + Right => match event.modifiers { + KeyModifiers::CONTROL => Key::CtrlRight, + _ => Key::Right, + }, + Home => Key::Home, + End => Key::End, + PageUp => Key::PageUp, + PageDown => Key::PageDown, + Tab => Key::Tab, + BackTab => Key::BackTab, + Insert => Key::Insert, + F(k) => Key::F(k), + Null => Key::Null, + Esc => Key::Esc, + Char(c) => match event.modifiers { + KeyModifiers::NONE | KeyModifiers::SHIFT => Key::Char(c), + KeyModifiers::CONTROL => Key::Ctrl(c), + KeyModifiers::ALT => Key::Alt(c), + _ => Key::Null, + }, + _ => Key::Null, + } +} diff --git a/crates/television/fuzzy.rs b/crates/television/fuzzy.rs new file mode 100644 index 0000000..5d83f63 --- /dev/null +++ b/crates/television/fuzzy.rs @@ -0,0 +1,25 @@ +use parking_lot::Mutex; +use std::ops::DerefMut; + +pub struct LazyMutex { + inner: Mutex>, + init: fn() -> T, +} + +impl LazyMutex { + pub const fn new(init: fn() -> T) -> Self { + Self { + inner: Mutex::new(None), + init, + } + } + + pub fn lock(&self) -> impl DerefMut + '_ { + parking_lot::MutexGuard::map(self.inner.lock(), |val| { + val.get_or_insert_with(self.init) + }) + } +} + +pub static MATCHER: LazyMutex = + LazyMutex::new(nucleo::Matcher::default); diff --git a/src/logging.rs b/crates/television/logging.rs similarity index 57% rename from src/logging.rs rename to crates/television/logging.rs index 0dbceff..907551f 100644 --- a/src/logging.rs +++ b/crates/television/logging.rs @@ -5,7 +5,6 @@ use tracing_subscriber::{fmt, prelude::*, EnvFilter}; use crate::config; lazy_static::lazy_static! { - pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", config::PROJECT_NAME.clone()); pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); } @@ -14,20 +13,14 @@ pub fn init() -> Result<()> { std::fs::create_dir_all(directory.clone())?; let log_path = directory.join(LOG_FILE.clone()); let log_file = std::fs::File::create(log_path)?; - let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into()); - // If the `RUST_LOG` environment variable is set, use that as the default, otherwise use the - // value of the `LOG_ENV` environment variable. If the `LOG_ENV` environment variable contains - // errors, then this will return an error. - let env_filter = env_filter - .try_from_env() - .or_else(|_| env_filter.with_env_var(LOG_ENV.clone()).from_env())?; let file_subscriber = fmt::layer() .with_file(true) .with_line_number(true) .with_writer(log_file) .with_target(false) .with_ansi(false) - .with_filter(env_filter); + .with_filter(EnvFilter::from_default_env()); + tracing_subscriber::registry() .with(file_subscriber) .with(ErrorLayer::default()) diff --git a/crates/television/main.rs b/crates/television/main.rs new file mode 100644 index 0000000..f27521d --- /dev/null +++ b/crates/television/main.rs @@ -0,0 +1,116 @@ +use std::io::{stdout, IsTerminal, Write}; + +use clap::Parser; +use color_eyre::Result; +use tracing::{debug, info}; + +use crate::app::App; +use crate::channels::CliTvChannel; +use crate::cli::Cli; + +mod action; +mod app; +mod channels; +mod cli; +mod components; +mod config; +mod entry; +mod errors; +mod event; +mod fuzzy; +mod logging; +mod previewers; +mod render; +mod tui; +mod utils; +pub mod television; +mod ui; + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + crate::errors::init()?; + crate::logging::init()?; + + let args = Cli::parse(); + + let mut app: App = App::new( + { + if is_readable_stdin() { + debug!("Using stdin channel"); + CliTvChannel::Stdin + } else { + debug!("Using {:?} channel", args.channel); + args.channel + } + }, + args.tick_rate, + args.frame_rate, + )?; + + if let Some(entry) = app.run(stdout().is_terminal()).await? { + // print entry to stdout + stdout().flush()?; + info!("{:?}", entry); + writeln!(stdout(), "{}", entry.stdout_repr())?; + } + Ok(()) +} + +pub fn is_readable_stdin() -> bool { + use std::io::IsTerminal; + + #[cfg(unix)] + fn imp() -> bool { + use std::{ + fs::File, + os::{fd::AsFd, unix::fs::FileTypeExt}, + }; + + let stdin = std::io::stdin(); + let Ok(fd) = stdin.as_fd().try_clone_to_owned() else { + return false; + }; + let file = File::from(fd); + let Ok(md) = file.metadata() else { + return false; + }; + let ft = md.file_type(); + let is_file = ft.is_file(); + let is_fifo = ft.is_fifo(); + let is_socket = ft.is_socket(); + is_file || is_fifo || is_socket + } + + #[cfg(windows)] + fn imp() -> bool { + let stdin = winapi_util::HandleRef::stdin(); + let typ = match winapi_util::file::typ(stdin) { + Ok(typ) => typ, + Err(err) => { + log::debug!( + "for heuristic stdin detection on Windows, \ + could not get file type of stdin \ + (thus assuming stdin is not readable): {err}", + ); + return false; + } + }; + let is_disk = typ.is_disk(); + let is_pipe = typ.is_pipe(); + let is_readable = is_disk || is_pipe; + log::debug!( + "for heuristic stdin detection on Windows, \ + found that is_disk={is_disk} and is_pipe={is_pipe}, \ + and thus concluded that is_stdin_readable={is_readable}", + ); + is_readable + } + + #[cfg(not(any(unix, windows)))] + fn imp() -> bool { + log::debug!("on non-{{Unix,Windows}}, assuming stdin is not readable"); + false + } + + !std::io::stdin().is_terminal() && imp() +} diff --git a/crates/television/previewers.rs b/crates/television/previewers.rs new file mode 100644 index 0000000..5c74c22 --- /dev/null +++ b/crates/television/previewers.rs @@ -0,0 +1,96 @@ +use std::sync::Arc; + +use crate::entry::Entry; + +mod basic; +mod cache; +mod env; +mod files; + +// previewer types +pub use basic::BasicPreviewer; +pub use env::EnvVarPreviewer; +pub use files::FilePreviewer; +use ratatui_image::protocol::StatefulProtocol; +use syntect::highlighting::Style; + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Default)] +pub enum PreviewType { + #[default] + Basic, + EnvVar, + Files, +} + +#[derive(Clone)] +pub enum PreviewContent { + Empty, + FileTooLarge, + HighlightedText(Vec>), + Image(Box), + Loading, + NotSupported, + PlainText(Vec), + PlainTextWrapped(String), +} + +pub const PREVIEW_NOT_SUPPORTED_MSG: &str = + "Preview for this file type is not yet supported"; +pub const FILE_TOO_LARGE_MSG: &str = "File too large"; + +/// A preview of an entry. +/// +/// # Fields +/// - `title`: The title of the preview. +/// - `content`: The content of the preview. +#[derive(Clone)] +pub struct Preview { + pub title: String, + pub content: PreviewContent, +} + +impl Default for Preview { + fn default() -> Self { + Preview { + title: String::new(), + content: PreviewContent::Empty, + } + } +} + +impl Preview { + pub fn new(title: String, content: PreviewContent) -> Self { + Preview { title, content } + } + + pub fn total_lines(&self) -> u16 { + match &self.content { + PreviewContent::HighlightedText(lines) => lines.len() as u16, + _ => 0, + } + } +} + +pub struct Previewer { + basic: BasicPreviewer, + file: FilePreviewer, + env_var: EnvVarPreviewer, +} + +impl Previewer { + pub fn new() -> Self { + Previewer { + basic: BasicPreviewer::new(), + file: FilePreviewer::new(), + env_var: EnvVarPreviewer::new(), + } + } + + pub async fn preview(&mut self, entry: &Entry) -> Arc { + match entry.preview_type { + PreviewType::Basic => self.basic.preview(entry), + PreviewType::EnvVar => self.env_var.preview(entry), + PreviewType::Files => self.file.preview(entry).await, + } + } +} diff --git a/crates/television/previewers/basic.rs b/crates/television/previewers/basic.rs new file mode 100644 index 0000000..f02f55b --- /dev/null +++ b/crates/television/previewers/basic.rs @@ -0,0 +1,19 @@ +use std::sync::Arc; + +use crate::entry::Entry; +use crate::previewers::{Preview, PreviewContent}; + +pub struct BasicPreviewer {} + +impl BasicPreviewer { + pub fn new() -> Self { + BasicPreviewer {} + } + + pub fn preview(&self, entry: &Entry) -> Arc { + Arc::new(Preview { + title: entry.name.clone(), + content: PreviewContent::PlainTextWrapped(entry.name.clone()), + }) + } +} diff --git a/crates/television/previewers/cache.rs b/crates/television/previewers/cache.rs new file mode 100644 index 0000000..47e4f6e --- /dev/null +++ b/crates/television/previewers/cache.rs @@ -0,0 +1,111 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + sync::Arc, +}; + +use tracing::debug; + +use crate::previewers::Preview; + +/// TODO: add unit tests +/// A ring buffer that also keeps track of the keys it contains to avoid duplicates. +/// +/// I'm planning on using this as a backend LRU-cache for the preview cache. +/// Basic idea: +/// - When a new key is pushed, if it's already in the buffer, do nothing. +/// - If the buffer is full, remove the oldest key and push the new key. +struct RingSet { + ring_buffer: VecDeque, + known_keys: HashSet, + capacity: usize, +} + +impl RingSet +where + T: Eq + std::hash::Hash + Clone + std::fmt::Debug, +{ + pub fn with_capacity(capacity: usize) -> Self { + RingSet { + ring_buffer: VecDeque::with_capacity(capacity), + known_keys: HashSet::with_capacity(capacity), + capacity, + } + } + + /// Push a new item to the back of the buffer, removing the oldest item if the buffer is full. + /// Returns the item that was removed, if any. + /// If the item is already in the buffer, do nothing and return None. + pub fn push(&mut self, item: T) -> Option { + // If the key is already in the buffer, do nothing + if self.contains(&item) { + debug!("Key already in ring buffer: {:?}", item); + return None; + } + let mut popped_key = None; + // If the buffer is full, remove the oldest key (e.g. pop from the front of the buffer) + if self.ring_buffer.len() >= self.capacity { + popped_key = self.pop(); + } + // finally, push the new key to the back of the buffer + self.ring_buffer.push_back(item.clone()); + self.known_keys.insert(item); + popped_key + } + + fn pop(&mut self) -> Option { + if let Some(item) = self.ring_buffer.pop_front() { + debug!("Removing key from ring buffer: {:?}", item); + self.known_keys.remove(&item); + Some(item) + } else { + None + } + } + + fn contains(&self, key: &T) -> bool { + self.known_keys.contains(key) + } +} + +/// Default size of the preview cache. +/// This does seem kind of arbitrary for now, will need to play around with it. +const DEFAULT_PREVIEW_CACHE_SIZE: usize = 100; + +/// A cache for previews. +/// The cache is implemented as a LRU cache with a fixed size. +pub struct PreviewCache { + entries: HashMap>, + ring_set: RingSet, +} + +impl PreviewCache { + /// Create a new preview cache with the given capacity. + pub fn new(capacity: usize) -> Self { + PreviewCache { + entries: HashMap::new(), + ring_set: RingSet::with_capacity(capacity), + } + } + + pub fn get(&self, key: &str) -> Option> { + self.entries.get(key).cloned() + } + + /// Insert a new preview into the cache. + /// If the cache is full, the oldest entry will be removed. + /// If the key is already in the cache, the preview will be updated. + pub fn insert(&mut self, key: String, preview: Arc) { + debug!("Inserting preview into cache: {}", key); + self.entries.insert(key.clone(), preview.clone()); + if let Some(oldest_key) = self.ring_set.push(key) { + debug!("Cache full, removing oldest entry: {}", oldest_key); + self.entries.remove(&oldest_key); + } + } +} + +impl Default for PreviewCache { + fn default() -> Self { + PreviewCache::new(DEFAULT_PREVIEW_CACHE_SIZE) + } +} diff --git a/crates/television/previewers/env.rs b/crates/television/previewers/env.rs new file mode 100644 index 0000000..60b64ee --- /dev/null +++ b/crates/television/previewers/env.rs @@ -0,0 +1,45 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use crate::entry; +use crate::previewers::{Preview, PreviewContent}; + +pub struct EnvVarPreviewer { + cache: HashMap>, +} + +impl EnvVarPreviewer { + pub fn new() -> Self { + EnvVarPreviewer { + cache: HashMap::new(), + } + } + + pub fn preview(&mut self, entry: &entry::Entry) -> Arc { + // check if we have that preview in the cache + if let Some(preview) = self.cache.get(entry) { + return preview.clone(); + } + let preview = Arc::new(Preview { + title: entry.name.clone(), + content: if let Some(preview) = &entry.value { + PreviewContent::PlainTextWrapped( + maybe_add_newline_after_colon(preview, &entry.name), + ) + } else { + PreviewContent::Empty + }, + }); + self.cache.insert(entry.clone(), preview.clone()); + preview + } +} + +const PATH: &str = "PATH"; + +fn maybe_add_newline_after_colon(s: &str, name: &str) -> String { + if name.contains(PATH) { + return s.replace(":", "\n"); + } + s.to_string() +} diff --git a/crates/television/previewers/files.rs b/crates/television/previewers/files.rs new file mode 100644 index 0000000..000710b --- /dev/null +++ b/crates/television/previewers/files.rs @@ -0,0 +1,320 @@ +use color_eyre::Result; +use image::{ImageReader, Rgb}; +use ratatui_image::picker::Picker; +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Seek}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use syntect::easy::HighlightLines; +use tokio::sync::Mutex; + +use syntect::{ + highlighting::{Style, Theme, ThemeSet}, + parsing::SyntaxSet, +}; +use tracing::{debug, info, warn}; + +use crate::entry; +use crate::previewers::{Preview, PreviewContent}; +use crate::utils::files::is_valid_utf8; +use crate::utils::files::FileType; +use crate::utils::files::{ + get_file_size, is_known_text_extension, +}; +use crate::utils::strings::preprocess_line; + +use super::cache::PreviewCache; + +pub struct FilePreviewer { + cache: Arc>, + syntax_set: Arc, + syntax_theme: Arc, + image_picker: Arc>, +} + +impl FilePreviewer { + pub fn new() -> Self { + let syntax_set = SyntaxSet::load_defaults_nonewlines(); + let theme_set = ThemeSet::load_defaults(); + info!("getting image picker"); + let image_picker = get_image_picker(); + info!("got image picker"); + + FilePreviewer { + cache: Arc::new(Mutex::new(PreviewCache::default())), + syntax_set: Arc::new(syntax_set), + syntax_theme: Arc::new( + theme_set.themes["base16-ocean.dark"].clone(), + ), + image_picker: Arc::new(Mutex::new(image_picker)), + } + } + + async fn compute_image_preview(&self, entry: &entry::Entry) { + let cache = self.cache.clone(); + let picker = self.image_picker.clone(); + let entry_c = entry.clone(); + tokio::spawn(async move { + info!("Loading image: {:?}", entry_c.name); + if let Ok(dyn_image) = + ImageReader::open(entry_c.name.clone()).unwrap().decode() + { + let image = picker.lock().await.new_resize_protocol(dyn_image); + let preview = Arc::new(Preview::new( + entry_c.name.clone(), + PreviewContent::Image(image), + )); + cache + .lock() + .await + .insert(entry_c.name.clone(), preview.clone()); + } + }); + } + + async fn compute_highlighted_text_preview( + &self, + entry: &entry::Entry, + reader: BufReader, + ) { + let cache = self.cache.clone(); + let syntax_set = self.syntax_set.clone(); + let syntax_theme = self.syntax_theme.clone(); + let entry_c = entry.clone(); + tokio::spawn(async move { + debug!( + "Computing highlights in the background for {:?}", + entry_c.name + ); + let lines: Vec = + reader.lines().map_while(Result::ok).collect(); + + match compute_highlights( + &PathBuf::from(&entry_c.name), + lines, + &syntax_set, + &syntax_theme, + ) { + Ok(highlighted_lines) => { + debug!( + "Successfully computed highlights for {:?}", + entry_c.name + ); + cache.lock().await.insert( + entry_c.name.clone(), + Arc::new(Preview::new( + entry_c.name, + PreviewContent::HighlightedText(highlighted_lines), + )), + ); + debug!("Inserted highlighted preview into cache"); + } + Err(e) => { + warn!("Error computing highlights: {:?}", e); + } + }; + }); + } + + /// The maximum file size that we will try to preview. + /// 4 MB + const MAX_FILE_SIZE: u64 = 4 * 1024 * 1024; + + fn get_file_type(&self, path: &Path) -> FileType { + debug!("Getting file type for {:?}", path); + let mut file_type = match infer::get_from_path(path) { + Ok(Some(t)) => { + let mime_type = t.mime_type(); + if mime_type.contains("image") { + FileType::Image + } else if mime_type.contains("text") { + FileType::Text + } else { + FileType::Other + } + } + _ => FileType::Unknown, + }; + + // if the file type is unknown, try to determine it from the extension or the content + if matches!(file_type, FileType::Unknown) { + if is_known_text_extension(path) { + file_type = FileType::Text; + } else if let Ok(mut f) = File::open(path) { + let mut buffer = [0u8; 256]; + if let Ok(bytes_read) = f.read(&mut buffer) { + if bytes_read > 0 && is_valid_utf8(&buffer) { + file_type = FileType::Text; + } + } + } + } + debug!("File type for {:?}: {:?}", path, file_type); + + file_type + } + + async fn cache_preview(&mut self, key: String, preview: Arc) { + self.cache.lock().await.insert(key, preview); + } + + pub async fn preview(&mut self, entry: &entry::Entry) -> Arc { + let path_buf = PathBuf::from(&entry.name); + + // do we have a preview in cache for that entry? + if let Some(preview) = self.cache.lock().await.get(&entry.name) { + return preview.clone(); + } + debug!("No preview in cache for {:?}", entry.name); + + // check file size + if get_file_size(&path_buf).map_or(false, |s| s > Self::MAX_FILE_SIZE) + { + debug!("File too large: {:?}", entry.name); + let preview = file_too_large(&entry.name); + self.cache_preview(entry.name.clone(), preview.clone()) + .await; + return preview; + } + + // try to determine file type + debug!("Computing preview for {:?}", entry.name); + match self.get_file_type(&path_buf) { + FileType::Text => { + match File::open(&path_buf) { + Ok(file) => { + // insert a non-highlighted version of the preview into the cache + let reader = BufReader::new(&file); + let preview = plain_text_preview(&entry.name, reader); + self.cache_preview( + entry.name.clone(), + preview.clone(), + ) + .await; + + // compute the highlighted version in the background + let mut reader = + BufReader::new(file.try_clone().unwrap()); + reader.seek(std::io::SeekFrom::Start(0)).unwrap(); + self.compute_highlighted_text_preview(entry, reader) + .await; + preview + } + Err(e) => { + warn!("Error opening file: {:?}", e); + let p = not_supported(&entry.name); + self.cache_preview(entry.name.clone(), p.clone()) + .await; + p + } + } + } + FileType::Image => { + debug!("Previewing image file: {:?}", entry.name); + // insert a loading preview into the cache + let preview = loading(&entry.name); + self.cache_preview(entry.name.clone(), preview.clone()) + .await; + // compute the image preview in the background + self.compute_image_preview(entry).await; + preview + } + FileType::Other => { + debug!("Previewing other file: {:?}", entry.name); + let preview = not_supported(&entry.name); + self.cache_preview(entry.name.clone(), preview.clone()) + .await; + preview + } + FileType::Unknown => { + debug!("Unknown file type: {:?}", entry.name); + let preview = not_supported(&entry.name); + self.cache_preview(entry.name.clone(), preview.clone()) + .await; + preview + } + } + } +} + +fn get_image_picker() -> Picker { + let mut picker = match Picker::from_termios() { + Ok(p) => p, + Err(_) => Picker::new((7, 14)), + }; + picker.guess_protocol(); + picker.background_color = Some(Rgb::([255, 0, 255])); + picker +} + +/// This should be enough to most standard terminal sizes +const TEMP_PLAIN_TEXT_PREVIEW_HEIGHT: usize = 200; + +fn plain_text_preview(title: &str, reader: BufReader<&File>) -> Arc { + debug!("Creating plain text preview for {:?}", title); + let mut lines = Vec::with_capacity(TEMP_PLAIN_TEXT_PREVIEW_HEIGHT); + for maybe_line in reader.lines() { + match maybe_line { + Ok(line) => lines.push(preprocess_line(&line)), + Err(e) => { + warn!("Error reading file: {:?}", e); + return not_supported(title); + } + } + if lines.len() >= TEMP_PLAIN_TEXT_PREVIEW_HEIGHT { + break; + } + } + Arc::new(Preview::new( + title.to_string(), + PreviewContent::PlainText(lines), + )) +} + +fn not_supported(title: &str) -> Arc { + Arc::new(Preview::new( + title.to_string(), + PreviewContent::NotSupported, + )) +} + +fn file_too_large(title: &str) -> Arc { + Arc::new(Preview::new( + title.to_string(), + PreviewContent::FileTooLarge, + )) +} + +fn loading(title: &str) -> Arc { + Arc::new(Preview::new(title.to_string(), PreviewContent::Loading)) +} + +fn compute_highlights( + file_path: &Path, + lines: Vec, + syntax_set: &SyntaxSet, + syntax_theme: &Theme, +) -> Result>> { + let syntax = + syntax_set + .find_syntax_for_file(file_path)? + .unwrap_or_else(|| { + warn!( + "No syntax found for {:?}, defaulting to plain text", + file_path + ); + syntax_set.find_syntax_plain_text() + }); + let mut highlighter = HighlightLines::new(syntax, syntax_theme); + let mut highlighted_lines = Vec::new(); + for line in lines { + let hl_regions = highlighter.highlight_line(&line, syntax_set)?; + highlighted_lines.push( + hl_regions + .iter() + .map(|(style, text)| (*style, (*text).to_string())) + .collect(), + ); + } + Ok(highlighted_lines) +} diff --git a/crates/television/render.rs b/crates/television/render.rs new file mode 100644 index 0000000..4aeab3a --- /dev/null +++ b/crates/television/render.rs @@ -0,0 +1,119 @@ +use color_eyre::Result; +use ratatui::layout::Rect; +use std::{ + io::{stderr, stdout, LineWriter}, + sync::Arc, +}; +use tracing::{debug, warn}; + +use tokio::{ + select, + sync::{mpsc, Mutex}, +}; + +use crate::television::Television; +use crate::{ + action::Action, config::Config, + tui::Tui, +}; + +#[derive(Debug)] +pub enum RenderingTask { + ClearScreen, + Render, + Resize(u16, u16), + Resume, + Suspend, + Quit, +} + +#[derive(Debug, Clone)] +enum IoStream { + Stdout, + BufferedStderr, +} + +impl IoStream { + fn to_stream(&self) -> Box { + match self { + IoStream::Stdout => Box::new(stdout()), + IoStream::BufferedStderr => Box::new(LineWriter::new(stderr())), + } + } +} + +pub async fn render( + mut render_rx: mpsc::UnboundedReceiver, + action_tx: mpsc::UnboundedSender, + config: Config, + television: Arc>, + frame_rate: f64, + is_output_tty: bool, +) -> Result<()> { + let stream = if is_output_tty { + debug!("Rendering to stdout"); + IoStream::Stdout.to_stream() + } else { + debug!("Rendering to stderr"); + IoStream::BufferedStderr.to_stream() + }; + let mut tui = Tui::new(stream)?.frame_rate(frame_rate); + + debug!("Entering tui"); + tui.enter()?; + + debug!("Registering action handler and config handler"); + television + .lock() + .await + .register_action_handler(action_tx.clone())?; + television + .lock() + .await + .register_config_handler(config.clone())?; + + // Rendering loop + loop { + select! { + _ = tokio::time::sleep(tokio::time::Duration::from_secs_f64(1.0 / frame_rate)) => { + action_tx.send(Action::Render)?; + } + maybe_task = render_rx.recv() => { + if let Some(task) = maybe_task { + match task { + RenderingTask::ClearScreen => { + tui.terminal.clear()?; + } + RenderingTask::Render => { + let mut television = television.lock().await; + tui.terminal.draw(|frame| { + if let Err(err) = television.draw(frame, frame.area()) { + warn!("Failed to draw: {:?}", err); + let _ = action_tx + .send(Action::Error(format!("Failed to draw: {err:?}"))); + } + })?; + } + RenderingTask::Resize(w, h) => { + tui.resize(Rect::new(0, 0, w, h))?; + action_tx.send(Action::Render)?; + } + RenderingTask::Suspend => { + tui.suspend()?; + action_tx.send(Action::Resume)?; + action_tx.send(Action::ClearScreen)?; + tui.enter()?; + } + RenderingTask::Resume => { + tui.enter()?; + } + RenderingTask::Quit => { + tui.exit()?; + break Ok(()); + } + } + } + } + } + } +} diff --git a/crates/television/television.rs b/crates/television/television.rs new file mode 100644 index 0000000..9a87738 --- /dev/null +++ b/crates/television/television.rs @@ -0,0 +1,883 @@ +use color_eyre::Result; +use futures::executor::block_on; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span, Text}, + widgets::{ + block::{Position, Title}, + Block, BorderType, Borders, ListState, Padding, Paragraph, Wrap, + }, + Frame, +}; +use ratatui_image::StatefulImage; +use std::{collections::HashMap, str::FromStr, sync::Arc}; +use syntect; +use syntect::highlighting::Color as SyntectColor; + +use tokio::sync::mpsc::UnboundedSender; + +use crate::channels::{CliTvChannel, TelevisionChannel}; +use crate::entry::{Entry, ENTRY_PLACEHOLDER}; +use crate::previewers::{ + Preview, PreviewContent, Previewer, FILE_TOO_LARGE_MSG, + PREVIEW_NOT_SUPPORTED_MSG, +}; +use crate::ui::input::{Input, InputRequest, StateChanged}; +use crate::ui::{build_results_list, create_layout, get_border_style}; +use crate::{action::Action, config::Config}; + +#[derive(PartialEq, Copy, Clone)] +enum Pane { + Results, + Preview, + Input, +} + +static PANES: [Pane; 3] = [Pane::Input, Pane::Results, Pane::Preview]; + +pub struct Television { + action_tx: Option>, + config: Config, + channel: Box, + current_pattern: String, + current_pane: Pane, + input: Input, + picker_state: ListState, + relative_picker_state: ListState, + picker_view_offset: usize, + results_area_height: u32, + previewer: Previewer, + preview_scroll: Option, + preview_pane_height: u16, + current_preview_total_lines: u16, + meta_paragraph_cache: HashMap>, +} + +const EMPTY_STRING: &str = ""; + +impl Television { + pub fn new(cli_channel: CliTvChannel) -> Self { + let mut tv_channel = cli_channel.to_channel(); + tv_channel.find(EMPTY_STRING); + + Self { + action_tx: None, + config: Config::default(), + channel: tv_channel, + current_pattern: EMPTY_STRING.to_string(), + current_pane: Pane::Input, + input: Input::new(EMPTY_STRING.to_string()), + picker_state: ListState::default(), + relative_picker_state: ListState::default(), + picker_view_offset: 0, + results_area_height: 0, + previewer: Previewer::new(), + preview_scroll: None, + preview_pane_height: 0, + current_preview_total_lines: 0, + meta_paragraph_cache: HashMap::new(), + } + } + + fn find(&mut self, pattern: &str) { + self.channel.find(pattern); + } + + pub fn get_selected_entry(&self) -> Option { + self.picker_state + .selected() + .and_then(|i| self.channel.get_result(u32::try_from(i).unwrap())) + } + + pub fn select_prev_entry(&mut self) { + if self.channel.result_count() == 0 { + return; + } + let new_index = (self.picker_state.selected().unwrap_or(0) + 1) + % self.channel.result_count() as usize; + self.picker_state.select(Some(new_index)); + if new_index == 0 { + self.picker_view_offset = 0; + self.relative_picker_state.select(Some(0)); + return; + } + if self.relative_picker_state.selected().unwrap_or(0) + == self.results_area_height as usize - 3 + { + self.picker_view_offset += 1; + self.relative_picker_state.select(Some( + self.picker_state + .selected() + .unwrap_or(0) + .min(self.results_area_height as usize - 3), + )); + } else { + self.relative_picker_state.select(Some( + (self.relative_picker_state.selected().unwrap_or(0) + 1) + .min(self.picker_state.selected().unwrap_or(0)), + )); + } + } + + pub fn select_next_entry(&mut self) { + if self.channel.result_count() == 0 { + return; + } + let selected = self.picker_state.selected().unwrap_or(0); + let relative_selected = + self.relative_picker_state.selected().unwrap_or(0); + if selected > 0 { + self.picker_state.select(Some(selected - 1)); + self.relative_picker_state + .select(Some(relative_selected.saturating_sub(1))); + if relative_selected == 0 { + self.picker_view_offset = + self.picker_view_offset.saturating_sub(1); + } + } else { + self.picker_view_offset = (self + .channel + .result_count() + .saturating_sub(self.results_area_height - 2)) + as usize; + self.picker_state.select(Some( + (self.channel.result_count() as usize).saturating_sub(1), + )); + self.relative_picker_state + .select(Some(self.results_area_height as usize - 3)); + } + } + + fn reset_preview_scroll(&mut self) { + self.preview_scroll = None; + } + + pub fn scroll_preview_down(&mut self, offset: u16) { + if self.preview_scroll.is_none() { + self.preview_scroll = Some(0); + } + if let Some(scroll) = self.preview_scroll { + self.preview_scroll = Some( + (scroll + offset).min( + self.current_preview_total_lines + .saturating_sub(2 * self.preview_pane_height / 3), + ), + ); + } + } + + pub fn scroll_preview_up(&mut self, offset: u16) { + if let Some(scroll) = self.preview_scroll { + self.preview_scroll = Some(scroll.saturating_sub(offset)); + } + } + + fn get_current_pane_index(&self) -> usize { + PANES + .iter() + .position(|pane| *pane == self.current_pane) + .unwrap() + } + + pub fn next_pane(&mut self) { + let current_index = self.get_current_pane_index(); + let next_index = (current_index + 1) % PANES.len(); + self.current_pane = PANES[next_index]; + } + + pub fn previous_pane(&mut self) { + let current_index = self.get_current_pane_index(); + let previous_index = if current_index == 0 { + PANES.len() - 1 + } else { + current_index - 1 + }; + self.current_pane = PANES[previous_index]; + } + + /// ┌───────────────────┐┌─────────────┐ + /// │ Results ││ Preview │ + /// │ ││ │ + /// │ ││ │ + /// │ ││ │ + /// └───────────────────┘│ │ + /// ┌───────────────────┐│ │ + /// │ Search x ││ │ + /// └───────────────────┘└─────────────┘ + pub fn move_to_pane_on_top(&mut self) { + if self.current_pane == Pane::Input { + self.current_pane = Pane::Results; + } + } + + /// ┌───────────────────┐┌─────────────┐ + /// │ Results x ││ Preview │ + /// │ ││ │ + /// │ ││ │ + /// │ ││ │ + /// └───────────────────┘│ │ + /// ┌───────────────────┐│ │ + /// │ Search ││ │ + /// └───────────────────┘└─────────────┘ + pub fn move_to_pane_below(&mut self) { + if self.current_pane == Pane::Results { + self.current_pane = Pane::Input; + } + } + + /// ┌───────────────────┐┌─────────────┐ + /// │ Results x ││ Preview │ + /// │ ││ │ + /// │ ││ │ + /// │ ││ │ + /// └───────────────────┘│ │ + /// ┌───────────────────┐│ │ + /// │ Search x ││ │ + /// └───────────────────┘└─────────────┘ + pub fn move_to_pane_right(&mut self) { + match self.current_pane { + Pane::Results | Pane::Input => { + self.current_pane = Pane::Preview; + } + _ => {} + } + } + + /// ┌───────────────────┐┌─────────────┐ + /// │ Results ││ Preview x │ + /// │ ││ │ + /// │ ││ │ + /// │ ││ │ + /// └───────────────────┘│ │ + /// ┌───────────────────┐│ │ + /// │ Search ││ │ + /// └───────────────────┘└─────────────┘ + pub fn move_to_pane_left(&mut self) { + if self.current_pane == Pane::Preview { + self.current_pane = Pane::Results; + } + } + + pub fn is_input_focused(&self) -> bool { + Pane::Input == self.current_pane + } +} + +// UI size +const UI_WIDTH_PERCENT: u16 = 95; +const UI_HEIGHT_PERCENT: u16 = 95; + +// Misc +const FOUR_SPACES: &str = " "; + +// Styles +// results +const DEFAULT_RESULT_NAME_FG: Color = Color::Blue; +const DEFAULT_RESULT_PREVIEW_FG: Color = Color::Rgb(150, 150, 150); +const DEFAULT_RESULT_LINE_NUMBER_FG: Color = Color::Yellow; +// input +const DEFAULT_INPUT_FG: Color = Color::Rgb(200, 200, 200); +const DEFAULT_RESULTS_COUNT_FG: Color = Color::Rgb(150, 150, 150); +// preview +const DEFAULT_PREVIEW_TITLE_FG: Color = Color::Blue; +const DEFAULT_SELECTED_PREVIEW_BG: Color = Color::Rgb(50, 50, 50); +const DEFAULT_PREVIEW_CONTENT_FG: Color = Color::Rgb(150, 150, 180); +const DEFAULT_PREVIEW_GUTTER_FG: Color = Color::Rgb(70, 70, 70); +const DEFAULT_PREVIEW_GUTTER_SELECTED_FG: Color = Color::Rgb(255, 150, 150); + +impl Television { + /// Register an action handler that can send actions for processing if necessary. + /// + /// # Arguments + /// + /// * `tx` - An unbounded sender that can send actions. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + pub fn register_action_handler( + &mut self, + tx: UnboundedSender, + ) -> Result<()> { + self.action_tx = Some(tx.clone()); + Ok(()) + } + + /// Register a configuration handler that provides configuration settings if necessary. + /// + /// # Arguments + /// + /// * `config` - Configuration settings. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + pub fn register_config_handler(&mut self, config: Config) -> Result<()> { + self.config = config; + Ok(()) + } + + /// Update the state of the component based on a received action. + /// + /// # Arguments + /// + /// * `action` - An action that may modify the state of the television. + /// + /// # Returns + /// + /// * `Result>` - An action to be processed or none. + pub async fn update(&mut self, action: Action) -> Result> { + match action { + Action::GoToPaneUp => { + self.move_to_pane_on_top(); + } + Action::GoToPaneDown => { + self.move_to_pane_below(); + } + Action::GoToPaneLeft => { + self.move_to_pane_left(); + } + Action::GoToPaneRight => { + self.move_to_pane_right(); + } + Action::GoToNextPane => { + self.next_pane(); + } + Action::GoToPrevPane => { + self.previous_pane(); + } + // handle input actions + Action::AddInputChar(_) + | Action::DeletePrevChar + | Action::DeleteNextChar + | Action::GoToInputEnd + | Action::GoToInputStart + | Action::GoToNextChar + | Action::GoToPrevChar + if self.is_input_focused() => + { + self.input.handle_action(&action); + match action { + Action::AddInputChar(_) + | Action::DeletePrevChar + | Action::DeleteNextChar => { + let new_pattern = self.input.value().to_string(); + if new_pattern != self.current_pattern { + self.current_pattern.clone_from(&new_pattern); + self.find(&new_pattern); + self.reset_preview_scroll(); + self.picker_state.select(Some(0)); + self.relative_picker_state.select(Some(0)); + self.picker_view_offset = 0; + } + } + _ => {} + } + } + Action::SelectNextEntry => { + self.select_next_entry(); + self.reset_preview_scroll(); + } + Action::SelectPrevEntry => { + self.select_prev_entry(); + self.reset_preview_scroll(); + } + Action::ScrollPreviewDown => self.scroll_preview_down(1), + Action::ScrollPreviewUp => self.scroll_preview_up(1), + Action::ScrollPreviewHalfPageDown => self.scroll_preview_down(20), + Action::ScrollPreviewHalfPageUp => self.scroll_preview_up(20), + _ => {} + } + Ok(None) + } + + /// Render the television on the screen. + /// + /// # Arguments + /// + /// * `f` - A frame used for rendering. + /// * `area` - The area in which the television should be drawn. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + pub fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let (results_area, input_area, preview_title_area, preview_area) = + create_layout(area); + + self.results_area_height = u32::from(results_area.height); + self.preview_pane_height = preview_area.height; + + // top left block: results + let results_block = Block::default() + .title( + Title::from(" Results ") + .position(Position::Top) + .alignment(Alignment::Center), + ) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(get_border_style(Pane::Results == self.current_pane)) + .style(Style::default()) + .padding(Padding::right(1)); + + if self.channel.result_count() > 0 + && self.picker_state.selected().is_none() + { + self.picker_state.select(Some(0)); + self.relative_picker_state.select(Some(0)); + } + + let entries = self.channel.results( + (results_area.height - 2).into(), + u32::try_from(self.picker_view_offset).unwrap(), + ); + let results_list = build_results_list(results_block, &entries); + + frame.render_stateful_widget( + results_list, + results_area, + &mut self.relative_picker_state, + ); + + // bottom left block: input + let input_block = Block::default() + .title( + Title::from(" Pattern ") + .position(Position::Top) + .alignment(Alignment::Center), + ) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(get_border_style(Pane::Input == self.current_pane)) + .style(Style::default()); + + let input_block_inner = input_block.inner(input_area); + + frame.render_widget(input_block, input_area); + + let inner_input_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), + Constraint::Fill(1), + Constraint::Length( + 3 * ((self.channel.total_count() as f32).log10().ceil() + as u16 + + 1) + + 3, + ), + ]) + .split(input_block_inner); + + let arrow_block = Block::default(); + let arrow = Paragraph::new(Span::styled("> ", Style::default())) + .block(arrow_block); + frame.render_widget(arrow, inner_input_chunks[0]); + + let interactive_input_block = Block::default(); + // keep 2 for borders and 1 for cursor + let width = inner_input_chunks[1].width.max(3) - 3; + let scroll = self.input.visual_scroll(width as usize); + let input = Paragraph::new(self.input.value()) + .scroll((0, u16::try_from(scroll).unwrap())) + .block(interactive_input_block) + .style(Style::default().fg(DEFAULT_INPUT_FG)) + .alignment(Alignment::Left); + frame.render_widget(input, inner_input_chunks[1]); + + let result_count_block = Block::default(); + let result_count = Paragraph::new(Span::styled( + format!( + " {} / {} ", + if self.channel.result_count() == 0 { + 0 + } else { + self.picker_state.selected().unwrap_or(0) + 1 + }, + self.channel.result_count(), + ), + Style::default().fg(DEFAULT_RESULTS_COUNT_FG), + )) + .block(result_count_block) + .alignment(Alignment::Right); + frame.render_widget(result_count, inner_input_chunks[2]); + + if let Pane::Input = self.current_pane { + // Make the cursor visible and ask tui-rs to put it at the + // specified coordinates after rendering + frame.set_cursor_position(( + // Put cursor past the end of the input text + inner_input_chunks[1].x + + u16::try_from( + (self.input.visual_cursor()).max(scroll) - scroll, + ) + .unwrap(), + // Move one line down, from the border to the input line + inner_input_chunks[1].y, + )); + } + + // top right block: preview title + let selected_entry = + self.get_selected_entry().unwrap_or(ENTRY_PLACEHOLDER); + + let preview = block_on(self.previewer.preview(&selected_entry)); + self.current_preview_total_lines = preview.total_lines(); + + let mut preview_title_spans = Vec::new(); + if let Some(icon) = &selected_entry.icon { + preview_title_spans.push(Span::styled( + icon.to_string(), + Style::default().fg(Color::from_str(icon.color).unwrap()), + )); + preview_title_spans.push(Span::raw(" ")); + } + preview_title_spans.push(Span::styled( + preview.title.clone(), + Style::default().fg(DEFAULT_PREVIEW_TITLE_FG), + )); + let preview_title = Paragraph::new(Line::from(preview_title_spans)) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(get_border_style(false)), + ) + .alignment(Alignment::Left); + frame.render_widget(preview_title, preview_title_area); + + // file preview + let preview_outer_block = Block::default() + .title( + Title::from(" Preview ") + .position(Position::Top) + .alignment(Alignment::Center), + ) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(get_border_style(Pane::Preview == self.current_pane)) + .style(Style::default()) + .padding(Padding::right(1)); + + let preview_inner_block = + Block::default().style(Style::default()).padding(Padding { + top: 0, + right: 1, + bottom: 0, + left: 1, + }); + let inner = preview_outer_block.inner(preview_area); + frame.render_widget(preview_outer_block, preview_area); + + if let PreviewContent::Image(img) = &preview.content { + let image_component = StatefulImage::new(None); + frame.render_stateful_widget( + image_component, + inner, + &mut img.clone(), + ); + } else { + let preview_block = self.build_preview_paragraph( + preview_inner_block, + inner, + &preview, + selected_entry + .line_number + // FIXME: this actually might panic in some edge cases + .map(|l| u16::try_from(l).unwrap()), + ); + frame.render_widget(preview_block, inner); + } + Ok(()) + } +} + +impl Television { + const FILL_CHAR_SLANTED: char = '╱'; + const FILL_CHAR_EMPTY: char = ' '; + + fn build_preview_paragraph<'b>( + &'b mut self, + preview_block: Block<'b>, + inner: Rect, + preview: &Arc, + target_line: Option, + ) -> Paragraph<'b> { + self.maybe_init_preview_scroll(target_line, inner.height); + match &preview.content { + PreviewContent::PlainText(content) => { + let mut lines = Vec::new(); + for (i, line) in content.iter().enumerate() { + lines.push(Line::from(vec![ + build_line_number_span(i + 1).style(Style::default().fg( + // FIXME: this actually might panic in some edge cases + if matches!( + target_line, + Some(l) if l == u16::try_from(i).unwrap() + 1 + ) + { + DEFAULT_PREVIEW_GUTTER_SELECTED_FG + } else { + DEFAULT_PREVIEW_GUTTER_FG + }, + )), + Span::styled(" │ ", + Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim()), + Span::styled( + line.to_string(), + Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG).bg( + if matches!(target_line, Some(l) if l == u16::try_from(i).unwrap() + 1) { + DEFAULT_SELECTED_PREVIEW_BG + } else { + Color::Reset + }, + ), + ), + ])); + } + let text = Text::from(lines); + Paragraph::new(text) + .block(preview_block) + .scroll((self.preview_scroll.unwrap_or(0), 0)) + } + PreviewContent::PlainTextWrapped(content) => { + let mut lines = Vec::new(); + for line in content.lines() { + lines.push(Line::styled( + line.to_string(), + Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG), + )); + } + let text = Text::from(lines); + Paragraph::new(text) + .block(preview_block) + .wrap(Wrap { trim: true }) + } + PreviewContent::HighlightedText(highlighted_lines) => { + compute_paragraph_from_highlighted_lines( + highlighted_lines, + target_line.map(|l| l as usize), + self.preview_scroll.unwrap_or(0), + self.preview_pane_height, + ) + .block(preview_block) + .alignment(Alignment::Left) + .scroll((self.preview_scroll.unwrap_or(0), 0)) + } + // meta + PreviewContent::Loading => self + .build_meta_preview_paragraph( + inner, + "Loading...", + Self::FILL_CHAR_EMPTY, + ) + .block(preview_block) + .alignment(Alignment::Left) + .style(Style::default().add_modifier(Modifier::ITALIC)), + PreviewContent::NotSupported => self + .build_meta_preview_paragraph( + inner, + PREVIEW_NOT_SUPPORTED_MSG, + Self::FILL_CHAR_SLANTED, + ) + .block(preview_block) + .alignment(Alignment::Left) + .style(Style::default().add_modifier(Modifier::ITALIC)), + PreviewContent::FileTooLarge => self + .build_meta_preview_paragraph( + inner, + FILE_TOO_LARGE_MSG, + Self::FILL_CHAR_SLANTED, + ) + .block(preview_block) + .alignment(Alignment::Left) + .style(Style::default().add_modifier(Modifier::ITALIC)), + _ => Paragraph::new(Text::raw(EMPTY_STRING)), + } + } + + fn maybe_init_preview_scroll( + &mut self, + target_line: Option, + height: u16, + ) { + if self.preview_scroll.is_none() { + self.preview_scroll = + Some(target_line.unwrap_or(0).saturating_sub(height / 3)); + } + } + + fn build_meta_preview_paragraph<'a>( + &mut self, + inner: Rect, + message: &str, + fill_char: char, + ) -> Paragraph<'a> { + if let Some(paragraph) = self.meta_paragraph_cache.get(message) { + return paragraph.clone(); + } + let message_len = message.len(); + let fill_char_str = fill_char.to_string(); + let fill_line = fill_char_str.repeat(inner.width as usize); + + // Build the paragraph content with slanted lines and center the custom message + let mut lines = Vec::new(); + + // Calculate the vertical center + let vertical_center = inner.height as usize / 2; + let horizontal_padding = (inner.width as usize - message_len) / 2 - 4; + + // Fill the paragraph with slanted lines and insert the centered custom message + for i in 0..inner.height { + if i as usize == vertical_center { + // Center the message horizontally in the middle line + let line = format!( + "{} {} {}", + fill_char_str.repeat(horizontal_padding), + message, + fill_char_str.repeat( + inner.width as usize + - horizontal_padding + - message_len + ) + ); + lines.push(Line::from(line)); + } else if i as usize + 1 == vertical_center + || (i as usize).saturating_sub(1) == vertical_center + { + let line = format!( + "{} {} {}", + fill_char_str.repeat(horizontal_padding), + " ".repeat(message_len), + fill_char_str.repeat( + inner.width as usize + - horizontal_padding + - message_len + ) + ); + lines.push(Line::from(line)); + } else { + lines.push(Line::from(fill_line.clone())); + } + } + + // Create a paragraph with the generated content + let p = Paragraph::new(Text::from(lines)); + self.meta_paragraph_cache + .insert(message.to_string(), p.clone()); + p + } +} + +/// This makes the `Action` type compatible with the `Input` logic. +pub trait InputActionHandler { + // Handle Key event. + fn handle_action(&mut self, action: &Action) -> Option; +} + +impl InputActionHandler for Input { + /// Handle Key event. + fn handle_action(&mut self, action: &Action) -> Option { + match action { + Action::AddInputChar(c) => { + self.handle(InputRequest::InsertChar(*c)) + } + Action::DeletePrevChar => { + self.handle(InputRequest::DeletePrevChar) + } + Action::DeleteNextChar => { + self.handle(InputRequest::DeleteNextChar) + } + Action::GoToPrevChar => self.handle(InputRequest::GoToPrevChar), + Action::GoToNextChar => self.handle(InputRequest::GoToNextChar), + Action::GoToInputStart => self.handle(InputRequest::GoToStart), + Action::GoToInputEnd => self.handle(InputRequest::GoToEnd), + _ => None, + } + } +} + +fn build_line_number_span<'a>(line_number: usize) -> Span<'a> { + Span::from(format!("{line_number:5} ")) +} + +fn compute_paragraph_from_highlighted_lines( + highlighted_lines: &[Vec<(syntect::highlighting::Style, String)>], + line_specifier: Option, + scroll: u16, + preview_pane_height: u16, +) -> Paragraph<'static> { + let preview_lines: Vec = highlighted_lines + .iter() + .enumerate() + .map(|(i, l)| { + if i < scroll as usize + || i >= (scroll + preview_pane_height) as usize + { + return Line::from(Span::raw(EMPTY_STRING)); + } + let line_number = + build_line_number_span(i + 1).style(Style::default().fg( + if line_specifier.is_some() + && i == line_specifier.unwrap() - 1 + { + DEFAULT_PREVIEW_GUTTER_SELECTED_FG + } else { + DEFAULT_PREVIEW_GUTTER_FG + }, + )); + Line::from_iter( + std::iter::once(line_number) + .chain(std::iter::once(Span::styled( + " │ ", + Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim(), + ))) + .chain(l.iter().cloned().map(|sr| { + convert_syn_region_to_span( + &(sr.0, sr.1.replace('\t', FOUR_SPACES)), + if line_specifier.is_some() + && i == line_specifier.unwrap() - 1 + { + Some(SyntectColor { + r: 50, + g: 50, + b: 50, + a: 255, + }) + } else { + None + }, + ) + })), + ) + }) + .collect(); + + Paragraph::new(preview_lines) +} + +fn convert_syn_region_to_span<'a>( + syn_region: &(syntect::highlighting::Style, String), + background: Option, +) -> Span<'a> { + let mut style = Style::default() + .fg(convert_syn_color_to_ratatui_color(syn_region.0.foreground)); + if let Some(background) = background { + style = style.bg(convert_syn_color_to_ratatui_color(background)); + } + style = match syn_region.0.font_style { + syntect::highlighting::FontStyle::BOLD => style.bold(), + syntect::highlighting::FontStyle::ITALIC => style.italic(), + syntect::highlighting::FontStyle::UNDERLINE => style.underlined(), + _ => style, + }; + Span::styled(syn_region.1.clone(), style) +} + +fn convert_syn_color_to_ratatui_color( + color: syntect::highlighting::Color, +) -> ratatui::style::Color { + ratatui::style::Color::Rgb(color.r, color.g, color.b) +} diff --git a/crates/television/tui.rs b/crates/television/tui.rs new file mode 100644 index 0000000..3aaffef --- /dev/null +++ b/crates/television/tui.rs @@ -0,0 +1,113 @@ +use std::{ + io::{stderr, LineWriter, Write}, + ops::{Deref, DerefMut}, +}; + +use color_eyre::Result; +use crossterm::{ + cursor, execute, + terminal::{ + disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, + EnterAlternateScreen, LeaveAlternateScreen, + }, +}; +use ratatui::{backend::CrosstermBackend, layout::Size}; +use tokio::task::JoinHandle; +use tracing::debug; + +pub struct Tui +where + W: Write, +{ + pub task: JoinHandle<()>, + pub frame_rate: f64, + pub terminal: ratatui::Terminal>, +} + +impl Tui +where + W: Write, +{ + pub fn new(writer: W) -> Result { + Ok(Self { + task: tokio::spawn(async {}), + frame_rate: 60.0, + terminal: ratatui::Terminal::new(CrosstermBackend::new(writer))?, + }) + } + + pub fn frame_rate(mut self, frame_rate: f64) -> Self { + self.frame_rate = frame_rate; + self + } + + pub fn size(&self) -> Result { + Ok(self.terminal.size()?) + } + + pub fn enter(&mut self) -> Result<()> { + enable_raw_mode()?; + let mut buffered_stderr = LineWriter::new(stderr()); + execute!(buffered_stderr, EnterAlternateScreen)?; + self.terminal.clear()?; + execute!(buffered_stderr, cursor::Hide)?; + Ok(()) + } + + pub fn exit(&mut self) -> Result<()> { + if is_raw_mode_enabled()? { + debug!("Exiting terminal"); + + disable_raw_mode()?; + let mut buffered_stderr = LineWriter::new(stderr()); + execute!(buffered_stderr, cursor::Show)?; + execute!(buffered_stderr, LeaveAlternateScreen)?; + } + + Ok(()) + } + + pub fn suspend(&mut self) -> Result<()> { + self.exit()?; + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; + Ok(()) + } + + pub fn resume(&mut self) -> Result<()> { + self.enter()?; + Ok(()) + } +} + +impl Deref for Tui +where + W: Write, +{ + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui +where + W: Write, +{ + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui +where + W: Write, +{ + fn drop(&mut self) { + match self.exit() { + Ok(_) => debug!("Successfully exited terminal"), + Err(e) => debug!("Failed to exit terminal: {:?}", e), + } + } +} diff --git a/crates/television/ui.rs b/crates/television/ui.rs new file mode 100644 index 0000000..22297b6 --- /dev/null +++ b/crates/television/ui.rs @@ -0,0 +1,176 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, List, ListDirection}, +}; +use std::str::FromStr; + +use crate::entry::Entry; +use crate::utils::strings::{next_char_boundary, slice_at_char_boundaries}; +use crate::utils::ui::centered_rect; + +pub mod input; + +// UI size +const UI_WIDTH_PERCENT: u16 = 90; +const UI_HEIGHT_PERCENT: u16 = 90; + +// Styles +// results +const DEFAULT_RESULT_NAME_FG: Color = Color::Blue; +const DEFAULT_RESULT_PREVIEW_FG: Color = Color::Rgb(150, 150, 150); +const DEFAULT_RESULT_LINE_NUMBER_FG: Color = Color::Yellow; +// input +const DEFAULT_INPUT_FG: Color = Color::Rgb(200, 200, 200); +const DEFAULT_RESULTS_COUNT_FG: Color = Color::Rgb(150, 150, 150); +// preview +const DEFAULT_PREVIEW_TITLE_FG: Color = Color::Blue; +const DEFAULT_SELECTED_PREVIEW_BG: Color = Color::Rgb(50, 50, 50); +const DEFAULT_PREVIEW_CONTENT_FG: Color = Color::Rgb(150, 150, 180); +const DEFAULT_PREVIEW_GUTTER_FG: Color = Color::Rgb(70, 70, 70); +const DEFAULT_PREVIEW_GUTTER_SELECTED_FG: Color = Color::Rgb(255, 150, 150); + +pub fn get_border_style(focused: bool) -> Style { + if focused { + Style::default().fg(Color::Green) + } else { + // TODO: make this depend on self.config + Style::default().fg(Color::Rgb(90, 90, 110)).dim() + } +} + +pub fn create_layout(area: Rect) -> (Rect, Rect, Rect, Rect) { + let main_block = centered_rect(UI_WIDTH_PERCENT, UI_HEIGHT_PERCENT, area); + + // split the main block into two vertical chunks + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(main_block); + + // left block: results + input field + let left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(10), Constraint::Length(3)]) + .split(chunks[0]); + + // right block: preview title + preview + let right_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(10)]) + .split(chunks[1]); + + ( + left_chunks[0], + left_chunks[1], + right_chunks[0], + right_chunks[1], + ) +} + +pub fn build_results_list<'a, 'b>( + results_block: Block<'b>, + entries: &'a [Entry], +) -> List<'a> +where + 'b: 'a, +{ + List::new(entries.iter().map(|entry| { + let mut spans = Vec::new(); + // optional icon + if let Some(icon) = &entry.icon { + spans.push(Span::styled( + icon.to_string(), + Style::default().fg(Color::from_str(icon.color).unwrap()), + )); + spans.push(Span::raw(" ")); + } + // entry name + if let Some(name_match_ranges) = &entry.name_match_ranges { + let mut last_match_end = 0; + for (start, end) in name_match_ranges + .iter() + .map(|(s, e)| (*s as usize, *e as usize)) + { + spans.push(Span::styled( + slice_at_char_boundaries( + &entry.name, + last_match_end, + start, + ), + Style::default() + .fg(DEFAULT_RESULT_NAME_FG) + .bold() + .italic(), + )); + spans.push(Span::styled( + slice_at_char_boundaries(&entry.name, start, end), + Style::default().fg(Color::Red).bold().italic(), + )); + last_match_end = end; + } + spans.push(Span::styled( + &entry.name[next_char_boundary(&entry.name, last_match_end)..], + Style::default().fg(DEFAULT_RESULT_NAME_FG).bold().italic(), + )); + } else { + spans.push(Span::styled( + entry.display_name(), + Style::default().fg(DEFAULT_RESULT_NAME_FG).bold().italic(), + )); + } + // optional line number + if let Some(line_number) = entry.line_number { + spans.push(Span::styled( + format!(":{line_number}"), + Style::default().fg(DEFAULT_RESULT_LINE_NUMBER_FG), + )); + } + // optional preview + if let Some(preview) = &entry.value { + spans.push(Span::raw(": ")); + + if let Some(preview_match_ranges) = &entry.value_match_ranges { + if !preview_match_ranges.is_empty() { + let mut last_match_end = 0; + for (start, end) in preview_match_ranges + .iter() + .map(|(s, e)| (*s as usize, *e as usize)) + { + spans.push(Span::styled( + slice_at_char_boundaries( + preview, + last_match_end, + start, + ), + Style::default().fg(DEFAULT_RESULT_PREVIEW_FG), + )); + spans.push(Span::styled( + slice_at_char_boundaries(preview, start, end), + Style::default().fg(Color::Red), + )); + last_match_end = end; + } + spans.push(Span::styled( + &preview[next_char_boundary( + preview, + preview_match_ranges.last().unwrap().1 as usize, + )..], + Style::default().fg(DEFAULT_RESULT_PREVIEW_FG), + )); + } + } else { + spans.push(Span::styled( + preview, + Style::default().fg(DEFAULT_RESULT_PREVIEW_FG), + )); + } + } + Line::from(spans) + })) + .direction(ListDirection::BottomToTop) + .highlight_style(Style::default().bg(Color::Rgb(50, 50, 50))) + .highlight_symbol("> ") + .block(results_block) +} diff --git a/crates/television/ui/input.rs b/crates/television/ui/input.rs new file mode 100644 index 0000000..eed1bfa --- /dev/null +++ b/crates/television/ui/input.rs @@ -0,0 +1,588 @@ +pub mod backend; + +/// Input requests are used to change the input state. +/// +/// Different backends can be used to convert events into requests. +#[allow(clippy::module_name_repetitions)] +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] +pub enum InputRequest { + SetCursor(usize), + InsertChar(char), + GoToPrevChar, + GoToNextChar, + GoToPrevWord, + GoToNextWord, + GoToStart, + GoToEnd, + DeletePrevChar, + DeleteNextChar, + DeletePrevWord, + DeleteNextWord, + DeleteLine, + DeleteTillEnd, +} + +#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] +pub struct StateChanged { + pub value: bool, + pub cursor: bool, +} + +#[allow(clippy::module_name_repetitions)] +pub type InputResponse = Option; + +/// The input buffer with cursor support. +/// +/// Example: +/// +/// ``` +/// use tui_input::Input; +/// +/// let input: Input = "Hello World".into(); +/// +/// assert_eq!(input.cursor(), 11); +/// assert_eq!(input.to_string(), "Hello World"); +/// ``` +#[derive(Default, Debug, Clone)] +pub struct Input { + value: String, + cursor: usize, +} + +impl Input { + /// Initialize a new instance with a given value + /// Cursor will be set to the given value's length. + pub fn new(value: String) -> Self { + let len = value.chars().count(); + Self { value, cursor: len } + } + + /// Set the value manually. + /// Cursor will be set to the given value's length. + pub fn with_value(mut self, value: String) -> Self { + self.cursor = value.chars().count(); + self.value = value; + self + } + + /// Set the cursor manually. + /// If the input is larger than the value length, it'll be auto adjusted. + pub fn with_cursor(mut self, cursor: usize) -> Self { + self.cursor = cursor.min(self.value.chars().count()); + self + } + + // Reset the cursor and value to default + pub fn reset(&mut self) { + self.cursor = Default::default(); + self.value = String::default(); + } + + /// Handle request and emit response. + #[allow(clippy::too_many_lines)] + pub fn handle(&mut self, req: InputRequest) -> InputResponse { + use InputRequest::{ + DeleteLine, DeleteNextChar, DeleteNextWord, DeletePrevChar, + DeletePrevWord, DeleteTillEnd, GoToEnd, GoToNextChar, + GoToNextWord, GoToPrevChar, GoToPrevWord, GoToStart, InsertChar, + SetCursor, + }; + match req { + SetCursor(pos) => { + let pos = pos.min(self.value.chars().count()); + if self.cursor == pos { + None + } else { + self.cursor = pos; + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + InsertChar(c) => { + if self.cursor == self.value.chars().count() { + self.value.push(c); + } else { + self.value = self + .value + .chars() + .take(self.cursor) + .chain( + std::iter::once(c) + .chain(self.value.chars().skip(self.cursor)), + ) + .collect(); + } + self.cursor += 1; + Some(StateChanged { + value: true, + cursor: true, + }) + } + + DeletePrevChar => { + if self.cursor == 0 { + None + } else { + self.cursor -= 1; + self.value = self + .value + .chars() + .enumerate() + .filter(|(i, _)| i != &self.cursor) + .map(|(_, c)| c) + .collect(); + + Some(StateChanged { + value: true, + cursor: true, + }) + } + } + + DeleteNextChar => { + if self.cursor == self.value.chars().count() { + None + } else { + self.value = self + .value + .chars() + .enumerate() + .filter(|(i, _)| i != &self.cursor) + .map(|(_, c)| c) + .collect(); + Some(StateChanged { + value: true, + cursor: false, + }) + } + } + + GoToPrevChar => { + if self.cursor == 0 { + None + } else { + self.cursor -= 1; + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + GoToPrevWord => { + if self.cursor == 0 { + None + } else { + self.cursor = self + .value + .chars() + .rev() + .skip( + self.value.chars().count().max(self.cursor) + - self.cursor, + ) + .skip_while(|c| !c.is_alphanumeric()) + .skip_while(|c| c.is_alphanumeric()) + .count(); + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + GoToNextChar => { + if self.cursor == self.value.chars().count() { + None + } else { + self.cursor += 1; + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + GoToNextWord => { + if self.cursor == self.value.chars().count() { + None + } else { + self.cursor = self + .value + .chars() + .enumerate() + .skip(self.cursor) + .skip_while(|(_, c)| c.is_alphanumeric()) + .find(|(_, c)| c.is_alphanumeric()) + .map(|(i, _)| i) + .unwrap_or_else(|| self.value.chars().count()); + + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + DeleteLine => { + if self.value.is_empty() { + None + } else { + let cursor = self.cursor; + self.value = "".into(); + self.cursor = 0; + Some(StateChanged { + value: true, + cursor: self.cursor == cursor, + }) + } + } + + DeletePrevWord => { + if self.cursor == 0 { + None + } else { + let remaining = self.value.chars().skip(self.cursor); + let rev = self + .value + .chars() + .rev() + .skip( + self.value.chars().count().max(self.cursor) + - self.cursor, + ) + .skip_while(|c| !c.is_alphanumeric()) + .skip_while(|c| c.is_alphanumeric()) + .collect::>(); + let rev_len = rev.len(); + self.value = + rev.into_iter().rev().chain(remaining).collect(); + self.cursor = rev_len; + Some(StateChanged { + value: true, + cursor: true, + }) + } + } + + DeleteNextWord => { + if self.cursor == self.value.chars().count() { + None + } else { + self.value = self + .value + .chars() + .take(self.cursor) + .chain( + self.value + .chars() + .skip(self.cursor) + .skip_while(|c| c.is_alphanumeric()) + .skip_while(|c| !c.is_alphanumeric()), + ) + .collect(); + + Some(StateChanged { + value: true, + cursor: false, + }) + } + } + + GoToStart => { + if self.cursor == 0 { + None + } else { + self.cursor = 0; + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + GoToEnd => { + let count = self.value.chars().count(); + if self.cursor == count { + None + } else { + self.cursor = count; + Some(StateChanged { + value: false, + cursor: true, + }) + } + } + + DeleteTillEnd => { + self.value = self.value.chars().take(self.cursor).collect(); + Some(StateChanged { + value: true, + cursor: false, + }) + } + } + } + + /// Get a reference to the current value. + pub fn value(&self) -> &str { + self.value.as_str() + } + + /// Get the currect cursor placement. + pub fn cursor(&self) -> usize { + self.cursor + } + + /// Get the current cursor position with account for multispace characters. + pub fn visual_cursor(&self) -> usize { + if self.cursor == 0 { + return 0; + } + + // Safe, because the end index will always be within bounds + unicode_width::UnicodeWidthStr::width(unsafe { + self.value.get_unchecked( + 0..self + .value + .char_indices() + .nth(self.cursor) + .map_or_else(|| self.value.len(), |(index, _)| index), + ) + }) + } + + /// Get the scroll position with account for multispace characters. + pub fn visual_scroll(&self, width: usize) -> usize { + let scroll = (self.visual_cursor()).max(width) - width; + let mut uscroll = 0; + let mut chars = self.value().chars(); + + while uscroll < scroll { + match chars.next() { + Some(c) => { + uscroll += + unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); + } + None => break, + } + } + uscroll + } +} + +impl From for String { + fn from(input: Input) -> Self { + input.value + } +} + +impl From for Input { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From<&str> for Input { + fn from(value: &str) -> Self { + Self::new(value.into()) + } +} + +impl std::fmt::Display for Input { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.value.fmt(f) + } +} + +#[cfg(test)] +mod tests { + + const TEXT: &str = "first second, third."; + + use super::*; + + #[test] + fn format() { + let input: Input = TEXT.into(); + println!("{}", input); + println!("{}", input); + } + + #[test] + fn set_cursor() { + let mut input: Input = TEXT.into(); + + let req = InputRequest::SetCursor(3); + let resp = input.handle(req); + + assert_eq!( + resp, + Some(StateChanged { + value: false, + cursor: true, + }) + ); + + assert_eq!(input.value(), "first second, third."); + assert_eq!(input.cursor(), 3); + + let req = InputRequest::SetCursor(30); + let resp = input.handle(req); + + assert_eq!(input.cursor(), TEXT.chars().count()); + assert_eq!( + resp, + Some(StateChanged { + value: false, + cursor: true, + }) + ); + + let req = InputRequest::SetCursor(TEXT.chars().count()); + let resp = input.handle(req); + + assert_eq!(input.cursor(), TEXT.chars().count()); + assert_eq!(resp, None); + } + + #[test] + fn insert_char() { + let mut input: Input = TEXT.into(); + + let req = InputRequest::InsertChar('x'); + let resp = input.handle(req); + + assert_eq!( + resp, + Some(StateChanged { + value: true, + cursor: true, + }) + ); + + assert_eq!(input.value(), "first second, third.x"); + assert_eq!(input.cursor(), TEXT.chars().count() + 1); + input.handle(req); + assert_eq!(input.value(), "first second, third.xx"); + assert_eq!(input.cursor(), TEXT.chars().count() + 2); + + let mut input = input.with_cursor(3); + input.handle(req); + assert_eq!(input.value(), "firxst second, third.xx"); + assert_eq!(input.cursor(), 4); + + input.handle(req); + assert_eq!(input.value(), "firxxst second, third.xx"); + assert_eq!(input.cursor(), 5); + } + + #[test] + fn go_to_prev_char() { + let mut input: Input = TEXT.into(); + + let req = InputRequest::GoToPrevChar; + let resp = input.handle(req); + + assert_eq!( + resp, + Some(StateChanged { + value: false, + cursor: true, + }) + ); + + assert_eq!(input.value(), "first second, third."); + assert_eq!(input.cursor(), TEXT.chars().count() - 1); + + let mut input = input.with_cursor(3); + input.handle(req); + assert_eq!(input.value(), "first second, third."); + assert_eq!(input.cursor(), 2); + + input.handle(req); + assert_eq!(input.value(), "first second, third."); + assert_eq!(input.cursor(), 1); + } + + #[test] + fn remove_unicode_chars() { + let mut input: Input = "¡test¡".into(); + + let req = InputRequest::DeletePrevChar; + let resp = input.handle(req); + + assert_eq!( + resp, + Some(StateChanged { + value: true, + cursor: true, + }) + ); + + assert_eq!(input.value(), "¡test"); + assert_eq!(input.cursor(), 5); + + input.handle(InputRequest::GoToStart); + + let req = InputRequest::DeleteNextChar; + let resp = input.handle(req); + + assert_eq!( + resp, + Some(StateChanged { + value: true, + cursor: false, + }) + ); + + assert_eq!(input.value(), "test"); + assert_eq!(input.cursor(), 0); + } + + #[test] + fn insert_unicode_chars() { + let mut input = Input::from("¡test¡").with_cursor(5); + + let req = InputRequest::InsertChar('☆'); + let resp = input.handle(req); + + assert_eq!( + resp, + Some(StateChanged { + value: true, + cursor: true, + }) + ); + + assert_eq!(input.value(), "¡test☆¡"); + assert_eq!(input.cursor(), 6); + + input.handle(InputRequest::GoToStart); + input.handle(InputRequest::GoToNextChar); + + let req = InputRequest::InsertChar('☆'); + let resp = input.handle(req); + + assert_eq!( + resp, + Some(StateChanged { + value: true, + cursor: true, + }) + ); + + assert_eq!(input.value(), "¡☆test☆¡"); + assert_eq!(input.cursor(), 2); + } + + #[test] + fn multispace_characters() { + let input: Input = "Hello, world!".into(); + assert_eq!(input.cursor(), 13); + assert_eq!(input.visual_cursor(), 23); + assert_eq!(input.visual_scroll(6), 18); + } +} diff --git a/crates/television/ui/input/backend.rs b/crates/television/ui/input/backend.rs new file mode 100644 index 0000000..3a0b3b7 --- /dev/null +++ b/crates/television/ui/input/backend.rs @@ -0,0 +1,75 @@ +use super::{Input, InputRequest, StateChanged}; +use ratatui::crossterm::event::{ + Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, +}; + +/// Converts crossterm event into input requests. +/// TODO: make these keybindings configurable. +pub fn to_input_request(evt: &CrosstermEvent) -> Option { + use InputRequest::*; + use KeyCode::*; + match evt { + CrosstermEvent::Key(KeyEvent { + code, + modifiers, + kind, + state: _, + }) if *kind == KeyEventKind::Press => match (*code, *modifiers) { + (Backspace, KeyModifiers::NONE) => Some(DeletePrevChar), + (Delete, KeyModifiers::NONE) => Some(DeleteNextChar), + (Tab, KeyModifiers::NONE) => None, + (Left, KeyModifiers::NONE) => Some(GoToPrevChar), + //(Left, KeyModifiers::CONTROL) => Some(GoToPrevWord), + (Right, KeyModifiers::NONE) => Some(GoToNextChar), + //(Right, KeyModifiers::CONTROL) => Some(GoToNextWord), + //(Char('u'), KeyModifiers::CONTROL) => Some(DeleteLine), + (Char('w'), KeyModifiers::CONTROL) + | (Backspace, KeyModifiers::ALT) => Some(DeletePrevWord), + (Delete, KeyModifiers::CONTROL) => Some(DeleteNextWord), + //(Char('k'), KeyModifiers::CONTROL) => Some(DeleteTillEnd), + (Char('a'), KeyModifiers::CONTROL) + | (Home, KeyModifiers::NONE) => Some(GoToStart), + (Char('e'), KeyModifiers::CONTROL) | (End, KeyModifiers::NONE) => { + Some(GoToEnd) + } + (Char(c), KeyModifiers::NONE) => Some(InsertChar(c)), + (Char(c), KeyModifiers::SHIFT) => Some(InsertChar(c)), + (_, _) => None, + }, + _ => None, + } +} + +/// Import this trait to implement `Input::handle_event()` for crossterm. +pub trait EventHandler { + /// Handle crossterm event. + fn handle_event(&mut self, evt: &CrosstermEvent) -> Option; +} + +impl EventHandler for Input { + /// Handle crossterm event. + fn handle_event(&mut self, evt: &CrosstermEvent) -> Option { + to_input_request(evt).and_then(|req| self.handle(req)) + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::{KeyEventKind, KeyEventState}; + + use super::*; + + #[test] + fn handle_tab() { + let evt = CrosstermEvent::Key(KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }); + + let req = to_input_request(&evt); + + assert!(req.is_none()); + } +} diff --git a/crates/television/utils.rs b/crates/television/utils.rs new file mode 100644 index 0000000..e87b2a3 --- /dev/null +++ b/crates/television/utils.rs @@ -0,0 +1,4 @@ +pub mod files; +pub mod indices; +pub mod strings; +pub mod ui; diff --git a/crates/television/utils/files.rs b/crates/television/utils/files.rs new file mode 100644 index 0000000..815f904 --- /dev/null +++ b/crates/television/utils/files.rs @@ -0,0 +1,399 @@ +use std::collections::HashSet; +use std::path::Path; + +use ignore::{types::TypesBuilder, WalkBuilder}; +use infer::Infer; +use lazy_static::lazy_static; + +use crate::config::default_num_threads; + +lazy_static::lazy_static! { + pub static ref DEFAULT_NUM_THREADS: usize = default_num_threads().into(); +} + +pub fn walk_builder(path: &Path, n_threads: usize) -> WalkBuilder { + let mut builder = WalkBuilder::new(path); + + // ft-based filtering + let mut types_builder = TypesBuilder::new(); + types_builder.add_defaults(); + builder.types(types_builder.build().unwrap()); + + builder.threads(n_threads); + builder +} + +pub fn get_file_size(path: &Path) -> Option { + std::fs::metadata(path).ok().map(|m| m.len()) +} + +#[derive(Debug)] +pub enum FileType { + Text, + Image, + Other, + Unknown, +} + +pub fn is_not_text(bytes: &[u8]) -> Option { + let infer = Infer::new(); + match infer.get(bytes) { + Some(t) => { + let mime_type = t.mime_type(); + if mime_type.contains("image") + || mime_type.contains("video") + || mime_type.contains("audio") + || mime_type.contains("archive") + || mime_type.contains("book") + || mime_type.contains("font") + { + Some(true) + } else { + None + } + } + None => None, + } +} + +pub fn is_valid_utf8(bytes: &[u8]) -> bool { + std::str::from_utf8(bytes).is_ok() +} + +pub fn is_known_text_extension(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| KNOWN_TEXT_FILE_EXTENSIONS.contains(ext)) +} + +lazy_static! { + static ref KNOWN_TEXT_FILE_EXTENSIONS: HashSet<&'static str> = [ + "ada", + "adb", + "ads", + "applescript", + "as", + "asc", + "ascii", + "ascx", + "asm", + "asmx", + "asp", + "aspx", + "atom", + "au3", + "awk", + "bas", + "bash", + "bashrc", + "bat", + "bbcolors", + "bcp", + "bdsgroup", + "bdsproj", + "bib", + "bowerrc", + "c", + "cbl", + "cc", + "cfc", + "cfg", + "cfm", + "cfml", + "cgi", + "cjs", + "clj", + "cljs", + "cls", + "cmake", + "cmd", + "cnf", + "cob", + "code-snippets", + "coffee", + "coffeekup", + "conf", + "cp", + "cpp", + "cpt", + "cpy", + "crt", + "cs", + "csh", + "cson", + "csproj", + "csr", + "css", + "csslintrc", + "csv", + "ctl", + "curlrc", + "cxx", + "d", + "dart", + "dfm", + "diff", + "dof", + "dpk", + "dpr", + "dproj", + "dtd", + "eco", + "editorconfig", + "ejs", + "el", + "elm", + "emacs", + "eml", + "ent", + "erb", + "erl", + "eslintignore", + "eslintrc", + "ex", + "exs", + "f", + "f03", + "f77", + "f90", + "f95", + "fish", + "for", + "fpp", + "frm", + "fs", + "fsproj", + "fsx", + "ftn", + "gemrc", + "gemspec", + "gitattributes", + "gitconfig", + "gitignore", + "gitkeep", + "gitmodules", + "go", + "gpp", + "gradle", + "graphql", + "groovy", + "groupproj", + "grunit", + "gtmpl", + "gvimrc", + "h", + "haml", + "hbs", + "hgignore", + "hh", + "hpp", + "hrl", + "hs", + "hta", + "htaccess", + "htc", + "htm", + "html", + "htpasswd", + "hxx", + "iced", + "iml", + "inc", + "inf", + "info", + "ini", + "ino", + "int", + "irbrc", + "itcl", + "itermcolors", + "itk", + "jade", + "java", + "jhtm", + "jhtml", + "js", + "jscsrc", + "jshintignore", + "jshintrc", + "json", + "json5", + "jsonld", + "jsp", + "jspx", + "jsx", + "ksh", + "less", + "lhs", + "lisp", + "log", + "ls", + "lsp", + "lua", + "m", + "m4", + "mak", + "map", + "markdown", + "master", + "md", + "mdown", + "mdwn", + "mdx", + "metadata", + "mht", + "mhtml", + "mjs", + "mk", + "mkd", + "mkdn", + "mkdown", + "ml", + "mli", + "mm", + "mxml", + "nfm", + "nfo", + "noon", + "npmignore", + "npmrc", + "nuspec", + "nvmrc", + "ops", + "pas", + "pasm", + "patch", + "pbxproj", + "pch", + "pem", + "pg", + "php", + "php3", + "php4", + "php5", + "phpt", + "phtml", + "pir", + "pl", + "pm", + "pmc", + "pod", + "pot", + "prettierrc", + "properties", + "props", + "pt", + "pug", + "purs", + "py", + "pyx", + "r", + "rake", + "rb", + "rbw", + "rc", + "rdoc", + "rdoc_options", + "resx", + "rexx", + "rhtml", + "rjs", + "rlib", + "ron", + "rs", + "rss", + "rst", + "rtf", + "rvmrc", + "rxml", + "s", + "sass", + "scala", + "scm", + "scss", + "seestyle", + "sh", + "shtml", + "sln", + "sls", + "spec", + "sql", + "sqlite", + "sqlproj", + "srt", + "ss", + "sss", + "st", + "strings", + "sty", + "styl", + "stylus", + "sub", + "sublime-build", + "sublime-commands", + "sublime-completions", + "sublime-keymap", + "sublime-macro", + "sublime-menu", + "sublime-project", + "sublime-settings", + "sublime-workspace", + "sv", + "svc", + "svg", + "swift", + "t", + "tcl", + "tcsh", + "terminal", + "tex", + "text", + "textile", + "tg", + "tk", + "tmLanguage", + "tmpl", + "tmTheme", + "tpl", + "ts", + "tsv", + "tsx", + "tt", + "tt2", + "ttml", + "twig", + "txt", + "v", + "vb", + "vbproj", + "vbs", + "vcproj", + "vcxproj", + "vh", + "vhd", + "vhdl", + "vim", + "viminfo", + "vimrc", + "vm", + "vue", + "webapp", + "webmanifest", + "wsc", + "x-php", + "xaml", + "xht", + "xhtml", + "xml", + "xs", + "xsd", + "xsl", + "xslt", + "y", + "yaml", + "yml", + "zsh", + "zshrc", + ] + .into(); +} diff --git a/crates/television/utils/indices.rs b/crates/television/utils/indices.rs new file mode 100644 index 0000000..318277b --- /dev/null +++ b/crates/television/utils/indices.rs @@ -0,0 +1,31 @@ +pub fn sep_name_and_value_indices( + indices: &mut Vec, + name_len: u32, +) -> (Vec, Vec, bool, bool) { + let mut name_indices = Vec::new(); + let mut value_indices = Vec::new(); + let mut should_add_name_indices = false; + let mut should_add_value_indices = false; + + for i in indices.drain(..) { + if i < name_len { + name_indices.push(i); + should_add_name_indices = true; + } else { + value_indices.push(i - name_len); + should_add_value_indices = true; + } + } + + name_indices.sort_unstable(); + name_indices.dedup(); + value_indices.sort_unstable(); + value_indices.dedup(); + + ( + name_indices, + value_indices, + should_add_name_indices, + should_add_value_indices, + ) +} diff --git a/crates/television/utils/strings.rs b/crates/television/utils/strings.rs new file mode 100644 index 0000000..645c1ab --- /dev/null +++ b/crates/television/utils/strings.rs @@ -0,0 +1,123 @@ +use lazy_static::lazy_static; +use std::fmt::Write; + +pub fn next_char_boundary(s: &str, start: usize) -> usize { + let mut i = start; + while !s.is_char_boundary(i) { + i += 1; + } + i +} + +pub fn prev_char_boundary(s: &str, start: usize) -> usize { + let mut i = start; + while !s.is_char_boundary(i) { + i -= 1; + } + i +} + +pub fn slice_at_char_boundaries( + s: &str, + start_byte_index: usize, + end_byte_index: usize, +) -> &str { + &s[prev_char_boundary(s, start_byte_index) + ..next_char_boundary(s, end_byte_index)] +} + +pub fn slice_up_to_char_boundary(s: &str, byte_index: usize) -> &str { + let mut char_index = byte_index; + while !s.is_char_boundary(char_index) { + char_index -= 1; + } + &s[..char_index] +} + +fn try_parse_utf8_char(input: &[u8]) -> Option<(char, usize)> { + let str_from_utf8 = |seq| std::str::from_utf8(seq).ok(); + + let decoded = input + .get(0..1) + .and_then(str_from_utf8) + .map(|c| (c, 1)) + .or_else(|| input.get(0..2).and_then(str_from_utf8).map(|c| (c, 2))) + .or_else(|| input.get(0..3).and_then(str_from_utf8).map(|c| (c, 3))) + .or_else(|| input.get(0..4).and_then(str_from_utf8).map(|c| (c, 4))); + + decoded.map(|(seq, n)| (seq.chars().next().unwrap(), n)) +} + +lazy_static! { + static ref NULL_SYMBOL: char = char::from_u32(0x2400).unwrap(); +} + +const SPACE_CHARACTER: char = ' '; +const TAB_CHARACTER: char = '\t'; +const LINE_FEED_CHARACTER: char = '\x0A'; +const DELETE_CHARACTER: char = '\x7F'; +const BOM_CHARACTER: char = '\u{FEFF}'; +const NULL_CHARACTER: char = '\x00'; +const UNIT_SEPARATOR_CHARACTER: char = '\u{001F}'; +const APPLICATION_PROGRAM_COMMAND_CHARACTER: char = '\u{009F}'; + +pub fn replace_nonprintable(input: &[u8], tab_width: usize) -> String { + let mut output = String::new(); + + let mut idx = 0; + let len = input.len(); + while idx < len { + if let Some((chr, skip_ahead)) = try_parse_utf8_char(&input[idx..]) { + idx += skip_ahead; + + match chr { + // space + SPACE_CHARACTER => output.push(' '), + // tab + TAB_CHARACTER => output.push_str(&" ".repeat(tab_width)), + // line feed + LINE_FEED_CHARACTER => { + output.push_str("␊\x0A"); + } + // ASCII control characters from 0x00 to 0x1F + NULL_CHARACTER..=UNIT_SEPARATOR_CHARACTER => { + output.push(*NULL_SYMBOL) + } + // control characters from \u{007F} to \u{009F} + DELETE_CHARACTER..=APPLICATION_PROGRAM_COMMAND_CHARACTER => { + output.push(*NULL_SYMBOL) + } + // don't print BOMs + BOM_CHARACTER => {} + // unicode characters above 0x0700 seem unstable with ratatui + c if c > '\u{0700}' => { + output.push(*NULL_SYMBOL); + } + // everything else + c => output.push(c), + } + } else { + write!(output, "\\x{:02X}", input[idx]).ok(); + idx += 1; + } + } + + output +} + +const MAX_LINE_LENGTH: usize = 500; + +pub fn preprocess_line(line: &str) -> String { + replace_nonprintable( + { + if line.len() > MAX_LINE_LENGTH { + slice_up_to_char_boundary(line, MAX_LINE_LENGTH) + } else { + line + } + } + .trim_end_matches(['\r', '\n', '\0']) + .as_bytes(), + 2, + ) +} diff --git a/crates/television/utils/ui.rs b/crates/television/utils/ui.rs new file mode 100644 index 0000000..c2e0f7d --- /dev/null +++ b/crates/television/utils/ui.rs @@ -0,0 +1,24 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +/// helper function to create a centered rect using up certain percentage of the available rect `r` +pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + // Cut the given rectangle into three vertical pieces + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + // Then cut the middle vertical piece into three width-wise pieces + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] // Return the middle chunk +} diff --git a/crates/television_derive/Cargo.lock b/crates/television_derive/Cargo.lock new file mode 100644 index 0000000..8514ea4 --- /dev/null +++ b/crates/television_derive/Cargo.lock @@ -0,0 +1,46 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "channel_derive" +version = "0.1.0" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" diff --git a/crates/television_derive/Cargo.toml b/crates/television_derive/Cargo.toml new file mode 100644 index 0000000..d3f447e --- /dev/null +++ b/crates/television_derive/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "television-derive" +version = "0.1.0" +edition = "2021" + +[dependencies] +proc-macro2 = "1.0.87" +quote = "1.0.37" +syn = "2.0.79" + + +[lib] +proc-macro = true diff --git a/crates/television_derive/src/lib.rs b/crates/television_derive/src/lib.rs new file mode 100644 index 0000000..2e1e689 --- /dev/null +++ b/crates/television_derive/src/lib.rs @@ -0,0 +1,80 @@ +use proc_macro::TokenStream; +use quote::quote; + + +#[proc_macro_derive(CliChannel)] +pub fn cli_channel_derive(input: TokenStream) -> TokenStream { + // Construct a representation of Rust code as a syntax tree + // that we can manipulate + let ast = syn::parse(input).unwrap(); + + // Build the trait implementation + impl_cli_channel(&ast) +} + +fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream { + // check that the struct is an enum + let variants = if let syn::Data::Enum(data_enum) = &ast.data { + &data_enum.variants + } else { + panic!("#[derive(CliChannel)] is only defined for enums"); + }; + + // check that the enum has at least one variant + assert!( + !variants.is_empty(), + "#[derive(CliChannel)] requires at least one variant" + ); + + // create the CliTvChannel enum + let cli_enum_variants = variants.iter().map(|variant| { + let variant_name = &variant.ident; + quote! { + #variant_name + } + }); + let cli_enum = quote! { + use clap::ValueEnum; + + #[derive(Debug, Clone, ValueEnum, Default, Copy)] + pub enum CliTvChannel { + #[default] + #(#cli_enum_variants),* + } + }; + + // Generate the match arms for the `to_channel` method + let arms = variants.iter().map(|variant| { + let variant_name = &variant.ident; + + // Get the inner type of the variant, assuming it is the first field of the variant + if let syn::Fields::Unnamed(fields) = &variant.fields { + if fields.unnamed.len() == 1 { + // Get the inner type of the variant (e.g., EnvChannel) + let inner_type = &fields.unnamed[0].ty; + + quote! { + CliTvChannel::#variant_name => Box::new(#inner_type::new()) + } + } else { + panic!("Enum variants should have exactly one unnamed field."); + } + } else { + panic!("Enum variants expected to only have unnamed fields."); + } + }); + + let gen = quote! { + #cli_enum + + impl CliTvChannel { + pub fn to_channel(self) -> Box { + match self { + #(#arms),* + } + } + } + }; + + gen.into() +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..1d90db4 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +edition = "2021" +max_width = 79 diff --git a/src/action.rs b/src/action.rs deleted file mode 100644 index 2830433..0000000 --- a/src/action.rs +++ /dev/null @@ -1,15 +0,0 @@ -use serde::{Deserialize, Serialize}; -use strum::Display; - -#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)] -pub enum Action { - Tick, - Render, - Resize(u16, u16), - Suspend, - Resume, - Quit, - ClearScreen, - Error(String), - Help, -} diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index 951ca7b..0000000 --- a/src/app.rs +++ /dev/null @@ -1,177 +0,0 @@ -use color_eyre::Result; -use crossterm::event::KeyEvent; -use ratatui::prelude::Rect; -use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc; -use tracing::{debug, info}; - -use crate::{ - action::Action, - components::{fps::FpsCounter, home::Home, Component}, - config::Config, - tui::{Event, Tui}, -}; - -pub struct App { - config: Config, - tick_rate: f64, - frame_rate: f64, - components: Vec>, - should_quit: bool, - should_suspend: bool, - mode: Mode, - last_tick_key_events: Vec, - action_tx: mpsc::UnboundedSender, - action_rx: mpsc::UnboundedReceiver, -} - -#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Mode { - #[default] - Home, -} - -impl App { - pub fn new(tick_rate: f64, frame_rate: f64) -> Result { - let (action_tx, action_rx) = mpsc::unbounded_channel(); - Ok(Self { - tick_rate, - frame_rate, - components: vec![Box::new(Home::new()), Box::new(FpsCounter::default())], - should_quit: false, - should_suspend: false, - config: Config::new()?, - mode: Mode::Home, - last_tick_key_events: Vec::new(), - action_tx, - action_rx, - }) - } - - pub async fn run(&mut self) -> Result<()> { - let mut tui = Tui::new()? - // .mouse(true) // uncomment this line to enable mouse support - .tick_rate(self.tick_rate) - .frame_rate(self.frame_rate); - tui.enter()?; - - for component in self.components.iter_mut() { - component.register_action_handler(self.action_tx.clone())?; - } - for component in self.components.iter_mut() { - component.register_config_handler(self.config.clone())?; - } - for component in self.components.iter_mut() { - component.init(tui.size()?)?; - } - - let action_tx = self.action_tx.clone(); - loop { - self.handle_events(&mut tui).await?; - self.handle_actions(&mut tui)?; - if self.should_suspend { - tui.suspend()?; - action_tx.send(Action::Resume)?; - action_tx.send(Action::ClearScreen)?; - // tui.mouse(true); - tui.enter()?; - } else if self.should_quit { - tui.stop()?; - break; - } - } - tui.exit()?; - Ok(()) - } - - async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { - let Some(event) = tui.next_event().await else { - return Ok(()); - }; - let action_tx = self.action_tx.clone(); - match event { - Event::Quit => action_tx.send(Action::Quit)?, - Event::Tick => action_tx.send(Action::Tick)?, - Event::Render => action_tx.send(Action::Render)?, - Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, - Event::Key(key) => self.handle_key_event(key)?, - _ => {} - } - for component in self.components.iter_mut() { - if let Some(action) = component.handle_events(Some(event.clone()))? { - action_tx.send(action)?; - } - } - Ok(()) - } - - fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { - let action_tx = self.action_tx.clone(); - let Some(keymap) = self.config.keybindings.get(&self.mode) else { - return Ok(()); - }; - match keymap.get(&vec![key]) { - Some(action) => { - info!("Got action: {action:?}"); - action_tx.send(action.clone())?; - } - _ => { - // If the key was not handled as a single key action, - // then consider it for multi-key combinations. - self.last_tick_key_events.push(key); - - // Check for multi-key combinations - if let Some(action) = keymap.get(&self.last_tick_key_events) { - info!("Got action: {action:?}"); - action_tx.send(action.clone())?; - } - } - } - Ok(()) - } - - fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> { - while let Ok(action) = self.action_rx.try_recv() { - if action != Action::Tick && action != Action::Render { - debug!("{action:?}"); - } - match action { - Action::Tick => { - self.last_tick_key_events.drain(..); - } - Action::Quit => self.should_quit = true, - Action::Suspend => self.should_suspend = true, - Action::Resume => self.should_suspend = false, - Action::ClearScreen => tui.terminal.clear()?, - Action::Resize(w, h) => self.handle_resize(tui, w, h)?, - Action::Render => self.render(tui)?, - _ => {} - } - for component in self.components.iter_mut() { - if let Some(action) = component.update(action.clone())? { - self.action_tx.send(action)? - }; - } - } - Ok(()) - } - - fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> { - tui.resize(Rect::new(0, 0, w, h))?; - self.render(tui)?; - Ok(()) - } - - fn render(&mut self, tui: &mut Tui) -> Result<()> { - tui.draw(|frame| { - for component in self.components.iter_mut() { - if let Err(err) = component.draw(frame, frame.area()) { - let _ = self - .action_tx - .send(Action::Error(format!("Failed to draw: {:?}", err))); - } - } - })?; - Ok(()) - } -} diff --git a/src/components.rs b/src/components.rs deleted file mode 100644 index 84c12c9..0000000 --- a/src/components.rs +++ /dev/null @@ -1,125 +0,0 @@ -use color_eyre::Result; -use crossterm::event::{KeyEvent, MouseEvent}; -use ratatui::{ - layout::{Rect, Size}, - Frame, -}; -use tokio::sync::mpsc::UnboundedSender; - -use crate::{action::Action, config::Config, tui::Event}; - -pub mod fps; -pub mod home; - -/// `Component` is a trait that represents a visual and interactive element of the user interface. -/// -/// Implementors of this trait can be registered with the main application loop and will be able to -/// receive events, update state, and be rendered on the screen. -pub trait Component { - /// Register an action handler that can send actions for processing if necessary. - /// - /// # Arguments - /// - /// * `tx` - An unbounded sender that can send actions. - /// - /// # Returns - /// - /// * `Result<()>` - An Ok result or an error. - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - let _ = tx; // to appease clippy - Ok(()) - } - /// Register a configuration handler that provides configuration settings if necessary. - /// - /// # Arguments - /// - /// * `config` - Configuration settings. - /// - /// # Returns - /// - /// * `Result<()>` - An Ok result or an error. - fn register_config_handler(&mut self, config: Config) -> Result<()> { - let _ = config; // to appease clippy - Ok(()) - } - /// Initialize the component with a specified area if necessary. - /// - /// # Arguments - /// - /// * `area` - Rectangular area to initialize the component within. - /// - /// # Returns - /// - /// * `Result<()>` - An Ok result or an error. - fn init(&mut self, area: Size) -> Result<()> { - let _ = area; // to appease clippy - Ok(()) - } - /// Handle incoming events and produce actions if necessary. - /// - /// # Arguments - /// - /// * `event` - An optional event to be processed. - /// - /// # Returns - /// - /// * `Result>` - An action to be processed or none. - fn handle_events(&mut self, event: Option) -> Result> { - let action = match event { - Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, - Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, - _ => None, - }; - Ok(action) - } - /// Handle key events and produce actions if necessary. - /// - /// # Arguments - /// - /// * `key` - A key event to be processed. - /// - /// # Returns - /// - /// * `Result>` - An action to be processed or none. - fn handle_key_event(&mut self, key: KeyEvent) -> Result> { - let _ = key; // to appease clippy - Ok(None) - } - /// Handle mouse events and produce actions if necessary. - /// - /// # Arguments - /// - /// * `mouse` - A mouse event to be processed. - /// - /// # Returns - /// - /// * `Result>` - An action to be processed or none. - fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result> { - let _ = mouse; // to appease clippy - Ok(None) - } - /// Update the state of the component based on a received action. (REQUIRED) - /// - /// # Arguments - /// - /// * `action` - An action that may modify the state of the component. - /// - /// # Returns - /// - /// * `Result>` - An action to be processed or none. - fn update(&mut self, action: Action) -> Result> { - let _ = action; // to appease clippy - Ok(None) - } - /// Render the component on the screen. (REQUIRED) - /// - /// # Arguments - /// - /// * `f` - A frame used for rendering. - /// * `area` - The area in which the component should be drawn. - /// - /// # Returns - /// - /// * `Result<()>` - An Ok result or an error. - fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>; -} diff --git a/src/components/fps.rs b/src/components/fps.rs deleted file mode 100644 index a79c4b4..0000000 --- a/src/components/fps.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::time::Instant; - -use color_eyre::Result; -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Style, Stylize}, - text::Span, - widgets::Paragraph, - Frame, -}; - -use super::Component; - -use crate::action::Action; - -#[derive(Debug, Clone, PartialEq)] -pub struct FpsCounter { - last_tick_update: Instant, - tick_count: u32, - ticks_per_second: f64, - - last_frame_update: Instant, - frame_count: u32, - frames_per_second: f64, -} - -impl Default for FpsCounter { - fn default() -> Self { - Self::new() - } -} - -impl FpsCounter { - pub fn new() -> Self { - Self { - last_tick_update: Instant::now(), - tick_count: 0, - ticks_per_second: 0.0, - last_frame_update: Instant::now(), - frame_count: 0, - frames_per_second: 0.0, - } - } - - fn app_tick(&mut self) -> Result<()> { - self.tick_count += 1; - let now = Instant::now(); - let elapsed = (now - self.last_tick_update).as_secs_f64(); - if elapsed >= 1.0 { - self.ticks_per_second = self.tick_count as f64 / elapsed; - self.last_tick_update = now; - self.tick_count = 0; - } - Ok(()) - } - - fn render_tick(&mut self) -> Result<()> { - self.frame_count += 1; - let now = Instant::now(); - let elapsed = (now - self.last_frame_update).as_secs_f64(); - if elapsed >= 1.0 { - self.frames_per_second = self.frame_count as f64 / elapsed; - self.last_frame_update = now; - self.frame_count = 0; - } - Ok(()) - } -} - -impl Component for FpsCounter { - fn update(&mut self, action: Action) -> Result> { - match action { - Action::Tick => self.app_tick()?, - Action::Render => self.render_tick()?, - _ => {} - }; - Ok(None) - } - - fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { - let [top, _] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area); - let message = format!( - "{:.2} ticks/sec, {:.2} FPS", - self.ticks_per_second, self.frames_per_second - ); - let span = Span::styled(message, Style::new().dim()); - let paragraph = Paragraph::new(span).right_aligned(); - frame.render_widget(paragraph, top); - Ok(()) - } -} diff --git a/src/components/home.rs b/src/components/home.rs deleted file mode 100644 index f6033da..0000000 --- a/src/components/home.rs +++ /dev/null @@ -1,48 +0,0 @@ -use color_eyre::Result; -use ratatui::{prelude::*, widgets::*}; -use tokio::sync::mpsc::UnboundedSender; - -use super::Component; -use crate::{action::Action, config::Config}; - -#[derive(Default)] -pub struct Home { - command_tx: Option>, - config: Config, -} - -impl Home { - pub fn new() -> Self { - Self::default() - } -} - -impl Component for Home { - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.command_tx = Some(tx); - Ok(()) - } - - fn register_config_handler(&mut self, config: Config) -> Result<()> { - self.config = config; - Ok(()) - } - - fn update(&mut self, action: Action) -> Result> { - match action { - Action::Tick => { - // add any logic here that should run on every tick - } - Action::Render => { - // add any logic here that should run on every render - } - _ => {} - } - Ok(None) - } - - fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { - frame.render_widget(Paragraph::new("hello world"), area); - Ok(()) - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 7f3f78b..0000000 --- a/src/main.rs +++ /dev/null @@ -1,25 +0,0 @@ -use clap::Parser; -use cli::Cli; -use color_eyre::Result; - -use crate::app::App; - -mod action; -mod app; -mod cli; -mod components; -mod config; -mod errors; -mod logging; -mod tui; - -#[tokio::main] -async fn main() -> Result<()> { - crate::errors::init()?; - crate::logging::init()?; - - let args = Cli::parse(); - let mut app = App::new(args.tick_rate, args.frame_rate)?; - app.run().await?; - Ok(()) -} diff --git a/src/tui.rs b/src/tui.rs deleted file mode 100644 index 9796e8d..0000000 --- a/src/tui.rs +++ /dev/null @@ -1,235 +0,0 @@ -#![allow(dead_code)] // Remove this once you start using the code - -use std::{ - io::{stdout, Stdout}, - ops::{Deref, DerefMut}, - time::Duration, -}; - -use color_eyre::Result; -use crossterm::{ - cursor, - event::{ - DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent, - }, - terminal::{EnterAlternateScreen, LeaveAlternateScreen}, -}; -use futures::{FutureExt, StreamExt}; -use ratatui::backend::CrosstermBackend as Backend; -use serde::{Deserialize, Serialize}; -use tokio::{ - sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, - task::JoinHandle, - time::interval, -}; -use tokio_util::sync::CancellationToken; -use tracing::error; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum Event { - Init, - Quit, - Error, - Closed, - Tick, - Render, - FocusGained, - FocusLost, - Paste(String), - Key(KeyEvent), - Mouse(MouseEvent), - Resize(u16, u16), -} - -pub struct Tui { - pub terminal: ratatui::Terminal>, - pub task: JoinHandle<()>, - pub cancellation_token: CancellationToken, - pub event_rx: UnboundedReceiver, - pub event_tx: UnboundedSender, - pub frame_rate: f64, - pub tick_rate: f64, - pub mouse: bool, - pub paste: bool, -} - -impl Tui { - pub fn new() -> Result { - let (event_tx, event_rx) = mpsc::unbounded_channel(); - Ok(Self { - terminal: ratatui::Terminal::new(Backend::new(stdout()))?, - task: tokio::spawn(async {}), - cancellation_token: CancellationToken::new(), - event_rx, - event_tx, - frame_rate: 60.0, - tick_rate: 4.0, - mouse: false, - paste: false, - }) - } - - pub fn tick_rate(mut self, tick_rate: f64) -> Self { - self.tick_rate = tick_rate; - self - } - - pub fn frame_rate(mut self, frame_rate: f64) -> Self { - self.frame_rate = frame_rate; - self - } - - pub fn mouse(mut self, mouse: bool) -> Self { - self.mouse = mouse; - self - } - - pub fn paste(mut self, paste: bool) -> Self { - self.paste = paste; - self - } - - pub fn start(&mut self) { - self.cancel(); // Cancel any existing task - self.cancellation_token = CancellationToken::new(); - let event_loop = Self::event_loop( - self.event_tx.clone(), - self.cancellation_token.clone(), - self.tick_rate, - self.frame_rate, - ); - self.task = tokio::spawn(async { - event_loop.await; - }); - } - - async fn event_loop( - event_tx: UnboundedSender, - cancellation_token: CancellationToken, - tick_rate: f64, - frame_rate: f64, - ) { - let mut event_stream = EventStream::new(); - let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); - let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); - - // if this fails, then it's likely a bug in the calling code - event_tx - .send(Event::Init) - .expect("failed to send init event"); - loop { - let event = tokio::select! { - _ = cancellation_token.cancelled() => { - break; - } - _ = tick_interval.tick() => Event::Tick, - _ = render_interval.tick() => Event::Render, - crossterm_event = event_stream.next().fuse() => match crossterm_event { - Some(Ok(event)) => match event { - CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), - CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), - CrosstermEvent::Resize(x, y) => Event::Resize(x, y), - CrosstermEvent::FocusLost => Event::FocusLost, - CrosstermEvent::FocusGained => Event::FocusGained, - CrosstermEvent::Paste(s) => Event::Paste(s), - _ => continue, // ignore other events - } - Some(Err(_)) => Event::Error, - None => break, // the event stream has stopped and will not produce any more events - }, - }; - if event_tx.send(event).is_err() { - // the receiver has been dropped, so there's no point in continuing the loop - break; - } - } - cancellation_token.cancel(); - } - - pub fn stop(&self) -> Result<()> { - self.cancel(); - let mut counter = 0; - while !self.task.is_finished() { - std::thread::sleep(Duration::from_millis(1)); - counter += 1; - if counter > 50 { - self.task.abort(); - } - if counter > 100 { - error!("Failed to abort task in 100 milliseconds for unknown reason"); - break; - } - } - Ok(()) - } - - pub fn enter(&mut self) -> Result<()> { - crossterm::terminal::enable_raw_mode()?; - crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; - if self.mouse { - crossterm::execute!(stdout(), EnableMouseCapture)?; - } - if self.paste { - crossterm::execute!(stdout(), EnableBracketedPaste)?; - } - self.start(); - Ok(()) - } - - pub fn exit(&mut self) -> Result<()> { - self.stop()?; - if crossterm::terminal::is_raw_mode_enabled()? { - self.flush()?; - if self.paste { - crossterm::execute!(stdout(), DisableBracketedPaste)?; - } - if self.mouse { - crossterm::execute!(stdout(), DisableMouseCapture)?; - } - crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; - crossterm::terminal::disable_raw_mode()?; - } - Ok(()) - } - - pub fn cancel(&self) { - self.cancellation_token.cancel(); - } - - pub fn suspend(&mut self) -> Result<()> { - self.exit()?; - #[cfg(not(windows))] - signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; - Ok(()) - } - - pub fn resume(&mut self) -> Result<()> { - self.enter()?; - Ok(()) - } - - pub async fn next_event(&mut self) -> Option { - self.event_rx.recv().await - } -} - -impl Deref for Tui { - type Target = ratatui::Terminal>; - - fn deref(&self) -> &Self::Target { - &self.terminal - } -} - -impl DerefMut for Tui { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.terminal - } -} - -impl Drop for Tui { - fn drop(&mut self) { - self.exit().unwrap(); - } -}