From 2c518f7ac60dd5d71ac72fe6f85d323b1fc4a45b Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy Date: Sun, 11 Feb 2024 07:05:55 -0500 Subject: [PATCH] feat: Add crates-tui tutorial --- Cargo.lock | 973 ++++++++++++++---- astro.config.mjs | 44 +- 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 | 216 ++++ .../src/bin/part-app-channel.rs | 240 +++++ .../src/bin/part-app-mode.rs | 183 ++++ .../src/bin/part-app-prototype.rs | 452 ++++++++ .../src/bin/part-app.rs | 121 +++ .../src/bin/part-errors.rs | 24 + .../src/bin/part-events.rs | 43 + .../src/bin/part-final.rs | 18 + .../src/bin/part-helper.rs | 160 +++ .../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/crates_io_api_helper.rs | 150 +++ 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 | 166 +++ .../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-basics.md | 146 +++ .../docs/tutorials/crates-tui/app-channels.md | 213 ++++ .../docs/tutorials/crates-tui/app-mode.md | 89 ++ .../tutorials/crates-tui/app-prototype.md | 70 ++ .../docs/tutorials/crates-tui/conclusion.md | 15 + .../crates-tui/crates-io-api-helper.md | 181 ++++ .../crates-tui/crates-tui-demo-1.png | 3 + .../crates-tui/crates-tui-demo-2.png | 3 + .../tutorials/crates-tui/crates-tui-demo.gif | 3 + .../docs/tutorials/crates-tui/errors.md | 75 ++ .../docs/tutorials/crates-tui/events.md | 92 ++ .../docs/tutorials/crates-tui/index.md | 58 ++ src/content/docs/tutorials/crates-tui/main.md | 134 +++ .../docs/tutorials/crates-tui/prompt.md | 41 + .../docs/tutorials/crates-tui/results.md | 39 + .../docs/tutorials/crates-tui/search.md | 59 ++ src/content/docs/tutorials/crates-tui/tui.md | 48 + .../docs/tutorials/crates-tui/widgets.md | 34 + src/content/docs/tutorials/index.md | 2 - 57 files changed, 4446 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/part-app-channel.rs 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-prototype.rs create mode 100644 code/crates-tui-tutorial-app/src/bin/part-app.rs 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-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/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-basics.md create mode 100644 src/content/docs/tutorials/crates-tui/app-channels.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/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/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 52868c428..a64bd08dc 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", prefetch: { prefetchAll: true, @@ -127,21 +135,39 @@ export default defineConfig({ ], }, { - label: "Async Counter App", + label: "Crates TUI App", collapsed: true, items: [ - { label: "Async Counter App", link: "/tutorials/counter-async-app/" }, + { label: "Crates TUI", link: "/tutorials/crates-tui/" }, + { label: "Main", link: "/tutorials/crates-tui/main" }, { - label: "Async KeyEvents", - link: "/tutorials/counter-async-app/async-event-stream/", + label: "Helper", + link: "/tutorials/crates-tui/crates-io-api-helper", }, - { label: "Async Render", link: "/tutorials/counter-async-app/full-async-events/" }, - { label: "Introducing Actions", link: "/tutorials/counter-async-app/actions/" }, + { label: "Tui", link: "/tutorials/crates-tui/tui" }, + { label: "Errors", link: "/tutorials/crates-tui/errors" }, + { label: "Events", link: "/tutorials/crates-tui/events" }, { - label: "Async Actions", - link: "/tutorials/counter-async-app/full-async-actions/", + label: "App", + collapsed: true, + items: [ + { label: "App", link: "/tutorials/crates-tui/app-basics" }, + { label: "App Mode", link: "/tutorials/crates-tui/app-mode" }, + { label: "App Channels", link: "/tutorials/crates-tui/app-channels" }, + { 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: "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..31f2aa004 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/app.rs @@ -0,0 +1,216 @@ +use color_eyre::eyre::Result; +use crossterm::event::KeyEvent; +use ratatui::prelude::*; + +use crate::{ + events::{Event, Events}, + tui::Tui, + widgets::{search_page::SearchPage, search_page::SearchPageWidget}, +}; + +// 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 { + Render, + Quit, + SwitchMode(Mode), + ScrollDown, + ScrollUp, + SubmitSearchQuery, + UpdateSearchResults, +} +// ANCHOR_END: action + +// ANCHOR: app_widget +struct AppWidget; +// ANCHOR_END: app_widget + +// ANCHOR: app +#[derive(Debug)] +pub struct App { + rx: tokio::sync::mpsc::UnboundedReceiver, + tx: tokio::sync::mpsc::UnboundedSender, + mode: Mode, + quit: bool, + search_page: SearchPage, +} +// 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; + Self { + rx, + tx, + mode, + search_page, + quit, + } + } + // 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)?; + } + while let Ok(action) = self.rx.try_recv() { + self.handle_action(action.clone(), &mut tui)?; + } + 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; + let maybe_action = match e { + Event::Render => Some(Action::Render), + Event::Crossterm(CrosstermEvent::Key(key)) => self.handle_key(key), + _ => None, + }; + maybe_action.map(|action| self.tx.send(action)); + Ok(()) + } + // ANCHOR_END: app_handle_event + + // ANCHOR: app_handle_key_event + fn handle_key(&mut self, key: KeyEvent) -> Option { + 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 + } + // ANCHOR_END: app_handle_key_event + + // ANCHOR: app_handle_action + fn handle_action(&mut self, action: Action, tui: &mut Tui) -> Result<()> { + match action { + Action::Render => self.draw(tui)?, + 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 +} + +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.update_cursor(frame); + })?; + Ok(()) + } + // ANCHOR_END: app_draw + + fn quit(&mut self) { + self.quit = true + } + + fn scroll_up(&mut self) { + self.search_page.scroll_up() + } + + fn scroll_down(&mut self) { + self.search_page.scroll_down() + } + + fn switch_mode(&mut self, mode: Mode) { + self.mode = mode; + } + + fn submit_search_query(&mut self) { + self.switch_mode(Mode::Results); + self.search_page.submit_query() + } + + fn update_search_results(&mut self) { + self.search_page.update_search_results(); + let _ = self.tx.send(Action::ScrollDown); + } + + fn should_quit(&self) -> bool { + self.quit + } + + fn update_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_statefulwidget +impl StatefulWidget for AppWidget { + type State = App; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + SearchPageWidget { mode: state.mode }.render( + area, + buf, + &mut state.search_page, + ); + } +} +// ANCHOR_END: app_statefulwidget diff --git a/code/crates-tui-tutorial-app/src/bin/part-app-channel.rs b/code/crates-tui-tutorial-app/src/bin/part-app-channel.rs new file mode 100644 index 000000000..edffe44cc --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app-channel.rs @@ -0,0 +1,240 @@ +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: 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 { + Render, + Quit, + SwitchMode(Mode), + ScrollDown, + ScrollUp, + SubmitSearchQuery, + UpdateSearchResults, +} +// ANCHOR_END: action + +// ANCHOR: app +pub struct App { + quit: bool, + frame_count: usize, + mode: Mode, + tx: tokio::sync::mpsc::UnboundedSender, + rx: tokio::sync::mpsc::UnboundedReceiver, +} +// ANCHOR_END: app + +impl App { + // ANCHOR: app_new + pub fn new() -> Self { + let quit = false; + let frame_count = 0; + let mode = Mode::default(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + Self { + quit, + frame_count, + mode, + tx, + rx, + } + } + // 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)? + } + while let Ok(action) = self.rx.try_recv() { + self.handle_action(action.clone(), &mut tui)?; + } + 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; + let maybe_action = match e { + Event::Render => Some(Action::Render), + Event::Crossterm(CrosstermEvent::Key(key)) => { + self.mode.handle_key(key) + } + _ => None, + }; + maybe_action.map(|action| self.tx.send(action)); + Ok(()) + } + // ANCHOR_END: app_handle_event + + // ANCHOR: app_handle_action + fn handle_action(&mut self, action: Action, tui: &mut Tui) -> Result<()> { + match action { + Action::Render => self.draw(tui)?, + 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| { + self.frame_count = frame.count(); + 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 [results, prompt] = + Layout::vertical([Constraint::Fill(0), Constraint::Length(5)]) + .areas(area); + + 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(); + + let table = Table::new(rows, widths).header(Row::new(vec![ + "Name", + "Description", + "Downloads", + ])); + Widget::render(table, results, buf); + + let color = if matches!(state.mode, Mode::Prompt) { + Color::Yellow + } else { + Color::Black + }; + Block::default() + .borders(Borders::ALL) + .border_style(color) + .render(prompt, 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-mode.rs b/code/crates-tui-tutorial-app/src/bin/part-app-mode.rs new file mode 100644 index 000000000..2d9115b87 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app-mode.rs @@ -0,0 +1,183 @@ +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, + frame_count: usize, + mode: Mode, +} +// ANCHOR_END: app + +impl App { + // ANCHOR: app_new + pub fn new() -> Self { + let quit = false; + let frame_count = 0; + let mode = Mode::default(); + Self { + quit, + frame_count, + mode, + } + } + // 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::Esc; + match e { + Event::Crossterm(CrosstermEvent::Key(key)) if key.code == 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| { + self.frame_count = frame.count(); + 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 [results, prompt] = + Layout::vertical([Constraint::Fill(0), Constraint::Length(5)]) + .areas(area); + + 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(); + + let table = Table::new(rows, widths).header(Row::new(vec![ + "Name", + "Description", + "Downloads", + ])); + Widget::render(table, results, buf); + + let color = if matches!(state.mode, Mode::Prompt) { + Color::Yellow + } else { + Color::Black + }; + Block::default() + .borders(Borders::ALL) + .border_style(color) + .render(prompt, 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-prototype.rs b/code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs new file mode 100644 index 000000000..fe5ebd4a9 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs @@ -0,0 +1,452 @@ +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>>, + + // Additional + pub fake_delay: u64, +} +// ANCHOR_END: search_parameters + +impl SearchParameters { + pub fn new( + search: String, + crates: Arc>>, + ) -> SearchParameters { + SearchParameters { + search, + page: 1, + page_size: 100, + sort: crates_io_api::Sort::Relevance, + crates, + fake_delay: 0, + } + } +} + +// ANCHOR: request_search_results +/// Performs the actual search, and sends the result back through the +/// sender. +pub async fn request_search_results( + params: &SearchParameters, +) -> Result<(), String> { + let client = create_client()?; + let query = create_query(params); + let crates = fetch_crates_and_metadata(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_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 { + Render, + Quit, + SwitchMode(Mode), + ScrollDown, + ScrollUp, + SubmitSearchQuery, + UpdateSearchResults, +} +// ANCHOR_END: action + +// ANCHOR: app +pub struct App { + quit: bool, + frame_count: usize, + mode: Mode, + tx: tokio::sync::mpsc::UnboundedSender, + rx: tokio::sync::mpsc::UnboundedReceiver, + crates: Arc>>, + table_state: TableState, + prompt: tui_input::Input, + cursor_position: Option, +} +// ANCHOR_END: app + +impl App { + // ANCHOR: app_new + pub fn new() -> Self { + let quit = false; + let frame_count = 0; + 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; + Self { + quit, + frame_count, + mode, + 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 { + self.handle_event(e)? + } + while let Ok(action) = self.rx.try_recv() { + self.handle_action(action.clone(), &mut tui)?; + } + 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; + let maybe_action = match e { + Event::Render => Some(Action::Render), + Event::Crossterm(CrosstermEvent::Key(key)) => self.handle_key(key), + _ => None, + }; + maybe_action.map(|action| self.tx.send(action)); + Ok(()) + } + // ANCHOR_END: app_handle_event + + // ANCHOR: app_handle_key_event + fn handle_key( + &mut self, + key: crossterm::event::KeyEvent, + ) -> Option { + 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 + } + // ANCHOR_END: app_handle_key_event + + // ANCHOR: app_handle_action + fn handle_action(&mut self, action: Action, tui: &mut Tui) -> Result<()> { + match action { + Action::Render => self.draw(tui)?, + 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| { + self.frame_count = frame.count(); + frame.render_stateful_widget(AppWidget, frame.size(), self); + self.update_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(), + ); + tokio::spawn(async move { + let _ = request_search_results(¶ms).await; + }); + self.switch_mode(Mode::Results); + } + // ANCHOR_END: app_submit_search_query + + fn update_search_results(&mut self) { + let _ = self.tx.send(Action::ScrollDown); + } + + fn update_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 [results, prompt] = + Layout::vertical([Constraint::Fill(0), Constraint::Length(5)]) + .areas(area); + + let widths = [ + Constraint::Length(15), + Constraint::Min(0), + Constraint::Length(20), + ]; + + let crates = state.crates.lock().unwrap(); + + // ANCHOR: render_rows + 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(); + // ANCHOR_END: render_rows + + let table = Table::new(rows, widths).header(Row::new(vec![ + "Name", + "Description", + "Downloads", + ])); + Widget::render(table, results, buf); + + let color = if matches!(state.mode, Mode::Prompt) { + Color::Yellow + } else { + Color::Black + }; + Block::default() + .borders(Borders::ALL) + .border_style(color) + .render(prompt, buf); + + // ANCHOR: render_prompt + Paragraph::new(state.prompt.value()).render( + prompt.inner(&Margin { + horizontal: 2, + vertical: 2, + }), + buf, + ); + // ANCHOR_END: render_prompt + + // ANCHOR: render_cursor + if matches!(state.mode, Mode::Prompt) { + let margin = (2, 2); + let width = (prompt.width as f64 as u16).saturating_sub(margin.0); + state.cursor_position = Some(Position::new( + (prompt.x + margin.0 + state.prompt.cursor() as u16).min(width), + prompt.y + margin.1, + )); + } else { + state.cursor_position = None + } + // ANCHOR_END: render_cursor + } +} +// 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.rs b/code/crates-tui-tutorial-app/src/bin/part-app.rs new file mode 100644 index 000000000..a5f78ac87 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-app.rs @@ -0,0 +1,121 @@ +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, +} +// ANCHOR_END: app + +impl App { + // ANCHOR: app_new + pub fn new() -> Self { + let quit = false; + let frame_count = 0; + Self { quit, frame_count } + } + // 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::Esc; + match e { + Event::Crossterm(CrosstermEvent::Key(key)) if key.code == 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) + } +} +// 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-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-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..18e364acb --- /dev/null +++ b/code/crates-tui-tutorial-app/src/bin/part-helper.rs @@ -0,0 +1,160 @@ +// 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 crates_io_api::CratesQuery; +use std::sync::{Arc, Mutex}; + +// ANCHOR: search_parameters +/// Represents the parameters needed for fetching crates asynchronously. +pub struct SearchParameters { + // Request + pub search: String, + pub page: u64, + pub page_size: u64, + pub sort: crates_io_api::Sort, + + // Response + pub crates: Arc>>, + + // Additional + pub fake_delay: u64, +} +// ANCHOR_END: search_parameters + +impl SearchParameters { + pub fn new( + search: String, + crates: Arc>>, + ) -> SearchParameters { + SearchParameters { + search, + page: 1, + page_size: 3, + sort: crates_io_api::Sort::Relevance, + crates, + fake_delay: 0, + } + } +} + +// ANCHOR: request_search_results +/// Performs the actual search, and sends the result back through the +/// sender. +pub async fn request_search_results( + params: &SearchParameters, +) -> Result<(), String> { + let client = create_client()?; + let query = create_query(params); + let crates = fetch_crates_and_metadata(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_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:#?}"))?; + // ANCHOR: crates_response + let crates = page_result.crates; + // ANCHOR_END: crates_response + Ok(crates) +} + +/// Handles the result after fetching crates and sending corresponding +/// actions. +fn update_state_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/crates_io_api_helper.rs b/code/crates-tui-tutorial-app/src/crates_io_api_helper.rs new file mode 100644 index 000000000..007819af9 --- /dev/null +++ b/code/crates-tui-tutorial-app/src/crates_io_api_helper.rs @@ -0,0 +1,150 @@ +use std::sync::{atomic::AtomicBool, 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 loading_status: Arc, + pub tx: Option>, + pub fake_delay: u64, +} +// ANCHOR_END: search_parameters + +impl SearchParameters { + pub fn new( + search: String, + crates: Arc>>, + ) -> SearchParameters { + SearchParameters { + search, + page: 1, + page_size: 3, + sort: crates_io_api::Sort::Relevance, + crates, + loading_status: Default::default(), + tx: None, + 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_and_metadata(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_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, +) { + 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()); + + 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..bd13d8d6c --- /dev/null +++ b/code/crates-tui-tutorial-app/src/widgets/search_page.rs @@ -0,0 +1,166 @@ +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; + +use crossterm::event::{Event as CrosstermEvent, KeyEvent}; +use itertools::Itertools; +use ratatui::{ + layout::{Constraint, Layout, Position}, + text::Line, + widgets::{StatefulWidget, Widget}, +}; +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()); + } + + // ANCHOR: submit + pub fn submit_query(&mut self) { + 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 { + search: self.prompt.input.value().into(), + page: self.page.clamp(1, u64::MAX), + page_size: self.page_size, + sort: self.sort.clone(), + crates: self.crates.clone(), + loading_status: self.loading_status.clone(), + tx: Some(self.tx.clone()), + fake_delay: 5, + } + } + // ANCHOR_END: create_search_parameters + + // ANCHOR: request_search_results + pub fn request_search_results(&self) { + let params = self.create_search_parameters(); + tokio::spawn(async move { + params.loading_status.store(true, Ordering::SeqCst); + let _ = crates_io_api_helper::request_search_results(¶ms).await; + params.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, + ) { + if state.loading() { + Line::from("Loading...").right_aligned().render(area, buf); + } + + 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..368251025 --- /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 update_cursor_state(&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.update_cursor_state(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-basics.md b/src/content/docs/tutorials/crates-tui/app-basics.md new file mode 100644 index 000000000..da7ec7c99 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-basics.md @@ -0,0 +1,146 @@ +--- +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.rs:app}} +``` + +Define some helper functions for initializing the `App`: + +```rust +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app.rs:app_new}} +} + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app.rs:app_default}} +``` + +Now define a `run` method for `App`: + +```rust +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app.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.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.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.rs:app_draw}} +} +``` + +This uses a unit struct called `AppWidget` that can be rendered as a `StatefulWidget` using the +`App` struct as its state. + +```rust +use ratatui::widgets::{StatefulWidget, Paragraph}; + +struct AppWidget; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app.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.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. + +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-channels.md b/src/content/docs/tutorials/crates-tui/app-channels.md new file mode 100644 index 000000000..0ee7f1db5 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-channels.md @@ -0,0 +1,213 @@ +--- +title: App Channels +--- + +In this section, you 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-channel.rs:action}} +``` + +## Channels + +Define the following fields in the `App` struct: + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-channel.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 and struct our application in any way you +please, and propagate information up from child to parent structs and back down to different +children. + +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-channel.rs:app_new}} +} +``` + +Let's also update the `async run` method now: + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-channel.rs:app_run}} +``` + +The two important parts of the `run` method are: + +- [`handle_event`](./app-handle-event) +- [`handle_action`](./app-handle-action) + +## handle_event + +Let's update `handle_event` again 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-channel.rs:app_handle_event}} +} +``` + +Most of the work in deciding which `Action` should be taken is done in `handle_key`. + +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 + +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-channel.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-channel.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. + +You may be wondering why not combine the `handle_event` and `handle_action` to get rid of the +`Action` enum. While this may seem like a lot more boilerplate at first, using an `Action` enum this +way has a few advantages. + +For example, `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 +} +``` + +Additionally, using an `Action` even allows us as app developers to trigger an action from anywhere +in the child 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-channel.rs:full_app}} +``` + +
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..086954908 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-mode.md @@ -0,0 +1,89 @@ +--- +title: App Mode +--- + +We saw in the previous sections that `main` looked like this: + +```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}} +``` + +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: + +```rust +use itertools::Itertools; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-mode.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 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..d70aab902 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/app-prototype.md @@ -0,0 +1,70 @@ +--- +title: App prototype +--- + +Define the following fields in the `App` struct: + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app}} +``` + +```rust +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_handle_event}} +} +``` + +```rust +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_handle_key_event}} +} +``` + +```rust +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_handle_action}} +} +``` + +```rust +impl App { +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:app_submit_search_query}} +} +``` + +```rust +impl StatefulWidget for AppWidget { + type State = App; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + + // ... + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:render_rows}} + + // ... + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:render_prompt}} + + // ... + + +{{#include @code/crates-tui-tutorial-app/src/bin/part-app-prototype.rs:render_cursor}} + + } +} +``` + +```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}} +``` 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..6cac3925c --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/conclusion.md @@ -0,0 +1,15 @@ +--- +title: Conclusion +--- + +If you put all of it together, you should be able run the TUI. + +![](./crates-tui-demo.gif) + +We only touched on the basics for building an `async` application with Ratatui, using `tokio` and +`crossterm`'s async features. + +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..894900af8 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/crates-io-api-helper.md @@ -0,0 +1,181 @@ +--- +title: Crates IO API Helper +--- + +In this tutorial, we are going to use the `crates_io_api` crate's [`AsyncClient`] to retrieve +results from a search query to crates.io, and make a helper module to simplify handling of the +request and response for the purposes of the tutorial. + +[`AsyncClient`]: + https://docs.rs/crates_io_api/latest/crates_io_api/struct.AsyncClient.html#method.new + +In order to initialize the client, you have to provide an email as the user agent. In the source +code of this tutorial, we read this email from the environment variable +`CRATES_TUI_TUTORIAL_APP_MYEMAIL`. + +```rust +let email = env!("CRATES_TUI_TUTORIAL_APP_MYEMAIL"); + +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) +``` + +:::tip + +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 +``` + +Or just hardcode your email into your working copy of the source code + +```rust +let email = "your-email-address@foo.com"; +``` + +::: + +Once you have created a client, you can make a query using the [`AsyncClient::crates`] function: + +[`AsyncClient::crates`]: + https://docs.rs/crates_io_api/latest/crates_io_api/struct.AsyncClient.html#method.crates + +```rust +{{#include @code/crates-tui-tutorial-app/src/crates_io_api_helper.rs:crates_query}} +``` + +This `crates` method takes a [`CratesQuery`] object that you will need to construct. + +[`CratesQuery`]: https://docs.rs/crates_io_api/latest/crates_io_api/struct.CratesQuery.html + +This `CratesQuery` object can be built with 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, we'll store everything we need to construct a `CratesQuery` in a +`SearchParameters` struct: + +```rust +pub struct SearchParameters { + // Request + pub search: String, + pub page: u64, + pub page_size: u64, + pub sort: crates_io_api::Sort, + + // Response + pub crates: Arc>>, +} +``` + +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 this `async` task it will be populated with the results of the query once the query is +completed. + +You can 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}} +``` + +```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 can clear the existing results and update the +`Arc>>` (i.e. the `search_params.crates` field) with the response: + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:update_state}} +``` + +If you refactor that into separate functions, and put it together, it'll look like this: + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:request_search_results}} +``` + +With the `crates_io_api` helper set up, you can spawn a task using `tokio` to fill the results of +the query into the `Arc>>` that is passed in the search params: + +```rust +tokio::spawn(async move { + let r = crates_io_api_helper::request_search_results(&search_params).await; +}); +``` + +You can now use this helper module to make `async` requests from the `app`. + +
+ +Copy this to src/crates_io_api_helper.rs + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-helper.rs:helper}} +``` + +
+ +## Exercise + +Make a `async` test using the `#[tokio::test]` directive: + +```rust +{{#include @code/crates-tui-tutorial-app/src/crates_io_api_helper.rs:test}} +``` + +You can test this `async` function by running the following in the command line: + +```bash +$ cargo test -- crates_io_api_helper --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 +``` + +Your file structure should look like this now: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + ├── crates_io_api_helper.rs + └── main.rs +``` + +We'll come back to this helper module after we set up more of our app infrastructure. To do that, +let's look at the contents of the `tui` module next. 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..6dd6dc549 --- /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:19ff594a044a048a38319a15f969642ba4299224bb26bd024dac27878a4299f7 +size 278805 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..945c40a93 --- /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:203a2553748108667abb495322e0484b99ae129ee7848282bd301a725431ee6f +size 278312 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..4ed205975 --- /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:820039aaf1b1a232ec2d3fcba2601ab9c8f1c7758da8d2e30da1fcf5cc345f3a +size 258577 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..9ae340ee4 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/errors.md @@ -0,0 +1,75 @@ +--- +title: Errors +--- + +Now that you have a `tui` module, in addition to restoring the state of the terminal at the end of +`main`, 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 to set a panic hook called `set_hook`. Additionally, `color_eyre` has +some ready to install hooks to leverage. 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_backend() { + 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 +something like [`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 full file 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: + +```rust +mod errors; +mod tui; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-errors.rs:main}} +``` + +:::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..9b2012a77 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/events.md @@ -0,0 +1,92 @@ +--- +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}} +``` + +
+ +## Exercise + +Let's make a very simple event loop. Update `main.rs` to the following: + +```rust +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. + +:::note[Homework] + +Experiment with different frame rates by modifying the interval stream for the render tick. + +Can you also display the current key pressed at the bottom 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..b5989cef2 --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/index.md @@ -0,0 +1,58 @@ +--- +title: Crates TUI +--- + +In the previous counter app, we had a purely sequential blocking application. However, there are +times when you may be interested in running IO operations or computations asynchronously in between +rendering frames. + +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 and will these crates as +dependencies: + +- [`tokio`] +- [`crates_io_api`] + +[crates-tui]: https://github.com/ratatui-org/crates-tui +[`tokio`]: https://tokio.rs/ +[`crates_io_api`]: https://docs.rs/crates_io_api/latest/crates_io_api/ + +:::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) to learn more. + +::: + +## Dependencies + +Here's an example of all the dependencies in the `Cargo.toml` file required for this tutorial: + +Run the following to setup a new project: + +```bash +cargo new crates-tui-tutorial-app --bin +``` + +This is what your folder structure should look like: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + └── main.rs +``` + +Let's go through making these files one by one, starting with `main.rs`. + +```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}} +``` 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..e895960ae --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/main.md @@ -0,0 +1,134 @@ +--- +title: Main +--- + +First, let's make your `main` function a `tokio` entry point. Adding the `#[tokio::main]` macro +allows you to use `async` and `await` inside `main`. You can spawn tokio tasks within `main` or any +function that `main` calls. + +Create a new `./src/main.rs` with the following contents: + +```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 `~/gitrepos/crates-tui-tutorial` +Sleeping for 5 seconds... +$ +``` + +:::tip + +Use `time cargo run` to see how long a process takes to run. + +::: + +:::note[Homework] + +Experiment with `main` and `tokio` before moving forward. + +For example, try to predicate what happens if you spawn multiple tokio tasks like so? + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-main-tasks-concurrent.rs}} +``` + +In the above example, if you change `#[tokio::main]` to `#[tokio::main(flavor = "current_thread")]`, +can you predict what would happen? Run it to confirm. Do you understand why it behaves the way it +does? + +Now, what happens if you run the following with `#[tokio::main]` 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 `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..efeeaf23c --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/search.md @@ -0,0 +1,59 @@ +--- +title: Search +--- + +In the `App` section of the tutorial, we used field called `search` that contained an instance of +the `SearchPage` struct: + +```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..acf24dccc --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/tui.md @@ -0,0 +1,48 @@ +--- +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 inputs from a user +2. Enter the alternate screen: This is required to preserve user's current terminal contents after + running the TUI + +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. + +```rust +{{#include @code/crates-tui-tutorial-app/src/bin/part-tui.rs}} +``` + +Let's update `main.rs` to the following: + +```rust +mod tui; + +{{#include @code/crates-tui-tutorial-app/src/bin/part-tui.rs:main}} +``` + +Your file structure should look like this: + +``` +. +├── Cargo.lock +├── Cargo.toml +└── src + ├── crates_io_api_helper.rs + ├── main.rs + └── tui.rs +``` + + + +Now, our TUI resets the terminal state at the end of the program. 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..f45b5b56a --- /dev/null +++ b/src/content/docs/tutorials/crates-tui/widgets.md @@ -0,0 +1,34 @@ +--- +title: Widgets +--- + +In this section we will discuss the widgets implemented for this tutorial: + +```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` and a `Scrollbar` widget. For the `SearchPrompt`, we +will use a `Block` with borders and `Paragraph`s for the text. + +We will be using the `StatefulWidget` pattern. `StatefulWidget` is a Rust 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 + +What information you store in each of these structs is generally up to you and your application. 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