diff --git a/Cargo.lock b/Cargo.lock index 8d3fec9..fe804b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,6 +256,27 @@ dependencies = [ "syn", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "document-features" version = "0.2.11" @@ -291,9 +312,9 @@ dependencies = [ [[package]] name = "endian-type" -version = "0.1.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" [[package]] name = "equivalent" @@ -318,15 +339,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e" dependencies = [ "libc", - "thiserror", + "thiserror 1.0.65", "winapi", ] [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -339,9 +360,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -349,15 +370,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -366,15 +387,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -383,21 +404,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -407,10 +428,20 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "globset" version = "0.4.18" @@ -505,9 +536,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -517,9 +548,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "indexmap" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", @@ -543,6 +574,15 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -607,6 +647,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.2" @@ -636,12 +682,6 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "proc-macro2" version = "1.0.101" @@ -653,9 +693,9 @@ dependencies = [ [[package]] name = "promkit" -version = "0.10.1" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d786bdb1a975eb52e53b4f85171968ef0ffe7321dd273d25422868a3a66633d" +checksum = "fc91580f8270f76ddf0a149da730fdbfaf2ca115718083f25ef767ac43dfe20a" dependencies = [ "anyhow", "async-trait", @@ -681,15 +721,16 @@ dependencies = [ [[package]] name = "promkit-widgets" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7ef81079760b198d5dde773c78b94a72edc2ebd057be386382c379e0d854fb6" +checksum = "0fe0c1c9d4a39811769fc7787df265e5705d2749fd6d768e1166b6dbced2927c" dependencies = [ "anyhow", "promkit-core", "rayon", "serde", "serde_json", + "termcfg", "tokio", ] @@ -704,9 +745,9 @@ dependencies = [ [[package]] name = "radix_trie" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" dependencies = [ "endian-type", "nibble_vec", @@ -741,6 +782,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -783,12 +835,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "ryu" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" - [[package]] name = "scopeguard" version = "1.2.0" @@ -797,18 +843,28 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -817,15 +873,25 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "indexmap", "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", ] [[package]] @@ -860,28 +926,29 @@ dependencies = [ [[package]] name = "sigrs" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "clap", + "dirs", "grep", "promkit", "promkit-core", "promkit-widgets", "rayon", "regex", + "serde", "strip-ansi-escapes", + "termcfg", "tokio", + "toml", ] [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -925,6 +992,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "termcfg" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e444ec0de07571e26f31f6ac5006c03540b61d35bec105031e82c6d6cbb5fab" +dependencies = [ + "crossterm", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -940,7 +1018,16 @@ version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.65", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -954,6 +1041,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio" version = "1.49.0" @@ -982,6 +1080,45 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -996,9 +1133,9 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "utf8parse" @@ -1148,3 +1285,15 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index f3387c9..6f9a52f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigrs" -version = "0.2.1" +version = "0.3.0" authors = ["ynqa "] edition = "2021" description = "Interactive grep (for streaming)" @@ -15,16 +15,21 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.102" clap = { version = "4.5.60", features = ["derive"] } +dirs = "6.0.0" grep = "0.4.1" -promkit = "0.10.1" +promkit = { version = "0.11.1", default-features = false } promkit-core = "0.2.0" -promkit-widgets = { version = "0.2.0", features = ["texteditor", "listbox"] } +promkit-widgets = { version = "0.3.1", features = ["texteditor", "listbox"], default-features = false } rayon = "1.11.0" regex = "1.12.3" +serde = { version = "1.0.228", features = ["derive"] } strip-ansi-escapes = "0.2.1" +termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] } tokio = { version = "1.49.0", features = ["full"] } +toml = "0.9.8" # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" -lto = "thin" +codegen-units = 1 +lto = true diff --git a/README.md b/README.md index e11fb28..becd32a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Interactive grep -|![sig.gif](https://github.com/ynqa/ynqa/blob/master/demo/sig.gif)|![sig_archived.gif](https://github.com/ynqa/ynqa/blob/master/demo/sig_archived.gif)| +|![sig.gif](https://github.com/ynqa/ynqa/blob/master/demo/sig.gif)|![sig.static.gif](https://github.com/ynqa/ynqa/blob/master/demo/sig.static.gif)| |---|---| ## Features @@ -28,8 +28,8 @@ Interactive grep and it is possible to switch to a mode where you can grep through these N entries based on key inputs at any given moment. - - Additionally, by starting in this mode, - it is also possible to grep through static data such as files. + - For static data such as files, *sig* automatically switches + to archived mode when the input reaches EOF. - like [ugrep](https://github.com/Genivia/ugrep) with `-Q` option. ## Installation @@ -76,7 +76,7 @@ nix shell github:ynqa/sig Or run it directly: ```nix -cat README.md | nix run github:ynqa/sig -- --archived +cat README.md | nix run github:ynqa/sig ``` ### Nix (classic) @@ -107,14 +107,9 @@ in stern --context kind-kind etcd |& sig # or sig --cmd "stern --context kind-kind etcd" # this is able to retry command by ctrl+r. -``` - -### Archived mode -```bash -cat README.md |& sig -a -# or -sig -a --cmd "cat README.md" +# or static input (switches to archived view after EOF) +cat README.md |& sig ``` ## Keymap @@ -123,6 +118,7 @@ sig -a --cmd "cat README.md" | :- | :- | Ctrl + C | Exit `sig` | Ctrl + R | Retry command if `--cmd` is specified +| Ctrl + S | Pause/Resume stream ingestion | Ctrl + F | Enter Archived mode | | Move the cursor one character to the left | | Move the cursor one character to the right @@ -156,10 +152,8 @@ $ stern --context kind-kind etcd |& sig Or the method to retry command by pressing ctrl+r: $ sig --cmd "stern --context kind-kind etcd" -Archived mode: -$ cat README.md |& sig -a -Or -$ sig -a --cmd "cat README.md" +Static input (switches to archived view after EOF): +$ cat README.md |& sig Options: --retrieval-timeout @@ -168,16 +162,78 @@ Options: Interval to render a line in milliseconds. -q, --queue-capacity Queue capacity to store lines. [default: 1000] - -a, --archived - Archived mode to grep through static data. -i, --ignore-case Case insensitive search. --cmd Command to execute on initial and retries. -Q, --query Initial query. + -c, --config + Path to the configuration file. -h, --help Print help (see more with '--help') -V, --version Print version ``` + +## Configuration + +
+The following settings are available in config.toml + +```toml +# Style for matched substrings +highlight_style = "fg=red" + +[streaming.editor] +# Query prompt while streaming +prefix = "❯❯ " +prefix_style = "fg=darkgreen" +active_char_style = "bg=darkcyan" +inactive_char_style = "" +# lines = + +[streaming.keybinds] +exit = ["Ctrl+C"] +goto_archived = ["Ctrl+F"] +retry = ["Ctrl+R"] +toggle_pause = ["Ctrl+S"] + +[streaming.keybinds.editor] +backward = ["Left"] +forward = ["Right"] +move_to_head = ["Ctrl+A"] +move_to_tail = ["Ctrl+E"] +erase = ["Backspace"] +erase_all = ["Ctrl+U"] + +[archived.editor] +# Query prompt in archived mode +prefix = "❯❯❯ " +prefix_style = "fg=darkblue" +active_char_style = "bg=darkcyan" +inactive_char_style = "" +# lines = + +[archived.listbox] +cursor = "❯ " +# active_item_style = +# inactive_item_style = +# lines = + +[archived.keybinds] +exit = ["Ctrl+C"] +retry = ["Ctrl+R"] +up = ["Up", "ScrollUp"] +down = ["Down", "ScrollDown"] + +[archived.keybinds.editor] +backward = ["Left"] +forward = ["Right"] +move_to_head = ["Ctrl+A"] +move_to_tail = ["Ctrl+E"] +erase = ["Backspace"] +erase_all = ["Ctrl+U"] +``` + +
diff --git a/assets/sig.static.tape b/assets/sig.static.tape new file mode 100644 index 0000000..d92f4a1 --- /dev/null +++ b/assets/sig.static.tape @@ -0,0 +1,12 @@ +Output assets/sig.static.gif + +Set Shell "bash" +Set Theme "Dracula" +Set FontSize 28 +Set Width 1800 +Set Height 1200 + +Type@50ms "cat README.md | sig" Sleep 1s +Enter Sleep 3s +Type@100ms "sig" Sleep 3s +Type@100ms " | Interactive" Sleep 3s diff --git a/assets/sig.tape b/assets/sig.tape new file mode 100644 index 0000000..e0c065c --- /dev/null +++ b/assets/sig.tape @@ -0,0 +1,12 @@ +Output assets/sig.gif + +Set Shell "bash" +Set Theme "Dracula" +Set PlaybackSpeed 0.5 +Set FontSize 28 +Set Width 1800 +Set Height 1200 + +Type@20ms "kubectl -n kube-system logs etcd-kind-control-plane | sig --render-interval 50" Sleep 300ms +Enter Sleep 2s +Type@100ms "info" Sleep 4s diff --git a/default.toml b/default.toml new file mode 100644 index 0000000..e23ea90 --- /dev/null +++ b/default.toml @@ -0,0 +1,52 @@ +# Style for matched substrings +highlight_style = "fg=red" + +[streaming.editor] +# Query prompt while streaming +prefix = "❯❯ " +prefix_style = "fg=darkgreen" +active_char_style = "bg=darkcyan" +inactive_char_style = "" +# lines = + +[streaming.keybinds] +exit = ["Ctrl+C"] +goto_archived = ["Ctrl+F"] +retry = ["Ctrl+R"] +toggle_pause = ["Ctrl+S"] + +[streaming.keybinds.editor] +backward = ["Left"] +forward = ["Right"] +move_to_head = ["Ctrl+A"] +move_to_tail = ["Ctrl+E"] +erase = ["Backspace"] +erase_all = ["Ctrl+U"] + +[archived.editor] +# Query prompt in archived mode +prefix = "❯❯❯ " +prefix_style = "fg=darkblue" +active_char_style = "bg=darkcyan" +inactive_char_style = "" +# lines = + +[archived.listbox] +cursor = "❯ " +# active_item_style = +# inactive_item_style = +# lines = + +[archived.keybinds] +exit = ["Ctrl+C"] +retry = ["Ctrl+R"] +up = ["Up", "ScrollUp"] +down = ["Down", "ScrollDown"] + +[archived.keybinds.editor] +backward = ["Left"] +forward = ["Right"] +move_to_head = ["Ctrl+A"] +move_to_tail = ["Ctrl+E"] +erase = ["Backspace"] +erase_all = ["Ctrl+U"] diff --git a/src/archived.rs b/src/archived.rs index e50ef42..5d5bcce 100644 --- a/src/archived.rs +++ b/src/archived.rs @@ -16,7 +16,10 @@ use promkit_widgets::{ text_editor, }; -use crate::highlight::highlight; +use crate::{ + config::{matches_keybind, ArchivedKeybinds}, + highlight::highlight, +}; #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] enum Index { @@ -36,94 +39,65 @@ struct Archived { highlight_style: ContentStyle, case_insensitive: bool, cmd: Option, + keybinds: ArchivedKeybinds, } impl Archived { fn evaluate_internal(&mut self, event: &Event) -> anyhow::Result { - match event { - Event::Key(KeyEvent { - code: KeyCode::Char('r'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => { - if self.cmd.is_some() { - // Exiting archive mode here allows - // the caller to re-enter streaming mode, - // as it is running in an infinite loop. - return Ok(promkit::Signal::Quit); - } + if matches_keybind(event, &self.keybinds.retry) { + if self.cmd.is_some() { + // Exiting archive mode here allows + // the caller to re-enter streaming mode, + // as it is running in an infinite loop. + return Ok(promkit::Signal::Quit); } + } - Event::Key(KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => return Err(anyhow::anyhow!("ctrl+c")), + if matches_keybind(event, &self.keybinds.exit) { + return Err(anyhow::anyhow!("exit")); + } - // Move cursor (text editor) - Event::Key(KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => { - self.readline.texteditor.backward(); - } - Event::Key(KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => { - self.readline.texteditor.forward(); - } - Event::Key(KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => self.readline.texteditor.move_to_head(), - Event::Key(KeyEvent { - code: KeyCode::Char('e'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => self.readline.texteditor.move_to_tail(), + if matches_keybind(event, &self.keybinds.editor.backward) { + self.readline.texteditor.backward(); + return Ok(promkit::Signal::Continue); + } - // Move cursor (listbox). - Event::Key(KeyEvent { - code: KeyCode::Up, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => { - self.text.listbox.backward(); - } - Event::Key(KeyEvent { - code: KeyCode::Down, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => { - self.text.listbox.forward(); - } + if matches_keybind(event, &self.keybinds.editor.forward) { + self.readline.texteditor.forward(); + return Ok(promkit::Signal::Continue); + } - // Erase char(s). - Event::Key(KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => self.readline.texteditor.erase(), - Event::Key(KeyEvent { - code: KeyCode::Char('u'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => self.readline.texteditor.erase_all(), + if matches_keybind(event, &self.keybinds.editor.move_to_head) { + self.readline.texteditor.move_to_head(); + return Ok(promkit::Signal::Continue); + } + + if matches_keybind(event, &self.keybinds.editor.move_to_tail) { + self.readline.texteditor.move_to_tail(); + return Ok(promkit::Signal::Continue); + } + + if matches_keybind(event, &self.keybinds.up) { + self.text.listbox.backward(); + return Ok(promkit::Signal::Continue); + } + if matches_keybind(event, &self.keybinds.down) { + self.text.listbox.forward(); + return Ok(promkit::Signal::Continue); + } + + if matches_keybind(event, &self.keybinds.editor.erase) { + self.readline.texteditor.erase(); + return Ok(promkit::Signal::Continue); + } + + if matches_keybind(event, &self.keybinds.editor.erase_all) { + self.readline.texteditor.erase_all(); + return Ok(promkit::Signal::Continue); + } + + match event { // Input char. Event::Key(KeyEvent { code: KeyCode::Char(ch), @@ -136,7 +110,7 @@ impl Archived { modifiers: KeyModifiers::SHIFT, kind: KeyEventKind::Press, state: KeyEventState::NONE, - }) => match self.readline.edit_mode { + }) => match self.readline.config.edit_mode { text_editor::Mode::Insert => self.readline.texteditor.insert(*ch), text_editor::Mode::Overwrite => self.readline.texteditor.overwrite(*ch), }, @@ -202,6 +176,7 @@ pub async fn run( readline: text_editor::State, text: listbox::State, highlight_style: ContentStyle, + keybinds: ArchivedKeybinds, case_insensitive: bool, cmd: Option, ) -> anyhow::Result<()> { @@ -222,6 +197,7 @@ pub async fn run( highlight_style, case_insensitive, cmd, + keybinds, } .run() .await diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..3eb9cc3 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,105 @@ +use std::collections::HashSet; + +use promkit_core::crossterm::{ + event::{Event, KeyEvent, MouseEvent}, + style::ContentStyle, +}; +use promkit_widgets::{listbox, text_editor}; +use serde::{Deserialize, Serialize}; +use termcfg::crossterm_config::{content_style_serde, event_set_serde}; + +pub static DEFAULT_CONFIG: &str = include_str!("../default.toml"); + +#[derive(Clone, Serialize, Deserialize)] +pub struct EditorKeybinds { + #[serde(with = "event_set_serde")] + pub backward: HashSet, + #[serde(with = "event_set_serde")] + pub forward: HashSet, + #[serde(with = "event_set_serde")] + pub move_to_head: HashSet, + #[serde(with = "event_set_serde")] + pub move_to_tail: HashSet, + #[serde(with = "event_set_serde")] + pub erase: HashSet, + #[serde(with = "event_set_serde")] + pub erase_all: HashSet, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct StreamingKeybinds { + #[serde(with = "event_set_serde")] + pub exit: HashSet, + #[serde(with = "event_set_serde")] + pub goto_archived: HashSet, + #[serde(with = "event_set_serde")] + pub retry: HashSet, + #[serde(with = "event_set_serde")] + pub toggle_pause: HashSet, + pub editor: EditorKeybinds, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct ArchivedKeybinds { + #[serde(with = "event_set_serde")] + pub exit: HashSet, + #[serde(with = "event_set_serde")] + pub retry: HashSet, + #[serde(with = "event_set_serde")] + pub up: HashSet, + #[serde(with = "event_set_serde")] + pub down: HashSet, + pub editor: EditorKeybinds, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct StreamingConfig { + pub editor: text_editor::Config, + pub keybinds: StreamingKeybinds, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct ArchivedConfig { + pub editor: text_editor::Config, + pub listbox: listbox::Config, + pub keybinds: ArchivedKeybinds, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Config { + pub streaming: StreamingConfig, + pub archived: ArchivedConfig, + #[serde(with = "content_style_serde")] + pub highlight_style: ContentStyle, +} + +impl Config { + pub fn load_from(content: &str) -> anyhow::Result { + toml::from_str(content).map_err(Into::into) + } +} + +pub fn matches_keybind(event: &Event, keybinds: &HashSet) -> bool { + let normalized = match event { + Event::Key(key) => Event::Key(KeyEvent::new(key.code, key.modifiers)), + Event::Mouse(mouse) => Event::Mouse(MouseEvent { + kind: mouse.kind, + column: 0, + row: 0, + modifiers: mouse.modifiers, + }), + other => other.clone(), + }; + + keybinds.contains(&normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_is_valid_toml() { + Config::load_from(DEFAULT_CONFIG).expect("default.toml must be valid"); + } +} diff --git a/src/main.rs b/src/main.rs index 11ab459..16e7a05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,11 @@ -use std::{collections::VecDeque, io}; +use std::{io, io::Write, path::PathBuf}; +use anyhow::anyhow; use clap::Parser; -use tokio::{ - sync::mpsc, - time::{timeout, Duration}, -}; +use tokio::time::Duration; use promkit_core::crossterm::{ self, cursor, execute, - style::{Color, ContentStyle}, terminal::{disable_raw_mode, enable_raw_mode}, }; use promkit_widgets::{ @@ -17,7 +14,11 @@ use promkit_widgets::{ }; mod archived; +mod config; +use config::{Config, DEFAULT_CONFIG}; mod highlight; +mod mouse; +use mouse::{DisableAlternateScrollCapture, EnableAlternateScrollCapture}; mod sig; mod spawn; mod terminal; @@ -45,10 +46,8 @@ $ stern --context kind-kind etcd |& sig Or the method to retry command by pressing ctrl+r: $ sig --cmd \"stern --context kind-kind etcd\" -Archived mode: -$ cat README.md |& sig -a -Or -$ sig -a --cmd \"cat README.md\" +Static input (switches to archived view after EOF): +$ cat README.md |& sig Options: {options} @@ -84,14 +83,6 @@ pub struct Args { )] pub queue_capacity: usize, - #[arg( - short = 'a', - long = "archived", - default_value = "false", - help = "Archived mode to grep through static data." - )] - pub archived: bool, - #[arg( short = 'i', long = "ignore-case", @@ -116,170 +107,143 @@ pub struct Args { in the text editor when the program starts." )] pub query: Option, + + #[arg(short = 'c', long = "config", help = "Path to the configuration file.")] + pub config_file: Option, } impl Drop for Args { fn drop(&mut self) { - disable_raw_mode().ok(); - execute!(io::stdout(), cursor::Show).ok(); + let _ = leave_terminal(); } } -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = Args::parse(); +/// Ensure that the specified file exists. +/// If it does not exist, creates the file and its parent directories if necessary, +/// and writes the default configuration content to it. +fn ensure_file_exists(path: &PathBuf) -> anyhow::Result<()> { + if path.exists() { + return Ok(()); + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| anyhow!("Failed to create directory: {e}"))?; + } + std::fs::File::create(path)?.write_all(DEFAULT_CONFIG.as_bytes())?; + Ok(()) +} + +/// Determine the configuration file path. +fn determine_config_file(config_path: Option) -> anyhow::Result { + if let Some(path) = config_path { + ensure_file_exists(&path)?; + return Ok(path); + } + + let default_path = dirs::config_dir() + .ok_or_else(|| anyhow!("Failed to determine the configuration directory"))? + .join("sig") + .join("config.toml"); + + ensure_file_exists(&default_path)?; + Ok(default_path) +} + +/// Enter the alternate screen and enable alternate scroll capture mode. +fn enter_terminal() -> anyhow::Result<()> { enable_raw_mode()?; - execute!(io::stdout(), cursor::Hide)?; - - let highlight_style = ContentStyle { - foreground_color: Some(Color::Red), - ..Default::default() - }; - - if args.archived { - let (tx, mut rx) = mpsc::channel(1); - - let input_task = match &args.cmd { - Some(cmd) => spawn::spawn_cmd_result_sender( - cmd, - tx, - Duration::from_millis(args.retrieval_timeout_millis), - ), - None => { - spawn::spawn_stdin_sender(tx, Duration::from_millis(args.retrieval_timeout_millis)) - } - }?; - - let mut queue = VecDeque::with_capacity(args.queue_capacity); - loop { - match timeout( - Duration::from_millis(args.retrieval_timeout_millis), - rx.recv(), - ) - .await - { - Ok(Some(line)) => { - if queue.len() > args.queue_capacity { - queue.pop_front().unwrap(); - } - queue.push_back(line.clone()); - } - Ok(None) => break, - Err(_) => break, - } - } + execute!( + io::stdout(), + crossterm::terminal::EnterAlternateScreen, + EnableAlternateScrollCapture, + cursor::Hide + )?; + Ok(()) +} - // Stop the input task - input_task.handle.abort(); +/// Leave the alternate screen and disable alternate scroll capture mode. +fn leave_terminal() -> anyhow::Result<()> { + disable_raw_mode()?; + execute!( + io::stdout(), + DisableAlternateScrollCapture, + crossterm::terminal::LeaveAlternateScreen, + cursor::Show + )?; + Ok(()) +} +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let config = determine_config_file(args.config_file.clone()) + .and_then(|config_file| { + std::fs::read_to_string(&config_file) + .map_err(|e| anyhow!("Failed to read configuration file: {e}")) + }) + .and_then(|content| Config::load_from(&content)) + .unwrap_or_else(|_e| { + Config::load_from(DEFAULT_CONFIG).expect("Failed to load default configuration") + }); + + enter_terminal()?; + + while let Ok((signal, queue)) = sig::run( + text_editor::State { + texteditor: TextEditor::new(args.query.clone().unwrap_or_default()), + history: Default::default(), + config: config.streaming.editor.clone(), + }, + config.highlight_style, + config.streaming.keybinds.clone(), + Duration::from_millis(args.retrieval_timeout_millis), + args.render_interval_millis.map(Duration::from_millis), + args.queue_capacity, + args.case_insensitive, + args.cmd.clone(), + ) + .await + { crossterm::execute!( io::stdout(), crossterm::terminal::Clear(crossterm::terminal::ClearType::All), cursor::MoveTo(0, 0), )?; - archived::run( - text_editor::State { - texteditor: TextEditor::new(args.query.clone().unwrap_or_default()), - prefix: String::from("❯❯❯ "), - prefix_style: ContentStyle { - foreground_color: Some(Color::DarkBlue), - ..Default::default() - }, - active_char_style: ContentStyle { - background_color: Some(Color::DarkCyan), - ..Default::default() - }, - ..Default::default() - }, - listbox::State { - listbox: listbox::Listbox::from_displayable(queue), - cursor: String::from("❯ "), - active_item_style: None, - inactive_item_style: None, - lines: Default::default(), - }, - highlight_style, - args.case_insensitive, - // In archived mode, command for retry is meaningless. - None, - ) - .await?; - } else { - while let Ok((signal, queue)) = sig::run( - text_editor::State { - texteditor: TextEditor::new(args.query.clone().unwrap_or_default()), - prefix: String::from("❯❯ "), - prefix_style: ContentStyle { - foreground_color: Some(Color::DarkGreen), - ..Default::default() - }, - active_char_style: ContentStyle { - background_color: Some(Color::DarkCyan), - ..Default::default() - }, - ..Default::default() - }, - highlight_style, - Duration::from_millis(args.retrieval_timeout_millis), - args.render_interval_millis.map(Duration::from_millis), - args.queue_capacity, - args.case_insensitive, - args.cmd.clone(), - ) - .await - { - crossterm::execute!( - io::stdout(), - crossterm::terminal::Clear(crossterm::terminal::ClearType::All), - cursor::MoveTo(0, 0), - )?; - - match signal { - Signal::GotoArchived => { - archived::run( - text_editor::State { - prefix: String::from("❯❯❯ "), - prefix_style: ContentStyle { - foreground_color: Some(Color::DarkBlue), - ..Default::default() - }, - active_char_style: ContentStyle { - background_color: Some(Color::DarkCyan), - ..Default::default() - }, - ..Default::default() - }, - listbox::State { - listbox: listbox::Listbox::from_displayable(queue), - cursor: String::from("❯ "), - active_item_style: None, - inactive_item_style: None, - lines: Default::default(), - }, - highlight_style, - args.case_insensitive, - args.cmd.clone(), - ) - .await?; - - // Re-enable raw mode and hide the cursor again here - // because they are disabled and shown, respectively, by promkit. - enable_raw_mode()?; - execute!(io::stdout(), cursor::Hide)?; - - crossterm::execute!( - io::stdout(), - crossterm::terminal::Clear(crossterm::terminal::ClearType::All), - cursor::MoveTo(0, 0), - )?; - } - Signal::GotoStreaming => { - continue; - } - _ => {} + match signal { + Signal::GotoArchived => { + archived::run( + text_editor::State { + texteditor: TextEditor::new(String::new()), + history: Default::default(), + config: config.archived.editor.clone(), + }, + listbox::State { + listbox: listbox::Listbox::from(queue), + config: config.archived.listbox.clone(), + }, + config.highlight_style, + config.archived.keybinds.clone(), + args.case_insensitive, + args.cmd.clone(), + ) + .await?; + + // Re-enable raw mode and hide the cursor again here + // because they are disabled and shown, respectively, by promkit. + enter_terminal()?; + + crossterm::execute!( + io::stdout(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::All), + cursor::MoveTo(0, 0), + )?; + } + Signal::GotoStreaming => { + continue; } + _ => {} } } diff --git a/src/mouse.rs b/src/mouse.rs new file mode 100644 index 0000000..1871d15 --- /dev/null +++ b/src/mouse.rs @@ -0,0 +1,63 @@ +use std::fmt; + +use promkit_core::crossterm::Command; + +/// Enable xterm alternate scroll mode (`CSI ? 1007 h`). +/// +/// This avoids capturing click events while allowing wheel input to be +/// translated into cursor up/down on the alternate screen. +/// +/// NOTE: +/// This mode is intended to be used together with +/// `crossterm::terminal::EnterAlternateScreen` (`CSI ? 1049 h`). +/// In the normal screen buffer, terminal scrollback usually takes +/// precedence and wheel input may not be forwarded to the application. +/// +/// References: +/// - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EnableAlternateScrollCapture; + +impl Command for EnableAlternateScrollCapture { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(concat!( + // Reset all related modes first. + "\x1b[?1007l", + "\x1b[?1016l", + "\x1b[?1006l", + "\x1b[?1015l", + "\x1b[?1003l", + "\x1b[?1002l", + "\x1b[?1000l", + // Enable alternate scroll mode only. + "\x1b[?1007h", + )) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DisableAlternateScrollCapture; + +impl Command for DisableAlternateScrollCapture { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str(concat!( + "\x1b[?1007l", + "\x1b[?1016l", + "\x1b[?1006l", + "\x1b[?1015l", + "\x1b[?1003l", + "\x1b[?1002l", + "\x1b[?1000l", + )) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} diff --git a/src/sig.rs b/src/sig.rs index 77f4b9f..cad58c8 100644 --- a/src/sig.rs +++ b/src/sig.rs @@ -1,7 +1,10 @@ -use std::{collections::VecDeque, sync::Arc}; +use std::{ + collections::{HashSet, VecDeque}, + sync::Arc, +}; use tokio::{ - sync::{mpsc, RwLock}, + sync::{mpsc, watch, RwLock}, task::JoinHandle, time::{self, Duration}, }; @@ -10,90 +13,110 @@ use promkit_core::{ crossterm::{ self, event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, - style::ContentStyle, + style::{Color, ContentStyle}, }, + pane::Pane, PaneFactory, }; -use promkit_widgets::text_editor; +use promkit_widgets::{text, text_editor}; +use termcfg::event::{event_def::EventDef, format::event_to_shortcut}; + +use crate::{ + config::{matches_keybind, StreamingKeybinds}, + highlight::highlight, + spawn, + terminal::Terminal, + Signal, +}; + +enum InputAction { + Continue, + TogglePause, + GotoArchived, + GotoStreaming, +} -use crate::{highlight::highlight, spawn, terminal::Terminal, Signal}; +#[derive(Clone)] +struct HintKeybindLabels { + archived: String, + pause_resume: String, + retry: Option, + exit: String, +} + +fn format_keybinds(events: &HashSet) -> String { + let mut labels = events + .iter() + .filter_map(|event| EventDef::try_from(event).ok().map(event_to_shortcut)) + .collect::>(); + labels.sort(); + labels.dedup(); + labels.join("/") +} + +fn create_hint_keybind_labels(keybinds: &StreamingKeybinds, has_cmd: bool) -> HintKeybindLabels { + HintKeybindLabels { + archived: format_keybinds(&keybinds.goto_archived), + pause_resume: format_keybinds(&keybinds.toggle_pause), + retry: has_cmd.then(|| format_keybinds(&keybinds.retry)), + exit: format_keybinds(&keybinds.exit), + } +} -// Evaluate a key event and return the corresponding Signal. +// Evaluate a key event and return the corresponding InputAction. fn evaluate_event( event: &Event, state: &mut text_editor::State, - cmd: Option, -) -> anyhow::Result { - match event { - Event::Key(KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => return Ok(Signal::GotoArchived), + has_cmd: bool, + keybinds: &StreamingKeybinds, +) -> anyhow::Result { + if matches_keybind(event, &keybinds.goto_archived) { + return Ok(InputAction::GotoArchived); + } - Event::Key(KeyEvent { - code: KeyCode::Char('r'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => { - if cmd.is_some() { - return Ok(Signal::GotoStreaming); - } - } + if has_cmd && matches_keybind(event, &keybinds.retry) { + return Ok(InputAction::GotoStreaming); + } - Event::Key(KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => return Err(anyhow::anyhow!("ctrl+c")), + if matches_keybind(event, &keybinds.toggle_pause) { + return Ok(InputAction::TogglePause); + } - // Move cursor. - Event::Key(KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => { - state.texteditor.backward(); - } - Event::Key(KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => { - state.texteditor.forward(); - } - Event::Key(KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => state.texteditor.move_to_head(), - Event::Key(KeyEvent { - code: KeyCode::Char('e'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => state.texteditor.move_to_tail(), + if matches_keybind(event, &keybinds.exit) { + return Err(anyhow::anyhow!("exit")); + } - // Erase char(s). - Event::Key(KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => state.texteditor.erase(), - Event::Key(KeyEvent { - code: KeyCode::Char('u'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => state.texteditor.erase_all(), + if matches_keybind(event, &keybinds.editor.backward) { + state.texteditor.backward(); + return Ok(InputAction::Continue); + } + + if matches_keybind(event, &keybinds.editor.forward) { + state.texteditor.forward(); + return Ok(InputAction::Continue); + } + + if matches_keybind(event, &keybinds.editor.move_to_head) { + state.texteditor.move_to_head(); + return Ok(InputAction::Continue); + } + + if matches_keybind(event, &keybinds.editor.move_to_tail) { + state.texteditor.move_to_tail(); + return Ok(InputAction::Continue); + } + + if matches_keybind(event, &keybinds.editor.erase) { + state.texteditor.erase(); + return Ok(InputAction::Continue); + } + if matches_keybind(event, &keybinds.editor.erase_all) { + state.texteditor.erase_all(); + return Ok(InputAction::Continue); + } + + match event { // Input char. Event::Key(KeyEvent { code: KeyCode::Char(ch), @@ -106,19 +129,52 @@ fn evaluate_event( modifiers: KeyModifiers::SHIFT, kind: KeyEventKind::Press, state: KeyEventState::NONE, - }) => match state.edit_mode { + }) => match state.config.edit_mode { text_editor::Mode::Insert => state.texteditor.insert(*ch), text_editor::Mode::Overwrite => state.texteditor.overwrite(*ch), }, _ => (), } - Ok(Signal::Continue) + Ok(InputAction::Continue) +} + +fn create_panes( + text_editor: &text_editor::State, + size: (u16, u16), + paused: bool, + keybind_labels: &HintKeybindLabels, +) -> Vec { + let badge = if paused { "[PAUSED]" } else { "[RUNNING]" }; + let retry_hint = match &keybind_labels.retry { + Some(retry) => format!(" | Retry({retry})"), + None => String::new(), + }; + let hint = text::State { + text: text::Text::from(format!( + "{badge} Archived({}) | Pause/Resume({}){} | Exit({})", + keybind_labels.archived, keybind_labels.pause_resume, retry_hint, keybind_labels.exit + )), + config: text::Config { + style: Some(ContentStyle { + foreground_color: Some(Color::DarkGrey), + ..Default::default() + }), + lines: Some(1), + }, + ..Default::default() + }; + + vec![ + text_editor.create_pane(size.0, size.1), + hint.create_pane(size.0, size.1), + ] } pub async fn run( text_editor: text_editor::State, highlight_style: ContentStyle, + keybinds: StreamingKeybinds, retrieval_timeout: Duration, render_interval: Option, queue_capacity: usize, @@ -126,15 +182,19 @@ pub async fn run( cmd: Option, ) -> anyhow::Result<(Signal, VecDeque)> { let size = crossterm::terminal::size()?; + let has_cmd = cmd.is_some(); + let keybind_labels = create_hint_keybind_labels(&keybinds, has_cmd); - let pane = text_editor.create_pane(size.0, size.1); - let mut term = Terminal::new(&pane)?; - term.draw_pane(&pane)?; + let panes = create_panes(&text_editor, size, false, &keybind_labels); + let term = Terminal::try_new(size, &panes)?; + term.draw_pane(&panes)?; let shared_term = Arc::new(RwLock::new(term)); let shared_text_editor = Arc::new(RwLock::new(text_editor)); let readonly_term = Arc::clone(&shared_term); let readonly_text_editor = Arc::clone(&shared_text_editor); + let (pause_tx, mut pause_rx) = watch::channel(false); + let keybind_labels_for_task = keybind_labels.clone(); let (tx, mut rx) = mpsc::channel(1); @@ -146,55 +206,95 @@ pub async fn run( let keeping: JoinHandle>> = tokio::spawn(async move { let mut queue = VecDeque::with_capacity(queue_capacity); let mut maybe_interval = render_interval.map(|p| time::interval(p)); + let mut paused = false; loop { + if paused { + if pause_rx.changed().await.is_err() { + break; + } + paused = *pause_rx.borrow_and_update(); + continue; + } + if let Some(interval) = &mut maybe_interval { interval.tick().await; } - match rx.recv().await { - Some(line) => { - let text_editor = readonly_text_editor.read().await; - let size = crossterm::terminal::size()?; - if queue.len() > queue_capacity { - queue.pop_front().unwrap(); + tokio::select! { + biased; + changed = pause_rx.changed() => { + if changed.is_err() { + break; } - queue.push_back(line.clone()); - - if let Some(highlighted) = highlight( - &text_editor.texteditor.text_without_cursor().to_string(), - &line, - highlight_style, - case_insensitive, - ) { - let matrix = highlighted.matrixify(size.0 as usize, size.1 as usize, 0).0; - let term = readonly_term.read().await; - term.draw_stream_and_pane( - matrix, - &text_editor.create_pane(size.0, size.1), - )?; + paused = *pause_rx.borrow_and_update(); + } + maybe_line = rx.recv() => { + match maybe_line { + Some(line) => { + let text_editor = readonly_text_editor.read().await; + let size = crossterm::terminal::size()?; + + if queue.len() > queue_capacity { + queue.pop_front().unwrap(); + } + queue.push_back(line.clone()); + + if let Some(highlighted) = highlight( + &text_editor.texteditor.text_without_cursor().to_string(), + &line, + highlight_style, + case_insensitive, + ) { + let matrix = highlighted.matrixify(size.0 as usize, size.1 as usize, 0).0; + let panes = + create_panes(&text_editor, size, paused, &keybind_labels_for_task); + let mut term = readonly_term.write().await; + let pane_rows = Terminal::pane_rows(size, &panes); + if term.sync_layout(size, pane_rows)? { + term.draw_pane(&panes)?; + } + term.draw_stream(&matrix)?; + } + } + None => break, } } - None => break, } } Ok(queue) }); - let mut signal: Signal; - loop { + let mut paused = false; + let signal = loop { + // Treat an exhausted input source as archived data. + if keeping.is_finished() { + break Signal::GotoArchived; + } + + if !event::poll(retrieval_timeout)? { + continue; + } + let event = event::read()?; let mut text_editor = shared_text_editor.write().await; - signal = evaluate_event(&event, &mut text_editor, cmd.clone())?; - if signal == Signal::GotoArchived || signal == Signal::GotoStreaming { - break; + let action = evaluate_event(&event, &mut text_editor, has_cmd, &keybinds)?; + match action { + InputAction::GotoArchived => break Signal::GotoArchived, + InputAction::GotoStreaming => break Signal::GotoStreaming, + InputAction::TogglePause => { + paused = !paused; + let _ = pause_tx.send(paused); + } + InputAction::Continue => {} } let size = crossterm::terminal::size()?; - let pane = text_editor.create_pane(size.0, size.1); + let panes = create_panes(&text_editor, size, paused, &keybind_labels); let mut term = shared_term.write().await; - term.draw_pane(&pane)?; - } + term.sync_layout(size, Terminal::pane_rows(size, &panes))?; + term.draw_pane(&panes)?; + }; if let Some(mut child) = input_task.child { let _ = child.kill().await; diff --git a/src/spawn.rs b/src/spawn.rs index b73774c..381cebd 100644 --- a/src/spawn.rs +++ b/src/spawn.rs @@ -70,30 +70,43 @@ pub fn spawn_cmd_result_sender( Ok(InputTask { handle: tokio::spawn(async move { + let mut stdout_closed = false; + let mut stderr_closed = false; + loop { + if stdout_closed && stderr_closed { + break; + } + tokio::select! { - stdout_res = timeout(retrieval_timeout, stdout_reader.next_line()) => { + stdout_res = timeout(retrieval_timeout, stdout_reader.next_line()), if !stdout_closed => { match stdout_res { Ok(Ok(Some(line))) => { let escaped = strip_ansi_escapes::strip_str(line.replace(['\n', '\t'], " ")); tx.send(escaped).await?; }, - // Don't break on stdout end, continue to read stderr (maybe) - _ => continue, + Ok(Ok(None)) => stdout_closed = true, + Ok(Err(err)) => return Err(err.into()), + // ignore timeout and continue + Err(_) => continue, } }, - stderr_res = timeout(retrieval_timeout, stderr_reader.next_line()) => { + stderr_res = timeout(retrieval_timeout, stderr_reader.next_line()), if !stderr_closed => { match stderr_res { Ok(Ok(Some(line))) => { let escaped = strip_ansi_escapes::strip_str(line.replace(['\n', '\t'], " ")); tx.send(escaped).await?; }, - // Don't break on stdout end, continue to read stdout (maybe) - _ => continue, + Ok(Ok(None)) => stderr_closed = true, + Ok(Err(err)) => return Err(err.into()), + // ignore timeout and continue + Err(_) => continue, } } } } + + Ok(()) }), child: Some(child), }) diff --git a/src/terminal.rs b/src/terminal.rs index b543acc..a65f404 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -7,72 +7,186 @@ use promkit_core::{ }; pub struct Terminal { - anchor_position: (u16, u16), + size: (u16, u16), + pane_rows: u16, +} + +/// Reset the scroll region to the entire terminal. +fn reset_scroll_region_sequence() -> &'static str { + crossterm::csi!("r") +} + +/// Set the scroll region to [top, bottom], where both are 1-based. +fn set_scroll_region_sequence(top_1based: u16, bottom_1based: u16) -> String { + format!(crossterm::csi!("{};{}r"), top_1based, bottom_1based) } impl Terminal { - pub fn new(pane: &Pane) -> anyhow::Result { - let mut offset_from_bottom = terminal::size()?; - offset_from_bottom.1 = offset_from_bottom - .1 - .saturating_sub(1 + pane.visible_row_count() as u16); - - Ok(Self { - anchor_position: (0, offset_from_bottom.1), - }) + /// Create a new Terminal instance and apply the initial scroll region. + pub fn try_new(size: (u16, u16), panes: &[Pane]) -> anyhow::Result { + let term = Self { + size, + pane_rows: Self::pane_rows(size, panes), + }; + term.apply_scroll_region()?; + io::stdout().flush()?; + Ok(term) } - pub fn draw_stream_and_pane( - &self, - items: Vec, - pane: &Pane, - ) -> anyhow::Result<()> { - let coefficient = items.len().saturating_sub(1) as u16; + /// Draw the stream content, which is displayed below the pane. + pub fn draw_stream(&self, items: &[StyledGraphemes]) -> anyhow::Result<()> { + let stream_height = self.stream_height(); + if items.is_empty() || stream_height == 0 { + io::stdout().flush()?; + return Ok(()); + } + + // With a 1-line stream area (e.g. terminal height 3), + // render directly instead of scrolling to keep pane rows stable. + if stream_height == 1 { + let row = items.last().expect("checked non-empty items"); + crossterm::queue!( + io::stdout(), + cursor::MoveTo(0, self.stream_top()), + terminal::Clear(terminal::ClearType::CurrentLine), + style::Print(row.styled_display()), + )?; + io::stdout().flush()?; + return Ok(()); + } + + let visible_rows = items.len().min(stream_height as usize); + let start = items.len().saturating_sub(visible_rows); + // Note: This view intentionally keeps only the tail of `items` that fits in the stream area. + // The trade-off is that older rows are dropped from the current frame + // when incoming data exceeds the stream height. + // In this realtime UI, we accept that loss because such overflow already exceeds + // what a human can read at once + // and tail-first rendering keeps behavior predictable under high throughput. + // + // If users need to re-check past matches, guide them to Archived mode (Ctrl+F). + let rows = &items[start..]; + let scroll_rows = rows.len() as u16; + let write_from = self.size.1.saturating_sub(scroll_rows); + crossterm::queue!( io::stdout(), - cursor::MoveTo( - self.anchor_position.0, - self.anchor_position.1.saturating_sub(coefficient) - ), - terminal::ScrollUp(1 + coefficient), - terminal::Clear(terminal::ClearType::FromCursorDown), + cursor::MoveTo(0, self.stream_top()), + terminal::ScrollUp(scroll_rows), )?; + for (idx, row) in rows.iter().enumerate() { + crossterm::queue!( + io::stdout(), + cursor::MoveTo(0, write_from + idx as u16), + terminal::Clear(terminal::ClearType::CurrentLine), + style::Print(row.styled_display()), + )?; + } - for item in items.iter() { + io::stdout().flush()?; + Ok(()) + } + + /// Draw the pane content. + /// This should be called after syncing the layout to ensure the pane area is correctly sized. + pub fn draw_pane(&self, panes: &[Pane]) -> anyhow::Result<()> { + for y in 0..self.pane_rows { crossterm::queue!( io::stdout(), - style::Print(item.styled_display()), - cursor::MoveToNextLine(1) + cursor::MoveTo(0, y), + terminal::Clear(terminal::ClearType::CurrentLine), )?; } + let mut y = 0u16; + for pane in panes { + if y >= self.pane_rows { + break; + } + + let viewport_height = (self.pane_rows - y) as usize; + for row in pane.extract(viewport_height) { + if y >= self.pane_rows { + break; + } + crossterm::queue!( + io::stdout(), + cursor::MoveTo(0, y), + style::Print(row.styled_display()), + )?; + y += 1; + } + } + io::stdout().flush()?; - self.draw(pane) + Ok(()) } - pub fn draw_pane(&mut self, pane: &Pane) -> anyhow::Result<()> { - let size = terminal::size()?; - crossterm::queue!( - io::stdout(), - cursor::MoveTo(self.anchor_position.0, self.anchor_position.1 + 1), - terminal::Clear(terminal::ClearType::FromCursorDown), - )?; - self.anchor_position.1 = size.1.saturating_sub(1 + pane.visible_row_count() as u16); - self.draw(pane) + /// Sync the terminal layout with the given size and pane rows. + /// Returns true if the layout was changed and the pane needs to be redrawn. + pub fn sync_layout(&mut self, size: (u16, u16), pane_rows: u16) -> anyhow::Result { + let pane_rows = pane_rows.min(size.1); + if self.size == size && self.pane_rows == pane_rows { + return Ok(false); + } + + self.size = size; + self.pane_rows = pane_rows; + self.apply_scroll_region()?; + self.clear_stream_area()?; + Ok(true) } - fn draw(&self, pane: &Pane) -> anyhow::Result<()> { - crossterm::queue!( - io::stdout(), - cursor::MoveTo(self.anchor_position.0, self.anchor_position.1 + 1), - terminal::Clear(terminal::ClearType::FromCursorDown), - )?; + pub fn pane_rows(size: (u16, u16), panes: &[Pane]) -> u16 { + panes + .iter() + .fold(0usize, |acc, pane| { + acc.saturating_add(pane.visible_row_count()) + }) + .min(size.1 as usize) as u16 + } + + fn stream_top(&self) -> u16 { + self.pane_rows + } + + fn stream_height(&self) -> u16 { + self.size.1.saturating_sub(self.pane_rows) + } + + fn clear_stream_area(&self) -> anyhow::Result<()> { + for y in self.stream_top()..self.size.1 { + crossterm::queue!( + io::stdout(), + cursor::MoveTo(0, y), + terminal::Clear(terminal::ClearType::CurrentLine), + )?; + } + Ok(()) + } - for row in pane.extract(pane.visible_row_count()) { - crossterm::queue!(io::stdout(), style::Print(row.styled_display()))?; + /// Apply the scroll region to the stream area, excluding the pane area. + fn apply_scroll_region(&self) -> anyhow::Result<()> { + if self.stream_height() == 0 { + crossterm::queue!(io::stdout(), style::Print(reset_scroll_region_sequence()),)?; + return Ok(()); } - io::stdout().flush()?; + let top = self.stream_top() + 1; + let bottom = self.size.1; + // Exclude the pane area from the scroll region, + // so that only the stream area is scrolled when new lines are added. + crossterm::queue!( + io::stdout(), + style::Print(set_scroll_region_sequence(top, bottom)), + )?; Ok(()) } } + +impl Drop for Terminal { + fn drop(&mut self) { + let _ = crossterm::queue!(io::stdout(), style::Print(reset_scroll_region_sequence())); + let _ = io::stdout().flush(); + } +}