Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bdac6ff65e | |||
| 060faf594d | |||
| 511046678d | |||
| 7e896d04bd | |||
| 79842ae70d | |||
| f508cd9194 | |||
| 00f231d79f | |||
| 33cd32ce40 | |||
| b0726b1fb4 | |||
| 2644f68cab | |||
| 0c765a608b | |||
| 97457af448 | |||
| cc7bb4a15f | |||
| 61f7096dfa | |||
| cf3983af3a | |||
| c2c700899d | |||
| 0bb4a205b7 | |||
| 5f48801bc9 | |||
| cf57bb8dc0 | |||
| 60c0fe1fa4 | |||
| 02121860d1 | |||
| 1bd325134e | |||
| d87321ba3e | |||
| da204857b7 | |||
| 8a14112999 | |||
| 5142062e35 | |||
| c6e5b319b3 | |||
| 5929ae514e | |||
| b28008d19d | |||
| 936e1f19c8 | |||
| e7a6d91c1f | |||
| 22f290de50 | |||
| 19ac6d5109 | |||
| 9c6d55967e | |||
| 56544deb56 | |||
| d621a0a657 | |||
| 6702ba8240 | |||
| ef708dfa04 | |||
| b606af53e6 | |||
| f6b8fda19d | |||
| 2f424ca9f9 |
@ -1,5 +1,5 @@
|
|||||||
[registries.lugit]
|
[registries.lugit]
|
||||||
index = "sparse+http://lugit.local/api/packages/lucodear/cargo/"
|
index = "sparse+https://git.lucode.dev/api/packages/lucas/cargo/"
|
||||||
|
|
||||||
[registry]
|
[registry]
|
||||||
global-credential-providers = ["cargo:token"]
|
global-credential-providers = ["cargo:token"]
|
||||||
|
|||||||
18
.github/img/logo-cli-tui.svg
vendored
Normal file
18
.github/img/logo-cli-tui.svg
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 142 182">
|
||||||
|
<style>
|
||||||
|
.a { fill: #000000; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.a { fill: #ffffff; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<path class="a" d="M4.91962,1.516H0V0H20.42877V61.6416h4.6695v1.51648H.3335V61.6416H4.91962ZM32.68567,58.60962q-5.33725-5.3865-5.33649-15.45264,0-10.06072,5.71161-15.49371,5.71014-5.432,16.051-5.432,10.33767,0,15.42578,5.01056Q69.62248,32.25306,69.624,42.5265,69.624,63.999,48.44489,64,38.02069,64,32.68567,58.60962ZM43.35858,39.83124v6.56811q0,9.18109.54187,11.03144a18.93828,18.93828,0,0,0,1.12549,3.032,3.54886,3.54886,0,0,0,3.58575,2.02167q3.252,0,4.25214-3.79071.751-2.69324.75079-12.12592v-7.1575a57.79357,57.79357,0,0,0-.87579-11.916q-.87543-3.74541-4.04382-3.74713a4.16107,4.16107,0,0,0-2.96,1.05243,6.4872,6.4872,0,0,0-1.62616,3.495A64.699,64.699,0,0,0,43.35858,39.83124ZM137.33051,61.6416V0H116.90173V1.516h4.91962V61.6416h-4.58618v1.51648H142V61.6416Zm-59.36792-3.032Q72.62468,53.2231,72.62518,43.157q0-10.06072,5.7116-15.49371,5.71161-5.432,16.051-5.432,10.33839,0,15.42669,5.01056,5.08347,5.01123,5.08538,15.28473Q114.8999,63.999,93.721,64q-10.423,0-15.75841-5.39038ZM88.6355,39.83124v6.56811q0,9.18109.541,11.03144a18.93282,18.93282,0,0,0,1.12641,3.032,3.54715,3.54715,0,0,0,3.58477,2.02167q3.25276,0,4.25269-3.79071.75165-2.69324.75122-12.12592h.00006v-7.1575a57.81892,57.81892,0,0,0-.87634-11.916q-.87533-3.74541-4.04425-3.74713a4.16011,4.16011,0,0,0-2.95954,1.05243,6.48466,6.48466,0,0,0-1.62567,3.495A64.70833,64.70833,0,0,0,88.6355,39.83124Z" />
|
||||||
|
<path class="a" d="M0,80H142V78H0Z" />
|
||||||
|
<path class="a" d="M94.54443,99.29932a2.3565,2.3565,0,0,0,.6631-1.6836v-.98536a2.33921,2.33921,0,0,0-.6631-1.70018,4.44567,4.44567,0,0,0-4.72656,0,2.33846,2.33846,0,0,0-.66211,1.70018v.98536a2.35572,2.35572,0,0,0,.66211,1.6836A4.35366,4.35366,0,0,0,94.54443,99.29932Zm-46.23925,7.54785a3.46851,3.46851,0,0,1,2.66894-1.00293,3.19824,3.19824,0,0,1,2.19336.67969,5.16812,5.16812,0,0,1,1.24072,1.70018l3.876-2.10839a7.58028,7.58028,0,0,0-2.669-3.11036,8.21,8.21,0,0,0-4.64111-1.17285,10.26889,10.26889,0,0,0-3.689.62891,7.50149,7.50149,0,0,0-2.78809,1.81836,8.0855,8.0855,0,0,0-1.751,2.89061,12.368,12.368,0,0,0,0,7.6836,8.0687,8.0687,0,0,0,1.751,2.89063A7.48955,7.48955,0,0,0,47.2851,119.563a10.37091,10.37091,0,0,0,3.72314.6289A8.69136,8.69136,0,0,0,55.7851,119.019a7.65872,7.65872,0,0,0,2.771-3.11035L54.748,113.73289a5.69171,5.69171,0,0,1-1.41064,1.76758,4.22674,4.22674,0,0,1-5.03222-.32324,3.85523,3.85523,0,0,1-.93506-2.73731v-2.85547A3.85523,3.85523,0,0,1,48.30518,106.84717Zm14.501,8.99316v3.94336H78.58154v-3.94336H73.21V94.62354H62.80615v3.94532h5.37207v17.27147Zm21.28321,0v3.94336H99.86572v-3.94336h-5.168V102.24072H84.08936v3.94336h5.57617v9.65625Z" />
|
||||||
|
<path class="a" d="M.1125,136h142v-2h-142Z" />
|
||||||
|
<path class="a" d="M113.99849,150.75006l-.00338,29.82263c0,1.40576-.00076,1.43286-1.437,1.42682L107.99972,182l-2-4,.01227-24,1.98773-4,5.2824-.00049C113.80174,149.99066,114.0182,150.22613,113.99849,150.75006ZM71.09006,150H65.99637l-2.025,7.84283V172l2,4,2.02783,2h2l2-2V150.95184C72.01657,150.23389,71.738,149.97736,71.09006,150Zm-19.09288.93536c.01294-.62616-.12866-.93237-.83526-.9129L46,150l-8,4v28l6.38239-.00488c1.71179-.00659,1.53778.20123,1.54071-1.50843.014-8.012.06695-16.47442.0769-24.48669l5.137.07477c.67347.0177.87873-.2204.86206-.88092C51.96044,153.66315,51.96538,152.46637,51.99718,150.93536Zm42.00043-.10565c.00116-.6178-.20532-.86523-.83746-.85425H88l-4,10v10.00379l-1.98486,5.99621L80,178v2l2,2,5.01953-.00586c.82953.02411.99329-.287.97974-.95581-.0365-1.79535-.008-3.24231-.00525-5.03833l.00006-.04834.00592.0238h5.09876c.667.01355.90283-.21789.90124-.89441C93.98125,167.11383,93.9829,158.79694,93.99761,150.82971Z" />
|
||||||
|
|
||||||
|
<path d="M28.61473,150c-.51233-.00214-.62006.2132-.61444.67511.02069,1.70349.02887,3.07172,0,4.775-.00873.51246.18084.55726.64862.54987H32.76c1.08905-.07819,1.24628.3313,1.2403,1.36353v23.64331c-.00354.82281.24206,1.01861,1.02051.9906L40.00865,182c.00555-8.14337.02985-16.66907-.0083-24.81226-.00452-.9696.28625-1.23071,1.18017-1.18774h4.81977v-6Z" style="fill:#39c976" />
|
||||||
|
<path d="M82,150.80725l-.0108,25.18726L66.80128,176c-.86212.037-.8302-.45453-.829-1.0451L66,150h-5.4176c-.579-.01239-.58271.30975-.5824.72021v24.49018c-.00122.57733.16406.8031.75885.78961h4.18066c.83344-.03284,1.09235-.16266,1.06049.61487v4.49988c-.01325.60809-.01215.89416.73822.88525H82l.01392-6H88V150H82.7843C82.11127,149.98059,81.99829,150.20361,82,150.80725Z" style="fill:#39c976" />
|
||||||
|
<path d="M102.55548,150c-.545-.01221-.55181.3-.55157.715L102,181.27057c-.00055.49359.07032.74328.63251.72943h5.35156L108,150Z" style="fill:#39c976" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
738
Cargo.lock
generated
738
Cargo.lock
generated
@ -1,6 +1,6 @@
|
|||||||
# This file is automatically @generated by Cargo.
|
# This file is automatically @generated by Cargo.
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "addr2line"
|
name = "addr2line"
|
||||||
@ -17,6 +17,24 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
|
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]]
|
[[package]]
|
||||||
name = "android-tzdata"
|
name = "android-tzdata"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@ -32,6 +50,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "approx"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@ -55,9 +82,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.5.0"
|
version = "2.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1"
|
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
@ -65,6 +92,33 @@ version = "3.15.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa"
|
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]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.0.90"
|
version = "1.0.90"
|
||||||
@ -79,16 +133,30 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.37"
|
version = "0.4.40"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e"
|
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-tzdata",
|
"android-tzdata",
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-targets",
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
@ -99,13 +167,61 @@ checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "croner"
|
name = "croner"
|
||||||
version = "2.0.4"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "516aad5374ea0ea75a0f0f4512fb4e7ad46c5eeff9971cb8ebc8fd74f1cd16c1"
|
checksum = "38fd53511eaf0b00a185613875fee58b208dfce016577d0ad4bb548e1c4fb3ee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"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 = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf"
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "eyre"
|
name = "eyre"
|
||||||
version = "0.6.12"
|
version = "0.6.12"
|
||||||
@ -116,6 +232,101 @@ dependencies = [
|
|||||||
"once_cell",
|
"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.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-executor"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-io"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"memchr",
|
||||||
|
"pin-project-lite",
|
||||||
|
"pin-utils",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gimli"
|
name = "gimli"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
@ -128,6 +339,28 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d"
|
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]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.60"
|
version = "0.1.60"
|
||||||
@ -157,6 +390,37 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
|
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indoc"
|
||||||
|
version = "2.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.69"
|
version = "0.3.69"
|
||||||
@ -168,29 +432,61 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.153"
|
version = "0.2.158"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.21"
|
version = "0.4.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
|
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lool"
|
name = "lool"
|
||||||
version = "0.3.1"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"chrono",
|
"chrono",
|
||||||
"croner",
|
"croner",
|
||||||
|
"crossterm",
|
||||||
|
"downcast-rs",
|
||||||
"eyre",
|
"eyre",
|
||||||
|
"futures",
|
||||||
"glob-match",
|
"glob-match",
|
||||||
"log",
|
"log",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"palette",
|
||||||
|
"ratatui",
|
||||||
|
"strum 0.27.1",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio_schedule",
|
"tokio-util",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.12.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37ee39891760e7d94734f6f63fedc29a2e4a152f836120753a72503f09fcf904"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -209,10 +505,23 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "mio"
|
||||||
version = "0.2.18"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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 = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
@ -232,12 +541,113 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
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]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.14"
|
version = "0.2.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
|
checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-utils"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.79"
|
version = "1.0.79"
|
||||||
@ -256,12 +666,189 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"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.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cassowary",
|
||||||
|
"compact_str",
|
||||||
|
"crossterm",
|
||||||
|
"indoc",
|
||||||
|
"instability",
|
||||||
|
"itertools",
|
||||||
|
"lru",
|
||||||
|
"paste",
|
||||||
|
"strum 0.26.3",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-truncate",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.23"
|
version = "0.1.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
|
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 0.26.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.27.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros 0.27.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "strum_macros"
|
||||||
|
version = "0.27.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.58"
|
version = "2.0.58"
|
||||||
@ -275,9 +862,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.37.0"
|
version = "1.44.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
|
checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
@ -286,9 +873,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-macros"
|
name = "tokio-macros"
|
||||||
version = "2.2.0"
|
version = "2.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
|
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@ -296,12 +883,15 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio_schedule"
|
name = "tokio-util"
|
||||||
version = "0.3.1"
|
version = "0.7.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b4a14ab1658c39d137ebcc5fbaab364c9922a6cc04ab48b364546c2e6022256"
|
checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -311,6 +901,47 @@ version = "1.0.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
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 0.1.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.92"
|
version = "0.2.92"
|
||||||
@ -365,6 +996,28 @@ version = "0.2.92"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
|
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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
@ -374,6 +1027,21 @@ dependencies = [
|
|||||||
"windows-targets",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.4"
|
version = "0.52.4"
|
||||||
@ -430,3 +1098,23 @@ name = "windows_x86_64_msvc"
|
|||||||
version = "0.52.4"
|
version = "0.52.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
|
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",
|
||||||
|
]
|
||||||
|
|||||||
68
Cargo.toml
68
Cargo.toml
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lool"
|
name = "lool"
|
||||||
version = "0.3.1"
|
version = "0.9.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "🧳 lool » lucode.ar rust common utilities"
|
description = "🧳 lool » lucode.ar rust common utilities"
|
||||||
authors = ["Lucas Colombo <lucas@lucode.ar>"]
|
authors = ["Lucas Colombo <lucas@lucode.ar>"]
|
||||||
@ -22,11 +22,29 @@ debug-assertions = false
|
|||||||
path = "lib/lib.rs"
|
path = "lib/lib.rs"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# cli utilities
|
# cli/tui utilities
|
||||||
"cli" = []
|
"cli" = []
|
||||||
"cli.stylize" = ["cli", "dep:bitflags"]
|
"cli.stylize" = ["cli", "dep:bitflags"]
|
||||||
|
"cli.tui" = [
|
||||||
|
"cli",
|
||||||
|
"dep:ratatui",
|
||||||
|
"dep:palette",
|
||||||
|
"dep:crossterm",
|
||||||
|
"dep:strum",
|
||||||
|
"dep:downcast-rs",
|
||||||
|
"dep:futures",
|
||||||
|
"dep:tokio",
|
||||||
|
"dep:tokio-util",
|
||||||
|
"crossterm?/event-stream",
|
||||||
|
"strum?/derive",
|
||||||
|
"tokio?/tokio-macros",
|
||||||
|
"tokio?/macros",
|
||||||
|
"tokio?/sync",
|
||||||
|
"tokio?/time",
|
||||||
|
]
|
||||||
|
"cli.tui.widgets" = ["cli.tui", "dep:unicode-width"]
|
||||||
# logging
|
# logging
|
||||||
"logger" = ["dep:log"]
|
"logger" = ["dep:log", "dep:glob-match"]
|
||||||
# macros
|
# macros
|
||||||
"macros" = []
|
"macros" = []
|
||||||
# scheduling
|
# scheduling
|
||||||
@ -46,21 +64,32 @@ path = "lib/lib.rs"
|
|||||||
"utils" = []
|
"utils" = []
|
||||||
"utils.threads" = ["utils", "macros", "dep:log"]
|
"utils.threads" = ["utils", "macros", "dep:log"]
|
||||||
|
|
||||||
|
# tokio
|
||||||
|
"tokio.rt" = [
|
||||||
|
"tokio?/rt"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# default
|
# default
|
||||||
eyre = { version = "0.6.12", default-features = false }
|
eyre = { version = "0.6.12", default-features = false }
|
||||||
|
|
||||||
# optional
|
# optional
|
||||||
bitflags = { version = "2.5.0", optional = true }
|
bitflags = { version = "2.9.0", optional = true }
|
||||||
chrono = { version = "0.4.37", optional = true }
|
chrono = { version = "0.4.40", optional = true }
|
||||||
log = { version = "0.4.21", optional = true }
|
log = { version = "0.4.27", optional = true }
|
||||||
tokio = { version = "1.37.0", optional = true }
|
tokio = { version = "1.44.1", optional = true }
|
||||||
croner = { version = "2.0.4", optional = true }
|
croner = { version = "2.1.0", optional = true }
|
||||||
num-traits = { version = "0.2.18", optional = true }
|
num-traits = { version = "0.2.19", optional = true }
|
||||||
tokio_schedule = "0.3.1"
|
glob-match = { version = "0.2.1", optional = true }
|
||||||
glob-match = "0.2.1"
|
tokio-util = { version = "0.7.14", optional = true }
|
||||||
|
ratatui = { version = "0.29.0", optional = true }
|
||||||
|
palette = { version = "0.7.6", optional = true }
|
||||||
|
crossterm = { version="0.28.1", optional = true}
|
||||||
|
strum = { version="0.27.1", optional = true }
|
||||||
|
downcast-rs = { version="2.0.1", optional = true}
|
||||||
|
futures = { version = "0.3.31", optional = true }
|
||||||
|
unicode-width = { version = "0.2.0", optional = true }
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "sched"
|
name = "sched"
|
||||||
@ -71,3 +100,18 @@ required-features = ["sched.threads", "sched.rule-recurrence"]
|
|||||||
name = "sched_tokio"
|
name = "sched_tokio"
|
||||||
path = "examples/sched_tokio.rs"
|
path = "examples/sched_tokio.rs"
|
||||||
required-features = ["sched.tokio", "sched.rule-recurrence"]
|
required-features = ["sched.tokio", "sched.rule-recurrence"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "widget_text_area"
|
||||||
|
path = "examples/widget_text_area.rs"
|
||||||
|
required-features = ["cli.tui.widgets", "tokio.rt"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "widget_grid_selector"
|
||||||
|
path = "examples/widget_grid_selector.rs"
|
||||||
|
required-features = ["cli.tui.widgets", "tokio.rt"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "widget_switch"
|
||||||
|
path = "examples/widget_switch.rs"
|
||||||
|
required-features = ["cli.tui.widgets", "tokio.rt"]
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<p align="center"><b>𝐥𝐨𝐨𝐥</b> is a tool-box library with common utilities for <b>𝚕𝚞𝚌𝚘𝚍𝚎.𝚊𝚛</b> projects.
|
<p align="center"><b>𝐥𝐨𝐨𝐥</b> is a tool-box library with common utilities for <b>𝚕𝚞𝚌𝚘𝚍𝚎.dev</b> projects.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|||||||
@ -29,6 +29,16 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- cargo run --example=threadpool --release --features utils.threads
|
- cargo run --example=threadpool --release --features utils.threads
|
||||||
|
|
||||||
|
example:gridselector:
|
||||||
|
desc: 🚀 run lool «example widget_grid_selector»
|
||||||
|
cmds:
|
||||||
|
- cargo watch --features=cli.tui.widgets,tokio.rt -c -x "run --example widget_grid_selector"
|
||||||
|
|
||||||
|
example:switch:
|
||||||
|
desc: 🚀 run lool «example widget_switch»
|
||||||
|
cmds:
|
||||||
|
- cargo watch --features=cli.tui.widgets,tokio.rt -c -x "run --example widget_switch"
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
desc: 🎨 format lool
|
desc: 🎨 format lool
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
187
examples/widget_grid_selector.rs
Normal file
187
examples/widget_grid_selector.rs
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
use std::io;
|
||||||
|
|
||||||
|
use {
|
||||||
|
lool::tui::widgets::gridselector::GridItem,
|
||||||
|
ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout},
|
||||||
|
style::{Modifier, Style, Stylize},
|
||||||
|
widgets::Paragraph,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use {
|
||||||
|
crossterm::{
|
||||||
|
event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
},
|
||||||
|
lool::tui::widgets::gridselector::{GridSelector, GridSelectorState},
|
||||||
|
ratatui::{layout::Rect, prelude::CrosstermBackend, style::Color, Frame, Terminal},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Item {
|
||||||
|
name: String,
|
||||||
|
emoji: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Item {
|
||||||
|
fn new(name: &str, emoji: &str) -> Self {
|
||||||
|
Item {
|
||||||
|
name: name.to_string(),
|
||||||
|
emoji: emoji.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Item> for GridItem {
|
||||||
|
fn from(val: Item) -> Self {
|
||||||
|
GridItem::new(format!("{} {}", val.name, val.emoji))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let mut stdout = stdout.lock();
|
||||||
|
enable_raw_mode()?;
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut term = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
// Run the draw loop
|
||||||
|
run(&mut term)?;
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(term.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
term.show_cursor()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run<B>(term: &mut Terminal<B>) -> io::Result<()>
|
||||||
|
where
|
||||||
|
B: ratatui::backend::Backend,
|
||||||
|
{
|
||||||
|
// vec![
|
||||||
|
// "feat ✨".to_string(),
|
||||||
|
// "fix".to_string(),
|
||||||
|
// "chore".to_string(),
|
||||||
|
// "docs".to_string(),
|
||||||
|
// "style".to_string(),
|
||||||
|
// "refactor".to_string(),
|
||||||
|
// "perf".to_string(),
|
||||||
|
// "test".to_string(),
|
||||||
|
// "build".to_string(),
|
||||||
|
// "ci".to_string(),
|
||||||
|
// "revert".to_string(),
|
||||||
|
// "release".to_string(),
|
||||||
|
// "wip".to_string(),
|
||||||
|
// ]
|
||||||
|
let items = vec![
|
||||||
|
Item::new("feat", "✨"),
|
||||||
|
Item::new("fix", "🐛"),
|
||||||
|
Item::new("chore", "🧹"),
|
||||||
|
Item::new("docs", "📚"),
|
||||||
|
Item::new("style", "💅"),
|
||||||
|
Item::new("refactor", "🔨"),
|
||||||
|
Item::new("perf", "🐎"),
|
||||||
|
Item::new("test", "🚨"),
|
||||||
|
Item::new("build", "👷"),
|
||||||
|
Item::new("ci", "🔧"),
|
||||||
|
Item::new("revert", "⏪"),
|
||||||
|
Item::new("release", "🚀"),
|
||||||
|
Item::new("wip", "🚧"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut grid_state = GridSelectorState::new(items).columns(5);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
term.draw(|f| draw(f, f.area(), &mut grid_state))?;
|
||||||
|
|
||||||
|
match event::read()? {
|
||||||
|
Event::Key(key) => match key {
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Esc, ..
|
||||||
|
} => break,
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Right,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
..
|
||||||
|
} => grid_state.move_right(),
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Left,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
..
|
||||||
|
} => grid_state.move_left(),
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Up,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
..
|
||||||
|
} => grid_state.move_up(),
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Down,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
..
|
||||||
|
} => grid_state.move_down(),
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Home,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
..
|
||||||
|
} => grid_state.move_to_row_start(),
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::End,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
..
|
||||||
|
} => grid_state.move_to_row_end(),
|
||||||
|
// selection
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Enter,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
..
|
||||||
|
} => grid_state.select(),
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(f: &mut Frame<'_>, area: Rect, state: &mut GridSelectorState) {
|
||||||
|
// vertical layout with 2 rows
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
f.render_stateful_widget(
|
||||||
|
GridSelector::default().with_selected_color(Color::Magenta),
|
||||||
|
layout[0],
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
let selected = state.selected().unwrap_or(GridItem::new("None"));
|
||||||
|
let hovered = state.hovered().unwrap_or(GridItem::new("None"));
|
||||||
|
|
||||||
|
let ps = Paragraph::new(selected)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::default().fg(Color::Magenta));
|
||||||
|
|
||||||
|
let ph = Paragraph::new(hovered)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::default().fg(Color::Blue));
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new("Hovered/Selected")
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
layout[1],
|
||||||
|
);
|
||||||
|
f.render_widget(ph, layout[2]);
|
||||||
|
f.render_widget(ps, layout[3]);
|
||||||
|
}
|
||||||
80
examples/widget_switch.rs
Normal file
80
examples/widget_switch.rs
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
use {
|
||||||
|
lool::tui::{
|
||||||
|
ratatui::{
|
||||||
|
backend::CrosstermBackend,
|
||||||
|
crossterm::{
|
||||||
|
event::{self},
|
||||||
|
execute,
|
||||||
|
terminal::{
|
||||||
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layout::{Constraint, Layout},
|
||||||
|
Terminal,
|
||||||
|
},
|
||||||
|
widgets::{
|
||||||
|
switch::Switch,
|
||||||
|
textarea::{Input, Key},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ratatui::layout::Flex,
|
||||||
|
std::io,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let mut stdout = stdout.lock();
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut term = Terminal::new(backend)?;
|
||||||
|
let mut switch_state = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
term.draw(|f| {
|
||||||
|
// Render the textarea
|
||||||
|
let switch =
|
||||||
|
Switch::with_status(switch_state).with_color_on(ratatui::style::Color::Blue);
|
||||||
|
|
||||||
|
let [horiz] = Layout::horizontal([Constraint::Percentage(100)])
|
||||||
|
.flex(Flex::Center)
|
||||||
|
.areas(f.area());
|
||||||
|
|
||||||
|
let [verti] = Layout::vertical([Constraint::Length(2)]).flex(Flex::Center).areas(horiz);
|
||||||
|
|
||||||
|
let [centered] =
|
||||||
|
Layout::horizontal([Constraint::Length(14)]).flex(Flex::Center).areas(verti);
|
||||||
|
|
||||||
|
f.render_widget(switch, centered);
|
||||||
|
})?;
|
||||||
|
match event::read()?.into() {
|
||||||
|
Input { key: Key::Esc, .. }
|
||||||
|
| Input {
|
||||||
|
key: Key::Char('c'),
|
||||||
|
shift: false,
|
||||||
|
ctrl: true,
|
||||||
|
alt: false,
|
||||||
|
} => break,
|
||||||
|
Input {
|
||||||
|
key: Key::Enter,
|
||||||
|
ctrl: false,
|
||||||
|
shift: false,
|
||||||
|
alt: false,
|
||||||
|
}
|
||||||
|
| Input {
|
||||||
|
key: Key::Char(' '),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
switch_state = !switch_state;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(term.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
term.show_cursor()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
77
examples/widget_text_area.rs
Normal file
77
examples/widget_text_area.rs
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
use {
|
||||||
|
lool::tui::{
|
||||||
|
ratatui::{
|
||||||
|
backend::CrosstermBackend,
|
||||||
|
crossterm::{
|
||||||
|
event::{self},
|
||||||
|
execute,
|
||||||
|
terminal::{
|
||||||
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layout::{Constraint, Direction, Layout},
|
||||||
|
Terminal,
|
||||||
|
},
|
||||||
|
widgets::textarea::{Input, Key, TextArea},
|
||||||
|
},
|
||||||
|
ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph},
|
||||||
|
std::{cmp, io},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let mut stdout = stdout.lock();
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut term = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let mut textarea = TextArea::default().with_block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.title(" Your name ")
|
||||||
|
.padding(Padding::horizontal(1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
term.draw(|f| {
|
||||||
|
const MIN_HEIGHT: usize = 1;
|
||||||
|
let height = cmp::max(textarea.lines().len(), MIN_HEIGHT) as u16 + 2;
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(height), Constraint::Min(1)])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
// Render the textarea
|
||||||
|
f.render_widget(&textarea, layout[0]);
|
||||||
|
f.render_widget(Paragraph::new("Press <Esc> to exit"), layout[1]);
|
||||||
|
})?;
|
||||||
|
match event::read()?.into() {
|
||||||
|
Input { key: Key::Esc, .. }
|
||||||
|
| Input {
|
||||||
|
key: Key::Char('c'),
|
||||||
|
shift: false,
|
||||||
|
ctrl: true,
|
||||||
|
alt: false,
|
||||||
|
}
|
||||||
|
| Input {
|
||||||
|
key: Key::Enter,
|
||||||
|
ctrl: false,
|
||||||
|
shift: false,
|
||||||
|
alt: false,
|
||||||
|
} => break,
|
||||||
|
input => {
|
||||||
|
textarea.input(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(term.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
term.show_cursor()?;
|
||||||
|
|
||||||
|
println!("Lines: {:?}", textarea.lines());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -1,24 +1,25 @@
|
|||||||
<p align="center"><img src="./../../.github/img/logo-cli.svg" width="200"></p>
|
<p align="center"><img src="./../../.github/img/logo-cli.svg" width="200"></p>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<p align="center"><b>lool » <code>cli</code></b> contains utilities for building command-line interfaces.</p>
|
<p align="center"><b>lool » <code>cli</code></b> contains utilities for building command-line interfaces.</p>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
This crate is for internal use. It's only published privately.
|
This crate is for internal use. It's only published privately.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo add lool --registry=lugit --features cli {sub-feature}
|
cargo add lool --registry=lugit --features cli {sub-feature}
|
||||||
```
|
```
|
||||||
|
|
||||||
# Sub-Features
|
# Sub-Features
|
||||||
|
|
||||||
- [x] [`stylize`](./stylize): colorize and stylize text for the terminal.
|
- [x] [`stylize`](./stylize): colorize and stylize text for the terminal.
|
||||||
|
- [x] [`tui`](./tui): build terminal user interfaces with ratatui.
|
||||||
|
|||||||
@ -1,2 +1,5 @@
|
|||||||
#[cfg(feature = "cli.stylize")]
|
#[cfg(feature = "cli.stylize")]
|
||||||
pub mod stylize;
|
pub mod stylize;
|
||||||
|
|
||||||
|
#[cfg(feature = "cli.tui")]
|
||||||
|
pub mod tui;
|
||||||
|
|||||||
255
lib/cli/tui/README.md
Normal file
255
lib/cli/tui/README.md
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
<p align="center"><img src="../../../.github/img/logo-cli-tui.svg" width="142"></p>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<p align="center"><b>lool » <code>cli.stylize</code></b> is a set of utilities for colorizing console outputs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
This crate is for internal use. It's only published privately.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo add lool --registry=lugit --features cli cli.tui
|
||||||
|
```
|
||||||
|
|
||||||
|
# `lool::cli::tui` Framework
|
||||||
|
|
||||||
|
This module provides a small framework for building async terminal user interfaces (TUIs) using a
|
||||||
|
component-based architecture, using the `ratatui` library.
|
||||||
|
|
||||||
|
This module defines two primary elements:
|
||||||
|
- the `App` struct,
|
||||||
|
- and the `Component` trait.
|
||||||
|
|
||||||
|
Together, these elements facilitate the creation of modular and interactive terminal applications.
|
||||||
|
|
||||||
|
## Framework
|
||||||
|
|
||||||
|
### `App` Struct
|
||||||
|
|
||||||
|
The `App` struct represents the main application and is responsible of (among other things):
|
||||||
|
|
||||||
|
- **Tick Rate and Frame Rate**: Controls the update frequency of the application.
|
||||||
|
- **Component Management**: Manages a collection of components that make up the user interface.
|
||||||
|
- **Event Handling**: Processes user inputs and dispatches actions to the appropriate components.
|
||||||
|
- **Lifecycle Management**: Handles the start, suspension, and termination of the application.
|
||||||
|
|
||||||
|
### `Component` Trait
|
||||||
|
|
||||||
|
The `Component` trait represents a visual and interactive element of the user interface.
|
||||||
|
|
||||||
|
Components can be nested, allowing for a hierarchical structure where each component can have child
|
||||||
|
components. This trait provides several methods for handling events, updating state, and rendering:
|
||||||
|
|
||||||
|
- **Event Handling**: Methods like `handle_frame_event` and `handle_paste_event` allow components
|
||||||
|
to respond to different types of events.
|
||||||
|
- **State Management**: Methods like `update` and `receive_message` enable components to update
|
||||||
|
their state based on actions or messages.
|
||||||
|
- **Initialization**: The `init` method allows components to perform setup tasks when they are first
|
||||||
|
created.
|
||||||
|
- **Rendering**: The `draw` method is responsible for rendering the component within a specified
|
||||||
|
area. All components must implement this method to display their content.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Component-Based Architecture
|
||||||
|
|
||||||
|
The framework uses a component-based architecture, where the user interface is composed of multiple
|
||||||
|
components. Each component can have child components, forming a tree-like structure. This design
|
||||||
|
promotes modularity and reusability, making it easier to manage complex user interfaces in a
|
||||||
|
structured and standardized way.
|
||||||
|
|
||||||
|
### Interaction Between `App` and `Component`
|
||||||
|
|
||||||
|
- **Initialization**: The `App` initializes all components and sets up the necessary event channels.
|
||||||
|
- **Event Dispatching**: The `App` listens for user inputs and dispatches actions to the relevant
|
||||||
|
components.
|
||||||
|
- **State Updates**: Components update their state based on the actions they receive and can
|
||||||
|
propagate these updates to their child components.
|
||||||
|
- **Rendering**: Components handle their own rendering logic, allowing for a flexible and
|
||||||
|
customizable user interface.
|
||||||
|
|
||||||
|
|
||||||
|
Usually, the `App` is provided with a root component that represents the main component of the
|
||||||
|
application.
|
||||||
|
|
||||||
|
From the Main/Root component, the application can be built by nesting child components as needed in
|
||||||
|
a tree-like structure. Example:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
App
|
||||||
|
└── RootComponent
|
||||||
|
└── Router
|
||||||
|
├── Home
|
||||||
|
│ ├── Header
|
||||||
|
│ └── Content
|
||||||
|
├── About
|
||||||
|
│ ├── Header
|
||||||
|
│ └── Content
|
||||||
|
└── Contact
|
||||||
|
├── Header
|
||||||
|
└── ContactForm
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, the `RootComponent` is the main component of the application and contains a
|
||||||
|
`Router`, which is another component that manages the routing logic. The `Router` component has
|
||||||
|
three child components: `Home`, `About`, and `Contact` and will render the appropriate component
|
||||||
|
depending on the current route.
|
||||||
|
|
||||||
|
Then, heach "route" component (`Home`, `About`, `Contact`) can have its own child components, such
|
||||||
|
as `Header`, `Content`, and `ContactForm` and use them to build the final user interface.
|
||||||
|
|
||||||
|
The `RootComponent` will call the `draw` method of the `Router` component, which will in turn call
|
||||||
|
the `draw` method of the current route component (`Home`, `About`, or `Contact`), and so on.
|
||||||
|
|
||||||
|
The `draw` chain will propagate down the component tree, allowing each component to render its
|
||||||
|
content. The `App` starts the draw chain a few times per second. The amount of draw calls per second
|
||||||
|
is controlled by the `frame_rate` of the `App`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut app = App::new(...).frame_rate(24); // 24 frames per second
|
||||||
|
```
|
||||||
|
|
||||||
|
Some tasks might be too expensive to be performed on every frame. In these cases, the `App` alsp
|
||||||
|
defines a `tick_rate` that controls how often the `handle_tick_event` method of the components is
|
||||||
|
called.
|
||||||
|
|
||||||
|
The tick event is often used to update the state of the components, while the frame event is used to
|
||||||
|
render the components in the terminal.
|
||||||
|
|
||||||
|
For example, a tick rate of 1 means that the `handle_tick_event` method of the components will be
|
||||||
|
called once per second. And a component might use this event to update its state, run background
|
||||||
|
tasks, or perform other operations that don't need to be done on every frame.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut app = App::new(...).tick_rate(10); // 10 ticks per second
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Communication
|
||||||
|
|
||||||
|
Components can communicate with each other using messages. The `Component` trait defines the
|
||||||
|
following methods:
|
||||||
|
|
||||||
|
- `register_action_handler`: registers a mpsc sender, to send messages to the bus.
|
||||||
|
- `receive_message`: receives a message from the bus.
|
||||||
|
|
||||||
|
At the start of the application, the `App` will call the `register_action_handler` method of each
|
||||||
|
component, and they can store the sender to send messages to the bus.
|
||||||
|
|
||||||
|
When a component wants to send a message to another component, it can use the sender it received
|
||||||
|
during the registration process.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
self.bus.send("an:action".to_string());
|
||||||
|
```
|
||||||
|
|
||||||
|
For simplicity, the messages are just strings, but one can serialize more complex data structures
|
||||||
|
into strings if needed in any format like JSON, TOML or even a custom format that just suits the
|
||||||
|
our needs:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
self.bus.send(format!("task:{}:state='{}',date='{}'", task_id, state, date));
|
||||||
|
```
|
||||||
|
|
||||||
|
Then we can receive the message in the `receive_message` method of another component:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn receive_message(&mut self, message: String) {
|
||||||
|
if message.starts_with("task:") {
|
||||||
|
// Parse the message and do whatever is needed with the data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Widgets
|
||||||
|
|
||||||
|
Apart from the tui framework, this module also provides a set of reusable "ratatui-native" widgets
|
||||||
|
that can be used. Theese are not components, but Widgets, just like native `Paragraph`, `Block`,
|
||||||
|
etc.
|
||||||
|
|
||||||
|
Right now, the following widgets are available:
|
||||||
|
|
||||||
|
### `TextArea`
|
||||||
|
|
||||||
|
This is a rip-off of the `TextArea` widget from the
|
||||||
|
[`tui-rs`](https://github.com/rhysd/tui-textarea) crate, but with less capabilities. In summary,
|
||||||
|
search, mouse support, copy, paste, and undo/redo functionalities were stripped off.
|
||||||
|
|
||||||
|
This implementation also changes the default key bindings to be more similar to the ones used in
|
||||||
|
the `coco` package (conventional commit cli utility). That is:
|
||||||
|
|
||||||
|
- <kbd>Enter</kbd> won't add a new line. **why?** Because that way, we can use the <kbd>Enter</kbd>
|
||||||
|
key to submit the "form" or cofirm the input.
|
||||||
|
- Removes all key bindings of stripped functionalities.
|
||||||
|
|
||||||
|
#### Vakudation
|
||||||
|
|
||||||
|
IN this implementation, the `TextArea` widget also supports validation. The validation is done by
|
||||||
|
accepting any number of validation functions that will be called every time the validity of the
|
||||||
|
text-area is checked.
|
||||||
|
|
||||||
|
One can add as many validation functions as needed:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
textarea..with_validations(vec![
|
||||||
|
|input: &str| {
|
||||||
|
if input.len() > 10 {
|
||||||
|
Err(format!("Input must be less than 10 characters"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required_validator,
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## `GridSelector` Widget
|
||||||
|
|
||||||
|
A selector restful widget that can be used to select items from a list. The items are displayed in a
|
||||||
|
grid, and the user can navigate through them using, for example, the arrow keys.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
See the [`widget_grid_selector.rs`](/examples/widget_grid_selector.rs) example for a full
|
||||||
|
demonstration of how to use the `GridSelector` widget.
|
||||||
|
|
||||||
|
In summary, the widget uses a `GridSelectorState` to keep track of the items and the state of the
|
||||||
|
widget.
|
||||||
|
|
||||||
|
The `GridSelectorState` takes a list of `GridItem`, which is a basic struct that encapsulates a
|
||||||
|
`String` value.
|
||||||
|
|
||||||
|
The `GridSelectorState::new` method accepts a list of any type that can be converted to a
|
||||||
|
`GridItem`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn new<I, T>(items: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = T>,
|
||||||
|
T: Into<GridItem>, // Accept anything that can be converted into GridItem
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
As a part of this library, the `Into<GridItem>` trait is implemented for `String`, `&str`.
|
||||||
|
|
||||||
|
The example at [`widget_grid_selector.rs`](/examples/widget_grid_selector.rs) demonstrates how to
|
||||||
|
implement the `Into<GridItem>` trait for a custom type.
|
||||||
|
|
||||||
|
## `Switch` Widget
|
||||||
|
|
||||||
|
A simple stateless switch widget that can be used to show visual feedback of a boolean state.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
See the [`widget_switch.rs`](/examples/widget_switch.rs) example for a full demonstration of how to
|
||||||
|
use the `Switch` widget.
|
||||||
194
lib/cli/tui/framework/app.rs
Normal file
194
lib/cli/tui/framework/app.rs
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
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<Box<dyn Component>>,
|
||||||
|
pub should_quit: bool,
|
||||||
|
pub should_suspend: bool,
|
||||||
|
pub keybindings: KeyBindings,
|
||||||
|
pub last_tick_key_events: Vec<KeyEvent>,
|
||||||
|
action_tx: mpsc::UnboundedSender<String>,
|
||||||
|
action_rx: mpsc::UnboundedReceiver<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(kb: Kb, components: Vec<Box<dyn Component>>) -> Result<Self> {
|
||||||
|
let keybindings = KeyBindings::new(kb);
|
||||||
|
let (action_tx, action_rx) = mpsc::unbounded_channel::<String>();
|
||||||
|
|
||||||
|
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<String, TryRecvError> {
|
||||||
|
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(&[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() {
|
||||||
|
if component.is_active() {
|
||||||
|
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() {
|
||||||
|
if component.is_active() {
|
||||||
|
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() {
|
||||||
|
if component.is_active() {
|
||||||
|
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() {
|
||||||
|
if component.is_active() {
|
||||||
|
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() {
|
||||||
|
if component.is_active() {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
426
lib/cli/tui/framework/component.rs
Normal file
426
lib/cli/tui/framework/component.rs
Normal file
@ -0,0 +1,426 @@
|
|||||||
|
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<String, Box<dyn Component>>;
|
||||||
|
|
||||||
|
// 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<String>) -> 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<Option<Action>>` - An action to be processed or none.
|
||||||
|
fn handle_events(&mut self, event: Option<Event>) -> Result<Vec<Action>> {
|
||||||
|
if self.is_active() {
|
||||||
|
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)?,
|
||||||
|
Some(Event::Tick) => self.handle_tick_event()?,
|
||||||
|
Some(Event::Render) => self.handle_frame_event()?,
|
||||||
|
Some(Event::Paste(ref event)) => self.handle_paste_event(event.clone())?,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(action) = action {
|
||||||
|
actions.push(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(children) = self.get_children() {
|
||||||
|
for child in children.values_mut() {
|
||||||
|
if child.is_active() {
|
||||||
|
let child_actions = child.handle_events(event.clone())?;
|
||||||
|
actions.extend(child_actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(actions)
|
||||||
|
} else {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle key events and produce actions if necessary.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `key` - A key event to be processed.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Result<Option<Action>>` - An action to be processed or none.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle mouse events and produce actions if necessary.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `mouse` - A mouse event to be processed.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Result<Option<Action>>` - An action to be processed or none.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result<Option<Action>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle Tick events and produce actions if necessary.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `tick` - A tick event to be processed.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Result<Option<Action>>` - An action to be processed or none.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn handle_tick_event(&mut self) -> Result<Option<Action>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle frame events and produce actions if necessary.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `tick` - A tick event to be processed.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Result<Option<Action>>` - An action to be processed or none.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn handle_frame_event(&mut self) -> Result<Option<Action>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle paste events and produce actions if necessary.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `message` - A string message to be processed.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Result<Option<Action>>` - An action to be processed or none.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn handle_paste_event(&mut self, message: String) -> Result<Option<Action>> {
|
||||||
|
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<Option<Action>>` - 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<dyn Component>>` - A mutable reference to the child component or none.
|
||||||
|
fn child_mut(&mut self, name: &str) -> Option<&mut Box<dyn Component>> {
|
||||||
|
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<dyn Component>>` - A reference to the child component or none.
|
||||||
|
#[allow(clippy::borrowed_box)]
|
||||||
|
fn child(&mut self, name: &str) -> Option<&Box<dyn Component>> {
|
||||||
|
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<String, Box<dyn Component>>` - All child components.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Vec[&mut Box<dyn Component>]` - 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) {
|
||||||
|
set_active_on_children(self, active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 still be able to call the children's `update` method.
|
||||||
|
pub fn update_children<T: Component + ?Sized>(this: &mut T, action: Action) -> Result<()> {
|
||||||
|
if this.is_active() {
|
||||||
|
if let Some(children) = this.get_children() {
|
||||||
|
for child in children.values_mut() {
|
||||||
|
if child.is_active() {
|
||||||
|
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 still be able pass the call to the children's `receive_message` method.
|
||||||
|
pub fn pass_message_to_children<T: Component + ?Sized>(
|
||||||
|
this: &mut T,
|
||||||
|
message: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
if this.is_active() {
|
||||||
|
if let Some(children) = this.get_children() {
|
||||||
|
for child in children.values_mut() {
|
||||||
|
if child.is_active() {
|
||||||
|
child.receive_message(message.clone())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set active/inactive to the children of a component.
|
||||||
|
///
|
||||||
|
/// This helper function is used to set active/inactive to the children of a component. It was
|
||||||
|
/// created to allow to easily implement the default `set_active` method of a component
|
||||||
|
/// implementation and be able to call the children's `set_active` method.
|
||||||
|
pub fn set_active_on_children<T: Component + ?Sized>(this: &mut T, active: bool) {
|
||||||
|
if let Some(children) = this.get_children() {
|
||||||
|
for child in children.values_mut() {
|
||||||
|
child.set_active(active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 still be
|
||||||
|
/// able to call the children's `init` method.
|
||||||
|
pub fn init_children<T: Component + ?Sized>(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 still be able to call the children's `register_action_handler` method.
|
||||||
|
pub fn pass_action_handler_to_children<T: Component + ?Sized>(
|
||||||
|
this: &mut T,
|
||||||
|
tx: UnboundedSender<String>,
|
||||||
|
) -> 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::<CastTo>()
|
||||||
|
} 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::<CastTo>()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
44
lib/cli/tui/framework/events.rs
Normal file
44
lib/cli/tui/framework/events.rs
Normal file
@ -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),
|
||||||
|
}
|
||||||
229
lib/cli/tui/framework/keyboard.rs
Normal file
229
lib/cli/tui/framework/keyboard.rs
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
use {
|
||||||
|
super::events::Action,
|
||||||
|
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
|
||||||
|
eyre::Result,
|
||||||
|
std::{collections::HashMap, str::FromStr},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
/// A struct that holds key bindings
|
||||||
|
///
|
||||||
|
/// The key bindings are stored in a hashmap where the key is a vector of
|
||||||
|
/// [`crossterm::event::KeyEvent`] and the value is a
|
||||||
|
/// [`Action`](crate::tui::Action). This is constructed automatically by [`Kb`](crate::tui::Kb)
|
||||||
|
/// using a [`str`] to [`Action`](crate::tui::Action) mapping, using special syntax to represent
|
||||||
|
/// keys and key sequences (see
|
||||||
|
/// [`parse_key_sequence`](crate::tui::utils::keyboard::parse_key_sequence) and
|
||||||
|
/// [`Kb`](crate::tui::Kb) for more information).
|
||||||
|
pub struct KeyBindings(pub HashMap<Vec<KeyEvent>, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `@internal`
|
||||||
|
///
|
||||||
|
/// Parses a string into a [`KeyEvent`]
|
||||||
|
fn parse_key_event(raw: &str) -> Result<KeyEvent> {
|
||||||
|
let raw_lower = raw.to_ascii_lowercase();
|
||||||
|
let (remaining, modifiers) = extract_modifiers(&raw_lower);
|
||||||
|
parse_key_code_with_modifiers(remaining, modifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `@internal`
|
||||||
|
///
|
||||||
|
/// Extracts the modifiers from a string formatted as `modifier-key`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `@internal`
|
||||||
|
///
|
||||||
|
/// Parses a string into a [`KeyEvent`] with modifiers
|
||||||
|
fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent> {
|
||||||
|
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(eyre::eyre!("Unable to parse `{}`", raw)),
|
||||||
|
};
|
||||||
|
Ok(KeyEvent::new(c, modifiers))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a [`KeyEvent`] to a string representation
|
||||||
|
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(' ') => "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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a string into a vector of [`KeyEvent`]
|
||||||
|
pub fn parse_key_sequence(raw: &str) -> Result<Vec<KeyEvent>> {
|
||||||
|
if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
|
||||||
|
return Err(eyre::eyre!("Invalid key sequence: `{}`", 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::<Vec<_>>();
|
||||||
|
|
||||||
|
sequences.into_iter().map(parse_key_event).collect()
|
||||||
|
}
|
||||||
263
lib/cli/tui/framework/tui.rs
Normal file
263
lib/cli/tui/framework/tui.rs
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
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 type IO = std::io::Stdout;
|
||||||
|
fn io() -> IO {
|
||||||
|
std::io::stdout()
|
||||||
|
}
|
||||||
|
pub type Frame<'a> = ratatui::Frame<'a>;
|
||||||
|
|
||||||
|
/// The Tui struct represents a terminal user interface.
|
||||||
|
///
|
||||||
|
/// It encapsulates [ratatui::Terminal] adding extra functionality:
|
||||||
|
/// - [Tui::start] and [Tui::stop] to start and stop the event loop
|
||||||
|
/// - [Tui::enter] and [Tui::exit] to enter and exit the crossterm terminal
|
||||||
|
/// [raw mode](https://docs.rs/crossterm/0.28.1/crossterm/terminal/index.html#raw-mode)
|
||||||
|
/// - Mapping of crossterm events to [Event]s
|
||||||
|
/// - Emits [Event::Tick] and [Event::Render] events at a specified rate
|
||||||
|
pub struct Tui {
|
||||||
|
pub terminal: ratatui::Terminal<Backend<IO>>,
|
||||||
|
pub task: JoinHandle<()>,
|
||||||
|
pub cancellation_token: CancellationToken,
|
||||||
|
pub event_rx: UnboundedReceiver<Event>,
|
||||||
|
pub event_tx: UnboundedSender<Event>,
|
||||||
|
pub frame_rate: f64,
|
||||||
|
pub tick_rate: f64,
|
||||||
|
pub mouse: bool,
|
||||||
|
pub paste: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tui {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the tick rate for the Tui. The tick rate is the number of times per second that the
|
||||||
|
/// Tui will emit a [Event::Tick] event. The default tick rate is 4 ticks per second.
|
||||||
|
///
|
||||||
|
/// The tick is different from the render rate, which is the number of times per second that
|
||||||
|
/// the application will be drawn to the screen. The tick rate is useful for updating the
|
||||||
|
/// application state, performing calculations, run background tasks, and other operations that
|
||||||
|
/// do not require a per-frame operation.
|
||||||
|
///
|
||||||
|
/// Tick rate will usually be lower than the frame rate.
|
||||||
|
pub fn tick_rate(mut self, tick_rate: f64) -> Self {
|
||||||
|
self.tick_rate = tick_rate;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the frame rate for the Tui. The frame rate is the number of times per second that the
|
||||||
|
/// Tui will emit a [Event::Render] event. The default frame rate is 60 frames per second.
|
||||||
|
///
|
||||||
|
/// The frame rate is the rate at which the application will be drawn to the screen (by calling
|
||||||
|
/// the `draw` method of each component).
|
||||||
|
pub fn frame_rate(mut self, frame_rate: f64) -> Self {
|
||||||
|
self.frame_rate = frame_rate;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets whether the Tui should capture mouse events. The default is false.
|
||||||
|
pub fn mouse(mut self, mouse: bool) -> Self {
|
||||||
|
self.mouse = mouse;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets whether the Tui should capture paste events. The default is false.
|
||||||
|
pub fn paste(mut self, paste: bool) -> Self {
|
||||||
|
self.paste = paste;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the Tui event loop.
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the Tui event loop.
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables cross-term raw mode and enters the alternate screen.
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disables cross-term raw mode and exits the alternate screen.
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the next event from the event channel.
|
||||||
|
pub async fn next(&mut self) -> Option<Event> {
|
||||||
|
self.event_rx.recv().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for Tui {
|
||||||
|
type Target = ratatui::Terminal<Backend<IO>>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
// deref Tui as Terminal
|
||||||
|
&self.terminal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DerefMut for Tui {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
// deref Tui as Terminal mutably
|
||||||
|
&mut self.terminal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Tui {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Ensure that the terminal is cleaned up when the Tui is dropped
|
||||||
|
self.exit().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
90
lib/cli/tui/mod.rs
Normal file
90
lib/cli/tui/mod.rs
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
mod framework {
|
||||||
|
pub mod app;
|
||||||
|
pub mod component;
|
||||||
|
pub mod events;
|
||||||
|
pub mod keyboard;
|
||||||
|
pub mod tui;
|
||||||
|
}
|
||||||
|
|
||||||
|
use {eyre::Result, palette::rgb::Rgb, ratatui::style::Color, std::str::FromStr};
|
||||||
|
|
||||||
|
pub use framework::{
|
||||||
|
app::{App, Kb},
|
||||||
|
component::{Children, Component},
|
||||||
|
events::{Action, Event},
|
||||||
|
keyboard::KeyBindings,
|
||||||
|
tui::{Frame, Tui, IO},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod utils {
|
||||||
|
pub mod component {
|
||||||
|
pub use super::super::framework::component::{
|
||||||
|
child_downcast, child_downcast_mut, init_children, pass_action_handler_to_children,
|
||||||
|
pass_message_to_children, set_active_on_children, update_children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod keyboard {
|
||||||
|
pub use super::super::framework::keyboard::{key_event_to_string, parse_key_sequence};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "cli.tui.widgets")]
|
||||||
|
pub mod widgets {
|
||||||
|
pub mod gridselector {
|
||||||
|
mod selector;
|
||||||
|
mod state;
|
||||||
|
mod widget;
|
||||||
|
|
||||||
|
pub use {selector::*, state::*};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod textarea;
|
||||||
|
|
||||||
|
pub mod switch {
|
||||||
|
mod widget;
|
||||||
|
pub use widget::*;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ratatui prelude
|
||||||
|
pub mod ratatui {
|
||||||
|
pub use ratatui::{prelude::*, *};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! components {
|
||||||
|
( $( $x:expr $( => $t:ty )* ),* ) => {
|
||||||
|
{
|
||||||
|
let mut temp_vec = Vec::new();
|
||||||
|
$(
|
||||||
|
temp_vec.push(
|
||||||
|
Box::new($x)
|
||||||
|
as Box<dyn lool::tui::Component $( $t + )* >
|
||||||
|
);
|
||||||
|
)*
|
||||||
|
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<dyn lool::tui::Component>
|
||||||
|
);
|
||||||
|
)*
|
||||||
|
map
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rgb(hex: &str) -> Result<Color> {
|
||||||
|
let rgb: Rgb<u8, u8> = Rgb::from_str(hex)?;
|
||||||
|
Ok(Color::Rgb(rgb.red, rgb.green, rgb.blue))
|
||||||
|
}
|
||||||
68
lib/cli/tui/widgets/gridselector/selector.rs
Normal file
68
lib/cli/tui/widgets/gridselector/selector.rs
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
//! {`GridSelector`} widget
|
||||||
|
//!
|
||||||
|
//! This module contains the [`BoxSelector`] ratatui widget.
|
||||||
|
//!
|
||||||
|
//! The grid selector is a stateful widget that allows the user to select an item from a list of
|
||||||
|
//! items displayed in a grid. The user can navigate the grid with the arrow keys, and select the
|
||||||
|
//! currently hovered item with the `Enter` key.
|
||||||
|
//!
|
||||||
|
//! The [`GridSelector`] widget uses a [`GridSelectorState`] to be able to keep its state between
|
||||||
|
//! renders.
|
||||||
|
|
||||||
|
use {super::GridSelectorState, ratatui::style::Color};
|
||||||
|
|
||||||
|
pub struct GridSelector {
|
||||||
|
color: Color,
|
||||||
|
hovered_color: Color,
|
||||||
|
selected_color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GridSelector {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
color: Color::Reset,
|
||||||
|
hovered_color: Color::Blue,
|
||||||
|
selected_color: Color::Green,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// imlementation of the build pattern for the GridSelector to set the colors
|
||||||
|
|
||||||
|
impl GridSelector {
|
||||||
|
/// Set the color of the items in the grid.
|
||||||
|
pub fn with_color(mut self, color: Color) -> Self {
|
||||||
|
self.color = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the color of the hovered item in the grid.
|
||||||
|
pub fn with_hovered_color(mut self, color: Color) -> Self {
|
||||||
|
self.hovered_color = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the color of the selected item in the grid.
|
||||||
|
pub fn with_selected_color(mut self, color: Color) -> Self {
|
||||||
|
self.selected_color = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_color(&self, for_idx: usize, state: &GridSelectorState) -> Color {
|
||||||
|
let mut color = self.color;
|
||||||
|
|
||||||
|
if let Some(hovered_idx) = state.hovered {
|
||||||
|
if for_idx == hovered_idx {
|
||||||
|
color = self.hovered_color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(selected_index) = state.selected {
|
||||||
|
if for_idx == selected_index {
|
||||||
|
color = self.selected_color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
color
|
||||||
|
}
|
||||||
|
}
|
||||||
272
lib/cli/tui/widgets/gridselector/state.rs
Normal file
272
lib/cli/tui/widgets/gridselector/state.rs
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
//! # Grid Selector State
|
||||||
|
//!
|
||||||
|
//! This module contains the [`GridSelectorState`] for the `GridSelector` widget.
|
||||||
|
//!
|
||||||
|
//! The state is used to keep track of the items, the selected item, and the hovered item and
|
||||||
|
//! encapsulates the navigation logic for the grid selector.
|
||||||
|
|
||||||
|
use ratatui::text::Text;
|
||||||
|
|
||||||
|
// If Text has a lifetime parameter, specify it in the implementation.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct GridItem(String);
|
||||||
|
|
||||||
|
impl GridItem {
|
||||||
|
// accept both String and &str
|
||||||
|
pub fn new<S>(value: S) -> Self
|
||||||
|
where
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
GridItem(value.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert Label into &str
|
||||||
|
impl AsRef<str> for GridItem {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert Label into String
|
||||||
|
impl From<GridItem> for String {
|
||||||
|
fn from(val: GridItem) -> Self {
|
||||||
|
val.0.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specify the lifetime for the implementation
|
||||||
|
impl<'a> From<GridItem> for Text<'a> {
|
||||||
|
fn from(val: GridItem) -> Self {
|
||||||
|
Text::from(val.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement a way to convert String into Label
|
||||||
|
impl From<String> for GridItem {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
GridItem(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement a way to convert &str into Label
|
||||||
|
impl<'a> From<&'a str> for GridItem {
|
||||||
|
fn from(value: &'a str) -> Self {
|
||||||
|
GridItem(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for the [`GridSelector`] widget.
|
||||||
|
///
|
||||||
|
/// This state is used to keep track of the items, the selected item, and the hovered item.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GridSelectorState {
|
||||||
|
pub items: Vec<GridItem>,
|
||||||
|
pub selected: Option<usize>,
|
||||||
|
pub hovered: Option<usize>,
|
||||||
|
pub(crate) columns: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GridSelectorState {
|
||||||
|
/// Create a new [`GridSelectorState`] with the given items.
|
||||||
|
pub fn new<I, T>(items: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = T>,
|
||||||
|
T: Into<GridItem>, // Accept anything that can be converted into Label
|
||||||
|
{
|
||||||
|
let items: Vec<GridItem> = items.into_iter().map(Into::into).collect();
|
||||||
|
Self {
|
||||||
|
items,
|
||||||
|
selected: None,
|
||||||
|
hovered: Some(0),
|
||||||
|
columns: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// builder method to set the number of columns
|
||||||
|
pub fn columns(mut self, columns: usize) -> Self {
|
||||||
|
self.columns = columns;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the selected item.
|
||||||
|
pub fn selected(&self) -> Option<GridItem> {
|
||||||
|
self.selected.map(|i| self.items[i].clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the index of the selected item.
|
||||||
|
pub fn selected_index(&self) -> Option<usize> {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the hovered item.
|
||||||
|
pub fn hovered(&self) -> Option<GridItem> {
|
||||||
|
self.hovered.map(|i| self.items[i].clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the hovered item right +1
|
||||||
|
///
|
||||||
|
/// Returns `true` if the hovered item was moved, `false` otherwise.
|
||||||
|
pub fn move_right(&mut self) -> bool {
|
||||||
|
self.hovered = if let Some(hovered) = self.hovered {
|
||||||
|
let next = hovered + 1;
|
||||||
|
if next < self.items.len() {
|
||||||
|
Some(next)
|
||||||
|
} else {
|
||||||
|
Some(0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the hovered item left -1
|
||||||
|
///
|
||||||
|
/// Returns `true` if the hovered item was moved,
|
||||||
|
/// `false` otherwise.
|
||||||
|
pub fn move_left(&mut self) -> bool {
|
||||||
|
self.hovered = if let Some(hovered) = self.hovered {
|
||||||
|
if hovered > 0 {
|
||||||
|
Some(hovered - 1)
|
||||||
|
} else {
|
||||||
|
Some(self.items.len() - 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(0)
|
||||||
|
};
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the hovered item down by one row.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the hovered item was moved, `false` otherwise.
|
||||||
|
pub fn move_down(&mut self) -> bool {
|
||||||
|
if let Some(hovered) = self.hovered {
|
||||||
|
let items_per_row = self.columns;
|
||||||
|
let num_items = self.items.len();
|
||||||
|
let current_row = hovered / items_per_row;
|
||||||
|
let next_row_start = (current_row + 1) * items_per_row;
|
||||||
|
let last_item_index = num_items - 1;
|
||||||
|
|
||||||
|
// If we are in the last row, we can't go down
|
||||||
|
if next_row_start > last_item_index {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut next_index = std::cmp::min(hovered + items_per_row, last_item_index);
|
||||||
|
|
||||||
|
// Handle the case where the next row has fewer items
|
||||||
|
let next_row_count = std::cmp::min(items_per_row, last_item_index - next_row_start + 1);
|
||||||
|
|
||||||
|
// check if both the next_row_count and the self.columns are odd numbers (3,5,7, etc)
|
||||||
|
// and next_row_count is less than self.columns
|
||||||
|
if next_row_count % 2 != 0 && items_per_row % 2 != 0 && next_row_count < items_per_row {
|
||||||
|
let shift = (items_per_row - next_row_count) / 2;
|
||||||
|
// if we are in the shifted range (left or right) of the row, adjust the hovered
|
||||||
|
// index accordingly
|
||||||
|
if hovered % items_per_row >= shift
|
||||||
|
&& hovered % items_per_row < items_per_row - shift
|
||||||
|
{
|
||||||
|
next_index = hovered + items_per_row - shift;
|
||||||
|
} else {
|
||||||
|
// if we are in the left part of the shifted range, set next to the first item
|
||||||
|
// in the last row and if we are in the right part of the shifted range, set
|
||||||
|
// next to the last item in the last row
|
||||||
|
next_index = if hovered % items_per_row < shift {
|
||||||
|
next_row_start
|
||||||
|
} else {
|
||||||
|
last_item_index
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.hovered = Some(std::cmp::min(next_index, last_item_index));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the hovered item up by one row.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the hovered item was moved, `false` otherwise.
|
||||||
|
pub fn move_up(&mut self) -> bool {
|
||||||
|
if let Some(hovered) = self.hovered {
|
||||||
|
let row_number = hovered / self.columns;
|
||||||
|
|
||||||
|
// If we are in the first row, we can't go up
|
||||||
|
if row_number == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut next_index = hovered.saturating_sub(self.columns);
|
||||||
|
|
||||||
|
// Handle case where the current index is in the last row
|
||||||
|
// let is_last_row = hovered >= self.items.len().saturating_sub(self.columns);
|
||||||
|
let last_row_start = (self.items.len() / self.columns) * self.columns;
|
||||||
|
let is_last_row = hovered >= last_row_start;
|
||||||
|
|
||||||
|
if is_last_row {
|
||||||
|
let last_row_count = self.items.len() % self.columns;
|
||||||
|
|
||||||
|
// If the last_row_count and self.columns are odd numbers (3,5,7, etc)
|
||||||
|
// and last_row_count is less than self.columns we need to adjust the next index
|
||||||
|
// to go to the cell just above the current hovered cell
|
||||||
|
if last_row_count % 2 != 0 && self.columns % 2 != 0 && last_row_count < self.columns
|
||||||
|
{
|
||||||
|
let shift = (self.columns - last_row_count) / 2;
|
||||||
|
next_index = hovered.saturating_sub(self.columns - shift);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure next_index stays within bounds
|
||||||
|
self.hovered = Some(std::cmp::min(next_index, self.items.len() - 1));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the hovered item to the first item in the current row.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the hovered item was moved, `false` otherwise.
|
||||||
|
pub fn move_to_row_start(&mut self) -> bool {
|
||||||
|
if let Some(hovered) = self.hovered {
|
||||||
|
let row_start = (hovered / self.columns) * self.columns;
|
||||||
|
self.hovered = Some(row_start);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the hovered item to the last item in the current row.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the hovered item was moved, `false` otherwise.
|
||||||
|
pub fn move_to_row_end(&mut self) -> bool {
|
||||||
|
if let Some(hovered) = self.hovered {
|
||||||
|
let row_end = std::cmp::min(
|
||||||
|
(hovered / self.columns + 1) * self.columns - 1,
|
||||||
|
self.items.len() - 1,
|
||||||
|
);
|
||||||
|
self.hovered = Some(row_end);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the hovered item.
|
||||||
|
///
|
||||||
|
/// Select the hovered item. Returns `true` if the hovered item was selected, `false` otherwise.
|
||||||
|
pub fn select(&mut self) -> bool {
|
||||||
|
if let Some(hovered) = self.hovered {
|
||||||
|
self.selected = Some(hovered);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
lib/cli/tui/widgets/gridselector/widget.rs
Normal file
63
lib/cli/tui/widgets/gridselector/widget.rs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
use {
|
||||||
|
super::{GridSelector, GridSelectorState},
|
||||||
|
ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Alignment, Constraint, Direction, Flex, Layout, Rect},
|
||||||
|
style::Style,
|
||||||
|
widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
|
||||||
|
},
|
||||||
|
std::rc::Rc,
|
||||||
|
unicode_width::UnicodeWidthStr,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl StatefulWidget for GridSelector {
|
||||||
|
type State = GridSelectorState;
|
||||||
|
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut GridSelectorState) {
|
||||||
|
let rows_layout = rows_layout(state, area);
|
||||||
|
let largest_item = largest_item(state);
|
||||||
|
|
||||||
|
for (i, row) in rows_layout.iter().enumerate() {
|
||||||
|
let row_items = state.items.iter().skip(i * state.columns).take(state.columns);
|
||||||
|
let columns_layout = columns_layout(row, row_items.len(), largest_item);
|
||||||
|
|
||||||
|
for (j, item) in row_items.enumerate() {
|
||||||
|
let main_index = i * state.columns + j;
|
||||||
|
let color = self.get_color(main_index, state);
|
||||||
|
|
||||||
|
let type_block =
|
||||||
|
Block::default().borders(Borders::ALL).border_style(Style::default().fg(color));
|
||||||
|
|
||||||
|
Paragraph::new(item.clone())
|
||||||
|
.style(Style::default().fg(color))
|
||||||
|
.alignment(Alignment::Left)
|
||||||
|
.block(type_block)
|
||||||
|
.render(columns_layout[j], buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rows_layout(state: &GridSelectorState, area: Rect) -> Rc<[Rect]> {
|
||||||
|
let row_count = (state.items.len() as f32 / state.columns as f32).ceil() as usize;
|
||||||
|
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints((0..row_count).map(|_| Constraint::Length(3)).collect::<Vec<_>>())
|
||||||
|
.spacing(0)
|
||||||
|
.split(area)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn columns_layout(row: &Rect, row_item_count: usize, largest_item: u16) -> Rc<[Rect]> {
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.flex(Flex::Center)
|
||||||
|
.constraints(
|
||||||
|
(0..row_item_count).map(|_| Constraint::Length(largest_item + 3)).collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.split(*row)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn largest_item(state: &GridSelectorState) -> u16 {
|
||||||
|
state.items.iter().map(|item| UnicodeWidthStr::width(item.as_ref())).max().unwrap_or(0) as u16
|
||||||
|
}
|
||||||
88
lib/cli/tui/widgets/switch/widget.rs
Normal file
88
lib/cli/tui/widgets/switch/widget.rs
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
use ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::{Color, Style},
|
||||||
|
widgets::{Block, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A switch widget
|
||||||
|
///
|
||||||
|
/// This widget is used to show visual confirmation of a boolean state
|
||||||
|
pub struct Switch {
|
||||||
|
/// The state of the switch
|
||||||
|
state: bool,
|
||||||
|
/// The color of the "on" state (`Green` by default)
|
||||||
|
color_on: Color,
|
||||||
|
/// The color of the "off" state (`DarkGray` by default)
|
||||||
|
color_off: Color,
|
||||||
|
/// The color of the switch itself (`White` by default)
|
||||||
|
color_switch: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Switch {
|
||||||
|
pub fn with_status(state: bool) -> Self {
|
||||||
|
Switch {
|
||||||
|
state,
|
||||||
|
color_on: Color::Green,
|
||||||
|
color_off: Color::DarkGray,
|
||||||
|
color_switch: Color::White,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_color_on(mut self, color: Color) -> Self {
|
||||||
|
self.color_on = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_color_off(mut self, color: Color) -> Self {
|
||||||
|
self.color_off = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_color_switch(mut self, color: Color) -> Self {
|
||||||
|
self.color_switch = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_layout(&self, area: Rect) -> (Rect, Rect) {
|
||||||
|
let main = Layout::default()
|
||||||
|
.direction(ratatui::layout::Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(2)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(ratatui::layout::Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Length(7), Constraint::Length(7)])
|
||||||
|
.split(main[0]);
|
||||||
|
|
||||||
|
(layout[0], layout[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_left_color(&self) -> Color {
|
||||||
|
if self.state {
|
||||||
|
self.color_on
|
||||||
|
} else {
|
||||||
|
self.color_switch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_right_color(&self) -> Color {
|
||||||
|
if self.state {
|
||||||
|
self.color_switch
|
||||||
|
} else {
|
||||||
|
self.color_off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for Switch {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let (left, right) = self.get_layout(area);
|
||||||
|
|
||||||
|
let left_block = Block::default().style(Style::default().bg(self.get_left_color()));
|
||||||
|
let right_block = Block::default().style(Style::default().bg(self.get_right_color()));
|
||||||
|
|
||||||
|
left_block.render(left, buf);
|
||||||
|
right_block.render(right, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
lib/cli/tui/widgets/textarea/behaviour/cursor.rs
Normal file
102
lib/cli/tui/widgets/textarea/behaviour/cursor.rs
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
use {
|
||||||
|
super::util::{find_word_start_backward, find_word_start_forward},
|
||||||
|
crate::tui::widgets::textarea::core::widget::Viewport,
|
||||||
|
std::cmp,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Specify how to move the cursor.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum CursorMove {
|
||||||
|
/// Move cursor forward by one character. When the cursor is at the end of line, it moves to the
|
||||||
|
/// head of next line.
|
||||||
|
Forward,
|
||||||
|
/// Move cursor backward by one character. When the cursor is at the head of line, it moves to
|
||||||
|
/// the end of previous line.
|
||||||
|
Back,
|
||||||
|
/// Move cursor up by one line.
|
||||||
|
Up,
|
||||||
|
/// Move cursor down by one line.
|
||||||
|
Down,
|
||||||
|
/// Move cursor to the head of line. When the cursor is at the head of line, it moves to the end of previous line.
|
||||||
|
Head,
|
||||||
|
/// Move cursor to the end of line. When the cursor is at the end of line, it moves to the head of next line.
|
||||||
|
End,
|
||||||
|
/// Move cursor forward by one word. Word boundary appears at spaces, punctuations, and others. For example
|
||||||
|
/// `fn foo(a)` consists of words `fn`, `foo`, `(`, `a`, `)`. When the cursor is at the end of line, it moves to the
|
||||||
|
/// head of next line.
|
||||||
|
WordForward,
|
||||||
|
/// Move cursor backward by one word. Word boundary appears at spaces, punctuations, and others. For example
|
||||||
|
/// `fn foo(a)` consists of words `fn`, `foo`, `(`, `a`, `)`.When the cursor is at the head of line, it moves to
|
||||||
|
/// the end of previous line.
|
||||||
|
WordBack,
|
||||||
|
/// Move cursor to keep it within the viewport. For example, when a viewport displays line 8 to line 16:
|
||||||
|
///
|
||||||
|
/// - cursor at line 4 is moved to line 8
|
||||||
|
/// - cursor at line 20 is moved to line 16
|
||||||
|
/// - cursor at line 12 is not moved
|
||||||
|
///
|
||||||
|
/// This is useful when you moved a cursor but you don't want to move the viewport.
|
||||||
|
InViewport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CursorMove {
|
||||||
|
pub(crate) fn next_cursor(
|
||||||
|
&self,
|
||||||
|
(row, col): (usize, usize),
|
||||||
|
lines: &[String],
|
||||||
|
viewport: &Viewport,
|
||||||
|
) -> Option<(usize, usize)> {
|
||||||
|
use CursorMove::*;
|
||||||
|
|
||||||
|
fn fit_col(col: usize, line: &str) -> usize {
|
||||||
|
cmp::min(col, line.chars().count())
|
||||||
|
}
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Forward if col >= lines[row].chars().count() => {
|
||||||
|
(row + 1 < lines.len()).then(|| (row + 1, 0))
|
||||||
|
}
|
||||||
|
Forward => Some((row, col + 1)),
|
||||||
|
Back if col == 0 => {
|
||||||
|
let row = row.checked_sub(1)?;
|
||||||
|
Some((row, lines[row].chars().count()))
|
||||||
|
}
|
||||||
|
Back => Some((row, col - 1)),
|
||||||
|
Up => {
|
||||||
|
let row = row.checked_sub(1)?;
|
||||||
|
Some((row, fit_col(col, &lines[row])))
|
||||||
|
}
|
||||||
|
Down => Some((row + 1, fit_col(col, lines.get(row + 1)?))),
|
||||||
|
Head => Some((row, 0)),
|
||||||
|
End => Some((row, lines[row].chars().count())),
|
||||||
|
WordForward => {
|
||||||
|
if let Some(col) = find_word_start_forward(&lines[row], col) {
|
||||||
|
Some((row, col))
|
||||||
|
} else if row + 1 < lines.len() {
|
||||||
|
Some((row + 1, 0))
|
||||||
|
} else {
|
||||||
|
Some((row, lines[row].chars().count()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WordBack => {
|
||||||
|
if let Some(col) = find_word_start_backward(&lines[row], col) {
|
||||||
|
Some((row, col))
|
||||||
|
} else if row > 0 {
|
||||||
|
Some((row - 1, lines[row - 1].chars().count()))
|
||||||
|
} else {
|
||||||
|
Some((row, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InViewport => {
|
||||||
|
let (row_top, col_top, row_bottom, col_bottom) = viewport.position();
|
||||||
|
|
||||||
|
let row = row.clamp(row_top as usize, row_bottom as usize);
|
||||||
|
let row = cmp::min(row, lines.len() - 1);
|
||||||
|
let col = col.clamp(col_top as usize, col_bottom as usize);
|
||||||
|
let col = fit_col(col, &lines[row]);
|
||||||
|
|
||||||
|
Some((row, col))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
236
lib/cli/tui/widgets/textarea/behaviour/highlight.rs
Normal file
236
lib/cli/tui/widgets/textarea/behaviour/highlight.rs
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
use {
|
||||||
|
super::util::spaces,
|
||||||
|
ratatui::{
|
||||||
|
style::Style,
|
||||||
|
text::{Line, Span},
|
||||||
|
},
|
||||||
|
std::{borrow::Cow, cmp::Ordering, iter},
|
||||||
|
unicode_width::UnicodeWidthChar as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum Boundary {
|
||||||
|
Cursor(Style),
|
||||||
|
Select(Style),
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Boundary {
|
||||||
|
fn cmp(&self, other: &Boundary) -> Ordering {
|
||||||
|
fn rank(b: &Boundary) -> u8 {
|
||||||
|
match b {
|
||||||
|
Boundary::Cursor(_) => 3,
|
||||||
|
Boundary::Select(_) => 1,
|
||||||
|
Boundary::End => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rank(self).cmp(&rank(other))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn style(&self) -> Option<Style> {
|
||||||
|
match self {
|
||||||
|
Boundary::Cursor(s) => Some(*s),
|
||||||
|
Boundary::Select(s) => Some(*s),
|
||||||
|
Boundary::End => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DisplayTextBuilder {
|
||||||
|
tab_len: u8,
|
||||||
|
width: usize,
|
||||||
|
mask: Option<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayTextBuilder {
|
||||||
|
fn new(tab_len: u8, mask: Option<char>) -> Self {
|
||||||
|
Self {
|
||||||
|
tab_len,
|
||||||
|
width: 0,
|
||||||
|
mask,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build<'s>(&mut self, s: &'s str) -> Cow<'s, str> {
|
||||||
|
if let Some(ch) = self.mask {
|
||||||
|
// Note: We don't need to track width on masking text since width of tab character is fixed
|
||||||
|
let masked = iter::repeat(ch).take(s.chars().count()).collect();
|
||||||
|
return Cow::Owned(masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tab = spaces(self.tab_len);
|
||||||
|
let mut buf = String::new();
|
||||||
|
for (i, c) in s.char_indices() {
|
||||||
|
if c == '\t' {
|
||||||
|
if buf.is_empty() {
|
||||||
|
buf.reserve(s.len());
|
||||||
|
buf.push_str(&s[..i]);
|
||||||
|
}
|
||||||
|
if self.tab_len > 0 {
|
||||||
|
let len = self.tab_len as usize - (self.width % self.tab_len as usize);
|
||||||
|
buf.push_str(&tab[..len]);
|
||||||
|
self.width += len;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !buf.is_empty() {
|
||||||
|
buf.push(c);
|
||||||
|
}
|
||||||
|
self.width += c.width().unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !buf.is_empty() {
|
||||||
|
Cow::Owned(buf)
|
||||||
|
} else {
|
||||||
|
Cow::Borrowed(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LineHighlighter<'a> {
|
||||||
|
line: &'a str,
|
||||||
|
spans: Vec<Span<'a>>,
|
||||||
|
boundaries: Vec<(Boundary, usize)>, // TODO: Consider smallvec
|
||||||
|
style_begin: Style,
|
||||||
|
cursor_at_end: bool,
|
||||||
|
cursor_style: Style,
|
||||||
|
tab_len: u8,
|
||||||
|
mask: Option<char>,
|
||||||
|
select_at_end: bool,
|
||||||
|
select_style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> LineHighlighter<'a> {
|
||||||
|
pub fn new(
|
||||||
|
line: &'a str,
|
||||||
|
cursor_style: Style,
|
||||||
|
tab_len: u8,
|
||||||
|
mask: Option<char>,
|
||||||
|
select_style: Style,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
line,
|
||||||
|
spans: vec![],
|
||||||
|
boundaries: vec![],
|
||||||
|
style_begin: Style::default(),
|
||||||
|
cursor_at_end: false,
|
||||||
|
cursor_style,
|
||||||
|
tab_len,
|
||||||
|
mask,
|
||||||
|
select_at_end: false,
|
||||||
|
select_style,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_line(&mut self, cursor_col: usize, style: Style) {
|
||||||
|
if let Some((start, c)) = self.line.char_indices().nth(cursor_col) {
|
||||||
|
self.boundaries.push((Boundary::Cursor(self.cursor_style), start));
|
||||||
|
self.boundaries.push((Boundary::End, start + c.len_utf8()));
|
||||||
|
} else {
|
||||||
|
self.cursor_at_end = true;
|
||||||
|
}
|
||||||
|
self.style_begin = style;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "search")]
|
||||||
|
pub fn search(&mut self, matches: impl Iterator<Item = (usize, usize)>, style: Style) {
|
||||||
|
for (start, end) in matches {
|
||||||
|
if start != end {
|
||||||
|
self.boundaries.push((Boundary::Search(style), start));
|
||||||
|
self.boundaries.push((Boundary::End, end));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selection(
|
||||||
|
&mut self,
|
||||||
|
current_row: usize,
|
||||||
|
start_row: usize,
|
||||||
|
start_off: usize,
|
||||||
|
end_row: usize,
|
||||||
|
end_off: usize,
|
||||||
|
) {
|
||||||
|
let (start, end) = if current_row == start_row {
|
||||||
|
if start_row == end_row {
|
||||||
|
(start_off, end_off)
|
||||||
|
} else {
|
||||||
|
self.select_at_end = true;
|
||||||
|
(start_off, self.line.len())
|
||||||
|
}
|
||||||
|
} else if current_row == end_row {
|
||||||
|
(0, end_off)
|
||||||
|
} else if start_row < current_row && current_row < end_row {
|
||||||
|
self.select_at_end = true;
|
||||||
|
(0, self.line.len())
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if start != end {
|
||||||
|
self.boundaries.push((Boundary::Select(self.select_style), start));
|
||||||
|
self.boundaries.push((Boundary::End, end));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_spans(self) -> Line<'a> {
|
||||||
|
let Self {
|
||||||
|
line,
|
||||||
|
mut spans,
|
||||||
|
mut boundaries,
|
||||||
|
tab_len,
|
||||||
|
style_begin,
|
||||||
|
cursor_style,
|
||||||
|
cursor_at_end,
|
||||||
|
mask,
|
||||||
|
select_at_end,
|
||||||
|
select_style,
|
||||||
|
} = self;
|
||||||
|
let mut builder = DisplayTextBuilder::new(tab_len, mask);
|
||||||
|
|
||||||
|
if boundaries.is_empty() {
|
||||||
|
let built = builder.build(line);
|
||||||
|
if !built.is_empty() {
|
||||||
|
spans.push(Span::styled(built, style_begin));
|
||||||
|
}
|
||||||
|
if cursor_at_end {
|
||||||
|
spans.push(Span::styled(" ", cursor_style));
|
||||||
|
} else if select_at_end {
|
||||||
|
spans.push(Span::styled(" ", select_style));
|
||||||
|
}
|
||||||
|
return Line::from(spans);
|
||||||
|
}
|
||||||
|
|
||||||
|
boundaries.sort_unstable_by(|(l, i), (r, j)| match i.cmp(j) {
|
||||||
|
Ordering::Equal => l.cmp(r),
|
||||||
|
o => o,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut style = style_begin;
|
||||||
|
let mut start = 0;
|
||||||
|
let mut stack = vec![];
|
||||||
|
|
||||||
|
for (next_boundary, end) in boundaries {
|
||||||
|
if start < end {
|
||||||
|
spans.push(Span::styled(builder.build(&line[start..end]), style));
|
||||||
|
}
|
||||||
|
|
||||||
|
style = if let Some(s) = next_boundary.style() {
|
||||||
|
stack.push(style);
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
stack.pop().unwrap_or(style_begin)
|
||||||
|
};
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
if start != line.len() {
|
||||||
|
spans.push(Span::styled(builder.build(&line[start..]), style));
|
||||||
|
}
|
||||||
|
|
||||||
|
if cursor_at_end {
|
||||||
|
spans.push(Span::styled(" ", cursor_style));
|
||||||
|
} else if select_at_end {
|
||||||
|
spans.push(Span::styled(" ", select_style));
|
||||||
|
}
|
||||||
|
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
|
}
|
||||||
383
lib/cli/tui/widgets/textarea/behaviour/input.rs
Normal file
383
lib/cli/tui/widgets/textarea/behaviour/input.rs
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
|
||||||
|
/// Backend-agnostic key input kind.
|
||||||
|
///
|
||||||
|
/// This type is marked as `#[non_exhaustive]` since more keys may be supported in the future.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq, Default)]
|
||||||
|
pub enum Key {
|
||||||
|
/// Normal letter key input
|
||||||
|
Char(char),
|
||||||
|
/// F1, F2, F3, ... keys
|
||||||
|
// F(u8),
|
||||||
|
/// Backspace key
|
||||||
|
Backspace,
|
||||||
|
/// Enter or return key
|
||||||
|
Enter,
|
||||||
|
/// Left arrow key
|
||||||
|
Left,
|
||||||
|
/// Right arrow key
|
||||||
|
Right,
|
||||||
|
/// Up arrow key
|
||||||
|
Up,
|
||||||
|
/// Down arrow key
|
||||||
|
Down,
|
||||||
|
/// Tab key
|
||||||
|
Tab,
|
||||||
|
/// Delete key
|
||||||
|
Delete,
|
||||||
|
/// Home key
|
||||||
|
Home,
|
||||||
|
/// End key
|
||||||
|
End,
|
||||||
|
/// Escape key
|
||||||
|
Esc,
|
||||||
|
/// Copy key. This key is supported by termwiz only
|
||||||
|
Copy,
|
||||||
|
/// Cut key. This key is supported by termwiz only
|
||||||
|
Cut,
|
||||||
|
/// Paste key. This key is supported by termwiz only
|
||||||
|
Paste,
|
||||||
|
/// An invalid key input (this key is always ignored by [`TextArea`](crate::TextArea))
|
||||||
|
#[default]
|
||||||
|
Null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backend-agnostic key input type.
|
||||||
|
///
|
||||||
|
/// When `crossterm`, `termion`, `termwiz` features are enabled, converting respective key input types into this
|
||||||
|
/// `Input` type is defined.
|
||||||
|
/// ```no_run
|
||||||
|
/// use tui_textarea::{TextArea, Input, Key};
|
||||||
|
/// use crossterm::event::{Event, read};
|
||||||
|
///
|
||||||
|
/// let event = read().unwrap();
|
||||||
|
///
|
||||||
|
/// // `Input::from` can convert backend-native event into `Input`
|
||||||
|
/// let input = Input::from(event.clone());
|
||||||
|
/// // or `Into::into`
|
||||||
|
/// let input: Input = event.clone().into();
|
||||||
|
/// // Conversion from `KeyEvent` value is also available
|
||||||
|
/// if let Event::Key(key) = event {
|
||||||
|
/// let input = Input::from(key);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Creating `Input` instance directly can cause backend-agnostic input as follows.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use tui_textarea::{TextArea, Input, Key};
|
||||||
|
///
|
||||||
|
/// let mut textarea = TextArea::default();
|
||||||
|
///
|
||||||
|
/// // Input Ctrl+A
|
||||||
|
/// textarea.input(Input {
|
||||||
|
/// key: Key::Char('a'),
|
||||||
|
/// ctrl: true,
|
||||||
|
/// alt: false,
|
||||||
|
/// shift: false,
|
||||||
|
/// });
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Hash, Eq)]
|
||||||
|
pub struct Input {
|
||||||
|
/// Typed key.
|
||||||
|
pub key: Key,
|
||||||
|
/// Ctrl modifier key. `true` means Ctrl key was pressed.
|
||||||
|
pub ctrl: bool,
|
||||||
|
/// Alt modifier key. `true` means Alt key was pressed.
|
||||||
|
pub alt: bool,
|
||||||
|
/// Shift modifier key. `true` means Shift key was pressed.
|
||||||
|
pub shift: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Input {
|
||||||
|
/// Returns Some(char) if the Input is a single char without any modifiers (except Shift for
|
||||||
|
/// case) and None otherwise.
|
||||||
|
pub fn maybe_char(&self) -> Option<char> {
|
||||||
|
match self {
|
||||||
|
Input {
|
||||||
|
key: Key::Char(c),
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
..
|
||||||
|
} => Some(*c),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input represents a new line, except is caused by Enter key without
|
||||||
|
/// any modifiers. Useful for using Enter as a confirm key instead of a new line key.
|
||||||
|
/// - Ctrl+Enter key
|
||||||
|
/// - Alt+Enter key
|
||||||
|
/// - Shift+Enter key
|
||||||
|
/// - Char is \n or \r
|
||||||
|
#[inline]
|
||||||
|
pub fn is_newline_except_enter(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Input {
|
||||||
|
key: Key::Char('\n' | '\r'),
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
..
|
||||||
|
} | Input {
|
||||||
|
key: Key::Enter,
|
||||||
|
ctrl: true,
|
||||||
|
..
|
||||||
|
} | Input {
|
||||||
|
key: Key::Enter,
|
||||||
|
alt: true,
|
||||||
|
..
|
||||||
|
} | Input {
|
||||||
|
key: Key::Enter,
|
||||||
|
shift: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input represents a new line (including Enter key).
|
||||||
|
/// - Enter key
|
||||||
|
///
|
||||||
|
/// + all conditions of `is_newline_except_enter`
|
||||||
|
#[inline]
|
||||||
|
pub fn is_newline(&self) -> bool {
|
||||||
|
self.is_newline_except_enter() || self.key == Key::Enter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is a single char without any modifiers (except Shift for upper
|
||||||
|
/// case).
|
||||||
|
#[inline]
|
||||||
|
pub fn is_char(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Input {
|
||||||
|
key: Key::Char(_),
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is a Tab
|
||||||
|
#[inline]
|
||||||
|
pub fn is_tab(&self) -> bool {
|
||||||
|
self.key == Key::Tab && !self.ctrl && !self.alt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is Backspace
|
||||||
|
#[inline]
|
||||||
|
pub fn is_backspace(&self) -> bool {
|
||||||
|
self.key == Key::Backspace && !self.ctrl && !self.alt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is Delete
|
||||||
|
#[inline]
|
||||||
|
pub fn is_delete(&self) -> bool {
|
||||||
|
self.key == Key::Delete && !self.ctrl && !self.alt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is key down arrow
|
||||||
|
#[inline]
|
||||||
|
pub fn is_down(&self) -> bool {
|
||||||
|
self.key == Key::Down && !self.ctrl && !self.alt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is key up arrow
|
||||||
|
#[inline]
|
||||||
|
pub fn is_up(&self) -> bool {
|
||||||
|
self.key == Key::Up && !self.ctrl && !self.alt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is key left arrow
|
||||||
|
#[inline]
|
||||||
|
pub fn is_left(&self) -> bool {
|
||||||
|
self.key == Key::Left && !self.ctrl && !self.alt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is key right arrow
|
||||||
|
#[inline]
|
||||||
|
pub fn is_right(&self) -> bool {
|
||||||
|
self.key == Key::Right && !self.ctrl && !self.alt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is key Home
|
||||||
|
#[inline]
|
||||||
|
pub fn is_home(&self) -> bool {
|
||||||
|
self.key == Key::Home
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is key End
|
||||||
|
#[inline]
|
||||||
|
pub fn is_end(&self) -> bool {
|
||||||
|
self.key == Key::End
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is ctrl+left
|
||||||
|
#[inline]
|
||||||
|
pub fn is_ctrl_left(&self) -> bool {
|
||||||
|
self.key == Key::Left && self.ctrl && !self.alt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the Input is ctrl+right
|
||||||
|
#[inline]
|
||||||
|
pub fn is_ctrl_right(&self) -> bool {
|
||||||
|
self.key == Key::Right && self.ctrl && !self.alt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a string representing the kind of key input.
|
||||||
|
/// e.g ":delete", ":backspace", ":tab", ":enter", "char"
|
||||||
|
/// or empty string if the key is null.
|
||||||
|
/// uses the is_* methods to determine the kind of key input.
|
||||||
|
pub fn kind(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
i if i.is_delete() => ":delete",
|
||||||
|
i if i.is_backspace() => ":backspace",
|
||||||
|
i if i.is_tab() => ":tab",
|
||||||
|
i if i.is_newline_except_enter() => ":non-enter-newline",
|
||||||
|
i if i.is_newline() => ":newline",
|
||||||
|
i if i.is_char() => ":char",
|
||||||
|
i if i.is_down() => ":down",
|
||||||
|
i if i.is_up() => ":up",
|
||||||
|
i if i.is_left() => ":left",
|
||||||
|
i if i.is_right() => ":right",
|
||||||
|
i if i.is_home() => ":home",
|
||||||
|
i if i.is_end() => ":end",
|
||||||
|
i if i.is_ctrl_left() => ":word-left",
|
||||||
|
i if i.is_ctrl_right() => ":word-right",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Event> for Input {
|
||||||
|
/// Convert [`crossterm::event::Event`] into [`Input`].
|
||||||
|
fn from(event: Event) -> Self {
|
||||||
|
match event {
|
||||||
|
Event::Key(key) => Self::from(key),
|
||||||
|
_ => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<KeyCode> for Key {
|
||||||
|
/// Convert [`crossterm::event::KeyCode`] into [`Key`].
|
||||||
|
fn from(code: KeyCode) -> Self {
|
||||||
|
match code {
|
||||||
|
KeyCode::Char(c) => Key::Char(c),
|
||||||
|
KeyCode::Backspace => Key::Backspace,
|
||||||
|
KeyCode::Enter => Key::Enter,
|
||||||
|
KeyCode::Left => Key::Left,
|
||||||
|
KeyCode::Right => Key::Right,
|
||||||
|
KeyCode::Up => Key::Up,
|
||||||
|
KeyCode::Down => Key::Down,
|
||||||
|
KeyCode::Tab => Key::Tab,
|
||||||
|
KeyCode::Delete => Key::Delete,
|
||||||
|
KeyCode::Home => Key::Home,
|
||||||
|
KeyCode::End => Key::End,
|
||||||
|
KeyCode::Esc => Key::Esc,
|
||||||
|
_ => Key::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<KeyEvent> for Input {
|
||||||
|
/// Convert [`crossterm::event::KeyEvent`] into [`Input`].
|
||||||
|
fn from(key: KeyEvent) -> Self {
|
||||||
|
if key.kind == KeyEventKind::Release {
|
||||||
|
// On Windows or when `crossterm::event::PushKeyboardEnhancementFlags` is set,
|
||||||
|
// key release event can be reported. Ignore it. (#14)
|
||||||
|
return Self::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||||
|
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||||
|
let key = Key::from(key.code);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
key,
|
||||||
|
ctrl,
|
||||||
|
alt,
|
||||||
|
shift,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use {super::*, crossterm::event::KeyEventState};
|
||||||
|
|
||||||
|
fn key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
|
||||||
|
KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: KeyEventState::empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_to_input() {
|
||||||
|
for (from, to) in [
|
||||||
|
(
|
||||||
|
key_event(KeyCode::Char('a'), KeyModifiers::empty()),
|
||||||
|
input(Key::Char('a'), false, false, false),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
key_event(KeyCode::Enter, KeyModifiers::empty()),
|
||||||
|
input(Key::Enter, false, false, false),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
key_event(KeyCode::Left, KeyModifiers::CONTROL),
|
||||||
|
input(Key::Left, true, false, false),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
key_event(KeyCode::Right, KeyModifiers::SHIFT),
|
||||||
|
input(Key::Right, false, false, true),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
key_event(KeyCode::Home, KeyModifiers::ALT),
|
||||||
|
input(Key::Home, false, true, false),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
key_event(KeyCode::NumLock, KeyModifiers::CONTROL),
|
||||||
|
input(Key::Null, true, false, false),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(Input::from(from), to, "{:?} -> {:?}", from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_to_input() {
|
||||||
|
for (from, to) in [
|
||||||
|
(
|
||||||
|
Event::Key(key_event(KeyCode::Char('a'), KeyModifiers::empty())),
|
||||||
|
input(Key::Char('a'), false, false, false),
|
||||||
|
),
|
||||||
|
(Event::FocusGained, input(Key::Null, false, false, false)),
|
||||||
|
] {
|
||||||
|
assert_eq!(Input::from(from.clone()), to, "{:?} -> {:?}", from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression for https://github.com/rhysd/tui-textarea/issues/14
|
||||||
|
#[test]
|
||||||
|
fn ignore_key_release_event() {
|
||||||
|
let mut from = key_event(KeyCode::Char('a'), KeyModifiers::empty());
|
||||||
|
from.kind = KeyEventKind::Release;
|
||||||
|
let to = input(Key::Null, false, false, false);
|
||||||
|
assert_eq!(Input::from(from), to, "{:?} -> {:?}", from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn input(key: Key, ctrl: bool, alt: bool, shift: bool) -> Input {
|
||||||
|
Input {
|
||||||
|
key,
|
||||||
|
ctrl,
|
||||||
|
alt,
|
||||||
|
shift,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
lib/cli/tui/widgets/textarea/behaviour/scroll.rs
Normal file
182
lib/cli/tui/widgets/textarea/behaviour/scroll.rs
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
use crate::tui::widgets::textarea::core::widget::Viewport;
|
||||||
|
|
||||||
|
/// Specify how to scroll the textarea.
|
||||||
|
///
|
||||||
|
/// This type is marked as `#[non_exhaustive]` since more variations may be supported in the future. Note that the cursor will
|
||||||
|
/// not move until it goes out the viewport. See also: [`TextArea::scroll`]
|
||||||
|
///
|
||||||
|
/// [`TextArea::scroll`]: https://docs.rs/tui-textarea/latest/tui_textarea/struct.TextArea.html#method.scroll
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Scrolling {
|
||||||
|
/// Scroll the textarea by rows (vertically) and columns (horizontally). Passing positive scroll amounts to `rows` and `cols`
|
||||||
|
/// scolls it to down and right. Negative integers means the opposite directions. `(i16, i16)` pair can be converted into
|
||||||
|
/// `Scrolling::Delta` where 1st element means rows and 2nd means columns.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::buffer::Buffer;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// # use ratatui::widgets::Widget as _;
|
||||||
|
/// use tui_textarea::{TextArea, Scrolling};
|
||||||
|
///
|
||||||
|
/// // Let's say terminal height is 8.
|
||||||
|
///
|
||||||
|
/// // Create textarea with 20 lines "0", "1", "2", "3", ...
|
||||||
|
/// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect();
|
||||||
|
/// # // Call `render` at least once to populate terminal size
|
||||||
|
/// # let r = Rect { x: 0, y: 0, width: 24, height: 8 };
|
||||||
|
/// # let mut b = Buffer::empty(r.clone());
|
||||||
|
/// # textarea.render(r, &mut b);
|
||||||
|
///
|
||||||
|
/// // Scroll down by 2 lines.
|
||||||
|
/// textarea.scroll(Scrolling::Delta{rows: 2, cols: 0});
|
||||||
|
/// assert_eq!(textarea.cursor(), (2, 0));
|
||||||
|
///
|
||||||
|
/// // (1, 0) is converted into Scrolling::Delta{rows: 1, cols: 0}
|
||||||
|
/// textarea.scroll((1, 0));
|
||||||
|
/// assert_eq!(textarea.cursor(), (3, 0));
|
||||||
|
/// ```
|
||||||
|
Delta { rows: i16, cols: i16 },
|
||||||
|
/// Scroll down the textarea by one page.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::buffer::Buffer;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// # use ratatui::widgets::Widget as _;
|
||||||
|
/// use tui_textarea::{TextArea, Scrolling};
|
||||||
|
///
|
||||||
|
/// // Let's say terminal height is 8.
|
||||||
|
///
|
||||||
|
/// // Create textarea with 20 lines "0", "1", "2", "3", ...
|
||||||
|
/// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect();
|
||||||
|
/// # // Call `render` at least once to populate terminal size
|
||||||
|
/// # let r = Rect { x: 0, y: 0, width: 24, height: 8 };
|
||||||
|
/// # let mut b = Buffer::empty(r.clone());
|
||||||
|
/// # textarea.render(r, &mut b);
|
||||||
|
///
|
||||||
|
/// // Scroll down by one page (8 lines)
|
||||||
|
/// textarea.scroll(Scrolling::PageDown);
|
||||||
|
/// assert_eq!(textarea.cursor(), (8, 0));
|
||||||
|
/// textarea.scroll(Scrolling::PageDown);
|
||||||
|
/// assert_eq!(textarea.cursor(), (16, 0));
|
||||||
|
/// textarea.scroll(Scrolling::PageDown);
|
||||||
|
/// assert_eq!(textarea.cursor(), (19, 0)); // Reached bottom of the textarea
|
||||||
|
/// ```
|
||||||
|
PageDown,
|
||||||
|
/// Scroll up the textarea by one page.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::buffer::Buffer;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// # use ratatui::widgets::Widget as _;
|
||||||
|
/// use tui_textarea::{TextArea, Scrolling, CursorMove};
|
||||||
|
///
|
||||||
|
/// // Let's say terminal height is 8.
|
||||||
|
///
|
||||||
|
/// // Create textarea with 20 lines "0", "1", "2", "3", ...
|
||||||
|
/// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect();
|
||||||
|
/// # // Call `render` at least once to populate terminal size
|
||||||
|
/// # let r = Rect { x: 0, y: 0, width: 24, height: 8 };
|
||||||
|
/// # let mut b = Buffer::empty(r.clone());
|
||||||
|
/// # textarea.render(r.clone(), &mut b);
|
||||||
|
///
|
||||||
|
/// // Go to the last line at first
|
||||||
|
/// textarea.move_cursor(CursorMove::Bottom);
|
||||||
|
/// assert_eq!(textarea.cursor(), (19, 0));
|
||||||
|
/// # // Call `render` to populate terminal size
|
||||||
|
/// # textarea.render(r.clone(), &mut b);
|
||||||
|
///
|
||||||
|
/// // Scroll up by one page (8 lines)
|
||||||
|
/// textarea.scroll(Scrolling::PageUp);
|
||||||
|
/// assert_eq!(textarea.cursor(), (11, 0));
|
||||||
|
/// textarea.scroll(Scrolling::PageUp);
|
||||||
|
/// assert_eq!(textarea.cursor(), (7, 0)); // Reached top of the textarea
|
||||||
|
/// ```
|
||||||
|
PageUp,
|
||||||
|
/// Scroll down the textarea by half of the page.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::buffer::Buffer;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// # use ratatui::widgets::Widget as _;
|
||||||
|
/// use tui_textarea::{TextArea, Scrolling};
|
||||||
|
///
|
||||||
|
/// // Let's say terminal height is 8.
|
||||||
|
///
|
||||||
|
/// // Create textarea with 10 lines "0", "1", "2", "3", ...
|
||||||
|
/// let mut textarea: TextArea = (0..10).into_iter().map(|i| i.to_string()).collect();
|
||||||
|
/// # // Call `render` at least once to populate terminal size
|
||||||
|
/// # let r = Rect { x: 0, y: 0, width: 24, height: 8 };
|
||||||
|
/// # let mut b = Buffer::empty(r.clone());
|
||||||
|
/// # textarea.render(r, &mut b);
|
||||||
|
///
|
||||||
|
/// // Scroll down by half-page (4 lines)
|
||||||
|
/// textarea.scroll(Scrolling::HalfPageDown);
|
||||||
|
/// assert_eq!(textarea.cursor(), (4, 0));
|
||||||
|
/// textarea.scroll(Scrolling::HalfPageDown);
|
||||||
|
/// assert_eq!(textarea.cursor(), (8, 0));
|
||||||
|
/// textarea.scroll(Scrolling::HalfPageDown);
|
||||||
|
/// assert_eq!(textarea.cursor(), (9, 0)); // Reached bottom of the textarea
|
||||||
|
/// ```
|
||||||
|
HalfPageDown,
|
||||||
|
/// Scroll up the textarea by half of the page.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::buffer::Buffer;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// # use ratatui::widgets::Widget as _;
|
||||||
|
/// use tui_textarea::{TextArea, Scrolling, CursorMove};
|
||||||
|
///
|
||||||
|
/// // Let's say terminal height is 8.
|
||||||
|
///
|
||||||
|
/// // Create textarea with 20 lines "0", "1", "2", "3", ...
|
||||||
|
/// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect();
|
||||||
|
/// # // Call `render` at least once to populate terminal size
|
||||||
|
/// # let r = Rect { x: 0, y: 0, width: 24, height: 8 };
|
||||||
|
/// # let mut b = Buffer::empty(r.clone());
|
||||||
|
/// # textarea.render(r.clone(), &mut b);
|
||||||
|
///
|
||||||
|
/// // Go to the last line at first
|
||||||
|
/// textarea.move_cursor(CursorMove::Bottom);
|
||||||
|
/// assert_eq!(textarea.cursor(), (19, 0));
|
||||||
|
/// # // Call `render` to populate terminal size
|
||||||
|
/// # textarea.render(r.clone(), &mut b);
|
||||||
|
///
|
||||||
|
/// // Scroll up by half-page (4 lines)
|
||||||
|
/// textarea.scroll(Scrolling::HalfPageUp);
|
||||||
|
/// assert_eq!(textarea.cursor(), (15, 0));
|
||||||
|
/// textarea.scroll(Scrolling::HalfPageUp);
|
||||||
|
/// assert_eq!(textarea.cursor(), (11, 0));
|
||||||
|
/// ```
|
||||||
|
HalfPageUp,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scrolling {
|
||||||
|
pub(crate) fn scroll(self, viewport: &mut Viewport) {
|
||||||
|
let (rows, cols) = match self {
|
||||||
|
Self::Delta { rows, cols } => (rows, cols),
|
||||||
|
Self::PageDown => {
|
||||||
|
let (_, _, _, height) = viewport.rect();
|
||||||
|
(height as i16, 0)
|
||||||
|
}
|
||||||
|
Self::PageUp => {
|
||||||
|
let (_, _, _, height) = viewport.rect();
|
||||||
|
(-(height as i16), 0)
|
||||||
|
}
|
||||||
|
Self::HalfPageDown => {
|
||||||
|
let (_, _, _, height) = viewport.rect();
|
||||||
|
((height as i16) / 2, 0)
|
||||||
|
}
|
||||||
|
Self::HalfPageUp => {
|
||||||
|
let (_, _, _, height) = viewport.rect();
|
||||||
|
(-(height as i16) / 2, 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
viewport.scroll(rows, cols);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(i16, i16)> for Scrolling {
|
||||||
|
fn from((rows, cols): (i16, i16)) -> Self {
|
||||||
|
Self::Delta { rows, cols }
|
||||||
|
}
|
||||||
|
}
|
||||||
63
lib/cli/tui/widgets/textarea/behaviour/util.rs
Normal file
63
lib/cli/tui/widgets/textarea/behaviour/util.rs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
pub fn spaces(size: u8) -> &'static str {
|
||||||
|
const SPACES: &str = " ";
|
||||||
|
&SPACES[..size as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Pos {
|
||||||
|
pub row: usize,
|
||||||
|
pub col: usize,
|
||||||
|
pub offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pos {
|
||||||
|
pub fn new(row: usize, col: usize, offset: usize) -> Self {
|
||||||
|
Self { row, col, offset }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||||
|
enum CharKind {
|
||||||
|
Space,
|
||||||
|
Punct,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CharKind {
|
||||||
|
fn new(c: char) -> Self {
|
||||||
|
if c.is_whitespace() {
|
||||||
|
Self::Space
|
||||||
|
} else if c.is_ascii_punctuation() {
|
||||||
|
Self::Punct
|
||||||
|
} else {
|
||||||
|
Self::Other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_word_start_forward(line: &str, start_col: usize) -> Option<usize> {
|
||||||
|
let mut it = line.chars().enumerate().skip(start_col);
|
||||||
|
let mut prev = CharKind::new(it.next()?.1);
|
||||||
|
for (col, c) in it {
|
||||||
|
let cur = CharKind::new(c);
|
||||||
|
if cur != CharKind::Space && prev != cur {
|
||||||
|
return Some(col);
|
||||||
|
}
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_word_start_backward(line: &str, start_col: usize) -> Option<usize> {
|
||||||
|
let idx = line.char_indices().nth(start_col).map(|(i, _)| i).unwrap_or(line.len());
|
||||||
|
let mut it = line[..idx].chars().rev().enumerate();
|
||||||
|
let mut cur = CharKind::new(it.next()?.1);
|
||||||
|
for (i, c) in it {
|
||||||
|
let next = CharKind::new(c);
|
||||||
|
if cur != CharKind::Space && next != cur {
|
||||||
|
return Some(start_col - i);
|
||||||
|
}
|
||||||
|
cur = next;
|
||||||
|
}
|
||||||
|
(cur != CharKind::Space).then_some(0)
|
||||||
|
}
|
||||||
65
lib/cli/tui/widgets/textarea/core/builder.rs
Normal file
65
lib/cli/tui/widgets/textarea/core/builder.rs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
use {
|
||||||
|
super::{validation::ValidatorFn, TextArea},
|
||||||
|
ratatui::{layout::Alignment, style::Style, widgets::Block},
|
||||||
|
};
|
||||||
|
|
||||||
|
impl<'a> TextArea<'a> {
|
||||||
|
/// Set the style of textarea. By default, textarea is not styled.
|
||||||
|
pub fn with_style(mut self, style: Style) -> Self {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the block of textarea. By default, no block is set.
|
||||||
|
pub fn with_block(mut self, block: Block<'a>) -> Self {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the placeholder text. The text is set in the textarea when no text is input. Setting a
|
||||||
|
/// non-empty string `""` enables the placeholder. The default value is an empty string so the
|
||||||
|
/// placeholder is disabled by default. To customize the text style, see
|
||||||
|
/// [`TextArea::set_placeholder_style`].
|
||||||
|
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
|
||||||
|
self.placeholder = placeholder.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the style of the placeholder text. The default style is a dark gray text.
|
||||||
|
pub fn with_placeholder_style(mut self, style: Style) -> Self {
|
||||||
|
self.placeholder_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specify a character masking the text. All characters in the textarea will be replaced by
|
||||||
|
/// this character. This API is useful for making a kind of credentials form such as a password
|
||||||
|
/// input.
|
||||||
|
pub fn with_mask_char(mut self, mask: Option<char>) -> Self {
|
||||||
|
self.mask = mask;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the style of cursor. By default, a cursor is rendered in the reversed color. Setting the
|
||||||
|
/// same style as cursor line hides a cursor.
|
||||||
|
pub fn with_cursor_style(mut self, style: Style) -> Self {
|
||||||
|
self.cursor_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set text alignment. When [`Alignment::Center`] or [`Alignment::Right`] is set, line number
|
||||||
|
/// is automatically disabled because those alignments don't work well with line numbers.
|
||||||
|
pub fn with_alignment(mut self, alignment: Alignment) -> Self {
|
||||||
|
self.alignment = alignment;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_validations(
|
||||||
|
mut self,
|
||||||
|
validations: impl IntoIterator<
|
||||||
|
Item = impl Fn(&str) -> Result<(), String> + Send + Sync + 'static,
|
||||||
|
>,
|
||||||
|
) -> Self {
|
||||||
|
self.validators.extend(validations.into_iter().map(ValidatorFn::new));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
122
lib/cli/tui/widgets/textarea/core/getset.rs
Normal file
122
lib/cli/tui/widgets/textarea/core/getset.rs
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
use {
|
||||||
|
super::TextArea,
|
||||||
|
crate::tui::widgets::textarea::behaviour::{cursor::CursorMove, scroll::Scrolling},
|
||||||
|
ratatui::{layout::Alignment, style::Style, widgets::Block},
|
||||||
|
};
|
||||||
|
|
||||||
|
impl<'a> TextArea<'a> {
|
||||||
|
/// Get the current style of textarea.
|
||||||
|
pub fn style(&self) -> Style {
|
||||||
|
self.style
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the block of textarea which was set by [`TextArea::set_block`].
|
||||||
|
pub fn remove_block(&mut self) {
|
||||||
|
self.block = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the block of textarea if exists.
|
||||||
|
pub fn block<'s>(&'s self) -> Option<&'s Block<'a>> {
|
||||||
|
self.block.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the placeholder text. An empty string means the placeholder is disabled. The default
|
||||||
|
/// value is an empty string.
|
||||||
|
pub fn placeholder_text(&self) -> &'_ str {
|
||||||
|
self.placeholder.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the placeholder style. When the placeholder text is empty, it returns `None` since the
|
||||||
|
/// placeholder is disabled. The default style is a dark gray text.
|
||||||
|
pub fn placeholder_style(&self) -> Option<Style> {
|
||||||
|
if self.placeholder.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(self.placeholder_style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the style of cursor.
|
||||||
|
pub fn cursor_style(&self) -> Style {
|
||||||
|
self.cursor_style
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get slice of line texts. This method borrows the content, but not moves. Note that the
|
||||||
|
/// returned slice will never be empty because an empty text means a slice containing one empty
|
||||||
|
/// line. This is correct since any text file must end with a newline.
|
||||||
|
pub fn lines(&'a self) -> &'a [String] {
|
||||||
|
&self.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert [`TextArea`] instance into line texts.
|
||||||
|
pub fn into_lines(self) -> Vec<String> {
|
||||||
|
self.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current cursor position. 0-base character-wise (row, col) cursor position.
|
||||||
|
pub fn cursor(&self) -> (usize, usize) {
|
||||||
|
self.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current selection range as a pair of the start position and the end position. The
|
||||||
|
/// range is bounded inclusively below and exclusively above. The positions are 0-base
|
||||||
|
/// character-wise (row, col) values. The first element of the pair is always smaller than the
|
||||||
|
/// second one even when it is ahead of the cursor. When no text is selected, this method
|
||||||
|
/// returns `None`.
|
||||||
|
pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
|
||||||
|
self.selection_start.map(|pos| {
|
||||||
|
if pos > self.cursor {
|
||||||
|
(self.cursor, pos)
|
||||||
|
} else {
|
||||||
|
(pos, self.cursor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current text alignment. The default alignment is [`Alignment::Left`].
|
||||||
|
pub fn alignment(&self) -> Alignment {
|
||||||
|
self.alignment
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the textarea has a empty content.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.lines == [""]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the yanked text. Text is automatically yanked when deleting strings by
|
||||||
|
/// [`TextArea::delete_line_by_head`], [`TextArea::delete_line_by_end`],
|
||||||
|
/// [`TextArea::delete_word`], [`TextArea::delete_next_word`], [`TextArea::delete_str`],
|
||||||
|
/// [`TextArea::copy`], and [`TextArea::cut`]. When multiple lines were yanked, they are always
|
||||||
|
/// joined with `\n`.
|
||||||
|
pub fn yank_text(&self) -> String {
|
||||||
|
self.yank.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a yanked text. The text can be inserted by [`TextArea::paste`]. `\n` and `\r\n` are
|
||||||
|
/// recognized as newline but `\r` isn't.
|
||||||
|
pub fn set_yank_text(&mut self, text: impl Into<String>) {
|
||||||
|
// `str::lines` is not available since it strips a newline at end
|
||||||
|
let lines: Vec<_> = text
|
||||||
|
.into()
|
||||||
|
.split('\n')
|
||||||
|
.map(|s| s.strip_suffix('\r').unwrap_or(s).to_string())
|
||||||
|
.collect();
|
||||||
|
self.yank = lines.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scroll the textarea. See [`Scrolling`] for the argument.
|
||||||
|
/// The cursor will not move until it goes out the viewport. When the cursor position is outside
|
||||||
|
/// the viewport after scroll, the cursor position will be adjusted to stay in the viewport
|
||||||
|
/// using the same logic as [`CursorMove::InViewport`].
|
||||||
|
pub fn scroll(&mut self, scrolling: impl Into<Scrolling>) {
|
||||||
|
self.scroll_with_shift(scrolling.into(), self.selection_start.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_with_shift(&mut self, scrolling: Scrolling, shift: bool) {
|
||||||
|
if shift && self.selection_start.is_none() {
|
||||||
|
self.selection_start = Some(self.cursor);
|
||||||
|
}
|
||||||
|
scrolling.scroll(&mut self.viewport);
|
||||||
|
self.move_cursor_with_shift(CursorMove::InViewport, shift);
|
||||||
|
}
|
||||||
|
}
|
||||||
514
lib/cli/tui/widgets/textarea/core/mod.rs
Normal file
514
lib/cli/tui/widgets/textarea/core/mod.rs
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
pub mod builder;
|
||||||
|
pub mod getset;
|
||||||
|
pub mod validation;
|
||||||
|
pub mod widget;
|
||||||
|
|
||||||
|
use {
|
||||||
|
super::behaviour::{
|
||||||
|
cursor::CursorMove,
|
||||||
|
highlight::LineHighlighter,
|
||||||
|
input::Input,
|
||||||
|
util::{spaces, Pos},
|
||||||
|
},
|
||||||
|
ratatui::{
|
||||||
|
layout::Alignment,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::Line,
|
||||||
|
widgets::Block,
|
||||||
|
},
|
||||||
|
std::{
|
||||||
|
cmp::Ordering,
|
||||||
|
fmt::{self, Debug},
|
||||||
|
},
|
||||||
|
unicode_width::UnicodeWidthChar as _,
|
||||||
|
validation::ValidatorFn,
|
||||||
|
widget::Viewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum YankText {
|
||||||
|
Piece(String),
|
||||||
|
Chunk(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for YankText {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Piece(String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for YankText {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self::Piece(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<Vec<String>> for YankText {
|
||||||
|
fn from(mut c: Vec<String>) -> Self {
|
||||||
|
match c.len() {
|
||||||
|
0 => Self::default(),
|
||||||
|
1 => Self::Piece(c.remove(0)),
|
||||||
|
_ => Self::Chunk(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for YankText {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Piece(s) => write!(f, "{}", s),
|
||||||
|
Self::Chunk(ss) => write!(f, "{}", ss.join("\n")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A type to manage state of textarea. These are some important methods:
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TextArea<'a> {
|
||||||
|
pub(crate) viewport: Viewport,
|
||||||
|
pub(crate) cursor_style: Style,
|
||||||
|
pub(crate) placeholder: String,
|
||||||
|
pub(crate) placeholder_style: Style,
|
||||||
|
lines: Vec<String>,
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
style: Style,
|
||||||
|
cursor: (usize, usize), // 0-base
|
||||||
|
tab_len: u8,
|
||||||
|
cursor_line_style: Style,
|
||||||
|
yank: YankText,
|
||||||
|
alignment: Alignment,
|
||||||
|
mask: Option<char>,
|
||||||
|
selection_start: Option<(usize, usize)>,
|
||||||
|
select_style: Style,
|
||||||
|
validators: Vec<ValidatorFn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, I> From<I> for TextArea<'a>
|
||||||
|
where
|
||||||
|
I: IntoIterator,
|
||||||
|
I::Item: Into<String>,
|
||||||
|
{
|
||||||
|
/// Convert any iterator whose elements can be converted into [`String`] into [`TextArea`]. Each
|
||||||
|
/// [`String`] element is handled as line. Ensure that the strings don't contain any newlines.
|
||||||
|
/// This method is useful to create [`TextArea`] from [`std::str::Lines`].
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use tui::widgets::TextArea;
|
||||||
|
/// let textarea = TextArea::from(["hello", "world"]);
|
||||||
|
/// ```
|
||||||
|
fn from(i: I) -> Self {
|
||||||
|
Self::new(i.into_iter().map(|s| s.into()).collect::<Vec<String>>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Default for TextArea<'a> {
|
||||||
|
/// Create [`TextArea`] instance with empty text content.
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(vec![String::new()])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TextArea<'a> {
|
||||||
|
/// Create [`TextArea`] instance with given lines.
|
||||||
|
pub fn new(mut lines: Vec<String>) -> Self {
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
lines,
|
||||||
|
block: None,
|
||||||
|
style: Style::default(),
|
||||||
|
cursor: (0, 0),
|
||||||
|
tab_len: 2,
|
||||||
|
cursor_line_style: Style::default(),
|
||||||
|
viewport: Viewport::default(),
|
||||||
|
cursor_style: Style::default().add_modifier(Modifier::REVERSED),
|
||||||
|
yank: YankText::default(),
|
||||||
|
alignment: Alignment::Left,
|
||||||
|
placeholder: String::new(),
|
||||||
|
placeholder_style: Style::default().fg(Color::DarkGray),
|
||||||
|
mask: None,
|
||||||
|
selection_start: None,
|
||||||
|
select_style: Style::default().bg(Color::LightBlue),
|
||||||
|
validators: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a key input with default key mappings.
|
||||||
|
/// Recieves any `impl Into<Input>`, e.g. `crossterm`'s key event types. [`Input`] so this
|
||||||
|
/// method can take the event values directly. This method returns if the input modified text
|
||||||
|
/// contents or not in the textarea.
|
||||||
|
pub fn input(&mut self, input: impl Into<Input>) -> bool {
|
||||||
|
let input = input.into();
|
||||||
|
let modified = match input.kind() {
|
||||||
|
":char" => {
|
||||||
|
if let Some(c) = input.maybe_char() {
|
||||||
|
self.insert_char(c);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
":non-enter-newline" => self.insert_newline(),
|
||||||
|
":tab" => self.insert_tab(),
|
||||||
|
":backspace" => self.delete_char(),
|
||||||
|
":delete" => self.delete_next_char(),
|
||||||
|
":down" => self.move_cursor_with_shift(CursorMove::Down, input.shift),
|
||||||
|
":up" => self.move_cursor_with_shift(CursorMove::Up, input.shift),
|
||||||
|
":right" => self.move_cursor_with_shift(CursorMove::Forward, input.shift),
|
||||||
|
":left" => self.move_cursor_with_shift(CursorMove::Back, input.shift),
|
||||||
|
":home" => self.move_cursor_with_shift(CursorMove::Head, input.shift),
|
||||||
|
":end" => self.move_cursor_with_shift(CursorMove::End, input.shift),
|
||||||
|
":word-right" => self.move_cursor_with_shift(CursorMove::WordForward, input.shift),
|
||||||
|
":word-left" => self.move_cursor_with_shift(CursorMove::WordBack, input.shift),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check invariants
|
||||||
|
debug_assert!(!self.lines.is_empty(), "no line after {:?}", input);
|
||||||
|
let (r, c) = self.cursor;
|
||||||
|
debug_assert!(
|
||||||
|
self.lines.len() > r,
|
||||||
|
"cursor {:?} exceeds max lines {} after {:?}",
|
||||||
|
self.cursor,
|
||||||
|
self.lines.len(),
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
debug_assert!(
|
||||||
|
self.lines[r].chars().count() >= c,
|
||||||
|
"cursor {:?} exceeds max col {} at line {:?} after {:?}",
|
||||||
|
self.cursor,
|
||||||
|
self.lines[r].chars().count(),
|
||||||
|
self.lines[r],
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
|
modified
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a single character at current cursor position.
|
||||||
|
pub fn insert_char(&mut self, c: char) {
|
||||||
|
if c == '\n' || c == '\r' {
|
||||||
|
self.insert_newline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.delete_selection(false);
|
||||||
|
let (row, col) = self.cursor;
|
||||||
|
let line = &mut self.lines[row];
|
||||||
|
let i = line.char_indices().nth(col).map(|(i, _)| i).unwrap_or(line.len());
|
||||||
|
line.insert(i, c);
|
||||||
|
self.cursor.1 += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a string at current cursor position. This method returns if some text was inserted or
|
||||||
|
/// not in the textarea. Both `\n` and `\r\n` are recognized as newlines but `\r` isn't.
|
||||||
|
pub fn insert_str<S: AsRef<str>>(&mut self, s: S) -> bool {
|
||||||
|
let modified = self.delete_selection(false);
|
||||||
|
let mut lines: Vec<_> =
|
||||||
|
s.as_ref().split('\n').map(|s| s.strip_suffix('\r').unwrap_or(s).to_string()).collect();
|
||||||
|
match lines.len() {
|
||||||
|
0 => modified,
|
||||||
|
1 => self.insert_piece(lines.remove(0)),
|
||||||
|
_ => self.insert_chunk(lines),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_chunk(&mut self, chunk: Vec<String>) -> bool {
|
||||||
|
debug_assert!(chunk.len() > 1, "Chunk size must be > 1: {:?}", chunk);
|
||||||
|
|
||||||
|
let (row, _col) = self.cursor;
|
||||||
|
let (row, col) = (
|
||||||
|
row + chunk.len() - 1,
|
||||||
|
chunk[chunk.len() - 1].chars().count(),
|
||||||
|
);
|
||||||
|
self.cursor = (row, col);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_piece(&mut self, s: String) -> bool {
|
||||||
|
if s.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (row, col) = self.cursor;
|
||||||
|
let line = &mut self.lines[row];
|
||||||
|
debug_assert!(
|
||||||
|
!s.contains('\n'),
|
||||||
|
"string given to TextArea::insert_piece must not contain newline: {:?}",
|
||||||
|
line,
|
||||||
|
);
|
||||||
|
|
||||||
|
let i = line.char_indices().nth(col).map(|(i, _)| i).unwrap_or(line.len());
|
||||||
|
line.insert_str(i, &s);
|
||||||
|
|
||||||
|
self.cursor.1 += s.chars().count();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_range(&mut self, start: Pos, end: Pos, should_yank: bool) {
|
||||||
|
self.cursor = (start.row, start.col);
|
||||||
|
|
||||||
|
if start.row == end.row {
|
||||||
|
let removed =
|
||||||
|
self.lines[start.row].drain(start.offset..end.offset).as_str().to_string();
|
||||||
|
if should_yank {
|
||||||
|
self.yank = removed.clone().into();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deleted = vec![self.lines[start.row].drain(start.offset..).as_str().to_string()];
|
||||||
|
deleted.extend(self.lines.drain(start.row + 1..end.row));
|
||||||
|
if start.row + 1 < self.lines.len() {
|
||||||
|
let mut last_line = self.lines.remove(start.row + 1);
|
||||||
|
self.lines[start.row].push_str(&last_line[end.offset..]);
|
||||||
|
last_line.truncate(end.offset);
|
||||||
|
deleted.push(last_line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_yank {
|
||||||
|
self.yank = YankText::Chunk(deleted.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a string from the current cursor position. The `chars` parameter means number of
|
||||||
|
/// characters, not a byte length of the string. Newlines at the end of lines are counted in the
|
||||||
|
/// number. This method returns if some text was deleted or not.
|
||||||
|
pub fn delete_str(&mut self, chars: usize) -> bool {
|
||||||
|
if self.delete_selection(false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if chars == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (start_row, start_col) = self.cursor;
|
||||||
|
|
||||||
|
let mut remaining = chars;
|
||||||
|
let mut find_end = move |line: &str| {
|
||||||
|
let mut col = 0usize;
|
||||||
|
for (i, _) in line.char_indices() {
|
||||||
|
if remaining == 0 {
|
||||||
|
return Some((i, col));
|
||||||
|
}
|
||||||
|
col += 1;
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
if remaining == 0 {
|
||||||
|
Some((line.len(), col))
|
||||||
|
} else {
|
||||||
|
remaining -= 1;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = &self.lines[start_row];
|
||||||
|
let start_offset =
|
||||||
|
{ line.char_indices().nth(start_col).map(|(i, _)| i).unwrap_or(line.len()) };
|
||||||
|
|
||||||
|
// First line
|
||||||
|
if let Some((offset_delta, _col_delta)) = find_end(&line[start_offset..]) {
|
||||||
|
let end_offset = start_offset + offset_delta;
|
||||||
|
let removed =
|
||||||
|
self.lines[start_row].drain(start_offset..end_offset).as_str().to_string();
|
||||||
|
self.yank = removed.clone().into();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut r = start_row + 1;
|
||||||
|
let mut offset = 0;
|
||||||
|
let mut col = 0;
|
||||||
|
|
||||||
|
while r < self.lines.len() {
|
||||||
|
let line = &self.lines[r];
|
||||||
|
if let Some((o, c)) = find_end(line) {
|
||||||
|
offset = o;
|
||||||
|
col = c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
r += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = Pos::new(start_row, start_col, start_offset);
|
||||||
|
let end = Pos::new(r, col, offset);
|
||||||
|
self.delete_range(start, end, true);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a tab at current cursor position. Note that this method does nothing when the tab
|
||||||
|
/// length is 0. This method returns if a tab string was inserted or not in the textarea.
|
||||||
|
pub fn insert_tab(&mut self) -> bool {
|
||||||
|
let modified = self.delete_selection(false);
|
||||||
|
if self.tab_len == 0 {
|
||||||
|
return modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (row, col) = self.cursor;
|
||||||
|
let width: usize = self.lines[row].chars().take(col).map(|c| c.width().unwrap_or(0)).sum();
|
||||||
|
let len = self.tab_len - (width % self.tab_len as usize) as u8;
|
||||||
|
self.insert_piece(spaces(len).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a newline at current cursor position.
|
||||||
|
pub fn insert_newline(&mut self) -> bool {
|
||||||
|
self.delete_selection(false);
|
||||||
|
|
||||||
|
let (row, col) = self.cursor;
|
||||||
|
let line = &mut self.lines[row];
|
||||||
|
let offset = line.char_indices().nth(col).map(|(i, _)| i).unwrap_or(line.len());
|
||||||
|
let next_line = line[offset..].to_string();
|
||||||
|
line.truncate(offset);
|
||||||
|
|
||||||
|
self.lines.insert(row + 1, next_line);
|
||||||
|
self.cursor = (row + 1, 0);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a newline from **head** of current cursor line. This method returns if a newline was
|
||||||
|
/// deleted or not in the textarea. When some text is selected, it is deleted instead.
|
||||||
|
pub fn delete_newline(&mut self) -> bool {
|
||||||
|
if self.delete_selection(false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (row, _) = self.cursor;
|
||||||
|
if row == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = self.lines.remove(row);
|
||||||
|
let prev_line = &mut self.lines[row - 1];
|
||||||
|
|
||||||
|
self.cursor = (row - 1, prev_line.chars().count());
|
||||||
|
prev_line.push_str(&line);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete one character before cursor. When the cursor is at head of line, the newline before
|
||||||
|
/// the cursor will be removed. This method returns if some text was deleted or not in the
|
||||||
|
/// textarea. When some text is selected, it is deleted instead.
|
||||||
|
pub fn delete_char(&mut self) -> bool {
|
||||||
|
if self.delete_selection(false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (row, col) = self.cursor;
|
||||||
|
if col == 0 {
|
||||||
|
return self.delete_newline();
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = &mut self.lines[row];
|
||||||
|
if let Some((offset, _c)) = line.char_indices().nth(col - 1) {
|
||||||
|
line.remove(offset);
|
||||||
|
self.cursor.1 -= 1;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete one character next to cursor. When the cursor is at end of line, the newline next to
|
||||||
|
/// the cursor will be removed. This method returns if a character was deleted or not in the
|
||||||
|
/// textarea.
|
||||||
|
pub fn delete_next_char(&mut self) -> bool {
|
||||||
|
if self.delete_selection(false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let before = self.cursor;
|
||||||
|
self.move_cursor_with_shift(CursorMove::Forward, false);
|
||||||
|
if before == self.cursor {
|
||||||
|
return false; // Cursor didn't move, meant no character at next of cursor.
|
||||||
|
}
|
||||||
|
|
||||||
|
self.delete_char()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start text selection at the cursor position. If text selection is already ongoing, the start
|
||||||
|
/// position is reset.
|
||||||
|
pub fn start_selection(&mut self) {
|
||||||
|
self.selection_start = Some(self.cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the current text selection. This method does nothing if text selection is not ongoing.
|
||||||
|
pub fn cancel_selection(&mut self) {
|
||||||
|
self.selection_start = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_offset(&self, row: usize, col: usize) -> usize {
|
||||||
|
let line = self.lines.get(row).unwrap_or(&self.lines[self.lines.len() - 1]);
|
||||||
|
line.char_indices().nth(col).map(|(i, _)| i).unwrap_or(line.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the style used for text selection. The default style is light blue.
|
||||||
|
pub fn set_selection_style(&mut self, style: Style) {
|
||||||
|
self.select_style = style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the style used for text selection.
|
||||||
|
pub fn selection_style(&mut self) -> Style {
|
||||||
|
self.select_style
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection_positions(&self) -> Option<(Pos, Pos)> {
|
||||||
|
let (sr, sc) = self.selection_start?;
|
||||||
|
let (er, ec) = self.cursor;
|
||||||
|
let (so, eo) = (self.line_offset(sr, sc), self.line_offset(er, ec));
|
||||||
|
let s = Pos::new(sr, sc, so);
|
||||||
|
let e = Pos::new(er, ec, eo);
|
||||||
|
match (sr, so).cmp(&(er, eo)) {
|
||||||
|
Ordering::Less => Some((s, e)),
|
||||||
|
Ordering::Equal => None,
|
||||||
|
Ordering::Greater => Some((e, s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_selection_positions(&mut self) -> Option<(Pos, Pos)> {
|
||||||
|
let range = self.selection_positions();
|
||||||
|
self.cancel_selection();
|
||||||
|
range
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_selection(&mut self, should_yank: bool) -> bool {
|
||||||
|
if let Some((s, e)) = self.take_selection_positions() {
|
||||||
|
self.delete_range(s, e, should_yank);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_cursor_with_shift(&mut self, m: CursorMove, shift: bool) -> bool {
|
||||||
|
if let Some(cursor) = m.next_cursor(self.cursor, &self.lines, &self.viewport) {
|
||||||
|
if shift {
|
||||||
|
if self.selection_start.is_none() {
|
||||||
|
self.start_selection();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.cancel_selection();
|
||||||
|
}
|
||||||
|
self.cursor = cursor;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn line_spans<'b>(&'b self, line: &'b str, row: usize) -> Line<'b> {
|
||||||
|
let mut hl = LineHighlighter::new(
|
||||||
|
line,
|
||||||
|
self.cursor_style,
|
||||||
|
self.tab_len,
|
||||||
|
self.mask,
|
||||||
|
self.select_style,
|
||||||
|
);
|
||||||
|
|
||||||
|
if row == self.cursor.0 {
|
||||||
|
hl.cursor_line(self.cursor.1, self.cursor_line_style);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((start, end)) = self.selection_positions() {
|
||||||
|
hl.selection(row, start.row, start.offset, end.row, end.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
hl.into_spans()
|
||||||
|
}
|
||||||
|
}
|
||||||
58
lib/cli/tui/widgets/textarea/core/validation/mod.rs
Normal file
58
lib/cli/tui/widgets/textarea/core/validation/mod.rs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
pub mod validators;
|
||||||
|
|
||||||
|
use {super::TextArea, std::sync::Arc};
|
||||||
|
|
||||||
|
pub enum ValidationResult {
|
||||||
|
Valid,
|
||||||
|
Invalid(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidatorFnType = Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ValidatorFn(ValidatorFnType);
|
||||||
|
|
||||||
|
impl ValidatorFn {
|
||||||
|
pub fn new<F>(f: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(&str) -> Result<(), String> + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
ValidatorFn(Arc::new(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to call the inner function
|
||||||
|
pub fn call(&self, arg: &str) -> Result<(), String> {
|
||||||
|
(self.0)(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ValidatorFn {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "CloneableFn {{ ... }}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TextArea<'a> {
|
||||||
|
pub fn validate(&self) -> ValidationResult {
|
||||||
|
let lines = self.lines().join("\n");
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
// For each validation function, call it and collect the errors
|
||||||
|
for validation in &self.validators {
|
||||||
|
match validation.call(&lines) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => errors.push(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
ValidationResult::Valid
|
||||||
|
} else {
|
||||||
|
ValidationResult::Invalid(errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
matches!(self.validate(), ValidationResult::Valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
pub fn required_validator(input: &str) -> Result<(), String> {
|
||||||
|
if input.is_empty() {
|
||||||
|
Err("This field is required".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
157
lib/cli/tui/widgets/textarea/core/widget.rs
Normal file
157
lib/cli/tui/widgets/textarea/core/widget.rs
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
use {
|
||||||
|
super::TextArea,
|
||||||
|
ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::Rect,
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::{Paragraph, Widget},
|
||||||
|
},
|
||||||
|
std::{
|
||||||
|
cmp,
|
||||||
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// &mut 'a (u16, u16, u16, u16) is not available since `render` method takes immutable reference of
|
||||||
|
// TextArea instance. In the case, the TextArea instance cannot be accessed from any other objects
|
||||||
|
// since it is mutablly borrowed.
|
||||||
|
//
|
||||||
|
// `ratatui::Frame::render_stateful_widget` would be an assumed way to render a stateful widget. But
|
||||||
|
// at this point we stick with using `ratatui::Frame::render_widget` because it is simpler API.
|
||||||
|
// Users don't need to manage states of textarea instances separately.
|
||||||
|
// https://docs.rs/ratatui/latest/ratatui/terminal/struct.Frame.html#method.render_stateful_widget
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct Viewport(AtomicU64);
|
||||||
|
|
||||||
|
impl Clone for Viewport {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
let u = self.0.load(Ordering::Relaxed);
|
||||||
|
Viewport(AtomicU64::new(u))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewport {
|
||||||
|
pub fn scroll_top(&self) -> (u16, u16) {
|
||||||
|
let u = self.0.load(Ordering::Relaxed);
|
||||||
|
((u >> 16) as u16, u as u16)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rect(&self) -> (u16, u16, u16, u16) {
|
||||||
|
let u = self.0.load(Ordering::Relaxed);
|
||||||
|
let width = (u >> 48) as u16;
|
||||||
|
let height = (u >> 32) as u16;
|
||||||
|
let row = (u >> 16) as u16;
|
||||||
|
let col = u as u16;
|
||||||
|
(row, col, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn position(&self) -> (u16, u16, u16, u16) {
|
||||||
|
let (row_top, col_top, width, height) = self.rect();
|
||||||
|
let row_bottom = row_top.saturating_add(height).saturating_sub(1);
|
||||||
|
let col_bottom = col_top.saturating_add(width).saturating_sub(1);
|
||||||
|
|
||||||
|
(
|
||||||
|
row_top,
|
||||||
|
col_top,
|
||||||
|
cmp::max(row_top, row_bottom),
|
||||||
|
cmp::max(col_top, col_bottom),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(&self, row: u16, col: u16, width: u16, height: u16) {
|
||||||
|
// Pack four u16 values into one u64 value
|
||||||
|
let u =
|
||||||
|
((width as u64) << 48) | ((height as u64) << 32) | ((row as u64) << 16) | col as u64;
|
||||||
|
self.0.store(u, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll(&mut self, rows: i16, cols: i16) {
|
||||||
|
fn apply_scroll(pos: u16, delta: i16) -> u16 {
|
||||||
|
if delta >= 0 {
|
||||||
|
pos.saturating_add(delta as u16)
|
||||||
|
} else {
|
||||||
|
pos.saturating_sub(-delta as u16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let u = self.0.get_mut();
|
||||||
|
let row = apply_scroll((*u >> 16) as u16, rows);
|
||||||
|
let col = apply_scroll(*u as u16, cols);
|
||||||
|
*u = (*u & 0xffff_ffff_0000_0000) | ((row as u64) << 16) | (col as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn next_scroll_top(prev_top: u16, cursor: u16, len: u16) -> u16 {
|
||||||
|
if cursor < prev_top {
|
||||||
|
cursor
|
||||||
|
} else if prev_top + len <= cursor {
|
||||||
|
cursor + 1 - len
|
||||||
|
} else {
|
||||||
|
prev_top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TextArea<'a> {
|
||||||
|
fn text_widget(&'a self, top_row: usize, height: usize) -> Text<'a> {
|
||||||
|
let lines_len = self.lines().len();
|
||||||
|
let bottom_row = cmp::min(top_row + height, lines_len);
|
||||||
|
let mut lines = Vec::with_capacity(bottom_row - top_row);
|
||||||
|
for (i, line) in self.lines()[top_row..bottom_row].iter().enumerate() {
|
||||||
|
lines.push(self.line_spans(line.as_str(), top_row + i));
|
||||||
|
}
|
||||||
|
Text::from(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder_widget(&'a self) -> Text<'a> {
|
||||||
|
let cursor = Span::styled(" ", self.cursor_style);
|
||||||
|
let text = Span::raw(self.placeholder.as_str());
|
||||||
|
Text::from(Line::from(vec![cursor, text]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_top_row(&self, prev_top: u16, height: u16) -> u16 {
|
||||||
|
next_scroll_top(prev_top, self.cursor().0 as u16, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_top_col(&self, prev_top: u16, width: u16) -> u16 {
|
||||||
|
let cursor = self.cursor().1 as u16;
|
||||||
|
next_scroll_top(prev_top, cursor, width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for &TextArea<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let Rect { width, height, .. } =
|
||||||
|
if let Some(b) = self.block() { b.inner(area) } else { area };
|
||||||
|
|
||||||
|
let (top_row, top_col) = self.viewport.scroll_top();
|
||||||
|
let top_row = self.scroll_top_row(top_row, height);
|
||||||
|
let top_col = self.scroll_top_col(top_col, width);
|
||||||
|
|
||||||
|
let (text, style) = if !self.placeholder.is_empty() && self.is_empty() {
|
||||||
|
(self.placeholder_widget(), self.placeholder_style)
|
||||||
|
} else {
|
||||||
|
(self.text_widget(top_row as _, height as _), self.style())
|
||||||
|
};
|
||||||
|
|
||||||
|
// To get fine control over the text color and the surrrounding block they have to be
|
||||||
|
// rendered separately / see https://github.com/ratatui-org/ratatui/issues/144
|
||||||
|
let mut text_area = area;
|
||||||
|
let mut inner = Paragraph::new(text).style(style).alignment(self.alignment());
|
||||||
|
if let Some(b) = self.block() {
|
||||||
|
text_area = b.inner(area);
|
||||||
|
// ratatui does not need `clone()` call because `Block` implements `WidgetRef` and `&T`
|
||||||
|
// implements `Widget` where `T: WidgetRef`. So `b.render` internally calls
|
||||||
|
// `b.render_ref` and it doesn't move out `self`.
|
||||||
|
b.render(area, buf)
|
||||||
|
}
|
||||||
|
if top_col != 0 {
|
||||||
|
inner = inner.scroll((0, top_col));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store scroll top position for rendering on the next tick
|
||||||
|
self.viewport.store(top_row, top_col, width, height);
|
||||||
|
|
||||||
|
inner.render(text_area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
lib/cli/tui/widgets/textarea/mod.rs
Normal file
17
lib/cli/tui/widgets/textarea/mod.rs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
pub(super) mod behaviour {
|
||||||
|
pub(super) mod cursor;
|
||||||
|
pub(super) mod highlight;
|
||||||
|
pub(super) mod input;
|
||||||
|
pub(super) mod scroll;
|
||||||
|
pub(super) mod util;
|
||||||
|
}
|
||||||
|
|
||||||
|
mod core;
|
||||||
|
|
||||||
|
pub use {
|
||||||
|
behaviour::input::{Input, Key},
|
||||||
|
core::{
|
||||||
|
validation::{validators, ValidationResult},
|
||||||
|
TextArea,
|
||||||
|
},
|
||||||
|
};
|
||||||
39
lib/lib.rs
39
lib/lib.rs
@ -1,18 +1,21 @@
|
|||||||
#![doc(
|
#![doc(
|
||||||
html_logo_url = "https://raw.githubusercontent.com/lucodear/lool/master/.github/img/logo.svg"
|
html_logo_url = "https://raw.githubusercontent.com/lucodear/lool/master/.github/img/logo.svg"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
|
||||||
#[cfg(feature = "sched")]
|
#[cfg(feature = "cli.tui")]
|
||||||
pub mod sched;
|
pub use cli::tui;
|
||||||
|
|
||||||
#[cfg(feature = "logger")]
|
#[cfg(feature = "sched")]
|
||||||
pub mod logger;
|
pub mod sched;
|
||||||
|
|
||||||
#[cfg(feature = "macros")]
|
#[cfg(feature = "logger")]
|
||||||
pub mod macros;
|
pub mod logger;
|
||||||
|
|
||||||
#[cfg(feature = "utils")]
|
#[cfg(feature = "macros")]
|
||||||
pub mod utils;
|
pub mod macros;
|
||||||
|
|
||||||
|
#[cfg(feature = "utils")]
|
||||||
|
pub mod utils;
|
||||||
|
|||||||
@ -155,12 +155,14 @@ impl Log for ConsoleLogger {
|
|||||||
format!(" {} ", styled_record.time)
|
format!(" {} ", styled_record.time)
|
||||||
};
|
};
|
||||||
|
|
||||||
let (ctx, ctx_separator) = if self.name.is_empty() {
|
let ctx = if self.name.is_empty() {
|
||||||
("".to_string(), "".to_string())
|
"".to_string()
|
||||||
} else {
|
} else {
|
||||||
(format!("{} »", self.name), ":".to_string())
|
format!("{} »", self.name)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let ctx_separator = if styled_record.line.is_empty() { "" } else { ":" };
|
||||||
|
|
||||||
// print to stdout
|
// print to stdout
|
||||||
println!(
|
println!(
|
||||||
"{}{}| {} | {}{}{} - {}",
|
"{}{}| {} | {}{}{} - {}",
|
||||||
|
|||||||
@ -14,11 +14,7 @@ fn between_10_and_20_seconds() {
|
|||||||
|
|
||||||
for i in 0..20 {
|
for i in 0..20 {
|
||||||
let addend = if i < 11 { i } else { i - 10 - 1 };
|
let addend = if i < 11 { i } else { i - 10 - 1 };
|
||||||
let minute = if i < 11 {
|
let minute = if i < 11 { initial_minute } else { initial_minute + 1 };
|
||||||
initial_minute
|
|
||||||
} else {
|
|
||||||
initial_minute + 1
|
|
||||||
};
|
|
||||||
|
|
||||||
next = rules.next_match_from(next).unwrap();
|
next = rules.next_match_from(next).unwrap();
|
||||||
println!("[{i}]: {:?}", next);
|
println!("[{i}]: {:?}", next);
|
||||||
|
|||||||
@ -118,11 +118,7 @@ pub(crate) fn get_next_run_time(
|
|||||||
) -> Option<DateTime<Local>> {
|
) -> Option<DateTime<Local>> {
|
||||||
let mut next_run_so_far: Option<DateTime<Local>> = None;
|
let mut next_run_so_far: Option<DateTime<Local>> = None;
|
||||||
|
|
||||||
let base = if let Some(from) = from {
|
let base = if let Some(from) = from { from } else { Local::now() };
|
||||||
from
|
|
||||||
} else {
|
|
||||||
Local::now()
|
|
||||||
};
|
|
||||||
|
|
||||||
for rule in rules {
|
for rule in rules {
|
||||||
let rule_next_run = rule.next_from(base);
|
let rule_next_run = rule.next_from(base);
|
||||||
|
|||||||
@ -181,11 +181,7 @@ impl<Tz: TimeZone> LoolDate<Tz> {
|
|||||||
pub fn set_nanos(&mut self, nanos: u32) {
|
pub fn set_nanos(&mut self, nanos: u32) {
|
||||||
// avoid `whith_nanosecond` returning None for > 2_000_000_000 values
|
// avoid `whith_nanosecond` returning None for > 2_000_000_000 values
|
||||||
// so we can safely unwrap the result
|
// so we can safely unwrap the result
|
||||||
let nanos = if nanos > 2_000_000_000 {
|
let nanos = if nanos > 2_000_000_000 { 1_999_999_999 } else { nanos };
|
||||||
1_999_999_999
|
|
||||||
} else {
|
|
||||||
nanos
|
|
||||||
};
|
|
||||||
self.date = self.date.with_nanosecond(nanos).unwrap();
|
self.date = self.date.with_nanosecond(nanos).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,11 +189,7 @@ impl<Tz: TimeZone> LoolDate<Tz> {
|
|||||||
///
|
///
|
||||||
/// values greater than `2,000,000` will be clamped to `1,999,999`
|
/// values greater than `2,000,000` will be clamped to `1,999,999`
|
||||||
pub fn set_micros(&mut self, micros: u32) {
|
pub fn set_micros(&mut self, micros: u32) {
|
||||||
let micros = if micros > 2_000_000 {
|
let micros = if micros > 2_000_000 { 1_999_999 } else { micros };
|
||||||
1_999_999
|
|
||||||
} else {
|
|
||||||
micros
|
|
||||||
};
|
|
||||||
self.date = self.date.with_nanosecond(micros * 1_000).unwrap();
|
self.date = self.date.with_nanosecond(micros * 1_000).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,3 +4,4 @@ chain_width = 100
|
|||||||
comment_width = 80
|
comment_width = 80
|
||||||
imports_indent = "Block"
|
imports_indent = "Block"
|
||||||
imports_granularity = "One"
|
imports_granularity = "One"
|
||||||
|
single_line_if_else_max_width = 70
|
||||||
Loading…
x
Reference in New Issue
Block a user