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/Cargo.lock b/Cargo.lock index 557a581..c6ed7d2 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" @@ -75,17 +84,64 @@ 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" +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.6.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -96,23 +152,56 @@ 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" 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" 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" +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" @@ -151,7 +240,7 @@ 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", @@ -169,6 +258,43 @@ 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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.13" @@ -180,15 +306,34 @@ 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", + "bitflags 2.11.0", "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 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -213,6 +358,87 @@ 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" +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 2.0.117", +] + +[[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 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]] +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 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -224,26 +450,26 @@ 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", -] +checksum = "dbe111bdda79626e929d51c9b229c8127d024abc2a99efeb48f16a0a76f7d54d" [[package]] -name = "dirs-sys" -version = "0.4.1" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", + "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,18 +492,124 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "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 = "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" @@ -299,6 +631,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" @@ -307,7 +651,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -328,7 +672,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -336,18 +680,23 @@ 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" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "heck" -version = "0.5.0" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "id-arena" @@ -355,6 +704,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 +734,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 2.0.117", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -381,9 +755,18 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itertools" -version = "0.12.1" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -394,6 +777,62 @@ 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" +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 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" @@ -407,21 +846,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] -name = "libredox" -version = "0.1.3" +name = "line-clipping" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" dependencies = [ - "bitflags", - "libc", + "bitflags 2.11.0", ] +[[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" @@ -447,22 +897,120 @@ 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 = "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]] +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]] @@ -472,10 +1020,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] -name = "option-ext" -version = "0.2.0" +name = "ordered-float" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] [[package]] name = "parking_lot" @@ -497,7 +1048,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -506,6 +1057,119 @@ 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 = "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" +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" @@ -543,6 +1207,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" @@ -581,21 +1251,108 @@ 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", + "bitflags 2.11.0", "cassowary", - "crossterm", + "compact_str 0.8.1", + "crossterm 0.28.1", "indoc", - "itertools", - "lru", + "instability", + "itertools 0.13.0", + "lru 0.12.4", "paste", - "stability", - "strum", + "strum 0.26.3", "unicode-segmentation", - "unicode-width", + "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-width 0.2.0", ] [[package]] @@ -604,20 +1361,38 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] -name = "redox_users" -version = "0.4.5" +name = "regex" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ - "getrandom 0.2.15", - "libredox", - "thiserror", + "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" @@ -652,16 +1427,38 @@ 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 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" 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", + "linux-raw-sys 0.12.1", "windows-sys 0.59.0", ] @@ -671,6 +1468,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" @@ -785,6 +1588,18 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +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" @@ -792,14 +1607,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] -name = "stability" -version = "0.1.1" +name = "static_assertions" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce" -dependencies = [ - "quote", - "syn 1.0.109", -] +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strsim" @@ -809,26 +1620,47 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.25.0" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", ] [[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 = "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" @@ -860,17 +1692,89 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.59.0", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "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]] 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]] @@ -884,6 +1788,73 @@ dependencies = [ "syn 2.0.117", ] +[[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 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 = "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" @@ -924,14 +1895,47 @@ version = "1.6.0" dependencies = [ "clap", "clap_complete", - "crossterm", - "dirs", + "dir_spec", + "eyre", "rand", - "ratatui", + "ratatui 0.30.0", "rust-embed", "serde", "tempfile", + "thiserror 2.0.18", + "tokio", "toml", + "tuirealm", +] + +[[package]] +name = "tuirealm" +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", +] + +[[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 2.0.117", ] [[package]] @@ -940,6 +1944,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -948,9 +1958,31 @@ 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 = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "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" @@ -958,6 +1990,12 @@ 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" @@ -970,12 +2008,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" @@ -1010,6 +2069,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" @@ -1038,12 +2142,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" @@ -1075,22 +2251,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]] @@ -1099,22 +2266,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]] @@ -1123,46 +2275,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" @@ -1175,48 +2309,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" @@ -1248,7 +2358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -1259,7 +2369,7 @@ 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", @@ -1290,7 +2400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4d5419d..478e9f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,22 +12,53 @@ edition = "2021" [dependencies] clap = { version = "^4.5", features = ["derive"] } clap_complete = "^4.5" -dirs = "^5.0" -crossterm = "^0.27" +dir_spec = "0.5.0" rust-embed = "^8.2" toml = "^0.8" +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" +rand = {version = "^0.8", features = ["alloc"]} +serde = {version = "^1.0", features = ["derive"]} -[dependencies.ratatui] -version = "^0.25" +[dev-dependencies] +tempfile = "3.27.0" -[dependencies.rand] -version = "^0.8" -features = ["alloc"] +[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" -[dependencies.serde] -version = "^1.0" -features = ["derive"] +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +perf = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } -[dev-dependencies] -tempfile = "3.27.0" +# 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/calculate.rs b/src/calculate.rs new file mode 100644 index 0000000..a16a2d7 --- /dev/null +++ b/src/calculate.rs @@ -0,0 +1,81 @@ +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; + +pub fn 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 +} + +pub fn 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_or(false) { + acc.overall.numerator += 1; + key.numerator += 1; + } + }); + + acc +} + +pub fn 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/cli.rs b/src/cli.rs new file mode 100644 index 0000000..41acea1 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,59 @@ +use clap::{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, + }, +} diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..450cb71 --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +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/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, +} diff --git a/src/components/test/component.rs b/src/components/test/component.rs new file mode 100644 index 0000000..fbeef60 --- /dev/null +++ b/src/components/test/component.rs @@ -0,0 +1,41 @@ +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 { + 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/components/test/event.rs b/src/components/test/event.rs new file mode 100644 index 0000000..91cac06 --- /dev/null +++ b/src/components/test/event.rs @@ -0,0 +1,38 @@ +use std::fmt; +use std::time::Instant; +use tuirealm::ratatui::crossterm::event::KeyEvent; + +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/test/mod.rs b/src/components/test/handler.rs similarity index 68% rename from src/test/mod.rs rename to src/components/test/handler.rs index d6997cb..564c1b1 100644 --- a/src/test/mod.rs +++ b/src/components/test/handler.rs @@ -1,78 +1,10 @@ -pub mod results; - -use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use std::fmt; +use super::event::TestEvent; +use super::word::TestWord; +use super::Test; 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, -} +use tuirealm::ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; 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; @@ -151,7 +83,6 @@ impl Test { _ => {} }; } - fn last_word(&mut self) { if self.current_word != 0 { self.current_word -= 1; diff --git a/src/components/test/mock_component.rs b/src/components/test/mock_component.rs new file mode 100644 index 0000000..3e2844d --- /dev/null +++ b/src/components/test/mock_component.rs @@ -0,0 +1,98 @@ +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(); + + 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..dda365f --- /dev/null +++ b/src/components/test/mod.rs @@ -0,0 +1,103 @@ +use tuirealm::event::Key; +use tuirealm::ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, +}; + +use crate::config::Theme; + +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, + 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..92eeada --- /dev/null +++ b/src/components/test/spans.rs @@ -0,0 +1,28 @@ +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; + +pub 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 +} diff --git a/src/components/test/status.rs b/src/components/test/status.rs new file mode 100644 index 0000000..7192d4e --- /dev/null +++ b/src/components/test/status.rs @@ -0,0 +1,116 @@ +use super::word::TestWord; +use crate::config::Theme; +use tuirealm::ratatui::text::Span; + +#[derive(PartialEq, Clone, Copy, Debug)] +pub enum Status { + Correct, + Incorrect, + CurrentUntyped, + CurrentCorrect, + CurrentIncorrect, + Cursor, + Untyped, + Overtyped, +} + +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; + + 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 +} + +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; + + 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 +} + +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 new file mode 100644 index 0000000..df597a5 --- /dev/null +++ b/src/components/test/word.rs @@ -0,0 +1,24 @@ +use super::event::TestEvent; + +#[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/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")] diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6e6c419 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,18 @@ +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("Content error: {0}")] + Content(String), +} + +pub type Result = std::result::Result; diff --git a/src/main.rs b/src/main.rs index 691cd39..7cc3cce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,429 +1,160 @@ +mod calculate; +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; + +use clap::CommandFactory; +use clap::Parser; +use clap_complete::generate; +use clap_complete::Shell; +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, +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; + +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); + } + }); - #[command(subcommand)] - command: Option, + Ok(()) } -#[derive(Debug, Subcommand)] -enum Command { - /// Generate shell completions - Completions { - /// Shell to generate completions for - shell: Shell, - }, +fn generate_completions(shell: Shell) -> eyre::Result<()> { + generate(shell, &mut Opt::command(), "ttyper", &mut io::stdout()); + Ok(()) } -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")), +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(), ) - .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") + 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.")); } -} -enum State { - Test(Test), - Results(Results), + Ok(contents) } -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); +#[tokio::main] +async fn main() -> eyre::Result<()> { + let options = Opt::parse(); + let config = options.config(); - // 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<()> { - 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()); + if let Some(Command::Completions { shell }) = options.command { + generate_completions(shell)?; return Ok(()); } - if opt.list_languages { - opt.languages() - .unwrap() - .for_each(|name| println!("{}", name.to_str().expect("Ill-formatted language name."))); - + if options.list_languages { + list_languages(&options)?; 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 contents = make_test(&options)?; - 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); - } + let (app, terminal_bridge) = setup_ttyper(contents, &options, &config)?; - terminal::enable_raw_mode()?; - execute!( - io::stdout(), - cursor::Hide, - cursor::SavePosition, - terminal::EnterAlternateScreen, - )?; - terminal.clear()?; + let model = Model::new(app, terminal_bridge, config, options); - let mut state = State::Test(Test::new( - contents, - !opt.no_backtrack, - opt.sudden_death, - !opt.no_backspace, - )); + event_loop(model)?; - state.render_into(&mut terminal, &config)?; - loop { - let event = event::read()?; + Ok(()) +} - // 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, - }, - _ => {} - } +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( + EventListenerCfg::::default() + .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( + Id::Test, + Box::new(TestComponent { + test: Test::new( + contents, + !options.no_backtrack, + options.sudden_death, + !options.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()))?; + + Ok((app, terminal_bridge)) +} - 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)); - } - } +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 { + // Tick + match model.app.tick(PollStrategy::BlockCollectUpTo(10)) { + 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; - } - 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; + 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); + } } - // 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(()) } diff --git a/src/messages.rs b/src/messages.rs new file mode 100644 index 0000000..1eebaaa --- /dev/null +++ b/src/messages.rs @@ -0,0 +1,10 @@ +use crate::components::result::Results; + +#[derive(Debug, PartialEq, Clone)] +pub enum Msg { + AppClose, + ShowResults(Results), + RestartTest, + StartTest(Vec), + None, +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..b124977 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,131 @@ +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::{Test, TestComponent}; +use crate::components::Screen as Id; +use crate::config::Config; +use crate::error::Result; +use crate::messages::Msg; + +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.mounted(&Id::Test) { + self.app.view(&Id::Test, f, chunks[0]); + } else if self.app.mounted(&Id::Results) { + 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/resources.rs b/src/resources.rs new file mode 100644 index 0000000..2cea75a --- /dev/null +++ b/src/resources.rs @@ -0,0 +1,114 @@ +use crate::cli::Opt; +use crate::config::Config; +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) + } + 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: fs::DirEntry| e.file_name()); + + Ok(builtin.chain(configured)) + } + + /// Config directory + pub fn config_dir(&self) -> PathBuf { + dir_spec::config_home() + .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") + } +} 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() -} 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); - } - } - } -}