diff --git a/Cargo.lock b/Cargo.lock index b8efbe593..cd09a8d5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "ahash" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" +checksum = "42cd52102d3df161c77a887b608d7a4897d7cc112886a9537b738a887a03aaff" dependencies = [ "cfg-if", "once_cell", @@ -46,9 +46,9 @@ checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" [[package]] name = "anstream" -version = "0.6.7" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd2405b3ac1faab2990b74d728624cd9fd115651fcecc7c2d8daf01376275ba" +checksum = "96b09b5178381e0874812a9b157f7fe84982617e48f71f4e3235482775e5b540" dependencies = [ "anstyle", "anstyle-parse", @@ -60,47 +60,47 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.4" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" +checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" [[package]] name = "anstyle-parse" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317b9a89c1868f5ea6ff1d9539a69f45dffc21ce321ac1fd1160dfa48c8e2140" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.1" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" dependencies = [ "anstyle", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "async-trait" -version = "0.1.74" +version = "0.1.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" +checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.49", ] [[package]] @@ -148,9 +148,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.1" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" dependencies = [ "serde", ] @@ -164,6 +164,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d32a994c2b3ca201d9b263612a374263f05e7adde37c4707f693dcd375076d1f" + [[package]] name = "bytes" version = "1.5.0" @@ -200,6 +206,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b" +dependencies = [ + "num-traits", + "serde", +] + [[package]] name = "clap" version = "4.5.1" @@ -234,7 +250,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.49", ] [[package]] @@ -312,14 +328,14 @@ dependencies = [ [[package]] name = "console" -version = "0.15.7" +version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c926e00cc70edefdc64d3a5ff31cc65bb97a3460097762bd23afb4d8145fccf8" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" dependencies = [ "encode_unicode", "lazy_static", "libc", - "windows-sys 0.45.0", + "windows-sys 0.52.0", ] [[package]] @@ -351,22 +367,70 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + [[package]] name = "cpufeatures" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce420fe07aecd3e67c5f910618fe65e94158f6dcc0adf44e00d69ce2bdfe0fd0" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" dependencies = [ "libc", ] +[[package]] +name = "crates-tui" +version = "0.1.0" +dependencies = [ + "color-eyre", + "crates_io_api", + "crossterm", + "futures", + "itertools", + "ratatui", + "tokio", + "tokio-stream", + "tui-input", +] + +[[package]] +name = "crates_io_api" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0122a67287f6795360b83a542cf2fbb3eb57a42729966c9ac792598c909902" +dependencies = [ + "chrono", + "futures", + "reqwest", + "serde", + "serde_derive", + "serde_json", + "serde_path_to_error", + "tokio", + "url", +] + [[package]] name = "crossterm" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.4.2", "crossterm_winapi", "futures-core", "libc", @@ -415,12 +479,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.3" +version = "0.20.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" +checksum = "c376d08ea6aa96aafe61237c7200d1241cb177b7d3a542d791f2d118e9cbb955" dependencies = [ - "darling_core 0.20.3", - "darling_macro 0.20.3", + "darling_core 0.20.6", + "darling_macro 0.20.6", ] [[package]] @@ -439,16 +503,16 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.3" +version = "0.20.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +checksum = "33043dcd19068b8192064c704b3f83eb464f91f1ff527b44a4e2b08d9cdb8855" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim 0.10.0", - "syn 2.0.46", + "syn 2.0.49", ] [[package]] @@ -464,38 +528,38 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.3" +version = "0.20.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +checksum = "c5a91391accf613803c2a9bf9abccdbaa07c54b4244a5b64883f9c3c137c86be" dependencies = [ - "darling_core 0.20.3", + "darling_core 0.20.6", "quote", - "syn 2.0.46", + "syn 2.0.49", ] [[package]] name = "deranged" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eb30d70a07a3b04884d2677f06bec33509dc67ca60d92949e5535352d3191dc" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", ] [[package]] name = "derive_builder" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "660047478bc508c0fde22c868991eec0c40a63e48d610befef466d48e2bee574" +checksum = "8f59169f400d8087f238c5c0c7db6a28af18681717f3b623227d92f397e938c7" dependencies = [ "derive_builder_macro", ] [[package]] name = "derive_builder_core" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b217e6dd1011a54d12f3b920a411b5abd44b1716ecfe94f5f2f2f7b52e08ab7" +checksum = "a4ec317cc3e7ef0928b0ca6e4a634a4d6c001672ae210438cf114a83e56b018d" dependencies = [ "darling 0.14.4", "proc-macro2", @@ -505,9 +569,9 @@ dependencies = [ [[package]] name = "derive_builder_macro" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5f77d7e20ac9153428f7ca14a88aba652adfc7a0ef0a06d654386310ef663b" +checksum = "870368c3fb35b8031abb378861d4460f573b92238ec2152c927a21f77e3e0127" dependencies = [ "derive_builder_core", "syn 1.0.109", @@ -530,10 +594,10 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e8ef033054e131169b8f0f9a7af8f5533a9436fadf3c500ed547f730f07090d" dependencies = [ - "darling 0.20.3", + "darling 0.20.6", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.49", ] [[package]] @@ -584,9 +648,9 @@ dependencies = [ [[package]] name = "either" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" [[package]] name = "encode_unicode" @@ -594,6 +658,15 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -602,24 +675,30 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f258a7194e7f7c2a7837a8913aeab7fd8c383457034fa20ce4dd3dcb813e8eb8" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "eyre" -version = "0.6.9" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80f656be11ddf91bd709454d15d5bd896fbaf4cc3314e69349e4d1569f5b46cd" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" dependencies = [ "indenter", "once_cell", ] +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + [[package]] name = "fnv" version = "1.0.7" @@ -632,6 +711,30 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.30" @@ -688,7 +791,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.49", ] [[package]] @@ -733,9 +836,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" dependencies = [ "cfg-if", "libc", @@ -748,6 +851,25 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "h2" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.13.2" @@ -772,9 +894,9 @@ checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "hermit-abi" -version = "0.3.3" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +checksum = "bd5256b483761cd23699d0da46cc6fd2ee3be420bbe6d020ae4a091e70b7e9fd" [[package]] name = "how-to-collapse-borders" @@ -805,6 +927,40 @@ dependencies = [ "ratatui", ] +[[package]] +name = "http" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "human-panic" version = "1.2.3" @@ -821,12 +977,59 @@ dependencies = [ "uuid", ] +[[package]] +name = "hyper" +version = "0.14.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "indenter" version = "0.3.3" @@ -835,9 +1038,9 @@ checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" [[package]] name = "indexmap" -version = "2.1.0" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +checksum = "233cf39063f058ea2caae4091bf4a3ef70a653afbc026f5c4a4135d114e3c177" dependencies = [ "equivalent", "hashbrown 0.14.3", @@ -849,6 +1052,12 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + [[package]] name = "itertools" version = "0.12.1" @@ -860,9 +1069,18 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.9" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "js-sys" +version = "0.3.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +checksum = "406cda4b368d531c842222cf9d2600a9a4acce8d29423695379c6868a143a9ee" +dependencies = [ + "wasm-bindgen", +] [[package]] name = "json5" @@ -893,7 +1111,7 @@ version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.4.2", "libc", "redox_syscall", ] @@ -906,9 +1124,9 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" [[package]] name = "lipsum" @@ -938,9 +1156,9 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "lru" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2994eeba8ed550fd9b47a0b38f0242bc3344e496483c6180b69139cc2fa5d1d7" +checksum = "db2c024b41519440580066ba82aab04092b333e09066a5eb86c7c4890df31f22" dependencies = [ "hashbrown 0.14.3", ] @@ -956,9 +1174,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minimal-lexical" @@ -968,18 +1192,18 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" dependencies = [ "adler", ] [[package]] name = "mio" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" dependencies = [ "libc", "log", @@ -987,6 +1211,24 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "7.1.3" @@ -1013,6 +1255,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-traits" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -1025,27 +1276,71 @@ dependencies = [ [[package]] name = "num_threads" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ "libc", ] [[package]] name = "object" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "openssl" +version = "0.10.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8" +dependencies = [ + "bitflags 2.4.2", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.49", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] [[package]] name = "option-ext" @@ -1121,11 +1416,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + [[package]] name = "pest" -version = "2.7.5" +version = "2.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae9cee2a55a544be8b89dc6848072af97a20f2422603c10865be2a42b580fff5" +checksum = "219c0dcc30b6a27553f9cc242972b67f75b60eb0db71f0b5462f38b058c41546" dependencies = [ "memchr", "thiserror", @@ -1134,9 +1435,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.7.5" +version = "2.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81d78524685f5ef2a3b3bd1cafbc9fcabb036253d9b1463e726a91cd16e2dfc2" +checksum = "22e1288dbd7786462961e69bfd4df7848c1e37e8b74303dbdab82c3a9cdd2809" dependencies = [ "pest", "pest_generator", @@ -1144,22 +1445,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.5" +version = "2.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68bd1206e71118b5356dae5ddc61c8b11e28b09ef6a31acbd15ea48a28e0c227" +checksum = "1381c29a877c6d34b8c176e734f35d7f7f5b3adaefe940cb4d1bb7af94678e2e" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.49", ] [[package]] name = "pest_meta" -version = "2.7.5" +version = "2.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c747191d4ad9e4a4ab9c8798f1e82a39affe7ef9648390b7e5548d18e099de6" +checksum = "d0934d6907f148c22a3acbda520c7eed243ad7487a30f51f6ce52b58b7077a8a" dependencies = [ "once_cell", "pest", @@ -1178,6 +1479,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + [[package]] name = "powerfmt" version = "0.2.0" @@ -1202,9 +1509,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.74" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2de98502f212cfcea8d0bb305bd0f49d7ebdd75b64ba0a68f937d888f4e0d6db" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] @@ -1256,7 +1563,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcb12f8fbf6c62614b0d56eb352af54f6a22410c3b079eb53ee93c7b97dd31d8" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.4.2", "cassowary", "compact_str", "crossterm", @@ -1403,13 +1710,13 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.2" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.3", + "regex-automata 0.4.5", "regex-syntax 0.8.2", ] @@ -1424,9 +1731,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" dependencies = [ "aho-corasick", "memchr", @@ -1445,6 +1752,46 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +[[package]] +name = "reqwest" +version = "0.11.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "ron" version = "0.8.1" @@ -1452,7 +1799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64", - "bitflags 2.4.1", + "bitflags 2.4.2", "serde", "serde_derive", ] @@ -1475,15 +1822,24 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustix" -version = "0.38.25" +version = "0.38.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc99bc2d4f1fed22595588a013687477aedf3cdcfb26558c559edb67b4d9b22e" +checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.4.2", "errno", "libc", "linux-raw-sys", - "windows-sys 0.48.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", ] [[package]] @@ -1494,9 +1850,18 @@ checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" [[package]] name = "ryu" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" + +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] [[package]] name = "scopeguard" @@ -1504,6 +1869,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.196" @@ -1521,7 +1909,7 @@ checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.49", ] [[package]] @@ -1535,12 +1923,34 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd154a240de39fdebcf5775d2675c204d7c13cf39a4c697be6493c8e734337c" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_spanned" -version = "0.6.4" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ + "form_urlencoded", + "itoa", + "ryu", "serde", ] @@ -1605,9 +2015,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.11.2" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" +checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" [[package]] name = "socket2" @@ -1675,7 +2085,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.46", + "syn 2.0.49", ] [[package]] @@ -1691,15 +2101,54 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.46" +version = "2.0.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89456b690ff72fddcecf231caedbe615c59480c93358a93dfae7fc29e3ebbf0e" +checksum = "915aea9e586f80826ee59f8453c1101f9d1c4b3964cd2460185ee8e299ada496" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a365e8cd18e44762ef95d87f284f4b5cd04107fec2ff3052bd6a3e6069669e67" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "terminal_size" version = "0.3.0" @@ -1721,22 +2170,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.50" +version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.50" +version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.49", ] [[package]] @@ -1779,6 +2228,21 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.36.0" @@ -1806,7 +2270,28 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.49", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", ] [[package]] @@ -1820,13 +2305,14 @@ dependencies = [ "futures-sink", "pin-project-lite", "tokio", + "tracing", ] [[package]] name = "toml" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35" +checksum = "9a9aad4a3066010876e8dcf5a8a06e70a558751117a145c6ce2b82c2e2054290" dependencies = [ "serde", "serde_spanned", @@ -1845,9 +2331,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.21.0" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" +checksum = "2c1b5fd4128cc8d3e0cb74d4ed9a9cc7c7284becd4df68f5f940e1ad123606f6" dependencies = [ "indexmap", "serde", @@ -1856,6 +2342,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + [[package]] name = "tracing" version = "0.1.40" @@ -1875,7 +2367,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.49", ] [[package]] @@ -1928,6 +2420,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tui-big-text" version = "0.4.1" @@ -1972,17 +2470,32 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" [[package]] name = "unicode-width" @@ -1990,6 +2503,17 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + [[package]] name = "utf8parse" version = "0.2.1" @@ -1998,9 +2522,9 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" +checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" dependencies = [ "getrandom", ] @@ -2011,6 +2535,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" @@ -2037,12 +2567,97 @@ dependencies = [ "quote", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e124130aee3fb58c5bdd6b639a0509486b0338acaaae0c84a5124b0f588b7f" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e7e1900c352b609c8488ad12639a311045f40a35491fb69ba8c12f758af70b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.49", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877b9c3f61ceea0e56331985743b13f3d25c406a7098d45180fb5f09bc19ed97" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b30af9e2d358182b5c7449424f017eba305ed32a7010509ede96cdc4696c46ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.49", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f186bd2dcf04330886ce82d6f33dd75a7bfcf69ecf5763b89fcde53b6ac9838" + +[[package]] +name = "web-sys" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96565907687f7aceb35bc5fc03770a8a0471d82e479f25832f54a0e3f4b28446" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "widget-showcase" version = "0.0.0" @@ -2076,15 +2691,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -2095,18 +2701,12 @@ dependencies = [ ] [[package]] -name = "windows-targets" -version = "0.42.2" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-targets 0.52.0", ] [[package]] @@ -2125,10 +2725,19 @@ dependencies = [ ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" +name = "windows-targets" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] [[package]] name = "windows_aarch64_gnullvm" @@ -2137,10 +2746,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" +name = "windows_aarch64_gnullvm" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" [[package]] name = "windows_aarch64_msvc" @@ -2149,10 +2758,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] -name = "windows_i686_gnu" -version = "0.42.2" +name = "windows_aarch64_msvc" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" [[package]] name = "windows_i686_gnu" @@ -2161,10 +2770,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] -name = "windows_i686_msvc" -version = "0.42.2" +name = "windows_i686_gnu" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" [[package]] name = "windows_i686_msvc" @@ -2173,10 +2782,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" +name = "windows_i686_msvc" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" [[package]] name = "windows_x86_64_gnu" @@ -2185,10 +2794,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" +name = "windows_x86_64_gnu" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" [[package]] name = "windows_x86_64_gnullvm" @@ -2197,10 +2806,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" +name = "windows_x86_64_gnullvm" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" [[package]] name = "windows_x86_64_msvc" @@ -2208,15 +2817,31 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + [[package]] name = "winnow" -version = "0.5.37" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cad8365489051ae9f054164e459304af2e7e9bb407c958076c8bf4aef52da5" +checksum = "d90f4e0f530c4c69f62b80d839e9ef3855edc9cba471a160c4d692deed62b401" dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -2234,20 +2859,20 @@ checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" [[package]] name = "zerocopy" -version = "0.7.31" +version = "0.7.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c4061bedbb353041c12f413700357bec76df2c7e2ca8e4df8bac24c6bf68e3d" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.31" +version = "0.7.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3c129550b3e6de3fd0ba67ba5c81818f9805e58b8d7fee80a3a59d2c9fc601a" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.49", ] diff --git a/astro.config.mjs b/astro.config.mjs index 7d2422332..278df56d9 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -12,6 +12,14 @@ import remarkIncludeCode from "/src/plugins/remark-code-import"; // https://astro.build/config export default defineConfig({ + image: { + service: { + entrypoint: "astro/assets/services/sharp", + config: { + limitInputPixels: false, + }, + }, + }, site: "https://ratatui.rs", image: { service: { @@ -135,21 +143,39 @@ export default defineConfig({ ], }, { - label: "Async Counter App", + label: "Crates TUI App", collapsed: true, items: [ - { label: "Async Counter App", link: "/tutorials/counter-async-app/" }, + { label: "Crates TUI", link: "/tutorials/crates-tui/" }, + { label: "Main", link: "/tutorials/crates-tui/main" }, { - label: "Async KeyEvents", - link: "/tutorials/counter-async-app/async-event-stream/", + label: "Helper", + link: "/tutorials/crates-tui/crates-io-api-helper", }, - { label: "Async Render", link: "/tutorials/counter-async-app/full-async-events/" }, - { label: "Introducing Actions", link: "/tutorials/counter-async-app/actions/" }, + { label: "Tui", link: "/tutorials/crates-tui/tui" }, + { label: "Errors", link: "/tutorials/crates-tui/errors" }, + { label: "Events", link: "/tutorials/crates-tui/events" }, { - label: "Async Actions", - link: "/tutorials/counter-async-app/full-async-actions/", + label: "App", + collapsed: true, + items: [ + { label: "App Basics", link: "/tutorials/crates-tui/app-basics" }, + { label: "App Mode", link: "/tutorials/crates-tui/app-mode" }, + { label: "App Async", link: "/tutorials/crates-tui/app-async" }, + { label: "App Prototype", link: "/tutorials/crates-tui/app-prototype" }, + ], + }, + { + label: "Widgets", + collapsed: true, + items: [ + { label: "Widgets", link: "/tutorials/crates-tui/widgets" }, + { label: "Prompt", link: "/tutorials/crates-tui/prompt" }, + { label: "Results", link: "/tutorials/crates-tui/results" }, + { label: "App", link: "/tutorials/crates-tui/app" }, + ], }, - { label: "Conclusion", link: "/tutorials/counter-async-app/conclusion/" }, + { label: "Conclusion", link: "/tutorials/crates-tui/conclusion" }, ], }, ], diff --git a/code/crates-tui-tutorial-app/.gitignore b/code/crates-tui-tutorial-app/.gitignore new file mode 100644 index 000000000..a0ae0dd24 --- /dev/null +++ b/code/crates-tui-tutorial-app/.gitignore @@ -0,0 +1 @@ +.data/*.log diff --git a/code/crates-tui-tutorial-app/Cargo.toml b/code/crates-tui-tutorial-app/Cargo.toml new file mode 100644 index 000000000..57027f05c --- /dev/null +++ b/code/crates-tui-tutorial-app/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "crates-tui" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +color-eyre = "0.6.2" +crates_io_api = "0.9.0" +crossterm = { version = "0.27.0", features = ["serde", "event-stream"] } +futures = "0.3.28" +itertools = "0.12.0" +ratatui = { version = "0.26.1", features = ["serde", "macros"] } +tokio = { version = "1.36.0", features = ["full"] } +tokio-stream = "0.1.14" +tui-input = "0.8.0" diff --git a/code/crates-tui-tutorial-app/LICENSE b/code/crates-tui-tutorial-app/LICENSE new file mode 100644 index 000000000..a0cdd3b6b --- /dev/null +++ b/code/crates-tui-tutorial-app/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 The Ratatui Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/code/crates-tui-tutorial-app/demo.tape b/code/crates-tui-tutorial-app/demo.tape new file mode 100644 index 000000000..3bcfa66e5 --- /dev/null +++ b/code/crates-tui-tutorial-app/demo.tape @@ -0,0 +1,23 @@ +# A VHS tape. See https://github.com/charmbracelet/vhs +Output crates-tui-demo.gif +Set Theme "Aardvark Blue" +Set Width 1600 +Set Height 800 +Type "cargo run --bin crates-tui" +Enter +Sleep 3s +Type @0.1s "ratatui" +Sleep 1s +Enter +Sleep 5s +Down @0.1s 5 +Sleep 5s +Up @0.1s 5 +Sleep 5s +Screenshot crates-tui-demo-2.png +Sleep 2s +Type "/" +Sleep 1s +Screenshot crates-tui-demo-1.png +Sleep 2s + diff --git a/code/crates-tui-tutorial-app/src/app.rs b/code/crates-tui-tutorial-app/src/app.rs new file mode 100644 index 000000000..cd9873946 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/app.rs @@ -0,0 +1,247 @@ +use std::sync::{atomic::AtomicBool, Arc, Mutex}; + +// ANCHOR: imports_all +// ANCHOR: imports_external +use color_eyre::eyre::Result; +use crossterm::event::KeyEvent; +use ratatui::prelude::*; +// ANCHOR: imports_external + +// ANCHOR: imports_core +use crate::{ + events::{Event, Events}, + tui::Tui, + widgets::{ + search_prompt::{SearchPrompt, SearchPromptWidget}, + search_results::{SearchResults, SearchResultsWidget}, + status_bar::{StatusBar, StatusBarWidget}, + }, +}; +// ANCHOR_END: imports_core +// ANCHOR_END: imports_all + +// ANCHOR: full_app +// ANCHOR: mode +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Mode { + #[default] + Prompt, + Results, +} +// ANCHOR_END: mode + +// ANCHOR: mode_handle_key +impl Mode { + fn handle_key(&self, key: KeyEvent) -> Option { + use crossterm::event::KeyCode::*; + let action = match self { + Mode::Prompt => match key.code { + Enter => Action::SubmitSearchQuery, + Esc => Action::SwitchMode(Mode::Results), + _ => return None, + }, + Mode::Results => match key.code { + Up => Action::ScrollUp, + Down => Action::ScrollDown, + Char('/') => Action::SwitchMode(Mode::Prompt), + Esc => Action::Quit, + _ => return None, + }, + }; + Some(action) + } +} +// ANCHOR_END: mode_handle_key + +// ANCHOR: action +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Quit, + SwitchMode(Mode), + ScrollDown, + ScrollUp, + SubmitSearchQuery, + UpdateSearchResults, +} +// ANCHOR_END: action + +// ANCHOR: app +#[derive(Debug)] +pub struct App { + quit: bool, + mode: Mode, + rx: tokio::sync::mpsc::UnboundedReceiver, + tx: tokio::sync::mpsc::UnboundedSender, + + status_bar: StatusBar, + results: SearchResults, + prompt: SearchPrompt, +} +// ANCHOR_END: app + +impl App { + // ANCHOR: app_new + pub fn new() -> Self { + let loading_status = Arc::new(AtomicBool::default()); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let crates: Arc>> = Default::default(); + let results = SearchResults::new(crates.clone()); + let prompt = SearchPrompt::new( + tx.clone(), + loading_status.clone(), + crates.clone(), + ); + let status_bar = StatusBar::new(loading_status); + let mode = Mode::default(); + let quit = false; + Self { + quit, + mode, + rx, + tx, + status_bar, + results, + prompt, + } + } + // ANCHOR_END: app_new + + // ANCHOR: app_run + pub async fn run( + &mut self, + mut tui: Tui, + mut events: Events, + ) -> Result<()> { + loop { + if let Some(e) = events.next().await { + if matches!(e, Event::Render) { + self.draw(&mut tui)? + } else { + self.handle_event(e)? + } + } + while let Ok(action) = self.rx.try_recv() { + self.handle_action(action)?; + } + if self.should_quit() { + break; + } + } + Ok(()) + } + // ANCHOR_END: app_run + + // ANCHOR: app_handle_event + fn handle_event(&mut self, e: Event) -> Result<()> { + use crossterm::event::Event as CrosstermEvent; + if let Event::Crossterm(CrosstermEvent::Key(key)) = e { + self.status_bar.last_key_event = Some(key); + self.handle_key(key) + }; + Ok(()) + } + // ANCHOR_END: app_handle_event + + // ANCHOR: app_handle_key_event + fn handle_key(&mut self, key: KeyEvent) { + let maybe_action = self.mode.handle_key(key); + if maybe_action.is_none() && matches!(self.mode, Mode::Prompt) { + self.prompt.handle_key(key); + } + maybe_action.map(|action| self.tx.send(action)); + } + // ANCHOR_END: app_handle_key_event + + // ANCHOR: app_handle_action + fn handle_action(&mut self, action: Action) -> Result<()> { + match action { + Action::Quit => self.quit(), + Action::SwitchMode(mode) => self.switch_mode(mode), + Action::ScrollUp => self.results.scroll_previous(), + Action::ScrollDown => self.results.scroll_next(), + Action::SubmitSearchQuery => { + self.results.clear_selection(); + self.prompt.submit_query(); + } + Action::UpdateSearchResults => self.results.update_search_results(), + } + Ok(()) + } + // ANCHOR_END: app_handle_action +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +impl App { + // ANCHOR: app_draw + fn draw(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + frame.render_stateful_widget(AppWidget, frame.size(), self); + self.set_cursor(frame); + })?; + Ok(()) + } + // ANCHOR_END: app_draw + + fn quit(&mut self) { + self.quit = true + } + + fn switch_mode(&mut self, mode: Mode) { + self.mode = mode; + } + + fn should_quit(&self) -> bool { + self.quit + } + + // ANCHOR: loading + fn set_cursor(&mut self, frame: &mut Frame<'_>) { + if matches!(self.mode, Mode::Prompt) { + if let Some(cursor_position) = self.prompt.cursor_position { + frame.set_cursor(cursor_position.x, cursor_position.y) + } + } + } +} + +const PROMPT_HEIGHT: u16 = 5; + +// ANCHOR: app_widget +struct AppWidget; +// ANCHOR_END: app_widget + +// ANCHOR: app_statefulwidget +impl StatefulWidget for AppWidget { + type State = App; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let [status_bar, main, prompt] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(PROMPT_HEIGHT), + ]) + .areas(area); + + StatusBarWidget.render(status_bar, buf, &mut state.status_bar); + + SearchResultsWidget::new(matches!(state.mode, Mode::Results)).render( + main, + buf, + &mut state.results, + ); + + SearchPromptWidget::new(matches!(state.mode, Mode::Prompt)).render( + prompt, + buf, + &mut state.prompt, + ); + } +} +// ANCHOR_END: app_statefulwidget + +// ANCHOR_END: full_app diff --git a/code/crates-tui-tutorial-app/src/bin/crates-tui.rs b/code/crates-tui-tutorial-app/src/bin/crates-tui.rs new file mode 100644 index 000000000..b401cfe3e --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/crates-tui.rs @@ -0,0 +1,18 @@ +use crates_tui::app; +use crates_tui::errors; +use crates_tui::events; +use crates_tui::tui; + +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + errors::install_hooks()?; + + let tui = tui::init()?; + let events = events::Events::new(); + app::App::new().run(tui, events).await?; + tui::restore()?; + + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-app-async.rs b/code/crates-tui-tutorial-app/src/bin/part-app-async.rs new file mode 100644 index 000000000..ff019a6b7 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app-async.rs @@ -0,0 +1,429 @@ +use crates_tui::errors; +use crates_tui::events; +use crates_tui::tui; + +use color_eyre::{eyre::Context, Result}; +use events::{Event, Events}; +use itertools::Itertools; +use ratatui::layout::Position; +use ratatui::prelude::*; +use ratatui::widgets::*; +use tui::Tui; + +// ANCHOR: helper +use crates_io_api::CratesQuery; +use std::sync::{Arc, Mutex}; +use tui_input::backend::crossterm::EventHandler; + +// ANCHOR: search_parameters +/// Represents the parameters needed for fetching crates asynchronously. +pub struct SearchParameters { + // Request + pub search: String, + pub page: u64, + pub page_size: u64, + pub sort: crates_io_api::Sort, + + // Response + pub crates: Arc>>, +} +// ANCHOR_END: search_parameters + +impl SearchParameters { + pub fn new( + search: String, + crates: Arc>>, + ) -> SearchParameters { + SearchParameters { + search, + page: 1, + page_size: 100, + sort: crates_io_api::Sort::Relevance, + crates, + } + } +} + +// ANCHOR: request_search_results +/// Performs the actual search, and sends the result back through the +/// sender. +pub async fn request_search_results( + params: &SearchParameters, +) -> Result<(), String> { + let client = create_client()?; + let query = create_query(params); + let crates = fetch_crates_and_metadata(client, query).await?; + update_state_with_fetched_crates(crates, params); + Ok(()) +} +// ANCHOR_END: request_search_results + +/// Helper function to create client and fetch crates, wrapping both actions +/// into a result pattern. +fn create_client() -> Result { + // ANCHOR: client + let email = std::env::var("CRATES_TUI_TUTORIAL_APP_MYEMAIL").context("Need to set CRATES_TUI_TUTORIAL_APP_MYEMAIL environment variable to proceed").unwrap(); + + let user_agent = format!("crates-tui ({email})"); + let rate_limit = std::time::Duration::from_millis(1000); + + crates_io_api::AsyncClient::new(&user_agent, rate_limit) + // ANCHOR_END: client + .map_err(|err| format!("API Client Error: {err:#?}")) +} + +// ANCHOR: create_query +fn create_query(params: &SearchParameters) -> CratesQuery { + crates_io_api::CratesQueryBuilder::default() + .search(¶ms.search) + .page(params.page) + .page_size(params.page_size) + .sort(params.sort.clone()) + .build() +} +// ANCHOR_END: create_query + +async fn fetch_crates_and_metadata( + client: crates_io_api::AsyncClient, + query: crates_io_api::CratesQuery, +) -> Result, String> { + // ANCHOR: crates_query + let page_result = client + .crates(query) + .await + // ANCHOR_END: crates_query + .map_err(|err| format!("API Client Error: {err:#?}"))?; + let crates = page_result.crates; + Ok(crates) +} + +/// Handles the result after fetching crates and sending corresponding +/// actions. +fn update_state_with_fetched_crates( + crates: Vec, + params: &SearchParameters, +) { + // ANCHOR: update_state + let mut app_crates = params.crates.lock().unwrap(); + app_crates.clear(); + app_crates.extend(crates); + // ANCHOR_END: update_state +} +// ANCHOR_END: helper + +// ANCHOR: full_app + +// ANCHOR: mode +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Mode { + #[default] + Prompt, + Results, +} +// ANCHOR_END: mode + +// ANCHOR: action +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Render, + Quit, + SwitchMode(Mode), + ScrollDown, + ScrollUp, + SubmitSearchQuery, +} +// ANCHOR_END: action + +// ANCHOR: app +pub struct App { + quit: bool, + last_key_event: Option, + mode: Mode, + + crates: Arc>>, // new + prompt: tui_input::Input, // new + cursor_position: Option, // new + table_state: TableState, // new +} +// ANCHOR_END: app + +impl App { + // ANCHOR: app_new + pub fn new() -> Self { + let quit = false; + let mode = Mode::default(); + let crates = Default::default(); + let table_state = TableState::default(); + let prompt = Default::default(); + let cursor_position = None; + let last_key_event = None; + Self { + quit, + mode, + last_key_event, + crates, + table_state, + prompt, + cursor_position, + } + } + // ANCHOR_END: app_new + + // ANCHOR: app_run + pub async fn run( + &mut self, + mut tui: Tui, + mut events: Events, + ) -> Result<()> { + loop { + if let Some(e) = events.next().await { + self.handle_event(e, &mut tui)? + } + if self.should_quit() { + break; + } + } + Ok(()) + } + // ANCHOR_END: app_run + + // ANCHOR: app_handle_event + fn handle_event(&mut self, e: Event, tui: &mut Tui) -> Result<()> { + use crossterm::event::Event as CrosstermEvent; + use crossterm::event::KeyCode::*; + match e { + Event::Render => self.draw(tui)?, + Event::Crossterm(CrosstermEvent::Key(key)) => { + self.last_key_event = Some(key); + match self.mode { + Mode::Prompt => match key.code { + Enter => self.submit_search_query(), // new + Esc => self.switch_mode(Mode::Results), + _ => { + // new + self.prompt.handle_event(&CrosstermEvent::Key(key)); + } + }, + Mode::Results => match key.code { + Up => self.scroll_up(), // new + Down => self.scroll_down(), // new + Char('/') => self.switch_mode(Mode::Prompt), // new + Esc => self.quit(), + _ => (), + }, + }; + } + _ => (), + }; + Ok(()) + } + // ANCHOR_END: app_handle_event + + // ANCHOR: app_draw + fn draw(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + frame.render_stateful_widget(AppWidget, frame.size(), self); + self.set_cursor(frame); // new + })?; + Ok(()) + } + // ANCHOR_END: app_draw + + // ANCHOR: app_switch_mode + fn switch_mode(&mut self, mode: Mode) { + self.mode = mode; + } + // ANCHOR_END: app_switch_mode + + // ANCHOR: app_quit + fn should_quit(&self) -> bool { + self.quit + } + + fn quit(&mut self) { + self.quit = true + } + // ANCHOR_END: app_quit + + // ANCHOR: app_scroll + fn scroll_up(&mut self) { + let last = self.crates.lock().unwrap().len().saturating_sub(1); + let wrap_index = self.crates.lock().unwrap().len().max(1); + let previous = self + .table_state + .selected() + .map_or(last, |i| (i + last) % wrap_index); + self.scroll_to(previous); + } + + fn scroll_down(&mut self) { + let wrap_index = self.crates.lock().unwrap().len().max(1); + let next = self + .table_state + .selected() + .map_or(0, |i| (i + 1) % wrap_index); + self.scroll_to(next); + } + + fn scroll_to(&mut self, index: usize) { + if self.crates.lock().unwrap().is_empty() { + self.table_state.select(None) + } else { + self.table_state.select(Some(index)); + } + } + // ANCHOR_END: app_scroll + + // ANCHOR: app_submit_search_query + fn submit_search_query(&mut self) { + self.table_state.select(None); + let search_params = SearchParameters::new( + self.prompt.value().into(), + self.crates.clone(), + ); + tokio::spawn(async move { + let _ = request_search_results(&search_params).await; + }); + self.switch_mode(Mode::Results); + } + // ANCHOR_END: app_submit_search_query + + // ANCHOR: app_update_cursor + fn set_cursor(&mut self, frame: &mut Frame<'_>) { + if matches!(self.mode, Mode::Prompt) { + if let Some(cursor_position) = self.cursor_position { + frame.set_cursor(cursor_position.x, cursor_position.y) + } + } + } + // ANCHOR_END: app_update_cursor +} + +// ANCHOR: app_default +impl Default for App { + fn default() -> Self { + Self::new() + } +} +// ANCHOR_END: app_default + +// ANCHOR: app_widget +struct AppWidget; +// ANCHOR_END: app_widget + +// ANCHOR: app_statefulwidget +impl StatefulWidget for AppWidget { + type State = App; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let [last_key_event, results, prompt] = Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(0), + Constraint::Length(5), + ]) + .areas(area); + + let table = state.results(); + StatefulWidget::render(table, results, buf, &mut state.table_state); // new + + let (block, paragraph) = state.prompt(); + block.render(prompt, buf); + paragraph.render( + prompt.inner(&Margin { + horizontal: 2, + vertical: 2, + }), + buf, + ); + + state.calculate_cursor_position(prompt); // new + + if let Some(key) = state.last_key_event { + Paragraph::new(format!("last key event: {:?}", key.code)) + .right_aligned() + .render(last_key_event, buf); + } + } +} +// ANCHOR_END: app_statefulwidget + +impl App { + // ANCHOR: app_results_table_widget + fn results(&self) -> Table<'static> { + let widths = [ + Constraint::Length(15), + Constraint::Min(0), + Constraint::Length(20), + ]; + + let crates = self.crates.lock().unwrap(); // new + + // new + let rows = crates + .iter() + .map(|krate| { + vec![ + krate.name.clone(), + krate.description.clone().unwrap_or_default(), + krate.downloads.to_string(), + ] + }) + .map(|row| Row::new(row.iter().map(String::from).collect_vec())) + .collect_vec(); + + Table::new(rows, widths) + .header(Row::new(vec!["Name", "Description", "Downloads"])) + .highlight_symbol(" █ ") // new + .highlight_spacing(HighlightSpacing::Always) // new + } + // ANCHOR_END: app_results_table_widget + + // ANCHOR: app_prompt_widget + fn prompt(&self) -> (Block, Paragraph) { + let color = if matches!(self.mode, Mode::Prompt) { + Color::Yellow + } else { + Color::Blue + }; + let block = Block::default().borders(Borders::ALL).border_style(color); + + let paragraph = Paragraph::new(self.prompt.value()); // new + + (block, paragraph) + } + // ANCHOR_END: app_prompt_widget + + // ANCHOR: update_prompt_cursor_state + fn calculate_cursor_position(&mut self, area: Rect) { + // ANCHOR: render_cursor + if matches!(self.mode, Mode::Prompt) { + let margin = (2, 2); + let width = (area.width as f64 as u16).saturating_sub(margin.0); + self.cursor_position = Some(Position::new( + (area.x + margin.0 + self.prompt.cursor() as u16).min(width), + area.y + margin.1, + )); + } else { + self.cursor_position = None + } + // ANCHOR_END: render_cursor + } + // ANCHOR_END: update_prompt_cursor_state +} + +// ANCHOR_END: full_app + +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + errors::install_hooks()?; + let tui = tui::init()?; + let events = events::Events::new(); + + App::new().run(tui, events).await?; + + tui::restore()?; + + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-app-async.tape b/code/crates-tui-tutorial-app/src/bin/part-app-async.tape new file mode 100644 index 000000000..e98666ca7 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app-async.tape @@ -0,0 +1,18 @@ +# A VHS tape. See https://github.com/charmbracelet/vhs +Output crates-tui-tutorial-part-app-async.gif +Set Theme "Aardvark Blue" +Set Width 1600 +Set Height 800 +Type "cargo run --bin part-app-async" +Enter +Sleep 3s +Type @0.1s "ratatui" +Sleep 1s +Enter +Sleep 5s +Down @0.1s 5 +Sleep 5s +Up @0.1s 5 +Sleep 5s + + diff --git a/code/crates-tui-tutorial-app/src/bin/part-app-basics.rs b/code/crates-tui-tutorial-app/src/bin/part-app-basics.rs new file mode 100644 index 000000000..d27f7bad6 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app-basics.rs @@ -0,0 +1,136 @@ +use crates_tui::errors; +use crates_tui::events; +use crates_tui::tui; + +use color_eyre::Result; +use events::{Event, Events}; +use ratatui::prelude::*; +use ratatui::widgets::*; +use tui::Tui; + +// ANCHOR: full_app + +// ANCHOR: app +pub struct App { + quit: bool, + frame_count: usize, + last_key_event: Option, +} +// ANCHOR_END: app + +impl App { + // ANCHOR: app_new + pub fn new() -> Self { + let quit = false; + let frame_count = 0; + let last_key_event = None; + Self { + quit, + frame_count, + last_key_event, + } + } + // ANCHOR_END: app_new + + // ANCHOR: app_run + pub async fn run( + &mut self, + mut tui: Tui, + mut events: Events, + ) -> Result<()> { + loop { + if let Some(e) = events.next().await { + self.handle_event(e, &mut tui)? + } + if self.should_quit() { + break; + } + } + Ok(()) + } + // ANCHOR_END: app_run + + // ANCHOR: app_handle_event + fn handle_event(&mut self, e: Event, tui: &mut Tui) -> Result<()> { + use crossterm::event::Event as CrosstermEvent; + use crossterm::event::KeyCode; + match e { + Event::Crossterm(CrosstermEvent::Key(key)) => { + self.last_key_event = Some(key); + if key.code == KeyCode::Esc { + self.quit() + } + } + Event::Render => self.draw(tui)?, + _ => (), + }; + Ok(()) + } + // ANCHOR_END: app_handle_event + + // ANCHOR: app_draw + fn draw(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + self.frame_count = frame.count(); + frame.render_stateful_widget(AppWidget, frame.size(), self); + })?; + Ok(()) + } + // ANCHOR_END: app_draw + + // ANCHOR: app_quit + fn should_quit(&self) -> bool { + self.quit + } + + fn quit(&mut self) { + self.quit = true + } + // ANCHOR_END: app_quit +} + +// ANCHOR: app_default +impl Default for App { + fn default() -> Self { + Self::new() + } +} +// ANCHOR_END: app_default + +// ANCHOR: app_widget +struct AppWidget; +// ANCHOR_END: app_widget + +// ANCHOR: app_statefulwidget +impl StatefulWidget for AppWidget { + type State = App; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + Paragraph::new(format!("frame counter: {}", state.frame_count)) + .render(area, buf); + + if let Some(key) = state.last_key_event { + Paragraph::new(format!("last key event: {:?}", key.code)) + .right_aligned() + .render(area, buf); + } + } +} +// ANCHOR_END: app_statefulwidget + +// ANCHOR_END: full_app + +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + errors::install_hooks()?; + let tui = tui::init()?; + let events = events::Events::new(); + + App::new().run(tui, events).await?; + + tui::restore()?; + + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-app-basics.tape b/code/crates-tui-tutorial-app/src/bin/part-app-basics.tape new file mode 100644 index 000000000..6af2aee8d --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app-basics.tape @@ -0,0 +1,15 @@ +# A VHS tape. See https://github.com/charmbracelet/vhs +Output crates-tui-tutorial-part-app-basics.gif +Set Theme "Aardvark Blue" +Set Width 900 +Set Height 600 +Type "cargo run --bin part-app-basics" +Enter +Sleep 5s +Enter +Sleep 2s +Type "j" +Sleep 2s +Type "k" +Sleep 2s +Escape diff --git a/code/crates-tui-tutorial-app/src/bin/part-app-mode.rs b/code/crates-tui-tutorial-app/src/bin/part-app-mode.rs new file mode 100644 index 000000000..569de9e52 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app-mode.rs @@ -0,0 +1,216 @@ +use crates_tui::errors; +use crates_tui::events; +use crates_tui::tui; + +use color_eyre::Result; +use events::{Event, Events}; +use itertools::Itertools; +use ratatui::prelude::*; +use ratatui::widgets::*; +use tui::Tui; + +// ANCHOR: full_app + +// ANCHOR: mode +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Mode { + #[default] + Prompt, + Results, +} +// ANCHOR_END: mode + +// ANCHOR: app +pub struct App { + quit: bool, + last_key_event: Option, + mode: Mode, // new +} +// ANCHOR_END: app + +impl App { + // ANCHOR: app_new + pub fn new() -> Self { + let quit = false; + let mode = Mode::default(); + let last_key_event = None; + Self { + quit, + mode, + last_key_event, + } + } + // ANCHOR_END: app_new + + // ANCHOR: app_run + pub async fn run( + &mut self, + mut tui: Tui, + mut events: Events, + ) -> Result<()> { + loop { + if let Some(e) = events.next().await { + self.handle_event(e, &mut tui)? + } + if self.should_quit() { + break; + } + } + Ok(()) + } + // ANCHOR_END: app_run + + // ANCHOR: app_handle_event + fn handle_event(&mut self, e: Event, tui: &mut Tui) -> Result<()> { + use crossterm::event::Event as CrosstermEvent; + use crossterm::event::KeyCode; + match e { + Event::Crossterm(CrosstermEvent::Key(key)) => { + self.last_key_event = Some(key); + if key.code == KeyCode::Esc { + match self.mode { + Mode::Prompt => self.switch_mode(Mode::Results), + Mode::Results => self.quit(), + } + } + } + Event::Render => self.draw(tui)?, + _ => (), + }; + Ok(()) + } + // ANCHOR_END: app_handle_event + + // ANCHOR: app_draw + fn draw(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + frame.render_stateful_widget(AppWidget, frame.size(), self); + })?; + Ok(()) + } + // ANCHOR_END: app_draw + + // ANCHOR: app_switch_mode + fn switch_mode(&mut self, mode: Mode) { + self.mode = mode; + } + // ANCHOR_END: app_switch_mode + + // ANCHOR: app_quit + fn should_quit(&self) -> bool { + self.quit + } + + fn quit(&mut self) { + self.quit = true + } + // ANCHOR_END: app_quit +} + +// ANCHOR: app_default +impl Default for App { + fn default() -> Self { + Self::new() + } +} +// ANCHOR_END: app_default + +// ANCHOR: app_widget +struct AppWidget; +// ANCHOR_END: app_widget + +// ANCHOR: app_statefulwidget +impl StatefulWidget for AppWidget { + type State = App; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let [last_key_event, results, prompt] = Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(0), + Constraint::Length(5), + ]) + .areas(area); + + let table = state.results(); + Widget::render(table, results, buf); + + let (block, paragraph) = state.prompt(); + block.render(prompt, buf); + paragraph.render( + prompt.inner(&Margin { + horizontal: 2, + vertical: 2, + }), + buf, + ); + + if let Some(key) = state.last_key_event { + Paragraph::new(format!("last key event: {:?}", key.code)) + .right_aligned() + .render(last_key_event, buf); + } + } +} +// ANCHOR_END: app_statefulwidget + +impl App { + // ANCHOR: app_results_table_widget + fn results(&self) -> Table<'_> { + let widths = [ + Constraint::Length(15), + Constraint::Min(0), + Constraint::Length(20), + ]; + + let rows = vec![ + ["hyper", "Fast and safe HTTP implementation", "1000000"], + ["serde", "Rust data structures", "1500000"], + ["tokio", "non-blocking I/O platform", "1300000"], + ["rand", "random number generation", "900000"], + ["actix-web", "fast web framework", "800000"], + ["syn", "Parsing source code", "700000"], + ["warp", "web server framework", "600000"], + ["Ratatui", "terminal user interfaces", "500000"], + ] + .iter() + .map(|row| Row::new(row.iter().map(|s| String::from(*s)).collect_vec())) + .collect_vec(); + + Table::new(rows, widths).header(Row::new(vec![ + "Name", + "Description", + "Downloads", + ])) + } + // ANCHOR_END: app_results_table_widget + + // ANCHOR: app_prompt_widget + fn prompt(&self) -> (Block, Paragraph) { + let color = if matches!(self.mode, Mode::Prompt) { + Color::Yellow + } else { + Color::Blue + }; + let block = Block::default().borders(Borders::ALL).border_style(color); + let paragraph = Paragraph::new("prompt"); + (block, paragraph) + } + // ANCHOR_END: app_prompt_widget +} + +// ANCHOR_END: full_app + +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + errors::install_hooks()?; + let tui = tui::init()?; + let events = events::Events::new(); + + App::new().run(tui, events).await?; + + tui::restore()?; + + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-app-mode.tape b/code/crates-tui-tutorial-app/src/bin/part-app-mode.tape new file mode 100644 index 000000000..219f5221d --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app-mode.tape @@ -0,0 +1,12 @@ +# A VHS tape. See https://github.com/charmbracelet/vhs +Output crates-tui-tutorial-part-app-mode.gif +Set Theme "Aardvark Blue" +Set Width 900 +Set Height 600 +Type "cargo run --bin part-app-mode" +Enter +Sleep 5s +Escape +Sleep 5s +Escape +Sleep 2s diff --git a/code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs b/code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs new file mode 100644 index 000000000..2e670b235 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs @@ -0,0 +1,463 @@ +use crates_tui::errors; +use crates_tui::events; +use crates_tui::tui; + +use color_eyre::{eyre::Context, Result}; +use events::{Event, Events}; +use itertools::Itertools; +use ratatui::layout::Position; +use ratatui::prelude::*; +use ratatui::widgets::*; +use tui::Tui; + +// ANCHOR: helper +use crates_io_api::CratesQuery; +use std::sync::{Arc, Mutex}; +use tui_input::backend::crossterm::EventHandler; + +// ANCHOR: search_parameters +/// Represents the parameters needed for fetching crates asynchronously. +pub struct SearchParameters { + // Request + pub search: String, + pub page: u64, + pub page_size: u64, + pub sort: crates_io_api::Sort, + + // Response + pub crates: Arc>>, +} +// ANCHOR_END: search_parameters + +impl SearchParameters { + pub fn new( + search: String, + crates: Arc>>, + ) -> SearchParameters { + SearchParameters { + search, + page: 1, + page_size: 100, + sort: crates_io_api::Sort::Relevance, + crates, + } + } +} + +// ANCHOR: request_search_results +/// Performs the actual search, and sends the result back through the +/// sender. +pub async fn request_search_results( + params: &SearchParameters, +) -> Result<(), String> { + let client = create_client()?; + let query = create_query(params); + let crates = fetch_crates_and_metadata(client, query).await?; + update_state_with_fetched_crates(crates, params); + Ok(()) +} +// ANCHOR_END: request_search_results + +/// Helper function to create client and fetch crates, wrapping both actions +/// into a result pattern. +fn create_client() -> Result { + // ANCHOR: client + let email = std::env::var("CRATES_TUI_TUTORIAL_APP_MYEMAIL").context("Need to set CRATES_TUI_TUTORIAL_APP_MYEMAIL environment variable to proceed").unwrap(); + + let user_agent = format!("crates-tui ({email})"); + let rate_limit = std::time::Duration::from_millis(1000); + + crates_io_api::AsyncClient::new(&user_agent, rate_limit) + // ANCHOR_END: client + .map_err(|err| format!("API Client Error: {err:#?}")) +} + +// ANCHOR: create_query +fn create_query(params: &SearchParameters) -> CratesQuery { + crates_io_api::CratesQueryBuilder::default() + .search(¶ms.search) + .page(params.page) + .page_size(params.page_size) + .sort(params.sort.clone()) + .build() +} +// ANCHOR_END: create_query + +async fn fetch_crates_and_metadata( + client: crates_io_api::AsyncClient, + query: crates_io_api::CratesQuery, +) -> Result, String> { + // ANCHOR: crates_query + let page_result = client + .crates(query) + .await + // ANCHOR_END: crates_query + .map_err(|err| format!("API Client Error: {err:#?}"))?; + let crates = page_result.crates; + Ok(crates) +} + +/// Handles the result after fetching crates and sending corresponding +/// actions. +fn update_state_with_fetched_crates( + crates: Vec, + params: &SearchParameters, +) { + // ANCHOR: update_state + let mut app_crates = params.crates.lock().unwrap(); + app_crates.clear(); + app_crates.extend(crates); + // ANCHOR_END: update_state +} +// ANCHOR_END: helper + +// ANCHOR: full_app + +// ANCHOR: mode +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Mode { + #[default] + Prompt, + Results, +} + +// ANCHOR: mode_handle_key +impl Mode { + fn handle_key(&self, key: crossterm::event::KeyEvent) -> Option { + use crossterm::event::KeyCode::*; + let action = match self { + Mode::Prompt => match key.code { + Enter => Action::SubmitSearchQuery, + Esc => Action::SwitchMode(Mode::Results), + _ => return None, + }, + Mode::Results => match key.code { + Up => Action::ScrollUp, + Down => Action::ScrollDown, + Char('/') => Action::SwitchMode(Mode::Prompt), + Esc => Action::Quit, + _ => return None, + }, + }; + Some(action) + } +} +// ANCHOR_END: mode_handle_key +// ANCHOR_END: mode + +// ANCHOR: action +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Quit, + SwitchMode(Mode), + ScrollDown, + ScrollUp, + SubmitSearchQuery, + UpdateSearchResults, +} +// ANCHOR_END: action + +// ANCHOR: app +pub struct App { + quit: bool, + last_key_event: Option, + mode: Mode, + crates: Arc>>, + table_state: TableState, + prompt: tui_input::Input, + cursor_position: Option, + + tx: tokio::sync::mpsc::UnboundedSender, // new + rx: tokio::sync::mpsc::UnboundedReceiver, // new +} +// ANCHOR_END: app + +impl App { + // ANCHOR: app_new + pub fn new() -> Self { + let quit = false; + let mode = Mode::default(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let crates = Default::default(); + let table_state = TableState::default(); + let prompt = Default::default(); + let cursor_position = None; + let last_key_event = None; + Self { + quit, + mode, + last_key_event, + tx, + rx, + crates, + table_state, + prompt, + cursor_position, + } + } + // ANCHOR_END: app_new + + // ANCHOR: app_run + pub async fn run( + &mut self, + mut tui: Tui, + mut events: Events, + ) -> Result<()> { + loop { + if let Some(e) = events.next().await { + if matches!(e, Event::Render) { + self.draw(&mut tui)? + } else { + self.handle_event(e)? + } + } + while let Ok(action) = self.rx.try_recv() { + self.handle_action(action)?; + } + if self.should_quit() { + break; + } + } + Ok(()) + } + // ANCHOR_END: app_run + + // ANCHOR: app_handle_event + fn handle_event(&mut self, e: Event) -> Result<()> { + use crossterm::event::Event as CrosstermEvent; + if let Event::Crossterm(CrosstermEvent::Key(key)) = e { + self.last_key_event = Some(key); + self.handle_key(key) + }; + Ok(()) + } + // ANCHOR_END: app_handle_event + + // ANCHOR: app_handle_key_event + fn handle_key(&mut self, key: crossterm::event::KeyEvent) { + use crossterm::event::Event as CrosstermEvent; + let maybe_action = self.mode.handle_key(key); + if maybe_action.is_none() && matches!(self.mode, Mode::Prompt) { + self.prompt.handle_event(&CrosstermEvent::Key(key)); + } + maybe_action.map(|action| self.tx.send(action)); + } + // ANCHOR_END: app_handle_key_event + + // ANCHOR: app_handle_action + fn handle_action(&mut self, action: Action) -> Result<()> { + match action { + Action::Quit => self.quit(), + Action::SwitchMode(mode) => self.switch_mode(mode), + Action::ScrollUp => self.scroll_up(), + Action::ScrollDown => self.scroll_down(), + Action::SubmitSearchQuery => self.submit_search_query(), + Action::UpdateSearchResults => self.update_search_results(), + } + Ok(()) + } + // ANCHOR_END: app_handle_action + + // ANCHOR: app_draw + fn draw(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + frame.render_stateful_widget(AppWidget, frame.size(), self); + self.set_cursor(frame); + })?; + Ok(()) + } + // ANCHOR_END: app_draw + + // ANCHOR: app_switch_mode + fn switch_mode(&mut self, mode: Mode) { + self.mode = mode; + } + // ANCHOR_END: app_switch_mode + + // ANCHOR: app_quit + fn should_quit(&self) -> bool { + self.quit + } + + fn quit(&mut self) { + self.quit = true + } + // ANCHOR_END: app_quit + + fn scroll_up(&mut self) { + let last = self.crates.lock().unwrap().len().saturating_sub(1); + let wrap_index = self.crates.lock().unwrap().len().max(1); + let previous = self + .table_state + .selected() + .map_or(last, |i| (i + last) % wrap_index); + self.scroll_to(previous); + } + + fn scroll_down(&mut self) { + let wrap_index = self.crates.lock().unwrap().len().max(1); + let next = self + .table_state + .selected() + .map_or(0, |i| (i + 1) % wrap_index); + self.scroll_to(next); + } + + fn scroll_to(&mut self, index: usize) { + if self.crates.lock().unwrap().is_empty() { + self.table_state.select(None) + } else { + self.table_state.select(Some(index)); + } + } + + // ANCHOR: app_submit_search_query + fn submit_search_query(&mut self) { + // prepare request + self.table_state.select(None); + let params = SearchParameters::new( + self.prompt.value().into(), + self.crates.clone(), + ); + let tx = self.tx.clone(); + tokio::spawn(async move { + let _ = request_search_results(¶ms).await; + tx.send(Action::UpdateSearchResults) // new + }); + self.switch_mode(Mode::Results); + } + // ANCHOR_END: app_submit_search_query + + // ANCHOR: app_update_search_results + fn update_search_results(&mut self) { + self.scroll_down(); // select first item in the results if they exist + } + // ANCHOR_END: app_update_search_results + + fn set_cursor(&mut self, frame: &mut Frame<'_>) { + if matches!(self.mode, Mode::Prompt) { + if let Some(cursor_position) = self.cursor_position { + frame.set_cursor(cursor_position.x, cursor_position.y) + } + } + } +} + +// ANCHOR: app_default +impl Default for App { + fn default() -> Self { + Self::new() + } +} +// ANCHOR_END: app_default + +// ANCHOR: app_widget +struct AppWidget; +// ANCHOR_END: app_widget + +// ANCHOR: app_statefulwidget +impl StatefulWidget for AppWidget { + type State = App; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let [last_key_event, results, prompt] = Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(0), + Constraint::Length(5), + ]) + .areas(area); + + let table = state.results(); + StatefulWidget::render(table, results, buf, &mut state.table_state); + + let (block, paragraph) = state.prompt(); + block.render(prompt, buf); + paragraph.render( + prompt.inner(&Margin { + horizontal: 2, + vertical: 2, + }), + buf, + ); + state.calculate_cursor_position(prompt); + + if let Some(key) = state.last_key_event { + Paragraph::new(format!("last key event: {:?}", key.code)) + .right_aligned() + .render(last_key_event, buf); + } + } +} + +impl App { + fn results(&self) -> Table<'static> { + let widths = [ + Constraint::Length(15), + Constraint::Min(0), + Constraint::Length(20), + ]; + + let crates = self.crates.lock().unwrap(); + + let rows = crates + .iter() + .map(|krate| { + vec![ + krate.name.clone(), + krate.description.clone().unwrap_or_default(), + krate.downloads.to_string(), + ] + }) + .map(|row| Row::new(row.iter().map(String::from).collect_vec())) + .collect_vec(); + + Table::new(rows, widths) + .header(Row::new(vec!["Name", "Description", "Downloads"])) + .highlight_symbol(" █ ") + .highlight_spacing(HighlightSpacing::Always) + } + + fn prompt(&self) -> (Block, Paragraph) { + let color = if matches!(self.mode, Mode::Prompt) { + Color::Yellow + } else { + Color::Blue + }; + let block = Block::default().borders(Borders::ALL).border_style(color); + + let paragraph = Paragraph::new(self.prompt.value()); + + (block, paragraph) + } + + fn calculate_cursor_position(&mut self, area: Rect) { + if matches!(self.mode, Mode::Prompt) { + let margin = (2, 2); + let width = (area.width as f64 as u16).saturating_sub(margin.0); + self.cursor_position = Some(Position::new( + (area.x + margin.0 + self.prompt.cursor() as u16).min(width), + area.y + margin.1, + )); + } else { + self.cursor_position = None + } + } +} +// ANCHOR_END: app_statefulwidget + +// ANCHOR_END: full_app + +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + errors::install_hooks()?; + let tui = tui::init()?; + let events = events::Events::new(); + + App::new().run(tui, events).await?; + + tui::restore()?; + + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-app-prototype.tape b/code/crates-tui-tutorial-app/src/bin/part-app-prototype.tape new file mode 100644 index 000000000..d67cf5677 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app-prototype.tape @@ -0,0 +1,17 @@ +# A VHS tape. See https://github.com/charmbracelet/vhs +Output crates-tui-tutorial-part-app-prototype.gif +Set Theme "Aardvark Blue" +Set Width 1600 +Set Height 800 +Type "cargo run --bin part-app-prototype" +Enter +Sleep 3s +Type @0.1s "ratatui" +Sleep 1s +Enter +Sleep 5s +Down @0.1s 5 +Sleep 5s +Up @0.1s 5 +Sleep 5s + diff --git a/code/crates-tui-tutorial-app/src/bin/part-errors.rs b/code/crates-tui-tutorial-app/src/bin/part-errors.rs new file mode 100644 index 000000000..c7380c9e7 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-errors.rs @@ -0,0 +1,24 @@ +use crates_tui::errors; +use crates_tui::tui; + +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + errors::install_hooks()?; + + let mut tui = tui::init()?; + + tui.draw(|frame| { + frame.render_widget( + ratatui::widgets::Paragraph::new("hello world"), + frame.size(), + ); + // panic!("Oops. Something went wrong!"); + })?; + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + tui::restore()?; + + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-events.rs b/code/crates-tui-tutorial-app/src/bin/part-events.rs new file mode 100644 index 000000000..4d085ec72 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-events.rs @@ -0,0 +1,43 @@ +use crates_tui::errors; +use crates_tui::events; +use crates_tui::tui; + +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + errors::install_hooks()?; + + let mut tui = tui::init()?; + + let mut events = events::Events::new(); + + use crossterm::event::Event as CrosstermEvent; + use crossterm::event::KeyCode::Esc; + + while let Some(evt) = events.next().await { + match evt { + events::Event::Render => { + tui.draw(|frame| { + frame.render_widget( + ratatui::widgets::Paragraph::new(format!( + "frame counter: {}", + frame.count() + )), + frame.size(), + ); + })?; + } + events::Event::Crossterm(CrosstermEvent::Key(key)) + if key.code == Esc => + { + break + } + _ => (), + } + } + + tui::restore()?; + + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-events.tape b/code/crates-tui-tutorial-app/src/bin/part-events.tape new file mode 100644 index 000000000..dbb8c271c --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-events.tape @@ -0,0 +1,11 @@ +# A VHS tape. See https://github.com/charmbracelet/vhs +Output crates-tui-tutorial-part-events.gif +Set Theme "Aardvark Blue" +Set Width 900 +Set Height 600 +Type "cargo run --bin part-events" +Enter +Sleep 5s +Escape +Sleep 2s + diff --git a/code/crates-tui-tutorial-app/src/bin/part-final.rs b/code/crates-tui-tutorial-app/src/bin/part-final.rs new file mode 100644 index 000000000..b401cfe3e --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-final.rs @@ -0,0 +1,18 @@ +use crates_tui::app; +use crates_tui::errors; +use crates_tui::events; +use crates_tui::tui; + +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + errors::install_hooks()?; + + let tui = tui::init()?; + let events = events::Events::new(); + app::App::new().run(tui, events).await?; + tui::restore()?; + + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-helper.rs b/code/crates-tui-tutorial-app/src/bin/part-helper.rs new file mode 100644 index 000000000..503698525 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-helper.rs @@ -0,0 +1,154 @@ +// ANCHOR: main +#[tokio::main] +async fn main() -> Result<()> { + let crates: Arc>> = Default::default(); + let search_params = SearchParameters::new("ratatui".into(), crates.clone()); + tokio::spawn(async move { + let _ = request_search_results(&search_params).await; + }) + .await?; + for krate in crates.lock().unwrap().iter() { + println!( + "name: {}\ndescription: {}\ndownloads: {}\n", + krate.name, + krate.description.clone().unwrap_or_default(), + krate.downloads + ); + } + Ok(()) +} +// ANCHOR_END: main + +// ANCHOR: helper +use color_eyre::{eyre::Context, Result}; +use std::sync::{Arc, Mutex}; + +// ANCHOR: search_parameters +pub struct SearchParameters { + // Request + pub search: String, + pub page: u64, + pub page_size: u64, + pub sort: crates_io_api::Sort, + + // Response + pub crates: Arc>>, +} +// ANCHOR_END: search_parameters + +// ANCHOR: search_parameters_new +impl SearchParameters { + pub fn new( + search: String, + crates: Arc>>, + ) -> SearchParameters { + SearchParameters { + search, + page: 1, + page_size: 100, + sort: crates_io_api::Sort::Relevance, + crates, + } + } +} +// ANCHOR_END: search_parameters_new + +// ANCHOR: request_search_results +/// Performs the actual search, and sends the result back through the +/// sender. +pub async fn request_search_results( + search_params: &SearchParameters, +) -> Result<(), String> { + let client = create_client()?; + let query = create_query(search_params); + let crates = fetch_crates(client, query).await?; + update_search_params_with_fetched_crates(crates, search_params); + Ok(()) +} +// ANCHOR_END: request_search_results + +fn create_client() -> Result { + // ANCHOR: client + let email = std::env::var("CRATES_TUI_TUTORIAL_APP_MYEMAIL").context("Need to set CRATES_TUI_TUTORIAL_APP_MYEMAIL environment variable to proceed").unwrap(); + + let user_agent = format!("crates-tui ({email})"); + let rate_limit = std::time::Duration::from_millis(1000); + + crates_io_api::AsyncClient::new(&user_agent, rate_limit) + // ANCHOR_END: client + .map_err(|err| format!("API Client Error: {err:#?}")) +} + +fn create_query( + search_params: &SearchParameters, +) -> crates_io_api::CratesQuery { + #[allow(clippy::let_and_return)] + // ANCHOR: create_query + let query = crates_io_api::CratesQueryBuilder::default() + .search(&search_params.search) + .page(search_params.page) + .page_size(search_params.page_size) + .sort(search_params.sort.clone()) + .build(); + // ANCHOR_END: create_query + query +} + +async fn fetch_crates( + client: crates_io_api::AsyncClient, + query: crates_io_api::CratesQuery, +) -> Result, String> { + // ANCHOR: crates_query + let page_result = client + .crates(query) + .await + // ANCHOR_END: crates_query + .map_err(|err| format!("API Client Error: {err:#?}"))?; + // ANCHOR: crates_response + let crates = page_result.crates; + // ANCHOR_END: crates_response + Ok(crates) +} + +fn update_search_params_with_fetched_crates( + crates: Vec, + search_params: &SearchParameters, +) { + // ANCHOR: update_state + let mut app_crates = search_params.crates.lock().unwrap(); + app_crates.clear(); + app_crates.extend(crates); + // ANCHOR_END: update_state +} + +// ANCHOR: test +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_crates_io() -> Result<()> { + let crates: Arc>> = Default::default(); + + let search_params = + SearchParameters::new("ratatui".into(), crates.clone()); + + tokio::spawn(async move { + let _ = request_search_results(&search_params).await; + }) + .await?; + + for krate in crates.lock().unwrap().iter() { + println!( + "name: {}\ndescription: {}\ndownloads: {}\n", + krate.name, + krate.description.clone().unwrap_or_default(), + krate.downloads + ); + } + + Ok(()) + } +} +// ANCHOR_END: test +// ANCHOR_END: helper diff --git a/code/crates-tui-tutorial-app/src/bin/part-main-error.rs b/code/crates-tui-tutorial-app/src/bin/part-main-error.rs new file mode 100644 index 000000000..1b9d02bdb --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-main-error.rs @@ -0,0 +1,8 @@ +// ANCHOR: main +fn main() -> color_eyre::Result<()> { + tokio::spawn(async { + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + }); + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-main-tasks-concurrent.rs b/code/crates-tui-tutorial-app/src/bin/part-main-tasks-concurrent.rs new file mode 100644 index 000000000..820d57f56 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-main-tasks-concurrent.rs @@ -0,0 +1,23 @@ +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + println!("Spawning a task that sleeps 5 seconds..."); + + let mut tasks = vec![]; + for i in 0..10 { + tasks.push(tokio::spawn(async move { + println!("Sleeping for 5 seconds in a tokio task {i}..."); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + i + })); + } + + println!("Getting return values from tasks..."); + while let Some(task) = tasks.pop() { + let return_value_from_task = task.await?; + println!("Got i = {return_value_from_task}"); + } + + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-main-tasks-sequential.rs b/code/crates-tui-tutorial-app/src/bin/part-main-tasks-sequential.rs new file mode 100644 index 000000000..f5ca27077 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-main-tasks-sequential.rs @@ -0,0 +1,23 @@ +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + println!("Spawning a task that sleeps 5 seconds..."); + + let mut tasks = vec![]; + for i in 0..10 { + tasks.push(async move { + println!("Sleeping for 5 seconds in a tokio task {i}..."); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + i + }); + } + + println!("Getting return values from tasks..."); + while let Some(task) = tasks.pop() { + let return_value_from_task = task.await; + println!("Got i = {return_value_from_task}"); + } + + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-main.rs b/code/crates-tui-tutorial-app/src/bin/part-main.rs new file mode 100644 index 000000000..32b0692ee --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-main.rs @@ -0,0 +1,8 @@ +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + println!("Sleeping for 5 seconds..."); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-tui.rs b/code/crates-tui-tutorial-app/src/bin/part-tui.rs new file mode 100644 index 000000000..dd387b3f9 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-tui.rs @@ -0,0 +1,21 @@ +use crates_tui::tui; + +// ANCHOR: main +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + let mut tui = tui::init()?; + + tui.draw(|frame| { + frame.render_widget( + ratatui::widgets::Paragraph::new("hello world"), + frame.size(), + ) + })?; + + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + tui::restore()?; + + Ok(()) +} +// ANCHOR_END: main diff --git a/code/crates-tui-tutorial-app/src/bin/part-tui.tape b/code/crates-tui-tutorial-app/src/bin/part-tui.tape new file mode 100644 index 000000000..350256071 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-tui.tape @@ -0,0 +1,8 @@ +# A VHS tape. See https://github.com/charmbracelet/vhs +Output crates-tui-tutorial-part-tui.gif +Set Theme "Aardvark Blue" +Set Width 900 +Set Height 600 +Type "cargo run --bin part-tui" +Enter +Sleep 8s diff --git a/code/crates-tui-tutorial-app/src/crates_io_api_helper.rs b/code/crates-tui-tutorial-app/src/crates_io_api_helper.rs new file mode 100644 index 000000000..1a0404525 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/crates_io_api_helper.rs @@ -0,0 +1,135 @@ +use std::sync::{Arc, Mutex}; + +use color_eyre::{eyre::Context, Result}; +use crates_io_api::CratesQuery; + +// ANCHOR: search_parameters +/// Represents the parameters needed for fetching crates asynchronously. +pub struct SearchParameters { + // Request + pub search: String, + pub page: u64, + pub page_size: u64, + pub sort: crates_io_api::Sort, + + // Response + pub crates: Arc>>, + + // Additional + pub fake_delay: u64, +} +// ANCHOR_END: search_parameters + +impl SearchParameters { + pub fn new( + search: String, + crates: Arc>>, + ) -> SearchParameters { + SearchParameters { + search, + page: 1, + page_size: 100, + sort: crates_io_api::Sort::Relevance, + crates, + fake_delay: 0, + } + } +} + +// ANCHOR: request_search_results +/// Performs the actual search, and sends the result back through the +/// sender. +pub async fn request_search_results( + params: &SearchParameters, +) -> Result<(), String> { + let client = create_client()?; + let query = create_query(params); + let crates = fetch_crates(client, query).await?; + update_state_with_fetched_crates(crates, params); + tokio::time::sleep(tokio::time::Duration::from_secs(params.fake_delay)) + .await; // simulate delay + Ok(()) +} +// ANCHOR_END: request_search_results + +/// Helper function to create client and fetch crates, wrapping both actions +/// into a result pattern. +fn create_client() -> Result { + // ANCHOR: client + let email = std::env::var("CRATES_TUI_TUTORIAL_APP_MYEMAIL").context("Need to set CRATES_TUI_TUTORIAL_APP_MYEMAIL environment variable to proceed").unwrap(); + + let user_agent = format!("crates-tui ({email})"); + let rate_limit = std::time::Duration::from_millis(1000); + + crates_io_api::AsyncClient::new(&user_agent, rate_limit) + // ANCHOR_END: client + .map_err(|err| format!("API Client Error: {err:#?}")) +} + +// ANCHOR: create_query +fn create_query(params: &SearchParameters) -> CratesQuery { + crates_io_api::CratesQueryBuilder::default() + .search(¶ms.search) + .page(params.page) + .page_size(params.page_size) + .sort(params.sort.clone()) + .build() +} +// ANCHOR_END: create_query + +async fn fetch_crates( + client: crates_io_api::AsyncClient, + query: crates_io_api::CratesQuery, +) -> Result, String> { + // ANCHOR: crates_query + let page_result = client + .crates(query) + .await + // ANCHOR_END: crates_query + .map_err(|err| format!("API Client Error: {err:#?}"))?; + let crates = page_result.crates; + Ok(crates) +} + +/// Handles the result after fetching crates and sending corresponding +/// actions. +fn update_state_with_fetched_crates( + crates: Vec, + params: &SearchParameters, +) { + let mut app_crates = params.crates.lock().unwrap(); + app_crates.clear(); + app_crates.extend(crates); +} + +// ANCHOR: test +#[cfg(test)] +mod tests { + + use super::*; + + #[tokio::test] + async fn test_crates_io() -> Result<()> { + let crates: Arc>> = Default::default(); + + let search_params = + SearchParameters::new("ratatui".into(), crates.clone()); + + tokio::spawn(async move { + let _ = request_search_results(&search_params).await; + }) + .await?; + + for krate in crates.lock().unwrap().iter() { + println!( + "name: {}\ndescription: {}\ndownloads: {}\n", + krate.name, + krate.description.clone().unwrap_or_default(), + krate.downloads + ); + } + + Ok(()) + } +} +// ANCHOR_END: test diff --git a/code/crates-tui-tutorial-app/src/errors.rs b/code/crates-tui-tutorial-app/src/errors.rs new file mode 100644 index 000000000..63c7d9a16 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/errors.rs @@ -0,0 +1,39 @@ +use color_eyre::{ + config::{EyreHook, HookBuilder, PanicHook}, + eyre::{self, Result}, +}; + +use crate::tui; + +pub fn install_hooks() -> Result<()> { + let (panic_hook, eyre_hook) = HookBuilder::default() + .panic_section(format!( + "This is a bug. Consider reporting it at {}", + env!("CARGO_PKG_REPOSITORY") + )) + .into_hooks(); + + install_color_eyre_panic_hook(panic_hook); + install_eyre_hook(eyre_hook)?; + + Ok(()) +} + +fn install_color_eyre_panic_hook(panic_hook: PanicHook) { + let panic_hook = panic_hook.into_panic_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + if let Err(err) = tui::restore() { + println!("Unable to restore terminal: {err:?}"); + } + panic_hook(panic_info); + })); +} + +fn install_eyre_hook(eyre_hook: EyreHook) -> color_eyre::Result<()> { + let eyre_hook = eyre_hook.into_eyre_hook(); + eyre::set_hook(Box::new(move |error| { + tui::restore().unwrap(); + eyre_hook(error) + }))?; + Ok(()) +} diff --git a/code/crates-tui-tutorial-app/src/events.rs b/code/crates-tui-tutorial-app/src/events.rs new file mode 100644 index 000000000..b3f953f13 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/events.rs @@ -0,0 +1,71 @@ +// ANCHOR: event +use crossterm::event::Event as CrosstermEvent; + +#[derive(Clone, Debug)] +pub enum Event { + Error, + Render, + Crossterm(CrosstermEvent), +} +// ANCHOR_END: event + +// ANCHOR: stream +use futures::StreamExt; + +type Stream = std::pin::Pin>>; +// ANCHOR_END: stream + +// ANCHOR: events +pub struct Events { + streams: tokio_stream::StreamMap<&'static str, Stream>, +} + +impl Default for Events { + fn default() -> Self { + Self { + streams: tokio_stream::StreamMap::from_iter([ + ("render", render_stream()), + ("crossterm", crossterm_stream()), + ]), + } + } +} + +impl Events { + pub fn new() -> Self { + Self::default() + } + + pub async fn next(&mut self) -> Option { + self.streams.next().await.map(|(_name, event)| event) + } +} +// ANCHOR_END: events + +// ANCHOR: render +fn render_stream() -> Stream { + const FRAME_RATE: f64 = 15.0; + let render_delay = std::time::Duration::from_secs_f64(1.0 / FRAME_RATE); + let render_interval = tokio::time::interval(render_delay); + Box::pin( + tokio_stream::wrappers::IntervalStream::new(render_interval) + .map(|_| Event::Render), + ) +} +// ANCHOR_END: render + +// ANCHOR: crossterm +fn crossterm_stream() -> Stream { + use crossterm::event::EventStream; + use crossterm::event::KeyEventKind; + use CrosstermEvent::Key; + Box::pin(EventStream::new().fuse().filter_map(|event| async move { + match event { + // Ignore key release / repeat events + Ok(Key(key)) if key.kind == KeyEventKind::Release => None, + Ok(event) => Some(Event::Crossterm(event)), + Err(_) => Some(Event::Error), + } + })) +} +// ANCHOR_END: crossterm diff --git a/code/crates-tui-tutorial-app/src/lib.rs b/code/crates-tui-tutorial-app/src/lib.rs new file mode 100644 index 000000000..326936267 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/lib.rs @@ -0,0 +1,6 @@ +pub mod app; +pub mod crates_io_api_helper; +pub mod errors; +pub mod events; +pub mod tui; +pub mod widgets; diff --git a/code/crates-tui-tutorial-app/src/tui.rs b/code/crates-tui-tutorial-app/src/tui.rs new file mode 100644 index 000000000..1a5532125 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/tui.rs @@ -0,0 +1,24 @@ +use ratatui::prelude::{CrosstermBackend, Terminal}; + +// ANCHOR: tui +pub type Tui = Terminal>; +// ANCHOR_END: tui + +// ANCHOR: backend +pub fn init() -> color_eyre::Result { + use crossterm::terminal::EnterAlternateScreen; + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(std::io::stdout(), EnterAlternateScreen)?; + let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?; + terminal.clear()?; + terminal.hide_cursor()?; + Ok(terminal) +} + +pub fn restore() -> color_eyre::Result<()> { + use crossterm::terminal::LeaveAlternateScreen; + crossterm::execute!(std::io::stdout(), LeaveAlternateScreen)?; + crossterm::terminal::disable_raw_mode()?; + Ok(()) +} +// ANCHOR_END: backend diff --git a/code/crates-tui-tutorial-app/src/widgets.rs b/code/crates-tui-tutorial-app/src/widgets.rs new file mode 100644 index 000000000..d120db602 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/widgets.rs @@ -0,0 +1,3 @@ +pub mod search_prompt; +pub mod search_results; +pub mod status_bar; diff --git a/code/crates-tui-tutorial-app/src/widgets/search_prompt.rs b/code/crates-tui-tutorial-app/src/widgets/search_prompt.rs new file mode 100644 index 000000000..3368425e6 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/widgets/search_prompt.rs @@ -0,0 +1,148 @@ +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; + +use ratatui::{layout::Position, prelude::*, widgets::*}; +use tui_input::backend::crossterm::EventHandler; + +use crate::app::{Action, Mode}; + +// ANCHOR: state +#[derive(Debug, Clone)] +pub struct SearchPrompt { + pub cursor_position: Option, + pub input: tui_input::Input, + sort: crates_io_api::Sort, + tx: tokio::sync::mpsc::UnboundedSender, + loading_status: Arc, + crates: Arc>>, +} + +impl SearchPrompt { + pub fn new( + tx: tokio::sync::mpsc::UnboundedSender, + loading_status: Arc, + crates: Arc>>, + ) -> Self { + Self { + cursor_position: Default::default(), + input: Default::default(), + sort: crates_io_api::Sort::Relevance, + tx, + loading_status, + crates, + } + } + + // ANCHOR: prompt_methods + pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) { + use crossterm::event::Event as CrosstermEvent; + self.input.handle_event(&CrosstermEvent::Key(key)); + } + + // ANCHOR: submit + pub fn submit_query(&mut self) { + let tx = self.tx.clone(); + let loading_status = self.loading_status.clone(); + let params = self.create_search_parameters(); + // params.fake_delay = 5; + tokio::spawn(async move { + loading_status.store(true, Ordering::SeqCst); + let _ = + crate::crates_io_api_helper::request_search_results(¶ms) + .await; + loading_status.store(false, Ordering::SeqCst); + let _ = tx.send(Action::UpdateSearchResults); + let _ = tx.send(Action::SwitchMode(Mode::Results)); + let _ = tx.send(Action::ScrollDown); + }); + } + + // ANCHOR: create_search_parameters + pub fn create_search_parameters( + &self, + ) -> crate::crates_io_api_helper::SearchParameters { + crate::crates_io_api_helper::SearchParameters::new( + self.input.value().into(), + self.crates.clone(), + ) + } + // ANCHOR_END: create_search_parameters + + // ANCHOR_END: submit +} + +// ANCHOR_END: state + +// ANCHOR: widget +pub struct SearchPromptWidget { + focused: bool, +} +// ANCHOR_END: widget + +impl SearchPromptWidget { + pub fn new(focused: bool) -> Self { + Self { focused } + } + + fn border(&self) -> Block { + let color = if self.focused { + Color::Yellow + } else { + Color::Black + }; + Block::default().borders(Borders::ALL).border_style(color) + } + + fn sort_by_text(&self, sort: crates_io_api::Sort) -> impl Widget { + Paragraph::new(Line::from(vec![ + "Sort By: ".into(), + format!("{:?}", sort).fg(Color::Blue), + ])) + .right_aligned() + } + + fn prompt_text<'a>( + &self, + width: usize, + input: &'a tui_input::Input, + ) -> impl Widget + 'a { + let scroll = input.cursor().saturating_sub(width.saturating_sub(4)); + let text = Line::from(vec![input.value().into()]); + Paragraph::new(text).scroll((0, scroll as u16)) + } + + fn calculate_cursor_position(&self, area: Rect, state: &mut SearchPrompt) { + if self.focused { + let margin = (2, 2); + let width = (area.width as f64 as u16).saturating_sub(margin.0); + state.cursor_position = Some(Position::new( + (area.x + margin.0 + state.input.cursor() as u16).min(width), + area.y + margin.1, + )); + } else { + state.cursor_position = None + } + } +} + +// ANCHOR: render +impl StatefulWidget for SearchPromptWidget { + type State = SearchPrompt; + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + self.border().render(area, buf); + + let [input, meta] = + Layout::horizontal([Constraint::Fill(0), Constraint::Length(25)]) + .areas(area); + + self.sort_by_text(state.sort.clone()) + .render(meta.inner(&Margin::new(2, 2)), buf); + self.prompt_text(input.width as usize, &state.input) + .render(input.inner(&Margin::new(2, 2)), buf); + + self.calculate_cursor_position(input, state); + } +} +// ANCHOR_END: render diff --git a/code/crates-tui-tutorial-app/src/widgets/search_results.rs b/code/crates-tui-tutorial-app/src/widgets/search_results.rs new file mode 100644 index 000000000..0b62fd133 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/widgets/search_results.rs @@ -0,0 +1,183 @@ +use std::sync::{Arc, Mutex}; + +use crates_io_api::Crate; +use itertools::Itertools; +use ratatui::{prelude::*, widgets::*}; + +// ANCHOR: state +#[derive(Debug, Default)] +pub struct SearchResults { + pub crates: Arc>>, + pub table_state: TableState, + pub scrollbar_state: ScrollbarState, +} + +impl SearchResults { + pub fn new(crates: Arc>>) -> Self { + Self { + crates, + table_state: Default::default(), + scrollbar_state: Default::default(), + } + } +} +// ANCHOR_END: state + +impl SearchResults { + fn rows(&self) -> Vec> { + self.crates + .lock() + .unwrap() + .iter() + .map(row_from_crate) + .collect_vec() + } + + fn header(&self) -> Row<'static> { + let header_cells = ["Name", "Description", "Downloads"] + .map(|h| h.bold().into()) + .map(vertical_pad); + Row::new(header_cells).height(TABLE_HEADER_HEIGHT) + } + + pub fn clear_selection(&mut self) { + self.table_state.select(None) + } + + // ANCHOR: scroll + pub fn scroll_next(&mut self) { + let wrap_index = self.crates.lock().unwrap().len().max(1); + let next = self + .table_state + .selected() + .map_or(0, |i| (i + 1) % wrap_index); + self.scroll_to(next); + } + + pub fn scroll_previous(&mut self) { + let last = self.crates.lock().unwrap().len().saturating_sub(1); + let wrap_index = self.crates.lock().unwrap().len().max(1); + let previous = self + .table_state + .selected() + .map_or(last, |i| (i + last) % wrap_index); + self.scroll_to(previous); + } + + fn scroll_to(&mut self, index: usize) { + if self.crates.lock().unwrap().is_empty() { + self.table_state.select(None) + } else { + self.table_state.select(Some(index)); + self.scrollbar_state = self.scrollbar_state.position(index); + } + } + + pub fn update_search_results(&mut self) { + self.table_state.select(None); + self.scrollbar_state = self + .scrollbar_state + .content_length(self.crates.lock().unwrap().len()) + } + + // ANCHOR_END: scroll +} + +const TABLE_HEADER_HEIGHT: u16 = 2; +const COLUMN_SPACING: u16 = 3; +const ROW_HEIGHT: u16 = 2; + +// ANCHOR: widget +pub struct SearchResultsWidget { + highlight: bool, +} + +impl SearchResultsWidget { + pub fn new(highlight: bool) -> Self { + Self { highlight } + } +} +// ANCHOR_END: widget + +impl SearchResultsWidget { + fn render_scrollbar( + &self, + area: Rect, + buf: &mut Buffer, + state: &mut SearchResults, + ) { + let [_, scrollbar_area] = Layout::vertical([ + Constraint::Length(TABLE_HEADER_HEIGHT), + Constraint::Fill(1), + ]) + .areas(area); + + Scrollbar::default() + .track_symbol(Some(" ")) + .thumb_symbol("▐") + .begin_symbol(None) + .end_symbol(None) + .render(scrollbar_area, buf, &mut state.scrollbar_state); + } + + fn render_table( + &self, + area: Rect, + buf: &mut Buffer, + state: &mut SearchResults, + ) { + let highlight_symbol = if self.highlight { + " █ " + } else { + " \u{2022} " + }; + + let column_widths = [ + Constraint::Max(20), + Constraint::Fill(1), + Constraint::Max(11), + ]; + + let header = state.header(); + let rows = state.rows(); + + let table = Table::new(rows, column_widths) + .header(header) + .column_spacing(COLUMN_SPACING) + .highlight_symbol(vertical_pad(highlight_symbol.into())) + .highlight_spacing(HighlightSpacing::Always); + + StatefulWidget::render(table, area, buf, &mut state.table_state); + } +} + +// ANCHOR: render +impl StatefulWidget for SearchResultsWidget { + type State = SearchResults; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let [table_area, scrollbar_area] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(1)]) + .areas(area); + + self.render_scrollbar(scrollbar_area, buf, state); + self.render_table(table_area, buf, state); + } +} +// ANCHOR_END: render + +fn vertical_pad(line: Line) -> Text { + Text::from(vec!["".into(), line]) +} + +fn row_from_crate(krate: &Crate) -> Row<'static> { + let crate_name = Line::from(krate.name.clone()); + let description = Line::from(krate.description.clone().unwrap_or_default()); + let downloads = Line::from(krate.downloads.to_string()).right_aligned(); + Row::new([ + vertical_pad(crate_name), + vertical_pad(description), + vertical_pad(downloads), + ]) + .height(ROW_HEIGHT) +} diff --git a/code/crates-tui-tutorial-app/src/widgets/status_bar.rs b/code/crates-tui-tutorial-app/src/widgets/status_bar.rs new file mode 100644 index 000000000..15ff228d5 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/widgets/status_bar.rs @@ -0,0 +1,47 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use ratatui::prelude::*; +use ratatui::widgets::*; + +#[derive(Debug)] +pub struct StatusBar { + pub last_key_event: Option, + pub loading_status: Arc, +} + +impl StatusBar { + pub fn new(loading_status: Arc) -> Self { + Self { + last_key_event: None, + loading_status, + } + } + + // ANCHOR: loading + pub fn loading(&self) -> bool { + self.loading_status.load(Ordering::SeqCst) + } +} + +// ANCHOR: app_widget +pub struct StatusBarWidget; +// ANCHOR_END: app_widget + +// ANCHOR: app_statefulwidget +impl StatefulWidget for StatusBarWidget { + type State = StatusBar; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + if let Some(key) = state.last_key_event { + Paragraph::new(format!("last key event: {:?}", key.code)) + .right_aligned() + .render(area, buf); + } + + if state.loading() { + Line::from("Loading...").render(area, buf); + } + } +} +// ANCHOR_END: app_statefulwidget diff --git a/src/content/docs/tutorials/counter-app/multiple-files/event.md b/src/content/docs/tutorials/counter-app/multiple-files/event.md index 0772e2456..d0f04b84c 100644 --- a/src/content/docs/tutorials/counter-app/multiple-files/event.md +++ b/src/content/docs/tutorials/counter-app/multiple-files/event.md @@ -174,9 +174,7 @@ This gives us a `sender` and `receiver` pair. The `sender` can be used to send e Notice that we are using `std::thread::spawn` in this `EventHandler`. This thread is spawned to handle events and runs in the background and is responsible for polling and sending events to our -main application through the channel. In the -[async counter tutorial](/tutorials/counter-async-app/async-event-stream/) we will use -`tokio::task::spawn` instead. +main application through the channel. In this background thread, we continuously poll for events with `event::poll(timeout)`. If an event is available, it's read and sent through the sender channel. The types of events we handle include, diff --git a/src/content/docs/tutorials/counter-async-app/actions.md b/src/content/docs/tutorials/counter-async-app/actions.md deleted file mode 100644 index 733f5dd27..000000000 --- a/src/content/docs/tutorials/counter-async-app/actions.md +++ /dev/null @@ -1,317 +0,0 @@ ---- -title: Counter App with Actions ---- - -One of the first steps to building truly `async` TUI applications is to use the `Command`, `Action`, -or `Message` pattern. - -:::tip - -The `Command` pattern is the concept of "reified method calls". You can learn a lot more about this -pattern from the excellent -[http://gameprogrammingpatterns.com](http://gameprogrammingpatterns.com/command.html). - -::: - -You can learn more about this concept in -[The Elm Architecture section](/concepts/application-patterns/the-elm-architecture/) of the -documentation. - -We have learnt about enums in JSON-editor tutorial. We are going to extend the counter application -to include `Action`s using Rust's enum features. The key idea is that we have an `Action` enum that -tracks all the actions that can be carried out by the `App`. Here's the variants of the `Action` -enum we will be using: - -```rust -pub enum Action { - Tick, - Increment, - Decrement, - Quit, - None, -} -``` - -Now we add a new `get_action` function to map a `Event` to an `Action`. - -```rust -fn get_action(_app: &App, event: Event) -> Action { - if let Key(key) = event { - return match key.code { - Char('j') => Action::Increment, - Char('k') => Action::Decrement, - Char('q') => Action::Quit, - _ => Action::None, - }; - }; - Action::None -} -``` - -:::tip - -Instead of using a `None` variant in `Action`, you can drop the `None` from `Action` and use Rust's -built-in `Option` types instead. This is what your code might actually look like: - -```rust -fn get_action(_app: &App, event: Event) -> Result> { - if let Key(key) = event { - let action = match key.code { - Char('j') => Action::Increment, - Char('k') => Action::Decrement, - Char('q') => Action::Quit, - _ => return Ok(None), - }; - return Ok(Some(action)) - }; - Ok(None) -} -``` - -But, for illustration purposes, in this tutorial we will stick to using `Action::None` for now. - -::: - -And the `update` function takes an `Action` instead: - -```rust -fn update(app: &mut App, action: Action) { - match action { - Action::Quit => app.should_quit = true, - Action::Increment => app.counter += 1, - Action::Decrement => app.counter -= 1, - Action::Tick => {}, - _ => {}, - }; -} - -``` - -Here's the full single file version of the counter app using the `Action` enum for your reference: - -```rust -mod tui; - -use color_eyre::eyre::Result; -use crossterm::{ - event::{self, Event::Key, KeyCode::Char}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use ratatui::{ - prelude::{CrosstermBackend, Terminal}, - widgets::Paragraph, -}; - -// App state -struct App { - counter: i64, - should_quit: bool, -} - -// App actions -pub enum Action { - Tick, - Increment, - Decrement, - Quit, - None, -} - -// App ui render function -fn ui(app: &App, f: &mut Frame) { - f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size()); -} - -fn get_action(_app: &App, event: Event) -> Action { - if let Key(key) = event { - return match key.code { - Char('j') => Action::Increment, - Char('k') => Action::Decrement, - Char('q') => Action::Quit, - _ => Action::None, - }; - }; - Action::None -} - -fn update(app: &mut App, action: Action) { - match action { - Action::Quit => app.should_quit = true, - Action::Increment => app.counter += 1, - Action::Decrement => app.counter -= 1, - Action::Tick => {}, - _ => {}, - }; -} - -fn run() -> Result<()> { - // ratatui terminal - let mut tui = tui::Tui::new()?.tick_rate(1.0).frame_rate(30.0); - tui.enter()?; - - // application state - let mut app = App { counter: 0, should_quit: false }; - - loop { - let event = tui.next().await?; // blocks until next event - - if let Event::Render = event.clone() { - // application render - tui.draw(|f| { - ui(f, &app); - })?; - } - let action = get_action(&mut app, event); // new - - // application update - update(&mut app, action); // new - - // application exit - if app.should_quit { - break; - } - } - - Ok(()) -} - -#[tokio::main] -async fn main() -> Result<()> { - let result = run().await; - - result?; - - Ok(()) -} -``` - -While this may seem like a lot more boilerplate to achieve the same thing, `Action` enums have a few -advantages. - -Firstly, they can be mapped from keypresses programmatically. For example, you can define a -configuration file that reads which keys are mapped to which `Action` like so: - -```toml -[keymap] -"q" = "Quit" -"j" = "Increment" -"k" = "Decrement" -``` - -Then you can add a new key configuration like so: - -```rust -struct App { - counter: i64, - should_quit: bool, - // new field - keyconfig: HashMap -} -``` - -If you populate `keyconfig` with the contents of a user provided `toml` file, then you can figure -out which action to take by updating the `get_action()` function: - -```rust -fn get_action(app: &App, event: Event) -> Action { - if let Event::Key(key) = event { - return app.keyconfig.get(key.code).unwrap_or(Action::None) - }; - Action::None -} -``` - -Another advantage of this is that the business logic of the `App` struct can be tested without -having to create an instance of a `Tui` or `EventHandler`, e.g.: - -```rust -mod tests { - #[test] - fn test_app() { - let mut app = App::new(); - let old_counter = app.counter; - update(&mut app, Action::Increment); - assert!(app.counter == old_counter + 1); - } -} -``` - -In the test above, we did not create an instance of the `Terminal` or the `EventHandler`, and did -not call the `run` function, but we are still able to test the business logic of our application. -Updating the app state on `Action`s gets us one step closer to making our application a "state -machine", which improves understanding and testability. - -If we wanted to be purist about it, we would make a struct called `AppState` which would be -immutable, and we would have an `update` function return a new instance of the `AppState`: - -```rust -fn update(app_state: AppState, action: Action) -> AppState { - let mut state = app_state.clone(); - state.counter += 1; - state -} -``` - -:::note - -[`Charm`'s `bubbletea`](https://github.com/charmbracelet/bubbletea) also follows the TEA paradigm. -Here's an example of what the `Update` function for a counter example might look like in Go: - -```go -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - - // Is it a key press? - case tea.KeyMsg: - // These keys should exit the program. - case "q": - return m, tea.Quit - - case "k": - m.counter-- - - case "j": - m.counter++ - } - - // Note that we're not returning a command. - return m, nil -} -``` - -::: - -Like in `Charm`, we may also want to choose a action to follow up after an `update` by returning -another `Action`: - -```rust -fn update(app_state: AppState, action: Action) -> (AppState, Action) { - let mut state = app_state.clone(); - state.counter += 1; - (state, Action::None) // no follow up action - // OR - (state, Action::Tick) // force app to tick -} -``` - -We would have to modify our `run` function to handle the above paradigm though. Also, writing code -to follow this architecture in Rust requires more upfront design, mostly because you have to make -your `AppState` struct `Clone`-friendly. - -For this tutorial, we will stick to having a mutable `App`: - -```rust -fn update(app: &mut App, action: Action) { - match action { - Action::Quit => app.should_quit = true, - Action::Increment => app.counter += 1, - Action::Decrement => app.counter -= 1, - Action::Tick => {}, - _ => {}, - }; -} -``` - -The other advantage of using an `Action` enum is that you can tell your application what it should -do next by sending a message over a channel. We will discuss this approach in the next section. diff --git a/src/content/docs/tutorials/counter-async-app/async-event-stream.md b/src/content/docs/tutorials/counter-async-app/async-event-stream.md deleted file mode 100644 index 006cce94e..000000000 --- a/src/content/docs/tutorials/counter-async-app/async-event-stream.md +++ /dev/null @@ -1,223 +0,0 @@ ---- -title: Async Event Stream ---- - -Previously, in the multiple file version of the counter app, in -[`event.rs`](/tutorials/counter-app/multiple-files/event/) we created an `EventHandler` using -`std::thread::spawn`, i.e. OS threads. - -In this section, we are going to do the same thing with "green" threads or tasks, i.e. rust's -`async`-`await` features + a future executor. We will be using `tokio` for this. - -Here's example code of reading key presses asynchronously comparing `std::thread` and `tokio::task`. -Notably, we are using `tokio::sync::mpsc` channels instead of `std::sync::mpsc` channels. And -because of this, receiving on a channel needs to be `.await`'d and hence needs to be in a `async fn` -method. - -```diff lang="rust" - enum Event { - Key(crossterm::event::KeyEvent) - } - - struct EventHandler { -- rx: std::sync::mpsc::Receiver, -+ rx: tokio::sync::mpsc::UnboundedReceiver, - } - - impl EventHandler { - fn new() -> Self { - let tick_rate = std::time::Duration::from_millis(250); -- let (tx, rx) = std::sync::mpsc::channel(); -+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); -- std::thread::spawn(move || { -+ tokio::spawn(async move { - loop { - if crossterm::event::poll(tick_rate).unwrap() { - match crossterm::event::read().unwrap() { - CrosstermEvent::Key(e) => { - if key.kind == event::KeyEventKind::Press { - tx.send(Event::Key(e)).unwrap() - } - }, - _ => unimplemented!(), - } - } - } - }) - - EventHandler { rx } - } - -- fn next(&self) -> Result { -+ async fn next(&mut self) -> Result { -- Ok(self.rx.recv()?) -+ self.rx.recv().await.ok_or(color_eyre::eyre::eyre!("Unable to get event")) - } - } -``` - -Even with this change, our `EventHandler` behaves the same way as before. In order to take advantage -of using `tokio` we have to use `tokio::select!`. - -We can use [`tokio`'s `select!` macro](https://tokio.rs/tokio/tutorial/select) to wait on multiple -`async` computations and return when a any single computation completes. - -:::note - -Using `crossterm::event::EventStream::new()` requires the `event-stream` feature to be enabled. This -also requires the `futures` crate. Naturally you'll also need `tokio`. - -If you haven't already, add the following to your `Cargo.toml`: - -```yml -crossterm = { version = "0.27.0", features = ["event-stream"] } futures = "0.3.28" tokio = { version -= "1.32.0", features = ["full"] } tokio-util = "0.7.9" # required for `CancellationToken` introduced in the next section -``` - -::: - -Here's what the `EventHandler` looks like with the `select!` macro: - -```rust -use color_eyre::eyre::Result; -use crossterm::event::KeyEvent; -use futures::{FutureExt, StreamExt}; -use tokio::{sync::mpsc, task::JoinHandle}; - -#[derive(Clone, Copy, Debug)] -pub enum Event { - Error, - Tick, - Key(KeyEvent), -} - -#[derive(Debug)] -pub struct EventHandler { - _tx: mpsc::UnboundedSender, - rx: mpsc::UnboundedReceiver, - task: Option>, -} - -impl EventHandler { - pub fn new() -> Self { - let tick_rate = std::time::Duration::from_millis(250); - - let (tx, rx) = mpsc::unbounded_channel(); - let _tx = tx.clone(); - - let task = tokio::spawn(async move { - let mut reader = crossterm::event::EventStream::new(); - let mut interval = tokio::time::interval(tick_rate); - loop { - let delay = interval.tick(); - let crossterm_event = reader.next().fuse(); - tokio::select! { - maybe_event = crossterm_event => { - match maybe_event { - Some(Ok(evt)) => { - match evt { - crossterm::event::Event::Key(key) => { - if key.kind == crossterm::event::KeyEventKind::Press { - tx.send(Event::Key(key)).unwrap(); - } - }, - _ => {}, - } - } - Some(Err(_)) => { - tx.send(Event::Error).unwrap(); - } - None => {}, - } - }, - _ = delay => { - tx.send(Event::Tick).unwrap(); - }, - } - } - }); - - Self { _tx, rx, task: Some(task) } - } - - pub async fn next(&mut self) -> Result { - self.rx.recv().await.ok_or(color_eyre::eyre::eyre!("Unable to get event")) - } -} -``` - -As mentioned before, since `EventHandler::next()` is a `async` function, when we use it we have to -call `.await` on it. And the function that is the call site of `event_handler.next().await` also -needs to be an `async` function. In our tutorial, we are going to use the event handler in the -`run()` function which will now be `async`. - -Also, now that we are getting events asynchronously, we don't need to call -`crossterm::event::poll()` in the `update` function. Let's make the `update` function take an -`Event` instead. - -If you place the above `EventHandler` in a `src/tui.rs` file, then here's what our application now -looks like: - -```rust -mod tui; - -fn update(app: &mut App, event: Event) -> Result<()> { - if let Event::Key(key) = event { - match key.code { - Char('j') => app.counter += 1, - Char('k') => app.counter -= 1, - Char('q') => app.should_quit = true, - _ => {}, - } - } - Ok(()) -} - -async fn run() -> Result<()> { - - let mut events = tui::EventHandler::new(); // new - - // ratatui terminal - let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?; - - // application state - let mut app = App { counter: 0, should_quit: false }; - - loop { - let event = events.next().await?; // new - - // application update - update(&mut app, event)?; - - // application render - t.draw(|f| { - ui(f, &app); - })?; - - // application exit - if app.should_quit { - break; - } - } - - Ok(()) -} - -#[tokio::main] -async fn main() -> Result<()> { - // setup terminal - startup()?; - - let result = run().await; - - // teardown terminal before unwrapping Result of app run - shutdown()?; - - result?; - - Ok(()) -} -``` - -Using `tokio` in this manner however only makes the key events asynchronous but doesn't make the -rest of our application asynchronous yet. We will discuss that in the next section. diff --git a/src/content/docs/tutorials/counter-async-app/async-increment-decrement.md b/src/content/docs/tutorials/counter-async-app/async-increment-decrement.md deleted file mode 100644 index 2eb4c1dc2..000000000 --- a/src/content/docs/tutorials/counter-async-app/async-increment-decrement.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -title: Async Increment & Decrement ---- - -Finally we can schedule increments and decrements using `tokio::spawn`. - -Here's the code for your reference: - -```rust -use std::time::Duration; - -use anyhow::Result; -use ratatui::{prelude::*, widgets::*}; -use tokio::sync::mpsc; - -pub fn initialize_panic_handler() { - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - shutdown().unwrap(); - original_hook(panic_info); - })); -} - -fn startup() -> Result<()> { - crossterm::terminal::enable_raw_mode()?; - crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?; - Ok(()) -} - -fn shutdown() -> Result<()> { - crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen)?; - crossterm::terminal::disable_raw_mode()?; - Ok(()) -} - -struct App { - action_tx: mpsc::UnboundedSender, - counter: i64, - should_quit: bool, - ticker: i64, -} - -fn ui(f: &mut Frame, app: &mut App) { - let area = f.size(); - f.render_widget( - Paragraph::new(format!( - "Press j or k to increment or decrement.\n\nCounter: {}\n\nTicker: {}", - app.counter, app.ticker - )) - .block( - Block::default() - .title("ratatui async counter app") - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded), - ) - .style(Style::default().fg(Color::Cyan)) - .alignment(Alignment::Center), - area, - ); -} - -#[derive(PartialEq)] -enum Action { - ScheduleIncrement, - ScheduleDecrement, - Increment, - Decrement, - Quit, - None, -} - -fn update(app: &mut App, msg: Action) -> Action { - match msg { - Action::Increment => { - app.counter += 1; - }, - Action::Decrement => { - app.counter -= 1; - }, - Action::ScheduleIncrement => { - let tx = app.action_tx.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(5)).await; - tx.send(Action::Increment).unwrap(); - }); - }, - Action::ScheduleDecrement => { - let tx = app.action_tx.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(5)).await; - tx.send(Action::Decrement).unwrap(); - }); - }, - Action::Quit => app.should_quit = true, // You can handle cleanup and exit here - _ => {}, - }; - Action::None -} - -fn handle_event(app: &App, tx: mpsc::UnboundedSender) -> tokio::task::JoinHandle<()> { - let tick_rate = std::time::Duration::from_millis(250); - tokio::spawn(async move { - loop { - let action = if crossterm::event::poll(tick_rate).unwrap() { - if let crossterm::event::Event::Key(key) = crossterm::event::read().unwrap() { - if key.kind == event::KeyEventKind::Press { - match key.code { - crossterm::event::KeyCode::Char('j') => Action::ScheduleIncrement, - crossterm::event::KeyCode::Char('k') => Action::ScheduleDecrement, - crossterm::event::KeyCode::Char('q') => Action::Quit, - _ => Action::None, - } else { - Action::None - } - } else { - Action::None - } - } else { - Action::None - }; - if let Err(_) = tx.send(action) { - break; - } - } - }) -} - -async fn run() -> Result<()> { - let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?; - - let (action_tx, mut action_rx) = mpsc::unbounded_channel(); - - let mut app = App { counter: 0, should_quit: false, action_tx, ticker: 0 }; - - let task = handle_event(&app, app.action_tx.clone()); - - loop { - t.draw(|f| { - ui(f, &mut app); - })?; - - if let Some(action) = action_rx.recv().await { - update(&mut app, action); - } - - if app.should_quit { - break; - } - app.ticker += 1; - } - - task.abort(); - - Ok(()) -} - -#[tokio::main] -async fn main() -> Result<()> { - initialize_panic_handler(); - startup()?; - run().await?; - shutdown()?; - Ok(()) -} - -``` diff --git a/src/content/docs/tutorials/counter-async-app/conclusion.md b/src/content/docs/tutorials/counter-async-app/conclusion.md deleted file mode 100644 index e86228f3f..000000000 --- a/src/content/docs/tutorials/counter-async-app/conclusion.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Conclusion ---- - -We touched on the basic framework for building an `async` application with Ratatui, namely using -`tokio` and `crossterm`'s async features to create an `Event` and `Action` enum that contain -`Render` variants. We also saw how we could use `tokio` channels to send `Action`s to run domain -specific async operations concurrently. - -There's more information in the documentation for a template that covers setting up a -[`Component` based architecture](/concepts/application-patterns/component-architecture/). diff --git a/src/content/docs/tutorials/counter-async-app/full-async-actions.md b/src/content/docs/tutorials/counter-async-app/full-async-actions.md deleted file mode 100644 index cc76f1929..000000000 --- a/src/content/docs/tutorials/counter-async-app/full-async-actions.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: Full Async Actions ---- - -Now that we have introduced `Event`s and `Action`s, we are going introduce a new `mpsc::channel` for -`Action`s. The advantage of this is that we can programmatically trigger updates to the state of the -app by sending `Action`s on the channel. - -Here's the `run` function refactored from before to introduce an `Action` channel. In addition to -refactoring, we store the `action_tx` half of the channel in the `App`. - -```rust -{{#include @code/ratatui-counter-async-app/src/main.rs:run}} -``` - -Running the code with this change should give the exact same behavior as before. - -Now that we have stored the `action_tx` half of the channel in the `App`, we can use this to -schedule tasks. For example, let's say we wanted to press `J` and `K` to perform some network -request and _then_ increment the counter. - -First, we have to update my `Action` enum: - -```rust -{{#include @code/ratatui-counter-async-app/src/main.rs:action_enum}} -``` - -Next, we can update my event handler: - -```rust -{{#include @code/ratatui-counter-async-app/src/main.rs:get_action}} -``` - -Finally, we can handle the action in my `update` function by spawning a tokio task: - -```rust -{{#include @code/ratatui-counter-async-app/src/main.rs:update}} -``` - -Here is the full code for reference: - -```rust -{{#include @code/ratatui-counter-async-app/src/main.rs:all}} -``` - -With that, we have a fully async application that is tokio ready to spawn tasks to do work -concurrently. diff --git a/src/content/docs/tutorials/counter-async-app/full-async-events.md b/src/content/docs/tutorials/counter-async-app/full-async-events.md deleted file mode 100644 index c6f3cc4c5..000000000 --- a/src/content/docs/tutorials/counter-async-app/full-async-events.md +++ /dev/null @@ -1,193 +0,0 @@ ---- -title: Full Async Events ---- - -There are a number of ways to make our application work more in an `async` manner. The easiest way -to do this is to add more `Event` variants to our existing `EventHandler`. Specifically, we would -like to only render in the main run loop when we receive a `Event::Render` variant: - -```rust -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum Event { - Quit, - Error, - Tick, - Render, // new - Key(KeyEvent), -} -``` - -Another thing I personally like to do is combine the `EventHandler` struct and the `Terminal` -functionality. To do this, we are going to rename our `EventHandler` struct to a `Tui` struct. We -are also going to include a few more `Event` variants for making our application more capable. - -Below is the relevant snippet of an updated `Tui` struct. You can click on the "Show hidden lines" -button at the top right of the code block or check out -[this section of the book](/how-to/develop-apps/terminal-and-event-handler/) for the full version -this struct. - -The key things to note are that we create a `tick_interval`, `render_interval` and `reader` stream -that can be polled using `tokio::select!`. This means that even while waiting for a key press, we -will still send a `Event::Tick` and `Event::Render` at regular intervals. - -```rust -#[derive(Clone, Debug)] -pub enum Event { - Init, - Quit, - Error, - Closed, - Tick, - Render, - FocusGained, - FocusLost, - Paste(String), - Key(KeyEvent), - Mouse(MouseEvent), - Resize(u16, u16), -} - -pub struct Tui { - pub terminal: ratatui::Terminal>, - pub task: JoinHandle<()>, - pub event_rx: UnboundedReceiver, - pub event_tx: UnboundedSender, - pub frame_rate: f64, - pub tick_rate: f64, -} - -impl Tui { - 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); - 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); - loop { - let tick_delay = tick_interval.tick(); - let render_delay = render_interval.tick(); - let crossterm_event = reader.next().fuse(); - tokio::select! { - 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(); - } - }, - } - } - 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(); - }, - } - } - }); - } -``` - -We made a number of changes to the `Tui` struct. - -1. We added a `Deref` and `DerefMut` so we can call `tui.draw(|f| ...)` to have it call - `tui.terminal.draw(|f| ...)`. -2. We moved the `startup()` and `shutdown()` functionality into the `Tui` struct. -3. We also added a `CancellationToken` so that we can start and stop the tokio task more easily. -4. We added `Event` variants for `Resize`, `Focus`, and `Paste`. -5. We added methods to set the `tick_rate`, `frame_rate`, and whether we want to enable `mouse` or - `paste` events. - -Here's the code for the fully async application: - -```rust -mod tui; - -use color_eyre::eyre::Result; -use crossterm::event::KeyCode::Char; -use ratatui::{prelude::CrosstermBackend, widgets::Paragraph}; -use tui::Event; - -// App state -struct App { - counter: i64, - should_quit: bool, -} - -// App ui render function -fn ui(f: &mut Frame, app: &App) { - f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size()); -} - -fn update(app: &mut App, event: Event) { - match event { - Event::Key(key) => { - match key.code { - Char('j') => app.counter += 1, - Char('k') => app.counter -= 1, - Char('q') => app.should_quit = true, - _ => Action::None, - } - }, - _ => {}, - }; -} - -async fn run() -> Result<()> { - // ratatui terminal - let mut tui = tui::Tui::new()?.tick_rate(1.0).frame_rate(30.0); - tui.enter()?; - - // application state - let mut app = App { counter: 0, should_quit: false }; - - loop { - let event = tui.next().await?; // blocks until next event - - if let Event::Render = event.clone() { - // application render - tui.draw(|f| { - ui(f, &app); - })?; - } - - // application update - update(&mut app, event); - - // application exit - if app.should_quit { - break; - } - } - tui.exit()?; - - Ok(()) -} - -#[tokio::main] -async fn main() -> Result<()> { - let result = run().await; - - result?; - - Ok(()) -} -``` - -The above code ensures that we render at a consistent frame rate. As an exercise, play around with -this frame rate and tick rate to see how the CPU utilization changes as you change those numbers. - -Even though our application renders in an "async" manner, we also want to perform "actions" in an -asynchronous manner. We will improve this in the next section to make our application truly async -capable. diff --git a/src/content/docs/tutorials/counter-async-app/index.md b/src/content/docs/tutorials/counter-async-app/index.md deleted file mode 100644 index 71e71391a..000000000 --- a/src/content/docs/tutorials/counter-async-app/index.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: Async Counter App ---- - -In the previous counter app, we had a purely sequential blocking application. There are times when -you may be interested in running IO operations or compute asynchronously. - -For this tutorial, we will build a single file version of an async TUI using -[tokio](https://tokio.rs/). This tutorial section is a simplified version of the -[async ratatui counter app](https://github.com/ratatui-org/templates/tree/main/component/ratatui-counter). - -## Installation - -Here's an example of the `Cargo.toml` file required for this tutorial: - -```toml -[package] -name = "ratatui-counter-async-app" -version = "0.1.0" -edition = "2021" - -[dependencies] -color-eyre = "0.6.2" -crossterm = { version = "0.27.0", features = ["event-stream"] } -ratatui = "0.24.0" -tokio = { version = "1.32.0", features = ["full"] } -tokio-util = "0.7.9" -futures = "0.3.28" -``` - -:::note - -If you were already using `crossterm` before, note that now you'll need to add -`features = ["event-stream"]` to use crossterm's async features. - -You can use `cargo add` from the command line to add the above dependencies in one go: - -```bash -cargo add ratatui crossterm color-eyre tokio tokio-util futures --features tokio/full,crossterm/event-stream -``` - -::: - -## Setup - -Let's take the single file multiple function example from the counter app from earlier: - -```rust -fn main() -> Result<()> { - // setup terminal - startup()?; - - let result = run(); - - // teardown terminal before unwrapping Result of app run - shutdown()?; - - result?; - - Ok(()) -} -``` - -Tokio is an asynchronous runtime for the Rust programming language. It provides the building blocks -needed for writing network applications. We recommend you read the -[Tokio documentation](https://tokio.rs/tokio/tutorial) to learn more. - -For the setup for this section of the tutorial, we are going to make just one change. We are going -to make our `main` function a `tokio` entry point. - -```rust -#[tokio::main] -async fn main() -> Result<()> { - // setup terminal - startup()?; - - let result = run(); - - // teardown terminal before unwrapping Result of app run - shutdown()?; - - result?; - - Ok(()) -} -``` - -Adding this `#[tokio::main]` macro allows us to spawn tokio tasks within `main`. At the moment, -there are no `async` functions other than `main` and we are not using `.await` anywhere yet. We will -change that in the following sections. But first, we let us introduce the `Action` enum. diff --git a/src/content/docs/tutorials/counter-async-app/sync-increment-decrement.md b/src/content/docs/tutorials/counter-async-app/sync-increment-decrement.md deleted file mode 100644 index 638972c08..000000000 --- a/src/content/docs/tutorials/counter-async-app/sync-increment-decrement.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -title: Sync Increment & Decrement ---- - -In order to set up an `async` application, it is important to make the generation of `Action`s -"asynchronous". - -We can do this by spawning a tokio task like so: - -```rust -fn start_event_handler(app: &App, tx: mpsc::UnboundedSender) -> tokio::task::JoinHandle<()> { - let tick_rate = std::time::Duration::from_millis(250); - tokio::spawn(async move { - loop { - let action = if crossterm::event::poll(tick_rate).unwrap() { - if let crossterm::event::Event::Key(key) = crossterm::event::read().unwrap() { - if key.kind == event::KeyEventKind::Press { - match key.code { - crossterm::event::KeyCode::Char('j') => Action::Increment, - crossterm::event::KeyCode::Char('k') => Action::Decrement, - crossterm::event::KeyCode::Char('q') => Action::Quit, - _ => Action::None, - } - } else { - Action::None - } - } else { - Action::None - } - } else { - Action::None - }; - if let Err(_) = tx.send(action) { - break; - } - } - }) -} -``` - -Here's the architecture of the application when using a separate `tokio` task to manage the -generation of `Action` events. - -```mermaid -graph TD - MainRun[Main: Run]; - CheckAction[Main: Check action_rx]; - UpdateTicker[Main: Update Ticker]; - UpdateApp[Main: Update App with Action]; - ShouldQuit[Main: Check should_quit?]; - BreakLoop[Main: Break Loop]; - MainStart[Main: Start]; - MainEnd[Main: End]; - MainStart --> MainRun; - MainRun --> CheckAction; - CheckAction -->|No Action| UpdateTicker; - UpdateTicker --> ShouldQuit; - CheckAction -->|Action Received| UpdateApp; - UpdateApp --> ShouldQuit; - ShouldQuit -->|Yes| BreakLoop; - BreakLoop --> MainEnd; - ShouldQuit -->|No| CheckAction; - EventStart[Event: start_event_handler]; - PollEvent[Event: Poll]; - ProcessKeyPress[Event: Process Key Press]; - SendAction[Event: Send Action]; - ContinueLoop[Event: Continue Loop]; - EventStart --> PollEvent; - PollEvent -->|Event Detected| ProcessKeyPress; - ProcessKeyPress --> SendAction; - SendAction --> ContinueLoop; - ContinueLoop --> PollEvent; - PollEvent -->|No Event| ContinueLoop; - SendAction -.-> CheckAction; -``` - -Here's the full code for your reference: - -```rust -use std::time::Duration; - -use anyhow::Result; -use ratatui::{prelude::*, widgets::*}; -use tokio::sync::mpsc; - -pub fn initialize_panic_handler() { - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - shutdown().unwrap(); - original_hook(panic_info); - })); -} - -fn startup() -> Result<()> { - crossterm::terminal::enable_raw_mode()?; - crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?; - Ok(()) -} - -fn shutdown() -> Result<()> { - crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen)?; - crossterm::terminal::disable_raw_mode()?; - Ok(()) -} - -struct App { - action_tx: mpsc::UnboundedSender, - counter: i64, - should_quit: bool, - ticker: i64, -} - -fn ui(f: &mut Frame, app: &mut App) { - let area = f.size(); - f.render_widget( - Paragraph::new(format!( - "Press j or k to increment or decrement.\n\nCounter: {}\n\nTicker: {}", - app.counter, app.ticker - )) - .block( - Block::default() - .title("ratatui async counter app") - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded), - ) - .style(Style::default().fg(Color::Cyan)) - .alignment(Alignment::Center), - area, - ); -} - -#[derive(PartialEq)] -enum Action { - Increment, - Decrement, - Quit, - None, -} - -fn update(app: &mut App, msg: Action) -> Action { - match msg { - Action::Increment => { - app.counter += 1; - }, - Action::Decrement => { - app.counter -= 1; - }, - Action::Quit => app.should_quit = true, // You can handle cleanup and exit here - _ => {}, - }; - Action::None -} - -fn start_event_handler(app: &App, tx: mpsc::UnboundedSender) -> tokio::task::JoinHandle<()> { - let tick_rate = std::time::Duration::from_millis(250); - tokio::spawn(async move { - loop { - let action = if crossterm::event::poll(tick_rate).unwrap() { - if let crossterm::event::Event::Key(key) = crossterm::event::read().unwrap() { - if key.kind == event::KeyEventKind::Press { - match key.code { - crossterm::event::KeyCode::Char('j') => Action::Increment, - crossterm::event::KeyCode::Char('k') => Action::Decrement, - crossterm::event::KeyCode::Char('q') => Action::Quit, - _ => Action::None, - } - } else { - Action::None - } - } else { - Action::None - } - } else { - Action::None - }; - if let Err(_) = tx.send(action) { - break; - } - } - }) -} - -async fn run() -> Result<()> { - let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?; - - let (action_tx, mut action_rx) = mpsc::unbounded_channel(); - - let mut app = App { counter: 0, should_quit: false, action_tx, ticker: 0 }; - - let task = start_event_handler(&app, app.action_tx.clone()); - - loop { - t.draw(|f| { - ui(f, &mut app); - })?; - - if let Some(action) = action_rx.recv().await { - update(&mut app, action); - } - - if app.should_quit { - break; - } - app.ticker += 1; - } - - task.abort(); - - Ok(()) -} - -#[tokio::main] -async fn main() -> Result<()> { - initialize_panic_handler(); - startup()?; - run().await?; - shutdown()?; - Ok(()) -} -``` diff --git a/src/content/docs/tutorials/crates-tui/app-async.md b/src/content/docs/tutorials/crates-tui/app-async.md new file mode 100644 index 000000000..fcf39fb98 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-async.md @@ -0,0 +1,149 @@ +--- +title: App Async +--- + +We are finally ready to incorporate the helper module into the `App` struct. + +Define the the following fields in the `App` struct: + +```rust title="src/app.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app}} +``` + +We already saw that we needed a `Arc>>` for getting results. Let's +use [`tui-input`] for handling the search prompt and a `Option` to handle displaying the +cursor in the prompt. + +[`tui-input`]: https://github.com/sayanarijit/tui-input + +Let's also add a `TableState` for allowing scrolling in the results. + +For the application, we want to be able to: + +**In prompt mode**: + +1. Type any character into the search prompt +2. Hit Enter to submit a search query +3. Hit Esc to return focus to the results view + +**In results mode**: + +1. Use arrow keys to scroll +2. Use `/` to enter search mode +3. Use Esc to quit the application + +Expand the `handle_events` to the match on mode and change the app state accordingly: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_handle_event}} +} +``` + +`tui-input` handles events for moving the cursor in the prompt. + +## Submit search query + +`tui-input` has a [`Input::value`] method that you can use to get a reference to the current search +query that the user has typed in, i.e. `self.prompt.value() -> &str`. + +[`Input::value`]: https://docs.rs/tui-input/latest/tui_input/struct.Input.html#method.value + +Implement the following method: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_submit_search_query}} +} +``` + +:::tip + +You can call `tokio::spawn` in a normal function and it will spin up a task in the background for +execution. + +::: + +## Scroll up and Scroll down + +When the `scroll_up` or `scroll_down` methods are called, you have to update the `TableState` of the +results to select the new index. + +Implement the following for wrapped scrolling: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_scroll}} +} +``` + +## Cursor state + +Ratatui hides the cursor by default every frame. To show it, we have to call `set_cursor` +explicitly. We only want to show the cursor when the prompt is in focus. + +Implement the following to show the cursor conditionally: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_draw}} + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_update_cursor}} + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:update_prompt_cursor_state}} +} +``` + +## Draw + +Finally, you can update the render using the new information to replace placeholder data with the +data from the results or the prompt value. + +### Results + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_results_table_widget}} +} +``` + +Note the use `highlight_symbol` here to show the cursor when scrolling. + +### Prompt + +Update the prompt widget to show the text from `tui-input::Input` in a `Paragraph` widget: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_prompt_widget}} +} +``` + +### Render + +And in the render function for the `StatefulWidget`, make sure you create a stateful widget for the +table results instead. You have to also call the function that updates the cursor position based on +the prompt `Rect`, which is only known during render. + +```rust title="src/app.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_statefulwidget}} +``` + +## Conclusion + +Here's the full app for your reference: + +```rust collapsed title="src/app.rs (click to expand)" +use color_eyre::Result; +use itertools::Itertools; +use ratatui::layout::Position; +use ratatui::prelude::*; +use ratatui::widgets::*; + +use crate::{ + events::{Event, Events}, + tui::Tui +}; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:full_app}} +``` diff --git a/src/content/docs/tutorials/crates-tui/app-basics.md b/src/content/docs/tutorials/crates-tui/app-basics.md new file mode 100644 index 000000000..1e70b9501 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-basics.md @@ -0,0 +1,161 @@ +--- +title: App Basic Structure +--- + +Before we proceed any further, we are going to refactor the code we already have to make it easier +to scale up in the future. We are going to move the event loop into a method on the `App` struct. + +## App + +Create a new file `./src/app.rs`: + +```rust title="src/app.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app}} +``` + +Define some helper functions for initializing the `App`: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_new}} +} + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_default}} +``` + +## App methods + +### App::run + +Now define a `run` method for `App`: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_run}} +} +``` + +:::note + +This run method is `async` and uses `events.next().await`, which returns a `Event` from the stream +you created earlier. + +::: + +### App::quit and App::should_quit + +The `run` method uses a `should_quit` method (and a corresponding `quit` method) that you can define +like this: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_quit}} +} +``` + +### App::handle_event + +This `run` method also uses a `handle_event` method that you can define like so: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_handle_event}} +} +``` + +### App::draw + +Finally, for the `draw` method, you could define it like this: + +```rust title="src/app.rs" +use ratatui::widgets::*; + +impl App { + fn draw(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + frame.render_widget( + Paragraph::new(format!( + "frame counter: {}", + frame.count() + )), + frame.size(), + ); + })?; + Ok(()) + } +} +``` + +But let's go one step further and set ourselves up for using the `StatefulWidget` pattern. + +## StatefulWidget pattern + +Define the `draw` method like this: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_draw}} +} +``` + +This uses a [unit-like struct] called `AppWidget` that can be rendered as a `StatefulWidget` using +the `App` struct as its state. + +[unit-like struct]: + https://doc.rust-lang.org/book/ch05-01-defining-structs.html#unit-like-structs-without-any-fields + +```rust title="src/app.rs" +use ratatui::widgets::{StatefulWidget, Paragraph}; + +struct AppWidget; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_statefulwidget}} +``` + +Here's the full `./src/app.rs` file for your reference: + +```rust collapsed title="src/app.rs (click to expand)" +use color_eyre::eyre::Result; +use ratatui::prelude::*; +use ratatui::widgets::*; + +use crate::{ + events::{Event, Events}, + tui::Tui +}; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:full_app}} +``` + +## Conclusion + +Now, run your application with a modified `main.rs` that uses the `App` struct you just created: + +```rust title="src/main.rs" +pub mod app; +pub mod errors; +pub mod events; +pub mod tui; +pub mod widgets; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-final.rs:main}} +``` + +You should get the same results as before. + +![](./crates-tui-tutorial-part-app-basics.gif) + +Your file structure should now look like this: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + ├── app.rs + ├── crates_io_api_helper.rs + ├── errors.rs + ├── events.rs + ├── main.rs + └── tui.rs +``` diff --git a/src/content/docs/tutorials/crates-tui/app-mode.md b/src/content/docs/tutorials/crates-tui/app-mode.md new file mode 100644 index 000000000..766d517eb --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-mode.md @@ -0,0 +1,110 @@ +--- +title: App Mode +--- + +In this section, you are going to expand on the `App` struct to add a `Mode`. + +## App + +Define the following fields in the `App` struct: + +```rust title="src/app.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app}} +``` + +## Mode + +Our app is going to have two focus modes: + +1. when the `Prompt` is in focus, + + ![](./crates-tui-demo-1.png) + +2. when the `Results` are in focus. + + ![](./crates-tui-demo-2.png) + +You can represent the state of the "focus" using an enum called `Mode`: + +```rust title="src/app.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:mode}} +``` + +The reason you want to do this is because you may want to do different things when receiving the +same event in different modes. For example, `ESC` when the prompt is in focus should switch the mode +to results, but `ESC` when the results are in focus should exit the app. + +## App::handle_event + +Change the `handle_event` function to do different things when `Esc` is pressed and different +`Mode`s are active: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_handle_event}} +} +``` + +You'll need to add a new `switch_mode` method: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_switch_mode}} +} +``` + +## Draw + +Let's make our view a little more interesting with some placeholder widgets. + +### Results + +For the results, use a `Table` with some mock data + +```rust title="src/app.rs" +use itertools::Itertools; + +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_results_table_widget}} +} +``` + +### Prompt + +For the prompt, make a `Block` that changes border color based on the mode: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_prompt_widget}} +} +``` + +### Render + +And in the render function for the `StatefulWidget` we can call these widget constructors: + +```rust title="src/app.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_statefulwidget}} +``` + +## Conclusion + +If you run it, you should see something like this: + +![](./crates-tui-tutorial-part-app-mode.gif) + +Here's the full `./src/app.rs` file for your reference: + +```rust collapsed title="src/app.rs (click to expand)" +use color_eyre::eyre::Result; +use itertools::Itertools; +use ratatui::prelude::*; +use ratatui::widgets::*; + +use crate::{ + events::{Event, Events}, + tui::Tui +}; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:full_app}} +``` diff --git a/src/content/docs/tutorials/crates-tui/app-prototype.md b/src/content/docs/tutorials/crates-tui/app-prototype.md new file mode 100644 index 000000000..6e5a71935 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-prototype.md @@ -0,0 +1,230 @@ +--- +title: App Prototype +--- + +In this section, we are going to expand on the `App` struct to add channels and actions. + +## Actions + +One of the first steps to building truly `async` TUI applications is to use the `Command`, `Action`, +or `Message` pattern. + +:::tip + +The `Command` pattern is a behavioral design pattern that represents function call as a stand-alone +object that contains all information about the function call. + +You can learn more from: + +- https://refactoring.guru/design-patterns/command +- http://gameprogrammingpatterns.com/command.html +- [The Elm Architecture section](/concepts/application-patterns/the-elm-architecture/) + +::: + +The key idea here is that `Action` enum variants maps exactly to different methods on the `App` +struct, and the variants of `Action` represent all the actions that can be carried out by an `app` +instance to update its state. + +The variants of the `Action` enum you will be using for this tutorial are: + +```rust title="src/app.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:action}} +``` + +## Channels + +Define the following fields in the `App` struct: + +```rust title="src/app.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app}} +``` + +where `tx` and `rx` are two parts of the pair of the `Action` channel from `tokio::mpsc`, i.e. + +- `tx`: Transmitter +- `rx`: Receiver + +These pairs are created using the `tokio::mpsc` channel, which stands for multiple producer single +consumer channels. These pairs from the channel can be used sending and receiving `Action`s across +thread and task boundaries. + +Practically, what this means for your application is that you can pass around clones of the +transmitter to any children of the `App` struct and children can send `Action`s at any point in the +operation of the app to trigger a state change in `App`. This works because you have a single `rx` +here in the root `App` struct that receives those `Action`s and acts on them. + +This allows you as a Ratatui app developer to organize your application in any way you please, and +still propagate information up from child to parent structs using the `tx` transmitter. + +Setup a `App::new()` function to construct an `App` instance like so: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_new}} +} +``` + +Let's also update the `async run` method now: + +```rust title="src/app.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_run}} +``` + +## handle_event + +Update `handle_event` to delegate to `Mode` to figure out which `Action` should be generated based +on the key event and the `Mode`. + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_handle_event}} +} +``` + +Most of the work in deciding which `Action` should be taken is done in `Mode::handle_key`. Since +this is oriented around `Mode`, implement the `handle_key` method on `Mode` in the following manner: + +```rust title="src/app.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:mode}} +``` + +:::note + +If the `maybe_action` is a `Some` variant, it is sent over the `tx` channel: + +```rust + maybe_action.map(|action| self.tx.send(action)); +``` + +::: + +## handle_action + +Now implement the `handle_action` method like so: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_handle_action}} +} +``` + +Because the `run` method has the following block of code, any `Action` received on `rx` will trigger +an call to the `handle_action` method. + +```rust +while let Ok(action) = self.rx.try_recv() { + self.handle_action(action.clone(), &mut tui)?; +} +``` + +Since this is a `while let` loop, multiple `Action`s can be queued in your application and the +`while let` will only return control back to the `run` method when all the actions have been +processed. + +Any time the `rx` receiver receives an `Action` from _any_ `tx` transmitter, the application will +"handle the action" and the state of the application will update. This means you can, for example, +send a new variant `Action::Error(String)` from deep down in a nested child instance, which can +force the app to show an error message as a popup. You can also pass a clone of the `tx` into a +tokio task, and have the tokio task propagate information back to the `App` asynchronously. This is +particularly useful for error messages when a `.unwrap()` would normally fail in a tokio task. + +While introducing `Action`s in between `Event`s and the app methods may seem like a lot more +boilerplate at first, using an `Action` enum this way has a few advantages. + +Firstly, `Action`s can be mapped from keypresses in a declarative manner. For example, you can +define a configuration file that reads which keys are mapped to which `Action` like so: + +```toml +[keyconfig] +"q" = "Quit" +"j" = "ScrollDown" +"k" = "ScrollUp" +``` + +Then you can add a new `keyconfig` in the `App` like so: + +```rust +struct App { + ... + // new field + keyconfig: HashMap +} +``` + +If you populate `keyconfig` with the contents of a user provided `toml` file, then you can figure +out which action to take directly from the keyconfig struct: + +```rust +fn handle_event(&mut self, event: Event) -> Option { + if let Event::Key(key) = event { + return self.keyconfig.get(key.code) + }; + None +} +``` + +A second reason you may want to use `Action`s is that it allows us to send a `tx` into a long +running task and retrieve information back from the task during its execution. For example, if a +task errors, you can send an `Action::Error(String)` back to the app which can then be displayed as +a popup. + +For example, you can send an `Action::UpdateSearchResults` from inside the task once the query is +complete, when can make sure that the first time is selected after the results are loaded (by +scrolling down): + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_submit_search_query}} + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_update_search_results}} +} +``` + +Finally, using an `Action` even allows us as app developers to trigger an action from anywhere in +any child struct by sending an `Action` over `tx`. + +Here's the full `./src/app.rs` file for your reference: + +```rust collapsed title="src/app.rs (click to expand)" +use color_eyre::Result; +use itertools::Itertools; +use ratatui::layout::Position; +use ratatui::prelude::*; +use ratatui::widgets::*; + +use crate::{ + events::{Event, Events}, + tui::Tui +}; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:full_app}} +``` + +## Conclusion + +This is what our app currently looks like: + +![](./crates-tui-tutorial-part-app-prototype.gif) + +However, currently everything is in a single file, and the `App` struct is starting to get a little +unwieldy. If we want to add more features or more widgets, this approach isn't going to scale very +well. + +In the rest of the tutorial, we are going to refactor the app into `StatefulWidget`s and add more +polish. + +Your folder structure should currently look like this: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + ├── app.rs + ├── crates_io_api_helper.rs + ├── errors.rs + ├── events.rs + ├── main.rs + └── tui.rs +``` diff --git a/src/content/docs/tutorials/crates-tui/app.md b/src/content/docs/tutorials/crates-tui/app.md new file mode 100644 index 000000000..4a227a675 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app.md @@ -0,0 +1,72 @@ +--- +title: App +--- + +## SearchPage + +Finally, let's make a field in the app struct that uses the `SearchPage` widget: + +```rust title="src/app.rs" +{{#include @code/crates-tui-tutorial-app/src/app.rs:imports_core}} + +{{#include @code/crates-tui-tutorial-app/src/app.rs:app}} +``` + +With this refactor, now `./src/app.rs` becomes a lot simpler. For example, app now delegates to the +search page widget for all core functionality. + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/app.rs:app_handle_action}} +} +``` + +## SearchPageWidget + +And rendering delegates to `SearchPageWidget`: + +```rust title="src/app.rs" +impl App { +{{#include @code/crates-tui-tutorial-app/src/app.rs:app_statefulwidget}} +} +``` + +## Conclusion + +Here's the full code for your reference: + +```rust collapsed title="src/app.rs (click to expand)" +{{#include @code/crates-tui-tutorial-app/src/app.rs}} +``` + +Your final folder structure will look like this: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + ├── app.rs + ├── crates_io_api_helper.rs + ├── errors.rs + ├── events.rs + ├── main.rs + ├── tui.rs + ├── widgets + │ ├── search_page.rs + │ ├── search_prompt.rs + │ └── search_results.rs + └── widgets.rs +``` + +If you put all of it together, you should be able run the TUI again: + +```bash +cargo run +``` + +:::note[Homework] + +Search for your favorite crates and explore crates.io using what you built! + +::: diff --git a/src/content/docs/tutorials/crates-tui/conclusion.md b/src/content/docs/tutorials/crates-tui/conclusion.md new file mode 100644 index 000000000..e6ea1c138 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/conclusion.md @@ -0,0 +1,14 @@ +--- +title: Conclusion +--- + +Congratulations! :tada: + +You built your first `async` TUI application. + +![](./crates-tui-demo.gif) + +If you are interested in learning more, check out the source code for [`crates-tui`] for a more +complex and featureful version of this tutorial. + +[`crates-tui`]: https://github.com/ratatui-org/crates-tui diff --git a/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md b/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md new file mode 100644 index 000000000..e8eb99571 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md @@ -0,0 +1,274 @@ +--- +title: Crates IO API Helper +--- + +In this section, we will make a helper module to simplify handling of the request and response for +the purposes of the tutorial. We are going to use the `crates_io_api` crate's [`AsyncClient`] to +retrieve results from a search query to crates.io. + +[`AsyncClient`]: + https://docs.rs/crates_io_api/latest/crates_io_api/struct.AsyncClient.html#method.new + +## Async test + +Before you proceed, create a file `src/crates_io_api_helper.rs` with a `async` test block so you can +experiment with the API. + +```rust title="src/crates_io_api_helper.rs" +use color_eyre::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_crates_io() -> Result<()> { + println!("TODO: test crates_io_api here") + // ... + } +} +``` + +You'll also need to add the module to `main.rs`: + +```diff lang="rust" title="src/main.rs" ++ mod crates_io_api_helper; + + #[tokio::main] + async fn main() -> color_eyre::Result<()> { +``` + +You can test this `async` function by running the following in the command line: + +```bash +$ cargo test -- crates_io_api_helper::tests::test_crates_io --nocapture +``` + +## Client + +To initialize the `crates_io_api::AsyncClient`, you have to provide an email to use as the user +agent. + +```rust title="src/crates_io_api_helper.rs ::tests" +#[tokio::test] +async fn test_crates_io() -> Result<()> { + let email = "your-email-address@foo.com"; + + let user_agent = format!("crates-tui ({email})"); + let rate_limit = std::time::Duration::from_millis(1000); + + let client = crates_io_api::AsyncClient::new(user_agent, rate_limit)?; + + // ... +} +``` + +:::tip + +In the source code of this tutorial, we read this email from the environment variable +`CRATES_TUI_TUTORIAL_APP_MYEMAIL`. You can set up a environment variable for the current session by +exporting a variable like so: + +```bash +export CRATES_TUI_TUTORIAL_APP_MYEMAIL=your-email-address@foo.com +``` + +And then you can read the email at compile time: + +```rust +let email = env!("CRATES_TUI_TUTORIAL_APP_MYEMAIL"); +``` + +Or at run time: + +```rust +let email = std::env::var("CRATES_TUI_TUTORIAL_APP_MYEMAIL") + .unwrap_or_else(|_| "backup-email@foo.com".into()) +``` + +::: + +## Crates Query + +Once you have created a client, you can make a query using the [`AsyncClient::crates`] function. +This `crates` method takes a [`CratesQuery`] object that you will need to construct. + +[`AsyncClient::crates`]: + https://docs.rs/crates_io_api/latest/crates_io_api/struct.AsyncClient.html#method.crates +[`CratesQuery`]: https://docs.rs/crates_io_api/latest/crates_io_api/struct.CratesQuery.html + +We can build this `CratesQuery` object using the following parameters: + +- Search query: `String` +- Page number: `u64` +- Page size: `u64` +- Sort order: `crates_io_api::Sort` + +### Search Parameters + +To make the code easier to manage, let's store everything we need to construct a `CratesQuery` in a +`SearchParameters` struct: + +```rust title="src/crates_io_api_helper.rs" +use std::sync::{Arc, Mutex}; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:search_parameters}} +``` + +You'll notice that we also added a `crates` field to the `SearchParameters`. + +This `crates` field will hold a clone of `Arc>>` that will be passed +into the `async` task. Inside the `async` task, it will be populated with the results from the +response of the query once the query is completed. + +### Constructor + +Create a `new` constructor to make it easier to create a `SearchParameter` instance: + +```rust title="src/crates_io_api_helper.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:search_parameters_new}} +``` + +Now, in the test function, you can initialize the search parameters with a search term `"ratatui"` +like so: + +```rust title="src/crates_io_api_helper.rs (tests::test_crates_io)" + // ... + let crates: Arc>> = Default::default(); + let search_params = SearchParameters::new("ratatui".into(), crates.clone()); + // ... +``` + +### Crates Query Builder + +Construct the query using `crates_io_api`'s [`CratesQueryBuilder`]: + +[`CratesQueryBuilder`]: + https://docs.rs/crates_io_api/latest/crates_io_api/struct.CratesQueryBuilder.html + +```rust title="src/crates_io_api_helper.rs (tests::test_crates_io)" + // ... +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:create_query}} + // ... +``` + +## Request Crates + +Once you have created the `client` and `query`, you can call the `.crates()` method on the client +and `await` the response. + +```rust title="src/crates_io_api_helper.rs (tests::test_crates_io)" +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:crates_query}} +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:crates_response}} +``` + +Once the request is completed, you get a response in `page_result` that has a field called `.crates` +which is a `Vec`. + +## Results + +Clear the existing results in the `search_params.crates` field and update the +`Arc>>` with the response: + +```rust title="src/crates_io_api_helper.rs (tests::test_crates_io)" +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:update_state}} +``` + +## Print + +Finally, add a `println!` for every element in the response to test that it worked: + +```rust title="src/crates_io_api_helper.rs (tests::test_crates_io)" + for krate in crates.lock().unwrap().iter() { + println!( + "name: {}\ndescription: {}\ndownloads: {}\n", + krate.name, + krate.description.clone().unwrap_or_default(), + krate.downloads + ); + } +``` + +Run the test again now: + +```bash +$ cargo test -- crates_io_api_helper::tests::test_crates_io --nocapture +``` + +You should get results like below (only the first three results are shown here for brevity): + +```plain +running 1 test + +name: ratatui +description: A library that's all about cooking up terminal user interfaces +downloads: 1026661 + +name: ratatui-textarea +description: [deprecated] ratatui is a simple yet powerful text editor widget for ratatui. Multi-line +text editor can be easily put as part of your ratatui application. Forked from tui-textarea. +downloads: 1794 + +name: ratatui-macros +description: Macros for Ratatui +downloads: 525 + +... +... +... + +test crates_io_api_helper::tests::test_crates_io ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s +``` + +## Refactor + +You may want to refactor the above code into separate functions for simplicity. If you do so, it'll +look like this: + +```rust title="src/crates_io_api_helper.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:request_search_results}} +``` + +You can now use this helper module to make `async` requests from the `app`. + +Here's the full code in `src/crates_io_api_helper.rs` for your reference: + +```rust collapsed title="src/crates_io_api_helper.rs (click to expand)" +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:helper}} +``` + +With the refactor, your test code should look like this: + +```rust title="src/crates_io_api_helper.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:test}} +``` + +## Conclusion + +With this `crates_io_api_helper` module set up, you can spawn a task using `tokio` to fill the +results of the query into the `Arc>>` like so: + +```rust +let crates: Arc>> = Default::default(); +let search_params = SearchParameters::new("ratatui".into(), crates.clone()); + +tokio::spawn(async move { + let _ = crates_io_api_helper::request_search_results(&search_params).await; +}); +``` + +We will use this helper module once we set up our TUI application. To do that, let's look at the +contents of the `tui` module next. + +Your file structure should now look like this: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + ├── crates_io_api_helper.rs + └── main.rs +``` diff --git a/src/content/docs/tutorials/crates-tui/crates-tui-demo-1.png b/src/content/docs/tutorials/crates-tui/crates-tui-demo-1.png new file mode 100644 index 000000000..043dd9cf8 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-tui-demo-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30c0a4665ab8b8b6d4d3c3968e1d3a2702d5e10f0cde5ac87e8008089843b75b +size 259785 diff --git a/src/content/docs/tutorials/crates-tui/crates-tui-demo-2.png b/src/content/docs/tutorials/crates-tui/crates-tui-demo-2.png new file mode 100644 index 000000000..8beb846b5 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-tui-demo-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:091e3ceb4c222c9a87bdd7a11aef1317668e2fd54bfc05d9e35ca525d07b1f9a +size 257693 diff --git a/src/content/docs/tutorials/crates-tui/crates-tui-demo.gif b/src/content/docs/tutorials/crates-tui/crates-tui-demo.gif new file mode 100644 index 000000000..142916337 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-tui-demo.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0a7f6010e30afa649a0b598c61a3ccd2a01a90b81d3e0a7bdb07bfda0d9bf37 +size 243417 diff --git a/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-async.gif b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-async.gif new file mode 100644 index 000000000..42f64fd65 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-async.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd243cda888e1ab2cc870e95d364b4b7826e115234f730313e34644a7d440e6b +size 611110 diff --git a/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-basics.gif b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-basics.gif new file mode 100644 index 000000000..d012e493d --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-basics.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:018ab3facc8d7654ffef86698e9de1dc2c87dc33e3572f42fd563ba392737e4e +size 204010 diff --git a/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-mode.gif b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-mode.gif new file mode 100644 index 000000000..06aec2740 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-mode.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c96d1969ccb8895fb165a8210953dccd0f2df766ce89998632fcb92eea796634 +size 216918 diff --git a/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-prototype.gif b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-prototype.gif new file mode 100644 index 000000000..56674ce42 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-prototype.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e1f44a56232fcb8a92a0f23632d5f7ae5999d9e8add936dd52200ebe417baf2 +size 394858 diff --git a/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-events.gif b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-events.gif new file mode 100644 index 000000000..2fa0b9317 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-events.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:147e89d5a118f4db6d396143d17cfde23f71074ab15cc63ca54a84250f0b77af +size 111569 diff --git a/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-tui.gif b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-tui.gif new file mode 100644 index 000000000..8ec050bdf --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-tui.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef29d6a8aec821ec3a7b7c0d7d9b2792c46678b3cd06762296737d058a825bfb +size 88418 diff --git a/src/content/docs/tutorials/crates-tui/errors.md b/src/content/docs/tutorials/crates-tui/errors.md new file mode 100644 index 000000000..5d9b4f0e8 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/errors.md @@ -0,0 +1,99 @@ +--- +title: Errors +--- + +You now have a `tui` module that restores the state of the terminal at the end of `main`. + +But in addition to that, you want to make sure that even when the application panics, you restore +the state of the terminal back to a normal working state. You will also want to print the error to +the terminal to that the user can see what went wrong. + +Rust has a built-in function called [`set_hook`] to set a panic hook. Additionally, `color_eyre` has +some ready to install hooks to you can leverage for better panics. + +[`set-hook`]: https://doc.rust-lang.org/std/panic/fn.set_hook.html + +Putting that together along with restoring the terminal backend state might look something like +this: + +```rust + let (panic_hook, _) = color_eyre::config::HookBuilder::default().into_hooks(); + let panic_hook = panic_hook.into_panic_hook(); + + std::panic::set_hook(Box::new(move |panic_info| { + if let Err(err) = crate::tui::restore() { + log::error!("Unable to restore terminal: {err:?}"); + } + panic_hook(panic_info); + })); +``` + +You can customize the output of the panic hook in a number of different ways. For example, with +[`human-panic`], you can autogenerate a log file that contains the stacktrace that a user can submit +to you for further investigation. + +[`human-panic`]: https://github.com/rust-cli/human-panic + +Here's the code using color_eyre to set a panic hook. Put the contents of this file into +`src/errors.rs`: + +```rust title="src/errors.rs" +{{#include @code/crates-tui-tutorial-app/src/errors.rs}} +``` + + + +Let's update `main.rs` to the following: + +```diff lang="rust" title="src/main.rs" + mod crates_io_api_helper; ++ mod errors; + mod tui; + + #[tokio::main] + async fn main() -> color_eyre::Result<()> { ++ errors::install_hooks()?; + + let mut tui = tui::init()?; + + tui.draw(|frame| { + frame.render_widget( + ratatui::widgets::Paragraph::new("hello world"), + frame.size(), + ); ++ // panic!("Oops. Something went wrong!"); + })?; + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + tui::restore()?; + + Ok(()) + } +``` + +:::note[Homework] + +Experiment with uncommenting the `panic!` in the code and see what happens. Try to run the code with +`panic!` and with and without the `errors::install_hooks()` call. + +::: + +:::tip + +If your terminal is in a messed up state, you can type `reset` and hit enter in the terminal to +reset your terminal state at any time. + +::: + +Your file structure should now look like this: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + ├── crates_io_api_helper.rs + ├── errors.rs + ├── main.rs + └── tui.rs +``` diff --git a/src/content/docs/tutorials/crates-tui/events.md b/src/content/docs/tutorials/crates-tui/events.md new file mode 100644 index 000000000..5729c1f58 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/events.md @@ -0,0 +1,98 @@ +--- +title: Events +--- + +We've already discussed `Events` and an `EventHandler` extensively in the +[counter app](../counter-app/multiple-files/event). And you can use the exact same approach in your +`async` application. If you do so, you can ignore this section. + +However, when using `tokio`, you have a few more options available to choose from. In this tutorial, +you'll see how to take advantage of [`tokio_stream`] to create custom streams and fuse them together +to get async events. + +[`tokio_stream`]: https://docs.rs/tokio-stream/latest/tokio_stream/ + +## Event enum + +First, create a `Event` enum, like before: + +```rust title="src/events.rs" +{{#include @code/crates-tui-tutorial-app/src/events.rs:event}} +``` + +This will represent all possible events you can receive from the `Events` stream. + +## Crossterm stream + +Next create a `crossterm_stream` function: + +```rust title="src/events.rs" +{{#include @code/crates-tui-tutorial-app/src/events.rs:stream}} + +{{#include @code/crates-tui-tutorial-app/src/events.rs:crossterm}} +``` + +## Render stream + +You can create stream using an `IntervalStream` for generating `Event::Render` events. + +```rust title="src/events.rs" +{{#include @code/crates-tui-tutorial-app/src/events.rs:render}} +``` + +## Event stream + +Putting it all together, make a `Events` struct like so: + +```rust title="src/events.rs" +{{#include @code/crates-tui-tutorial-app/src/events.rs:events}} +``` + +With that, you can create an instance of `Events` using `Events::new()`, and get the next event on +the stream using `Events::next().await`. + +Here's the full `./src/events.rs` for your reference: + +```rust collapsed title="src/events.rs (click to expand)" +{{#include @code/crates-tui-tutorial-app/src/events.rs}} +``` + +## Demo + +Let's make a very simple event loop TUI using this `events` module. Update `main.rs` to the +following: + +```rust title="src/main.rs" +mod crates_io_api_helper; +mod errors; +mod events; +mod tui; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-events.rs:main}} +``` + +Run the code to see the frame counter increment based on the frame rate. + +![](./crates-tui-tutorial-part-events.gif) + +Experiment with different frame rates by modifying the interval stream for the render tick. + +:::note[Homework] + +Can you display the current key pressed at the top right of the screen? + +::: + +Your file structure should now look like this: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + ├── crates_io_api_helper.rs + ├── errors.rs + ├── events.rs + ├── main.rs + └── tui.rs +``` diff --git a/src/content/docs/tutorials/crates-tui/index.md b/src/content/docs/tutorials/crates-tui/index.md new file mode 100644 index 000000000..4c5d787f7 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/index.md @@ -0,0 +1,57 @@ +--- +title: Crates TUI +--- + +In the previous tutorials, we were building purely sequentially operating applications. However, +there are times when you may be interested in running IO operations or heavy computations _in +between_ drawing 2 frames. You can achieve a consistent frame rate for rendering by running these +blocking operations in a background thread or task. + +This tutorial will walk you through creating an `async` TUI app that makes an `async` request to +crates.io and lists the results based on a user prompt. This tutorial is a simplified version of the +[crates-tui] application. + +[crates-tui]: https://github.com/ratatui-org/crates-tui + +![](./crates-tui-demo-1.png) + +## Dependencies + +Run the following to setup a new project: + +```bash +cargo new crates-tui-tutorial --bin +``` + +Here's all the dependencies required for this tutorial: + +```toml title="Cargo.toml" +{{#include @code/crates-tui-tutorial-app/Cargo.toml:7:}} +``` + +Copy these dependencies into your `Cargo.toml`'s dependencies section. + +:::note + +[`tokio`] is an asynchronous runtime for the Rust programming language. It provides the building +blocks needed for writing network applications. We recommend you read the +[Tokio documentation](https://tokio.rs/tokio/tutorial). + +You may also want to check out the documentation of [`crates_io_api`] before we begin. + +::: + +[`tokio`]: https://tokio.rs/ +[`crates_io_api`]: https://docs.rs/crates_io_api/latest/crates_io_api/ + +This is what your folder structure should now look like: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + └── main.rs +``` + +Let's go through making these files one by one, starting with `main.rs`. diff --git a/src/content/docs/tutorials/crates-tui/main.md b/src/content/docs/tutorials/crates-tui/main.md new file mode 100644 index 000000000..2ca78820a --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/main.md @@ -0,0 +1,159 @@ +--- +title: Main +--- + +Let's make the `main` function a `tokio` entry point. + +Add the `#[tokio::main]` macro to the `main` function and make the function `async`. This allows you +to use `async` and `await` inside `main`. You can also now spawn tokio tasks within your +application. + +```rust title="src/main.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-main.rs}} +``` + +You can run this with `cargo run`, and you'll see that the terminal prints and then blocks for 5 +seconds before returning control. + +```bash +$ cargo run + Compiling crates-tui v0.1.0 (~/gitrepos/crates-tui-tutorial) + Finished dev [unoptimized + debuginfo] target(s) in 0.31s + Running `target/debug/crates-tui-tutorial` +Sleeping for 5 seconds... +$ +``` + +:::tip + +On UNIX systems, you can use `time cargo run` to see how long a process takes to run. + + + +```bash +$ time cargo run + Finished dev [unoptimized + debuginfo] target(s) in 0.08s + Running `target/debug/crates-tui-tutorial` +Sleeping for 5 seconds... +cargo run 0.09s user 0.05s system 2% cpu 5.262 total +$ +``` + +In this case, it took `5.262` seconds to run `cargo run`. + +::: + +:::note[Homework] + +Try to predicate what happens if you spawn multiple tokio tasks? e.g. + +```rust title="src/main.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-main-tasks-concurrent.rs}} +``` + + + +Now, what happens if you run the following instead? + +```rust title="src/main.rs" +{{#include @code/crates-tui-tutorial-app/src/bin/part-main-tasks-sequential.rs}} +``` + +Do you understand the different between creating a future and `await`ing on it later _versus_ +spawning a future and `await`ing on the spawn's `JoinHandle` later? + +::: + + + +## Conclusion + +We will expand on `main.rs` in the following sections. Right now, your project should look like +this: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + └── main.rs +``` diff --git a/src/content/docs/tutorials/crates-tui/prompt.md b/src/content/docs/tutorials/crates-tui/prompt.md new file mode 100644 index 000000000..602ae2491 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/prompt.md @@ -0,0 +1,39 @@ +--- +title: Prompt +--- + +## Search Prompt State + +The state of the search prompt is represented by this struct: + +```rust title="src/widgets/search_prompt.rs" +{{#include @code/crates-tui-tutorial-app/src/widgets/search_prompt.rs:state}} +``` + +## Search Prompt Widget + +Here is the search prompt widget: + +```rust title="src/widgets/search_prompt.rs" +{{#include @code/crates-tui-tutorial-app/src/widgets/search_prompt.rs:widget}} +``` + +To render the prompt, you can + +1. render a border +2. split the horizontal space into 2 + - render the prompt text into the first part + - render the sort by text into the second part + +Finally you have to update the cursor state so that the `app` chooses to show the cursor +appropriately. + +```rust title="src/widgets/search_prompt.rs" +{{#include @code/crates-tui-tutorial-app/src/widgets/search_prompt.rs:render}} +``` + +Here's the full code for reference: + +```rust collapsed title="src/widgets/search_prompt.rs (click to expand)" +{{#include @code/crates-tui-tutorial-app/src/widgets/search_prompt.rs}} +``` diff --git a/src/content/docs/tutorials/crates-tui/results.md b/src/content/docs/tutorials/crates-tui/results.md new file mode 100644 index 000000000..e49ef3f80 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/results.md @@ -0,0 +1,37 @@ +--- +title: Results +--- + +## Search Results State + +Here is the search results state: + +```rust title="src/widgets/search_results.rs" +{{#include @code/crates-tui-tutorial-app/src/widgets/search_results.rs:state}} +``` + +`crates_io_api::Crate` has fields + +- name: `String` +- description: `Option` +- downloads: `u64` + +## Search Results Widget + +Here is the search results widget: + +```rust title="src/widgets/search_results.rs" +{{#include @code/crates-tui-tutorial-app/src/widgets/search_results.rs:widget}} +``` + +And the implementation of the stateful widget render looks like this: + +```rust title="src/widgets/search_results.rs" +{{#include @code/crates-tui-tutorial-app/src/widgets/search_results.rs:render}} +``` + +Here's the full code for reference: + +```rust collapsed title="src/widgets/search_results.rs (click to expand)" +{{#include @code/crates-tui-tutorial-app/src/widgets/search_results.rs}} +``` diff --git a/src/content/docs/tutorials/crates-tui/tui.md b/src/content/docs/tutorials/crates-tui/tui.md new file mode 100644 index 000000000..bf2660a87 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/tui.md @@ -0,0 +1,61 @@ +--- +title: Tui +--- + +In order for your application to present as a terminal user interface, you need to do 2 things: + +1. Enter raw mode: This is required to get key or mouse inputs from a user +2. Enter the alternate screen: This is required to preserve the state of the user's current terminal + contents + +Define a couple of functions to `init` and `restore` the terminal state: + +```rust title="src/tui.rs" +{{#include @code/crates-tui-tutorial-app/src/tui.rs}} +``` + +`init` returns the `ratatui::terminal::Terminal` struct. + +After calling `init`, the terminal is now in the appropriate state to make your application behave +as a TUI application. Just have to make sure we call `tui::restore()` at the end of your program. + +Let's update `main.rs` to the following: + +```rust title="src/main.rs" +mod crates_io_api_helper; +mod tui; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-tui.rs:main}} +``` + +If you run this using `cargo run`, your terminal should enter the alternate screen, display the +`"hello world"` Paragraph widget, sleep for 5 seconds and reset the terminal back to the normal +state. + +![](./crates-tui-tutorial-part-tui.gif) + +:::note[Homework] + +What happens if you comment out this line from `init`? + +```rust title="src/tui.rs" + // crossterm::execute!(std::io::stdout(), EnterAlternateScreen)?; +``` + +Experiment with commenting out `init` and `restore` from `main` to see how the app behaves. + +::: + +Your file structure should now look like this: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + ├── crates_io_api_helper.rs + ├── main.rs + └── tui.rs +``` + +Next, we will handle errors. diff --git a/src/content/docs/tutorials/crates-tui/widgets.md b/src/content/docs/tutorials/crates-tui/widgets.md new file mode 100644 index 000000000..73cd75fe1 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/widgets.md @@ -0,0 +1,39 @@ +--- +title: Widgets +--- + +In this section we will discuss implementing widgets. + +Create a new file `./src/widgets.rs` with the following content: + +```rust title="src/widgets.rs" +{{#include @code/crates-tui-tutorial-app/src/widgets.rs}} +``` + +We will be making a `SearchPage` widget that composes a `SearchResults` widget and a `SearchPrompt` +widget. + +![](./crates-tui-demo-1.png) + +For the `SearchResults`, we will use a `Table` like before, and additionally a `Scrollbar` widget. +For the `SearchPrompt`, we will use a `Block` with borders and `Paragraph`s for the text like +before. + +We will be using the `StatefulWidget` pattern. `StatefulWidget` is a trait in Ratatui that is +defined like so: + +```rust +pub trait StatefulWidget { + type State; + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State); +} +``` + +For this `StatefulWidget` pattern, you will always have at a minimum two `struct`s for every widget: + +1. the state +2. the widget + +You used this pattern already in the `app` module with the `App` struct as the state and the +`AppWidget` struct as the widget that is rendered. Now you are going to apply it to refactor the +`App` into children. diff --git a/src/content/docs/tutorials/index.md b/src/content/docs/tutorials/index.md index bb7444283..37f007635 100644 --- a/src/content/docs/tutorials/index.md +++ b/src/content/docs/tutorials/index.md @@ -10,8 +10,6 @@ title: Tutorials organizing its structure for a `ratatui`-based application to edit json key value pairs. JSON Editor TUI will provide an interface for users to input key-value pairs, which are then converted into correct JSON format and printed to stdout. -- [Async Counter App](./counter-async-app/): This tutorial, expands on the Counter app to build a an - async TUI using [tokio](https://tokio.rs/). :::note