From 9b0abb5945793c3eaa707922696bb0c65668bebb Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy Date: Sun, 11 Feb 2024 07:05:55 -0500 Subject: [PATCH 1/5] feat: Add crates-tui tutorial --- Cargo.lock | 973 ++++++++++++++---- astro.config.mjs | 45 +- code/crates-tui-tutorial-app/.gitignore | 1 + code/crates-tui-tutorial-app/Cargo.toml | 17 + code/crates-tui-tutorial-app/LICENSE | 21 + code/crates-tui-tutorial-app/demo.tape | 23 + code/crates-tui-tutorial-app/src/app.rs | 227 ++++ .../src/bin/crates-tui.rs | 18 + .../src/bin/part-app-async.rs | 429 ++++++++ .../src/bin/part-app-async.tape | 18 + .../src/bin/part-app-basics.rs | 136 +++ .../src/bin/part-app-basics.tape | 15 + .../src/bin/part-app-mode.rs | 216 ++++ .../src/bin/part-app-mode.tape | 12 + .../src/bin/part-app-prototype.rs | 463 +++++++++ .../src/bin/part-app-prototype.tape | 17 + .../src/bin/part-errors.rs | 24 + .../src/bin/part-events.rs | 43 + .../src/bin/part-events.tape | 11 + .../src/bin/part-final.rs | 18 + .../src/bin/part-helper.rs | 154 +++ .../src/bin/part-main-error.rs | 8 + .../src/bin/part-main-tasks-concurrent.rs | 23 + .../src/bin/part-main-tasks-sequential.rs | 23 + .../src/bin/part-main.rs | 8 + .../src/bin/part-tui.rs | 21 + .../src/bin/part-tui.tape | 8 + .../src/crates_io_api_helper.rs | 149 +++ code/crates-tui-tutorial-app/src/errors.rs | 39 + code/crates-tui-tutorial-app/src/events.rs | 71 ++ code/crates-tui-tutorial-app/src/lib.rs | 6 + code/crates-tui-tutorial-app/src/tui.rs | 24 + code/crates-tui-tutorial-app/src/widgets.rs | 3 + .../src/widgets/search_page.rs | 160 +++ .../src/widgets/search_prompt.rs | 84 ++ .../src/widgets/search_results.rs | 163 +++ .../counter-app/multiple-files/event.md | 4 +- .../tutorials/counter-async-app/actions.md | 317 ------ .../counter-async-app/async-event-stream.md | 223 ---- .../async-increment-decrement.md | 167 --- .../tutorials/counter-async-app/conclusion.md | 11 - .../counter-async-app/full-async-actions.md | 47 - .../counter-async-app/full-async-events.md | 193 ---- .../docs/tutorials/counter-async-app/index.md | 90 -- .../sync-increment-decrement.md | 221 ---- .../docs/tutorials/crates-tui/app-async.md | 124 +++ .../docs/tutorials/crates-tui/app-basics.md | 151 +++ .../docs/tutorials/crates-tui/app-mode.md | 97 ++ .../tutorials/crates-tui/app-prototype.md | 234 +++++ src/content/docs/tutorials/crates-tui/app.md | 64 ++ .../docs/tutorials/crates-tui/conclusion.md | 14 + .../crates-tui/crates-io-api-helper.md | 261 +++++ .../crates-tui/crates-tui-demo-1.png | 3 + .../crates-tui/crates-tui-demo-2.png | 3 + .../tutorials/crates-tui/crates-tui-demo.gif | 3 + .../crates-tui-tutorial-part-app-async.gif | 3 + .../crates-tui-tutorial-part-app-basics.gif | 3 + .../crates-tui-tutorial-part-app-mode.gif | 3 + ...crates-tui-tutorial-part-app-prototype.gif | 3 + .../crates-tui-tutorial-part-events.gif | 3 + .../crates-tui-tutorial-part-tui.gif | 3 + .../docs/tutorials/crates-tui/errors.md | 99 ++ .../docs/tutorials/crates-tui/events.md | 94 ++ .../docs/tutorials/crates-tui/index.md | 59 ++ src/content/docs/tutorials/crates-tui/main.md | 157 +++ .../docs/tutorials/crates-tui/prompt.md | 41 + .../docs/tutorials/crates-tui/results.md | 39 + .../docs/tutorials/crates-tui/search.md | 58 ++ src/content/docs/tutorials/crates-tui/tui.md | 61 ++ .../docs/tutorials/crates-tui/widgets.md | 39 + src/content/docs/tutorials/index.md | 2 - 71 files changed, 5108 insertions(+), 1457 deletions(-) create mode 100644 code/crates-tui-tutorial-app/.gitignore create mode 100644 code/crates-tui-tutorial-app/Cargo.toml create mode 100644 code/crates-tui-tutorial-app/LICENSE create mode 100644 code/crates-tui-tutorial-app/demo.tape create mode 100644 code/crates-tui-tutorial-app/src/app.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/crates-tui.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-app-async.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-app-async.tape create mode 100644 code/crates-tui-tutorial-app/src/bin/part-app-basics.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-app-basics.tape create mode 100644 code/crates-tui-tutorial-app/src/bin/part-app-mode.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-app-mode.tape create mode 100644 code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-app-prototype.tape create mode 100644 code/crates-tui-tutorial-app/src/bin/part-errors.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-events.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-events.tape create mode 100644 code/crates-tui-tutorial-app/src/bin/part-final.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-helper.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-main-error.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-main-tasks-concurrent.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-main-tasks-sequential.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-main.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-tui.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-tui.tape create mode 100644 code/crates-tui-tutorial-app/src/crates_io_api_helper.rs create mode 100644 code/crates-tui-tutorial-app/src/errors.rs create mode 100644 code/crates-tui-tutorial-app/src/events.rs create mode 100644 code/crates-tui-tutorial-app/src/lib.rs create mode 100644 code/crates-tui-tutorial-app/src/tui.rs create mode 100644 code/crates-tui-tutorial-app/src/widgets.rs create mode 100644 code/crates-tui-tutorial-app/src/widgets/search_page.rs create mode 100644 code/crates-tui-tutorial-app/src/widgets/search_prompt.rs create mode 100644 code/crates-tui-tutorial-app/src/widgets/search_results.rs delete mode 100644 src/content/docs/tutorials/counter-async-app/actions.md delete mode 100644 src/content/docs/tutorials/counter-async-app/async-event-stream.md delete mode 100644 src/content/docs/tutorials/counter-async-app/async-increment-decrement.md delete mode 100644 src/content/docs/tutorials/counter-async-app/conclusion.md delete mode 100644 src/content/docs/tutorials/counter-async-app/full-async-actions.md delete mode 100644 src/content/docs/tutorials/counter-async-app/full-async-events.md delete mode 100644 src/content/docs/tutorials/counter-async-app/index.md delete mode 100644 src/content/docs/tutorials/counter-async-app/sync-increment-decrement.md create mode 100644 src/content/docs/tutorials/crates-tui/app-async.md create mode 100644 src/content/docs/tutorials/crates-tui/app-basics.md create mode 100644 src/content/docs/tutorials/crates-tui/app-mode.md create mode 100644 src/content/docs/tutorials/crates-tui/app-prototype.md create mode 100644 src/content/docs/tutorials/crates-tui/app.md create mode 100644 src/content/docs/tutorials/crates-tui/conclusion.md create mode 100644 src/content/docs/tutorials/crates-tui/crates-io-api-helper.md create mode 100644 src/content/docs/tutorials/crates-tui/crates-tui-demo-1.png create mode 100644 src/content/docs/tutorials/crates-tui/crates-tui-demo-2.png create mode 100644 src/content/docs/tutorials/crates-tui/crates-tui-demo.gif create mode 100644 src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-async.gif create mode 100644 src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-basics.gif create mode 100644 src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-mode.gif create mode 100644 src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-app-prototype.gif create mode 100644 src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-events.gif create mode 100644 src/content/docs/tutorials/crates-tui/crates-tui-tutorial-part-tui.gif create mode 100644 src/content/docs/tutorials/crates-tui/errors.md create mode 100644 src/content/docs/tutorials/crates-tui/events.md create mode 100644 src/content/docs/tutorials/crates-tui/index.md create mode 100644 src/content/docs/tutorials/crates-tui/main.md create mode 100644 src/content/docs/tutorials/crates-tui/prompt.md create mode 100644 src/content/docs/tutorials/crates-tui/results.md create mode 100644 src/content/docs/tutorials/crates-tui/search.md create mode 100644 src/content/docs/tutorials/crates-tui/tui.md create mode 100644 src/content/docs/tutorials/crates-tui/widgets.md 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..163201685 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,40 @@ 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", 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: "Search", link: "/tutorials/crates-tui/search" }, + { 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..fafd08220 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/app.rs @@ -0,0 +1,227 @@ +// ANCHOR: imports_all +// ANCHOR: imports_external +use color_eyre::eyre::Result; +use crossterm::event::KeyEvent; +use ratatui::{prelude::*, widgets::Paragraph}; +// ANCHOR: imports_external + +// ANCHOR: imports_core +use crate::{ + events::{Event, Events}, + tui::Tui, + widgets::{search_page::SearchPage, search_page::SearchPageWidget}, +}; +// 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, + last_key_event: Option, + mode: Mode, + rx: tokio::sync::mpsc::UnboundedReceiver, + tx: tokio::sync::mpsc::UnboundedSender, + + search_page: SearchPage, // new +} +// ANCHOR_END: app + +impl App { + // ANCHOR: app_new + pub fn new() -> Self { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let search_page = SearchPage::new(tx.clone()); + let mode = Mode::default(); + let quit = false; + let last_key_event = None; + Self { + quit, + last_key_event, + mode, + rx, + tx, + search_page, + } + } + // 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: KeyEvent) { + let maybe_action = self.mode.handle_key(key); + if maybe_action.is_none() && matches!(self.mode, Mode::Prompt) { + self.search_page.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.search_page.scroll_up(), + Action::ScrollDown => self.search_page.scroll_down(), + Action::SubmitSearchQuery => self.search_page.submit_query(), + Action::UpdateSearchResults => { + self.search_page.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 + } + + fn set_cursor(&mut self, frame: &mut Frame<'_>) { + if matches!(self.mode, Mode::Prompt) { + if let Some(cursor_position) = self.search_page.cursor_position() { + frame.set_cursor(cursor_position.x, cursor_position.y) + } + } + } +} + +// 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, search_page] = + Layout::vertical([Constraint::Length(1), Constraint::Fill(0)]) + .areas(area); + + if let Some(key) = state.last_key_event { + Paragraph::new(format!("last key event: {:?}", key.code)) + .right_aligned() + .render(status_bar, buf); + } + + if state.search_page.loading() { + Line::from("Loading...").render(status_bar, buf); + } + + SearchPageWidget { mode: state.mode }.render( + search_page, + buf, + &mut state.search_page, + ); + } +} +// 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..db6ac2dc2 --- /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: 3, // TODO: set it to 100 later + 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..1677f69e4 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/crates_io_api_helper.rs @@ -0,0 +1,149 @@ +use std::sync::{Arc, Mutex}; + +use crates_io_api::CratesQuery; +use tokio::sync::mpsc::UnboundedSender; + +use crate::app::Action; +use color_eyre::{eyre::Context, Result}; + +// 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 tx: Option>, + pub fake_delay: u64, +} +// ANCHOR_END: search_parameters + +impl SearchParameters { + pub fn new( + search: String, + crates: Arc>>, + tx: Option>, + ) -> SearchParameters { + SearchParameters { + search, + page: 1, + page_size: 100, + sort: crates_io_api::Sort::Relevance, + crates, + tx, + 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); + + // After a successful fetch, send relevant actions based on the result + if !app_crates.is_empty() { + let _ = params + .tx + .clone() + .map(|tx| tx.send(Action::UpdateSearchResults)); + } +} + +// 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(), None); + + 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..8433a2dc3 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/widgets.rs @@ -0,0 +1,3 @@ +pub mod search_page; +pub mod search_prompt; +pub mod search_results; diff --git a/code/crates-tui-tutorial-app/src/widgets/search_page.rs b/code/crates-tui-tutorial-app/src/widgets/search_page.rs new file mode 100644 index 000000000..8576714b1 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/widgets/search_page.rs @@ -0,0 +1,160 @@ +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; + +use crossterm::event::{Event as CrosstermEvent, KeyEvent}; +use itertools::Itertools; +use ratatui::{ + layout::{Constraint, Layout, Position}, + widgets::StatefulWidget, +}; +use tokio::sync::mpsc::UnboundedSender; +use tui_input::backend::crossterm::EventHandler; + +use crate::{ + app::{Action, Mode}, + crates_io_api_helper, + widgets::{search_prompt::SearchPrompt, search_results::SearchResults}, +}; + +use super::{ + search_prompt::SearchPromptWidget, search_results::SearchResultsWidget, +}; + +// ANCHOR: search_page +#[derive(Debug)] +pub struct SearchPage { + pub results: SearchResults, + pub prompt: SearchPrompt, + + pub page: u64, + pub page_size: u64, + pub sort: crates_io_api::Sort, + pub crates: Arc>>, + pub loading_status: Arc, + tx: UnboundedSender, +} +// ANCHOR_END: search_page + +impl SearchPage { + pub fn new(tx: UnboundedSender) -> Self { + let loading_status = Arc::new(AtomicBool::default()); + Self { + results: SearchResults::default(), + prompt: SearchPrompt::default(), + page: 1, + page_size: 25, + sort: crates_io_api::Sort::Relevance, + crates: Default::default(), + tx, + loading_status, + } + } + + pub fn scroll_up(&mut self) { + self.results.scroll_previous(); + } + + pub fn scroll_down(&mut self) { + self.results.scroll_next(); + } + + pub fn loading(&self) -> bool { + self.loading_status.load(Ordering::SeqCst) + } + + // ANCHOR: prompt_methods + pub fn handle_key(&mut self, key: KeyEvent) { + self.prompt.input.handle_event(&CrosstermEvent::Key(key)); + } + + pub fn cursor_position(&self) -> Option { + self.prompt.cursor_position + } + // ANCHOR_END: prompt_methods + + pub fn update_search_results(&mut self) { + self.results.select(None); + let crates: Vec<_> = + self.crates.lock().unwrap().iter().cloned().collect_vec(); + self.results.crates = crates; + self.results.content_length(self.results.crates.len()); + self.scroll_down(); + } + + // ANCHOR: submit + pub fn submit_query(&mut self) { + let _ = self.tx.send(Action::SwitchMode(Mode::Results)); + self.prepare_request(); + self.request_search_results(); + } + + pub fn prepare_request(&mut self) { + self.results.select(None); + } + + // ANCHOR: create_search_parameters + pub fn create_search_parameters( + &self, + ) -> crates_io_api_helper::SearchParameters { + crates_io_api_helper::SearchParameters::new( + self.prompt.input.value().into(), + self.crates.clone(), + Some(self.tx.clone()), + ) + } + // ANCHOR_END: create_search_parameters + + // ANCHOR: request_search_results + pub fn request_search_results(&self) { + let loading_status = self.loading_status.clone(); + let mut params = self.create_search_parameters(); + params.fake_delay = 5; + tokio::spawn(async move { + loading_status.store(true, Ordering::SeqCst); + let _ = crates_io_api_helper::request_search_results(¶ms).await; + loading_status.store(false, Ordering::SeqCst); + }); + } + // ANCHOR_END: request_search_results + + // ANCHOR_END: submit +} + +// ANCHOR: search_page_widget +pub struct SearchPageWidget { + pub mode: Mode, +} + +impl StatefulWidget for SearchPageWidget { + type State = SearchPage; + + fn render( + self, + area: ratatui::prelude::Rect, + buf: &mut ratatui::prelude::Buffer, + state: &mut Self::State, + ) { + let prompt_height = 5; + + let [main, prompt] = Layout::vertical([ + Constraint::Min(0), + Constraint::Length(prompt_height), + ]) + .areas(area); + + SearchResultsWidget::new(matches!(self.mode, Mode::Results)).render( + main, + buf, + &mut state.results, + ); + + SearchPromptWidget::new(self.mode, state.sort.clone()).render( + prompt, + buf, + &mut state.prompt, + ); + } +} +// ANCHOR_END: search_page_widget 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..776b28f53 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/widgets/search_prompt.rs @@ -0,0 +1,84 @@ +use ratatui::{layout::Position, prelude::*, widgets::*}; + +use crate::app::Mode; + +// ANCHOR: state +#[derive(Default, Debug, Clone)] +pub struct SearchPrompt { + pub cursor_position: Option, + pub input: tui_input::Input, +} +// ANCHOR_END: state + +// ANCHOR: widget +pub struct SearchPromptWidget { + mode: Mode, + sort: crates_io_api::Sort, +} +// ANCHOR_END: widget + +impl SearchPromptWidget { + pub fn new(mode: Mode, sort: crates_io_api::Sort) -> Self { + Self { mode, sort } + } + + fn border(&self) -> Block { + let color = if matches!(self.mode, Mode::Prompt) { + Color::Yellow + } else { + Color::Black + }; + Block::default().borders(Borders::ALL).border_style(color) + } + + fn sort_by_text(&self) -> impl Widget { + Paragraph::new(Line::from(vec![ + "Sort By: ".into(), + format!("{:?}", self.sort.clone()).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 matches!(self.mode, Mode::Prompt) { + 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() + .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..78f714303 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/widgets/search_results.rs @@ -0,0 +1,163 @@ +use crates_io_api::Crate; +use itertools::Itertools; +use ratatui::{prelude::*, widgets::*}; + +// ANCHOR: state +#[derive(Debug, Default)] +pub struct SearchResults { + pub crates: Vec, + pub table_state: TableState, + pub scrollbar_state: ScrollbarState, +} +// ANCHOR_END: state + +const TABLE_HEADER_HEIGHT: u16 = 2; +const COLUMN_SPACING: u16 = 3; +const ROW_HEIGHT: u16 = 2; + +impl SearchResults { + fn rows(&self) -> Vec> { + self.crates.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) + } +} + +impl SearchResults { + pub fn content_length(&mut self, content_length: usize) { + self.scrollbar_state = + self.scrollbar_state.content_length(content_length) + } + + pub fn select(&mut self, index: Option) { + self.table_state.select(index) + } + + pub fn scroll_next(&mut self) { + let wrap_index = self.crates.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.len().saturating_sub(1); + let wrap_index = self.crates.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.is_empty() { + self.table_state.select(None) + } else { + self.table_state.select(Some(index)); + self.scrollbar_state = self.scrollbar_state.position(index); + } + } +} + +// 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/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..a79fdddbb --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-async.md @@ -0,0 +1,124 @@ +--- +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 +{{#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 +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 +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 +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 +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}} +} +``` + +## StatefulWidget + +Finally, you can update the render the methods in `App` to replace placeholder data with the data +from the results or the prompt value: + +```rust +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. + +Update the prompt widget to show the text from `tui-input::Input` in a `Paragraph` widget: + +```rust +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_prompt_widget}} +} +``` + +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 +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_statefulwidget}} +``` 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..88aaae4a2 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-basics.md @@ -0,0 +1,151 @@ +--- +title: App +--- + +Before we proceed any further, we are going to refactor the code we already have to make it easier +to scale up. We are going to move the event loop into a method on the `App` struct. + +Create a new file `./src/app.rs`: + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app}} +``` + +Define some helper functions for initializing the `App`: + +```rust +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}} +``` + +Now define a `run` method for `App`: + +```rust +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. + +::: + +The `run` method uses a `should_quit` method (and a corresponding `quit` method) that you can define +like this: + +```rust +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_quit}} +} +``` + +This `run` method also uses a `handle_event` method that you can define like so: + +```rust +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_handle_event}} +} +``` + +Finally, for the `draw` method, you could define it like this: + +```rust +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. + +Define the `draw` method like this: + +```rust +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 +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: + +
+ +Copy the following into src/app.rs + +```rust +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}} +``` + +
+ +Now, run your application with a modified `main.rs` that uses the `App` struct you just created: + +```rust +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..cbb7f070d --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-mode.md @@ -0,0 +1,97 @@ +--- +title: App Mode +--- + +In this section, you are going to expand on the `App` struct to add a `Mode`. + +Define the following fields in the `App` struct: + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app}} +``` + +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 +{{#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. + +Change the `handle_event` function to use the `Mode` to do different things when `Esc` is pressed: + +```rust +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 +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_switch_mode}} +} +``` + +Let's make our view a little more interesting with some placeholder text for the results: + +```rust +use itertools::Itertools; + +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_results_table_widget}} +} +``` + +We will also make a prompt that changes border color based on the mode: + +```rust +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_prompt_widget}} +} +``` + +And in the render function for the `StatefulWidget` we can call these widget constructors: + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_statefulwidget}} +``` + +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: + +
+ +Copy the following into src/app.rs + +```rust +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..623ad8869 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-prototype.md @@ -0,0 +1,234 @@ +--- +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 +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:action}} +``` + +## Channels + +Define the following fields in the `App` struct: + +```rust +{{#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 +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 +{{#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 +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 +{{#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 +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 +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: + +
+ +Copy the following into src/app.rs + +```rust +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-prototype.rs:full_app}} +``` + +
+ +This is what our app currently looks like: + +![](./crates-tui-tutorial-part-app-prototype.gif) + +## Conclusion + +With everything in one file, the `App` struct can get a little unwieldy. Moreover, 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..2d2445322 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app.md @@ -0,0 +1,64 @@ +--- +title: App +--- + +Finally, let's make a field in the app struct that uses the `SearchPage` widget: + +```rust +{{#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 +impl App { +{{#include @code/crates-tui-tutorial-app/src/app.rs:app_handle_action}} +} +``` + +And rendering delegates to `SearchPageWidget`: + +```rust +impl App { +{{#include @code/crates-tui-tutorial-app/src/app.rs:app_statefulwidget}} +} +``` + +
+ +Copy the following into src/app.rs + +```rust +{{#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. + +![](./crates-tui-demo.gif) + +Search for your favorite crates and explore crates.io. 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..9b81fd3dc --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md @@ -0,0 +1,261 @@ +--- +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 + +Before you proceed, create a file `src/crates_io_helper.rs` with a `async` test block so you can +experiment with the API. + +```rust +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" filename=./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 +``` + +To initialize the `crates_io_api::AsyncClient`, you have to provide an email to use as the user +agent. + +```rust +#[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()) +``` + +::: + +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` + +To make the code easier to manage, let's store everything we need to construct a `CratesQuery` in a +`SearchParameters` struct: + +```rust +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. + +Create a `new` constructor to make it easier to create a `SearchParameter` instance: + +```rust +{{#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 + // ... + let crates: Arc>> = Default::default(); + let search_params = SearchParameters::new("ratatui".into(), crates.clone()); + // ... +``` + +Construct the query using `crates_io_api`'s [`CratesQueryBuilder`]: + +[`CratesQueryBuilder`]: + https://docs.rs/crates_io_api/latest/crates_io_api/struct.CratesQueryBuilder.html + +```rust + // ... +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:create_query}} + // ... +``` + +Once you have created the `client` and `query`, you can call the `.crates()` method on the client +and `await` the response. + +```rust +{{#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`. + +Clear the existing results in the `search_params.crates` field and update the +`Arc>>` with the response: + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:update_state}} +``` + +Finally, add a `println!` for every element in the response to test that it worked: + +```rust + 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 so: + +```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 +``` + +:::note + +We set the `page_size` to `3` for testing purposes in the constructor for `SearchParameters`. Change +that to the maximum value of `100`. + +::: + +## Refactor + +You may want to refactor the above code into separate functions for simplicity. If you do so, it'll +look like this: + +```rust +{{#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 code in src/crates_io_api_helper.rs for your reference + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:helper}} +``` + +
+ +With the refactor, your test code should look like this: + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:test}} +``` + +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..54475d758 --- /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 +{{#include @code/crates-tui-tutorial-app/src/errors.rs}} +``` + + + +Let's update `main.rs` to the following: + +```diff lang="rust" + 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..cad6ba8eb --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/events.md @@ -0,0 +1,94 @@ +--- +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/ + +First, create a `Event` enum, like before: + +```rust +{{#include @code/crates-tui-tutorial-app/src/events.rs:event}} +``` + +This will represent all possible events you can receive from the `Events` stream. + +Next create a `crossterm_stream` function: + +```rust +{{#include @code/crates-tui-tutorial-app/src/events.rs:stream}} + +{{#include @code/crates-tui-tutorial-app/src/events.rs:crossterm}} +``` + +You can create stream using an `IntervalStream` for generating `Event::Render` events. + +```rust +{{#include @code/crates-tui-tutorial-app/src/events.rs:render}} +``` + +Putting it all together, make a `Events` struct like so: + +```rust +{{#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: + +
+ +Copy the following into src/events.rs + +```rust +{{#include @code/crates-tui-tutorial-app/src/events.rs}} +``` + +
+ +Let's make a very simple event loop TUI using this `events` module. Update `main.rs` to the +following: + +```rust +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..f377ebeb5 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/index.md @@ -0,0 +1,59 @@ +--- +title: Crates TUI +--- + +In the previous tutorials, we were building a purely sequentially operational applications. However, +there are times when you may be interested in running IO operations or heavy computations in between +rendering frames. And when you do this, you don't want to block rendering. You can achieve a +consistent frame rate for rendering by running these blocking operations in a background thread or +task. + +This tutorial will lead you through creating an `async` TUI app that lists crates from crates.io +based on a user search request in an `async` manner. + +![](./crates-tui-demo-1.png) + +This tutorial is a simplified version of the [crates-tui] application. + +[crates-tui]: https://github.com/ratatui-org/crates-tui + +## Dependencies + +Run the following to setup a new project: + +```bash +cargo new crates-tui-tutorial --bin +``` + +Here's all the dependencies in the `Cargo.toml` file required for this tutorial: + +```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..df5b538c6 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/main.md @@ -0,0 +1,157 @@ +--- +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 +{{#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 +{{#include @code/crates-tui-tutorial-app/src/bin/part-main-tasks-concurrent.rs}} +``` + + + +Now, what happens if you run the following instead? + +```rust +{{#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? + +::: + + + +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..8afcb6460 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/prompt.md @@ -0,0 +1,41 @@ +--- +title: Prompt +--- + +The state of the search prompt is represented by this struct: + +```rust +{{#include @code/crates-tui-tutorial-app/src/widgets/search_prompt.rs:state}} +``` + +Here is the search prompt widget: + +```rust +{{#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 +{{#include @code/crates-tui-tutorial-app/src/widgets/search_prompt.rs:render}} +``` + +Here's the full code for reference: + +
+ +Copy the following into src/widgets/search_prompt.rs + +```rust +{{#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..1c385e4ae --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/results.md @@ -0,0 +1,39 @@ +--- +title: Results +--- + +Here is the search results state: + +```rust +{{#include @code/crates-tui-tutorial-app/src/widgets/search_results.rs:state}} +``` + +`crates_io_api::Crate` has fields + +- name: `String` +- description: `Option` +- downloads: `u64` + +Here is the search results widget: + +```rust +{{#include @code/crates-tui-tutorial-app/src/widgets/search_results.rs:widget}} +``` + +And the implementation of the stateful widget render looks like this: + +```rust +{{#include @code/crates-tui-tutorial-app/src/widgets/search_results.rs:render}} +``` + +Here's the full code for reference: + +
+ +Copy the following into src/widgets/search_results.rs + +```rust +{{#include @code/crates-tui-tutorial-app/src/widgets/search_results.rs}} +``` + +
diff --git a/src/content/docs/tutorials/crates-tui/search.md b/src/content/docs/tutorials/crates-tui/search.md new file mode 100644 index 000000000..ba268d11a --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/search.md @@ -0,0 +1,58 @@ +--- +title: Search +--- + +Create a new file, `./src/widgets/search_page.rs` with the following contents: + +```rust +{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:search_page}} +``` + +This struct represents the `State` in the `StatefulWidget` pattern. This struct contains two nested +children fields, `results` and `prompt` that contain the state of the respective views. + +Create the search parameters struct like so: + +```rust +{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:create_search_parameters}} +``` + +and spawn a tokio task to make request like so: + +```rust +{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:request_search_results}} +``` + +:::note + +This method spawns a tokio task and returns immediately, i.e. does not block. This method is not an +`async` method but spawns an async background task. + +::: + +This struct also contains methods for managing the prompt state using `tui_input`: + +```rust +{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:prompt_methods}} +``` + +These methods are called from the `app` in the corresponding `Action`s. + +For the search page widget, create struct with just one field. You can then implement the render +method on the `StatefulWidget` trait to render both the prompt and the results: + +```rust +{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:search_page_widget}} +``` + +Here is the search page widget in its entirety: + +
+ +Copy the following into src/widgets/search_page.rs + +```rust +{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.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..5ae95fe95 --- /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 +{{#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 +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 + // 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..7f724e31d --- /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 +{{#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 From 3f64a3cf1dd38a2f87e54c7f6efd474a1294bae9 Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy Date: Tue, 20 Feb 2024 22:43:22 -0500 Subject: [PATCH 2/5] feat: Add title for code blocks --- .../docs/tutorials/crates-tui/app-async.md | 16 +++++------ .../docs/tutorials/crates-tui/app-basics.md | 20 ++++++------- .../docs/tutorials/crates-tui/app-mode.md | 16 +++++------ .../tutorials/crates-tui/app-prototype.md | 18 ++++++------ src/content/docs/tutorials/crates-tui/app.md | 8 +++--- .../crates-tui/crates-io-api-helper.md | 28 +++++++++---------- .../docs/tutorials/crates-tui/errors.md | 4 +-- .../docs/tutorials/crates-tui/events.md | 12 ++++---- .../docs/tutorials/crates-tui/index.md | 4 +-- src/content/docs/tutorials/crates-tui/main.md | 6 ++-- .../docs/tutorials/crates-tui/prompt.md | 8 +++--- .../docs/tutorials/crates-tui/results.md | 8 +++--- .../docs/tutorials/crates-tui/search.md | 12 ++++---- src/content/docs/tutorials/crates-tui/tui.md | 6 ++-- .../docs/tutorials/crates-tui/widgets.md | 2 +- 15 files changed, 84 insertions(+), 84 deletions(-) diff --git a/src/content/docs/tutorials/crates-tui/app-async.md b/src/content/docs/tutorials/crates-tui/app-async.md index a79fdddbb..6eded5077 100644 --- a/src/content/docs/tutorials/crates-tui/app-async.md +++ b/src/content/docs/tutorials/crates-tui/app-async.md @@ -6,7 +6,7 @@ We are finally ready to incorporate the helper module into the `App` struct. Define the the following fields in the `App` struct: -```rust +```rust title="src/app.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app}} ``` @@ -34,7 +34,7 @@ For the application, we want to be able to: Expand the `handle_events` to the match on mode and change the app state accordingly: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_handle_event}} } @@ -51,7 +51,7 @@ query that the user has typed in, i.e. `self.prompt.value() -> &str`. Implement the following method: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_submit_search_query}} } @@ -71,7 +71,7 @@ results to select the new index. Implement the following for wrapped scrolling: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_scroll}} } @@ -84,7 +84,7 @@ explicitly. We only want to show the cursor when the prompt is in focus. Implement the following to show the cursor conditionally: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_draw}} @@ -99,7 +99,7 @@ impl App { Finally, you can update the render the methods in `App` to replace placeholder data with the data from the results or the prompt value: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_results_table_widget}} } @@ -109,7 +109,7 @@ Note the use `highlight_symbol` here to show the cursor when scrolling. Update the prompt widget to show the text from `tui-input::Input` in a `Paragraph` widget: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_prompt_widget}} } @@ -119,6 +119,6 @@ And in the render function for the `StatefulWidget`, make sure you create a stat 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 +```rust title="src/app.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-app-async.rs:app_statefulwidget}} ``` diff --git a/src/content/docs/tutorials/crates-tui/app-basics.md b/src/content/docs/tutorials/crates-tui/app-basics.md index 88aaae4a2..4c81f3a00 100644 --- a/src/content/docs/tutorials/crates-tui/app-basics.md +++ b/src/content/docs/tutorials/crates-tui/app-basics.md @@ -7,13 +7,13 @@ to scale up. We are going to move the event loop into a method on the `App` stru Create a new file `./src/app.rs`: -```rust +```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 +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_new}} } @@ -23,7 +23,7 @@ impl App { Now define a `run` method for `App`: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_run}} } @@ -39,7 +39,7 @@ you created earlier. The `run` method uses a `should_quit` method (and a corresponding `quit` method) that you can define like this: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_quit}} } @@ -47,7 +47,7 @@ impl App { This `run` method also uses a `handle_event` method that you can define like so: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_handle_event}} } @@ -55,7 +55,7 @@ impl App { Finally, for the `draw` method, you could define it like this: -```rust +```rust title="src/app.rs" use ratatui::widgets::*; impl App { @@ -78,7 +78,7 @@ But let's go one step further and set ourselves up for using the `StatefulWidget Define the `draw` method like this: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:app_draw}} } @@ -90,7 +90,7 @@ 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 +```rust title="src/app.rs" use ratatui::widgets::{StatefulWidget, Paragraph}; struct AppWidget; @@ -104,7 +104,7 @@ Here's the full `./src/app.rs` file for your reference: Copy the following into src/app.rs -```rust +```rust title="src/app.rs" use color_eyre::eyre::Result; use ratatui::prelude::*; use ratatui::widgets::*; @@ -121,7 +121,7 @@ use crate::{ Now, run your application with a modified `main.rs` that uses the `App` struct you just created: -```rust +```rust title="src/main.rs" pub mod app; pub mod errors; pub mod events; diff --git a/src/content/docs/tutorials/crates-tui/app-mode.md b/src/content/docs/tutorials/crates-tui/app-mode.md index cbb7f070d..32702c400 100644 --- a/src/content/docs/tutorials/crates-tui/app-mode.md +++ b/src/content/docs/tutorials/crates-tui/app-mode.md @@ -6,7 +6,7 @@ In this section, you are going to expand on the `App` struct to add a `Mode`. Define the following fields in the `App` struct: -```rust +```rust title="src/app.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app}} ``` @@ -22,7 +22,7 @@ Our app is going to have two focus modes: You can represent the state of the "focus" using an enum called `Mode`: -```rust +```rust title="src/app.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:mode}} ``` @@ -32,7 +32,7 @@ to results, but `ESC` when the results are in focus should exit the app. Change the `handle_event` function to use the `Mode` to do different things when `Esc` is pressed: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_handle_event}} } @@ -40,7 +40,7 @@ impl App { You'll need to add a new `switch_mode` method: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_switch_mode}} } @@ -48,7 +48,7 @@ impl App { Let's make our view a little more interesting with some placeholder text for the results: -```rust +```rust title="src/app.rs" use itertools::Itertools; impl App { @@ -58,7 +58,7 @@ impl App { We will also make a prompt that changes border color based on the mode: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_prompt_widget}} } @@ -66,7 +66,7 @@ impl App { And in the render function for the `StatefulWidget` we can call these widget constructors: -```rust +```rust title="src/app.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.rs:app_statefulwidget}} ``` @@ -80,7 +80,7 @@ Here's the full `./src/app.rs` file for your reference: Copy the following into src/app.rs -```rust +```rust title="src/app.rs" use color_eyre::eyre::Result; use itertools::Itertools; use ratatui::prelude::*; diff --git a/src/content/docs/tutorials/crates-tui/app-prototype.md b/src/content/docs/tutorials/crates-tui/app-prototype.md index 623ad8869..9f3a3c0a4 100644 --- a/src/content/docs/tutorials/crates-tui/app-prototype.md +++ b/src/content/docs/tutorials/crates-tui/app-prototype.md @@ -28,7 +28,7 @@ instance to update its state. The variants of the `Action` enum you will be using for this tutorial are: -```rust +```rust title="src/app.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:action}} ``` @@ -36,7 +36,7 @@ The variants of the `Action` enum you will be using for this tutorial are: Define the following fields in the `App` struct: -```rust +```rust title="src/app.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app}} ``` @@ -59,7 +59,7 @@ still propagate information up from child to parent structs using the `tx` trans Setup a `App::new()` function to construct an `App` instance like so: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_new}} } @@ -67,7 +67,7 @@ impl App { Let's also update the `async run` method now: -```rust +```rust title="src/app.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_run}} ``` @@ -76,7 +76,7 @@ Let's also update the `async run` method now: Update `handle_event` to delegate to `Mode` to figure out which `Action` should be generated based on the key event and the `Mode`. -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_handle_event}} } @@ -85,7 +85,7 @@ impl App { 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 +```rust title="src/app.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:mode}} ``` @@ -103,7 +103,7 @@ If the `maybe_action` is a `Some` variant, it is sent over the `tx` channel: Now implement the `handle_action` method like so: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_handle_action}} } @@ -173,7 +173,7 @@ For example, you can send an `Action::UpdateSearchResults` from inside the task complete, when can make sure that the first time is selected after the results are loaded (by scrolling down): -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_submit_search_query}} @@ -190,7 +190,7 @@ Here's the full `./src/app.rs` file for your reference: Copy the following into src/app.rs -```rust +```rust title="src/app.rs" use color_eyre::eyre::Result; use itertools::Itertools; use ratatui::prelude::*; diff --git a/src/content/docs/tutorials/crates-tui/app.md b/src/content/docs/tutorials/crates-tui/app.md index 2d2445322..1c9a80903 100644 --- a/src/content/docs/tutorials/crates-tui/app.md +++ b/src/content/docs/tutorials/crates-tui/app.md @@ -4,7 +4,7 @@ title: App Finally, let's make a field in the app struct that uses the `SearchPage` widget: -```rust +```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}} @@ -13,7 +13,7 @@ Finally, let's make a field in the app struct that uses the `SearchPage` widget: 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 +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/app.rs:app_handle_action}} } @@ -21,7 +21,7 @@ impl App { And rendering delegates to `SearchPageWidget`: -```rust +```rust title="src/app.rs" impl App { {{#include @code/crates-tui-tutorial-app/src/app.rs:app_statefulwidget}} } @@ -31,7 +31,7 @@ impl App { Copy the following into src/app.rs -```rust +```rust title="src/app.rs" {{#include @code/crates-tui-tutorial-app/src/app.rs}} ``` 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 index 9b81fd3dc..d7bb7e24e 100644 --- a/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md +++ b/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md @@ -9,10 +9,10 @@ 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 -Before you proceed, create a file `src/crates_io_helper.rs` with a `async` test block so you can +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 +```rust title="src/crates_io_api_helper.rs" use color_eyre::Result; #[cfg(test)] @@ -29,7 +29,7 @@ mod tests { You'll also need to add the module to `main.rs`: -```diff lang="rust" filename=./src/main.rs +```diff lang="rust" title="src/main.rs" + mod crates_io_api_helper; #[tokio::main] @@ -45,7 +45,7 @@ $ cargo test -- crates_io_api_helper::tests::test_crates_io --nocapture To initialize the `crates_io_api::AsyncClient`, you have to provide an email to use as the user agent. -```rust +```rust title="src/crates_io_api_helper.rs ::tests" #[tokio::test] async fn test_crates_io() -> Result<()> { let email = "your-email-address@foo.com"; @@ -101,7 +101,7 @@ We can build this `CratesQuery` object using the following parameters: To make the code easier to manage, let's store everything we need to construct a `CratesQuery` in a `SearchParameters` struct: -```rust +```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}} @@ -115,14 +115,14 @@ response of the query once the query is completed. Create a `new` constructor to make it easier to create a `SearchParameter` instance: -```rust +```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 +```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()); @@ -134,7 +134,7 @@ Construct the query using `crates_io_api`'s [`CratesQueryBuilder`]: [`CratesQueryBuilder`]: https://docs.rs/crates_io_api/latest/crates_io_api/struct.CratesQueryBuilder.html -```rust +```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}} // ... @@ -143,7 +143,7 @@ Construct the query using `crates_io_api`'s [`CratesQueryBuilder`]: Once you have created the `client` and `query`, you can call the `.crates()` method on the client and `await` the response. -```rust +```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}} ``` @@ -154,13 +154,13 @@ which is a `Vec`. Clear the existing results in the `search_params.crates` field and update the `Arc>>` with the response: -```rust +```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}} ``` Finally, add a `println!` for every element in the response to test that it worked: -```rust +```rust title="src/crates_io_api_helper.rs (tests::test_crates_io)" for krate in crates.lock().unwrap().iter() { println!( "name: {}\ndescription: {}\ndownloads: {}\n", @@ -212,7 +212,7 @@ that to the maximum value of `100`. You may want to refactor the above code into separate functions for simplicity. If you do so, it'll look like this: -```rust +```rust title="src/crates_io_api_helper.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:request_search_results}} ``` @@ -222,7 +222,7 @@ You can now use this helper module to make `async` requests from the `app`. Here's the code in src/crates_io_api_helper.rs for your reference -```rust +```rust title="src/crates_io_api_helper.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:helper}} ``` @@ -230,7 +230,7 @@ You can now use this helper module to make `async` requests from the `app`. With the refactor, your test code should look like this: -```rust +```rust title="src/crates_io_api_helper.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:test}} ``` diff --git a/src/content/docs/tutorials/crates-tui/errors.md b/src/content/docs/tutorials/crates-tui/errors.md index 54475d758..5d9b4f0e8 100644 --- a/src/content/docs/tutorials/crates-tui/errors.md +++ b/src/content/docs/tutorials/crates-tui/errors.md @@ -37,7 +37,7 @@ to you for further investigation. Here's the code using color_eyre to set a panic hook. Put the contents of this file into `src/errors.rs`: -```rust +```rust title="src/errors.rs" {{#include @code/crates-tui-tutorial-app/src/errors.rs}} ``` @@ -45,7 +45,7 @@ Here's the code using color_eyre to set a panic hook. Put the contents of this f Let's update `main.rs` to the following: -```diff lang="rust" +```diff lang="rust" title="src/main.rs" mod crates_io_api_helper; + mod errors; mod tui; diff --git a/src/content/docs/tutorials/crates-tui/events.md b/src/content/docs/tutorials/crates-tui/events.md index cad6ba8eb..e2e02c110 100644 --- a/src/content/docs/tutorials/crates-tui/events.md +++ b/src/content/docs/tutorials/crates-tui/events.md @@ -14,7 +14,7 @@ to get async events. First, create a `Event` enum, like before: -```rust +```rust title="src/events.rs" {{#include @code/crates-tui-tutorial-app/src/events.rs:event}} ``` @@ -22,7 +22,7 @@ This will represent all possible events you can receive from the `Events` stream Next create a `crossterm_stream` function: -```rust +```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}} @@ -30,13 +30,13 @@ Next create a `crossterm_stream` function: You can create stream using an `IntervalStream` for generating `Event::Render` events. -```rust +```rust title="src/events.rs" {{#include @code/crates-tui-tutorial-app/src/events.rs:render}} ``` Putting it all together, make a `Events` struct like so: -```rust +```rust title="src/events.rs" {{#include @code/crates-tui-tutorial-app/src/events.rs:events}} ``` @@ -49,7 +49,7 @@ Here's the full `./src/events.rs` for your reference: Copy the following into src/events.rs -```rust +```rust title="src/events.rs" {{#include @code/crates-tui-tutorial-app/src/events.rs}} ``` @@ -58,7 +58,7 @@ Here's the full `./src/events.rs` for your reference: Let's make a very simple event loop TUI using this `events` module. Update `main.rs` to the following: -```rust +```rust title="src/main.rs" mod crates_io_api_helper; mod errors; mod events; diff --git a/src/content/docs/tutorials/crates-tui/index.md b/src/content/docs/tutorials/crates-tui/index.md index f377ebeb5..af141e6cb 100644 --- a/src/content/docs/tutorials/crates-tui/index.md +++ b/src/content/docs/tutorials/crates-tui/index.md @@ -25,9 +25,9 @@ Run the following to setup a new project: cargo new crates-tui-tutorial --bin ``` -Here's all the dependencies in the `Cargo.toml` file required for this tutorial: +Here's all the dependencies required for this tutorial: -```toml +```toml title="Cargo.toml" {{#include @code/crates-tui-tutorial-app/Cargo.toml:7:}} ``` diff --git a/src/content/docs/tutorials/crates-tui/main.md b/src/content/docs/tutorials/crates-tui/main.md index df5b538c6..767166c65 100644 --- a/src/content/docs/tutorials/crates-tui/main.md +++ b/src/content/docs/tutorials/crates-tui/main.md @@ -8,7 +8,7 @@ Add the `#[tokio::main]` macro to the `main` function and make the function `asy to use `async` and `await` inside `main`. You can also now spawn tokio tasks within your application. -```rust +```rust title="src/main.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-main.rs}} ``` @@ -49,7 +49,7 @@ In this case, it took `5.262` seconds to run `cargo run`. Try to predicate what happens if you spawn multiple tokio tasks? e.g. -```rust +```rust title="src/main.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-main-tasks-concurrent.rs}} ``` @@ -69,7 +69,7 @@ behaves the way it does? Now, what happens if you run the following instead? -```rust +```rust title="src/main.rs" {{#include @code/crates-tui-tutorial-app/src/bin/part-main-tasks-sequential.rs}} ``` diff --git a/src/content/docs/tutorials/crates-tui/prompt.md b/src/content/docs/tutorials/crates-tui/prompt.md index 8afcb6460..2ac8860a6 100644 --- a/src/content/docs/tutorials/crates-tui/prompt.md +++ b/src/content/docs/tutorials/crates-tui/prompt.md @@ -4,13 +4,13 @@ title: Prompt The state of the search prompt is represented by this struct: -```rust +```rust title="src/widgets/search_prompt.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_prompt.rs:state}} ``` Here is the search prompt widget: -```rust +```rust title="src/widgets/search_prompt.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_prompt.rs:widget}} ``` @@ -24,7 +24,7 @@ To render the prompt, you can Finally you have to update the cursor state so that the `app` chooses to show the cursor appropriately. -```rust +```rust title="src/widgets/search_prompt.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_prompt.rs:render}} ``` @@ -34,7 +34,7 @@ Here's the full code for reference: Copy the following into src/widgets/search_prompt.rs -```rust +```rust title="src/widgets/search_prompt.rs" {{#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 index 1c385e4ae..154dbf15c 100644 --- a/src/content/docs/tutorials/crates-tui/results.md +++ b/src/content/docs/tutorials/crates-tui/results.md @@ -4,7 +4,7 @@ title: Results Here is the search results state: -```rust +```rust title="src/widgets/search_results.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_results.rs:state}} ``` @@ -16,13 +16,13 @@ Here is the search results state: Here is the search results widget: -```rust +```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 +```rust title="src/widgets/search_results.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_results.rs:render}} ``` @@ -32,7 +32,7 @@ Here's the full code for reference: Copy the following into src/widgets/search_results.rs -```rust +```rust title="src/widgets/search_results.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_results.rs}} ``` diff --git a/src/content/docs/tutorials/crates-tui/search.md b/src/content/docs/tutorials/crates-tui/search.md index ba268d11a..2acf01492 100644 --- a/src/content/docs/tutorials/crates-tui/search.md +++ b/src/content/docs/tutorials/crates-tui/search.md @@ -4,7 +4,7 @@ title: Search Create a new file, `./src/widgets/search_page.rs` with the following contents: -```rust +```rust title="src/widgets/search_page.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:search_page}} ``` @@ -13,13 +13,13 @@ children fields, `results` and `prompt` that contain the state of the respective Create the search parameters struct like so: -```rust +```rust title="src/widgets/search_page.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:create_search_parameters}} ``` and spawn a tokio task to make request like so: -```rust +```rust title="src/widgets/search_page.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:request_search_results}} ``` @@ -32,7 +32,7 @@ This method spawns a tokio task and returns immediately, i.e. does not block. Th This struct also contains methods for managing the prompt state using `tui_input`: -```rust +```rust title="src/widgets/search_page.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:prompt_methods}} ``` @@ -41,7 +41,7 @@ These methods are called from the `app` in the corresponding `Action`s. For the search page widget, create struct with just one field. You can then implement the render method on the `StatefulWidget` trait to render both the prompt and the results: -```rust +```rust title="src/widgets/search_page.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:search_page_widget}} ``` @@ -51,7 +51,7 @@ Here is the search page widget in its entirety: Copy the following into src/widgets/search_page.rs -```rust +```rust title="src/widgets/search_page.rs" {{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs}} ``` diff --git a/src/content/docs/tutorials/crates-tui/tui.md b/src/content/docs/tutorials/crates-tui/tui.md index 5ae95fe95..bf2660a87 100644 --- a/src/content/docs/tutorials/crates-tui/tui.md +++ b/src/content/docs/tutorials/crates-tui/tui.md @@ -10,7 +10,7 @@ In order for your application to present as a terminal user interface, you need Define a couple of functions to `init` and `restore` the terminal state: -```rust +```rust title="src/tui.rs" {{#include @code/crates-tui-tutorial-app/src/tui.rs}} ``` @@ -21,7 +21,7 @@ as a TUI application. Just have to make sure we call `tui::restore()` at the end Let's update `main.rs` to the following: -```rust +```rust title="src/main.rs" mod crates_io_api_helper; mod tui; @@ -38,7 +38,7 @@ state. What happens if you comment out this line from `init`? -```rust +```rust title="src/tui.rs" // crossterm::execute!(std::io::stdout(), EnterAlternateScreen)?; ``` diff --git a/src/content/docs/tutorials/crates-tui/widgets.md b/src/content/docs/tutorials/crates-tui/widgets.md index 7f724e31d..73cd75fe1 100644 --- a/src/content/docs/tutorials/crates-tui/widgets.md +++ b/src/content/docs/tutorials/crates-tui/widgets.md @@ -6,7 +6,7 @@ In this section we will discuss implementing widgets. Create a new file `./src/widgets.rs` with the following content: -```rust +```rust title="src/widgets.rs" {{#include @code/crates-tui-tutorial-app/src/widgets.rs}} ``` From 6079ef4f804cf429691fe0cdfd9ece7ba1718137 Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy Date: Tue, 20 Feb 2024 23:16:49 -0500 Subject: [PATCH 3/5] feat: Add collapsed for reference code blocks --- .../docs/tutorials/crates-tui/app-async.md | 17 +++++++++++++++++ .../docs/tutorials/crates-tui/app-basics.md | 8 +------- .../docs/tutorials/crates-tui/app-mode.md | 8 +------- .../docs/tutorials/crates-tui/app-prototype.md | 11 +++-------- src/content/docs/tutorials/crates-tui/app.md | 8 ++------ .../crates-tui/crates-io-api-helper.md | 8 ++------ src/content/docs/tutorials/crates-tui/events.md | 16 ++++++++++------ src/content/docs/tutorials/crates-tui/prompt.md | 8 +------- .../docs/tutorials/crates-tui/results.md | 8 +------- src/content/docs/tutorials/crates-tui/search.md | 8 +------- 10 files changed, 39 insertions(+), 61 deletions(-) diff --git a/src/content/docs/tutorials/crates-tui/app-async.md b/src/content/docs/tutorials/crates-tui/app-async.md index 6eded5077..cc9439e66 100644 --- a/src/content/docs/tutorials/crates-tui/app-async.md +++ b/src/content/docs/tutorials/crates-tui/app-async.md @@ -122,3 +122,20 @@ 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}} ``` + +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 index 4c81f3a00..59afba517 100644 --- a/src/content/docs/tutorials/crates-tui/app-basics.md +++ b/src/content/docs/tutorials/crates-tui/app-basics.md @@ -100,11 +100,7 @@ struct AppWidget; Here's the full `./src/app.rs` file for your reference: -
- -Copy the following into src/app.rs - -```rust title="src/app.rs" +```rust collapsed title="src/app.rs (click to expand)" use color_eyre::eyre::Result; use ratatui::prelude::*; use ratatui::widgets::*; @@ -117,8 +113,6 @@ use crate::{ {{#include @code/crates-tui-tutorial-app/src/bin/part-app-basics.rs:full_app}} ``` -
- Now, run your application with a modified `main.rs` that uses the `App` struct you just created: ```rust title="src/main.rs" diff --git a/src/content/docs/tutorials/crates-tui/app-mode.md b/src/content/docs/tutorials/crates-tui/app-mode.md index 32702c400..f0e6bfbe3 100644 --- a/src/content/docs/tutorials/crates-tui/app-mode.md +++ b/src/content/docs/tutorials/crates-tui/app-mode.md @@ -76,11 +76,7 @@ If you run it, you should see something like this: Here's the full `./src/app.rs` file for your reference: -
- -Copy the following into src/app.rs - -```rust title="src/app.rs" +```rust collapsed title="src/app.rs (click to expand)" use color_eyre::eyre::Result; use itertools::Itertools; use ratatui::prelude::*; @@ -93,5 +89,3 @@ use crate::{ {{#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 index 9f3a3c0a4..9a9106eee 100644 --- a/src/content/docs/tutorials/crates-tui/app-prototype.md +++ b/src/content/docs/tutorials/crates-tui/app-prototype.md @@ -186,13 +186,10 @@ any child struct by sending an `Action` over `tx`. Here's the full `./src/app.rs` file for your reference: -
- -Copy the following into src/app.rs - -```rust title="src/app.rs" -use color_eyre::eyre::Result; +```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::*; @@ -204,8 +201,6 @@ use crate::{ {{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:full_app}} ``` -
- This is what our app currently looks like: ![](./crates-tui-tutorial-part-app-prototype.gif) diff --git a/src/content/docs/tutorials/crates-tui/app.md b/src/content/docs/tutorials/crates-tui/app.md index 1c9a80903..bf05e73d6 100644 --- a/src/content/docs/tutorials/crates-tui/app.md +++ b/src/content/docs/tutorials/crates-tui/app.md @@ -27,16 +27,12 @@ impl App { } ``` -
+Here's the full code for your reference: -Copy the following into src/app.rs - -```rust title="src/app.rs" +```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: ``` 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 index d7bb7e24e..d3498bb80 100644 --- a/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md +++ b/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md @@ -218,16 +218,12 @@ look like this: 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: -Here's the code in src/crates_io_api_helper.rs for your reference - -```rust title="src/crates_io_api_helper.rs" +```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" diff --git a/src/content/docs/tutorials/crates-tui/events.md b/src/content/docs/tutorials/crates-tui/events.md index e2e02c110..5729c1f58 100644 --- a/src/content/docs/tutorials/crates-tui/events.md +++ b/src/content/docs/tutorials/crates-tui/events.md @@ -12,6 +12,8 @@ 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" @@ -20,6 +22,8 @@ First, create a `Event` enum, like before: 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" @@ -28,12 +32,16 @@ Next create a `crossterm_stream` function: {{#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" @@ -45,15 +53,11 @@ the stream using `Events::next().await`. Here's the full `./src/events.rs` for your reference: -
- -Copy the following into src/events.rs - -```rust title="src/events.rs" +```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: diff --git a/src/content/docs/tutorials/crates-tui/prompt.md b/src/content/docs/tutorials/crates-tui/prompt.md index 2ac8860a6..df3ba3227 100644 --- a/src/content/docs/tutorials/crates-tui/prompt.md +++ b/src/content/docs/tutorials/crates-tui/prompt.md @@ -30,12 +30,6 @@ appropriately. Here's the full code for reference: -
- -Copy the following into src/widgets/search_prompt.rs - -```rust title="src/widgets/search_prompt.rs" +```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 index 154dbf15c..010de6df9 100644 --- a/src/content/docs/tutorials/crates-tui/results.md +++ b/src/content/docs/tutorials/crates-tui/results.md @@ -28,12 +28,6 @@ And the implementation of the stateful widget render looks like this: Here's the full code for reference: -
- -Copy the following into src/widgets/search_results.rs - -```rust title="src/widgets/search_results.rs" +```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/search.md b/src/content/docs/tutorials/crates-tui/search.md index 2acf01492..c41d71959 100644 --- a/src/content/docs/tutorials/crates-tui/search.md +++ b/src/content/docs/tutorials/crates-tui/search.md @@ -47,12 +47,6 @@ method on the `StatefulWidget` trait to render both the prompt and the results: Here is the search page widget in its entirety: -
- -Copy the following into src/widgets/search_page.rs - -```rust title="src/widgets/search_page.rs" +```rust collapsed title="src/widgets/search_page.rs (click to expand)" {{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs}} ``` - -
From be1cde9e815bd4b21f97e185cc05b4eb0258cea6 Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy Date: Wed, 21 Feb 2024 01:21:11 -0500 Subject: [PATCH 4/5] feat: Add subsection headings --- astro.config.mjs | 2 +- .../docs/tutorials/crates-tui/app-async.md | 14 ++++++++--- .../docs/tutorials/crates-tui/app-basics.md | 18 ++++++++++++- .../docs/tutorials/crates-tui/app-mode.md | 25 ++++++++++++++++--- src/content/docs/tutorials/crates-tui/app.md | 18 ++++++++++--- .../crates-tui/crates-io-api-helper.md | 20 +++++++++++++++ .../docs/tutorials/crates-tui/index.md | 20 +++++++-------- src/content/docs/tutorials/crates-tui/main.md | 2 ++ .../docs/tutorials/crates-tui/prompt.md | 4 +++ .../docs/tutorials/crates-tui/results.md | 4 +++ .../docs/tutorials/crates-tui/search.md | 4 +++ 11 files changed, 109 insertions(+), 22 deletions(-) diff --git a/astro.config.mjs b/astro.config.mjs index 163201685..0aec3a64b 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -159,7 +159,7 @@ export default defineConfig({ label: "App", collapsed: true, items: [ - { label: "App", link: "/tutorials/crates-tui/app-basics" }, + { 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" }, diff --git a/src/content/docs/tutorials/crates-tui/app-async.md b/src/content/docs/tutorials/crates-tui/app-async.md index cc9439e66..fcf39fb98 100644 --- a/src/content/docs/tutorials/crates-tui/app-async.md +++ b/src/content/docs/tutorials/crates-tui/app-async.md @@ -94,10 +94,12 @@ impl App { } ``` -## StatefulWidget +## Draw -Finally, you can update the render the methods in `App` to replace placeholder data with the data -from the results or the prompt value: +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 { @@ -107,6 +109,8 @@ impl App { 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" @@ -115,6 +119,8 @@ impl App { } ``` +### 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. @@ -123,6 +129,8 @@ the prompt `Rect`, which is only known during render. {{#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)" diff --git a/src/content/docs/tutorials/crates-tui/app-basics.md b/src/content/docs/tutorials/crates-tui/app-basics.md index 59afba517..9f9fed1a9 100644 --- a/src/content/docs/tutorials/crates-tui/app-basics.md +++ b/src/content/docs/tutorials/crates-tui/app-basics.md @@ -1,10 +1,12 @@ --- -title: App +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. 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" @@ -21,6 +23,10 @@ impl App { {{#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" @@ -36,6 +42,8 @@ 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: @@ -45,6 +53,8 @@ impl App { } ``` +### App::handle_event + This `run` method also uses a `handle_event` method that you can define like so: ```rust title="src/app.rs" @@ -53,6 +63,8 @@ impl App { } ``` +### App::draw + Finally, for the `draw` method, you could define it like this: ```rust title="src/app.rs" @@ -76,6 +88,8 @@ impl App { 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" @@ -113,6 +127,8 @@ use crate::{ {{#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" diff --git a/src/content/docs/tutorials/crates-tui/app-mode.md b/src/content/docs/tutorials/crates-tui/app-mode.md index f0e6bfbe3..766d517eb 100644 --- a/src/content/docs/tutorials/crates-tui/app-mode.md +++ b/src/content/docs/tutorials/crates-tui/app-mode.md @@ -4,12 +4,16 @@ 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, @@ -30,7 +34,10 @@ The reason you want to do this is because you may want to do different things wh 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. -Change the `handle_event` function to use the `Mode` to do different things when `Esc` is pressed: +## 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 { @@ -46,7 +53,13 @@ impl App { } ``` -Let's make our view a little more interesting with some placeholder text for the results: +## 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; @@ -56,7 +69,9 @@ impl App { } ``` -We will also make a prompt that changes border color based on the mode: +### Prompt + +For the prompt, make a `Block` that changes border color based on the mode: ```rust title="src/app.rs" impl App { @@ -64,12 +79,16 @@ impl App { } ``` +### 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) diff --git a/src/content/docs/tutorials/crates-tui/app.md b/src/content/docs/tutorials/crates-tui/app.md index bf05e73d6..4a227a675 100644 --- a/src/content/docs/tutorials/crates-tui/app.md +++ b/src/content/docs/tutorials/crates-tui/app.md @@ -2,6 +2,8 @@ title: App --- +## SearchPage + Finally, let's make a field in the app struct that uses the `SearchPage` widget: ```rust title="src/app.rs" @@ -19,6 +21,8 @@ impl App { } ``` +## SearchPageWidget + And rendering delegates to `SearchPageWidget`: ```rust title="src/app.rs" @@ -27,6 +31,8 @@ impl App { } ``` +## Conclusion + Here's the full code for your reference: ```rust collapsed title="src/app.rs (click to expand)" @@ -53,8 +59,14 @@ Your final folder structure will look like this: └── widgets.rs ``` -If you put all of it together, you should be able run the TUI. +If you put all of it together, you should be able run the TUI again: + +```bash +cargo run +``` + +:::note[Homework] -![](./crates-tui-demo.gif) +Search for your favorite crates and explore crates.io using what you built! -Search for your favorite crates and explore crates.io. +::: 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 index d3498bb80..a49baf3ae 100644 --- a/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md +++ b/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md @@ -9,6 +9,8 @@ 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. @@ -42,6 +44,8 @@ You can test this `async` function by running the following in the command line: $ 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. @@ -84,6 +88,8 @@ let email = std::env::var("CRATES_TUI_TUTORIAL_APP_MYEMAIL") ::: +## 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. @@ -98,6 +104,8 @@ We can build this `CratesQuery` object using the following parameters: - 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: @@ -113,6 +121,8 @@ This `crates` field will hold a clone of `Arc>>` 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" @@ -129,6 +139,8 @@ like so: // ... ``` +### Crates Query Builder + Construct the query using `crates_io_api`'s [`CratesQueryBuilder`]: [`CratesQueryBuilder`]: @@ -140,6 +152,8 @@ Construct the query using `crates_io_api`'s [`CratesQueryBuilder`]: // ... ``` +## Request Crates + Once you have created the `client` and `query`, you can call the `.crates()` method on the client and `await` the response. @@ -151,6 +165,8 @@ and `await` the 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: @@ -158,6 +174,8 @@ Clear the existing results in the `search_params.crates` field and update the {{#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)" @@ -230,6 +248,8 @@ With the refactor, your test code should look like this: {{#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: diff --git a/src/content/docs/tutorials/crates-tui/index.md b/src/content/docs/tutorials/crates-tui/index.md index af141e6cb..4c5d787f7 100644 --- a/src/content/docs/tutorials/crates-tui/index.md +++ b/src/content/docs/tutorials/crates-tui/index.md @@ -2,21 +2,19 @@ title: Crates TUI --- -In the previous tutorials, we were building a purely sequentially operational applications. However, -there are times when you may be interested in running IO operations or heavy computations in between -rendering frames. And when you do this, you don't want to block rendering. You can achieve a -consistent frame rate for rendering by running these blocking operations in a background thread or -task. +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 lead you through creating an `async` TUI app that lists crates from crates.io -based on a user search request in an `async` manner. - -![](./crates-tui-demo-1.png) - -This tutorial is a simplified version of the [crates-tui] application. +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: diff --git a/src/content/docs/tutorials/crates-tui/main.md b/src/content/docs/tutorials/crates-tui/main.md index 767166c65..2ca78820a 100644 --- a/src/content/docs/tutorials/crates-tui/main.md +++ b/src/content/docs/tutorials/crates-tui/main.md @@ -145,6 +145,8 @@ And when it is awaited on, it blocks the current thread. --> +## Conclusion + We will expand on `main.rs` in the following sections. Right now, your project should look like this: diff --git a/src/content/docs/tutorials/crates-tui/prompt.md b/src/content/docs/tutorials/crates-tui/prompt.md index df3ba3227..602ae2491 100644 --- a/src/content/docs/tutorials/crates-tui/prompt.md +++ b/src/content/docs/tutorials/crates-tui/prompt.md @@ -2,12 +2,16 @@ 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" diff --git a/src/content/docs/tutorials/crates-tui/results.md b/src/content/docs/tutorials/crates-tui/results.md index 010de6df9..e49ef3f80 100644 --- a/src/content/docs/tutorials/crates-tui/results.md +++ b/src/content/docs/tutorials/crates-tui/results.md @@ -2,6 +2,8 @@ title: Results --- +## Search Results State + Here is the search results state: ```rust title="src/widgets/search_results.rs" @@ -14,6 +16,8 @@ Here is the search results state: - description: `Option` - downloads: `u64` +## Search Results Widget + Here is the search results widget: ```rust title="src/widgets/search_results.rs" diff --git a/src/content/docs/tutorials/crates-tui/search.md b/src/content/docs/tutorials/crates-tui/search.md index c41d71959..a8a25e1b6 100644 --- a/src/content/docs/tutorials/crates-tui/search.md +++ b/src/content/docs/tutorials/crates-tui/search.md @@ -2,6 +2,8 @@ title: Search --- +## Search Page State + Create a new file, `./src/widgets/search_page.rs` with the following contents: ```rust title="src/widgets/search_page.rs" @@ -38,6 +40,8 @@ This struct also contains methods for managing the prompt state using `tui_input These methods are called from the `app` in the corresponding `Action`s. +## Search Page Widget + For the search page widget, create struct with just one field. You can then implement the render method on the `StatefulWidget` trait to render both the prompt and the results: From b77a2c20812babc3748e3e974b7db59c9c91245f Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy Date: Wed, 21 Feb 2024 07:40:06 -0500 Subject: [PATCH 5/5] feat: Remove search page widget --- astro.config.mjs | 1 - code/crates-tui-tutorial-app/src/app.rs | 82 +++++---- .../src/bin/part-helper.rs | 2 +- .../src/crates_io_api_helper.rs | 18 +- code/crates-tui-tutorial-app/src/widgets.rs | 2 +- .../src/widgets/search_page.rs | 160 ------------------ .../src/widgets/search_prompt.rs | 86 ++++++++-- .../src/widgets/search_results.rs | 58 ++++--- .../src/widgets/status_bar.rs | 47 +++++ .../docs/tutorials/crates-tui/app-basics.md | 2 +- .../tutorials/crates-tui/app-prototype.md | 9 +- .../crates-tui/crates-io-api-helper.md | 13 +- .../docs/tutorials/crates-tui/search.md | 56 ------ 13 files changed, 227 insertions(+), 309 deletions(-) delete mode 100644 code/crates-tui-tutorial-app/src/widgets/search_page.rs create mode 100644 code/crates-tui-tutorial-app/src/widgets/status_bar.rs delete mode 100644 src/content/docs/tutorials/crates-tui/search.md diff --git a/astro.config.mjs b/astro.config.mjs index 0aec3a64b..278df56d9 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -170,7 +170,6 @@ export default defineConfig({ collapsed: true, items: [ { label: "Widgets", link: "/tutorials/crates-tui/widgets" }, - { label: "Search", link: "/tutorials/crates-tui/search" }, { label: "Prompt", link: "/tutorials/crates-tui/prompt" }, { label: "Results", link: "/tutorials/crates-tui/results" }, { label: "App", link: "/tutorials/crates-tui/app" }, diff --git a/code/crates-tui-tutorial-app/src/app.rs b/code/crates-tui-tutorial-app/src/app.rs index fafd08220..cd9873946 100644 --- a/code/crates-tui-tutorial-app/src/app.rs +++ b/code/crates-tui-tutorial-app/src/app.rs @@ -1,15 +1,21 @@ +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::*, widgets::Paragraph}; +use ratatui::prelude::*; // ANCHOR: imports_external // ANCHOR: imports_core use crate::{ events::{Event, Events}, tui::Tui, - widgets::{search_page::SearchPage, search_page::SearchPageWidget}, + widgets::{ + search_prompt::{SearchPrompt, SearchPromptWidget}, + search_results::{SearchResults, SearchResultsWidget}, + status_bar::{StatusBar, StatusBarWidget}, + }, }; // ANCHOR_END: imports_core // ANCHOR_END: imports_all @@ -63,30 +69,39 @@ pub enum Action { #[derive(Debug)] pub struct App { quit: bool, - last_key_event: Option, mode: Mode, rx: tokio::sync::mpsc::UnboundedReceiver, tx: tokio::sync::mpsc::UnboundedSender, - search_page: SearchPage, // new + 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 search_page = SearchPage::new(tx.clone()); + 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; - let last_key_event = None; Self { quit, - last_key_event, mode, rx, tx, - search_page, + status_bar, + results, + prompt, } } // ANCHOR_END: app_new @@ -120,7 +135,7 @@ impl App { 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.status_bar.last_key_event = Some(key); self.handle_key(key) }; Ok(()) @@ -131,7 +146,7 @@ impl App { 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.search_page.handle_key(key); + self.prompt.handle_key(key); } maybe_action.map(|action| self.tx.send(action)); } @@ -142,12 +157,13 @@ impl App { match action { Action::Quit => self.quit(), Action::SwitchMode(mode) => self.switch_mode(mode), - Action::ScrollUp => self.search_page.scroll_up(), - Action::ScrollDown => self.search_page.scroll_down(), - Action::SubmitSearchQuery => self.search_page.submit_query(), - Action::UpdateSearchResults => { - self.search_page.update_search_results() + 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(()) } @@ -183,15 +199,18 @@ impl App { self.quit } + // ANCHOR: loading fn set_cursor(&mut self, frame: &mut Frame<'_>) { if matches!(self.mode, Mode::Prompt) { - if let Some(cursor_position) = self.search_page.cursor_position() { + 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 @@ -201,24 +220,25 @@ impl StatefulWidget for AppWidget { type State = App; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let [status_bar, search_page] = - Layout::vertical([Constraint::Length(1), Constraint::Fill(0)]) - .areas(area); - - if let Some(key) = state.last_key_event { - Paragraph::new(format!("last key event: {:?}", key.code)) - .right_aligned() - .render(status_bar, buf); - } + let [status_bar, main, prompt] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(PROMPT_HEIGHT), + ]) + .areas(area); - if state.search_page.loading() { - Line::from("Loading...").render(status_bar, buf); - } + StatusBarWidget.render(status_bar, buf, &mut state.status_bar); + + SearchResultsWidget::new(matches!(state.mode, Mode::Results)).render( + main, + buf, + &mut state.results, + ); - SearchPageWidget { mode: state.mode }.render( - search_page, + SearchPromptWidget::new(matches!(state.mode, Mode::Prompt)).render( + prompt, buf, - &mut state.search_page, + &mut state.prompt, ); } } diff --git a/code/crates-tui-tutorial-app/src/bin/part-helper.rs b/code/crates-tui-tutorial-app/src/bin/part-helper.rs index db6ac2dc2..503698525 100644 --- a/code/crates-tui-tutorial-app/src/bin/part-helper.rs +++ b/code/crates-tui-tutorial-app/src/bin/part-helper.rs @@ -45,7 +45,7 @@ impl SearchParameters { SearchParameters { search, page: 1, - page_size: 3, // TODO: set it to 100 later + page_size: 100, sort: crates_io_api::Sort::Relevance, crates, } 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 index 1677f69e4..1a0404525 100644 --- a/code/crates-tui-tutorial-app/src/crates_io_api_helper.rs +++ b/code/crates-tui-tutorial-app/src/crates_io_api_helper.rs @@ -1,10 +1,7 @@ use std::sync::{Arc, Mutex}; -use crates_io_api::CratesQuery; -use tokio::sync::mpsc::UnboundedSender; - -use crate::app::Action; use color_eyre::{eyre::Context, Result}; +use crates_io_api::CratesQuery; // ANCHOR: search_parameters /// Represents the parameters needed for fetching crates asynchronously. @@ -19,7 +16,6 @@ pub struct SearchParameters { pub crates: Arc>>, // Additional - pub tx: Option>, pub fake_delay: u64, } // ANCHOR_END: search_parameters @@ -28,7 +24,6 @@ impl SearchParameters { pub fn new( search: String, crates: Arc>>, - tx: Option>, ) -> SearchParameters { SearchParameters { search, @@ -36,7 +31,6 @@ impl SearchParameters { page_size: 100, sort: crates_io_api::Sort::Relevance, crates, - tx, fake_delay: 0, } } @@ -106,14 +100,6 @@ fn update_state_with_fetched_crates( let mut app_crates = params.crates.lock().unwrap(); app_crates.clear(); app_crates.extend(crates); - - // After a successful fetch, send relevant actions based on the result - if !app_crates.is_empty() { - let _ = params - .tx - .clone() - .map(|tx| tx.send(Action::UpdateSearchResults)); - } } // ANCHOR: test @@ -127,7 +113,7 @@ mod tests { let crates: Arc>> = Default::default(); let search_params = - SearchParameters::new("ratatui".into(), crates.clone(), None); + SearchParameters::new("ratatui".into(), crates.clone()); tokio::spawn(async move { let _ = request_search_results(&search_params).await; diff --git a/code/crates-tui-tutorial-app/src/widgets.rs b/code/crates-tui-tutorial-app/src/widgets.rs index 8433a2dc3..d120db602 100644 --- a/code/crates-tui-tutorial-app/src/widgets.rs +++ b/code/crates-tui-tutorial-app/src/widgets.rs @@ -1,3 +1,3 @@ -pub mod search_page; pub mod search_prompt; pub mod search_results; +pub mod status_bar; diff --git a/code/crates-tui-tutorial-app/src/widgets/search_page.rs b/code/crates-tui-tutorial-app/src/widgets/search_page.rs deleted file mode 100644 index 8576714b1..000000000 --- a/code/crates-tui-tutorial-app/src/widgets/search_page.rs +++ /dev/null @@ -1,160 +0,0 @@ -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Mutex, -}; - -use crossterm::event::{Event as CrosstermEvent, KeyEvent}; -use itertools::Itertools; -use ratatui::{ - layout::{Constraint, Layout, Position}, - widgets::StatefulWidget, -}; -use tokio::sync::mpsc::UnboundedSender; -use tui_input::backend::crossterm::EventHandler; - -use crate::{ - app::{Action, Mode}, - crates_io_api_helper, - widgets::{search_prompt::SearchPrompt, search_results::SearchResults}, -}; - -use super::{ - search_prompt::SearchPromptWidget, search_results::SearchResultsWidget, -}; - -// ANCHOR: search_page -#[derive(Debug)] -pub struct SearchPage { - pub results: SearchResults, - pub prompt: SearchPrompt, - - pub page: u64, - pub page_size: u64, - pub sort: crates_io_api::Sort, - pub crates: Arc>>, - pub loading_status: Arc, - tx: UnboundedSender, -} -// ANCHOR_END: search_page - -impl SearchPage { - pub fn new(tx: UnboundedSender) -> Self { - let loading_status = Arc::new(AtomicBool::default()); - Self { - results: SearchResults::default(), - prompt: SearchPrompt::default(), - page: 1, - page_size: 25, - sort: crates_io_api::Sort::Relevance, - crates: Default::default(), - tx, - loading_status, - } - } - - pub fn scroll_up(&mut self) { - self.results.scroll_previous(); - } - - pub fn scroll_down(&mut self) { - self.results.scroll_next(); - } - - pub fn loading(&self) -> bool { - self.loading_status.load(Ordering::SeqCst) - } - - // ANCHOR: prompt_methods - pub fn handle_key(&mut self, key: KeyEvent) { - self.prompt.input.handle_event(&CrosstermEvent::Key(key)); - } - - pub fn cursor_position(&self) -> Option { - self.prompt.cursor_position - } - // ANCHOR_END: prompt_methods - - pub fn update_search_results(&mut self) { - self.results.select(None); - let crates: Vec<_> = - self.crates.lock().unwrap().iter().cloned().collect_vec(); - self.results.crates = crates; - self.results.content_length(self.results.crates.len()); - self.scroll_down(); - } - - // ANCHOR: submit - pub fn submit_query(&mut self) { - let _ = self.tx.send(Action::SwitchMode(Mode::Results)); - self.prepare_request(); - self.request_search_results(); - } - - pub fn prepare_request(&mut self) { - self.results.select(None); - } - - // ANCHOR: create_search_parameters - pub fn create_search_parameters( - &self, - ) -> crates_io_api_helper::SearchParameters { - crates_io_api_helper::SearchParameters::new( - self.prompt.input.value().into(), - self.crates.clone(), - Some(self.tx.clone()), - ) - } - // ANCHOR_END: create_search_parameters - - // ANCHOR: request_search_results - pub fn request_search_results(&self) { - let loading_status = self.loading_status.clone(); - let mut params = self.create_search_parameters(); - params.fake_delay = 5; - tokio::spawn(async move { - loading_status.store(true, Ordering::SeqCst); - let _ = crates_io_api_helper::request_search_results(¶ms).await; - loading_status.store(false, Ordering::SeqCst); - }); - } - // ANCHOR_END: request_search_results - - // ANCHOR_END: submit -} - -// ANCHOR: search_page_widget -pub struct SearchPageWidget { - pub mode: Mode, -} - -impl StatefulWidget for SearchPageWidget { - type State = SearchPage; - - fn render( - self, - area: ratatui::prelude::Rect, - buf: &mut ratatui::prelude::Buffer, - state: &mut Self::State, - ) { - let prompt_height = 5; - - let [main, prompt] = Layout::vertical([ - Constraint::Min(0), - Constraint::Length(prompt_height), - ]) - .areas(area); - - SearchResultsWidget::new(matches!(self.mode, Mode::Results)).render( - main, - buf, - &mut state.results, - ); - - SearchPromptWidget::new(self.mode, state.sort.clone()).render( - prompt, - buf, - &mut state.prompt, - ); - } -} -// ANCHOR_END: search_page_widget diff --git a/code/crates-tui-tutorial-app/src/widgets/search_prompt.rs b/code/crates-tui-tutorial-app/src/widgets/search_prompt.rs index 776b28f53..3368425e6 100644 --- a/code/crates-tui-tutorial-app/src/widgets/search_prompt.rs +++ b/code/crates-tui-tutorial-app/src/widgets/search_prompt.rs @@ -1,29 +1,93 @@ +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; + use ratatui::{layout::Position, prelude::*, widgets::*}; +use tui_input::backend::crossterm::EventHandler; -use crate::app::Mode; +use crate::app::{Action, Mode}; // ANCHOR: state -#[derive(Default, Debug, Clone)] +#[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 { - mode: Mode, - sort: crates_io_api::Sort, + focused: bool, } // ANCHOR_END: widget impl SearchPromptWidget { - pub fn new(mode: Mode, sort: crates_io_api::Sort) -> Self { - Self { mode, sort } + pub fn new(focused: bool) -> Self { + Self { focused } } fn border(&self) -> Block { - let color = if matches!(self.mode, Mode::Prompt) { + let color = if self.focused { Color::Yellow } else { Color::Black @@ -31,10 +95,10 @@ impl SearchPromptWidget { Block::default().borders(Borders::ALL).border_style(color) } - fn sort_by_text(&self) -> impl Widget { + fn sort_by_text(&self, sort: crates_io_api::Sort) -> impl Widget { Paragraph::new(Line::from(vec![ "Sort By: ".into(), - format!("{:?}", self.sort.clone()).fg(Color::Blue), + format!("{:?}", sort).fg(Color::Blue), ])) .right_aligned() } @@ -50,7 +114,7 @@ impl SearchPromptWidget { } fn calculate_cursor_position(&self, area: Rect, state: &mut SearchPrompt) { - if matches!(self.mode, Mode::Prompt) { + 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( @@ -73,7 +137,7 @@ impl StatefulWidget for SearchPromptWidget { Layout::horizontal([Constraint::Fill(0), Constraint::Length(25)]) .areas(area); - self.sort_by_text() + 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); diff --git a/code/crates-tui-tutorial-app/src/widgets/search_results.rs b/code/crates-tui-tutorial-app/src/widgets/search_results.rs index 78f714303..0b62fd133 100644 --- a/code/crates-tui-tutorial-app/src/widgets/search_results.rs +++ b/code/crates-tui-tutorial-app/src/widgets/search_results.rs @@ -1,3 +1,5 @@ +use std::sync::{Arc, Mutex}; + use crates_io_api::Crate; use itertools::Itertools; use ratatui::{prelude::*, widgets::*}; @@ -5,19 +7,30 @@ use ratatui::{prelude::*, widgets::*}; // ANCHOR: state #[derive(Debug, Default)] pub struct SearchResults { - pub crates: Vec, + pub crates: Arc>>, pub table_state: TableState, pub scrollbar_state: ScrollbarState, } -// ANCHOR_END: state -const TABLE_HEADER_HEIGHT: u16 = 2; -const COLUMN_SPACING: u16 = 3; -const ROW_HEIGHT: u16 = 2; +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.iter().map(row_from_crate).collect_vec() + self.crates + .lock() + .unwrap() + .iter() + .map(row_from_crate) + .collect_vec() } fn header(&self) -> Row<'static> { @@ -26,20 +39,14 @@ impl SearchResults { .map(vertical_pad); Row::new(header_cells).height(TABLE_HEADER_HEIGHT) } -} -impl SearchResults { - pub fn content_length(&mut self, content_length: usize) { - self.scrollbar_state = - self.scrollbar_state.content_length(content_length) - } - - pub fn select(&mut self, index: Option) { - self.table_state.select(index) + pub fn clear_selection(&mut self) { + self.table_state.select(None) } + // ANCHOR: scroll pub fn scroll_next(&mut self) { - let wrap_index = self.crates.len().max(1); + let wrap_index = self.crates.lock().unwrap().len().max(1); let next = self .table_state .selected() @@ -48,8 +55,8 @@ impl SearchResults { } pub fn scroll_previous(&mut self) { - let last = self.crates.len().saturating_sub(1); - let wrap_index = self.crates.len().max(1); + 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() @@ -58,15 +65,28 @@ impl SearchResults { } fn scroll_to(&mut self, index: usize) { - if self.crates.is_empty() { + 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, 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/crates-tui/app-basics.md b/src/content/docs/tutorials/crates-tui/app-basics.md index 9f9fed1a9..1e70b9501 100644 --- a/src/content/docs/tutorials/crates-tui/app-basics.md +++ b/src/content/docs/tutorials/crates-tui/app-basics.md @@ -3,7 +3,7 @@ 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. We are going to move the event loop into a method on the `App` struct. +to scale up in the future. We are going to move the event loop into a method on the `App` struct. ## App diff --git a/src/content/docs/tutorials/crates-tui/app-prototype.md b/src/content/docs/tutorials/crates-tui/app-prototype.md index 9a9106eee..6e5a71935 100644 --- a/src/content/docs/tutorials/crates-tui/app-prototype.md +++ b/src/content/docs/tutorials/crates-tui/app-prototype.md @@ -201,14 +201,15 @@ use crate::{ {{#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) -## Conclusion - -With everything in one file, the `App` struct can get a little unwieldy. Moreover, if we want to add -more features or more widgets, this approach isn't going to scale very well. +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. 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 index a49baf3ae..e8eb99571 100644 --- a/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md +++ b/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md @@ -195,7 +195,7 @@ Run the test again now: $ cargo test -- crates_io_api_helper::tests::test_crates_io --nocapture ``` -You should get results like so: +You should get results like below (only the first three results are shown here for brevity): ```plain running 1 test @@ -213,18 +213,15 @@ 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 ``` -:::note - -We set the `page_size` to `3` for testing purposes in the constructor for `SearchParameters`. Change -that to the maximum value of `100`. - -::: - ## Refactor You may want to refactor the above code into separate functions for simplicity. If you do so, it'll diff --git a/src/content/docs/tutorials/crates-tui/search.md b/src/content/docs/tutorials/crates-tui/search.md deleted file mode 100644 index a8a25e1b6..000000000 --- a/src/content/docs/tutorials/crates-tui/search.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: Search ---- - -## Search Page State - -Create a new file, `./src/widgets/search_page.rs` with the following contents: - -```rust title="src/widgets/search_page.rs" -{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:search_page}} -``` - -This struct represents the `State` in the `StatefulWidget` pattern. This struct contains two nested -children fields, `results` and `prompt` that contain the state of the respective views. - -Create the search parameters struct like so: - -```rust title="src/widgets/search_page.rs" -{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:create_search_parameters}} -``` - -and spawn a tokio task to make request like so: - -```rust title="src/widgets/search_page.rs" -{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:request_search_results}} -``` - -:::note - -This method spawns a tokio task and returns immediately, i.e. does not block. This method is not an -`async` method but spawns an async background task. - -::: - -This struct also contains methods for managing the prompt state using `tui_input`: - -```rust title="src/widgets/search_page.rs" -{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:prompt_methods}} -``` - -These methods are called from the `app` in the corresponding `Action`s. - -## Search Page Widget - -For the search page widget, create struct with just one field. You can then implement the render -method on the `StatefulWidget` trait to render both the prompt and the results: - -```rust title="src/widgets/search_page.rs" -{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs:search_page_widget}} -``` - -Here is the search page widget in its entirety: - -```rust collapsed title="src/widgets/search_page.rs (click to expand)" -{{#include @code/crates-tui-tutorial-app/src/widgets/search_page.rs}} -```