From 83260a27d904527e47d0f97e741248a29413f2ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:51:55 -0400 Subject: [PATCH 01/30] added new deps: eyre, tuirealm, thiserror for a more structure and handling of things --- Cargo.lock | 461 ++++++++++++++++++++++++++++++++++++++++++++++------- Cargo.toml | 9 +- 2 files changed, 406 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 557a581..61b2ba9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.18" @@ -83,9 +92,9 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -108,6 +117,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -151,10 +169,10 @@ version = "4.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -169,6 +187,29 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.13" @@ -180,15 +221,33 @@ dependencies = [ [[package]] name = "crossterm" -version = "0.27.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "libc", "mio", "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -213,6 +272,62 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -244,6 +359,21 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.13.0" @@ -266,6 +396,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -337,12 +477,6 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -355,6 +489,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.13.0" @@ -373,6 +519,19 @@ version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -381,9 +540,9 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itertools" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -394,6 +553,29 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "lazy-regex" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f82f270298cdeeffe3ce09272627714a227bce8b499afa71fc47efb84dc582b7" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -416,12 +598,24 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.12" @@ -455,14 +649,14 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "mio" -version = "0.8.11" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "log", "wasi", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -522,7 +716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -581,21 +775,23 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.25.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5659e52e4ba6e07b2dad9f1158f578ef84a73762625ddb51536019f34d180eb" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags", "cassowary", - "crossterm", + "compact_str", + "crossterm 0.28.1", "indoc", + "instability", "itertools", "lru", "paste", - "stability", "strum", "unicode-segmentation", - "unicode-width", + "unicode-truncate", + "unicode-width 0.2.0", ] [[package]] @@ -615,9 +811,38 @@ checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" dependencies = [ "getrandom 0.2.15", "libredox", - "thiserror", + "thiserror 1.0.63", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rust-embed" version = "8.5.0" @@ -638,7 +863,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.117", + "syn", "walkdir", ] @@ -652,6 +877,28 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -661,7 +908,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.59.0", ] @@ -671,6 +918,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -719,7 +972,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -792,14 +1045,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] -name = "stability" -version = "0.1.1" +name = "smawk" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce" -dependencies = [ - "quote", - "syn 1.0.109", -] +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strsim" @@ -809,35 +1064,24 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.25.0" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.25.3" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.4.1", + "heck", "proc-macro2", "quote", "rustversion", - "syn 2.0.117", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "syn", ] [[package]] @@ -860,17 +1104,37 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.59.0", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.2.0", +] + [[package]] name = "thiserror" version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.63", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -881,7 +1145,18 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -924,14 +1199,55 @@ version = "1.6.0" dependencies = [ "clap", "clap_complete", - "crossterm", + "crossterm 0.29.0", "dirs", + "eyre", "rand", "ratatui", "rust-embed", "serde", "tempfile", + "thiserror 2.0.18", "toml", + "tui-realm-stdlib", + "tuirealm", +] + +[[package]] +name = "tui-realm-stdlib" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333d8b8da2b3479a68fdf4bd2621f888fa869501d6c5c72bac940d0eeb19b083" +dependencies = [ + "textwrap", + "tuirealm", + "unicode-width 0.2.0", +] + +[[package]] +name = "tuirealm" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a96abaf33552e9e2487bf56f537e49fad1d1fbc55a3fd542449d4e30e7c8f3" +dependencies = [ + "bitflags", + "crossterm 0.29.0", + "dyn-clone", + "lazy-regex", + "ratatui", + "thiserror 2.0.18", + "tuirealm_derive", +] + +[[package]] +name = "tuirealm_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad3c151090da5a036321776209b3d026df637d7f52c0984ff8d45927326ab4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -946,18 +1262,41 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.13", +] + [[package]] name = "unicode-width" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1248,7 +1587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -1259,10 +1598,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1278,7 +1617,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -1338,7 +1677,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4d5419d..95c3f7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,17 +8,20 @@ homepage = "https://github.com/max-niederman/ttyper" license = "MIT" authors = ["Max Niederman "] edition = "2021" - [dependencies] clap = { version = "^4.5", features = ["derive"] } clap_complete = "^4.5" dirs = "^5.0" -crossterm = "^0.27" +crossterm = "^0.29" rust-embed = "^8.2" toml = "^0.8" +tuirealm = { version = "3.3.0", features = ["crossterm", "derive"] } +eyre = "0.6.12" +thiserror = "2.0.18" +tui-realm-stdlib = { version = "3.1.0", features = ["crossterm"] } [dependencies.ratatui] -version = "^0.25" +version = "^0.29" [dependencies.rand] version = "^0.8" From b6f1a1353f234cacd216c5fdc22e5d32a1a9da26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:53:05 -0400 Subject: [PATCH 02/30] Refactored result component --- src/components/result.rs | 238 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 src/components/result.rs diff --git a/src/components/result.rs b/src/components/result.rs new file mode 100644 index 0000000..7031ac7 --- /dev/null +++ b/src/components/result.rs @@ -0,0 +1,238 @@ +use rand::{seq::SliceRandom, thread_rng}; +use tuirealm::ratatui::crossterm::event::{ + KeyCode as CrosstermKeyCode, KeyModifiers as CrosstermKeyModifiers, +}; +use tuirealm::ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + symbols::Marker, + text::{Line, Span, Text}, + widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Widget}, +}; +use tuirealm::{ + command::{Cmd, CmdResult}, + event::{Event, Key, KeyModifiers, NoUserEvent}, + props::{Alignment, Borders as TuiBorders, Color, Style}, + AttrValue, Attribute, Component, Frame, MockComponent, State, +}; + +use crate::config::Theme; +use crate::messages::Msg; +use crate::types::{Fraction, Results}; + +// Convert CPS to WPM (clicks per second) +const WPM_PER_CPS: f64 = 12.0; + +// Width of the moving average window for the WPM chart +const WPM_SMA_WIDTH: usize = 10; + +pub struct ResultsComponent { + pub results: Results, + pub theme: Theme, +} + +impl MockComponent for ResultsComponent { + fn view(&mut self, frame: &mut Frame, area: Rect) { + let buf = frame.buffer_mut(); + + buf.set_style(area, self.theme.default); + + // Chunks + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(area); + let res_chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) // Graph looks tremendously better with just a little margin + .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) + .split(chunks[0]); + let info_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) + .split(res_chunks[0]); + + let msg = if self.results.missed_words.is_empty() { + "Press 'q' to quit or 'r' for another test" + } else { + "Press 'q' to quit, 'r' for another test or 'p' to practice missed words" + }; + + let exit = Span::styled(msg, self.theme.results_restart_prompt); + buf.set_span(chunks[1].x, chunks[1].y, &exit, chunks[1].width); + + // Sections + let mut overview_text = Text::styled("", self.theme.results_overview); + overview_text.extend([ + Line::from(format!( + "Adjusted WPM: {:.1}", + self.results.timing.overall_cps + * WPM_PER_CPS + * f64::from(self.results.accuracy.overall) + )), + Line::from(format!( + "Accuracy: {:.1}%", + f64::from(self.results.accuracy.overall) * 100f64 + )), + Line::from(format!( + "Raw WPM: {:.1}", + self.results.timing.overall_cps * WPM_PER_CPS + )), + Line::from(format!( + "Correct Keypresses: {}", + self.results.accuracy.overall + )), + ]); + let overview = Paragraph::new(overview_text).block( + Block::default() + .title(Span::styled("Overview", self.theme.title)) + .borders(Borders::ALL) + .border_type(self.theme.border_type) + .border_style(self.theme.results_overview_border), + ); + overview.render(info_chunks[0], buf); + + let mut worst_keys: Vec<(&tuirealm::ratatui::crossterm::event::KeyEvent, &Fraction)> = self + .results + .accuracy + .per_key + .iter() + .filter(|(key, _)| matches!(key.code, CrosstermKeyCode::Char(_))) + .collect(); + worst_keys.sort_unstable_by_key(|x| x.1); + + let mut worst_text = Text::styled("", self.theme.results_worst_keys); + worst_text.extend( + worst_keys + .iter() + .filter_map(|(key, acc)| { + if let CrosstermKeyCode::Char(character) = key.code { + let key_accuracy = f64::from(**acc) * 100.0; + if key_accuracy != 100.0 { + Some(format!("- {} at {:.1}% accuracy", character, key_accuracy)) + } else { + None + } + } else { + None + } + }) + .take(5) + .map(Line::from), + ); + let worst = Paragraph::new(worst_text).block( + Block::default() + .title(Span::styled("Worst Keys", self.theme.title)) + .borders(Borders::ALL) + .border_type(self.theme.border_type) + .border_style(self.theme.results_worst_keys_border), + ); + worst.render(info_chunks[1], buf); + + let wpm_sma: Vec<(f64, f64)> = self + .results + .timing + .per_event + .windows(WPM_SMA_WIDTH) + .enumerate() + .map(|(i, window)| { + ( + (i + WPM_SMA_WIDTH) as f64, + window.len() as f64 / window.iter().copied().sum::() * WPM_PER_CPS, + ) + }) + .collect(); + + // Render the chart if possible + if !wpm_sma.is_empty() { + let wpm_sma_min = wpm_sma + .iter() + .map(|(_, x)| x) + .fold(f64::INFINITY, |a, &b| a.min(b)); + let wpm_sma_max = wpm_sma + .iter() + .map(|(_, x)| x) + .fold(f64::NEG_INFINITY, |a, &b| a.max(b)); + + let wpm_datasets = vec![Dataset::default() + .name("WPM") + .marker(Marker::Braille) + .graph_type(GraphType::Line) + .style(self.theme.results_chart) + .data(&wpm_sma)]; + + let y_label_min = wpm_sma_min as u16; + let y_label_max = (wpm_sma_max as u16).max(y_label_min + 6); + + let wpm_chart = Chart::new(wpm_datasets) + .block(Block::default().title(vec![Span::styled("Chart", self.theme.title)])) + .x_axis( + Axis::default() + .title(Span::styled("Keypresses", self.theme.results_chart_x)) + .bounds([0.0, self.results.timing.per_event.len() as f64]), + ) + .y_axis( + Axis::default() + .title(Span::styled( + "WPM (10-keypress rolling average)", + self.theme.results_chart_y, + )) + .bounds([wpm_sma_min, wpm_sma_max]) + .labels( + (y_label_min..y_label_max) + .step_by(5) + .map(|n| Span::raw(format!("{}", n))) + .collect::>(), + ), + ); + wpm_chart.render(res_chunks[1], buf); + } + } + + fn query(&self, _attr: Attribute) -> Option { + None + } + + fn attr(&mut self, _attr: Attribute, _value: AttrValue) {} + + fn state(&self) -> State { + State::None + } + + fn perform(&mut self, _cmd: Cmd) -> CmdResult { + CmdResult::None + } +} + +impl Component for ResultsComponent { + fn on(&mut self, ev: Event) -> Option { + match ev { + Event::Keyboard(key) + if key.code == Key::Char('q') && key.modifiers == KeyModifiers::NONE => + { + Some(Msg::AppClose) + } + Event::Keyboard(key) + if key.code == Key::Char('r') && key.modifiers == KeyModifiers::NONE => + { + Some(Msg::RestartTest) + } + Event::Keyboard(key) + if key.code == Key::Char('p') && key.modifiers == KeyModifiers::NONE => + { + if self.results.missed_words.is_empty() { + return None; + } + // repeat each missed word 5 times + let mut practice_words: Vec = (self.results.missed_words) + .iter() + .flat_map(|w| vec![w.clone(); 5]) + .collect(); + + practice_words.shuffle(&mut thread_rng()); + + Some(Msg::StartTest(practice_words)) + } + _ => None, + } + } +} From 6c25db88b55eccb570c49a034fa869fc3c0b1ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:53:19 -0400 Subject: [PATCH 03/30] Refactored test component --- src/components/mod.rs | 2 + src/components/test.rs | 324 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 src/components/mod.rs create mode 100644 src/components/test.rs diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..032d642 --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,2 @@ +pub mod result; +pub mod test; diff --git a/src/components/test.rs b/src/components/test.rs new file mode 100644 index 0000000..1d08a5e --- /dev/null +++ b/src/components/test.rs @@ -0,0 +1,324 @@ +use tuirealm::ratatui::{ + buffer::Buffer, + layout::{Constraint, Direction, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Widget}, +}; +use tuirealm::{ + command::{Cmd, CmdResult}, + event::{Event, Key, KeyModifiers as TuiKeyModifiers, NoUserEvent}, + props::{Alignment, Borders as TuiBorders, Color, Style}, + AttrValue, Attribute, Component, Frame, MockComponent, State, +}; + +use crate::config::Theme; +use crate::messages::Msg; +use crate::types::{Results, Test, TestWord}; + +pub struct TestComponent { + pub test: Test, + pub theme: Theme, +} + +impl MockComponent for TestComponent { + fn view(&mut self, frame: &mut Frame, area: Rect) { + let buf = frame.buffer_mut(); + + buf.set_style(area, self.theme.default); + + // Chunks + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Length(6)]) + .split(area); + + // Input Section + let input_block = Block::default() + .title(Line::from(vec![Span::styled("Input", self.theme.title)])) + .borders(Borders::ALL) + .border_type(self.theme.border_type) + .border_style(self.theme.input_border); + + let input_inner_area = input_block.inner(chunks[0]); + input_block.render(chunks[0], buf); + + let input_text = Line::from(self.test.words[self.test.current_word].progress.clone()); + buf.set_line( + input_inner_area.x, + input_inner_area.y, + &input_text, + input_inner_area.width, + ); + + // Target (Prompt) Section + let target_lines: Vec = { + let words = words_to_spans(&self.test.words, self.test.current_word, &self.theme); + + let mut lines: Vec = Vec::new(); + let mut current_line: Vec = Vec::new(); + let mut current_width = 0; + for word in words { + let word_width: usize = word.iter().map(|s| s.width()).sum(); + + if current_width + word_width > chunks[1].width as usize - 2 { + lines.push(Line::from(current_line.clone())); + current_line.clear(); + current_width = 0; + } + + current_line.extend(word); + current_width += word_width; + } + lines.push(Line::from(current_line)); + + lines + }; + + let target = Paragraph::new(target_lines).block( + Block::default() + .title(Span::styled("Prompt", self.theme.title)) + .borders(Borders::ALL) + .border_type(self.theme.border_type) + .border_style(self.theme.prompt_border), + ); + target.render(chunks[1], buf); + + // Cursor positioning + let inner_x = chunks[0].x + 1; + let inner_y = chunks[0].y + 1; + let progress_width = + Line::from(self.test.words[self.test.current_word].progress.as_str()).width() as u16; + let max_cursor_x = chunks[0].right().saturating_sub(2); + + frame.set_cursor_position(((inner_x + progress_width).min(max_cursor_x), inner_y)); + } + + fn query(&self, _attr: Attribute) -> Option { + None + } + + fn attr(&mut self, _attr: Attribute, _value: AttrValue) {} + + fn state(&self) -> State { + State::None + } + + fn perform(&mut self, _cmd: Cmd) -> CmdResult { + CmdResult::None + } +} + +impl Component for TestComponent { + fn on(&mut self, ev: Event) -> Option { + match ev { + Event::Keyboard(key_event) => { + // Convert tuirealm KeyEvent to crossterm KeyEvent for Test::handle_key + let crossterm_key = convert_tuirealm_to_crossterm_key(key_event); + + self.test.handle_key(crossterm_key); + if self.test.complete { + let results = Results::from(&self.test); + Some(Msg::ShowResults(results)) + } else { + Some(Msg::None) + } + } + _ => None, + } + } +} + +fn convert_tuirealm_to_crossterm_key( + key: tuirealm::event::KeyEvent, +) -> tuirealm::ratatui::crossterm::event::KeyEvent { + use tuirealm::event::Key; + use tuirealm::ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, + }; + + let code = match key.code { + Key::Backspace => KeyCode::Backspace, + Key::Enter => KeyCode::Enter, + Key::Left => KeyCode::Left, + Key::Right => KeyCode::Right, + Key::Up => KeyCode::Up, + Key::Down => KeyCode::Down, + Key::Home => KeyCode::Home, + Key::End => KeyCode::End, + Key::PageUp => KeyCode::PageUp, + Key::PageDown => KeyCode::PageDown, + Key::Tab => KeyCode::Tab, + Key::BackTab => KeyCode::BackTab, + Key::Delete => KeyCode::Delete, + Key::Insert => KeyCode::Insert, + Key::Function(n) => KeyCode::F(n), + Key::Char(c) => KeyCode::Char(c), + Key::Null => KeyCode::Null, + Key::Esc => KeyCode::Esc, + Key::CapsLock => KeyCode::CapsLock, + Key::ScrollLock => KeyCode::ScrollLock, + Key::NumLock => KeyCode::NumLock, + Key::PrintScreen => KeyCode::PrintScreen, + Key::Pause => KeyCode::Pause, + Key::Menu => KeyCode::Menu, + Key::KeypadBegin => KeyCode::KeypadBegin, + _ => KeyCode::Null, + }; + + let mut modifiers = KeyModifiers::empty(); + if key.modifiers.contains(TuiKeyModifiers::SHIFT) { + modifiers.insert(KeyModifiers::SHIFT); + } + if key.modifiers.contains(TuiKeyModifiers::CONTROL) { + modifiers.insert(KeyModifiers::CONTROL); + } + if key.modifiers.contains(TuiKeyModifiers::ALT) { + modifiers.insert(KeyModifiers::ALT); + } + + KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + } +} + +// Helpers + +fn words_to_spans<'a>( + words: &'a [TestWord], + current_word: usize, + theme: &'a Theme, +) -> Vec>> { + let mut spans = Vec::new(); + + for word in &words[..current_word] { + let parts = split_typed_word(word); + spans.push(word_parts_to_spans(parts, theme)); + } + + if current_word < words.len() { + let parts_current = split_current_word(&words[current_word]); + spans.push(word_parts_to_spans(parts_current, theme)); + + for word in &words[current_word + 1..] { + let parts = vec![(word.text.clone(), Status::Untyped)]; + spans.push(word_parts_to_spans(parts, theme)); + } + } + spans +} + +#[derive(PartialEq, Clone, Copy, Debug)] +enum Status { + Correct, + Incorrect, + CurrentUntyped, + CurrentCorrect, + CurrentIncorrect, + Cursor, + Untyped, + Overtyped, +} + +fn split_current_word(word: &TestWord) -> Vec<(String, Status)> { + let mut parts = Vec::new(); + let mut cur_string = String::new(); + let mut cur_status = Status::Untyped; + + let mut progress = word.progress.chars(); + for tc in word.text.chars() { + let p = progress.next(); + let status = match p { + None => Status::CurrentUntyped, + Some(c) => match c { + c if c == tc => Status::CurrentCorrect, + _ => Status::CurrentIncorrect, + }, + }; + + if status == cur_status { + cur_string.push(tc); + } else { + if !cur_string.is_empty() { + parts.push((cur_string, cur_status)); + cur_string = String::new(); + } + cur_string.push(tc); + cur_status = status; + + // first currentuntyped is cursor + if status == Status::CurrentUntyped { + parts.push((cur_string, Status::Cursor)); + cur_string = String::new(); + } + } + } + if !cur_string.is_empty() { + parts.push((cur_string, cur_status)); + } + let overtyped = progress.collect::(); + if !overtyped.is_empty() { + parts.push((overtyped, Status::Overtyped)); + } + parts +} + +fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> { + let mut parts = Vec::new(); + let mut cur_string = String::new(); + let mut cur_status = Status::Untyped; + + let mut progress = word.progress.chars(); + for tc in word.text.chars() { + let p = progress.next(); + let status = match p { + None => Status::Untyped, + Some(c) => match c { + c if c == tc => Status::Correct, + _ => Status::Incorrect, + }, + }; + + if status == cur_status { + cur_string.push(tc); + } else { + if !cur_string.is_empty() { + parts.push((cur_string, cur_status)); + cur_string = String::new(); + } + cur_string.push(tc); + cur_status = status; + } + } + if !cur_string.is_empty() { + parts.push((cur_string, cur_status)); + } + + let overtyped = progress.collect::(); + if !overtyped.is_empty() { + parts.push((overtyped, Status::Overtyped)); + } + parts +} + +fn word_parts_to_spans(parts: Vec<(String, Status)>, theme: &Theme) -> Vec> { + let mut spans = Vec::new(); + for (text, status) in parts { + let style = match status { + Status::Correct => theme.prompt_correct, + Status::Incorrect => theme.prompt_incorrect, + Status::Untyped => theme.prompt_untyped, + Status::CurrentUntyped => theme.prompt_current_untyped, + Status::CurrentCorrect => theme.prompt_current_correct, + Status::CurrentIncorrect => theme.prompt_current_incorrect, + Status::Cursor => theme.prompt_current_untyped.patch(theme.prompt_cursor), + Status::Overtyped => theme.prompt_incorrect, + }; + + spans.push(Span::styled(text, style)); + } + spans.push(Span::styled(" ", theme.prompt_untyped)); + spans +} From 2131ae09a7eaba8d368bb573e595f196910a6776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:53:39 -0400 Subject: [PATCH 04/30] Removed the rather confusing test dir I thought these were integration tests mb --- src/test/mod.rs | 178 -------------------------------------------- src/test/results.rs | 145 ------------------------------------ 2 files changed, 323 deletions(-) delete mode 100644 src/test/mod.rs delete mode 100644 src/test/results.rs diff --git a/src/test/mod.rs b/src/test/mod.rs deleted file mode 100644 index d6997cb..0000000 --- a/src/test/mod.rs +++ /dev/null @@ -1,178 +0,0 @@ -pub mod results; - -use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use std::fmt; -use std::time::Instant; - -pub struct TestEvent { - pub time: Instant, - pub key: KeyEvent, - pub correct: Option, -} - -pub fn is_missed_word_event(event: &TestEvent) -> bool { - event.correct != Some(true) -} - -impl fmt::Debug for TestEvent { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("TestEvent") - .field("time", &String::from("Instant { ... }")) - .field("key", &self.key) - .finish() - } -} - -#[derive(Debug)] -pub struct TestWord { - pub text: String, - pub progress: String, - pub events: Vec, -} - -impl From for TestWord { - fn from(string: String) -> Self { - TestWord { - text: string, - progress: String::new(), - events: Vec::new(), - } - } -} - -impl From<&str> for TestWord { - fn from(string: &str) -> Self { - Self::from(string.to_string()) - } -} - -#[derive(Debug)] -pub struct Test { - pub words: Vec, - pub current_word: usize, - pub complete: bool, - pub backtracking_enabled: bool, - pub sudden_death_enabled: bool, - pub backspace_enabled: bool, -} - -impl Test { - pub fn new( - words: Vec, - backtracking_enabled: bool, - sudden_death_enabled: bool, - backspace_enabled: bool, - ) -> Self { - Self { - words: words.into_iter().map(TestWord::from).collect(), - current_word: 0, - complete: false, - backtracking_enabled, - sudden_death_enabled, - backspace_enabled, - } - } - - pub fn handle_key(&mut self, key: KeyEvent) { - if key.kind != KeyEventKind::Press { - return; - } - - let word = &mut self.words[self.current_word]; - match key.code { - KeyCode::Char(' ') | KeyCode::Enter => { - if word.text.chars().nth(word.progress.len()) == Some(' ') { - word.progress.push(' '); - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(true), - key, - }) - } else if !word.progress.is_empty() || word.text.is_empty() { - let correct = word.text == word.progress; - if self.sudden_death_enabled && !correct { - self.reset(); - } else { - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(correct), - key, - }); - self.next_word(); - } - } - } - KeyCode::Backspace => { - if word.progress.is_empty() && self.backtracking_enabled && self.backspace_enabled { - self.last_word(); - } else if self.backspace_enabled { - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(!word.text.starts_with(&word.progress[..])), - key, - }); - word.progress.pop(); - } - } - // CTRL-BackSpace and CTRL-W - KeyCode::Char('h') | KeyCode::Char('w') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - if self.words[self.current_word].progress.is_empty() { - self.last_word(); - } - - let word = &mut self.words[self.current_word]; - - word.events.push(TestEvent { - time: Instant::now(), - correct: None, - key, - }); - word.progress.clear(); - } - KeyCode::Char(c) => { - word.progress.push(c); - let correct = word.text.starts_with(&word.progress[..]); - if self.sudden_death_enabled && !correct { - self.reset(); - } else { - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(correct), - key, - }); - if word.progress == word.text && self.current_word == self.words.len() - 1 { - self.complete = true; - self.current_word = 0; - } - } - } - _ => {} - }; - } - - fn last_word(&mut self) { - if self.current_word != 0 { - self.current_word -= 1; - } - } - - fn next_word(&mut self) { - if self.current_word == self.words.len() - 1 { - self.complete = true; - self.current_word = 0; - } else { - self.current_word += 1; - } - } - - fn reset(&mut self) { - self.words.iter_mut().for_each(|word: &mut TestWord| { - word.progress.clear(); - word.events.clear(); - }); - self.current_word = 0; - self.complete = false; - } -} diff --git a/src/test/results.rs b/src/test/results.rs deleted file mode 100644 index babe298..0000000 --- a/src/test/results.rs +++ /dev/null @@ -1,145 +0,0 @@ -use super::{is_missed_word_event, Test}; - -use crossterm::event::KeyEvent; -use std::collections::HashMap; -use std::{cmp, fmt}; - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub struct Fraction { - pub numerator: usize, - pub denominator: usize, -} - -impl Fraction { - pub const fn new(numerator: usize, denominator: usize) -> Self { - Self { - numerator, - denominator, - } - } -} - -impl From for f64 { - fn from(f: Fraction) -> Self { - f.numerator as f64 / f.denominator as f64 - } -} - -impl cmp::Ord for Fraction { - fn cmp(&self, other: &Self) -> cmp::Ordering { - f64::from(*self).partial_cmp(&f64::from(*other)).unwrap() - } -} - -impl PartialOrd for Fraction { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl fmt::Display for Fraction { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}/{}", self.numerator, self.denominator) - } -} - -pub struct TimingData { - // Instead of storing WPM, we store CPS (clicks per second) - pub overall_cps: f64, - pub per_event: Vec, - pub per_key: HashMap, -} - -pub struct AccuracyData { - pub overall: Fraction, - pub per_key: HashMap, -} - -pub struct Results { - pub timing: TimingData, - pub accuracy: AccuracyData, - pub missed_words: Vec, -} - -impl From<&Test> for Results { - fn from(test: &Test) -> Self { - let events: Vec<&super::TestEvent> = - test.words.iter().flat_map(|w| w.events.iter()).collect(); - - Self { - timing: calc_timing(&events), - accuracy: calc_accuracy(&events), - missed_words: calc_missed_words(test), - } - } -} - -fn calc_timing(events: &[&super::TestEvent]) -> TimingData { - let mut timing = TimingData { - overall_cps: -1.0, - per_event: Vec::new(), - per_key: HashMap::new(), - }; - - // map of keys to a two-tuple (total time, clicks) for counting average - let mut keys: HashMap = HashMap::new(); - - for win in events.windows(2) { - let event_dur = win[1] - .time - .checked_duration_since(win[0].time) - .map(|d| d.as_secs_f64()); - - if let Some(event_dur) = event_dur { - timing.per_event.push(event_dur); - - let key = keys.entry(win[1].key).or_insert((0.0, 0)); - key.0 += event_dur; - key.1 += 1; - } - } - - timing.per_key = keys - .into_iter() - .map(|(key, (total, count))| (key, total / count as f64)) - .collect(); - - timing.overall_cps = timing.per_event.len() as f64 / timing.per_event.iter().sum::(); - - timing -} - -fn calc_accuracy(events: &[&super::TestEvent]) -> AccuracyData { - let mut acc = AccuracyData { - overall: Fraction::new(0, 0), - per_key: HashMap::new(), - }; - - events - .iter() - .filter(|event| event.correct.is_some()) - .for_each(|event| { - let key = acc - .per_key - .entry(event.key) - .or_insert_with(|| Fraction::new(0, 0)); - - acc.overall.denominator += 1; - key.denominator += 1; - - if event.correct.unwrap() { - acc.overall.numerator += 1; - key.numerator += 1; - } - }); - - acc -} - -fn calc_missed_words(test: &Test) -> Vec { - test.words - .iter() - .filter(|word| word.events.iter().any(is_missed_word_event)) - .map(|word| word.text.clone()) - .collect() -} From 8c5c4af12e2456c6d260702727b5e0c8195450ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:53:46 -0400 Subject: [PATCH 05/30] added cli file for cli specific things --- src/cli.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/cli.rs diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..62ff72c --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,59 @@ +use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::Shell; +use std::path::PathBuf; +use std::{num, str}; + +#[derive(Debug, Parser)] +#[command(about, version)] +pub struct Opt { + /// Read test contents from the specified file, or "-" for stdin + #[arg(value_name = "PATH")] + pub contents: Option, + + #[arg(short, long)] + pub debug: bool, + + /// Specify word count + #[arg(short, long, value_name = "N", default_value = "50")] + pub words: num::NonZeroUsize, + + /// Use config file + #[arg(short, long, value_name = "PATH")] + pub config: Option, + + /// Specify test language in file + #[arg(long, value_name = "PATH")] + pub language_file: Option, + + /// Specify test language + #[arg(short, long, value_name = "LANG")] + pub language: Option, + + /// List installed languages + #[arg(long)] + pub list_languages: bool, + + /// Disable backtracking to completed words + #[arg(long)] + pub no_backtrack: bool, + + /// Enable sudden death mode to restart on first error + #[arg(long)] + pub sudden_death: bool, + + /// Disable backspace + #[arg(long)] + pub no_backspace: bool, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Generate shell completions + Completions { + /// Shell to generate completions for + shell: Shell, + }, +} From 4d251fcf917810286a783f1b9439cde16cea86c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:53:55 -0400 Subject: [PATCH 06/30] Using tuilrealm --- src/config.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index cb22f5f..1f43e9e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,11 +1,11 @@ -use ratatui::{ - style::{Color, Modifier, Style}, - widgets::BorderType, -}; use serde::{ de::{self, IntoDeserializer}, Deserialize, }; +use tuirealm::ratatui::{ + style::{Color, Modifier, Style}, + widgets::BorderType, +}; #[derive(Debug, Deserialize)] #[serde(default)] @@ -23,7 +23,7 @@ impl Default for Config { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] #[serde(default)] pub struct Theme { #[serde(deserialize_with = "deserialize_style")] From 8cb3020423c76ca155d1866eb28667f36c5a385e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:53:59 -0400 Subject: [PATCH 07/30] Dedicated error enum --- src/error.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/error.rs diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..e5187e0 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,21 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum TtyperError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Terminal error: {0}")] + Terminal(String), + + #[error("Application error: {0}")] + Application(String), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("Content error: {0}")] + Content(String), +} + +pub type Result = std::result::Result; From 4f0011ae85b94d2497d69a93750c5765a1c2b5ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:54:38 -0400 Subject: [PATCH 08/30] New main entry ponit Still room for more simplification --- src/main.rs | 490 ++++++++++------------------------------------------ 1 file changed, 91 insertions(+), 399 deletions(-) diff --git a/src/main.rs b/src/main.rs index 691cd39..ba448ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,247 +1,31 @@ +mod cli; +mod components; mod config; -mod test; -mod ui; - -use config::Config; -use test::{results::Results, Test}; - -use clap::{CommandFactory, Parser, Subcommand}; -use clap_complete::{generate, Shell}; -use crossterm::{ - self, cursor, - event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, - execute, terminal, -}; -use rand::{seq::SliceRandom, thread_rng}; -use ratatui::{ - backend::CrosstermBackend, - layout::{Constraint, Direction, Layout}, - terminal::Terminal, +mod error; +mod messages; +mod model; +mod resources; +mod types; + +use clap::CommandFactory; +use clap::Parser; +use clap_complete::generate; +use std::io; +use std::time::Duration; +use tuirealm::{ + event::NoUserEvent, terminal::TerminalBridge, Application, EventListenerCfg, PollStrategy, + Update, }; -use rust_embed::RustEmbed; -use std::{ - ffi::OsString, - fs, - io::{self, BufRead}, - num, - path::PathBuf, - str, -}; - -#[derive(RustEmbed)] -#[folder = "resources/runtime"] -struct Resources; - -#[derive(Debug, Parser)] -#[command(about, version)] -struct Opt { - /// Read test contents from the specified file, or "-" for stdin - #[arg(value_name = "PATH")] - contents: Option, - - #[arg(short, long)] - debug: bool, - - /// Specify word count - #[arg(short, long, value_name = "N", default_value = "50")] - words: num::NonZeroUsize, - - /// Use config file - #[arg(short, long, value_name = "PATH")] - config: Option, - - /// Specify test language in file - #[arg(long, value_name = "PATH")] - language_file: Option, - - /// Specify test language - #[arg(short, long, value_name = "LANG")] - language: Option, - - /// List installed languages - #[arg(long)] - list_languages: bool, - - /// Disable backtracking to completed words - #[arg(long)] - no_backtrack: bool, - - /// Enable sudden death mode to restart on first error - #[arg(long)] - sudden_death: bool, - - /// Disable backspace - #[arg(long)] - no_backspace: bool, - - #[command(subcommand)] - command: Option, -} - -#[derive(Debug, Subcommand)] -enum Command { - /// Generate shell completions - Completions { - /// Shell to generate completions for - shell: Shell, - }, -} - -impl Opt { - fn gen_contents(&self) -> Option> { - match &self.contents { - Some(path) => { - let lines: Vec = if path.as_os_str() == "-" { - std::io::stdin() - .lock() - .lines() - .map_while(Result::ok) - .collect() - } else { - let file = fs::File::open(path).expect("Error reading language file."); - io::BufReader::new(file) - .lines() - .map_while(Result::ok) - .collect() - }; - - Some(lines.iter().map(String::from).collect()) - } - None => { - let lang_name = self - .language - .clone() - .unwrap_or_else(|| self.config().default_language); - - let bytes: Vec = self - .language_file - .as_ref() - .map(fs::read) - .and_then(Result::ok) - .or_else(|| fs::read(self.language_dir().join(&lang_name)).ok()) - .or_else(|| { - Resources::get(&format!("language/{}", &lang_name)) - .map(|f| f.data.into_owned()) - })?; - - let mut rng = thread_rng(); - - let mut language: Vec<&str> = str::from_utf8(&bytes) - .expect("Language file had non-utf8 encoding.") - .lines() - .collect(); - language.shuffle(&mut rng); - - let mut contents: Vec<_> = language - .into_iter() - .cycle() - .take(self.words.get()) - .map(ToOwned::to_owned) - .collect(); - contents.shuffle(&mut rng); - - Some(contents) - } - } - } - - /// Configuration - fn config(&self) -> Config { - fs::read( - self.config - .clone() - .unwrap_or_else(|| self.config_dir().join("config.toml")), - ) - .map(|bytes| { - toml::from_str(str::from_utf8(&bytes).unwrap_or_default()) - .expect("Configuration was ill-formed.") - }) - .unwrap_or_default() - } - - /// Installed languages under config directory - fn languages(&self) -> io::Result> { - let builtin = Resources::iter().filter_map(|name| { - name.strip_prefix("language/") - .map(ToOwned::to_owned) - .map(OsString::from) - }); - - let configured = self - .language_dir() - .read_dir() - .into_iter() - .flatten() - .map_while(Result::ok) - .map(|e| e.file_name()); - - Ok(builtin.chain(configured)) - } - - /// Config directory - fn config_dir(&self) -> PathBuf { - dirs::config_dir() - .expect("Failed to find config directory.") - .join("ttyper") - } - - /// Language directory under config directory - fn language_dir(&self) -> PathBuf { - self.config_dir().join("language") - } -} - -enum State { - Test(Test), - Results(Results), -} -impl State { - fn render_into( - &self, - terminal: &mut Terminal, - config: &Config, - ) -> io::Result<()> { - match self { - State::Test(test) => { - terminal.draw(|f| { - let area = f.size(); - f.render_widget(config.theme.apply_to(test), area); +use cli::{Command, Opt}; +use components::test::TestComponent; +use error::TtyperError; +use messages::Msg; +use model::{Id, Model}; +use types::Test; - // Position cursor at end of input for IME composition support - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Length(6)]) - .split(area); - let inner_x = chunks[0].x + 1; - let inner_y = chunks[0].y + 1; - let progress_width = - ratatui::text::Line::from(test.words[test.current_word].progress.as_str()) - .width() as u16; - let max_cursor_x = chunks[0].right().saturating_sub(2); - f.set_cursor((inner_x + progress_width).min(max_cursor_x), inner_y); - })?; - } - State::Results(results) => { - terminal.draw(|f| { - f.render_widget(config.theme.apply_to(results), f.size()); - })?; - } - } - Ok(()) - } -} - -fn main() -> io::Result<()> { +fn main() -> eyre::Result<()> { let opt = Opt::parse(); - if opt.debug { - dbg!(&opt); - } - - let config = opt.config(); - if opt.debug { - dbg!(&config); - } if let Some(Command::Completions { shell }) = opt.command { generate(shell, &mut Opt::command(), "ttyper", &mut io::stdout()); @@ -249,181 +33,89 @@ fn main() -> io::Result<()> { } if opt.list_languages { - opt.languages() - .unwrap() - .for_each(|name| println!("{}", name.to_str().expect("Ill-formatted language name."))); + opt.languages().map_err(TtyperError::Io)?.for_each(|name| { + if let Some(s) = name.to_str() { + println!("{}", s); + } + }); return Ok(()); } - let backend = CrosstermBackend::new(io::stdout()); - let mut terminal = Terminal::new(backend)?; - let contents = opt - .gen_contents() - .expect("Couldn't get test contents. Make sure the specified language actually exists."); + let config = opt.config(); + let contents = opt.gen_contents().ok_or_else(|| { + TtyperError::Content( + "Couldn't get test contents. Make sure the specified language actually exists.".into(), + ) + })?; if contents.is_empty() { - eprintln!("Error: the provided file or language contains no words to type."); - eprintln!("If you specified a file, make sure it isn't empty."); - std::process::exit(1); + return Err(eyre::eyre!("Error: the provided file or language contains no words to type. If you specified a file, make sure it isn't empty.")); } - terminal::enable_raw_mode()?; - execute!( - io::stdout(), - cursor::Hide, - cursor::SavePosition, - terminal::EnterAlternateScreen, - )?; - terminal.clear()?; - - let mut state = State::Test(Test::new( - contents, - !opt.no_backtrack, - opt.sudden_death, - !opt.no_backspace, - )); - - state.render_into(&mut terminal, &config)?; - loop { - let event = event::read()?; - - // handle exit controls - match event { - Event::Key(KeyEvent { - code: KeyCode::Char('c'), - kind: KeyEventKind::Press, - modifiers: KeyModifiers::CONTROL, - .. - }) => break, - Event::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - .. - }) => match state { - State::Test(ref test) => { - state = State::Results(Results::from(test)); - } - State::Results(_) => break, - }, - _ => {} - } - - match state { - State::Test(ref mut test) => { - if let Event::Key(key) = event { - test.handle_key(key); - if test.complete { - state = State::Results(Results::from(&*test)); - } - } + let mut terminal_bridge = + TerminalBridge::init_crossterm().map_err(|e| TtyperError::Terminal(e.to_string()))?; + + let mut app: Application = Application::init( + EventListenerCfg::::default() + .crossterm_input_listener(Duration::from_millis(20), 1) + .poll_timeout(Duration::from_millis(10)) + .tick_interval(Duration::from_secs(1)), + ); + + app.mount( + Id::Test, + Box::new(TestComponent { + test: Test::new( + contents, + !opt.no_backtrack, + opt.sudden_death, + !opt.no_backspace, + ), + theme: config.theme.clone(), + }), + vec![], + ) + .map_err(|e| TtyperError::Application(e.to_string()))?; + + // Enable focus + app.active(&Id::Test) + .map_err(|e| TtyperError::Application(e.to_string()))?; + + let mut model = Model::new(app, terminal_bridge, config, opt); + + // Terminal initialization (Alternate screen, Raw mode) is already handled by + let _ = model.terminal.clear_screen(); + + while !model.quit { + // Tick + match model.app.tick(PollStrategy::Once) { + Err(err) => { + let _ = model.terminal.restore(); + return Err(TtyperError::Application(err.to_string()).into()); } - State::Results(ref result) => match event { - Event::Key(KeyEvent { - code: KeyCode::Char('r'), - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - .. - }) => { - let new_contents = opt.gen_contents().expect( - "Couldn't get test contents. Make sure the specified language actually exists.", - ); - if new_contents.is_empty() { - continue; + Ok(messages) => { + if !messages.is_empty() { + model.redraw = true; + for msg in messages.into_iter() { + let mut msg = Some(msg); + while msg.is_some() { + msg = model.update(msg); + } } - state = State::Test(Test::new( - new_contents, - !opt.no_backtrack, - opt.sudden_death, - !opt.no_backspace, - )); } - Event::Key(KeyEvent { - code: KeyCode::Char('p'), - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - .. - }) => { - if result.missed_words.is_empty() { - continue; - } - // repeat each missed word 5 times - let mut practice_words: Vec = (result.missed_words) - .iter() - .flat_map(|w| vec![w.clone(); 5]) - .collect(); - practice_words.shuffle(&mut thread_rng()); - state = State::Test(Test::new( - practice_words, - !opt.no_backtrack, - opt.sudden_death, - !opt.no_backspace, - )); - } - Event::Key(KeyEvent { - code: KeyCode::Char('q'), - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - .. - }) => break, - _ => {} - }, + } } - state.render_into(&mut terminal, &config)?; - } - - terminal::disable_raw_mode()?; - execute!( - io::stdout(), - cursor::RestorePosition, - cursor::Show, - terminal::LeaveAlternateScreen, - )?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - fn make_opt(path: PathBuf) -> Opt { - Opt { - contents: Some(path), - debug: false, - words: num::NonZeroUsize::new(50).unwrap(), - config: None, - language_file: None, - language: None, - list_languages: false, - no_backtrack: false, - sudden_death: false, - no_backspace: false, - command: None, + // Redraw + if model.redraw { + model.view()?; + model.redraw = false; } } - #[test] - fn gen_contents_empty_file_returns_empty_vec() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("empty.txt"); - fs::File::create(&path).unwrap(); + // Restore terminal + let _ = model.terminal.restore(); - let contents = make_opt(path).gen_contents().unwrap(); - assert!(contents.is_empty(), "empty file should produce empty vec"); - } - - #[test] - fn gen_contents_nonempty_file_returns_words() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("words.txt"); - let mut f = fs::File::create(&path).unwrap(); - writeln!(f, "hello world rust").unwrap(); - - let contents = make_opt(path).gen_contents().unwrap(); - assert!(!contents.is_empty(), "non-empty file should produce words"); - } + Ok(()) } From 9dc21a62b35ea964c75cf03d507045bbe904ceae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:54:46 -0400 Subject: [PATCH 09/30] Dedicated file for supported messages --- src/messages.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/messages.rs diff --git a/src/messages.rs b/src/messages.rs new file mode 100644 index 0000000..3b95368 --- /dev/null +++ b/src/messages.rs @@ -0,0 +1,10 @@ +use crate::types::Results; + +#[derive(Debug, PartialEq, Clone)] +pub enum Msg { + AppClose, + ShowResults(Results), + RestartTest, + StartTest(Vec), + None, +} From 0894161804ebd5bcee381d85c2575b687545de74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:55:02 -0400 Subject: [PATCH 10/30] Dedicated message processing --- src/model.rs | 137 ++++++++++++++ src/ui.rs | 512 --------------------------------------------------- 2 files changed, 137 insertions(+), 512 deletions(-) create mode 100644 src/model.rs delete mode 100644 src/ui.rs diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..4582c65 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,137 @@ +use tuirealm::ratatui::layout::{Constraint, Direction, Layout}; +use tuirealm::terminal::CrosstermTerminalAdapter; +use tuirealm::{event::NoUserEvent, terminal::TerminalBridge, Application, Update}; + +use crate::cli::Opt; +use crate::components::result::ResultsComponent; +use crate::components::test::TestComponent; +use crate::config::Config; +use crate::error::Result; +use crate::messages::Msg; +use crate::types::Test; + +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +pub enum Id { + Test, + Results, +} + +pub struct Model { + pub app: Application, + pub quit: bool, + pub redraw: bool, + pub terminal: TerminalBridge, + pub config: Config, + pub opt: Opt, +} + +impl Model { + pub fn new( + app: Application, + terminal: TerminalBridge, + config: Config, + opt: Opt, + ) -> Self { + Self { + app, + quit: false, + redraw: true, + terminal, + config, + opt, + } + } + + pub fn view(&mut self) -> Result<()> { + self.terminal + .draw(|f| { + let area = f.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(100)]) + .split(area); + + // Render the active view + // We assume only one view is active at a time (Test or Results) + if self.app.active(&Id::Test).is_ok() { + self.app.view(&Id::Test, f, chunks[0]); + } else if self.app.active(&Id::Results).is_ok() { + self.app.view(&Id::Results, f, chunks[0]); + } + }) + .map_err(|e| crate::error::TtyperError::Terminal(e.to_string()))?; + Ok(()) + } +} + +impl Update for Model { + fn update(&mut self, msg: Option) -> Option { + if let Some(msg) = msg { + self.redraw = true; + match msg { + Msg::AppClose => { + self.quit = true; + None + } + Msg::ShowResults(results) => { + // Mount Results component + let _ = self.app.umount(&Id::Test); + let _ = self.app.mount( + Id::Results, + Box::new(ResultsComponent { + results, + theme: self.config.theme.clone(), + }), + vec![], + ); + let _ = self.app.active(&Id::Results); + None + } + Msg::RestartTest => { + // Generate new content + let contents = self.opt.gen_contents().unwrap_or_default(); + + // Mount Test component + let _ = self.app.umount(&Id::Results); + let _ = self.app.mount( + Id::Test, + Box::new(TestComponent { + test: Test::new( + contents, + !self.opt.no_backtrack, + self.opt.sudden_death, + !self.opt.no_backspace, + ), + theme: self.config.theme.clone(), + }), + vec![], + ); + let _ = self.app.active(&Id::Test); + None + } + Msg::StartTest(words) => { + // Mount Test component with specific words + let _ = self.app.umount(&Id::Results); + let _ = self.app.mount( + Id::Test, + Box::new(TestComponent { + test: Test::new( + words, + !self.opt.no_backtrack, + self.opt.sudden_death, + !self.opt.no_backspace, + ), + theme: self.config.theme.clone(), + }), + vec![], + ); + let _ = self.app.active(&Id::Test); + None + } + Msg::None => None, + } + } else { + None + } + } +} diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index 68a69fb..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,512 +0,0 @@ -use crate::config::Theme; - -use super::test::{results, Test, TestWord}; - -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use ratatui::{ - buffer::Buffer, - layout::{Constraint, Direction, Layout, Rect}, - symbols::Marker, - text::{Line, Span, Text}, - widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Widget}, -}; -use results::Fraction; - -// Convert CPS to WPM (clicks per second) -const WPM_PER_CPS: f64 = 12.0; - -// Width of the moving average window for the WPM chart -const WPM_SMA_WIDTH: usize = 10; - -#[derive(Clone)] -struct SizedBlock<'a> { - block: Block<'a>, - area: Rect, -} - -impl SizedBlock<'_> { - fn render(self, buf: &mut Buffer) { - self.block.render(self.area, buf) - } -} - -trait UsedWidget: Widget {} -impl UsedWidget for Paragraph<'_> {} - -trait DrawInner { - fn draw_inner(&self, content: T, buf: &mut Buffer); -} - -impl DrawInner<&Line<'_>> for SizedBlock<'_> { - fn draw_inner(&self, content: &Line, buf: &mut Buffer) { - let inner = self.block.inner(self.area); - buf.set_line(inner.x, inner.y, content, inner.width); - } -} - -impl DrawInner for SizedBlock<'_> { - fn draw_inner(&self, content: T, buf: &mut Buffer) { - let inner = self.block.inner(self.area); - content.render(inner, buf); - } -} - -pub trait ThemedWidget { - fn render(self, area: Rect, buf: &mut Buffer, theme: &Theme); -} - -pub struct Themed<'t, W: ?Sized> { - theme: &'t Theme, - widget: W, -} -impl Widget for Themed<'_, W> { - fn render(self, area: Rect, buf: &mut Buffer) { - self.widget.render(area, buf, self.theme) - } -} -impl Theme { - pub fn apply_to(&self, widget: W) -> Themed<'_, W> { - Themed { - theme: self, - widget, - } - } -} - -impl ThemedWidget for &Test { - fn render(self, area: Rect, buf: &mut Buffer, theme: &Theme) { - buf.set_style(area, theme.default); - - // Chunks - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Length(6)]) - .split(area); - - // Sections - let input = SizedBlock { - block: Block::default() - .title(Line::from(vec![Span::styled("Input", theme.title)])) - .borders(Borders::ALL) - .border_type(theme.border_type) - .border_style(theme.input_border), - area: chunks[0], - }; - input.draw_inner( - &Line::from(self.words[self.current_word].progress.clone()), - buf, - ); - input.render(buf); - - let target_lines: Vec = { - let words = words_to_spans(&self.words, self.current_word, theme); - - let mut lines: Vec = Vec::new(); - let mut current_line: Vec = Vec::new(); - let mut current_width = 0; - for word in words { - let word_width: usize = word.iter().map(|s| s.width()).sum(); - - if current_width + word_width > chunks[1].width as usize - 2 { - current_line.push(Span::raw("\n")); - lines.push(Line::from(current_line.clone())); - current_line.clear(); - current_width = 0; - } - - current_line.extend(word); - current_width += word_width; - } - lines.push(Line::from(current_line)); - - lines - }; - let target = Paragraph::new(target_lines).block( - Block::default() - .title(Span::styled("Prompt", theme.title)) - .borders(Borders::ALL) - .border_type(theme.border_type) - .border_style(theme.prompt_border), - ); - target.render(chunks[1], buf); - } -} - -fn words_to_spans<'a>( - words: &'a [TestWord], - current_word: usize, - theme: &'a Theme, -) -> Vec>> { - let mut spans = Vec::new(); - - for word in &words[..current_word] { - let parts = split_typed_word(word); - spans.push(word_parts_to_spans(parts, theme)); - } - - let parts_current = split_current_word(&words[current_word]); - spans.push(word_parts_to_spans(parts_current, theme)); - - for word in &words[current_word + 1..] { - let parts = vec![(word.text.clone(), Status::Untyped)]; - spans.push(word_parts_to_spans(parts, theme)); - } - spans -} - -#[derive(PartialEq, Clone, Copy, Debug)] -enum Status { - Correct, - Incorrect, - CurrentUntyped, - CurrentCorrect, - CurrentIncorrect, - Cursor, - Untyped, - Overtyped, -} - -fn split_current_word(word: &TestWord) -> Vec<(String, Status)> { - let mut parts = Vec::new(); - let mut cur_string = String::new(); - let mut cur_status = Status::Untyped; - - let mut progress = word.progress.chars(); - for tc in word.text.chars() { - let p = progress.next(); - let status = match p { - None => Status::CurrentUntyped, - Some(c) => match c { - c if c == tc => Status::CurrentCorrect, - _ => Status::CurrentIncorrect, - }, - }; - - if status == cur_status { - cur_string.push(tc); - } else { - if !cur_string.is_empty() { - parts.push((cur_string, cur_status)); - cur_string = String::new(); - } - cur_string.push(tc); - cur_status = status; - - // first currentuntyped is cursor - if status == Status::CurrentUntyped { - parts.push((cur_string, Status::Cursor)); - cur_string = String::new(); - } - } - } - if !cur_string.is_empty() { - parts.push((cur_string, cur_status)); - } - let overtyped = progress.collect::(); - if !overtyped.is_empty() { - parts.push((overtyped, Status::Overtyped)); - } - parts -} - -fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> { - let mut parts = Vec::new(); - let mut cur_string = String::new(); - let mut cur_status = Status::Untyped; - - let mut progress = word.progress.chars(); - for tc in word.text.chars() { - let p = progress.next(); - let status = match p { - None => Status::Untyped, - Some(c) => match c { - c if c == tc => Status::Correct, - _ => Status::Incorrect, - }, - }; - - if status == cur_status { - cur_string.push(tc); - } else { - if !cur_string.is_empty() { - parts.push((cur_string, cur_status)); - cur_string = String::new(); - } - cur_string.push(tc); - cur_status = status; - } - } - if !cur_string.is_empty() { - parts.push((cur_string, cur_status)); - } - - let overtyped = progress.collect::(); - if !overtyped.is_empty() { - parts.push((overtyped, Status::Overtyped)); - } - parts -} - -fn word_parts_to_spans(parts: Vec<(String, Status)>, theme: &Theme) -> Vec> { - let mut spans = Vec::new(); - for (text, status) in parts { - let style = match status { - Status::Correct => theme.prompt_correct, - Status::Incorrect => theme.prompt_incorrect, - Status::Untyped => theme.prompt_untyped, - Status::CurrentUntyped => theme.prompt_current_untyped, - Status::CurrentCorrect => theme.prompt_current_correct, - Status::CurrentIncorrect => theme.prompt_current_incorrect, - Status::Cursor => theme.prompt_current_untyped.patch(theme.prompt_cursor), - Status::Overtyped => theme.prompt_incorrect, - }; - - spans.push(Span::styled(text, style)); - } - spans.push(Span::styled(" ", theme.prompt_untyped)); - spans -} - -impl ThemedWidget for &results::Results { - fn render(self, area: Rect, buf: &mut Buffer, theme: &Theme) { - buf.set_style(area, theme.default); - - // Chunks - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(1)]) - .split(area); - let res_chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) // Graph looks tremendously better with just a little margin - .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) - .split(chunks[0]); - let info_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) - .split(res_chunks[0]); - - let msg = if self.missed_words.is_empty() { - "Press 'q' to quit or 'r' for another test" - } else { - "Press 'q' to quit, 'r' for another test or 'p' to practice missed words" - }; - - let exit = Span::styled(msg, theme.results_restart_prompt); - buf.set_span(chunks[1].x, chunks[1].y, &exit, chunks[1].width); - - // Sections - let mut overview_text = Text::styled("", theme.results_overview); - overview_text.extend([ - Line::from(format!( - "Adjusted WPM: {:.1}", - self.timing.overall_cps * WPM_PER_CPS * f64::from(self.accuracy.overall) - )), - Line::from(format!( - "Accuracy: {:.1}%", - f64::from(self.accuracy.overall) * 100f64 - )), - Line::from(format!( - "Raw WPM: {:.1}", - self.timing.overall_cps * WPM_PER_CPS - )), - Line::from(format!("Correct Keypresses: {}", self.accuracy.overall)), - ]); - let overview = Paragraph::new(overview_text).block( - Block::default() - .title(Span::styled("Overview", theme.title)) - .borders(Borders::ALL) - .border_type(theme.border_type) - .border_style(theme.results_overview_border), - ); - overview.render(info_chunks[0], buf); - - let mut worst_keys: Vec<(&KeyEvent, &Fraction)> = self - .accuracy - .per_key - .iter() - .filter(|(key, _)| matches!(key.code, KeyCode::Char(_))) - .collect(); - worst_keys.sort_unstable_by_key(|x| x.1); - - let mut worst_text = Text::styled("", theme.results_worst_keys); - worst_text.extend( - worst_keys - .iter() - .filter_map(|(key, acc)| { - if let KeyCode::Char(character) = key.code { - let key_accuracy = f64::from(**acc) * 100.0; - if key_accuracy != 100.0 { - Some(format!("- {} at {:.1}% accuracy", character, key_accuracy)) - } else { - None - } - } else { - None - } - }) - .take(5) - .map(Line::from), - ); - let worst = Paragraph::new(worst_text).block( - Block::default() - .title(Span::styled("Worst Keys", theme.title)) - .borders(Borders::ALL) - .border_type(theme.border_type) - .border_style(theme.results_worst_keys_border), - ); - worst.render(info_chunks[1], buf); - - let wpm_sma: Vec<(f64, f64)> = self - .timing - .per_event - .windows(WPM_SMA_WIDTH) - .enumerate() - .map(|(i, window)| { - ( - (i + WPM_SMA_WIDTH) as f64, - window.len() as f64 / window.iter().copied().sum::() * WPM_PER_CPS, - ) - }) - .collect(); - - // Render the chart if possible - if !wpm_sma.is_empty() { - let wpm_sma_min = wpm_sma - .iter() - .map(|(_, x)| x) - .fold(f64::INFINITY, |a, &b| a.min(b)); - let wpm_sma_max = wpm_sma - .iter() - .map(|(_, x)| x) - .fold(f64::NEG_INFINITY, |a, &b| a.max(b)); - - let wpm_datasets = vec![Dataset::default() - .name("WPM") - .marker(Marker::Braille) - .graph_type(GraphType::Line) - .style(theme.results_chart) - .data(&wpm_sma)]; - - let y_label_min = wpm_sma_min as u16; - let y_label_max = (wpm_sma_max as u16).max(y_label_min + 6); - - let wpm_chart = Chart::new(wpm_datasets) - .block(Block::default().title(vec![Span::styled("Chart", theme.title)])) - .x_axis( - Axis::default() - .title(Span::styled("Keypresses", theme.results_chart_x)) - .bounds([0.0, self.timing.per_event.len() as f64]), - ) - .y_axis( - Axis::default() - .title(Span::styled( - "WPM (10-keypress rolling average)", - theme.results_chart_y, - )) - .bounds([wpm_sma_min, wpm_sma_max]) - .labels( - (y_label_min..y_label_max) - .step_by(5) - .map(|n| Span::raw(format!("{}", n))) - .collect(), - ), - ); - wpm_chart.render(res_chunks[1], buf); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - mod split_words { - use super::Status::*; - use super::*; - - struct TestCase { - word: &'static str, - progress: &'static str, - expected: Vec<(&'static str, Status)>, - } - - fn setup(test_case: TestCase) -> (TestWord, Vec<(String, Status)>) { - let mut word = TestWord::from(test_case.word); - word.progress = test_case.progress.to_string(); - - let expected = test_case - .expected - .iter() - .map(|(s, v)| (s.to_string(), *v)) - .collect::>(); - - (word, expected) - } - - #[test] - fn typed_words_split() { - let cases = vec![ - TestCase { - word: "monkeytype", - progress: "monkeytype", - expected: vec![("monkeytype", Correct)], - }, - TestCase { - word: "monkeytype", - progress: "monkeXtype", - expected: vec![("monke", Correct), ("y", Incorrect), ("type", Correct)], - }, - TestCase { - word: "monkeytype", - progress: "monkeas", - expected: vec![("monke", Correct), ("yt", Incorrect), ("ype", Untyped)], - }, - ]; - - for case in cases { - let (word, expected) = setup(case); - let got = split_typed_word(&word); - assert_eq!(got, expected); - } - } - - #[test] - fn current_word_split() { - let cases = vec![ - TestCase { - word: "monkeytype", - progress: "monkeytype", - expected: vec![("monkeytype", CurrentCorrect)], - }, - TestCase { - word: "monkeytype", - progress: "monke", - expected: vec![ - ("monke", CurrentCorrect), - ("y", Cursor), - ("type", CurrentUntyped), - ], - }, - TestCase { - word: "monkeytype", - progress: "monkeXt", - expected: vec![ - ("monke", CurrentCorrect), - ("y", CurrentIncorrect), - ("t", CurrentCorrect), - ("y", Cursor), - ("pe", CurrentUntyped), - ], - }, - ]; - - for case in cases { - let (word, expected) = setup(case); - let got = split_current_word(&word); - assert_eq!(got, expected); - } - } - } -} From ea12dc3dc3b46616cc106b7eb6fbf526fa39e247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:55:10 -0400 Subject: [PATCH 11/30] Refactored resource handling --- src/resources.rs | 114 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/resources.rs diff --git a/src/resources.rs b/src/resources.rs new file mode 100644 index 0000000..e85cead --- /dev/null +++ b/src/resources.rs @@ -0,0 +1,114 @@ +use crate::cli::Opt; +use crate::config::Config; +use crate::error::{Result, TtyperError}; +use rand::{seq::SliceRandom, thread_rng}; +use rust_embed::RustEmbed; +use std::ffi::OsString; +use std::io::BufRead; +use std::path::PathBuf; +use std::{fs, io, str}; + +#[derive(RustEmbed)] +#[folder = "resources/runtime"] +struct Resources; + +impl Opt { + pub fn gen_contents(&self) -> Option> { + match &self.contents { + Some(path) => { + let lines: Vec = if path.as_os_str() == "-" { + std::io::stdin() + .lock() + .lines() + .map_while(std::result::Result::ok) + .collect() + } else { + let file = fs::File::open(path).ok()?; + io::BufReader::new(file) + .lines() + .map_while(std::result::Result::ok) + .collect() + }; + + Some(lines.iter().map(String::from).collect()) + } + None => { + let lang_name = self + .language + .clone() + .unwrap_or_else(|| self.config().default_language); + + let bytes: Vec = self + .language_file + .as_ref() + .map(fs::read) + .and_then(std::result::Result::ok) + .or_else(|| fs::read(self.language_dir().join(&lang_name)).ok()) + .or_else(|| { + Resources::get(&format!("language/{}", &lang_name)) + .map(|f| f.data.into_owned()) + })?; + + let mut rng = thread_rng(); + + let mut language: Vec<&str> = str::from_utf8(&bytes).ok()?.lines().collect(); + language.shuffle(&mut rng); + + let mut contents: Vec<_> = language + .into_iter() + .cycle() + .take(self.words.get()) + .map(ToOwned::to_owned) + .collect(); + contents.shuffle(&mut rng); + + Some(contents) + } + } + } + + /// Configuration + pub fn config(&self) -> Config { + let config_path = self + .config + .clone() + .unwrap_or_else(|| self.config_dir().join("config.toml")); + + fs::read(config_path) + .map(|bytes| { + toml::from_str(str::from_utf8(&bytes).unwrap_or_default()).unwrap_or_default() + }) + .unwrap_or_default() + } + + /// Installed languages under config directory + pub fn languages(&self) -> io::Result> { + let builtin = Resources::iter().filter_map(|name| { + name.strip_prefix("language/") + .map(ToOwned::to_owned) + .map(OsString::from) + }); + + let configured = self + .language_dir() + .read_dir() + .into_iter() + .flatten() + .map_while(std::result::Result::ok) + .map(|e| e.file_name()); + + Ok(builtin.chain(configured)) + } + + /// Config directory + pub fn config_dir(&self) -> PathBuf { + dirs::config_dir() + .map(|d| d.join("ttyper")) + .unwrap_or_else(|| PathBuf::from(".")) + } + + /// Language directory under config directory + pub fn language_dir(&self) -> PathBuf { + self.config_dir().join("language") + } +} From b0ee391a3faf4d91325701449e5b72819cc10124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 18:55:16 -0400 Subject: [PATCH 12/30] Core types --- src/mod.rs | 2 + src/types.rs | 343 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 src/mod.rs create mode 100644 src/types.rs diff --git a/src/mod.rs b/src/mod.rs new file mode 100644 index 0000000..c6a7284 --- /dev/null +++ b/src/mod.rs @@ -0,0 +1,2 @@ +pub mod types; +pub mod error; diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..cce10c9 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,343 @@ +use std::collections::HashMap; +use std::time::Instant; +use std::{cmp, fmt}; +use tuirealm::ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +pub type StartTestWords = Vec; + +pub struct TestEvent { + pub time: Instant, + pub key: KeyEvent, + pub correct: Option, +} + +pub fn is_missed_word_event(event: &TestEvent) -> bool { + event.correct != Some(true) +} + +impl fmt::Debug for TestEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TestEvent") + .field("time", &String::from("Instant { ... }")) + .field("key", &self.key) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TestWord { + pub text: String, + pub progress: String, + pub events: Vec, +} + +impl Clone for TestEvent { + fn clone(&self) -> Self { + Self { + time: self.time, + key: self.key, + correct: self.correct, + } + } +} + +impl PartialEq for TestEvent { + fn eq(&self, other: &Self) -> bool { + self.time == other.time && self.key == other.key && self.correct == other.correct + } +} + +impl From for TestWord { + fn from(string: String) -> Self { + TestWord { + text: string, + progress: String::new(), + events: Vec::new(), + } + } +} + +impl From<&str> for TestWord { + fn from(string: &str) -> Self { + Self::from(string.to_string()) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Test { + pub words: Vec, + pub current_word: usize, + pub complete: bool, + pub backtracking_enabled: bool, + pub sudden_death_enabled: bool, + pub backspace_enabled: bool, +} + +impl Test { + pub fn new( + words: Vec, + backtracking_enabled: bool, + sudden_death_enabled: bool, + backspace_enabled: bool, + ) -> Self { + Self { + words: words.into_iter().map(TestWord::from).collect(), + current_word: 0, + complete: false, + backtracking_enabled, + sudden_death_enabled, + backspace_enabled, + } + } + + pub fn handle_key(&mut self, key: KeyEvent) { + if key.kind != KeyEventKind::Press { + return; + } + + let word = &mut self.words[self.current_word]; + match key.code { + KeyCode::Char(' ') | KeyCode::Enter => { + if word.text.chars().nth(word.progress.len()) == Some(' ') { + word.progress.push(' '); + word.events.push(TestEvent { + time: Instant::now(), + correct: Some(true), + key, + }) + } else if !word.progress.is_empty() || word.text.is_empty() { + let correct = word.text == word.progress; + if self.sudden_death_enabled && !correct { + self.reset(); + } else { + word.events.push(TestEvent { + time: Instant::now(), + correct: Some(correct), + key, + }); + self.next_word(); + } + } + } + KeyCode::Backspace => { + if word.progress.is_empty() && self.backtracking_enabled && self.backspace_enabled { + self.last_word(); + } else if self.backspace_enabled { + word.events.push(TestEvent { + time: Instant::now(), + correct: Some(!word.text.starts_with(&word.progress[..])), + key, + }); + word.progress.pop(); + } + } + // CTRL-BackSpace and CTRL-W + KeyCode::Char('h') | KeyCode::Char('w') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + if self.words[self.current_word].progress.is_empty() { + self.last_word(); + } + + let word = &mut self.words[self.current_word]; + + word.events.push(TestEvent { + time: Instant::now(), + correct: None, + key, + }); + word.progress.clear(); + } + KeyCode::Char(c) => { + word.progress.push(c); + let correct = word.text.starts_with(&word.progress[..]); + if self.sudden_death_enabled && !correct { + self.reset(); + } else { + word.events.push(TestEvent { + time: Instant::now(), + correct: Some(correct), + key, + }); + if word.progress == word.text && self.current_word == self.words.len() - 1 { + self.complete = true; + self.current_word = 0; + } + } + } + _ => {} + }; + } + + fn last_word(&mut self) { + if self.current_word != 0 { + self.current_word -= 1; + } + } + + fn next_word(&mut self) { + if self.current_word == self.words.len() - 1 { + self.complete = true; + self.current_word = 0; + } else { + self.current_word += 1; + } + } + + fn reset(&mut self) { + self.words.iter_mut().for_each(|word: &mut TestWord| { + word.progress.clear(); + word.events.clear(); + }); + self.current_word = 0; + self.complete = false; + } +} + +// Result types + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Fraction { + pub numerator: usize, + pub denominator: usize, +} + +impl Fraction { + pub const fn new(numerator: usize, denominator: usize) -> Self { + Self { + numerator, + denominator, + } + } +} + +impl From for f64 { + fn from(f: Fraction) -> Self { + f.numerator as f64 / f.denominator as f64 + } +} + +impl cmp::Ord for Fraction { + fn cmp(&self, other: &Self) -> cmp::Ordering { + f64::from(*self).partial_cmp(&f64::from(*other)).unwrap() + } +} + +impl PartialOrd for Fraction { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl fmt::Display for Fraction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.numerator, self.denominator) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TimingData { + // Instead of storing WPM, we store CPS (clicks per second) + pub overall_cps: f64, + pub per_event: Vec, + pub per_key: HashMap, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AccuracyData { + pub overall: Fraction, + pub per_key: HashMap, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Results { + pub timing: TimingData, + pub accuracy: AccuracyData, + pub missed_words: Vec, +} + +impl From<&Test> for Results { + fn from(test: &Test) -> Self { + let events: Vec<&TestEvent> = test.words.iter().flat_map(|w| w.events.iter()).collect(); + + Self { + timing: calc_timing(&events), + accuracy: calc_accuracy(&events), + missed_words: calc_missed_words(test), + } + } +} + +fn calc_timing(events: &[&TestEvent]) -> TimingData { + let mut timing = TimingData { + overall_cps: -1.0, + per_event: Vec::new(), + per_key: HashMap::new(), + }; + + // map of keys to a two-tuple (total time, clicks) for counting average + let mut keys: HashMap = HashMap::new(); + + for win in events.windows(2) { + let event_dur = win[1] + .time + .checked_duration_since(win[0].time) + .map(|d| d.as_secs_f64()); + + if let Some(event_dur) = event_dur { + timing.per_event.push(event_dur); + + let key = keys.entry(win[1].key).or_insert((0.0, 0)); + key.0 += event_dur; + key.1 += 1; + } + } + + timing.per_key = keys + .into_iter() + .map(|(key, (total, count))| (key, total / count as f64)) + .collect(); + + timing.overall_cps = if timing.per_event.is_empty() { + 0.0 + } else { + timing.per_event.len() as f64 / timing.per_event.iter().sum::() + }; + + timing +} + +fn calc_accuracy(events: &[&TestEvent]) -> AccuracyData { + let mut acc = AccuracyData { + overall: Fraction::new(0, 0), + per_key: HashMap::new(), + }; + + events + .iter() + .filter(|event| event.correct.is_some()) + .for_each(|event| { + let key = acc + .per_key + .entry(event.key) + .or_insert_with(|| Fraction::new(0, 0)); + + acc.overall.denominator += 1; + key.denominator += 1; + + if event.correct.unwrap() { + acc.overall.numerator += 1; + key.numerator += 1; + } + }); + + acc +} + +fn calc_missed_words(test: &Test) -> Vec { + test.words + .iter() + .filter(|word| word.events.iter().any(is_missed_word_event)) + .map(|word| word.text.clone()) + .collect() +} From 42b2f23ad0c8e80b0c59ba0be8aa645e6582c422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 19:13:15 -0400 Subject: [PATCH 13/30] Kind of psuedo-code refactor towards a model I like --- src/main.rs | 70 +++++--- src/mod.rs | 2 - src/types.rs | 343 ------------------------------------- src/types/accuracy_data.rs | 5 + src/{ => types}/cli.rs | 0 src/types/fraction.rs | 38 ++++ src/types/results.rs | 18 ++ src/types/test/handler.rs | 103 +++++++++++ src/types/test/mod.rs | 29 ++++ src/types/test_event.rs | 34 ++++ src/types/test_word.rs | 22 +++ src/types/timing_data.rs | 7 + src/utils.rs | 73 ++++++++ 13 files changed, 374 insertions(+), 370 deletions(-) delete mode 100644 src/mod.rs delete mode 100644 src/types.rs create mode 100644 src/types/accuracy_data.rs rename src/{ => types}/cli.rs (100%) create mode 100644 src/types/fraction.rs create mode 100644 src/types/results.rs create mode 100644 src/types/test/handler.rs create mode 100644 src/types/test/mod.rs create mode 100644 src/types/test_event.rs create mode 100644 src/types/test_word.rs create mode 100644 src/types/timing_data.rs create mode 100644 src/utils.rs diff --git a/src/main.rs b/src/main.rs index ba448ab..9b5ea31 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,14 @@ -mod cli; mod components; mod config; mod error; mod messages; mod model; mod resources; -mod types; use clap::CommandFactory; use clap::Parser; use clap_complete::generate; +use clap_complete::Shell; use std::io; use std::time::Duration; use tuirealm::{ @@ -24,25 +23,22 @@ use messages::Msg; use model::{Id, Model}; use types::Test; -fn main() -> eyre::Result<()> { - let opt = Opt::parse(); - - if let Some(Command::Completions { shell }) = opt.command { - generate(shell, &mut Opt::command(), "ttyper", &mut io::stdout()); - return Ok(()); - } +fn list_languages() -> eyre::Result<()> { + opt.languages().map_err(TtyperError::Io)?.for_each(|name| { + if let Some(s) = name.to_str() { + println!("{}", s); + } + }); - if opt.list_languages { - opt.languages().map_err(TtyperError::Io)?.for_each(|name| { - if let Some(s) = name.to_str() { - println!("{}", s); - } - }); + return Ok(()); +} - return Ok(()); - } +fn generate_completions(shell: Shell) -> eyre::Result<()> { + generate(shell, &mut Opt::command(), "ttyper", &mut io::stdout()); + return Ok(()); +} - let config = opt.config(); +fn make_test(opt) -> eyre::Result<()> { let contents = opt.gen_contents().ok_or_else(|| { TtyperError::Content( "Couldn't get test contents. Make sure the specified language actually exists.".into(), @@ -52,8 +48,31 @@ fn main() -> eyre::Result<()> { if contents.is_empty() { return Err(eyre::eyre!("Error: the provided file or language contains no words to type. If you specified a file, make sure it isn't empty.")); } +} + +fn main() -> eyre::Result<()> { + let options = Opt::parse(); + let config = options.config(); + + if let Some(Command::Completions { shell }) = options.command { + generate_completions(shell)? + } + + if options.list_languages { + list_languages(); + } + + let contents = make_test(options); + + let (app, terminal_bridge) = setup_ttyper(contents, options); + + let mut model = Model::new(app, terminal_bridge, config, options); - let mut terminal_bridge = + Ok(()) +} + +fn setup_ttyper(contents, options) -> (app, bridge) { + let mut terminal_bridge = TerminalBridge::init_crossterm().map_err(|e| TtyperError::Terminal(e.to_string()))?; let mut app: Application = Application::init( @@ -68,9 +87,9 @@ fn main() -> eyre::Result<()> { Box::new(TestComponent { test: Test::new( contents, - !opt.no_backtrack, - opt.sudden_death, - !opt.no_backspace, + !options.no_backtrack, + options.sudden_death, + !options.no_backspace, ), theme: config.theme.clone(), }), @@ -82,9 +101,11 @@ fn main() -> eyre::Result<()> { app.active(&Id::Test) .map_err(|e| TtyperError::Application(e.to_string()))?; - let mut model = Model::new(app, terminal_bridge, config, opt); +} + - // Terminal initialization (Alternate screen, Raw mode) is already handled by +fn event_loop(model: Model) -> eyre::Result<()> { + // Terminal initialization (Alternate screen, Raw mode) is already handled by let _ = model.terminal.clear_screen(); while !model.quit { @@ -117,5 +138,4 @@ fn main() -> eyre::Result<()> { // Restore terminal let _ = model.terminal.restore(); - Ok(()) } diff --git a/src/mod.rs b/src/mod.rs deleted file mode 100644 index c6a7284..0000000 --- a/src/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod types; -pub mod error; diff --git a/src/types.rs b/src/types.rs deleted file mode 100644 index cce10c9..0000000 --- a/src/types.rs +++ /dev/null @@ -1,343 +0,0 @@ -use std::collections::HashMap; -use std::time::Instant; -use std::{cmp, fmt}; -use tuirealm::ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; - -pub type StartTestWords = Vec; - -pub struct TestEvent { - pub time: Instant, - pub key: KeyEvent, - pub correct: Option, -} - -pub fn is_missed_word_event(event: &TestEvent) -> bool { - event.correct != Some(true) -} - -impl fmt::Debug for TestEvent { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("TestEvent") - .field("time", &String::from("Instant { ... }")) - .field("key", &self.key) - .finish() - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct TestWord { - pub text: String, - pub progress: String, - pub events: Vec, -} - -impl Clone for TestEvent { - fn clone(&self) -> Self { - Self { - time: self.time, - key: self.key, - correct: self.correct, - } - } -} - -impl PartialEq for TestEvent { - fn eq(&self, other: &Self) -> bool { - self.time == other.time && self.key == other.key && self.correct == other.correct - } -} - -impl From for TestWord { - fn from(string: String) -> Self { - TestWord { - text: string, - progress: String::new(), - events: Vec::new(), - } - } -} - -impl From<&str> for TestWord { - fn from(string: &str) -> Self { - Self::from(string.to_string()) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Test { - pub words: Vec, - pub current_word: usize, - pub complete: bool, - pub backtracking_enabled: bool, - pub sudden_death_enabled: bool, - pub backspace_enabled: bool, -} - -impl Test { - pub fn new( - words: Vec, - backtracking_enabled: bool, - sudden_death_enabled: bool, - backspace_enabled: bool, - ) -> Self { - Self { - words: words.into_iter().map(TestWord::from).collect(), - current_word: 0, - complete: false, - backtracking_enabled, - sudden_death_enabled, - backspace_enabled, - } - } - - pub fn handle_key(&mut self, key: KeyEvent) { - if key.kind != KeyEventKind::Press { - return; - } - - let word = &mut self.words[self.current_word]; - match key.code { - KeyCode::Char(' ') | KeyCode::Enter => { - if word.text.chars().nth(word.progress.len()) == Some(' ') { - word.progress.push(' '); - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(true), - key, - }) - } else if !word.progress.is_empty() || word.text.is_empty() { - let correct = word.text == word.progress; - if self.sudden_death_enabled && !correct { - self.reset(); - } else { - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(correct), - key, - }); - self.next_word(); - } - } - } - KeyCode::Backspace => { - if word.progress.is_empty() && self.backtracking_enabled && self.backspace_enabled { - self.last_word(); - } else if self.backspace_enabled { - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(!word.text.starts_with(&word.progress[..])), - key, - }); - word.progress.pop(); - } - } - // CTRL-BackSpace and CTRL-W - KeyCode::Char('h') | KeyCode::Char('w') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - if self.words[self.current_word].progress.is_empty() { - self.last_word(); - } - - let word = &mut self.words[self.current_word]; - - word.events.push(TestEvent { - time: Instant::now(), - correct: None, - key, - }); - word.progress.clear(); - } - KeyCode::Char(c) => { - word.progress.push(c); - let correct = word.text.starts_with(&word.progress[..]); - if self.sudden_death_enabled && !correct { - self.reset(); - } else { - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(correct), - key, - }); - if word.progress == word.text && self.current_word == self.words.len() - 1 { - self.complete = true; - self.current_word = 0; - } - } - } - _ => {} - }; - } - - fn last_word(&mut self) { - if self.current_word != 0 { - self.current_word -= 1; - } - } - - fn next_word(&mut self) { - if self.current_word == self.words.len() - 1 { - self.complete = true; - self.current_word = 0; - } else { - self.current_word += 1; - } - } - - fn reset(&mut self) { - self.words.iter_mut().for_each(|word: &mut TestWord| { - word.progress.clear(); - word.events.clear(); - }); - self.current_word = 0; - self.complete = false; - } -} - -// Result types - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub struct Fraction { - pub numerator: usize, - pub denominator: usize, -} - -impl Fraction { - pub const fn new(numerator: usize, denominator: usize) -> Self { - Self { - numerator, - denominator, - } - } -} - -impl From for f64 { - fn from(f: Fraction) -> Self { - f.numerator as f64 / f.denominator as f64 - } -} - -impl cmp::Ord for Fraction { - fn cmp(&self, other: &Self) -> cmp::Ordering { - f64::from(*self).partial_cmp(&f64::from(*other)).unwrap() - } -} - -impl PartialOrd for Fraction { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl fmt::Display for Fraction { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}/{}", self.numerator, self.denominator) - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct TimingData { - // Instead of storing WPM, we store CPS (clicks per second) - pub overall_cps: f64, - pub per_event: Vec, - pub per_key: HashMap, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct AccuracyData { - pub overall: Fraction, - pub per_key: HashMap, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Results { - pub timing: TimingData, - pub accuracy: AccuracyData, - pub missed_words: Vec, -} - -impl From<&Test> for Results { - fn from(test: &Test) -> Self { - let events: Vec<&TestEvent> = test.words.iter().flat_map(|w| w.events.iter()).collect(); - - Self { - timing: calc_timing(&events), - accuracy: calc_accuracy(&events), - missed_words: calc_missed_words(test), - } - } -} - -fn calc_timing(events: &[&TestEvent]) -> TimingData { - let mut timing = TimingData { - overall_cps: -1.0, - per_event: Vec::new(), - per_key: HashMap::new(), - }; - - // map of keys to a two-tuple (total time, clicks) for counting average - let mut keys: HashMap = HashMap::new(); - - for win in events.windows(2) { - let event_dur = win[1] - .time - .checked_duration_since(win[0].time) - .map(|d| d.as_secs_f64()); - - if let Some(event_dur) = event_dur { - timing.per_event.push(event_dur); - - let key = keys.entry(win[1].key).or_insert((0.0, 0)); - key.0 += event_dur; - key.1 += 1; - } - } - - timing.per_key = keys - .into_iter() - .map(|(key, (total, count))| (key, total / count as f64)) - .collect(); - - timing.overall_cps = if timing.per_event.is_empty() { - 0.0 - } else { - timing.per_event.len() as f64 / timing.per_event.iter().sum::() - }; - - timing -} - -fn calc_accuracy(events: &[&TestEvent]) -> AccuracyData { - let mut acc = AccuracyData { - overall: Fraction::new(0, 0), - per_key: HashMap::new(), - }; - - events - .iter() - .filter(|event| event.correct.is_some()) - .for_each(|event| { - let key = acc - .per_key - .entry(event.key) - .or_insert_with(|| Fraction::new(0, 0)); - - acc.overall.denominator += 1; - key.denominator += 1; - - if event.correct.unwrap() { - acc.overall.numerator += 1; - key.numerator += 1; - } - }); - - acc -} - -fn calc_missed_words(test: &Test) -> Vec { - test.words - .iter() - .filter(|word| word.events.iter().any(is_missed_word_event)) - .map(|word| word.text.clone()) - .collect() -} diff --git a/src/types/accuracy_data.rs b/src/types/accuracy_data.rs new file mode 100644 index 0000000..ff5d7d9 --- /dev/null +++ b/src/types/accuracy_data.rs @@ -0,0 +1,5 @@ +#[derive(Clone, Debug, PartialEq)] +pub struct AccuracyData { + pub overall: Fraction, + pub per_key: HashMap, +} diff --git a/src/cli.rs b/src/types/cli.rs similarity index 100% rename from src/cli.rs rename to src/types/cli.rs diff --git a/src/types/fraction.rs b/src/types/fraction.rs new file mode 100644 index 0000000..bd0c398 --- /dev/null +++ b/src/types/fraction.rs @@ -0,0 +1,38 @@ +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Fraction { + pub numerator: usize, + pub denominator: usize, +} + +impl Fraction { + pub const fn new(numerator: usize, denominator: usize) -> Self { + Self { + numerator, + denominator, + } + } +} + +impl From for f64 { + fn from(f: Fraction) -> Self { + f.numerator as f64 / f.denominator as f64 + } +} + +impl cmp::Ord for Fraction { + fn cmp(&self, other: &Self) -> cmp::Ordering { + f64::from(*self).partial_cmp(&f64::from(*other)).unwrap() + } +} + +impl PartialOrd for Fraction { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl fmt::Display for Fraction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.numerator, self.denominator) + } +} diff --git a/src/types/results.rs b/src/types/results.rs new file mode 100644 index 0000000..015aaf2 --- /dev/null +++ b/src/types/results.rs @@ -0,0 +1,18 @@ +#[derive(Clone, Debug, PartialEq)] +pub struct Results { + pub timing: TimingData, + pub accuracy: AccuracyData, + pub missed_words: Vec, +} + +impl From<&Test> for Results { + fn from(test: &Test) -> Self { + let events: Vec<&TestEvent> = test.words.iter().flat_map(|w| w.events.iter()).collect(); + + Self { + timing: calc_timing(&events), + accuracy: calc_accuracy(&events), + missed_words: calc_missed_words(test), + } + } +} diff --git a/src/types/test/handler.rs b/src/types/test/handler.rs new file mode 100644 index 0000000..39139f1 --- /dev/null +++ b/src/types/test/handler.rs @@ -0,0 +1,103 @@ +impl Test { + pub fn handle_key(&mut self, key: KeyEvent) { + if key.kind != KeyEventKind::Press { + return; + } + + let word = &mut self.words[self.current_word]; + match key.code { + KeyCode::Char(' ') | KeyCode::Enter => { + if word.text.chars().nth(word.progress.len()) == Some(' ') { + word.progress.push(' '); + word.events.push(TestEvent { + time: Instant::now(), + correct: Some(true), + key, + }) + } else if !word.progress.is_empty() || word.text.is_empty() { + let correct = word.text == word.progress; + if self.sudden_death_enabled && !correct { + self.reset(); + } else { + word.events.push(TestEvent { + time: Instant::now(), + correct: Some(correct), + key, + }); + self.next_word(); + } + } + } + KeyCode::Backspace => { + if word.progress.is_empty() && self.backtracking_enabled && self.backspace_enabled { + self.last_word(); + } else if self.backspace_enabled { + word.events.push(TestEvent { + time: Instant::now(), + correct: Some(!word.text.starts_with(&word.progress[..])), + key, + }); + word.progress.pop(); + } + } + // CTRL-BackSpace and CTRL-W + KeyCode::Char('h') | KeyCode::Char('w') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + if self.words[self.current_word].progress.is_empty() { + self.last_word(); + } + + let word = &mut self.words[self.current_word]; + + word.events.push(TestEvent { + time: Instant::now(), + correct: None, + key, + }); + word.progress.clear(); + } + KeyCode::Char(c) => { + word.progress.push(c); + let correct = word.text.starts_with(&word.progress[..]); + if self.sudden_death_enabled && !correct { + self.reset(); + } else { + word.events.push(TestEvent { + time: Instant::now(), + correct: Some(correct), + key, + }); + if word.progress == word.text && self.current_word == self.words.len() - 1 { + self.complete = true; + self.current_word = 0; + } + } + } + _ => {} + }; + } + fn last_word(&mut self) { + if self.current_word != 0 { + self.current_word -= 1; + } + } + + fn next_word(&mut self) { + if self.current_word == self.words.len() - 1 { + self.complete = true; + self.current_word = 0; + } else { + self.current_word += 1; + } + } + + fn reset(&mut self) { + self.words.iter_mut().for_each(|word: &mut TestWord| { + word.progress.clear(); + word.events.clear(); + }); + self.current_word = 0; + self.complete = false; + } +} diff --git a/src/types/test/mod.rs b/src/types/test/mod.rs new file mode 100644 index 0000000..c7362d7 --- /dev/null +++ b/src/types/test/mod.rs @@ -0,0 +1,29 @@ +pub mod handler; + +#[derive(Debug, Clone, PartialEq)] +pub struct Test { + pub words: Vec, + pub current_word: usize, + pub complete: bool, + pub backtracking_enabled: bool, + pub sudden_death_enabled: bool, + pub backspace_enabled: bool, +} + +impl Test { + pub fn new( + words: Vec, + backtracking_enabled: bool, + sudden_death_enabled: bool, + backspace_enabled: bool, + ) -> Self { + Self { + words: words.into_iter().map(TestWord::from).collect(), + current_word: 0, + complete: false, + backtracking_enabled, + sudden_death_enabled, + backspace_enabled, + } + } +} diff --git a/src/types/test_event.rs b/src/types/test_event.rs new file mode 100644 index 0000000..2b0b8b5 --- /dev/null +++ b/src/types/test_event.rs @@ -0,0 +1,34 @@ +pub struct TestEvent { + pub time: Instant, + pub key: KeyEvent, + pub correct: Option, +} + +pub fn is_missed_word_event(event: &TestEvent) -> bool { + event.correct != Some(true) +} + +impl fmt::Debug for TestEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TestEvent") + .field("time", &String::from("Instant { ... }")) + .field("key", &self.key) + .finish() + } +} + +impl PartialEq for TestEvent { + fn eq(&self, other: &Self) -> bool { + self.time == other.time && self.key == other.key && self.correct == other.correct + } +} + +impl Clone for TestEvent { + fn clone(&self) -> Self { + Self { + time: self.time, + key: self.key, + correct: self.correct, + } + } +} diff --git a/src/types/test_word.rs b/src/types/test_word.rs new file mode 100644 index 0000000..a917fa2 --- /dev/null +++ b/src/types/test_word.rs @@ -0,0 +1,22 @@ +#[derive(Debug, Clone, PartialEq)] +pub struct TestWord { + pub text: String, + pub progress: String, + pub events: Vec, +} + +impl From for TestWord { + fn from(string: String) -> Self { + TestWord { + text: string, + progress: String::new(), + events: Vec::new(), + } + } +} + +impl From<&str> for TestWord { + fn from(string: &str) -> Self { + Self::from(string.to_string()) + } +} diff --git a/src/types/timing_data.rs b/src/types/timing_data.rs new file mode 100644 index 0000000..57e3109 --- /dev/null +++ b/src/types/timing_data.rs @@ -0,0 +1,7 @@ +#[derive(Clone, Debug, PartialEq)] +pub struct TimingData { + // Instead of storing WPM, we store CPS (clicks per second) + pub overall_cps: f64, + pub per_event: Vec, + pub per_key: HashMap, +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..81fa8ad --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,73 @@ +fn calc_timing(events: &[&TestEvent]) -> TimingData { + let mut timing = TimingData { + overall_cps: -1.0, + per_event: Vec::new(), + per_key: HashMap::new(), + }; + + // map of keys to a two-tuple (total time, clicks) for counting average + let mut keys: HashMap = HashMap::new(); + + for win in events.windows(2) { + let event_dur = win[1] + .time + .checked_duration_since(win[0].time) + .map(|d| d.as_secs_f64()); + + if let Some(event_dur) = event_dur { + timing.per_event.push(event_dur); + + let key = keys.entry(win[1].key).or_insert((0.0, 0)); + key.0 += event_dur; + key.1 += 1; + } + } + + timing.per_key = keys + .into_iter() + .map(|(key, (total, count))| (key, total / count as f64)) + .collect(); + + timing.overall_cps = if timing.per_event.is_empty() { + 0.0 + } else { + timing.per_event.len() as f64 / timing.per_event.iter().sum::() + }; + + timing +} + +fn calc_accuracy(events: &[&TestEvent]) -> AccuracyData { + let mut acc = AccuracyData { + overall: Fraction::new(0, 0), + per_key: HashMap::new(), + }; + + events + .iter() + .filter(|event| event.correct.is_some()) + .for_each(|event| { + let key = acc + .per_key + .entry(event.key) + .or_insert_with(|| Fraction::new(0, 0)); + + acc.overall.denominator += 1; + key.denominator += 1; + + if event.correct.unwrap() { + acc.overall.numerator += 1; + key.numerator += 1; + } + }); + + acc +} + +fn calc_missed_words(test: &Test) -> Vec { + test.words + .iter() + .filter(|word| word.events.iter().any(is_missed_word_event)) + .map(|word| word.text.clone()) + .collect() +} From 964df5c68f270f9debf275f55336000a29c61433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 23:07:17 -0400 Subject: [PATCH 14/30] Calculate --- src/{utils.rs => calculate.rs} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename src/{utils.rs => calculate.rs} (91%) diff --git a/src/utils.rs b/src/calculate.rs similarity index 91% rename from src/utils.rs rename to src/calculate.rs index 81fa8ad..0410fca 100644 --- a/src/utils.rs +++ b/src/calculate.rs @@ -1,4 +1,4 @@ -fn calc_timing(events: &[&TestEvent]) -> TimingData { +fn timing(events: &[&TestEvent]) -> TimingData { let mut timing = TimingData { overall_cps: -1.0, per_event: Vec::new(), @@ -37,7 +37,7 @@ fn calc_timing(events: &[&TestEvent]) -> TimingData { timing } -fn calc_accuracy(events: &[&TestEvent]) -> AccuracyData { +fn accuracy(events: &[&TestEvent]) -> AccuracyData { let mut acc = AccuracyData { overall: Fraction::new(0, 0), per_key: HashMap::new(), @@ -64,7 +64,7 @@ fn calc_accuracy(events: &[&TestEvent]) -> AccuracyData { acc } -fn calc_missed_words(test: &Test) -> Vec { +fn missed_words(test: &Test) -> Vec { test.words .iter() .filter(|word| word.events.iter().any(is_missed_word_event)) From 1424d8201fa0c1fc039c015f9ebe3cea8852c0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 23:15:48 -0400 Subject: [PATCH 15/30] Relinked everything --- src/calculate.rs | 13 +++++++---- src/components/result.rs | 8 +++---- src/main.rs | 48 ++++++++++++++++++++++++-------------- src/model.rs | 13 +++-------- src/resources.rs | 9 +++---- src/types/accuracy_data.rs | 4 ++++ src/types/fraction.rs | 21 ++++++++++++----- src/types/mod.rs | 23 ++++++++++++++++++ src/types/results.rs | 9 ++++--- src/types/test/handler.rs | 5 ++++ src/types/test/mod.rs | 1 + src/types/test_event.rs | 4 ++++ src/types/test_word.rs | 2 ++ src/types/timing_data.rs | 3 +++ 14 files changed, 115 insertions(+), 48 deletions(-) create mode 100644 src/types/mod.rs diff --git a/src/calculate.rs b/src/calculate.rs index 0410fca..50b3102 100644 --- a/src/calculate.rs +++ b/src/calculate.rs @@ -1,4 +1,9 @@ -fn timing(events: &[&TestEvent]) -> TimingData { +use std::collections::HashMap; +use tuirealm::ratatui::crossterm::event::{KeyEvent}; +use crate::types::{TestEvent, TimingData, AccuracyData, Fraction, Test}; +use crate::types::test_event::is_missed_word_event; + +pub fn timing(events: &[&TestEvent]) -> TimingData { let mut timing = TimingData { overall_cps: -1.0, per_event: Vec::new(), @@ -37,7 +42,7 @@ fn timing(events: &[&TestEvent]) -> TimingData { timing } -fn accuracy(events: &[&TestEvent]) -> AccuracyData { +pub fn accuracy(events: &[&TestEvent]) -> AccuracyData { let mut acc = AccuracyData { overall: Fraction::new(0, 0), per_key: HashMap::new(), @@ -55,7 +60,7 @@ fn accuracy(events: &[&TestEvent]) -> AccuracyData { acc.overall.denominator += 1; key.denominator += 1; - if event.correct.unwrap() { + if event.correct.unwrap_or(false) { acc.overall.numerator += 1; key.numerator += 1; } @@ -64,7 +69,7 @@ fn accuracy(events: &[&TestEvent]) -> AccuracyData { acc } -fn missed_words(test: &Test) -> Vec { +pub fn missed_words(test: &Test) -> Vec { test.words .iter() .filter(|word| word.events.iter().any(is_missed_word_event)) diff --git a/src/components/result.rs b/src/components/result.rs index 7031ac7..ffe718c 100644 --- a/src/components/result.rs +++ b/src/components/result.rs @@ -134,7 +134,7 @@ impl MockComponent for ResultsComponent { .per_event .windows(WPM_SMA_WIDTH) .enumerate() - .map(|(i, window)| { + .map(|(i, window): (usize, &[f64])| { ( (i + WPM_SMA_WIDTH) as f64, window.len() as f64 / window.iter().copied().sum::() * WPM_PER_CPS, @@ -147,11 +147,11 @@ impl MockComponent for ResultsComponent { let wpm_sma_min = wpm_sma .iter() .map(|(_, x)| x) - .fold(f64::INFINITY, |a, &b| a.min(b)); + .fold(f64::INFINITY, |a: f64, &b: &f64| a.min(b)); let wpm_sma_max = wpm_sma .iter() .map(|(_, x)| x) - .fold(f64::NEG_INFINITY, |a, &b| a.max(b)); + .fold(f64::NEG_INFINITY, |a: f64, &b: &f64| a.max(b)); let wpm_datasets = vec![Dataset::default() .name("WPM") @@ -225,7 +225,7 @@ impl Component for ResultsComponent { // repeat each missed word 5 times let mut practice_words: Vec = (self.results.missed_words) .iter() - .flat_map(|w| vec![w.clone(); 5]) + .flat_map(|w: &String| vec![w.clone(); 5]) .collect(); practice_words.shuffle(&mut thread_rng()); diff --git a/src/main.rs b/src/main.rs index 9b5ea31..3569a4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ +mod calculate; mod components; mod config; mod error; mod messages; mod model; mod resources; +mod types; use clap::CommandFactory; use clap::Parser; @@ -16,14 +18,14 @@ use tuirealm::{ Update, }; -use cli::{Command, Opt}; -use components::test::TestComponent; -use error::TtyperError; -use messages::Msg; -use model::{Id, Model}; -use types::Test; +use crate::components::test::TestComponent; +use crate::config::Config; +use crate::error::TtyperError; +use crate::messages::Msg; +use crate::model::Model; +use crate::types::{Command, Id, Opt, Test}; -fn list_languages() -> eyre::Result<()> { +fn list_languages(opt: &Opt) -> eyre::Result<()> { opt.languages().map_err(TtyperError::Io)?.for_each(|name| { if let Some(s) = name.to_str() { println!("{}", s); @@ -38,7 +40,7 @@ fn generate_completions(shell: Shell) -> eyre::Result<()> { return Ok(()); } -fn make_test(opt) -> eyre::Result<()> { +fn make_test(opt: &Opt) -> eyre::Result> { let contents = opt.gen_contents().ok_or_else(|| { TtyperError::Content( "Couldn't get test contents. Make sure the specified language actually exists.".into(), @@ -48,6 +50,8 @@ fn make_test(opt) -> eyre::Result<()> { if contents.is_empty() { return Err(eyre::eyre!("Error: the provided file or language contains no words to type. If you specified a file, make sure it isn't empty.")); } + + Ok(contents) } fn main() -> eyre::Result<()> { @@ -59,20 +63,29 @@ fn main() -> eyre::Result<()> { } if options.list_languages { - list_languages(); + list_languages(&options)?; } - let contents = make_test(options); + let contents = make_test(&options)?; + + let (app, terminal_bridge) = setup_ttyper(contents, &options, &config)?; - let (app, terminal_bridge) = setup_ttyper(contents, options); + let model = Model::new(app, terminal_bridge, config, options); - let mut model = Model::new(app, terminal_bridge, config, options); + event_loop(model)?; Ok(()) } -fn setup_ttyper(contents, options) -> (app, bridge) { - let mut terminal_bridge = +fn setup_ttyper( + contents: Vec, + options: &Opt, + config: &Config, +) -> eyre::Result<( + Application, + TerminalBridge, +)> { + let terminal_bridge = TerminalBridge::init_crossterm().map_err(|e| TtyperError::Terminal(e.to_string()))?; let mut app: Application = Application::init( @@ -101,11 +114,11 @@ fn setup_ttyper(contents, options) -> (app, bridge) { app.active(&Id::Test) .map_err(|e| TtyperError::Application(e.to_string()))?; + Ok((app, terminal_bridge)) } - -fn event_loop(model: Model) -> eyre::Result<()> { - // Terminal initialization (Alternate screen, Raw mode) is already handled by +fn event_loop(mut model: Model) -> eyre::Result<()> { + // Terminal initialization (Alternate screen, Raw mode) is already handled by let _ = model.terminal.clear_screen(); while !model.quit { @@ -138,4 +151,5 @@ fn event_loop(model: Model) -> eyre::Result<()> { // Restore terminal let _ = model.terminal.restore(); + Ok(()) } diff --git a/src/model.rs b/src/model.rs index 4582c65..9fa1a5b 100644 --- a/src/model.rs +++ b/src/model.rs @@ -2,19 +2,12 @@ use tuirealm::ratatui::layout::{Constraint, Direction, Layout}; use tuirealm::terminal::CrosstermTerminalAdapter; use tuirealm::{event::NoUserEvent, terminal::TerminalBridge, Application, Update}; -use crate::cli::Opt; use crate::components::result::ResultsComponent; use crate::components::test::TestComponent; use crate::config::Config; use crate::error::Result; use crate::messages::Msg; -use crate::types::Test; - -#[derive(Debug, Eq, PartialEq, Clone, Hash)] -pub enum Id { - Test, - Results, -} +use crate::types::{Id, Opt, Test}; pub struct Model { pub app: Application, @@ -54,9 +47,9 @@ impl Model { // Render the active view // We assume only one view is active at a time (Test or Results) if self.app.active(&Id::Test).is_ok() { - self.app.view(&Id::Test, f, chunks[0]); + let _ = self.app.view(&Id::Test, f, chunks[0]); } else if self.app.active(&Id::Results).is_ok() { - self.app.view(&Id::Results, f, chunks[0]); + let _ = self.app.view(&Id::Results, f, chunks[0]); } }) .map_err(|e| crate::error::TtyperError::Terminal(e.to_string()))?; diff --git a/src/resources.rs b/src/resources.rs index e85cead..1cde648 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -1,6 +1,6 @@ -use crate::cli::Opt; use crate::config::Config; use crate::error::{Result, TtyperError}; +use crate::types::Opt; use rand::{seq::SliceRandom, thread_rng}; use rust_embed::RustEmbed; use std::ffi::OsString; @@ -54,7 +54,7 @@ impl Opt { let mut language: Vec<&str> = str::from_utf8(&bytes).ok()?.lines().collect(); language.shuffle(&mut rng); - let mut contents: Vec<_> = language + let mut contents: Vec = language .into_iter() .cycle() .take(self.words.get()) @@ -76,7 +76,8 @@ impl Opt { fs::read(config_path) .map(|bytes| { - toml::from_str(str::from_utf8(&bytes).unwrap_or_default()).unwrap_or_default() + toml::from_str::(str::from_utf8(&bytes).unwrap_or_default()) + .unwrap_or_default() }) .unwrap_or_default() } @@ -95,7 +96,7 @@ impl Opt { .into_iter() .flatten() .map_while(std::result::Result::ok) - .map(|e| e.file_name()); + .map(|e: fs::DirEntry| e.file_name()); Ok(builtin.chain(configured)) } diff --git a/src/types/accuracy_data.rs b/src/types/accuracy_data.rs index ff5d7d9..d7ce709 100644 --- a/src/types/accuracy_data.rs +++ b/src/types/accuracy_data.rs @@ -1,3 +1,7 @@ +use std::collections::HashMap; +use tuirealm::ratatui::crossterm::event::KeyEvent; +use crate::types::Fraction; + #[derive(Clone, Debug, PartialEq)] pub struct AccuracyData { pub overall: Fraction, diff --git a/src/types/fraction.rs b/src/types/fraction.rs index bd0c398..53e75a0 100644 --- a/src/types/fraction.rs +++ b/src/types/fraction.rs @@ -1,11 +1,14 @@ -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +use std::cmp; +use std::fmt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub struct Fraction { pub numerator: usize, pub denominator: usize, } impl Fraction { - pub const fn new(numerator: usize, denominator: usize) -> Self { + pub fn new(numerator: usize, denominator: usize) -> Self { Self { numerator, denominator, @@ -14,18 +17,24 @@ impl Fraction { } impl From for f64 { - fn from(f: Fraction) -> Self { - f.numerator as f64 / f.denominator as f64 + fn from(fraction: Fraction) -> Self { + if fraction.denominator == 0 { + 0.0 + } else { + fraction.numerator as f64 / fraction.denominator as f64 + } } } impl cmp::Ord for Fraction { fn cmp(&self, other: &Self) -> cmp::Ordering { - f64::from(*self).partial_cmp(&f64::from(*other)).unwrap() + f64::from(*self) + .partial_cmp(&f64::from(*other)) + .unwrap_or(cmp::Ordering::Equal) } } -impl PartialOrd for Fraction { +impl cmp::PartialOrd for Fraction { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } diff --git a/src/types/mod.rs b/src/types/mod.rs new file mode 100644 index 0000000..a9f54b0 --- /dev/null +++ b/src/types/mod.rs @@ -0,0 +1,23 @@ +pub mod accuracy_data; +pub mod cli; +pub mod fraction; +pub mod results; +pub mod test; +pub mod test_event; +pub mod test_word; +pub mod timing_data; + +pub use accuracy_data::AccuracyData; +pub use cli::{Command, Opt}; +pub use fraction::Fraction; +pub use results::Results; +pub use test::Test; +pub use test_event::TestEvent; +pub use test_word::TestWord; +pub use timing_data::TimingData; + +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +pub enum Id { + Test, + Results, +} diff --git a/src/types/results.rs b/src/types/results.rs index 015aaf2..2ea1b40 100644 --- a/src/types/results.rs +++ b/src/types/results.rs @@ -1,3 +1,6 @@ +use crate::calculate; +use crate::types::{AccuracyData, Test, TestEvent, TimingData}; + #[derive(Clone, Debug, PartialEq)] pub struct Results { pub timing: TimingData, @@ -10,9 +13,9 @@ impl From<&Test> for Results { let events: Vec<&TestEvent> = test.words.iter().flat_map(|w| w.events.iter()).collect(); Self { - timing: calc_timing(&events), - accuracy: calc_accuracy(&events), - missed_words: calc_missed_words(test), + timing: calculate::timing(&events), + accuracy: calculate::accuracy(&events), + missed_words: calculate::missed_words(test), } } } diff --git a/src/types/test/handler.rs b/src/types/test/handler.rs index 39139f1..262233f 100644 --- a/src/types/test/handler.rs +++ b/src/types/test/handler.rs @@ -1,3 +1,8 @@ +use super::Test; +use crate::types::{TestEvent, TestWord}; +use std::time::Instant; +use tuirealm::ratatui::crossterm::event::{KeyEvent, KeyCode, KeyModifiers, KeyEventKind}; + impl Test { pub fn handle_key(&mut self, key: KeyEvent) { if key.kind != KeyEventKind::Press { diff --git a/src/types/test/mod.rs b/src/types/test/mod.rs index c7362d7..fab91ab 100644 --- a/src/types/test/mod.rs +++ b/src/types/test/mod.rs @@ -1,4 +1,5 @@ pub mod handler; +use crate::types::TestWord; #[derive(Debug, Clone, PartialEq)] pub struct Test { diff --git a/src/types/test_event.rs b/src/types/test_event.rs index 2b0b8b5..d3d8e6a 100644 --- a/src/types/test_event.rs +++ b/src/types/test_event.rs @@ -1,3 +1,7 @@ +use std::time::Instant; +use tuirealm::ratatui::crossterm::event::KeyEvent; +use std::fmt; + pub struct TestEvent { pub time: Instant, pub key: KeyEvent, diff --git a/src/types/test_word.rs b/src/types/test_word.rs index a917fa2..216744b 100644 --- a/src/types/test_word.rs +++ b/src/types/test_word.rs @@ -1,3 +1,5 @@ +use crate::types::TestEvent; + #[derive(Debug, Clone, PartialEq)] pub struct TestWord { pub text: String, diff --git a/src/types/timing_data.rs b/src/types/timing_data.rs index 57e3109..e9fb911 100644 --- a/src/types/timing_data.rs +++ b/src/types/timing_data.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use tuirealm::ratatui::crossterm::event::KeyEvent; + #[derive(Clone, Debug, PartialEq)] pub struct TimingData { // Instead of storing WPM, we store CPS (clicks per second) From 1119e7668b244e91b207087aa8570d5c9cd9dbf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 24 Mar 2026 23:18:04 -0400 Subject: [PATCH 16/30] Cargo clippy and cargo check --- Cargo.toml | 36 ++++++++++++++++++++++++++++++++++++ src/components/result.rs | 5 +---- src/components/test.rs | 13 +++++++------ src/error.rs | 3 --- src/main.rs | 4 ++-- src/model.rs | 4 ++-- src/resources.rs | 1 - src/types/cli.rs | 2 +- 8 files changed, 49 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 95c3f7c..f72182e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,3 +34,39 @@ features = ["derive"] [dev-dependencies] tempfile = "3.27.0" +[workspace.lints.rust] +mismatched_lifetime_syntaxes = "deny" +unused_imports = "deny" +unused_must_use = "deny" +dead_code = "deny" +unstable_name_collisions = "deny" +unused_assignments = "deny" +deprecated = "deny" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +perf = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } + +# deny +filetype_is_file = "deny" +cargo = { level = "deny", priority = -1 } +unwrap_used = "deny" +panic = "deny" +match_like_matches_macro = "deny" +needless_update = "deny" +expect_used = "deny" + +# allow +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +empty_docs = "allow" +unnecessary_debug_formatting = "allow" + +# allow +significant_drop_tightening = "allow" +missing_panics_doc = "allow" +multiple_crate_versions = "allow" + diff --git a/src/components/result.rs b/src/components/result.rs index ffe718c..9663eda 100644 --- a/src/components/result.rs +++ b/src/components/result.rs @@ -1,7 +1,5 @@ use rand::{seq::SliceRandom, thread_rng}; -use tuirealm::ratatui::crossterm::event::{ - KeyCode as CrosstermKeyCode, KeyModifiers as CrosstermKeyModifiers, -}; +use tuirealm::ratatui::crossterm::event::KeyCode as CrosstermKeyCode; use tuirealm::ratatui::{ layout::{Constraint, Direction, Layout, Rect}, symbols::Marker, @@ -11,7 +9,6 @@ use tuirealm::ratatui::{ use tuirealm::{ command::{Cmd, CmdResult}, event::{Event, Key, KeyModifiers, NoUserEvent}, - props::{Alignment, Borders as TuiBorders, Color, Style}, AttrValue, Attribute, Component, Frame, MockComponent, State, }; diff --git a/src/components/test.rs b/src/components/test.rs index 1d08a5e..b0d5e24 100644 --- a/src/components/test.rs +++ b/src/components/test.rs @@ -1,13 +1,11 @@ use tuirealm::ratatui::{ - buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Widget}, }; use tuirealm::{ command::{Cmd, CmdResult}, - event::{Event, Key, KeyModifiers as TuiKeyModifiers, NoUserEvent}, - props::{Alignment, Borders as TuiBorders, Color, Style}, + event::{Event, NoUserEvent}, AttrValue, Attribute, Component, Frame, MockComponent, State, }; @@ -166,13 +164,16 @@ fn convert_tuirealm_to_crossterm_key( }; let mut modifiers = KeyModifiers::empty(); - if key.modifiers.contains(TuiKeyModifiers::SHIFT) { + if key.modifiers.contains(tuirealm::event::KeyModifiers::SHIFT) { modifiers.insert(KeyModifiers::SHIFT); } - if key.modifiers.contains(TuiKeyModifiers::CONTROL) { + if key + .modifiers + .contains(tuirealm::event::KeyModifiers::CONTROL) + { modifiers.insert(KeyModifiers::CONTROL); } - if key.modifiers.contains(TuiKeyModifiers::ALT) { + if key.modifiers.contains(tuirealm::event::KeyModifiers::ALT) { modifiers.insert(KeyModifiers::ALT); } diff --git a/src/error.rs b/src/error.rs index e5187e0..6e6c419 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,9 +11,6 @@ pub enum TtyperError { #[error("Application error: {0}")] Application(String), - #[error("Configuration error: {0}")] - Config(String), - #[error("Content error: {0}")] Content(String), } diff --git a/src/main.rs b/src/main.rs index 3569a4d..e1a9a0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,12 +32,12 @@ fn list_languages(opt: &Opt) -> eyre::Result<()> { } }); - return Ok(()); + Ok(()) } fn generate_completions(shell: Shell) -> eyre::Result<()> { generate(shell, &mut Opt::command(), "ttyper", &mut io::stdout()); - return Ok(()); + Ok(()) } fn make_test(opt: &Opt) -> eyre::Result> { diff --git a/src/model.rs b/src/model.rs index 9fa1a5b..8a4856b 100644 --- a/src/model.rs +++ b/src/model.rs @@ -47,9 +47,9 @@ impl Model { // Render the active view // We assume only one view is active at a time (Test or Results) if self.app.active(&Id::Test).is_ok() { - let _ = self.app.view(&Id::Test, f, chunks[0]); + self.app.view(&Id::Test, f, chunks[0]); } else if self.app.active(&Id::Results).is_ok() { - let _ = self.app.view(&Id::Results, f, chunks[0]); + self.app.view(&Id::Results, f, chunks[0]); } }) .map_err(|e| crate::error::TtyperError::Terminal(e.to_string()))?; diff --git a/src/resources.rs b/src/resources.rs index 1cde648..733a70f 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -1,5 +1,4 @@ use crate::config::Config; -use crate::error::{Result, TtyperError}; use crate::types::Opt; use rand::{seq::SliceRandom, thread_rng}; use rust_embed::RustEmbed; diff --git a/src/types/cli.rs b/src/types/cli.rs index 62ff72c..41acea1 100644 --- a/src/types/cli.rs +++ b/src/types/cli.rs @@ -1,4 +1,4 @@ -use clap::{CommandFactory, Parser, Subcommand}; +use clap::{Parser, Subcommand}; use clap_complete::Shell; use std::path::PathBuf; use std::{num, str}; From 362b08974f14dc1df5a0db5069d0e5432ca86b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Wed, 25 Mar 2026 21:09:37 -0400 Subject: [PATCH 17/30] Renaming and cleanup --- src/components/result.rs | 67 ++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/src/components/result.rs b/src/components/result.rs index 9663eda..1096b59 100644 --- a/src/components/result.rs +++ b/src/components/result.rs @@ -1,5 +1,5 @@ use rand::{seq::SliceRandom, thread_rng}; -use tuirealm::ratatui::crossterm::event::KeyCode as CrosstermKeyCode; +use tuirealm::ratatui::crossterm::event::{KeyCode as CrosstermKeyCode, KeyEvent}; use tuirealm::ratatui::{ layout::{Constraint, Direction, Layout, Rect}, symbols::Marker, @@ -17,10 +17,10 @@ use crate::messages::Msg; use crate::types::{Fraction, Results}; // Convert CPS to WPM (clicks per second) -const WPM_PER_CPS: f64 = 12.0; +const WORDS_PER_MINUTE_PER_CPS: f64 = 12.0; // Width of the moving average window for the WPM chart -const WPM_SMA_WIDTH: usize = 10; +const WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH: usize = 10; pub struct ResultsComponent { pub results: Results, @@ -29,25 +29,29 @@ pub struct ResultsComponent { impl MockComponent for ResultsComponent { fn view(&mut self, frame: &mut Frame, area: Rect) { - let buf = frame.buffer_mut(); + let buffer = frame.buffer_mut(); - buf.set_style(area, self.theme.default); + buffer.set_style(area, self.theme.default); // Chunks let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(1)]) .split(area); - let res_chunks = Layout::default() + + let result_chunks = Layout::default() .direction(Direction::Vertical) .margin(1) // Graph looks tremendously better with just a little margin .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) .split(chunks[0]); + let info_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) - .split(res_chunks[0]); + .split(result_chunks[0]); + // Handling the incomplete tests + // TODO: Show a better screen here let msg = if self.results.missed_words.is_empty() { "Press 'q' to quit or 'r' for another test" } else { @@ -55,7 +59,7 @@ impl MockComponent for ResultsComponent { }; let exit = Span::styled(msg, self.theme.results_restart_prompt); - buf.set_span(chunks[1].x, chunks[1].y, &exit, chunks[1].width); + buffer.set_span(chunks[1].x, chunks[1].y, &exit, chunks[1].width); // Sections let mut overview_text = Text::styled("", self.theme.results_overview); @@ -63,7 +67,7 @@ impl MockComponent for ResultsComponent { Line::from(format!( "Adjusted WPM: {:.1}", self.results.timing.overall_cps - * WPM_PER_CPS + * WORDS_PER_MINUTE_PER_CPS * f64::from(self.results.accuracy.overall) )), Line::from(format!( @@ -72,7 +76,7 @@ impl MockComponent for ResultsComponent { )), Line::from(format!( "Raw WPM: {:.1}", - self.results.timing.overall_cps * WPM_PER_CPS + self.results.timing.overall_cps * WORDS_PER_MINUTE_PER_CPS )), Line::from(format!( "Correct Keypresses: {}", @@ -86,15 +90,17 @@ impl MockComponent for ResultsComponent { .border_type(self.theme.border_type) .border_style(self.theme.results_overview_border), ); - overview.render(info_chunks[0], buf); + overview.render(info_chunks[0], buffer); - let mut worst_keys: Vec<(&tuirealm::ratatui::crossterm::event::KeyEvent, &Fraction)> = self + let mut worst_keys: Vec<(&KeyEvent, &Fraction)> = self .results .accuracy .per_key .iter() .filter(|(key, _)| matches!(key.code, CrosstermKeyCode::Char(_))) .collect(); + + // Unstable because we don't care about order, just results worst_keys.sort_unstable_by_key(|x| x.1); let mut worst_text = Text::styled("", self.theme.results_worst_keys); @@ -116,6 +122,7 @@ impl MockComponent for ResultsComponent { .take(5) .map(Line::from), ); + let worst = Paragraph::new(worst_text).block( Block::default() .title(Span::styled("Worst Keys", self.theme.title)) @@ -123,29 +130,32 @@ impl MockComponent for ResultsComponent { .border_type(self.theme.border_type) .border_style(self.theme.results_worst_keys_border), ); - worst.render(info_chunks[1], buf); - let wpm_sma: Vec<(f64, f64)> = self + worst.render(info_chunks[1], buffer); + + let words_per_minute_sliding_moving_average: Vec<(f64, f64)> = self .results .timing .per_event - .windows(WPM_SMA_WIDTH) + .windows(WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH) .enumerate() .map(|(i, window): (usize, &[f64])| { ( - (i + WPM_SMA_WIDTH) as f64, - window.len() as f64 / window.iter().copied().sum::() * WPM_PER_CPS, + (i + WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH) as f64, + window.len() as f64 / window.iter().copied().sum::() + * WORDS_PER_MINUTE_PER_CPS, ) }) .collect(); - // Render the chart if possible - if !wpm_sma.is_empty() { - let wpm_sma_min = wpm_sma + // Render the chart if possible. + if words_per_minute_sliding_moving_average.is_empty() { + let minimum_average = words_per_minute_sliding_moving_average .iter() .map(|(_, x)| x) .fold(f64::INFINITY, |a: f64, &b: &f64| a.min(b)); - let wpm_sma_max = wpm_sma + + let maximum_average = words_per_minute_sliding_moving_average .iter() .map(|(_, x)| x) .fold(f64::NEG_INFINITY, |a: f64, &b: &f64| a.max(b)); @@ -155,10 +165,10 @@ impl MockComponent for ResultsComponent { .marker(Marker::Braille) .graph_type(GraphType::Line) .style(self.theme.results_chart) - .data(&wpm_sma)]; + .data(&words_per_minute_sliding_moving_average)]; - let y_label_min = wpm_sma_min as u16; - let y_label_max = (wpm_sma_max as u16).max(y_label_min + 6); + let y_label_minimum = minimum_average as u16; + let y_label_maximum = (maximum_average as u16).max(y_label_minimum + 6); let wpm_chart = Chart::new(wpm_datasets) .block(Block::default().title(vec![Span::styled("Chart", self.theme.title)])) @@ -173,18 +183,19 @@ impl MockComponent for ResultsComponent { "WPM (10-keypress rolling average)", self.theme.results_chart_y, )) - .bounds([wpm_sma_min, wpm_sma_max]) + .bounds([minimum_average, maximum_average]) .labels( - (y_label_min..y_label_max) + (y_label_minimum..y_label_maximum) .step_by(5) .map(|n| Span::raw(format!("{}", n))) .collect::>(), ), ); - wpm_chart.render(res_chunks[1], buf); + wpm_chart.render(result_chunks[1], buffer); } } + // DEFAULT IMPLEMENTATIONS ROUGHLY fn query(&self, _attr: Attribute) -> Option { None } @@ -208,11 +219,13 @@ impl Component for ResultsComponent { { Some(Msg::AppClose) } + Event::Keyboard(key) if key.code == Key::Char('r') && key.modifiers == KeyModifiers::NONE => { Some(Msg::RestartTest) } + Event::Keyboard(key) if key.code == Key::Char('p') && key.modifiers == KeyModifiers::NONE => { From 0ab18a7367afb4929a76ac84e35edae4db4371db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Wed, 25 Mar 2026 22:30:22 -0400 Subject: [PATCH 18/30] Handling quit events --- src/components/test.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/test.rs b/src/components/test.rs index b0d5e24..01fa71b 100644 --- a/src/components/test.rs +++ b/src/components/test.rs @@ -1,3 +1,4 @@ +use ratatui::crossterm::event::{KeyCode, KeyModifiers}; use tuirealm::ratatui::{ layout::{Constraint, Direction, Layout, Rect}, text::{Line, Span}, @@ -113,6 +114,19 @@ impl Component for TestComponent { // Convert tuirealm KeyEvent to crossterm KeyEvent for Test::handle_key let crossterm_key = convert_tuirealm_to_crossterm_key(key_event); + // Respect the Ctrl-C signal + if crossterm_key.modifiers == KeyModifiers::CONTROL + && crossterm_key.code == KeyCode::Char('c') + { + return Some(Msg::AppClose); + } + + if crossterm_key.code == KeyCode::Esc { + let results = Results::from(&self.test); + + return Some(Msg::ShowResults(results)); + } + self.test.handle_key(crossterm_key); if self.test.complete { let results = Results::from(&self.test); From 1d94738b008c3d79a2e51ca5609f5b9ac7e79ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Wed, 25 Mar 2026 22:30:44 -0400 Subject: [PATCH 19/30] Fixed response menu issue --- src/components/result.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/result.rs b/src/components/result.rs index 1096b59..50f3580 100644 --- a/src/components/result.rs +++ b/src/components/result.rs @@ -149,7 +149,7 @@ impl MockComponent for ResultsComponent { .collect(); // Render the chart if possible. - if words_per_minute_sliding_moving_average.is_empty() { + if !words_per_minute_sliding_moving_average.is_empty() { let minimum_average = words_per_minute_sliding_moving_average .iter() .map(|(_, x)| x) From 3f176906b3e5d82b2944cb273a3a5c6f19b37943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Wed, 25 Mar 2026 22:35:00 -0400 Subject: [PATCH 20/30] Handling redraws --- src/components/test.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/test.rs b/src/components/test.rs index 01fa71b..fd3e310 100644 --- a/src/components/test.rs +++ b/src/components/test.rs @@ -135,6 +135,8 @@ impl Component for TestComponent { Some(Msg::None) } } + // Simply signal that we need a redraw + Event::WindowResize(_, _) => Some(Msg::None), _ => None, } } From 2e4b477304a320f12a2967680ab5e1521f70a8b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Wed, 25 Mar 2026 22:38:00 -0400 Subject: [PATCH 21/30] Updated deps --- Cargo.lock | 1 - Cargo.toml | 16 ++++------------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61b2ba9..f9a7d61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1199,7 +1199,6 @@ version = "1.6.0" dependencies = [ "clap", "clap_complete", - "crossterm 0.29.0", "dirs", "eyre", "rand", diff --git a/Cargo.toml b/Cargo.toml index f72182e..87b1b82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,28 +8,20 @@ homepage = "https://github.com/max-niederman/ttyper" license = "MIT" authors = ["Max Niederman "] edition = "2021" + [dependencies] clap = { version = "^4.5", features = ["derive"] } clap_complete = "^4.5" dirs = "^5.0" -crossterm = "^0.29" rust-embed = "^8.2" toml = "^0.8" tuirealm = { version = "3.3.0", features = ["crossterm", "derive"] } eyre = "0.6.12" thiserror = "2.0.18" tui-realm-stdlib = { version = "3.1.0", features = ["crossterm"] } - -[dependencies.ratatui] -version = "^0.29" - -[dependencies.rand] -version = "^0.8" -features = ["alloc"] - -[dependencies.serde] -version = "^1.0" -features = ["derive"] +ratatui = "^0.29" +rand = {version = "^0.8", features = ["alloc"]} +serde = {version = "^1.0", features = ["derive"]} [dev-dependencies] tempfile = "3.27.0" From b4e8457d5485ebf44761b69af66efb03b969fed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Wed, 25 Mar 2026 22:45:24 -0400 Subject: [PATCH 22/30] Fixed deps --- Cargo.lock | 910 +++++++++++++++++++++++++++++++++++++---- Cargo.toml | 3 +- src/components/test.rs | 10 +- 3 files changed, 844 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9a7d61..e51fd3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,12 +84,48 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "autocfg" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -105,6 +141,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -128,9 +176,15 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" @@ -172,7 +226,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -201,6 +255,20 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -225,7 +293,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "mio", "parking_lot", @@ -241,7 +309,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "derive_more", "document-features", @@ -272,6 +340,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "darling" version = "0.23.0" @@ -292,7 +370,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -303,7 +381,22 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", ] [[package]] @@ -325,7 +418,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", ] [[package]] @@ -396,6 +489,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "eyre" version = "0.6.12" @@ -406,18 +508,63 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.63", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "generic-array" version = "0.14.7" @@ -439,6 +586,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -447,7 +606,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -468,7 +627,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -476,6 +635,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -483,6 +647,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "id-arena" version = "2.3.0" @@ -529,7 +699,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -547,12 +717,48 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy-regex" version = "3.4.0" @@ -573,9 +779,15 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.117", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -594,10 +806,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", ] +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -641,12 +862,52 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.0.4" @@ -659,6 +920,64 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -671,6 +990,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -700,6 +1028,113 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -716,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -737,6 +1172,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -779,18 +1220,103 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cassowary", - "compact_str", + "compact_str 0.8.1", "crossterm 0.28.1", "indoc", "instability", - "itertools", - "lru", + "itertools 0.13.0", + "lru 0.12.4", "paste", - "strum", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate 1.1.0", + "unicode-width 0.2.0", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.0", + "compact_str 0.9.0", + "hashbrown 0.16.1", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru 0.16.3", + "strum 0.27.2", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate 2.0.1", + "unicode-width 0.2.0", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum 0.27.2", + "time", "unicode-segmentation", - "unicode-truncate", "unicode-width 0.2.0", ] @@ -800,7 +1326,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -863,7 +1389,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn", + "syn 2.0.117", "walkdir", ] @@ -892,7 +1418,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -905,7 +1431,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -972,7 +1498,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1039,16 +1565,16 @@ dependencies = [ ] [[package]] -name = "smallvec" -version = "1.13.2" +name = "siphasher" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] -name = "smawk" -version = "0.3.2" +name = "smallvec" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "static_assertions" @@ -1068,7 +1594,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -1081,7 +1616,30 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -1109,14 +1667,66 @@ dependencies = [ ] [[package]] -name = "textwrap" -version = "0.16.2" +name = "terminfo" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ - "smawk", - "unicode-linebreak", - "unicode-width 0.2.0", + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.63", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", ] [[package]] @@ -1145,7 +1755,7 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1156,9 +1766,30 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "toml" version = "0.8.19" @@ -1202,38 +1833,26 @@ dependencies = [ "dirs", "eyre", "rand", - "ratatui", + "ratatui 0.30.0", "rust-embed", "serde", "tempfile", "thiserror 2.0.18", "toml", - "tui-realm-stdlib", "tuirealm", ] -[[package]] -name = "tui-realm-stdlib" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333d8b8da2b3479a68fdf4bd2621f888fa869501d6c5c72bac940d0eeb19b083" -dependencies = [ - "textwrap", - "tuirealm", - "unicode-width 0.2.0", -] - [[package]] name = "tuirealm" version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3a96abaf33552e9e2487bf56f537e49fad1d1fbc55a3fd542449d4e30e7c8f3" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm 0.29.0", "dyn-clone", "lazy-regex", - "ratatui", + "ratatui 0.29.0", "thiserror 2.0.18", "tuirealm_derive", ] @@ -1246,7 +1865,7 @@ checksum = "bad3c151090da5a036321776209b3d026df637d7f52c0984ff8d45927326ab4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1256,22 +1875,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] -name = "unicode-ident" -version = "1.0.12" +name = "ucd-trie" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] -name = "unicode-linebreak" -version = "0.1.5" +name = "unicode-ident" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-segmentation" -version = "1.11.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" [[package]] name = "unicode-truncate" @@ -1279,11 +1898,22 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.13", ] +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width 0.2.0", +] + [[package]] name = "unicode-width" version = "0.1.13" @@ -1308,12 +1938,33 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1348,6 +1999,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1376,12 +2072,84 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.63", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.63", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1600,7 +2368,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1616,7 +2384,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -1628,7 +2396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -1676,7 +2444,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 87b1b82..b97f55d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,8 +18,7 @@ toml = "^0.8" tuirealm = { version = "3.3.0", features = ["crossterm", "derive"] } eyre = "0.6.12" thiserror = "2.0.18" -tui-realm-stdlib = { version = "3.1.0", features = ["crossterm"] } -ratatui = "^0.29" +ratatui = "0.30" rand = {version = "^0.8", features = ["alloc"]} serde = {version = "^1.0", features = ["derive"]} diff --git a/src/components/test.rs b/src/components/test.rs index fd3e310..1e0632b 100644 --- a/src/components/test.rs +++ b/src/components/test.rs @@ -1,4 +1,7 @@ -use ratatui::crossterm::event::{KeyCode, KeyModifiers}; +use tuirealm::event::Key; +use tuirealm::ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, +}; use tuirealm::ratatui::{ layout::{Constraint, Direction, Layout, Rect}, text::{Line, Span}, @@ -145,11 +148,6 @@ impl Component for TestComponent { fn convert_tuirealm_to_crossterm_key( key: tuirealm::event::KeyEvent, ) -> tuirealm::ratatui::crossterm::event::KeyEvent { - use tuirealm::event::Key; - use tuirealm::ratatui::crossterm::event::{ - KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, - }; - let code = match key.code { Key::Backspace => KeyCode::Backspace, Key::Enter => KeyCode::Enter, From c8eea7dfafcf4bbf09e05608b62aebdfc304a736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Wed, 25 Mar 2026 22:49:50 -0400 Subject: [PATCH 23/30] Formatting --- src/calculate.rs | 6 +++--- src/types/accuracy_data.rs | 2 +- src/types/test/handler.rs | 2 +- src/types/test_event.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/calculate.rs b/src/calculate.rs index 50b3102..2d34034 100644 --- a/src/calculate.rs +++ b/src/calculate.rs @@ -1,7 +1,7 @@ -use std::collections::HashMap; -use tuirealm::ratatui::crossterm::event::{KeyEvent}; -use crate::types::{TestEvent, TimingData, AccuracyData, Fraction, Test}; use crate::types::test_event::is_missed_word_event; +use crate::types::{AccuracyData, Fraction, Test, TestEvent, TimingData}; +use std::collections::HashMap; +use tuirealm::ratatui::crossterm::event::KeyEvent; pub fn timing(events: &[&TestEvent]) -> TimingData { let mut timing = TimingData { diff --git a/src/types/accuracy_data.rs b/src/types/accuracy_data.rs index d7ce709..515f872 100644 --- a/src/types/accuracy_data.rs +++ b/src/types/accuracy_data.rs @@ -1,6 +1,6 @@ +use crate::types::Fraction; use std::collections::HashMap; use tuirealm::ratatui::crossterm::event::KeyEvent; -use crate::types::Fraction; #[derive(Clone, Debug, PartialEq)] pub struct AccuracyData { diff --git a/src/types/test/handler.rs b/src/types/test/handler.rs index 262233f..886e855 100644 --- a/src/types/test/handler.rs +++ b/src/types/test/handler.rs @@ -1,7 +1,7 @@ use super::Test; use crate::types::{TestEvent, TestWord}; use std::time::Instant; -use tuirealm::ratatui::crossterm::event::{KeyEvent, KeyCode, KeyModifiers, KeyEventKind}; +use tuirealm::ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; impl Test { pub fn handle_key(&mut self, key: KeyEvent) { diff --git a/src/types/test_event.rs b/src/types/test_event.rs index d3d8e6a..91cac06 100644 --- a/src/types/test_event.rs +++ b/src/types/test_event.rs @@ -1,6 +1,6 @@ +use std::fmt; use std::time::Instant; use tuirealm::ratatui::crossterm::event::KeyEvent; -use std::fmt; pub struct TestEvent { pub time: Instant, From 86a5137a006716221806dead84681b0883cc5228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 17:23:18 -0400 Subject: [PATCH 24/30] Reorg --- src/{types => }/cli.rs | 0 src/components/mod.rs | 6 + src/components/result.rs | 248 ------------- src/components/test.rs | 339 ------------------ src/components/test/component.rs | 34 ++ .../test/event.rs} | 0 src/{types => components}/test/handler.rs | 0 src/components/test/mock_component.rs | 87 +++++ src/components/test/mod.rs | 107 ++++++ src/components/test/spans.rs | 45 +++ src/components/test/status.rs | 92 +++++ .../test_word.rs => components/test/word.rs} | 0 src/types/accuracy_data.rs | 9 - src/types/fraction.rs | 47 --- src/types/mod.rs | 23 -- src/types/results.rs | 21 -- src/types/test/mod.rs | 30 -- src/types/timing_data.rs | 10 - 18 files changed, 371 insertions(+), 727 deletions(-) rename src/{types => }/cli.rs (100%) delete mode 100644 src/components/result.rs delete mode 100644 src/components/test.rs create mode 100644 src/components/test/component.rs rename src/{types/test_event.rs => components/test/event.rs} (100%) rename src/{types => components}/test/handler.rs (100%) create mode 100644 src/components/test/mock_component.rs create mode 100644 src/components/test/mod.rs create mode 100644 src/components/test/spans.rs create mode 100644 src/components/test/status.rs rename src/{types/test_word.rs => components/test/word.rs} (100%) delete mode 100644 src/types/accuracy_data.rs delete mode 100644 src/types/fraction.rs delete mode 100644 src/types/mod.rs delete mode 100644 src/types/results.rs delete mode 100644 src/types/test/mod.rs delete mode 100644 src/types/timing_data.rs diff --git a/src/types/cli.rs b/src/cli.rs similarity index 100% rename from src/types/cli.rs rename to src/cli.rs diff --git a/src/components/mod.rs b/src/components/mod.rs index 032d642..450cb71 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,2 +1,8 @@ pub mod result; pub mod test; + +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +pub enum Screen { + Test, + Results, +} diff --git a/src/components/result.rs b/src/components/result.rs deleted file mode 100644 index 50f3580..0000000 --- a/src/components/result.rs +++ /dev/null @@ -1,248 +0,0 @@ -use rand::{seq::SliceRandom, thread_rng}; -use tuirealm::ratatui::crossterm::event::{KeyCode as CrosstermKeyCode, KeyEvent}; -use tuirealm::ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, - symbols::Marker, - text::{Line, Span, Text}, - widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Widget}, -}; -use tuirealm::{ - command::{Cmd, CmdResult}, - event::{Event, Key, KeyModifiers, NoUserEvent}, - AttrValue, Attribute, Component, Frame, MockComponent, State, -}; - -use crate::config::Theme; -use crate::messages::Msg; -use crate::types::{Fraction, Results}; - -// Convert CPS to WPM (clicks per second) -const WORDS_PER_MINUTE_PER_CPS: f64 = 12.0; - -// Width of the moving average window for the WPM chart -const WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH: usize = 10; - -pub struct ResultsComponent { - pub results: Results, - pub theme: Theme, -} - -impl MockComponent for ResultsComponent { - fn view(&mut self, frame: &mut Frame, area: Rect) { - let buffer = frame.buffer_mut(); - - buffer.set_style(area, self.theme.default); - - // Chunks - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(1)]) - .split(area); - - let result_chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) // Graph looks tremendously better with just a little margin - .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) - .split(chunks[0]); - - let info_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) - .split(result_chunks[0]); - - // Handling the incomplete tests - // TODO: Show a better screen here - let msg = if self.results.missed_words.is_empty() { - "Press 'q' to quit or 'r' for another test" - } else { - "Press 'q' to quit, 'r' for another test or 'p' to practice missed words" - }; - - let exit = Span::styled(msg, self.theme.results_restart_prompt); - buffer.set_span(chunks[1].x, chunks[1].y, &exit, chunks[1].width); - - // Sections - let mut overview_text = Text::styled("", self.theme.results_overview); - overview_text.extend([ - Line::from(format!( - "Adjusted WPM: {:.1}", - self.results.timing.overall_cps - * WORDS_PER_MINUTE_PER_CPS - * f64::from(self.results.accuracy.overall) - )), - Line::from(format!( - "Accuracy: {:.1}%", - f64::from(self.results.accuracy.overall) * 100f64 - )), - Line::from(format!( - "Raw WPM: {:.1}", - self.results.timing.overall_cps * WORDS_PER_MINUTE_PER_CPS - )), - Line::from(format!( - "Correct Keypresses: {}", - self.results.accuracy.overall - )), - ]); - let overview = Paragraph::new(overview_text).block( - Block::default() - .title(Span::styled("Overview", self.theme.title)) - .borders(Borders::ALL) - .border_type(self.theme.border_type) - .border_style(self.theme.results_overview_border), - ); - overview.render(info_chunks[0], buffer); - - let mut worst_keys: Vec<(&KeyEvent, &Fraction)> = self - .results - .accuracy - .per_key - .iter() - .filter(|(key, _)| matches!(key.code, CrosstermKeyCode::Char(_))) - .collect(); - - // Unstable because we don't care about order, just results - worst_keys.sort_unstable_by_key(|x| x.1); - - let mut worst_text = Text::styled("", self.theme.results_worst_keys); - worst_text.extend( - worst_keys - .iter() - .filter_map(|(key, acc)| { - if let CrosstermKeyCode::Char(character) = key.code { - let key_accuracy = f64::from(**acc) * 100.0; - if key_accuracy != 100.0 { - Some(format!("- {} at {:.1}% accuracy", character, key_accuracy)) - } else { - None - } - } else { - None - } - }) - .take(5) - .map(Line::from), - ); - - let worst = Paragraph::new(worst_text).block( - Block::default() - .title(Span::styled("Worst Keys", self.theme.title)) - .borders(Borders::ALL) - .border_type(self.theme.border_type) - .border_style(self.theme.results_worst_keys_border), - ); - - worst.render(info_chunks[1], buffer); - - let words_per_minute_sliding_moving_average: Vec<(f64, f64)> = self - .results - .timing - .per_event - .windows(WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH) - .enumerate() - .map(|(i, window): (usize, &[f64])| { - ( - (i + WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH) as f64, - window.len() as f64 / window.iter().copied().sum::() - * WORDS_PER_MINUTE_PER_CPS, - ) - }) - .collect(); - - // Render the chart if possible. - if !words_per_minute_sliding_moving_average.is_empty() { - let minimum_average = words_per_minute_sliding_moving_average - .iter() - .map(|(_, x)| x) - .fold(f64::INFINITY, |a: f64, &b: &f64| a.min(b)); - - let maximum_average = words_per_minute_sliding_moving_average - .iter() - .map(|(_, x)| x) - .fold(f64::NEG_INFINITY, |a: f64, &b: &f64| a.max(b)); - - let wpm_datasets = vec![Dataset::default() - .name("WPM") - .marker(Marker::Braille) - .graph_type(GraphType::Line) - .style(self.theme.results_chart) - .data(&words_per_minute_sliding_moving_average)]; - - let y_label_minimum = minimum_average as u16; - let y_label_maximum = (maximum_average as u16).max(y_label_minimum + 6); - - let wpm_chart = Chart::new(wpm_datasets) - .block(Block::default().title(vec![Span::styled("Chart", self.theme.title)])) - .x_axis( - Axis::default() - .title(Span::styled("Keypresses", self.theme.results_chart_x)) - .bounds([0.0, self.results.timing.per_event.len() as f64]), - ) - .y_axis( - Axis::default() - .title(Span::styled( - "WPM (10-keypress rolling average)", - self.theme.results_chart_y, - )) - .bounds([minimum_average, maximum_average]) - .labels( - (y_label_minimum..y_label_maximum) - .step_by(5) - .map(|n| Span::raw(format!("{}", n))) - .collect::>(), - ), - ); - wpm_chart.render(result_chunks[1], buffer); - } - } - - // DEFAULT IMPLEMENTATIONS ROUGHLY - fn query(&self, _attr: Attribute) -> Option { - None - } - - fn attr(&mut self, _attr: Attribute, _value: AttrValue) {} - - fn state(&self) -> State { - State::None - } - - fn perform(&mut self, _cmd: Cmd) -> CmdResult { - CmdResult::None - } -} - -impl Component for ResultsComponent { - fn on(&mut self, ev: Event) -> Option { - match ev { - Event::Keyboard(key) - if key.code == Key::Char('q') && key.modifiers == KeyModifiers::NONE => - { - Some(Msg::AppClose) - } - - Event::Keyboard(key) - if key.code == Key::Char('r') && key.modifiers == KeyModifiers::NONE => - { - Some(Msg::RestartTest) - } - - Event::Keyboard(key) - if key.code == Key::Char('p') && key.modifiers == KeyModifiers::NONE => - { - if self.results.missed_words.is_empty() { - return None; - } - // repeat each missed word 5 times - let mut practice_words: Vec = (self.results.missed_words) - .iter() - .flat_map(|w: &String| vec![w.clone(); 5]) - .collect(); - - practice_words.shuffle(&mut thread_rng()); - - Some(Msg::StartTest(practice_words)) - } - _ => None, - } - } -} diff --git a/src/components/test.rs b/src/components/test.rs deleted file mode 100644 index 1e0632b..0000000 --- a/src/components/test.rs +++ /dev/null @@ -1,339 +0,0 @@ -use tuirealm::event::Key; -use tuirealm::ratatui::crossterm::event::{ - KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, -}; -use tuirealm::ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Widget}, -}; -use tuirealm::{ - command::{Cmd, CmdResult}, - event::{Event, NoUserEvent}, - AttrValue, Attribute, Component, Frame, MockComponent, State, -}; - -use crate::config::Theme; -use crate::messages::Msg; -use crate::types::{Results, Test, TestWord}; - -pub struct TestComponent { - pub test: Test, - pub theme: Theme, -} - -impl MockComponent for TestComponent { - fn view(&mut self, frame: &mut Frame, area: Rect) { - let buf = frame.buffer_mut(); - - buf.set_style(area, self.theme.default); - - // Chunks - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Length(6)]) - .split(area); - - // Input Section - let input_block = Block::default() - .title(Line::from(vec![Span::styled("Input", self.theme.title)])) - .borders(Borders::ALL) - .border_type(self.theme.border_type) - .border_style(self.theme.input_border); - - let input_inner_area = input_block.inner(chunks[0]); - input_block.render(chunks[0], buf); - - let input_text = Line::from(self.test.words[self.test.current_word].progress.clone()); - buf.set_line( - input_inner_area.x, - input_inner_area.y, - &input_text, - input_inner_area.width, - ); - - // Target (Prompt) Section - let target_lines: Vec = { - let words = words_to_spans(&self.test.words, self.test.current_word, &self.theme); - - let mut lines: Vec = Vec::new(); - let mut current_line: Vec = Vec::new(); - let mut current_width = 0; - for word in words { - let word_width: usize = word.iter().map(|s| s.width()).sum(); - - if current_width + word_width > chunks[1].width as usize - 2 { - lines.push(Line::from(current_line.clone())); - current_line.clear(); - current_width = 0; - } - - current_line.extend(word); - current_width += word_width; - } - lines.push(Line::from(current_line)); - - lines - }; - - let target = Paragraph::new(target_lines).block( - Block::default() - .title(Span::styled("Prompt", self.theme.title)) - .borders(Borders::ALL) - .border_type(self.theme.border_type) - .border_style(self.theme.prompt_border), - ); - target.render(chunks[1], buf); - - // Cursor positioning - let inner_x = chunks[0].x + 1; - let inner_y = chunks[0].y + 1; - let progress_width = - Line::from(self.test.words[self.test.current_word].progress.as_str()).width() as u16; - let max_cursor_x = chunks[0].right().saturating_sub(2); - - frame.set_cursor_position(((inner_x + progress_width).min(max_cursor_x), inner_y)); - } - - fn query(&self, _attr: Attribute) -> Option { - None - } - - fn attr(&mut self, _attr: Attribute, _value: AttrValue) {} - - fn state(&self) -> State { - State::None - } - - fn perform(&mut self, _cmd: Cmd) -> CmdResult { - CmdResult::None - } -} - -impl Component for TestComponent { - fn on(&mut self, ev: Event) -> Option { - match ev { - Event::Keyboard(key_event) => { - // Convert tuirealm KeyEvent to crossterm KeyEvent for Test::handle_key - let crossterm_key = convert_tuirealm_to_crossterm_key(key_event); - - // Respect the Ctrl-C signal - if crossterm_key.modifiers == KeyModifiers::CONTROL - && crossterm_key.code == KeyCode::Char('c') - { - return Some(Msg::AppClose); - } - - if crossterm_key.code == KeyCode::Esc { - let results = Results::from(&self.test); - - return Some(Msg::ShowResults(results)); - } - - self.test.handle_key(crossterm_key); - if self.test.complete { - let results = Results::from(&self.test); - Some(Msg::ShowResults(results)) - } else { - Some(Msg::None) - } - } - // Simply signal that we need a redraw - Event::WindowResize(_, _) => Some(Msg::None), - _ => None, - } - } -} - -fn convert_tuirealm_to_crossterm_key( - key: tuirealm::event::KeyEvent, -) -> tuirealm::ratatui::crossterm::event::KeyEvent { - let code = match key.code { - Key::Backspace => KeyCode::Backspace, - Key::Enter => KeyCode::Enter, - Key::Left => KeyCode::Left, - Key::Right => KeyCode::Right, - Key::Up => KeyCode::Up, - Key::Down => KeyCode::Down, - Key::Home => KeyCode::Home, - Key::End => KeyCode::End, - Key::PageUp => KeyCode::PageUp, - Key::PageDown => KeyCode::PageDown, - Key::Tab => KeyCode::Tab, - Key::BackTab => KeyCode::BackTab, - Key::Delete => KeyCode::Delete, - Key::Insert => KeyCode::Insert, - Key::Function(n) => KeyCode::F(n), - Key::Char(c) => KeyCode::Char(c), - Key::Null => KeyCode::Null, - Key::Esc => KeyCode::Esc, - Key::CapsLock => KeyCode::CapsLock, - Key::ScrollLock => KeyCode::ScrollLock, - Key::NumLock => KeyCode::NumLock, - Key::PrintScreen => KeyCode::PrintScreen, - Key::Pause => KeyCode::Pause, - Key::Menu => KeyCode::Menu, - Key::KeypadBegin => KeyCode::KeypadBegin, - _ => KeyCode::Null, - }; - - let mut modifiers = KeyModifiers::empty(); - if key.modifiers.contains(tuirealm::event::KeyModifiers::SHIFT) { - modifiers.insert(KeyModifiers::SHIFT); - } - if key - .modifiers - .contains(tuirealm::event::KeyModifiers::CONTROL) - { - modifiers.insert(KeyModifiers::CONTROL); - } - if key.modifiers.contains(tuirealm::event::KeyModifiers::ALT) { - modifiers.insert(KeyModifiers::ALT); - } - - KeyEvent { - code, - modifiers, - kind: KeyEventKind::Press, - state: KeyEventState::empty(), - } -} - -// Helpers - -fn words_to_spans<'a>( - words: &'a [TestWord], - current_word: usize, - theme: &'a Theme, -) -> Vec>> { - let mut spans = Vec::new(); - - for word in &words[..current_word] { - let parts = split_typed_word(word); - spans.push(word_parts_to_spans(parts, theme)); - } - - if current_word < words.len() { - let parts_current = split_current_word(&words[current_word]); - spans.push(word_parts_to_spans(parts_current, theme)); - - for word in &words[current_word + 1..] { - let parts = vec![(word.text.clone(), Status::Untyped)]; - spans.push(word_parts_to_spans(parts, theme)); - } - } - spans -} - -#[derive(PartialEq, Clone, Copy, Debug)] -enum Status { - Correct, - Incorrect, - CurrentUntyped, - CurrentCorrect, - CurrentIncorrect, - Cursor, - Untyped, - Overtyped, -} - -fn split_current_word(word: &TestWord) -> Vec<(String, Status)> { - let mut parts = Vec::new(); - let mut cur_string = String::new(); - let mut cur_status = Status::Untyped; - - let mut progress = word.progress.chars(); - for tc in word.text.chars() { - let p = progress.next(); - let status = match p { - None => Status::CurrentUntyped, - Some(c) => match c { - c if c == tc => Status::CurrentCorrect, - _ => Status::CurrentIncorrect, - }, - }; - - if status == cur_status { - cur_string.push(tc); - } else { - if !cur_string.is_empty() { - parts.push((cur_string, cur_status)); - cur_string = String::new(); - } - cur_string.push(tc); - cur_status = status; - - // first currentuntyped is cursor - if status == Status::CurrentUntyped { - parts.push((cur_string, Status::Cursor)); - cur_string = String::new(); - } - } - } - if !cur_string.is_empty() { - parts.push((cur_string, cur_status)); - } - let overtyped = progress.collect::(); - if !overtyped.is_empty() { - parts.push((overtyped, Status::Overtyped)); - } - parts -} - -fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> { - let mut parts = Vec::new(); - let mut cur_string = String::new(); - let mut cur_status = Status::Untyped; - - let mut progress = word.progress.chars(); - for tc in word.text.chars() { - let p = progress.next(); - let status = match p { - None => Status::Untyped, - Some(c) => match c { - c if c == tc => Status::Correct, - _ => Status::Incorrect, - }, - }; - - if status == cur_status { - cur_string.push(tc); - } else { - if !cur_string.is_empty() { - parts.push((cur_string, cur_status)); - cur_string = String::new(); - } - cur_string.push(tc); - cur_status = status; - } - } - if !cur_string.is_empty() { - parts.push((cur_string, cur_status)); - } - - let overtyped = progress.collect::(); - if !overtyped.is_empty() { - parts.push((overtyped, Status::Overtyped)); - } - parts -} - -fn word_parts_to_spans(parts: Vec<(String, Status)>, theme: &Theme) -> Vec> { - let mut spans = Vec::new(); - for (text, status) in parts { - let style = match status { - Status::Correct => theme.prompt_correct, - Status::Incorrect => theme.prompt_incorrect, - Status::Untyped => theme.prompt_untyped, - Status::CurrentUntyped => theme.prompt_current_untyped, - Status::CurrentCorrect => theme.prompt_current_correct, - Status::CurrentIncorrect => theme.prompt_current_incorrect, - Status::Cursor => theme.prompt_current_untyped.patch(theme.prompt_cursor), - Status::Overtyped => theme.prompt_incorrect, - }; - - spans.push(Span::styled(text, style)); - } - spans.push(Span::styled(" ", theme.prompt_untyped)); - spans -} diff --git a/src/components/test/component.rs b/src/components/test/component.rs new file mode 100644 index 0000000..c26cb12 --- /dev/null +++ b/src/components/test/component.rs @@ -0,0 +1,34 @@ +impl Component for TestComponent { + fn on(&mut self, ev: Event) -> Option { + match ev { + Event::Keyboard(key_event) => { + // Convert tuirealm KeyEvent to crossterm KeyEvent for Test::handle_key + let crossterm_key = convert_tuirealm_to_crossterm_key(key_event); + + // Respect the Ctrl-C signal + if crossterm_key.modifiers == KeyModifiers::CONTROL + && crossterm_key.code == KeyCode::Char('c') + { + return Some(Msg::AppClose); + } + + if crossterm_key.code == KeyCode::Esc { + let results = Results::from(&self.test); + + return Some(Msg::ShowResults(results)); + } + + self.test.handle_key(crossterm_key); + if self.test.complete { + let results = Results::from(&self.test); + Some(Msg::ShowResults(results)) + } else { + Some(Msg::None) + } + } + // Simply signal that we need a redraw + Event::WindowResize(_, _) => Some(Msg::None), + _ => None, + } + } +} diff --git a/src/types/test_event.rs b/src/components/test/event.rs similarity index 100% rename from src/types/test_event.rs rename to src/components/test/event.rs diff --git a/src/types/test/handler.rs b/src/components/test/handler.rs similarity index 100% rename from src/types/test/handler.rs rename to src/components/test/handler.rs diff --git a/src/components/test/mock_component.rs b/src/components/test/mock_component.rs new file mode 100644 index 0000000..7889df6 --- /dev/null +++ b/src/components/test/mock_component.rs @@ -0,0 +1,87 @@ +impl MockComponent for TestComponent { + fn view(&mut self, frame: &mut Frame, area: Rect) { + let buf = frame.buffer_mut(); + + buf.set_style(area, self.theme.default); + + // Chunks + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Length(6)]) + .split(area); + + // Input Section + let input_block = Block::default() + .title(Line::from(vec![Span::styled("Input", self.theme.title)])) + .borders(Borders::ALL) + .border_type(self.theme.border_type) + .border_style(self.theme.input_border); + + let input_inner_area = input_block.inner(chunks[0]); + input_block.render(chunks[0], buf); + + let input_text = Line::from(self.test.words[self.test.current_word].progress.clone()); + buf.set_line( + input_inner_area.x, + input_inner_area.y, + &input_text, + input_inner_area.width, + ); + + // Target (Prompt) Section + let target_lines: Vec = { + let words = words_to_spans(&self.test.words, self.test.current_word, &self.theme); + + let mut lines: Vec = Vec::new(); + let mut current_line: Vec = Vec::new(); + let mut current_width = 0; + for word in words { + let word_width: usize = word.iter().map(|s| s.width()).sum(); + + if current_width + word_width > chunks[1].width as usize - 2 { + lines.push(Line::from(current_line.clone())); + current_line.clear(); + current_width = 0; + } + + current_line.extend(word); + current_width += word_width; + } + lines.push(Line::from(current_line)); + + lines + }; + + let target = Paragraph::new(target_lines).block( + Block::default() + .title(Span::styled("Prompt", self.theme.title)) + .borders(Borders::ALL) + .border_type(self.theme.border_type) + .border_style(self.theme.prompt_border), + ); + target.render(chunks[1], buf); + + // Cursor positioning + let inner_x = chunks[0].x + 1; + let inner_y = chunks[0].y + 1; + let progress_width = + Line::from(self.test.words[self.test.current_word].progress.as_str()).width() as u16; + let max_cursor_x = chunks[0].right().saturating_sub(2); + + frame.set_cursor_position(((inner_x + progress_width).min(max_cursor_x), inner_y)); + } + + fn query(&self, _attr: Attribute) -> Option { + None + } + + fn attr(&mut self, _attr: Attribute, _value: AttrValue) {} + + fn state(&self) -> State { + State::None + } + + fn perform(&mut self, _cmd: Cmd) -> CmdResult { + CmdResult::None + } +} diff --git a/src/components/test/mod.rs b/src/components/test/mod.rs new file mode 100644 index 0000000..f52b10c --- /dev/null +++ b/src/components/test/mod.rs @@ -0,0 +1,107 @@ +use tuirealm::event::Key; +use tuirealm::ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, +}; +use tuirealm::ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Widget}, +}; +use tuirealm::{ + command::{Cmd, CmdResult}, + event::{Event, NoUserEvent}, + AttrValue, Attribute, Component, Frame, MockComponent, State, +}; + +use crate::config::Theme; +use crate::messages::Msg; +use crate::types::{Results, Test, TestWord}; + +pub mod handler; + +pub struct TestComponent { + pub test: Test, + pub theme: Theme, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Test { + pub words: Vec, + pub current_word: usize, + pub complete: bool, + pub backtracking_enabled: bool, + pub sudden_death_enabled: bool, + pub backspace_enabled: bool, +} + +impl Test { + pub fn new( + words: Vec, + backtracking_enabled: bool, + sudden_death_enabled: bool, + backspace_enabled: bool, + ) -> Self { + Self { + words: words.into_iter().map(TestWord::from).collect(), + current_word: 0, + complete: false, + backtracking_enabled, + sudden_death_enabled, + backspace_enabled, + } + } +} + +fn convert_tuirealm_to_crossterm_key( + key: tuirealm::event::KeyEvent, +) -> tuirealm::ratatui::crossterm::event::KeyEvent { + let code = match key.code { + Key::Backspace => KeyCode::Backspace, + Key::Enter => KeyCode::Enter, + Key::Left => KeyCode::Left, + Key::Right => KeyCode::Right, + Key::Up => KeyCode::Up, + Key::Down => KeyCode::Down, + Key::Home => KeyCode::Home, + Key::End => KeyCode::End, + Key::PageUp => KeyCode::PageUp, + Key::PageDown => KeyCode::PageDown, + Key::Tab => KeyCode::Tab, + Key::BackTab => KeyCode::BackTab, + Key::Delete => KeyCode::Delete, + Key::Insert => KeyCode::Insert, + Key::Function(n) => KeyCode::F(n), + Key::Char(c) => KeyCode::Char(c), + Key::Null => KeyCode::Null, + Key::Esc => KeyCode::Esc, + Key::CapsLock => KeyCode::CapsLock, + Key::ScrollLock => KeyCode::ScrollLock, + Key::NumLock => KeyCode::NumLock, + Key::PrintScreen => KeyCode::PrintScreen, + Key::Pause => KeyCode::Pause, + Key::Menu => KeyCode::Menu, + Key::KeypadBegin => KeyCode::KeypadBegin, + _ => KeyCode::Null, + }; + + let mut modifiers = KeyModifiers::empty(); + if key.modifiers.contains(tuirealm::event::KeyModifiers::SHIFT) { + modifiers.insert(KeyModifiers::SHIFT); + } + if key + .modifiers + .contains(tuirealm::event::KeyModifiers::CONTROL) + { + modifiers.insert(KeyModifiers::CONTROL); + } + if key.modifiers.contains(tuirealm::event::KeyModifiers::ALT) { + modifiers.insert(KeyModifiers::ALT); + } + + KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + } +} diff --git a/src/components/test/spans.rs b/src/components/test/spans.rs new file mode 100644 index 0000000..cfb402d --- /dev/null +++ b/src/components/test/spans.rs @@ -0,0 +1,45 @@ +// Helpers + +fn words_to_spans<'a>( + words: &'a [TestWord], + current_word: usize, + theme: &'a Theme, +) -> Vec>> { + let mut spans = Vec::new(); + + for word in &words[..current_word] { + let parts = split_typed_word(word); + spans.push(word_parts_to_spans(parts, theme)); + } + + if current_word < words.len() { + let parts_current = split_current_word(&words[current_word]); + spans.push(word_parts_to_spans(parts_current, theme)); + + for word in &words[current_word + 1..] { + let parts = vec![(word.text.clone(), Status::Untyped)]; + spans.push(word_parts_to_spans(parts, theme)); + } + } + spans +} + +fn word_parts_to_spans(parts: Vec<(String, Status)>, theme: &Theme) -> Vec> { + let mut spans = Vec::new(); + for (text, status) in parts { + let style = match status { + Status::Correct => theme.prompt_correct, + Status::Incorrect => theme.prompt_incorrect, + Status::Untyped => theme.prompt_untyped, + Status::CurrentUntyped => theme.prompt_current_untyped, + Status::CurrentCorrect => theme.prompt_current_correct, + Status::CurrentIncorrect => theme.prompt_current_incorrect, + Status::Cursor => theme.prompt_current_untyped.patch(theme.prompt_cursor), + Status::Overtyped => theme.prompt_incorrect, + }; + + spans.push(Span::styled(text, style)); + } + spans.push(Span::styled(" ", theme.prompt_untyped)); + spans +} diff --git a/src/components/test/status.rs b/src/components/test/status.rs new file mode 100644 index 0000000..0e1b168 --- /dev/null +++ b/src/components/test/status.rs @@ -0,0 +1,92 @@ +#[derive(PartialEq, Clone, Copy, Debug)] +enum Status { + Correct, + Incorrect, + CurrentUntyped, + CurrentCorrect, + CurrentIncorrect, + Cursor, + Untyped, + Overtyped, +} + +fn split_current_word(word: &TestWord) -> Vec<(String, Status)> { + let mut parts = Vec::new(); + let mut cur_string = String::new(); + let mut cur_status = Status::Untyped; + + let mut progress = word.progress.chars(); + for tc in word.text.chars() { + let p = progress.next(); + let status = match p { + None => Status::CurrentUntyped, + Some(c) => match c { + c if c == tc => Status::CurrentCorrect, + _ => Status::CurrentIncorrect, + }, + }; + + if status == cur_status { + cur_string.push(tc); + } else { + if !cur_string.is_empty() { + parts.push((cur_string, cur_status)); + cur_string = String::new(); + } + cur_string.push(tc); + cur_status = status; + + // first currentuntyped is cursor + if status == Status::CurrentUntyped { + parts.push((cur_string, Status::Cursor)); + cur_string = String::new(); + } + } + } + if !cur_string.is_empty() { + parts.push((cur_string, cur_status)); + } + let overtyped = progress.collect::(); + if !overtyped.is_empty() { + parts.push((overtyped, Status::Overtyped)); + } + parts +} + +fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> { + let mut parts = Vec::new(); + let mut cur_string = String::new(); + let mut cur_status = Status::Untyped; + + let mut progress = word.progress.chars(); + for tc in word.text.chars() { + let p = progress.next(); + let status = match p { + None => Status::Untyped, + Some(c) => match c { + c if c == tc => Status::Correct, + _ => Status::Incorrect, + }, + }; + + if status == cur_status { + cur_string.push(tc); + } else { + if !cur_string.is_empty() { + parts.push((cur_string, cur_status)); + cur_string = String::new(); + } + cur_string.push(tc); + cur_status = status; + } + } + if !cur_string.is_empty() { + parts.push((cur_string, cur_status)); + } + + let overtyped = progress.collect::(); + if !overtyped.is_empty() { + parts.push((overtyped, Status::Overtyped)); + } + parts +} diff --git a/src/types/test_word.rs b/src/components/test/word.rs similarity index 100% rename from src/types/test_word.rs rename to src/components/test/word.rs diff --git a/src/types/accuracy_data.rs b/src/types/accuracy_data.rs deleted file mode 100644 index 515f872..0000000 --- a/src/types/accuracy_data.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate::types::Fraction; -use std::collections::HashMap; -use tuirealm::ratatui::crossterm::event::KeyEvent; - -#[derive(Clone, Debug, PartialEq)] -pub struct AccuracyData { - pub overall: Fraction, - pub per_key: HashMap, -} diff --git a/src/types/fraction.rs b/src/types/fraction.rs deleted file mode 100644 index 53e75a0..0000000 --- a/src/types/fraction.rs +++ /dev/null @@ -1,47 +0,0 @@ -use std::cmp; -use std::fmt; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub struct Fraction { - pub numerator: usize, - pub denominator: usize, -} - -impl Fraction { - pub fn new(numerator: usize, denominator: usize) -> Self { - Self { - numerator, - denominator, - } - } -} - -impl From for f64 { - fn from(fraction: Fraction) -> Self { - if fraction.denominator == 0 { - 0.0 - } else { - fraction.numerator as f64 / fraction.denominator as f64 - } - } -} - -impl cmp::Ord for Fraction { - fn cmp(&self, other: &Self) -> cmp::Ordering { - f64::from(*self) - .partial_cmp(&f64::from(*other)) - .unwrap_or(cmp::Ordering::Equal) - } -} - -impl cmp::PartialOrd for Fraction { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl fmt::Display for Fraction { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}/{}", self.numerator, self.denominator) - } -} diff --git a/src/types/mod.rs b/src/types/mod.rs deleted file mode 100644 index a9f54b0..0000000 --- a/src/types/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -pub mod accuracy_data; -pub mod cli; -pub mod fraction; -pub mod results; -pub mod test; -pub mod test_event; -pub mod test_word; -pub mod timing_data; - -pub use accuracy_data::AccuracyData; -pub use cli::{Command, Opt}; -pub use fraction::Fraction; -pub use results::Results; -pub use test::Test; -pub use test_event::TestEvent; -pub use test_word::TestWord; -pub use timing_data::TimingData; - -#[derive(Debug, Eq, PartialEq, Clone, Hash)] -pub enum Id { - Test, - Results, -} diff --git a/src/types/results.rs b/src/types/results.rs deleted file mode 100644 index 2ea1b40..0000000 --- a/src/types/results.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::calculate; -use crate::types::{AccuracyData, Test, TestEvent, TimingData}; - -#[derive(Clone, Debug, PartialEq)] -pub struct Results { - pub timing: TimingData, - pub accuracy: AccuracyData, - pub missed_words: Vec, -} - -impl From<&Test> for Results { - fn from(test: &Test) -> Self { - let events: Vec<&TestEvent> = test.words.iter().flat_map(|w| w.events.iter()).collect(); - - Self { - timing: calculate::timing(&events), - accuracy: calculate::accuracy(&events), - missed_words: calculate::missed_words(test), - } - } -} diff --git a/src/types/test/mod.rs b/src/types/test/mod.rs deleted file mode 100644 index fab91ab..0000000 --- a/src/types/test/mod.rs +++ /dev/null @@ -1,30 +0,0 @@ -pub mod handler; -use crate::types::TestWord; - -#[derive(Debug, Clone, PartialEq)] -pub struct Test { - pub words: Vec, - pub current_word: usize, - pub complete: bool, - pub backtracking_enabled: bool, - pub sudden_death_enabled: bool, - pub backspace_enabled: bool, -} - -impl Test { - pub fn new( - words: Vec, - backtracking_enabled: bool, - sudden_death_enabled: bool, - backspace_enabled: bool, - ) -> Self { - Self { - words: words.into_iter().map(TestWord::from).collect(), - current_word: 0, - complete: false, - backtracking_enabled, - sudden_death_enabled, - backspace_enabled, - } - } -} diff --git a/src/types/timing_data.rs b/src/types/timing_data.rs deleted file mode 100644 index e9fb911..0000000 --- a/src/types/timing_data.rs +++ /dev/null @@ -1,10 +0,0 @@ -use std::collections::HashMap; -use tuirealm::ratatui::crossterm::event::KeyEvent; - -#[derive(Clone, Debug, PartialEq)] -pub struct TimingData { - // Instead of storing WPM, we store CPS (clicks per second) - pub overall_cps: f64, - pub per_event: Vec, - pub per_key: HashMap, -} From d5110b2e6279ebab1e6b1831a643259651933cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 17:31:44 -0400 Subject: [PATCH 25/30] Fixed redudant to to call --- src/resources.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources.rs b/src/resources.rs index 733a70f..cf6dbe6 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -29,7 +29,7 @@ impl Opt { .collect() }; - Some(lines.iter().map(String::from).collect()) + Some(lines) } None => { let lang_name = self From 0c8c2a8ab97de65bc5c1b6f2dfae16d98da2db3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 17:39:13 -0400 Subject: [PATCH 26/30] Clean reorg --- src/calculate.rs | 7 +++++-- src/components/test/component.rs | 7 +++++++ src/components/test/handler.rs | 3 ++- src/components/test/mock_component.rs | 11 ++++++++++ src/components/test/mod.rs | 20 +++++++----------- src/components/test/spans.rs | 27 +++++------------------- src/components/test/status.rs | 30 ++++++++++++++++++++++++--- src/components/test/word.rs | 2 +- src/main.rs | 11 ++++++---- src/messages.rs | 2 +- src/model.rs | 5 +++-- src/resources.rs | 2 +- 12 files changed, 78 insertions(+), 49 deletions(-) diff --git a/src/calculate.rs b/src/calculate.rs index 2d34034..a16a2d7 100644 --- a/src/calculate.rs +++ b/src/calculate.rs @@ -1,5 +1,8 @@ -use crate::types::test_event::is_missed_word_event; -use crate::types::{AccuracyData, Fraction, Test, TestEvent, TimingData}; +use crate::components::result::accuracy::Data as AccuracyData; +use crate::components::result::fraction::Fraction; +use crate::components::result::timing::Data as TimingData; +use crate::components::test::event::{is_missed_word_event, TestEvent}; +use crate::components::test::Test; use std::collections::HashMap; use tuirealm::ratatui::crossterm::event::KeyEvent; diff --git a/src/components/test/component.rs b/src/components/test/component.rs index c26cb12..fbeef60 100644 --- a/src/components/test/component.rs +++ b/src/components/test/component.rs @@ -1,3 +1,10 @@ +use tuirealm::ratatui::crossterm::event::{KeyCode, KeyModifiers}; +use tuirealm::{Component, Event, NoUserEvent}; + +use super::{convert_tuirealm_to_crossterm_key, TestComponent}; +use crate::components::result::Results; +use crate::messages::Msg; + impl Component for TestComponent { fn on(&mut self, ev: Event) -> Option { match ev { diff --git a/src/components/test/handler.rs b/src/components/test/handler.rs index 886e855..564c1b1 100644 --- a/src/components/test/handler.rs +++ b/src/components/test/handler.rs @@ -1,5 +1,6 @@ +use super::event::TestEvent; +use super::word::TestWord; use super::Test; -use crate::types::{TestEvent, TestWord}; use std::time::Instant; use tuirealm::ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; diff --git a/src/components/test/mock_component.rs b/src/components/test/mock_component.rs index 7889df6..3e2844d 100644 --- a/src/components/test/mock_component.rs +++ b/src/components/test/mock_component.rs @@ -1,3 +1,14 @@ +use tuirealm::ratatui::layout::{Constraint, Direction, Layout, Rect}; +use tuirealm::ratatui::text::{Line, Span}; +use tuirealm::ratatui::widgets::{Block, Borders, Paragraph, Widget}; +use tuirealm::{ + command::{Cmd, CmdResult}, + AttrValue, Attribute, Frame, MockComponent, State, +}; + +use super::spans::words_to_spans; +use super::TestComponent; + impl MockComponent for TestComponent { fn view(&mut self, frame: &mut Frame, area: Rect) { let buf = frame.buffer_mut(); diff --git a/src/components/test/mod.rs b/src/components/test/mod.rs index f52b10c..dda365f 100644 --- a/src/components/test/mod.rs +++ b/src/components/test/mod.rs @@ -2,22 +2,18 @@ use tuirealm::event::Key; use tuirealm::ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, }; -use tuirealm::ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Widget}, -}; -use tuirealm::{ - command::{Cmd, CmdResult}, - event::{Event, NoUserEvent}, - AttrValue, Attribute, Component, Frame, MockComponent, State, -}; use crate::config::Theme; -use crate::messages::Msg; -use crate::types::{Results, Test, TestWord}; +pub mod component; +pub mod event; pub mod handler; +pub mod mock_component; +pub mod spans; +pub mod status; +pub mod word; + +pub use word::TestWord; pub struct TestComponent { pub test: Test, diff --git a/src/components/test/spans.rs b/src/components/test/spans.rs index cfb402d..92eeada 100644 --- a/src/components/test/spans.rs +++ b/src/components/test/spans.rs @@ -1,6 +1,9 @@ -// Helpers +use super::status::{split_current_word, split_typed_word, word_parts_to_spans, Status}; +use super::word::TestWord; +use crate::config::Theme; +use tuirealm::ratatui::text::Span; -fn words_to_spans<'a>( +pub fn words_to_spans<'a>( words: &'a [TestWord], current_word: usize, theme: &'a Theme, @@ -23,23 +26,3 @@ fn words_to_spans<'a>( } spans } - -fn word_parts_to_spans(parts: Vec<(String, Status)>, theme: &Theme) -> Vec> { - let mut spans = Vec::new(); - for (text, status) in parts { - let style = match status { - Status::Correct => theme.prompt_correct, - Status::Incorrect => theme.prompt_incorrect, - Status::Untyped => theme.prompt_untyped, - Status::CurrentUntyped => theme.prompt_current_untyped, - Status::CurrentCorrect => theme.prompt_current_correct, - Status::CurrentIncorrect => theme.prompt_current_incorrect, - Status::Cursor => theme.prompt_current_untyped.patch(theme.prompt_cursor), - Status::Overtyped => theme.prompt_incorrect, - }; - - spans.push(Span::styled(text, style)); - } - spans.push(Span::styled(" ", theme.prompt_untyped)); - spans -} diff --git a/src/components/test/status.rs b/src/components/test/status.rs index 0e1b168..7192d4e 100644 --- a/src/components/test/status.rs +++ b/src/components/test/status.rs @@ -1,5 +1,9 @@ +use super::word::TestWord; +use crate::config::Theme; +use tuirealm::ratatui::text::Span; + #[derive(PartialEq, Clone, Copy, Debug)] -enum Status { +pub enum Status { Correct, Incorrect, CurrentUntyped, @@ -10,7 +14,7 @@ enum Status { Overtyped, } -fn split_current_word(word: &TestWord) -> Vec<(String, Status)> { +pub fn split_current_word(word: &TestWord) -> Vec<(String, Status)> { let mut parts = Vec::new(); let mut cur_string = String::new(); let mut cur_status = Status::Untyped; @@ -53,7 +57,7 @@ fn split_current_word(word: &TestWord) -> Vec<(String, Status)> { parts } -fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> { +pub fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> { let mut parts = Vec::new(); let mut cur_string = String::new(); let mut cur_status = Status::Untyped; @@ -90,3 +94,23 @@ fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> { } parts } + +pub fn word_parts_to_spans<'a>(parts: Vec<(String, Status)>, theme: &'a Theme) -> Vec> { + let mut spans = Vec::new(); + for (text, status) in parts { + let style = match status { + Status::Correct => theme.prompt_correct, + Status::Incorrect => theme.prompt_incorrect, + Status::Untyped => theme.prompt_untyped, + Status::CurrentUntyped => theme.prompt_current_untyped, + Status::CurrentCorrect => theme.prompt_current_correct, + Status::CurrentIncorrect => theme.prompt_current_incorrect, + Status::Cursor => theme.prompt_current_untyped.patch(theme.prompt_cursor), + Status::Overtyped => theme.prompt_incorrect, + }; + + spans.push(Span::styled(text, style)); + } + spans.push(Span::styled(" ", theme.prompt_untyped)); + spans +} diff --git a/src/components/test/word.rs b/src/components/test/word.rs index 216744b..df597a5 100644 --- a/src/components/test/word.rs +++ b/src/components/test/word.rs @@ -1,4 +1,4 @@ -use crate::types::TestEvent; +use super::event::TestEvent; #[derive(Debug, Clone, PartialEq)] pub struct TestWord { diff --git a/src/main.rs b/src/main.rs index e1a9a0f..44f0234 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ mod calculate; +mod cli; mod components; mod config; mod error; mod messages; mod model; mod resources; -mod types; use clap::CommandFactory; use clap::Parser; @@ -18,12 +18,13 @@ use tuirealm::{ Update, }; -use crate::components::test::TestComponent; +use crate::cli::{Command, Opt}; +use crate::components::test::{Test, TestComponent}; +use crate::components::Screen as Id; use crate::config::Config; use crate::error::TtyperError; use crate::messages::Msg; use crate::model::Model; -use crate::types::{Command, Id, Opt, Test}; fn list_languages(opt: &Opt) -> eyre::Result<()> { opt.languages().map_err(TtyperError::Io)?.for_each(|name| { @@ -59,11 +60,13 @@ fn main() -> eyre::Result<()> { let config = options.config(); if let Some(Command::Completions { shell }) = options.command { - generate_completions(shell)? + generate_completions(shell)?; + return Ok(()); } if options.list_languages { list_languages(&options)?; + return Ok(()); } let contents = make_test(&options)?; diff --git a/src/messages.rs b/src/messages.rs index 3b95368..1eebaaa 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -1,4 +1,4 @@ -use crate::types::Results; +use crate::components::result::Results; #[derive(Debug, PartialEq, Clone)] pub enum Msg { diff --git a/src/model.rs b/src/model.rs index 8a4856b..eed0122 100644 --- a/src/model.rs +++ b/src/model.rs @@ -2,12 +2,13 @@ use tuirealm::ratatui::layout::{Constraint, Direction, Layout}; use tuirealm::terminal::CrosstermTerminalAdapter; use tuirealm::{event::NoUserEvent, terminal::TerminalBridge, Application, Update}; +use crate::cli::Opt; use crate::components::result::ResultsComponent; -use crate::components::test::TestComponent; +use crate::components::test::{Test, TestComponent}; +use crate::components::Screen as Id; use crate::config::Config; use crate::error::Result; use crate::messages::Msg; -use crate::types::{Id, Opt, Test}; pub struct Model { pub app: Application, diff --git a/src/resources.rs b/src/resources.rs index cf6dbe6..f208ba3 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -1,5 +1,5 @@ +use crate::cli::Opt; use crate::config::Config; -use crate::types::Opt; use rand::{seq::SliceRandom, thread_rng}; use rust_embed::RustEmbed; use std::ffi::OsString; From b94dcf0fba211f2f2053bc88d2e87e3b60573403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 17:40:09 -0400 Subject: [PATCH 27/30] Fixed to proper usage for mounting --- src/model.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/model.rs b/src/model.rs index eed0122..b124977 100644 --- a/src/model.rs +++ b/src/model.rs @@ -47,9 +47,9 @@ impl Model { // Render the active view // We assume only one view is active at a time (Test or Results) - if self.app.active(&Id::Test).is_ok() { + if self.app.mounted(&Id::Test) { self.app.view(&Id::Test, f, chunks[0]); - } else if self.app.active(&Id::Results).is_ok() { + } else if self.app.mounted(&Id::Results) { self.app.view(&Id::Results, f, chunks[0]); } }) From bcb66b30801ca1c76ef3b972373f60b88a94c411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 21:08:15 -0400 Subject: [PATCH 28/30] Properly added result --- .gitignore | 2 + src/components/result/accuracy.rs | 9 ++ src/components/result/component.rs | 44 ++++++ src/components/result/fraction.rs | 47 ++++++ src/components/result/mock_component.rs | 199 ++++++++++++++++++++++++ src/components/result/mod.rs | 44 ++++++ src/components/result/timing.rs | 10 ++ 7 files changed, 355 insertions(+) create mode 100644 src/components/result/accuracy.rs create mode 100644 src/components/result/component.rs create mode 100644 src/components/result/fraction.rs create mode 100644 src/components/result/mock_component.rs create mode 100644 src/components/result/mod.rs create mode 100644 src/components/result/timing.rs diff --git a/.gitignore b/.gitignore index 4a398fa..6cc760f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ result # Editor .vim/ .nvimrc + +!/src/components/result diff --git a/src/components/result/accuracy.rs b/src/components/result/accuracy.rs new file mode 100644 index 0000000..ec9dd46 --- /dev/null +++ b/src/components/result/accuracy.rs @@ -0,0 +1,9 @@ +use super::fraction::Fraction; +use std::collections::HashMap; +use tuirealm::ratatui::crossterm::event::KeyEvent; + +#[derive(Clone, Debug, PartialEq)] +pub struct Data { + pub overall: Fraction, + pub per_key: HashMap, +} diff --git a/src/components/result/component.rs b/src/components/result/component.rs new file mode 100644 index 0000000..a656a9b --- /dev/null +++ b/src/components/result/component.rs @@ -0,0 +1,44 @@ +use rand::{seq::SliceRandom, thread_rng}; +use tuirealm::{ + event::{Key, KeyModifiers}, + Component, Event, NoUserEvent, +}; + +use super::ResultsComponent; +use crate::messages::Msg; + +impl Component for ResultsComponent { + fn on(&mut self, ev: Event) -> Option { + match ev { + Event::Keyboard(key) + if key.code == Key::Char('q') && key.modifiers == KeyModifiers::NONE => + { + Some(Msg::AppClose) + } + + Event::Keyboard(key) + if key.code == Key::Char('r') && key.modifiers == KeyModifiers::NONE => + { + Some(Msg::RestartTest) + } + + Event::Keyboard(key) + if key.code == Key::Char('p') && key.modifiers == KeyModifiers::NONE => + { + if self.results.missed_words.is_empty() { + return None; + } + // repeat each missed word 5 times + let mut practice_words: Vec = (self.results.missed_words) + .iter() + .flat_map(|w: &String| vec![w.clone(); 5]) + .collect(); + + practice_words.shuffle(&mut thread_rng()); + + Some(Msg::StartTest(practice_words)) + } + _ => None, + } + } +} diff --git a/src/components/result/fraction.rs b/src/components/result/fraction.rs new file mode 100644 index 0000000..53e75a0 --- /dev/null +++ b/src/components/result/fraction.rs @@ -0,0 +1,47 @@ +use std::cmp; +use std::fmt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct Fraction { + pub numerator: usize, + pub denominator: usize, +} + +impl Fraction { + pub fn new(numerator: usize, denominator: usize) -> Self { + Self { + numerator, + denominator, + } + } +} + +impl From for f64 { + fn from(fraction: Fraction) -> Self { + if fraction.denominator == 0 { + 0.0 + } else { + fraction.numerator as f64 / fraction.denominator as f64 + } + } +} + +impl cmp::Ord for Fraction { + fn cmp(&self, other: &Self) -> cmp::Ordering { + f64::from(*self) + .partial_cmp(&f64::from(*other)) + .unwrap_or(cmp::Ordering::Equal) + } +} + +impl cmp::PartialOrd for Fraction { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl fmt::Display for Fraction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.numerator, self.denominator) + } +} diff --git a/src/components/result/mock_component.rs b/src/components/result/mock_component.rs new file mode 100644 index 0000000..2e24acf --- /dev/null +++ b/src/components/result/mock_component.rs @@ -0,0 +1,199 @@ +use tuirealm::ratatui::crossterm::event::{KeyCode as CrosstermKeyCode, KeyEvent}; +use tuirealm::ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + symbols::Marker, + text::{Line, Span, Text}, + widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Widget}, +}; +use tuirealm::{ + command::{Cmd, CmdResult}, + AttrValue, Attribute, Frame, MockComponent, State, +}; + +use super::{ + Fraction, ResultsComponent, WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH, WORDS_PER_MINUTE_PER_CPS, +}; + +impl MockComponent for ResultsComponent { + fn view(&mut self, frame: &mut Frame, area: Rect) { + let buffer = frame.buffer_mut(); + + buffer.set_style(area, self.theme.default); + + // Chunks + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(area); + + let result_chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) // Graph looks tremendously better with just a little margin + .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) + .split(chunks[0]); + + let info_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) + .split(result_chunks[0]); + + // Handling the incomplete tests + // TODO: Show a better screen here + let msg = if self.results.missed_words.is_empty() { + "Press 'q' to quit or 'r' for another test" + } else { + "Press 'q' to quit, 'r' for another test or 'p' to practice missed words" + }; + + let exit = Span::styled(msg, self.theme.results_restart_prompt); + buffer.set_span(chunks[1].x, chunks[1].y, &exit, chunks[1].width); + + // Sections + let mut overview_text = Text::styled("", self.theme.results_overview); + overview_text.extend([ + Line::from(format!( + "Adjusted WPM: {:.1}", + self.results.timing.overall_cps + * WORDS_PER_MINUTE_PER_CPS + * f64::from(self.results.accuracy.overall) + )), + Line::from(format!( + "Accuracy: {:.1}%", + f64::from(self.results.accuracy.overall) * 100f64 + )), + Line::from(format!( + "Raw WPM: {:.1}", + self.results.timing.overall_cps * WORDS_PER_MINUTE_PER_CPS + )), + Line::from(format!( + "Correct Keypresses: {}", + self.results.accuracy.overall + )), + ]); + let overview = Paragraph::new(overview_text).block( + Block::default() + .title(Span::styled("Overview", self.theme.title)) + .borders(Borders::ALL) + .border_type(self.theme.border_type) + .border_style(self.theme.results_overview_border), + ); + overview.render(info_chunks[0], buffer); + + let mut worst_keys: Vec<(&KeyEvent, &Fraction)> = self + .results + .accuracy + .per_key + .iter() + .filter(|(key, _)| matches!(key.code, CrosstermKeyCode::Char(_))) + .collect(); + + // Unstable because we don't care about order, just results + worst_keys.sort_unstable_by_key(|x| x.1); + + let mut worst_text = Text::styled("", self.theme.results_worst_keys); + worst_text.extend( + worst_keys + .iter() + .filter_map(|(key, acc)| { + if let CrosstermKeyCode::Char(character) = key.code { + let key_accuracy = f64::from(**acc) * 100.0; + if key_accuracy != 100.0 { + Some(format!("- {} at {:.1}% accuracy", character, key_accuracy)) + } else { + None + } + } else { + None + } + }) + .take(5) + .map(Line::from), + ); + + let worst = Paragraph::new(worst_text).block( + Block::default() + .title(Span::styled("Worst Keys", self.theme.title)) + .borders(Borders::ALL) + .border_type(self.theme.border_type) + .border_style(self.theme.results_worst_keys_border), + ); + + worst.render(info_chunks[1], buffer); + + let words_per_minute_sliding_moving_average: Vec<(f64, f64)> = self + .results + .timing + .per_event + .windows(WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH) + .enumerate() + .map(|(i, window): (usize, &[f64])| { + ( + (i + WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH) as f64, + window.len() as f64 / window.iter().copied().sum::() + * WORDS_PER_MINUTE_PER_CPS, + ) + }) + .collect(); + + // Render the chart if possible. + if !words_per_minute_sliding_moving_average.is_empty() { + let minimum_average = words_per_minute_sliding_moving_average + .iter() + .map(|(_, x)| x) + .fold(f64::INFINITY, |a: f64, &b: &f64| a.min(b)); + + let maximum_average = words_per_minute_sliding_moving_average + .iter() + .map(|(_, x)| x) + .fold(f64::NEG_INFINITY, |a: f64, &b: &f64| a.max(b)); + + let wpm_datasets = vec![Dataset::default() + .name("WPM") + .marker(Marker::Braille) + .graph_type(GraphType::Line) + .style(self.theme.results_chart) + .data(&words_per_minute_sliding_moving_average)]; + + let y_label_minimum = minimum_average as u16; + let y_label_maximum = (maximum_average as u16).max(y_label_minimum + 6); + + let wpm_chart = Chart::new(wpm_datasets) + .block(Block::default().title(vec![Span::styled("Chart", self.theme.title)])) + .x_axis( + Axis::default() + .title(Span::styled("Keypresses", self.theme.results_chart_x)) + .bounds([0.0, self.results.timing.per_event.len() as f64]), + ) + .y_axis( + Axis::default() + .title(Span::styled( + "WPM (10-keypress rolling average)", + self.theme.results_chart_y, + )) + .bounds([minimum_average, maximum_average]) + .labels( + (y_label_minimum..y_label_maximum) + .step_by(5) + .map(|n| Span::raw(format!("{}", n))) + .collect::>(), + ), + ); + wpm_chart.render(result_chunks[1], buffer); + } + } + + // DEFAULT IMPLEMENTATIONS ROUGHLY + fn query(&self, _attr: Attribute) -> Option { + None + } + + fn attr(&mut self, _attr: Attribute, _value: AttrValue) {} + + fn state(&self) -> State { + State::None + } + + fn perform(&mut self, _cmd: Cmd) -> CmdResult { + CmdResult::None + } +} diff --git a/src/components/result/mod.rs b/src/components/result/mod.rs new file mode 100644 index 0000000..593d304 --- /dev/null +++ b/src/components/result/mod.rs @@ -0,0 +1,44 @@ +use crate::calculate; +use crate::components::test::event::TestEvent; +use crate::components::test::Test; +use crate::config::Theme; + +pub mod accuracy; +pub mod component; +pub mod fraction; +pub mod mock_component; +pub mod timing; + +pub use accuracy::Data as AccuracyData; +pub use fraction::Fraction; +pub use timing::Data as TimingData; + +// Convert CPS to WPM (clicks per second) +pub const WORDS_PER_MINUTE_PER_CPS: f64 = 12.0; + +// Width of the moving average window for the WPM chart +pub const WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH: usize = 10; + +#[derive(Clone, Debug, PartialEq)] +pub struct Results { + pub timing: TimingData, + pub accuracy: AccuracyData, + pub missed_words: Vec, +} + +impl From<&Test> for Results { + fn from(test: &Test) -> Self { + let events: Vec<&TestEvent> = test.words.iter().flat_map(|w| w.events.iter()).collect(); + + Self { + timing: calculate::timing(&events), + accuracy: calculate::accuracy(&events), + missed_words: calculate::missed_words(test), + } + } +} + +pub struct ResultsComponent { + pub results: Results, + pub theme: Theme, +} diff --git a/src/components/result/timing.rs b/src/components/result/timing.rs new file mode 100644 index 0000000..eaf4c1c --- /dev/null +++ b/src/components/result/timing.rs @@ -0,0 +1,10 @@ +use std::collections::HashMap; +use tuirealm::ratatui::crossterm::event::KeyEvent; + +#[derive(Clone, Debug, PartialEq)] +pub struct Data { + // Instead of storing WPM, we store CPS (clicks per second) + pub overall_cps: f64, + pub per_event: Vec, + pub per_key: HashMap, +} From ff1aed42388f92ccb63b0fb59d581808eec0e357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 22:53:53 -0400 Subject: [PATCH 29/30] Switched to dir-spec For default XDG conformance --- Cargo.lock | 136 +++++------------------------------------------ Cargo.toml | 2 +- src/resources.rs | 2 +- 3 files changed, 16 insertions(+), 124 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e51fd3a..1615566 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,25 +432,10 @@ dependencies = [ ] [[package]] -name = "dirs" -version = "5.0.1" +name = "dir_spec" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] +checksum = "dbe111bdda79626e929d51c9b229c8127d024abc2a99efeb48f16a0a76f7d54d" [[package]] name = "document-features" @@ -800,16 +785,6 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" -[[package]] -name = "libredox" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" -dependencies = [ - "bitflags 2.11.0", - "libc", -] - [[package]] name = "line-clipping" version = "0.3.5" @@ -984,12 +959,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "ordered-float" version = "4.6.0" @@ -1019,7 +988,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -1329,17 +1298,6 @@ dependencies = [ "bitflags 2.11.0", ] -[[package]] -name = "redox_users" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" -dependencies = [ - "getrandom 0.2.15", - "libredox", - "thiserror 1.0.63", -] - [[package]] name = "regex" version = "1.12.3" @@ -1830,7 +1788,7 @@ version = "1.6.0" dependencies = [ "clap", "clap_complete", - "dirs", + "dir_spec", "eyre", "rand", "ratatui 0.30.0", @@ -2181,22 +2139,13 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -2205,22 +2154,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows-targets", ] [[package]] @@ -2229,46 +2163,28 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2281,48 +2197,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -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.6" diff --git a/Cargo.toml b/Cargo.toml index b97f55d..f86cc08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ edition = "2021" [dependencies] clap = { version = "^4.5", features = ["derive"] } clap_complete = "^4.5" -dirs = "^5.0" +dir_spec = "0.5.0" rust-embed = "^8.2" toml = "^0.8" tuirealm = { version = "3.3.0", features = ["crossterm", "derive"] } diff --git a/src/resources.rs b/src/resources.rs index f208ba3..2cea75a 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -102,7 +102,7 @@ impl Opt { /// Config directory pub fn config_dir(&self) -> PathBuf { - dirs::config_dir() + dir_spec::config_home() .map(|d| d.join("ttyper")) .unwrap_or_else(|| PathBuf::from(".")) } From e2d45e63fd4a8f4ea7d5a4a0f30a4ffd95593a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 7 Apr 2026 08:47:57 -0400 Subject: [PATCH 30/30] Switched to an async model With more complexity this will EVENTUALLY make sense --- Cargo.lock | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +- src/main.rs | 12 +++--- 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1615566..c6ed7d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic" version = "0.6.1" @@ -159,6 +170,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cassowary" version = "0.3.0" @@ -313,6 +330,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", + "futures-core", "mio", "parking_lot", "rustix 1.1.4", @@ -550,6 +568,48 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1092,6 +1152,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1528,6 +1594,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.13.2" @@ -1748,6 +1820,41 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tokio" +version = "1.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.19" @@ -1796,6 +1903,7 @@ dependencies = [ "serde", "tempfile", "thiserror 2.0.18", + "tokio", "toml", "tuirealm", ] @@ -1806,12 +1914,16 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3a96abaf33552e9e2487bf56f537e49fad1d1fbc55a3fd542449d4e30e7c8f3" dependencies = [ + "async-trait", "bitflags 2.11.0", "crossterm 0.29.0", "dyn-clone", + "futures-util", "lazy-regex", "ratatui 0.29.0", "thiserror 2.0.18", + "tokio", + "tokio-util", "tuirealm_derive", ] diff --git a/Cargo.toml b/Cargo.toml index f86cc08..478e9f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,8 @@ clap_complete = "^4.5" dir_spec = "0.5.0" rust-embed = "^8.2" toml = "^0.8" -tuirealm = { version = "3.3.0", features = ["crossterm", "derive"] } +tuirealm = { version = "3.3.0", features = ["crossterm", "derive", "async-ports"] } +tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread"] } eyre = "0.6.12" thiserror = "2.0.18" ratatui = "0.30" diff --git a/src/main.rs b/src/main.rs index 44f0234..7cc3cce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,7 +55,8 @@ fn make_test(opt: &Opt) -> eyre::Result> { Ok(contents) } -fn main() -> eyre::Result<()> { +#[tokio::main] +async fn main() -> eyre::Result<()> { let options = Opt::parse(); let config = options.config(); @@ -93,9 +94,10 @@ fn setup_ttyper( let mut app: Application = Application::init( EventListenerCfg::::default() - .crossterm_input_listener(Duration::from_millis(20), 1) - .poll_timeout(Duration::from_millis(10)) - .tick_interval(Duration::from_secs(1)), + .with_handle(tokio::runtime::Handle::current()) + .async_crossterm_input_listener(Duration::from_millis(0), 1) + .tick_interval(Duration::from_secs(1)) + .async_tick(true), ); app.mount( @@ -126,7 +128,7 @@ fn event_loop(mut model: Model) -> eyre::Result<()> { while !model.quit { // Tick - match model.app.tick(PollStrategy::Once) { + match model.app.tick(PollStrategy::BlockCollectUpTo(10)) { Err(err) => { let _ = model.terminal.restore(); return Err(TtyperError::Application(err.to_string()).into());