From d621a0a65760e9b41cddc6b435c3e2a38a8ec0aa Mon Sep 17 00:00:00 2001 From: Lucas Colombo Date: Tue, 17 Sep 2024 03:43:44 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=E2=9C=A8=20tui=20framework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/img/logo-cli-tui.svg | 18 + Cargo.lock | 692 ++++++++++++++++++++++++++++- Cargo.toml | 35 +- lib/cli/mod.rs | 3 + lib/cli/tui/README.md | 24 + lib/cli/tui/framework/app.rs | 184 ++++++++ lib/cli/tui/framework/component.rs | 351 +++++++++++++++ lib/cli/tui/framework/events.rs | 44 ++ lib/cli/tui/framework/keyboard.rs | 217 +++++++++ lib/cli/tui/framework/tui.rs | 233 ++++++++++ lib/cli/tui/mod.rs | 46 ++ 11 files changed, 1824 insertions(+), 23 deletions(-) create mode 100644 .github/img/logo-cli-tui.svg create mode 100644 lib/cli/tui/README.md create mode 100644 lib/cli/tui/framework/app.rs create mode 100644 lib/cli/tui/framework/component.rs create mode 100644 lib/cli/tui/framework/events.rs create mode 100644 lib/cli/tui/framework/keyboard.rs create mode 100644 lib/cli/tui/framework/tui.rs create mode 100644 lib/cli/tui/mod.rs diff --git a/.github/img/logo-cli-tui.svg b/.github/img/logo-cli-tui.svg new file mode 100644 index 0000000..5741f04 --- /dev/null +++ b/.github/img/logo-cli-tui.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 11e0971..8c91779 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,24 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -32,6 +50,15 @@ dependencies = [ "libc", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "autocfg" version = "1.2.0" @@ -55,9 +82,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bumpalo" @@ -65,6 +92,33 @@ version = "3.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytes" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.0.90" @@ -79,9 +133,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", @@ -91,6 +145,20 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "compact_str" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6050c3a16ddab2e412160b31f2c871015704239bca62f72f6e5f0be631d3f644" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation-sys" version = "0.8.6" @@ -99,13 +167,61 @@ checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] name = "croner" -version = "2.0.4" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516aad5374ea0ea75a0f0f4512fb4e7ad46c5eeff9971cb8ebc8fd74f1cd16c1" +checksum = "eba3aaaafb3c313b352ff02626adb2dfaf9663159d339a878f6e5b2f6259a97c" dependencies = [ "chrono", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "eyre" version = "0.6.12" @@ -116,6 +232,101 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "gimli" version = "0.28.1" @@ -128,6 +339,28 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "iana-time-zone" version = "0.1.60" @@ -157,6 +390,31 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +[[package]] +name = "instability" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + [[package]] name = "js-sys" version = "0.3.69" @@ -168,15 +426,31 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.153" +version = "0.2.158" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] [[package]] name = "log" -version = "0.4.21" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "lool" @@ -185,11 +459,27 @@ dependencies = [ "bitflags", "chrono", "croner", + "crossterm", + "downcast-rs", "eyre", + "futures", "glob-match", "log", "num-traits", + "palette", + "ratatui", + "strum", "tokio", + "tokio-util", +] + +[[package]] +name = "lru" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ee39891760e7d94734f6f63fedc29a2e4a152f836120753a72503f09fcf904" +dependencies = [ + "hashbrown", ] [[package]] @@ -208,10 +498,23 @@ dependencies = [ ] [[package]] -name = "num-traits" -version = "0.2.18" +name = "mio" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi", + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -231,12 +534,113 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "palette_derive", + "phf", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" 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.79" @@ -255,12 +659,167 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" +dependencies = [ + "bitflags", +] + [[package]] name = "rustc-demangle" version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustix" +version = "0.38.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.58" @@ -274,9 +833,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.37.0" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "pin-project-lite", @@ -285,21 +844,69 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +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.92" @@ -354,6 +961,28 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.52.0" @@ -363,6 +992,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-targets" version = "0.52.4" @@ -419,3 +1057,23 @@ name = "windows_x86_64_msvc" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 5dd7cc4..aede35d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,9 +22,25 @@ debug-assertions = false path = "lib/lib.rs" [features] -# cli utilities +# cli/tui utilities "cli" = [] "cli.stylize" = ["cli", "dep:bitflags"] +"cli.tui" = [ + "cli", + "dep:ratatui", + "dep:palette", + "dep:crossterm", + "dep:strum", + "dep:downcast-rs", + "dep:futures", + "dep:tokio", + "crossterm?/event-stream", + "strum?/derive", + "tokio?/tokio-macros", + "tokio?/macros", + "tokio?/sync", + "tokio?/time", +] # logging "logger" = ["dep:log", "dep:glob-match"] # macros @@ -52,13 +68,20 @@ path = "lib/lib.rs" eyre = { version = "0.6.12", default-features = false } # optional -bitflags = { version = "2.5.0", optional = true } +bitflags = { version = "2.6.0", optional = true } chrono = { version = "0.4.37", optional = true } -log = { version = "0.4.21", optional = true } -tokio = { version = "1.37.0", optional = true } -croner = { version = "2.0.4", optional = true } -num-traits = { version = "0.2.18", optional = true } +log = { version = "0.4.22", optional = true } +tokio = { version = "1.40.0", optional = true } +croner = { version = "2.0.5", optional = true } +num-traits = { version = "0.2.19", optional = true } glob-match = { version = "0.2.1", optional = true } +tokio-util = { version = "0.7.12", optional = true } +ratatui = { version = "0.28.1", optional = true } +palette = { version = "0.7.6", optional = true } +crossterm = { version="0.28.1", optional = true} +strum = { version="0.26.1", optional = true } +downcast-rs = { version="1.2.1", optional = true} +futures = { version = "0.3.30", optional = true } [[example]] diff --git a/lib/cli/mod.rs b/lib/cli/mod.rs index 07e9900..39f2d96 100644 --- a/lib/cli/mod.rs +++ b/lib/cli/mod.rs @@ -1,2 +1,5 @@ #[cfg(feature = "cli.stylize")] pub mod stylize; + +#[cfg(feature = "cli.tui")] +pub mod tui; diff --git a/lib/cli/tui/README.md b/lib/cli/tui/README.md new file mode 100644 index 0000000..74dbea7 --- /dev/null +++ b/lib/cli/tui/README.md @@ -0,0 +1,24 @@ +

+ +
+
+
+ +

lool ยป cli.stylize is a set of utilities for colorizing console outputs. +

+ +
+
+
+ +# Installation + +This crate is for internal use. It's only published privately. + +```bash +cargo add lool --registry=lugit --features cli cli.tui +``` + +# Usage + +Pending. \ No newline at end of file diff --git a/lib/cli/tui/framework/app.rs b/lib/cli/tui/framework/app.rs new file mode 100644 index 0000000..34830fa --- /dev/null +++ b/lib/cli/tui/framework/app.rs @@ -0,0 +1,184 @@ +use { + super::{ + component::Component, + events::{Action, Event}, + keyboard::KeyBindings, + tui::Tui, + }, + crossterm::event::{KeyCode, KeyEvent}, + eyre::Result, + ratatui::prelude::Rect, + std::{collections::HashMap, str::FromStr}, + tokio::sync::mpsc::{self, error::TryRecvError}, +}; + +pub type Kb<'a> = HashMap<&'a str, String>; + +pub struct App { + pub tick_rate: f64, + pub frame_rate: f64, + pub components: Vec>, + pub should_quit: bool, + pub should_suspend: bool, + pub keybindings: KeyBindings, + pub last_tick_key_events: Vec, + action_tx: mpsc::UnboundedSender, + action_rx: mpsc::UnboundedReceiver, +} + +impl App { + pub fn new(kb: Kb, components: Vec>) -> Result { + let keybindings = KeyBindings::new(kb); + let (action_tx, action_rx) = mpsc::unbounded_channel::(); + + Ok(Self { + tick_rate: 1.into(), + frame_rate: 4.into(), + action_tx, + action_rx, + components, + keybindings, + should_quit: false, + should_suspend: false, + last_tick_key_events: Vec::new(), + }) + } + + fn send(&self, action: Action) -> Result<()> { + match action { + Action::AppAction(cmd) => self.action_tx.send(cmd)?, + Action::Key(key) => self.action_tx.send(key)?, + action => self.action_tx.send(action.to_string())?, + } + + Ok(()) + } + + fn try_recv(&mut self) -> Result { + self.action_rx.try_recv() + } + + pub async fn run(&mut self) -> Result<()> { + let mut tui = Tui::new()?.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.init(tui.size()?)?; + } + + loop { + if let Some(e) = tui.next().await { + match e { + Event::Quit => self.send(Action::Quit)?, + Event::Tick => self.send(Action::Tick)?, + Event::Render => self.send(Action::Render)?, + // Event::Resize(x, y) => self.send(Action::Resize(x, y))?, + Event::Key(key) => { + if let Some(action) = self.keybindings.get(&vec![key]) { + self.send(action.clone())?; + } else { + // 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) = self.keybindings.get(&self.last_tick_key_events) { + self.send(action.clone())?; + } + } + + // send the key event as simple key event too (not as action) if it's a + // single alphanumeric char key + if let KeyCode::Char(c) = key.code { + if c.is_alphanumeric() { + self.send(Action::Key(c.to_string()))?; + } + } + } + _ => {} + } + let mut actions = Vec::new(); + + for component in self.components.iter_mut() { + let component_actions = component.handle_events(Some(e.clone()))?; + actions.extend(component_actions); + } + + for action in actions { + self.send(action)?; + } + } + + while let Ok(action) = self.try_recv() { + let enum_action = Action::from_str(&action).ok(); + if let Some(a) = enum_action { + match a { + 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::Resize(w, h) => { + tui.resize(Rect::new(0, 0, w, h))?; + let mut errors = Vec::new(); + tui.draw(|f| { + for component in self.components.iter_mut() { + let r = component.draw(f, f.area()); + if let Err(e) = r { + errors.push(format!("Failed to draw: {:?}", e)); + } + } + })?; + for error in errors { + self.send(Action::Error(error)).unwrap(); + } + } + Action::Render => { + let mut errors = Vec::new(); + tui.draw(|f| { + for component in self.components.iter_mut() { + let r = component.draw(f, f.area()); + if let Err(e) = r { + errors.push(format!("Failed to draw: {:?}", e)); + } + } + })?; + for error in errors { + self.send(Action::Error(error)).unwrap(); + } + } + _ => {} + } + + for component in self.components.iter_mut() { + component.update(a.clone())?; + } + } else { + // unrecognized action, might be a custom component action + // send it to all components as a raw string + for component in self.components.iter_mut() { + let _ = component.receive_message(action.clone()); + } + } + } + if self.should_suspend { + tui.suspend()?; + self.send(Action::Resume)?; + tui = Tui::new()?.tick_rate(self.tick_rate).frame_rate(self.frame_rate); + // tui.mouse(true); + tui.enter()?; + } else if self.should_quit { + tui.stop()?; + break; + } + } + tui.exit()?; + Ok(()) + } +} diff --git a/lib/cli/tui/framework/component.rs b/lib/cli/tui/framework/component.rs new file mode 100644 index 0000000..602df7d --- /dev/null +++ b/lib/cli/tui/framework/component.rs @@ -0,0 +1,351 @@ +use { + super::{ + events::{Action, Event}, + tui::Frame, + }, + crossterm::event::{KeyEvent, MouseEvent}, + downcast_rs::{impl_downcast, Downcast}, + eyre::Result, + ratatui::layout::{Rect, Size}, + std::collections::HashMap, + tokio::sync::mpsc::UnboundedSender, +}; + +pub type Children = HashMap>; + +// TODO: create a component! macro to simplify the creation of components, adding the boilerplate +// code, similar to what we do with the `rustler!` macro in the `rustler` crate. + +/// `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: Downcast { + /// 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. + #[allow(unused_variables)] + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + pass_action_handler_to_children(self, tx) + } + + /// Initialize the component with a specified area if necessary. + /// By default, this method will pass the initialization to the children. If you want to + /// override this method, you will lose calling the children's `init` method. That's why we + /// provide a helper function to initialize the children. Something like the following is + /// recommended: + /// + /// ```rust + /// fn init(&mut self, area: Rect) -> Result<()> { + /// // Do something with the area + /// + /// // Initialize the children + /// init_children(self, area) + /// } + /// ``` + /// + /// # Arguments + /// + /// * `area` - Rectangular area to initialize the component within. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + #[allow(unused)] + fn init(&mut self, area: Size) -> Result<()> { + init_children(self, area) + } + + /// Handle incoming events and produce actions if necessary. + /// In most cases, you should avoid overriding this method, as it will handle the children's + /// events and also rerout actions to `self.handle_key_events` and `self.handle_mouse_events`. + /// + /// In most cases, you might want to implement those two methods instead of this one. + /// + /// # 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 mut actions = vec![]; + + let action = match event { + Some(Event::Key(key_event)) => self.handle_key_events(key_event)?, + Some(Event::Mouse(mouse_event)) => self.handle_mouse_events(mouse_event)?, + _ => None, + }; + + if let Some(action) = action { + actions.push(action); + } + + if let Some(children) = self.get_children() { + for child in children.values_mut() { + let child_actions = child.handle_events(event.clone())?; + actions.extend(child_actions); + } + } + + Ok(actions) + } + + /// 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. + #[allow(unused_variables)] + fn handle_key_events(&mut self, key: KeyEvent) -> Result> { + 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. + #[allow(unused_variables)] + fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result> { + Ok(None) + } + + /// Update the state of the component based on a received action. If you want to override this + /// method, you will lose calling the children's update methods. That's why we provide a helper + /// function to update the children's state. Something like the following is recommended: + /// + /// ```rust + /// fn update(&mut self, action: Action) -> Result<()> { + /// // Do something with the action + /// + /// // Update the children + /// update_children(self, action) + /// } + /// ``` + /// + /// # Arguments + /// + /// * `action` - An action that may modify the state of the component. + /// + /// # Returns + /// + /// * `Result>` - An action to be processed or none. + #[allow(unused_variables)] + fn update(&mut self, action: Action) -> Result<()> { + update_children(self, action) + } + + /// Receive a custom message, probably from another component. + /// If you want to override this method, you will lose calling the children's `receive_message` + /// method. That's why we provide a helper function to pass the message. Something like the + /// following is recommended: + /// + /// ```rust + /// fn receive_message(&mut self, message: String) -> Result<()> { + /// // Do something with the message + /// + /// // Pass the message to the children + /// pass_message_to_children(self, message) + /// } + /// ``` + /// # Arguments + /// + /// * `message` - A string message to be processed. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + #[allow(unused_variables)] + fn receive_message(&mut self, message: String) -> Result<()> { + pass_message_to_children(self, message) + } + + /// 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, f: &mut Frame<'_>, area: Rect) -> Result<()>; + + /// Get a child component by name as a mutable reference. + /// + /// # Arguments + /// * `name` - The name of the child component. + /// + /// # Returns + /// * `Option<&mut Box>` - A mutable reference to the child component or none. + fn child_mut(&mut self, name: &str) -> Option<&mut Box> { + if let Some(children) = self.get_children() { + children.get_mut(name) + } else { + None + } + } + + /// Get a child component by name as an immutable reference. + /// + /// # Arguments + /// * `name` - The name of the child component. + /// + /// # Returns + /// * `Option<&Box>` - A reference to the child component or none. + fn child(&mut self, name: &str) -> Option<&Box> { + if let Some(children) = self.get_children() { + children.get(name) + } else { + None + } + } + + /// get all child components. This is necessary if the component has children, as will be + /// used by other functions to have knowledge of the children. + /// + /// # Attributes + /// * `children`: `HashMap>` - All child components. + /// + /// # Returns + /// + /// * `Vec[&mut Box]` - A vector of mutable references to the child components. + fn get_children(&mut self) -> Option<&mut Children> { + None + } + + /// gets the active state of the component. + /// + /// # Returns + /// * `bool` - The active state of the component. + fn is_active(&self) -> bool { + true + } + + /// Set the active state of the component. + /// + /// # Arguments + /// * `active` - The active state of the component. + fn set_active(&mut self, _active: bool) {} +} + +impl_downcast!(Component); + +/// Update the children's state based on a received action. +/// +/// This helper function is used to update the children's state based on a received action. It was +/// created to allow to easily override the default `update` method of a component implementation and +/// be able to call the children's `update` method. +pub fn update_children(this: &mut T, action: Action) -> Result<()> { + if let Some(children) = this.get_children() { + for child in children.values_mut() { + child.update(action.clone())?; + } + } + + Ok(()) +} + +/// Pass a message to the children of a component. +/// +/// This helper function is used to pass a message to the children of a component. It was created +/// to allow to easily override the default `receive_message` method of a component implementation +/// and be able pass the call to the children's `receive_message` method. +pub fn pass_message_to_children( + this: &mut T, + message: String, +) -> Result<()> { + if let Some(children) = this.get_children() { + for child in children.values_mut() { + child.receive_message(message.clone())?; + } + } + + Ok(()) +} + +/// Initialize the children of a component. +/// +/// This helper function is used to initialize the children of a component. It was created to +/// allow to easily override the default `init` method of a component implementation and be able +/// to call the children's `init` method. +pub fn init_children(this: &mut T, area: Size) -> Result<()> { + if let Some(children) = this.get_children() { + for child in children.values_mut() { + child.init(area)?; + } + } + + Ok(()) +} + +/// Pass the action handler to the children of a component. +/// +/// This helper function is used to pass the action handler to the children of a component. It was +/// created to allow to easily override the default `register_action_handler` method of a component +/// implementation and be able to call the children's `register_action_handler` method. +pub fn pass_action_handler_to_children( + this: &mut T, + tx: UnboundedSender, +) -> Result<()> { + if let Some(children) = this.get_children() { + for child in children.values_mut() { + child.register_action_handler(tx.clone())?; + } + } + + Ok(()) +} + +/// Get a child downcasted to a specific type by name as a mutable reference. +/// +/// # Arguments +/// * `name` - The name of the child component. +/// +/// # Returns +/// * `Option<&mut T>` - A mutable reference to the child component or none. +pub fn child_downcast_mut<'a, CastTo: Component, This: Component + ?Sized>( + this: &'a mut This, + name: &str, +) -> Option<&'a mut CastTo> { + if let Some(child) = this.child_mut(name) { + child.downcast_mut::() + } else { + None + } +} + +/// Get a child downcasted to a specific type by name as an immutable reference. +/// +/// # Arguments +/// * `name` - The name of the child component. +/// +/// # Returns +/// * `Option<&T>` - A reference to the child component or none. +pub fn child_downcast<'a, CastTo: Component, This: Component + ?Sized>( + this: &'a mut This, + name: &str, +) -> Option<&'a CastTo> { + if let Some(child) = this.child(name) { + child.downcast_ref::() + } else { + None + } +} diff --git a/lib/cli/tui/framework/events.rs b/lib/cli/tui/framework/events.rs new file mode 100644 index 0000000..29bae78 --- /dev/null +++ b/lib/cli/tui/framework/events.rs @@ -0,0 +1,44 @@ +use { + crossterm::event::{KeyEvent, MouseEvent}, + std::fmt::{Display, Formatter, Result}, + strum::EnumString, +}; + +#[derive(Debug, PartialEq, Eq, Clone, EnumString)] +pub enum Action { + Tick, + Render, + Resize(u16, u16), + Suspend, + Resume, + Quit, + Refresh, + Error(String), + Help, + Version, + AppAction(String), + Key(String), +} + +impl Display for Action { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let enum_str = write!(f, "{:?}", self); + enum_str + } +} + +#[derive(Clone, Debug)] +pub enum Event { + Init, + Quit, + Error, + Closed, + Tick, + Render, + FocusGained, + FocusLost, + Paste(String), + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} diff --git a/lib/cli/tui/framework/keyboard.rs b/lib/cli/tui/framework/keyboard.rs new file mode 100644 index 0000000..00e8819 --- /dev/null +++ b/lib/cli/tui/framework/keyboard.rs @@ -0,0 +1,217 @@ +use { + super::events::Action, + crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, + eyre::Result, + std::{collections::HashMap, str::FromStr}, +}; + +#[derive(Clone, Debug, Default)] +pub struct Config { + pub keybindings: KeyBindings, +} + +#[derive(Clone, Debug, Default)] +pub struct KeyBindings(pub HashMap, Action>); + +impl KeyBindings { + pub fn new(raw: HashMap<&str, String>) -> Self { + let keybindings = raw + .into_iter() + .map(|(key_str, cmd)| { + let action = Action::from_str(&cmd); + + match action { + Ok(action) => (parse_key_sequence(&key_str).unwrap(), action), + Err(_) => (parse_key_sequence(&key_str).unwrap(), Action::AppAction(cmd)), + } + }) + .collect(); + + KeyBindings(keybindings) + } + + pub fn get(&self, key_events: &[KeyEvent]) -> Option<&Action> { + self.0.get(key_events) + } +} + +fn parse_key_event(raw: &str) -> Result { + let raw_lower = raw.to_ascii_lowercase(); + let (remaining, modifiers) = extract_modifiers(&raw_lower); + parse_key_code_with_modifiers(remaining, modifiers) +} + +fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { + let mut modifiers = KeyModifiers::empty(); + let mut current = raw; + + loop { + match current { + rest if rest.starts_with("ctrl-") => { + modifiers.insert(KeyModifiers::CONTROL); + current = &rest[5..]; + } + rest if rest.starts_with("alt-") => { + modifiers.insert(KeyModifiers::ALT); + current = &rest[4..]; + } + rest if rest.starts_with("shift-") => { + modifiers.insert(KeyModifiers::SHIFT); + current = &rest[6..]; + } + _ => break, // break out of the loop if no known prefix is detected + }; + } + + (current, modifiers) +} + +fn parse_key_code_with_modifiers( + raw: &str, + mut modifiers: KeyModifiers, +) -> Result { + let c = match raw { + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "backtab" => { + modifiers.insert(KeyModifiers::SHIFT); + KeyCode::BackTab + } + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "f1" => KeyCode::F(1), + "f2" => KeyCode::F(2), + "f3" => KeyCode::F(3), + "f4" => KeyCode::F(4), + "f5" => KeyCode::F(5), + "f6" => KeyCode::F(6), + "f7" => KeyCode::F(7), + "f8" => KeyCode::F(8), + "f9" => KeyCode::F(9), + "f10" => KeyCode::F(10), + "f11" => KeyCode::F(11), + "f12" => KeyCode::F(12), + "space" => KeyCode::Char(' '), + "hyphen" => KeyCode::Char('-'), + "minus" => KeyCode::Char('-'), + "tab" => KeyCode::Tab, + c if c.len() == 1 => { + let mut c = c.chars().next().unwrap(); + if modifiers.contains(KeyModifiers::SHIFT) { + c = c.to_ascii_uppercase(); + } + KeyCode::Char(c) + } + _ => return Err(format!("Unable to parse {raw}")), + }; + Ok(KeyEvent::new(c, modifiers)) +} + +pub fn key_event_to_string(key_event: &KeyEvent) -> String { + let char; + let key_code = match key_event.code { + KeyCode::Backspace => "backspace", + KeyCode::Enter => "enter", + KeyCode::Left => "left", + KeyCode::Right => "right", + KeyCode::Up => "up", + KeyCode::Down => "down", + KeyCode::Home => "home", + KeyCode::End => "end", + KeyCode::PageUp => "pageup", + KeyCode::PageDown => "pagedown", + KeyCode::Tab => "tab", + KeyCode::BackTab => "backtab", + KeyCode::Delete => "delete", + KeyCode::Insert => "insert", + KeyCode::F(c) => { + char = format!("f({c})"); + &char + } + KeyCode::Char(c) if c == ' ' => "space", + KeyCode::Char(c) => { + char = c.to_string(); + &char + } + KeyCode::Esc => "esc", + KeyCode::Null => "", + KeyCode::CapsLock => "", + KeyCode::Menu => "", + KeyCode::ScrollLock => "", + KeyCode::Media(_) => "", + KeyCode::NumLock => "", + KeyCode::PrintScreen => "", + KeyCode::Pause => "", + KeyCode::KeypadBegin => "", + KeyCode::Modifier(_) => "", + }; + + let mut modifiers = Vec::with_capacity(3); + + if key_event.modifiers.intersects(KeyModifiers::CONTROL) { + modifiers.push("ctrl"); + } + + if key_event.modifiers.intersects(KeyModifiers::SHIFT) { + modifiers.push("shift"); + } + + if key_event.modifiers.intersects(KeyModifiers::ALT) { + modifiers.push("alt"); + } + + // if the modifiers is "shift" and the key code is a letter, we just return the letter + // otherwise we return the modifiers joined by a dash and the key code + if modifiers.len() == 1 + && key_code.chars().count() == 1 + && key_code.chars().all(char::is_alphabetic) + { + return key_code.to_string(); + } + + let mut key = modifiers.join("-"); + + if !key.is_empty() { + key.push('-'); + } + + key.push_str(key_code); + + key +} + +pub fn parse_key_sequence(raw: &str) -> Result, String> { + 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); + 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::>(); + + sequences.into_iter().map(parse_key_event).collect() +} diff --git a/lib/cli/tui/framework/tui.rs b/lib/cli/tui/framework/tui.rs new file mode 100644 index 0000000..f8cc1cd --- /dev/null +++ b/lib/cli/tui/framework/tui.rs @@ -0,0 +1,233 @@ +use { + super::events::Event, + crossterm::{ + cursor, + event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event as CrosstermEvent, KeyEventKind, + }, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, + }, + eyre::Result, + futures::{FutureExt, StreamExt}, + ratatui::backend::CrosstermBackend as Backend, + std::{ + ops::{Deref, DerefMut}, + time::Duration, + }, + tokio::{ + sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + task::JoinHandle, + }, + tokio_util::sync::CancellationToken, +}; + +pub use ratatui::prelude::{Color, Constraint, Direction, Layout, Rect, Size}; + +pub type IO = std::io::Stdout; +pub fn io() -> IO { + std::io::stdout() +} +pub type Frame<'a> = ratatui::Frame<'a>; + +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 tick_rate = 4.0; + let frame_rate = 60.0; + let terminal = ratatui::Terminal::new(Backend::new(io()))?; + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let cancellation_token = CancellationToken::new(); + let task = tokio::spawn(async {}); + let mouse = false; + let paste = false; + Ok(Self { + terminal, + task, + cancellation_token, + event_rx, + event_tx, + frame_rate, + tick_rate, + mouse, + paste, + }) + } + + 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) { + let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate); + let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate); + self.cancel(); + self.cancellation_token = CancellationToken::new(); + let _cancellation_token = self.cancellation_token.clone(); + let _event_tx = self.event_tx.clone(); + self.task = tokio::spawn(async move { + let mut reader = crossterm::event::EventStream::new(); + let mut tick_interval = tokio::time::interval(tick_delay); + let mut render_interval = tokio::time::interval(render_delay); + _event_tx.send(Event::Init).unwrap(); + loop { + let tick_delay = tick_interval.tick(); + let render_delay = render_interval.tick(); + let crossterm_event = reader.next().fuse(); + tokio::select! { + _ = _cancellation_token.cancelled() => { + break; + } + maybe_event = crossterm_event => { + match maybe_event { + Some(Ok(evt)) => { + match evt { + CrosstermEvent::Key(key) => { + if key.kind == KeyEventKind::Press { + _event_tx.send(Event::Key(key)).unwrap(); + } + }, + CrosstermEvent::Mouse(mouse) => { + _event_tx.send(Event::Mouse(mouse)).unwrap(); + }, + CrosstermEvent::Resize(x, y) => { + _event_tx.send(Event::Resize(x, y)).unwrap(); + }, + CrosstermEvent::FocusLost => { + _event_tx.send(Event::FocusLost).unwrap(); + }, + CrosstermEvent::FocusGained => { + _event_tx.send(Event::FocusGained).unwrap(); + }, + CrosstermEvent::Paste(s) => { + _event_tx.send(Event::Paste(s)).unwrap(); + }, + } + } + Some(Err(_)) => { + _event_tx.send(Event::Error).unwrap(); + } + None => {}, + } + }, + _ = tick_delay => { + _event_tx.send(Event::Tick).unwrap(); + }, + _ = render_delay => { + _event_tx.send(Event::Render).unwrap(); + }, + } + } + }); + } + + 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 { + eprintln!("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!(io(), EnterAlternateScreen, cursor::Hide)?; + if self.mouse { + crossterm::execute!(io(), EnableMouseCapture)?; + } + if self.paste { + crossterm::execute!(io(), 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!(io(), DisableBracketedPaste)?; + } + if self.mouse { + crossterm::execute!(io(), DisableMouseCapture)?; + } + crossterm::execute!(io(), 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() + } + + pub fn resume(&mut self) -> Result<()> { + self.enter()?; + Ok(()) + } + + pub async fn next(&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(); + } +} diff --git a/lib/cli/tui/mod.rs b/lib/cli/tui/mod.rs new file mode 100644 index 0000000..b212e35 --- /dev/null +++ b/lib/cli/tui/mod.rs @@ -0,0 +1,46 @@ +use {eyre::Result, palette::rgb::Rgb, ratatui::style::Color, std::str::FromStr}; + +pub mod framework { + pub mod app; + pub mod component; + pub mod events; + pub mod keyboard; + pub mod tui; +} + +#[macro_export] +macro_rules! components { + ( $( $x:expr $( => $t:ty )* ),* ) => { + { + let mut temp_vec = Vec::new(); + $( + temp_vec.push( + Box::new($x) + as Box + ); + )* + temp_vec + } + }; +} + +#[macro_export] +macro_rules! children { + ( $( $name:expr => $value:expr ),* ) => { + { + let mut map = std::collections::HashMap::new(); + $( + map.insert( + $name.to_string(), + Box::new($value) as Box + ); + )* + map + } + }; +} + +pub fn rgb(hex: &str) -> Result { + let rgb: Rgb = Rgb::from_str(hex)?; + Ok(Color::Rgb(rgb.red, rgb.green, rgb.blue)) +}