diff --git a/.envrc b/.envrc deleted file mode 100644 index 8392d15..0000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..aa0c5c3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "ansible.python.interpreterPath": "c:\\Program Files\\Python311\\python.exe" +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 87aff5d..6ad475d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,23 +3,70 @@ version = 3 [[package]] -name = "ansi_term" -version = "0.12.1" +name = "ahash" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ - "winapi", + "cfg-if", + "once_cell", + "version_check", + "zerocopy", ] [[package]] -name = "atty" -version = "0.2.14" +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "anstream" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" dependencies = [ - "hermit-abi", - "libc", - "winapi", + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" + +[[package]] +name = "anstyle-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", ] [[package]] @@ -35,13 +82,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "block-buffer" -version = "0.10.4" +name = "bitflags" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" [[package]] name = "cassowary" @@ -49,6 +93,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "castaway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -57,35 +110,70 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "2.34.0" +version = "4.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f" dependencies = [ - "ansi_term", - "atty", - "bitflags", + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", "strsim", - "textwrap", - "unicode-width", - "vec_map", ] [[package]] -name = "cpufeatures" -version = "0.2.7" +name = "clap_derive" +version = "4.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58" +checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6" dependencies = [ - "libc", + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", ] [[package]] name = "crossterm" -version = "0.25.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags", + "bitflags 2.5.0", "crossterm_winapi", "libc", "mio", @@ -97,103 +185,109 @@ dependencies = [ [[package]] name = "crossterm_winapi" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ "winapi", ] -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "dirs" -version = "4.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.3.7" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", + "option-ext", "redox_users", - "winapi", + "windows-sys 0.48.0", ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "either" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] +checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "getrandom" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ "cfg-if", "libc", "wasi", ] +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "heck" -version = "0.3.3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" dependencies = [ - "unicode-segmentation", + "equivalent", + "hashbrown", ] [[package]] -name = "hermit-abi" -version = "0.1.19" +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + +[[package]] +name = "itertools" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ - "libc", + "either", ] [[package]] -name = "lazy_static" -version = "1.4.0" +name = "itoa" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "libc" -version = "0.2.142" +version = "0.2.144" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" +checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" [[package]] name = "lock_api" @@ -207,25 +301,49 @@ dependencies = [ [[package]] name = "log" -version = "0.4.17" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" + +[[package]] +name = "lru" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" dependencies = [ - "cfg-if", + "hashbrown", ] +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + [[package]] name = "mio" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.48.0", ] +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.1" @@ -246,53 +364,35 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-sys", + "windows-sys 0.45.0", ] [[package]] -name = "ppv-lite86" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" - -[[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "ppv-lite86" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" -version = "1.0.56" +version = "1.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.26" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] @@ -327,13 +427,33 @@ dependencies = [ "getrandom", ] +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags 2.5.0", + "cassowary", + "compact_str", + "crossterm", + "itertools", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -348,47 +468,16 @@ dependencies = [ ] [[package]] -name = "rust-embed" -version = "6.6.1" +name = "rustversion" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b68543d5527e158213414a92832d2aab11a84d2571a5eb021ebe22c43aab066" -dependencies = [ - "rust-embed-impl", - "rust-embed-utils", - "walkdir", -] +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" [[package]] -name = "rust-embed-impl" -version = "6.5.0" +name = "ryu" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d4e0f0ced47ded9a68374ac145edd65a6c1fa13a96447b873660b2a568a0fd7" -dependencies = [ - "proc-macro2", - "quote", - "rust-embed-utils", - "syn 1.0.109", - "walkdir", -] - -[[package]] -name = "rust-embed-utils" -version = "7.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512b0ab6853f7e14e3c8754acb43d6f748bb9ced66aa5915a6553ac8213f7731" -dependencies = [ - "sha2", - "walkdir", -] - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "scopeguard" @@ -398,40 +487,38 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.160" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2f3770c8bce3bcda7e149193a069a0f4365bda1fa5cd88e03bca26afc1216c" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.160" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291a097c63d8497e00160b166a967a4a79c64f3facdd01cbd7502231688d77df" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn", ] [[package]] -name = "sha2" -version = "0.10.6" +name = "serde_spanned" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "serde", ] [[package]] name = "signal-hook" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732768f1176d21d09e076c23a93123d40bba92d50c4058da34d45c8de8e682b9" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" dependencies = [ "libc", "signal-hook-registry", @@ -464,66 +551,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" [[package]] -name = "strsim" -version = "0.8.0" +name = "stability" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +checksum = "2ff9eaf853dec4c8802325d8b6d3dffa86cc707fd7a1a4cdbf416e13b061787a" +dependencies = [ + "quote", + "syn", +] [[package]] -name = "structopt" -version = "0.3.26" +name = "static_assertions" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" -dependencies = [ - "clap", - "lazy_static", - "structopt-derive", -] +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "structopt-derive" -version = "0.4.18" +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" dependencies = [ - "heck", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 1.0.109", + "strum_macros", ] [[package]] -name = "syn" -version = "1.0.109" +name = "strum_macros" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ + "heck", "proc-macro2", "quote", - "unicode-ident", + "rustversion", + "syn", ] [[package]] name = "syn" -version = "2.0.15" +version = "2.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a34fcf3e8b60f57e6a14301a2e916d323af98b0ea63c599441eec8558660c822" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] -[[package]] -name = "textwrap" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" -dependencies = [ - "unicode-width", -] - [[package]] name = "thiserror" version = "1.0.40" @@ -541,62 +622,79 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn", ] [[package]] name = "toml" -version = "0.5.11" +version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335" dependencies = [ "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", ] [[package]] -name = "ttyper" -version = "1.2.0" +name = "toml_datetime" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" dependencies = [ - "crossterm", - "dirs", - "rand", - "rust-embed", "serde", - "structopt", - "toml", - "tui", ] [[package]] -name = "tui" -version = "0.19.0" +name = "toml_edit" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1" +checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38" dependencies = [ - "bitflags", - "cassowary", + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "ttyper" +version = "2.0.0-pre.1" +dependencies = [ + "clap", "crossterm", + "dirs", + "rand", + "rand_chacha", + "ratatui", + "serde", + "toml", "unicode-segmentation", - "unicode-width", ] [[package]] -name = "typenum" -version = "1.16.0" +name = "unicode-ident" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" +checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" [[package]] -name = "unicode-ident" -version = "1.0.8" +name = "unicode-segmentation" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" [[package]] -name = "unicode-segmentation" -version = "1.10.1" +name = "unicode-truncate" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +checksum = "5a5fbabedabe362c618c714dbefda9927b5afc8e2a8102f47f081089a9019226" +dependencies = [ + "itertools", + "unicode-width", +] [[package]] name = "unicode-width" @@ -605,10 +703,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" [[package]] -name = "vec_map" -version = "0.8.2" +name = "utf8parse" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "version_check" @@ -616,16 +714,6 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" -[[package]] -name = "walkdir" -version = "2.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -648,15 +736,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" -dependencies = [ - "winapi", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -669,7 +748,25 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[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.0", +] + +[[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.5", ] [[package]] @@ -678,13 +775,44 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", ] [[package]] @@ -693,38 +821,157 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "winnow" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 706d585..340eee6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ttyper" description = "Terminal-based typing test." -version = "1.2.0" +version = "2.0.0-pre.1" readme = "README.md" repository = "https://github.com/max-niederman/ttyper.git" homepage = "https://github.com/max-niederman/ttyper" @@ -10,24 +10,14 @@ authors = ["Max Niederman "] edition = "2018" [dependencies] -structopt = "0.3" -dirs = "4.0" -crossterm = "0.25" -rust-embed = "6.4" -toml = "0.5" +crossterm = "0.27.0" +clap = { version = "4.5.7", features = ["derive"] } +ratatui = "0.26.3" +unicode-segmentation = "1.11.0" +rand = "0.8.5" +serde = { version = "1.0.203", features = ["derive"] } +dirs = "5.0.1" +toml = "0.8.14" -[dependencies.tui] -version = "0.19" -default-features = false -features = ["crossterm"] - -[dependencies.rand] -version = "0.8" -features = ["alloc"] - -[dependencies.serde] -version = "1.0" -features = ["derive"] - -[build-dependencies] -dirs = "4.0" +[dev-dependencies] +rand_chacha = "0.3.1" diff --git a/build.rs b/build.rs deleted file mode 100644 index b1bcabb..0000000 --- a/build.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; - -fn copy, V: AsRef>(from: U, to: V) -> std::io::Result<()> { - let mut stack = vec![PathBuf::from(from.as_ref())]; - - let output_root = PathBuf::from(to.as_ref()); - let input_root = PathBuf::from(from.as_ref()).components().count(); - - while let Some(working_path) = stack.pop() { - // Generate a relative path - let src: PathBuf = working_path.components().skip(input_root).collect(); - - // Create a destination if missing - let dest = if src.components().count() == 0 { - output_root.clone() - } else { - output_root.join(&src) - }; - if fs::metadata(&dest).is_err() { - fs::create_dir_all(&dest)?; - } - - for entry in fs::read_dir(working_path)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if let Some(filename) = path.file_name() { - let dest_path = dest.join(filename); - fs::copy(&path, &dest_path)?; - } - } - } - - Ok(()) -} - -#[allow(unused_must_use)] -fn main() -> std::io::Result<()> { - let install_path = dirs::config_dir() - .expect("Couldn't find a configuration directory to install to.") - .join("ttyper"); - fs::create_dir_all(&install_path); - - let resources_path = env::current_dir() - .expect("Couldn't find the source directory.") - .join("resources") - .join("runtime"); - copy(&resources_path, &install_path); - - Ok(()) -} diff --git a/flake.lock b/flake.lock deleted file mode 100644 index de3f602..0000000 --- a/flake.lock +++ /dev/null @@ -1,114 +0,0 @@ -{ - "nodes": { - "fenix": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src" - }, - "locked": { - "lastModified": 1639808710, - "narHash": "sha256-OKDHt4D14puuqfVHptQ6EvjIR9RaHXyPzMh8Rjo8vzA=", - "owner": "nix-community", - "repo": "fenix", - "rev": "9b391fc1831ece6c245a4eafe7b52f5c806df28c", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "flake-utils": { - "locked": { - "lastModified": 1638122382, - "narHash": "sha256-sQzZzAbvKEqN9s0bzWuYmRaA03v40gaJ4+iL1LXjaeI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "74f7e4319258e287b0f9cb95426c9853b282730b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "naersk": { - "inputs": { - "nixpkgs": "nixpkgs" - }, - "locked": { - "lastModified": 1639731602, - "narHash": "sha256-5u/J/7KrY/fYL/OeBLxP4E9NdNdQrriHpOyPBleKp5I=", - "owner": "nmattia", - "repo": "naersk", - "rev": "5415c7045366bb53db1a33cf7d975942b5553a28", - "type": "github" - }, - "original": { - "owner": "nmattia", - "repo": "naersk", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1639837534, - "narHash": "sha256-zAZoVtvVfrs41e+kEEumyptQ4DOkcXQIYgxmaJ51+hs=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e1f9e754a40b645a39f6592d45df943cb5f59dcf", - "type": "github" - }, - "original": { - "id": "nixpkgs", - "type": "indirect" - } - }, - "nixpkgs_2": { - "locked": { - "lastModified": 1639699734, - "narHash": "sha256-tlX6WebGmiHb2Hmniff+ltYp+7dRfdsBxw9YczLsP60=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "03ec468b14067729a285c2c7cfa7b9434a04816c", - "type": "github" - }, - "original": { - "id": "nixpkgs", - "ref": "nixos-unstable", - "type": "indirect" - } - }, - "root": { - "inputs": { - "fenix": "fenix", - "flake-utils": "flake-utils", - "naersk": "naersk", - "nixpkgs": "nixpkgs_2" - } - }, - "rust-analyzer-src": { - "flake": false, - "locked": { - "lastModified": 1639175515, - "narHash": "sha256-Yj38u9BpKfyGrcSEaoSEnOns885xn/Ask6lR5rsxS8k=", - "owner": "rust-analyzer", - "repo": "rust-analyzer", - "rev": "d03397fe1173eaeb2e04c9e55ac223289e7e08ee", - "type": "github" - }, - "original": { - "owner": "rust-analyzer", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index aa0389a..0000000 --- a/flake.nix +++ /dev/null @@ -1,45 +0,0 @@ -{ - inputs = { - flake-utils.url = "github:numtide/flake-utils"; - - nixpkgs.url = "nixpkgs/nixos-unstable"; - fenix = { - url = "github:nix-community/fenix"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - naersk.url = "github:nmattia/naersk"; - }; - - outputs = { self, nixpkgs, flake-utils, fenix, naersk }: - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = nixpkgs.legacyPackages.${system}; - rust = with fenix.packages.${system}; stable; - naersk-lib = naersk.lib.${system}.override { - inherit (rust) rustc cargo; - }; - in - rec { - # `nix build` - packages.ttyper = naersk-lib.buildPackage rec { - pname = "ttyper"; - root = ./.; - }; - defaultPackage = packages.ttyper; - - # `nix run` - apps.ttyper = flake-utils.lib.mkApp { - drv = packages.ttyper; - }; - defaultApp = apps.ttyper; - - # `nix develop` - devShell = pkgs.mkShell { - nativeBuildInputs = with pkgs; [ - (rust.withComponents [ "rustc" "cargo" "rust-src" "rustfmt" "clippy" ]) - rust-analyzer - ]; - }; - } - ); -} diff --git a/src/config.rs b/src/config.rs index 1b2d8a4..7c0039d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,13 +1,19 @@ +use std::path::PathBuf; + +use ratatui::style::{Color, Modifier, Style}; use serde::{ de::{self, IntoDeserializer}, Deserialize, }; -use tui::style::{Color, Modifier, Style}; #[derive(Debug, Deserialize)] #[serde(default)] pub struct Config { - pub default_language: String, + pub default_language: PathBuf, + pub default_lexer: String, + + pub max_misalignment: usize, + pub theme: Theme, } @@ -15,6 +21,10 @@ impl Default for Config { fn default() -> Self { Self { default_language: "english200".into(), + default_lexer: "extended-grapheme-clusters".into(), // TODO: this should be unicode words probably + + max_misalignment: 8, + theme: Theme::default(), } } @@ -22,103 +32,11 @@ impl Default for Config { #[derive(Debug, Deserialize)] #[serde(default)] -pub struct Theme { - #[serde(deserialize_with = "deserialize_style")] - pub default: Style, - #[serde(deserialize_with = "deserialize_style")] - pub title: Style, - - // test widget - #[serde(deserialize_with = "deserialize_style")] - pub input_border: Style, - #[serde(deserialize_with = "deserialize_style")] - pub prompt_border: Style, - - #[serde(deserialize_with = "deserialize_style")] - pub prompt_correct: Style, - #[serde(deserialize_with = "deserialize_style")] - pub prompt_incorrect: Style, - #[serde(deserialize_with = "deserialize_style")] - pub prompt_untyped: Style, - - #[serde(deserialize_with = "deserialize_style")] - pub prompt_current_correct: Style, - #[serde(deserialize_with = "deserialize_style")] - pub prompt_current_incorrect: Style, - #[serde(deserialize_with = "deserialize_style")] - pub prompt_current_untyped: Style, - - #[serde(deserialize_with = "deserialize_style")] - pub prompt_cursor: Style, - - // results widget - #[serde(deserialize_with = "deserialize_style")] - pub results_overview: Style, - #[serde(deserialize_with = "deserialize_style")] - pub results_overview_border: Style, - - #[serde(deserialize_with = "deserialize_style")] - pub results_worst_keys: Style, - #[serde(deserialize_with = "deserialize_style")] - pub results_worst_keys_border: Style, - - #[serde(deserialize_with = "deserialize_style")] - pub results_chart: Style, - #[serde(deserialize_with = "deserialize_style")] - pub results_chart_x: Style, - #[serde(deserialize_with = "deserialize_style")] - pub results_chart_y: Style, - - #[serde(deserialize_with = "deserialize_style")] - pub results_restart_prompt: Style, -} +pub struct Theme {} impl Default for Theme { fn default() -> Self { - Self { - default: Style::default(), - - title: Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - - input_border: Style::default().fg(Color::Cyan), - prompt_border: Style::default().fg(Color::Green), - - prompt_correct: Style::default().fg(Color::Green), - prompt_incorrect: Style::default().fg(Color::Red), - prompt_untyped: Style::default().fg(Color::Gray), - - prompt_current_correct: Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - prompt_current_incorrect: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - prompt_current_untyped: Style::default() - .fg(Color::Blue) - .add_modifier(Modifier::BOLD), - - prompt_cursor: Style::default().add_modifier(Modifier::UNDERLINED), - - results_overview: Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - results_overview_border: Style::default().fg(Color::Cyan), - - results_worst_keys: Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - results_worst_keys_border: Style::default().fg(Color::Cyan), - - results_chart: Style::default().fg(Color::Cyan), - results_chart_x: Style::default().fg(Color::Cyan), - results_chart_y: Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::BOLD), - - results_restart_prompt: Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::ITALIC), - } + Self {} } } @@ -177,6 +95,18 @@ where deserializer.deserialize_str(StyleVisitor) } +pub fn default_config_file_path() -> std::path::PathBuf { + dirs::config_dir().unwrap().join("config.toml") +} + +pub fn load(opt: &crate::Opt) -> Config { + if let Ok(config_raw) = std::fs::read_to_string(&opt.config_file) { + toml::from_str(&config_raw).unwrap() + } else { + Config::default() + } +} + fn deserialize_color<'de, D>(deserializer: D) -> Result where D: de::Deserializer<'de>, diff --git a/src/contents.rs b/src/contents.rs new file mode 100644 index 0000000..ae01248 --- /dev/null +++ b/src/contents.rs @@ -0,0 +1,140 @@ +use std::{ + fs::File, + io::{BufRead, BufReader}, + num::NonZeroUsize, +}; + +use rand::{ + distributions::{DistIter, Uniform}, + prelude::*, +}; +use unicode_segmentation::GraphemeCursor; + +use crate::opt::{Command, FileLexer}; + +/// A trait for types that can be used as trial contents. +/// +/// The iterator should yield "words," i.e. the smallest chunks +/// of the test that should not be split across line breaks. +pub trait Contents: Iterator { + fn restart(&mut self); +} + +pub fn generate(env: &crate::Env) -> Box { + match &env.opt.command { + Command::File { path, lexer } => { + let raw = std::fs::read_to_string(path).unwrap(); + match lexer { + FileLexer::ExtendedGraphemeClusters => Box::new(ExtendedGraphemeClusters::new(raw)), + } + } + Command::Words { + count, + language: language_name, + language_cutoff, + } => { + let language_name = language_name + .clone() + .unwrap_or(env.config.default_language.clone()); + + let language: Vec<_> = if language_name.is_file() { + BufReader::new(File::open(language_name).unwrap()) + .lines() + .take(language_cutoff.unwrap_or(NonZeroUsize::MAX).get()) + .map(Result::unwrap) + .collect() + } else { + todo!("builtin languages") + }; + + let contents = Uniform::from(0..language.len()) + .map(move |i| language[i].clone()) + .sample_iter(thread_rng()); + + let contents: Box = if let Some(count) = count { + Box::new(Take::new(contents, count.get())) + } else { + Box::new(contents) + }; + + contents + } + } +} + +struct Take { + contents: C, + count: usize, + remaining: usize, +} + +impl Take { + pub fn new(inner: C, count: usize) -> Self { + Self { + count, + remaining: count, + contents: inner, + } + } +} + +impl Iterator for Take { + type Item = C::Item; + + fn next(&mut self) -> Option { + if self.remaining == 0 { + None + } else { + self.remaining -= 1; + self.contents.next() + } + } +} + +impl Contents for Take { + fn restart(&mut self) { + self.remaining = self.count; + self.contents.restart(); + } +} + +impl Contents for DistIter +where + D: Distribution, + R: Rng, +{ + fn restart(&mut self) {} +} + +struct ExtendedGraphemeClusters = String> { + string: S, + cursor: unicode_segmentation::GraphemeCursor, +} + +impl> ExtendedGraphemeClusters { + pub fn new(string: S) -> Self { + Self { + cursor: GraphemeCursor::new(0, string.as_ref().len(), true), + string, + } + } +} + +impl> Iterator for ExtendedGraphemeClusters { + type Item = String; + + fn next(&mut self) -> Option { + let start = self.cursor.cur_cursor(); + let end = self + .cursor + .next_boundary(self.string.as_ref(), 0) + .unwrap()?; + Some(self.string.as_ref()[start..end].to_owned()) + } +} + +impl> Contents for ExtendedGraphemeClusters { + fn restart(&mut self) { + self.cursor.set_cursor(0); + } +} diff --git a/src/main.rs b/src/main.rs index 6921358..490aafa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,278 +1,38 @@ mod config; -mod test; -mod ui; +mod contents; +mod opt; +mod trial; use config::Config; -use test::{results::Results, Test}; +use opt::Opt; -use crossterm::{ - self, cursor, - event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, - execute, terminal, -}; -use rand::{seq::SliceRandom, thread_rng}; -use rust_embed::RustEmbed; -use std::{ - ffi::OsString, - fs, - io::{self, BufRead}, - num, - path::PathBuf, - str, -}; -use structopt::StructOpt; -use tui::{backend::CrosstermBackend, terminal::Terminal}; - -#[derive(RustEmbed)] -#[folder = "resources/runtime"] -struct Resources; - -#[derive(Debug, StructOpt)] -#[structopt(name = "ttyper", about = "Terminal-based typing test.")] -struct Opt { - #[structopt(parse(from_os_str))] - contents: Option, - - #[structopt(short, long)] - debug: bool, - - /// Specify word count - #[structopt(short, long, default_value = "50")] - words: num::NonZeroUsize, - - /// Use config file - #[structopt(short, long)] - config: Option, - - /// Specify test language in file - #[structopt(long, parse(from_os_str))] - language_file: Option, - - /// Specify test language - #[structopt(short, long)] - language: Option, - - /// List installed languages - #[structopt(long)] - list_languages: bool, +#[derive(Debug)] +pub struct Env { + pub config: Config, + pub opt: Opt, } -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() - .filter_map(Result::ok) - .collect() - } else { - let file = fs::File::open(path).expect("Error reading language file."); - io::BufReader::new(file) - .lines() - .filter_map(Result::ok) - .collect() - }; - - Some(lines.iter().map(String::from).collect()) - } - None => { - let lang_name = self - .language - .clone() - .unwrap_or_else(|| self.config().default_language); - - let bytes: Vec = self - .language_file - .as_ref() - .map(fs::read) - .and_then(Result::ok) - .or_else(|| fs::read(self.language_dir().join(&lang_name)).ok()) - .or_else(|| { - Resources::get(&format!("language/{}", &lang_name)) - .map(|f| f.data.into_owned()) - })?; - - let mut rng = thread_rng(); - - let mut language: Vec<&str> = str::from_utf8(&bytes) - .expect("Language file had non-utf8 encoding.") - .lines() - .collect(); - language.shuffle(&mut rng); - - let mut contents: Vec<_> = language - .into_iter() - .cycle() - .take(self.words.get()) - .map(ToOwned::to_owned) - .collect(); - contents.shuffle(&mut rng); - - Some(contents) - } - } - } - - /// Configuration - fn config(&self) -> Config { - fs::read( - self.config - .clone() - .unwrap_or_else(|| self.config_dir().join("config.toml")), - ) - .map(|bytes| toml::from_slice(&bytes).expect("Configuration was ill-formed.")) - .unwrap_or_default() - } - - /// Installed languages under config directory - fn languages(&self) -> io::Result> { - Ok(self - .language_dir() - .read_dir()? - .filter_map(Result::ok) - .map(|e| e.file_name()) - .collect()) - } - - /// Config directory - fn config_dir(&self) -> PathBuf { - dirs::config_dir() - .expect("Failed to find config directory.") - .join("ttyper") - } - - /// Language directory under config directory - fn language_dir(&self) -> PathBuf { - self.config_dir().join("language") - } -} - -enum State { - Test(Test), - Results(Results), -} - -impl State { - fn render_into( - &self, - terminal: &mut Terminal, - config: &Config, - ) -> crossterm::Result<()> { - match self { - State::Test(test) => { - terminal.draw(|f| { - f.render_widget(config.theme.apply_to(test), f.size()); - })?; - } - State::Results(results) => { - terminal.draw(|f| { - f.render_widget(config.theme.apply_to(results), f.size()); - })?; - } - } - Ok(()) - } -} - -fn main() -> crossterm::Result<()> { - let opt = Opt::from_args(); +fn main() { + let opt = ::parse(); if opt.debug { dbg!(&opt); } - let config = opt.config(); + let config = config::load(&opt); if opt.debug { dbg!(&config); } - if opt.list_languages { - opt.languages() - .expect("Couldn't get installed languages under config directory. Make sure the config directory exists.") - .iter() - .for_each(|name| println!("{}", name.to_str().expect("Ill-formatted language name."))); - return Ok(()); - } - - let backend = CrosstermBackend::new(io::stdout()); - let mut terminal = Terminal::new(backend)?; - - terminal::enable_raw_mode()?; - execute!( - io::stdout(), - cursor::Hide, - cursor::SavePosition, - terminal::EnterAlternateScreen, - )?; - terminal.clear()?; - - let mut state = State::Test(Test::new(opt.gen_contents().expect( - "Couldn't get test contents. Make sure the specified language actually exists.", - ))); - - state.render_into(&mut terminal, &config)?; - loop { - let event = event::read()?; + let env = Env { config, opt }; - // handle exit controls - match event { - Event::Key(KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - .. - }) => break, - Event::Key(KeyEvent { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - .. - }) => match state { - State::Test(ref test) => { - state = State::Results(Results::from(test)); - } - State::Results(_) => break, - }, - _ => {} - } + let mut contents = contents::generate(&env); - 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)); - } - } - } - State::Results(_) => match event { - Event::Key(KeyEvent { - code: KeyCode::Char('r'), - modifiers: KeyModifiers::NONE, - .. - }) => { - state = State::Test(Test::new(opt.gen_contents().expect( - "Couldn't get test contents. Make sure the specified language actually exists.", - ))); - } - Event::Key(KeyEvent { - code: KeyCode::Char('q'), - modifiers: KeyModifiers::NONE, - .. - }) => break, - _ => {} - }, - } - - state.render_into(&mut terminal, &config)?; + for line in &mut *contents { + println!("{}", line); + } + println!(":: restart ::"); + contents.restart(); + for line in &mut *contents { + println!("{}", line); } - - terminal::disable_raw_mode()?; - execute!( - io::stdout(), - cursor::RestorePosition, - cursor::Show, - terminal::LeaveAlternateScreen, - )?; - - Ok(()) } diff --git a/src/opt.rs b/src/opt.rs new file mode 100644 index 0000000..bc8a1d4 --- /dev/null +++ b/src/opt.rs @@ -0,0 +1,59 @@ +use std::{ + num::{self, NonZeroUsize}, + path::PathBuf, + str::FromStr, +}; + +#[derive(Debug, clap::Parser)] +pub struct Opt { + #[command(subcommand)] + pub command: Command, + + #[clap(long, default_value_os_t = crate::config::default_config_file_path())] + pub config_file: PathBuf, + + #[clap(long)] + pub debug: bool, +} + +#[derive(Debug, clap::Parser)] +pub enum Command { + /// Reads test contents from a file. + File { + /// Path to the file. + path: PathBuf, + + /// Lexer with which to read the file. + #[clap(short, long, default_value = "extended-grapheme-clusters")] + lexer: FileLexer, + }, + /// Generates random words for test contents. + Words { + /// Number of words to generate. + count: Option, + + /// Language to sample words from. + #[clap(short, long)] + language: Option, + + /// Take first N words from the language while sampling. + #[clap(short = 'c', long)] + language_cutoff: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileLexer { + ExtendedGraphemeClusters, +} + +impl FromStr for FileLexer { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "extended-grapheme-clusters" => Ok(Self::ExtendedGraphemeClusters), + _ => Err(format!("unknown lexer: {}", s)), + } + } +} diff --git a/src/test/mod.rs b/src/test/mod.rs deleted file mode 100644 index daab25e..0000000 --- a/src/test/mod.rs +++ /dev/null @@ -1,132 +0,0 @@ -pub mod results; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use std::fmt; -use std::time::Instant; - -pub struct TestEvent { - pub time: Instant, - pub key: KeyEvent, - pub correct: Option, -} - -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(), - } - } -} - -#[derive(Debug)] -pub struct Test { - pub words: Vec, - pub current_word: usize, - pub complete: bool, -} - -impl Test { - pub fn new(words: Vec) -> Self { - Self { - words: words.into_iter().map(TestWord::from).collect(), - current_word: 0, - complete: false, - } - } - - pub fn handle_key(&mut self, key: KeyEvent) { - let word = &mut self.words[self.current_word]; - match key.code { - KeyCode::Char(' ') | KeyCode::Enter => { - if word.text.chars().nth(word.progress.len()) == Some(' ') { - word.progress.push(' '); - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(true), - key, - }) - } else if !word.progress.is_empty() || word.text.is_empty() { - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(word.text == word.progress), - key, - }); - self.next_word(); - } - } - KeyCode::Backspace => { - if word.progress.is_empty() { - self.last_word(); - } else { - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(!word.text.starts_with(&word.progress[..])), - key, - }); - word.progress.pop(); - } - } - // CTRL-BackSpace - KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => { - if self.words[self.current_word].progress.is_empty() { - self.last_word(); - } - - let word = &mut self.words[self.current_word]; - - word.events.push(TestEvent { - time: Instant::now(), - correct: None, - key, - }); - word.progress.clear(); - } - KeyCode::Char(c) => { - word.progress.push(c); - word.events.push(TestEvent { - time: Instant::now(), - correct: Some(word.text.starts_with(&word.progress[..])), - key, - }); - if word.progress == word.text && self.current_word == self.words.len() - 1 { - self.complete = true; - self.current_word = 0; - } - } - _ => {} - }; - } - - fn last_word(&mut self) { - if self.current_word != 0 { - self.current_word -= 1; - } - } - - fn next_word(&mut self) { - if self.current_word == self.words.len() - 1 { - self.complete = true; - self.current_word = 0; - } else { - self.current_word += 1; - } - } -} diff --git a/src/test/results.rs b/src/test/results.rs deleted file mode 100644 index 8d7cebd..0000000 --- a/src/test/results.rs +++ /dev/null @@ -1,145 +0,0 @@ -use super::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 trait PartialResults { - fn progress(&self) -> Fraction; -} - -impl PartialResults for Test { - fn progress(&self) -> Fraction { - Fraction { - numerator: self.current_word + 1, - denominator: self.words.len(), - } - } -} - -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, -} - -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: { - 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 - }, - accuracy: { - 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 - }, - } - } -} diff --git a/src/trial/history.rs b/src/trial/history.rs new file mode 100644 index 0000000..ee314d9 --- /dev/null +++ b/src/trial/history.rs @@ -0,0 +1,326 @@ +use std::{convert::TryFrom, iter}; + +use unicode_segmentation::UnicodeSegmentation; + +/// Stores the history of typed grapheme clusters, +/// and evaluates mistakes using a modified version +/// of the Needleman-Wunsch algorithm for sequence alignment. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct History { + /// The maximum misalignment allowed between the typed and reference strings. + /// + /// By only allowing some misalignment, we get O(p min{n, m}) space complexity + /// and O(p) time complexity for each push, where p is the max misalignment. + max_misalignment: usize, + + /// The m typed grapheme clusters. + typed: String, + + /// The starting indices of the typed grapheme clusters. + typed_indices: Vec, + + /// The n reference grapheme clusters. + reference: String, + + /// The starting indices of the reference grapheme clusters. + reference_indices: Vec, + + /// Tracked entries in the NW matrix. + /// + /// This is a flattened array of m rows, with each containing + /// the 2p + 1 tracked entries in the corresponding row of the NW matrix. + /// Note that the first p rows have entries outside the matrix, + /// which are set to [`NWEntry::Invalid`]. + nw_entries: Vec, +} + +impl History { + /// Creates a new history, with empty typed and reference strings. + pub fn new(max_misalignment: usize) -> Self { + let mut nw_entries = vec![u32::MAX; 2 * max_misalignment + 1]; + + // the 1,1 entry is always 0 + nw_entries[max_misalignment] = 0; + + Self { + max_misalignment, + typed: String::new(), + typed_indices: Vec::new(), + reference: String::new(), + reference_indices: Vec::new(), + nw_entries, + } + } + + /// Pushes a reference grapheme cluster to the history. + pub fn push_reference(&mut self, reference: &str) { + debug_assert_eq!(reference.graphemes(true).count(), 1); + + // Push the index of the cluster. + self.reference_indices.push(self.reference.len()); + + // Push to the string. + self.reference.push_str(reference); + + // The index of the new column. + let col = self.reference_indices.len(); + + // A peekable iterator over indices of the previous column. + // Used to get the left and top-left neighbors. + let mut prev_col_indices = self.col_indices(col - 1).peekable(); + + // The value of the top neighbor. + // I.e., the last entry entered. + let mut top = u32::MAX; + + for EntryIndices { row, buf: idx, .. } in self.col_indices(col) { + let top_left = if row <= 0 { + u32::MAX + } else { + self.nw_entries[prev_col_indices.next().unwrap().buf] + }; + let left = prev_col_indices + .peek() + .map(|&i| self.nw_entries[i.buf]) + .unwrap_or(u32::MAX); + + let replacement_cost = top_left + .saturating_add(self.replacement_cost(row, col as isize).unwrap_or(u32::MAX)); + let insertion_cost = top.saturating_add(Self::INSERTION_COST); + let deletion_cost = left.saturating_add(Self::DELETION_COST); + + let val = *[replacement_cost, insertion_cost, deletion_cost] + .iter() + .min() + .unwrap(); + + self.nw_entries[idx] = val; + + top = val; + } + } + + /// Push a typed grapheme cluster to the history. + pub fn push_typed(&mut self, typed: &str) { + debug_assert_eq!(typed.graphemes(true).count(), 1); + + // Push the index of the cluster. + self.typed_indices.push(self.typed.len()); + + // Push to the string. + self.typed.push_str(typed); + + // The index of the new row. + let row = self.typed_indices.len(); + + // None of the row's entries are tracked. + if row > self.reference_indices.len() + self.max_misalignment { + return; + } + + // A peekable iterator over indices of the previous row. + // Used to get the top and top-left neighbors. + let mut prev_row_indices = self.row_indices(row - 1).peekable(); + // The value of the left neighbor. + let mut left = u32::MAX; + + self.nw_entries.resize( + self.nw_entries.len() + self.tracked_entries_per_row(), + u32::MAX, + ); + + for EntryIndices { col, buf: idx, .. } in self.row_indices(row).filter({ + let cols = self.reference_indices.len() + 1; + move |&i| i.col < cols as isize + }) { + let top_left = match prev_row_indices.next() { + Some(i) => self.nw_entries[i.buf], + None => break, + }; + + let top = prev_row_indices + .peek() + .map(|&i| self.nw_entries[i.buf]) + .unwrap_or(u32::MAX); + + let replacement_cost = top_left + .saturating_add(self.replacement_cost(row as isize, col).unwrap_or(u32::MAX)); + let insertion_cost = top.saturating_add(Self::DELETION_COST); + let deletion_cost = left.saturating_add(Self::INSERTION_COST); + + let val = *[replacement_cost, insertion_cost, deletion_cost] + .iter() + .min() + .unwrap(); + + self.nw_entries[idx] = val; + + left = val; + } + } + + // A match is free. + const MATCH_COST: u32 = 0; + // A mismatch requires two keystrokes, + // one to delete the typed grapheme cluster + // and one to insert the reference grapheme cluster. + const MISMATCH_COST: u32 = 2; + // An insertion or deletion requires one keystroke. + const INSERTION_COST: u32 = 1; + const DELETION_COST: u32 = 1; + + fn tracked_entries_per_row(&self) -> usize { + 2 * self.max_misalignment + 1 + } + + fn row_indices(&self, row: usize) -> impl Iterator { + let first_col = row as isize - self.max_misalignment as isize; + + let first_buf = row * self.tracked_entries_per_row(); + let buf_range = first_buf..first_buf + self.tracked_entries_per_row(); + + buf_range + .zip(first_col..) + .map(move |(buf, col)| EntryIndices { + row: row as isize, + col, + buf, + }) + } + + fn col_indices(&self, col: usize) -> impl Iterator { + let tracked_entries_per_row = self.tracked_entries_per_row(); + let nw_entries_len = self.nw_entries.len(); + + let first_row = col.saturating_sub(self.max_misalignment) as isize; + let buf = if first_row == 0 { + self.max_misalignment + col as usize + } else { + (first_row as usize + 1) * tracked_entries_per_row - 1 + }; + + let first = EntryIndices { + row: first_row, + col: col as isize, + buf, + }; + + iter::successors(Some(first), move |&EntryIndices { row, col, buf }| { + Some(EntryIndices { + row: row + 1, + col, + buf: buf + tracked_entries_per_row - 1, + }) + }) + .take_while(move |&idx| idx.buf < nw_entries_len) + .take(tracked_entries_per_row) + } + + fn replacement_cost(&self, row: isize, col: isize) -> Option { + let typed_bidx = *self.typed_indices.get(usize::try_from(row - 1).ok()?)?; + let reference_bidx = *self.reference_indices.get(usize::try_from(col - 1).ok()?)?; + + let typed = first_grapheme_cluster(&self.typed[typed_bidx..]); + let reference = first_grapheme_cluster(&self.reference[reference_bidx..]); + + if typed == reference { + Some(Self::MATCH_COST) + } else { + Some(Self::MISMATCH_COST) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EntryIndices { + row: isize, + col: isize, + buf: usize, +} + +fn first_grapheme_cluster(string: &str) -> &str { + string.graphemes(true).next().unwrap() +} + +#[cfg(test)] +mod tests { + use rand::{seq::SliceRandom, SeedableRng}; + use rand_chacha::ChaCha20Rng; + + use super::*; + + #[test] + fn new() { + let history = History::new(1); + + assert_eq!(history.max_misalignment, 1); + assert_eq!(history.typed, ""); + assert_eq!(history.typed_indices, vec![]); + assert_eq!(history.reference, ""); + assert_eq!(history.reference_indices, vec![]); + assert_eq!(history.nw_entries[1], 0); + } + + #[test] + fn reproduces_needleman_wunsch_with_high_max_misalignment() { + const NW_MAT: [[u32; 9]; 7] = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8], + [1, 0, 1, 2, 3, 4, 5, 6, 7], + [2, 1, 2, 3, 2, 3, 4, 5, 6], + [3, 2, 3, 4, 3, 4, 5, 6, 7], + [4, 3, 4, 5, 4, 5, 4, 5, 6], + [5, 4, 3, 4, 5, 6, 5, 4, 5], + [6, 5, 4, 5, 6, 7, 6, 5, 4], + ]; + const REFERENCE: &'static str = "saturday"; + const TYPED: &'static str = "sunday"; + const PERMUTATIONS: usize = 1_000; + + let mut rng = ChaCha20Rng::seed_from_u64(42); + + let mut push_order = [vec![true; 6], vec![false; 8]].concat(); + + for _ in 0..PERMUTATIONS { + push_order.shuffle(&mut rng); + + let mut history = History::new(8); + + let mut typed = TYPED.graphemes(true); + let mut reference = REFERENCE.graphemes(true); + + for &push_typed in &push_order { + if push_typed { + history.push_typed(typed.next().unwrap()); + } else { + history.push_reference(reference.next().unwrap()); + } + + check_tracked_rows(&history, &NW_MAT); + } + } + } + + fn check_tracked_rows( + history: &History, + reference: &[[u32; N]; M], + ) { + let rows = history.typed_indices.len() + 1; + let cols = history.reference_indices.len() + 1; + + let mut tracked = vec![u32::MAX; rows * history.tracked_entries_per_row()]; + + for row in 0..rows { + for col in 0..cols { + let offset = match (col + history.max_misalignment).checked_sub(row) { + Some(offset) if offset < history.tracked_entries_per_row() => offset, + _ => continue, + }; + let tracked_idx = row * history.tracked_entries_per_row() + offset; + + tracked[tracked_idx] = reference[row][col]; + } + } + + assert_eq!(history.nw_entries, tracked); + } +} diff --git a/src/trial/mod.rs b/src/trial/mod.rs new file mode 100644 index 0000000..f4d153e --- /dev/null +++ b/src/trial/mod.rs @@ -0,0 +1,115 @@ +mod history; + +use history::History; + +use unicode_segmentation::UnicodeSegmentation; + +/// The state of a trial. +pub struct Trial { + /// The history of typed grapheme clusters and reference grapheme clusters. + history: History, + + /// The current grapheme cluster being typed. + /// Once the cluster is finished, it is pushed to `history`. + working_grapheme_cluster: String, +} + +/// A user input to a trial. +pub enum Input { + /// Type a Unicode scalar value. + TypeScalar(char), + /// Delete the last typed grapheme cluster. + DeleteGraphemeCluster, + /// Delete the "last word". + DeleteWord, +} + +impl Trial { + /// Create a new trial. + pub fn new(env: &crate::Env) -> Self { + Self { + history: History::new(env.config.max_misalignment), + working_grapheme_cluster: String::new(), + } + } + + /// Process a user input. + pub fn process(&mut self, input: Input) { + match input { + Input::TypeScalar(c) => { + if let Some(finished_cluster) = push_to_working_cluster(&mut self.working_grapheme_cluster, c) { + self.history.push_typed(&finished_cluster); + } + } + Input::DeleteGraphemeCluster if self.working_grapheme_cluster.is_empty() => { + todo!() + } + Input::DeleteGraphemeCluster => { + self.working_grapheme_cluster.clear(); + } + Input::DeleteWord => { + todo!() + } + } + } +} + +/// Push a scalar value to the working grapheme cluster, returning the previous cluster if it was finished. +fn push_to_working_cluster(working_grapheme_cluster: &mut String, scalar: char) -> Option { + working_grapheme_cluster.push(scalar); + + let mut clusters = working_grapheme_cluster.graphemes(true); + let first_cluster = clusters.next().unwrap(); + + if let Some(new_cluster) = clusters.next() { + let first_cluster = first_cluster.to_owned(); + let new_cluster = new_cluster.to_owned(); + + *working_grapheme_cluster = new_cluster; + + Some(first_cluster) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn push_to_working_cluster_handles_latin() { + let mut working_grapheme_cluster = String::new(); + + assert_eq!( + push_to_working_cluster(&mut working_grapheme_cluster, 'a'), + None + ); + assert_eq!( + push_to_working_cluster(&mut working_grapheme_cluster, 'a'), + Some("a".to_owned()) + ); + + assert_eq!( + push_to_working_cluster(&mut working_grapheme_cluster, 'é'), + Some("a".to_owned()) + ); + assert_eq!( + push_to_working_cluster(&mut working_grapheme_cluster, 'a'), + Some("é".to_owned()) + ); + + assert_eq!( + push_to_working_cluster(&mut working_grapheme_cluster, 'a'), + Some("a".to_owned()) + ); + assert_eq!( + push_to_working_cluster(&mut working_grapheme_cluster, '\u{0302}'), // combining circumflex accent + None + ); + assert_eq!( + push_to_working_cluster(&mut working_grapheme_cluster, 'a'), + Some("a\u{0302}".to_owned()) + ); + } +} diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index ca5022a..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,339 +0,0 @@ -use crate::config::Theme; - -use super::test::{results, Test}; - -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use results::Fraction; -use std::iter; -use tui::{ - buffer::Buffer, - layout::{Constraint, Direction, Layout, Rect}, - symbols::Marker, - text::{Span, Spans, Text}, - widgets::{Axis, Block, BorderType, Borders, Chart, Dataset, GraphType, Paragraph, Widget}, -}; - -// 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<&Spans<'_>> for SizedBlock<'_> { - fn draw_inner(&self, content: &Spans, buf: &mut Buffer) { - let inner = self.block.inner(self.area); - buf.set_spans(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<'t, W: ThemedWidget> Widget for Themed<'t, 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(Spans::from(vec![Span::styled("Input", theme.title)])) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(theme.input_border), - area: chunks[0], - }; - input.draw_inner( - &Spans::from(self.words[self.current_word].progress.clone()), - buf, - ); - input.render(buf); - - let target_lines: Vec = { - let words = iter::empty::>() - // already typed words - .chain(self.words[..self.current_word].iter().map(|w| { - vec![Span::styled( - w.text.clone() + " ", - if w.progress == w.text { - theme.prompt_correct - } else { - theme.prompt_incorrect - }, - )] - })) - // current word - .chain({ - let progress_ind = self.words[self.current_word] - .progress - .len() - .min(self.words[self.current_word].text.len()); - - let correct = self.words[self.current_word] - .text - .starts_with(&self.words[self.current_word].progress[..]); - - let (typed, untyped) = - self.words[self.current_word] - .text - .split_at(ceil_char_boundary( - &self.words[self.current_word].text, - progress_ind, - )); - - let mut remaining = untyped.chars().chain(iter::once(' ')); - let cursor = remaining.next().unwrap(); - - iter::once(vec![ - Span::styled( - typed, - if correct { - theme.prompt_current_correct - } else { - theme.prompt_current_incorrect - }, - ), - Span::styled( - cursor.to_string(), - theme.prompt_current_untyped.patch(theme.prompt_cursor), - ), - Span::styled(remaining.collect::(), theme.prompt_current_untyped), - ]) - }) - // remaining words - .chain( - self.words[self.current_word + 1..] - .iter() - .map(|w| vec![Span::styled(w.text.clone() + " ", theme.prompt_untyped)]), - ); - - 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(Spans::from(current_line.clone())); - current_line.clear(); - current_width = 0; - } - - current_line.extend(word); - current_width += word_width; - } - lines.push(Spans::from(current_line)); - - lines - }; - let target = Paragraph::new(target_lines).block( - Block::default() - .title(Span::styled("Prompt", theme.title)) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(theme.prompt_border), - ); - target.render(chunks[1], buf); - } -} - -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 exit = Span::styled( - "Press 'q' to quit or 'r' for another test.", - 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([ - Spans::from(format!( - "Adjusted WPM: {:.1}", - self.timing.overall_cps * WPM_PER_CPS * f64::from(self.accuracy.overall) - )), - Spans::from(format!( - "Accuracy: {:.1}%", - f64::from(self.accuracy.overall) * 100f64 - )), - Spans::from(format!( - "Raw WPM: {:.1}", - self.timing.overall_cps * WPM_PER_CPS - )), - Spans::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(BorderType::Rounded) - .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() - .take(5) - .filter_map(|(key, acc)| { - if let KeyCode::Char(character) = key.code { - Some(format!( - "- {} at {:.1}% accuracy", - character, - f64::from(**acc) * 100.0, - )) - } else { - None - } - }) - .map(Spans::from), - ); - let worst = Paragraph::new(worst_text).block( - Block::default() - .title(Span::styled("Worst Keys", theme.title)) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .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(); - - 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 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( - (wpm_sma_min as u16..wpm_sma_max as u16) - .step_by(5) - .map(|n| Span::raw(format!("{}", n))) - .collect(), - ), - ); - wpm_chart.render(res_chunks[1], buf); - } -} - -// FIXME: replace with `str::ceil_char_boundary` when stable -fn ceil_char_boundary(string: &str, index: usize) -> usize { - if string.is_char_boundary(index) { - index - } else { - ceil_char_boundary(string, index + 1) - } -}