diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index b478eeb..22dacb7 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,7 @@ target/ [Oo][Uu][Tt] + +# direnv and nix +.direnv/ +result diff --git a/Cargo.lock b/Cargo.lock index d57a3e7..8f81670 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -112,7 +118,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -121,12 +127,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.8.0" @@ -157,6 +157,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.15" @@ -184,7 +193,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -239,11 +248,25 @@ version = "7.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" dependencies = [ - "crossterm 0.28.1", + "crossterm", "unicode-segmentation", "unicode-width 0.2.0", ] +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -260,31 +283,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "crossterm" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" -dependencies = [ - "bitflags 1.3.2", - "crossterm_winapi", - "libc", - "mio 0.8.11", - "parking_lot", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.8.0", + "bitflags", "crossterm_winapi", - "mio 1.0.3", + "mio", "parking_lot", "rustix", "signal-hook", @@ -301,6 +308,40 @@ dependencies = [ "winapi", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dirs" version = "6.0.0" @@ -333,6 +374,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -370,6 +417,12 @@ 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 = "foreign-types" version = "0.3.2" @@ -464,7 +517,7 @@ dependencies = [ "cfg-if", "libc", "wasi 0.13.3+wasi-0.2.2", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -497,6 +550,11 @@ name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -757,6 +815,12 @@ dependencies = [ "syn", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -778,6 +842,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.7.1" @@ -788,6 +871,28 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -800,6 +905,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.14" @@ -834,7 +948,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.8.0", + "bitflags", "libc", ] @@ -866,6 +980,15 @@ version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "memchr" version = "2.7.4" @@ -887,18 +1010,6 @@ dependencies = [ "adler2", ] -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.0.3" @@ -958,7 +1069,7 @@ version = "0.10.71" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd" dependencies = [ - "bitflags 2.8.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -1022,9 +1133,15 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-targets", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1107,13 +1224,34 @@ dependencies = [ "zerocopy 0.8.14", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags 2.8.0", + "bitflags", ] [[package]] @@ -1198,7 +1336,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.8.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -1277,7 +1415,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.8.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -1370,8 +1508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio 0.8.11", - "mio 1.0.3", + "mio", "signal-hook", ] @@ -1415,12 +1552,40 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1464,7 +1629,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.8.0", + "bitflags", "core-foundation", "system-configuration-sys", ] @@ -1532,7 +1697,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio 1.0.3", + "mio", "pin-project-lite", "socket2", "windows-sys 0.52.0", @@ -1657,19 +1822,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tui" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1" -dependencies = [ - "bitflags 1.3.2", - "cassowary", - "crossterm 0.25.0", - "unicode-segmentation", - "unicode-width 0.1.14", -] - [[package]] name = "typy" version = "0.7.0" @@ -1678,15 +1830,17 @@ dependencies = [ "chrono", "clap", "comfy-table", - "crossterm 0.28.1", + "crossterm", "dirs", + "include_dir", "lazy_static", "rand", + "ratatui", "reqwest", "serde", "serde_json", "toml", - "tui", + "toml_edit", ] [[package]] @@ -1701,6 +1855,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -1887,7 +2052,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -1898,7 +2063,7 @@ checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ "windows-result", "windows-strings", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -1907,7 +2072,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -1917,16 +2082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ "windows-result", - "windows-targets 0.52.6", -] - -[[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", + "windows-targets", ] [[package]] @@ -1935,7 +2091,7 @@ 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]] @@ -1944,22 +2100,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]] @@ -1968,46 +2109,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" @@ -2020,48 +2143,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" @@ -2083,7 +2182,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" dependencies = [ - "bitflags 2.8.0", + "bitflags", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4d0460c..be9cdca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,11 @@ edition = "2021" crossterm = "0.28.1" clap = {version = "4.5.30", features = ["derive"]} rand = "0.9" -tui = "0.19.0" +ratatui = "0.29" dirs = "6.0" toml = "0.8.20" +toml_edit = "0.22" +include_dir = "0.7" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" lazy_static = "1.5.0" diff --git a/README.md b/README.md index 0a94f5a..ff9eab1 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,33 @@ ## Table of contents - [Overview](#overview) - [Installation](#installation) +- [Usage](#usage) +- [Keybindings](#keybindings) - [Flags](#flags) - [Configuration](#configuration) +- [Themes](#themes) +- [Cursor](#cursor) - [Stats](#stats) +- [Language](#language) - [Uninstall](#uninstall) ## Overview ![Description of the GIF](./docs/assets/demo.gif) -I wanted to create a simple typing game to improve my typing speed and accuracy. I really like using [monkeytype](https://monkeytype.com/) and I thought, why not create something similar -in the terminal? I searched for some but didn't find anything I really liked, so I built it myself. Typy is a terminal-based typing game that displays a random -word and asks you to type it as fast as possible. The game tracks your typing speed and accuracy, allowing you to monitor your progress over time. Typy also supports -different game modes, such as uppercase and punctuation, to help you improve your typing skills in different areas. + +I wanted a simple typing game to improve my typing speed and accuracy. I really like using +[monkeytype](https://monkeytype.com/), so I built something similar for the terminal. Typy is a +terminal typing test built with [ratatui](https://ratatui.rs/): it shows a stream of random words, +tracks your speed and accuracy, and plots your words-per-minute over the run. + +The app is fully interactive: + +- a **home screen** to start a test, +- a **settings page** (theme, cursor, language, mode, time) with dropdown menus, +- a **results screen** with WPM / accuracy and a graph, +- a **stats page** with your recent games and averages. + +It also supports different modes (`uppercase`, `punctuation`), multiple languages, and themeable +colors. ## Installation To install Typy, you can use the [Cargo] package manager: @@ -32,7 +48,7 @@ To install Typy, you can use the [Cargo] package manager: [Cargo]: https://doc.rust-lang.org/cargo/ ```bash -cargo install --git "https://github.com/Pazl27/typy-cli.git" --tag "v0.9.0" +cargo install --git "https://github.com/Pazl27/typy-cli.git" ``` If you prefer to get the newest version and compile it yourself, follow these steps: @@ -56,7 +72,7 @@ If you prefer to get the newest version and compile it yourself, follow these st 4. Ensure the `english.txt` file is in the correct location: ```bash mkdir -p ~/.local/share/typy - cp resources/english.txt ~/.local/share/typy/ + cp resources/lang/english.txt ~/.local/share/typy/ ``` If you have Nix with flakes enabled, you can install typy-cli directly: @@ -71,88 +87,211 @@ Or to run without installing: nix run github:Pazl27/typy-cli ``` +## Usage +Run `typy` with no arguments to open the interactive app. You start on the home screen, from where +you can begin a test, open the settings, or view your stats. + +```bash +typy +``` + +If you pass a game option (`-t` or `-m`), Typy skips the home screen, runs a single test right away, +shows the results, and exits when you press a key — handy for a quick run: + +```bash +typy -t 60 -m punctuation +``` + +## Keybindings + +**Home** + +| Key | Action | +| ------------- | ------------------- | +| any key | start a test | +| `s` | open settings | +| `p` | open stats | +| `q` / `Esc` | quit | + +**Typing** + +| Key | Action | +| -------------- | ----------------------------------------- | +| letters | type the word | +| `Space` | jump to the start of the next word | +| `Backspace` | delete the last character | +| `Esc` | cancel (back to home, or quit in quick run) | + +**Results / Stats / Settings** + +| Key | Action | +| ------------------- | ----------------------------------------- | +| any key (results) | restart, or exit in quick run | +| `Esc` / `q` | back | +| `j` / `k` (settings)| move between rows / dropdown options | +| `Enter` (settings) | open a dropdown / confirm a selection | + +`Ctrl + c` quits from anywhere. + ## Flags -The `Typy` application supports the following flags: +The `typy` application supports the following flags: -- `-t, --time `: Sets the duration of the game. The default value is `30`. - - if you set the time to a too low value the graph ends up scuffed. - - e.g., `typy-cli -t 60` sets the game duration to 60 seconds. +- `-t, --time `: Duration of the test in seconds. Runs a single test immediately and exits. + - e.g., `typy -t 60` runs a 60 second test. -- `-s, --stats`: Shows the stats of the game. - - not implemented atm. - - e.g., `typy-cli --stats` displays the game statistics. +- `-m, --mode ...`: Mode(s) to play. Runs a single test immediately. + - possible modes are `normal`, `uppercase` and `punctuation`. + - e.g., `typy -m uppercase punctuation`. -- `-c, --config`: Creates a config file if it doesn't exist and opens it. - - e.g., `typy-cli --config` creates and opens the configuration file. +- `-s, --stats`: Show statistics for your past games. -- `-m, --mode `: Sets the mode of the game. Multiple values can be specified. - - possible modes are `uppercase`, `punctuation` and `normal`. - - e.g., `typy-cli -m uppercase,punctuation` sets the game mode to uppercase and punctuation. +- `-c, --config`: Create the config file if it doesn't exist and open it in `$EDITOR`. +Run `typy --help` for the full help with usage examples. ## Configuration -Typy allows you to configure the colors (theme) via a TOML file. The configuration file is located at `~/.config/typy/config.toml`. You can also configure Typy using the command line with the `typy -c` option. -Inside of the configuration file, you can specify the colors for the theme, graph, and cursor style. Also you can specify some default settings. - -Here is an example configuration block for the `config.toml` file: +Typy is configured via a TOML file located at `~/.config/typy/config.toml`. You can edit it directly +or open it with `typy -c`. Everything below can also be changed live from the in-app settings page +(`s` on the home screen), which writes your choices back to this file. ```toml # ~/.config/typy/config.toml -[theme] -fg = "#516D49" -missing = "#918273" -error = "#FB4934" -accent = "#D3869B" - -[graph] -data = "#8EC07C" -title = "#458588" -axis = "#B16286" - -[cursor] -style = "SteadyBar" # possible options are: DefaultUserShape, BlinkingBlock, SteadyBlock, BlinkingUnderScore, SteadyUnderScore, BlinkingBar, SteadyBar, +theme = "Catppuccin Mocha" # name of a built-in or custom theme (see Themes) +cursor = "block" # caret style (see Cursor) [modes] -default_mode = "normal" # possible modes are "normal"|"uppercase"|"punctuation", combinations of modes is also possible e.g: "uppercase, punctuation" -uppercase_chance = "3" # possible are values between 0 and 1, if value is too high it gets clamped to 1, if too low it gets clamped to 0 -punctuation_chance = "0.5" # possible are values between 0 and 1, if value is too high it gets clamped to 1, if too low it gets clamped to 0 +default_mode = "normal" # "normal" | "uppercase" | "punctuation", or a combination e.g. "uppercase, punctuation" +uppercase_chance = "0.3" # 0.0–1.0, clamped +punctuation_chance = "0.5" # 0.0–1.0, clamped [language] -lang = "english" # select your desired language +lang = "english" # word list to use (see Language) + +[game] +time = 30 # default test duration in seconds ``` -To apply the configuration, you can either edit the `config.toml` file directly or use the `typy -c` command to to open the file in your preferred editor: +## Themes +A theme is selected by name with the top-level `theme` key. Typy ships with several built-in themes: -```bash -typy -c +- `Catppuccin Mocha` (default) +- `Gruvbox Dark` +- `Dracula` +- `Nord` +- `Tokyo Night` +- `Solarized Dark` +- `One Dark` +- `Rosé Pine` + +You can pick one from the settings page, or set it directly: + +```toml +theme = "Nord" ``` -This allows you to customize the appearance of Typy to match your preferences. +### Custom themes +To define your own themes, create `~/.config/typy/theme.toml` (next to `config.toml`). Each table is +one theme; its display name is the `name` field (falling back to the table key). Custom themes appear +in the settings dropdown alongside the built-ins and override a built-in with the same name. -## Stats -The stats are saved in a file located at `~/.local/share/typy/stats.json`. The stats file tracks the stats of the past 10 games. Also it shows the average WPM, -RAW and accuracy of the all games played. -To check your stats you can use the `typy --stats` command. +```toml +# ~/.config/typy/theme.toml + +[neon] +name = "Neon Dream" +fg = "#ffffff" +missing = "#555555" +error = "#ff0044" +accent = "#00ffcc" +graph_data = "#00ffcc" # optional; graph_* fall back to sensible defaults +graph_title = "#ff00ff" +graph_axis = "#333333" +``` -```bash -typy -s +Then select it: + +```toml +theme = "Neon Dream" +``` + +Colors are `#rrggbb` hex values. `fg` is correct text, `missing` is untyped text, `error` is +mistakes, and `accent` is the highlight color; `graph_data`, `graph_title` and `graph_axis` color the +results graph. + +## Cursor +The typing caret uses your terminal's cursor. Set its style with the `cursor` key (or from settings): + +```toml +cursor = "bar" ``` -This will display the stats of the last 10 games and looks something like this: + +Possible values: `block`, `underline`, `bar`, and their blinking variants `blinking block`, +`blinking underline`, `blinking bar`. + +## Stats +Your results are saved to `~/.local/share/typy/scores.json`, which keeps your last 10 games plus the +running averages for WPM, RAW and accuracy. + +You can view them in two ways: + +- In the app: press `p` on the home screen. +- From the terminal: `typy -s`. + ![Stats](./docs/assets/snapshot_2025-02-24_00-28-16.png) -To close this view press `Ctrl + c` or `esc`. + +Press `Esc` or `q` to close the view. ## Language -The language files are located at `~/.local/share/typy/`. The default language is `english`. You can change the language by editing the `config.toml` file or by using the -`typy -c` command. If you want to add a new language you can create a new file in the `~/.local/share/typy/` directory and add the words in the following format: +Word lists live in `~/.local/share/typy/` as `.txt` files. The repository ships several +languages under `resources/lang/` (`english`, `german`, `french`, `italian`, `romanian`, `russian`, +`spanish`). + +### How word lists are fetched +> [!IMPORTANT] +> `cargo install` only installs the `typy` binary — it does **not** copy any language files to your +> machine. The word lists are fetched **on demand, over the network**. + +When you use a language for the first time, Typy looks for `~/.local/share/typy/.txt`. If +it isn't there, it **downloads that one file** from GitHub +(`raw.githubusercontent.com/Pazl27/typy-cli/.../resources/lang/.txt`) and saves it locally. +From then on it's read from disk and no network access is needed. + +What this means in practice: + +- The **first run needs an internet connection** (to fetch at least `english`). If GitHub is + unreachable, starting a test will fail with a download error. +- Only the language you actually use is downloaded, not all of them. +- Downloads always come from the `master` branch, regardless of the installed version/tag. +- **Themes** are the exception — they are compiled into the binary and are always available offline. +- The **Nix** package bundles `english.txt` and installs it on first launch, so Nix users work + offline out of the box. + +To use Typy fully offline (or to pre-seed languages), copy the files yourself: + +```bash +mkdir -p ~/.local/share/typy +cp resources/lang/*.txt ~/.local/share/typy/ # from a cloned repo +``` + +Every `.txt` file found in `~/.local/share/typy/` is listed in the settings language dropdown — just +pick one. You can also set it in the config: + +```toml +[language] +lang = "german" +``` + +To add your own language, drop a file in `~/.local/share/typy/` with one word per line: + ```txt word1 word2 ... ``` -The language file should be named after the language you want to add. For example, if you want to add a German language file, you would create a file named `german.txt` and add the German words to it. -If you want to use the new language you need to change the `lang` field in the `config.toml` file to the name of the language file without the `.txt` extension. -If you want to provide a new language to the Typy repository, feel free to create a pull request. Atm I only have the `english.txt` file in the repository. + +Name it after the language (without the `.txt` extension). It will then show up in the settings +dropdown. Pull requests adding new languages to the repository are welcome. ## Uninstall ```bash diff --git a/flake.lock b/flake.lock index 8c71bf4..b92acde 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1743583204, - "narHash": "sha256-F7n4+KOIfWrwoQjXrL2wD9RhFYLs2/GGe/MQY1sSdlE=", + "lastModified": 1784497964, + "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "2c8d3f48d33929642c1c12cd243df4cc7d2ce434", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", "type": "github" }, "original": { @@ -48,11 +48,11 @@ ] }, "locked": { - "lastModified": 1743820323, - "narHash": "sha256-UXxJogXhPhBFaX4uxmMudcD/x3sEGFtoSc4busTcftY=", + "lastModified": 1784526465, + "narHash": "sha256-L37teKC6oINWG4PGZLIqbphMWvSQ0PEz+aWxAk+rIDw=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "b4734ce867252f92cdc7d25f8cc3b7cef153e703", + "rev": "58c6334db52d51fc5dd8877c90b01f00cf8a696b", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index d4dc3e2..eee869b 100644 --- a/flake.nix +++ b/flake.nix @@ -46,7 +46,7 @@ inherit buildInputs nativeBuildInputs; postInstall = '' mkdir -p $out/share/typy - cp $src/resources/english.txt $out/share/typy/english.txt + cp $src/resources/lang/english.txt $out/share/typy/english.txt wrapProgram $out/bin/typy \ --run "mkdir -p ~/.local/share/typy" \ @@ -63,6 +63,16 @@ type = "app"; program = "${self.packages.${system}.default}/bin/typy"; }; + + devShells.default = pkgs.mkShell { + inherit buildInputs; + nativeBuildInputs = with pkgs; [ + rust-bin.stable.latest.default + pkg-config + ]; + # Let pkg-config find openssl for the `reqwest` build. + PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; + }; } ); } diff --git a/resources/english.txt b/resources/lang/english.txt similarity index 100% rename from resources/english.txt rename to resources/lang/english.txt diff --git a/resources/french.txt b/resources/lang/french.txt similarity index 100% rename from resources/french.txt rename to resources/lang/french.txt diff --git a/resources/german.txt b/resources/lang/german.txt similarity index 100% rename from resources/german.txt rename to resources/lang/german.txt diff --git a/resources/italian.txt b/resources/lang/italian.txt similarity index 100% rename from resources/italian.txt rename to resources/lang/italian.txt diff --git a/resources/romanian.txt b/resources/lang/romanian.txt similarity index 100% rename from resources/romanian.txt rename to resources/lang/romanian.txt diff --git a/resources/russian.txt b/resources/lang/russian.txt similarity index 100% rename from resources/russian.txt rename to resources/lang/russian.txt diff --git a/resources/spanish.txt b/resources/lang/spanish.txt similarity index 100% rename from resources/spanish.txt rename to resources/lang/spanish.txt diff --git a/resources/themes/catppuccin-mocha.toml b/resources/themes/catppuccin-mocha.toml new file mode 100644 index 0000000..193acab --- /dev/null +++ b/resources/themes/catppuccin-mocha.toml @@ -0,0 +1,8 @@ +name = "Catppuccin Mocha" +fg = "#cdd6f4" +missing = "#6c7086" +error = "#f38ba8" +accent = "#f9e2af" +graph_data = "#a6e3a1" +graph_title = "#cba6f7" +graph_axis = "#585b70" diff --git a/resources/themes/dracula.toml b/resources/themes/dracula.toml new file mode 100644 index 0000000..4659923 --- /dev/null +++ b/resources/themes/dracula.toml @@ -0,0 +1,8 @@ +name = "Dracula" +fg = "#f8f8f2" +missing = "#6272a4" +error = "#ff5555" +accent = "#bd93f9" +graph_data = "#50fa7b" +graph_title = "#ff79c6" +graph_axis = "#44475a" diff --git a/resources/themes/gruvbox-dark.toml b/resources/themes/gruvbox-dark.toml new file mode 100644 index 0000000..361ade2 --- /dev/null +++ b/resources/themes/gruvbox-dark.toml @@ -0,0 +1,8 @@ +name = "Gruvbox Dark" +fg = "#ebdbb2" +missing = "#928374" +error = "#fb4934" +accent = "#fabd2f" +graph_data = "#b8bb26" +graph_title = "#fe8019" +graph_axis = "#504945" diff --git a/resources/themes/nord.toml b/resources/themes/nord.toml new file mode 100644 index 0000000..d660c93 --- /dev/null +++ b/resources/themes/nord.toml @@ -0,0 +1,8 @@ +name = "Nord" +fg = "#d8dee9" +missing = "#4c566a" +error = "#bf616a" +accent = "#88c0d0" +graph_data = "#a3be8c" +graph_title = "#b48ead" +graph_axis = "#434c5e" diff --git a/resources/themes/onedark.toml b/resources/themes/onedark.toml new file mode 100644 index 0000000..37cde0f --- /dev/null +++ b/resources/themes/onedark.toml @@ -0,0 +1,8 @@ +name = "One Dark" +fg = "#abb2bf" +missing = "#5c6370" +error = "#e06c75" +accent = "#61afef" +graph_data = "#98c379" +graph_title = "#c678dd" +graph_axis = "#4b5263" diff --git a/resources/themes/rose-pine.toml b/resources/themes/rose-pine.toml new file mode 100644 index 0000000..cd2b4f5 --- /dev/null +++ b/resources/themes/rose-pine.toml @@ -0,0 +1,8 @@ +name = "Rosé Pine" +fg = "#e0def4" +missing = "#6e6a86" +error = "#eb6f92" +accent = "#ebbcba" +graph_data = "#9ccfd8" +graph_title = "#c4a7e7" +graph_axis = "#26233a" diff --git a/resources/themes/solarized-dark.toml b/resources/themes/solarized-dark.toml new file mode 100644 index 0000000..5b3e998 --- /dev/null +++ b/resources/themes/solarized-dark.toml @@ -0,0 +1,8 @@ +name = "Solarized Dark" +fg = "#839496" +missing = "#586e75" +error = "#dc322f" +accent = "#268bd2" +graph_data = "#859900" +graph_title = "#d33682" +graph_axis = "#073642" diff --git a/resources/themes/tokyonight.toml b/resources/themes/tokyonight.toml new file mode 100644 index 0000000..8e327ac --- /dev/null +++ b/resources/themes/tokyonight.toml @@ -0,0 +1,8 @@ +name = "Tokyo Night" +fg = "#c0caf5" +missing = "#565f89" +error = "#f7768e" +accent = "#7aa2f7" +graph_data = "#9ece6a" +graph_title = "#bb9af7" +graph_axis = "#3b4261" diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..fda0168 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,368 @@ +use std::io::stdout; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use crossterm::cursor::SetCursorStyle; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::execute; + +use crate::config::save_settings; +use crate::mode::Mode; +use crate::scores::progress::{Averages, Data, Score}; +use crate::settings::SettingsState; +use crate::theme::{self, Theme}; +use crate::tui::{events, Tui}; +use crate::typing::TypingSession; +use crate::ui; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Screen { + Home, + Typing, + Results, + Settings, + Stats, +} + +pub struct StatsData { + pub averages: Averages, + pub scores: Vec, +} + +pub struct App { + pub screen: Screen, + pub should_quit: bool, + pub theme: Theme, + pub theme_name: String, + pub cursor_style: String, + pub language: String, + pub mode_tokens: Vec, + pub time: u64, + pub session: Option, + pub settings: Option, + pub stats: Option, + pub direct: bool, + pub record: u32, + pub previous_record: u32, + pub new_record: bool, + results_opened: Option, +} + +impl App { + pub fn new( + theme: Theme, + cursor_style: String, + language: String, + mode_tokens: Vec, + time: u64, + direct: bool, + ) -> Self { + App { + screen: Screen::Home, + should_quit: false, + theme_name: theme.name.clone(), + theme, + cursor_style, + language, + mode_tokens, + time, + session: None, + settings: None, + stats: None, + direct, + record: Data::get_record(time).unwrap_or(0), + previous_record: 0, + new_record: false, + results_opened: None, + } + } + + fn refresh_record(&mut self) { + self.record = Data::get_record(self.time).unwrap_or(0); + } + + fn tick(&mut self) { + if self.screen != Screen::Typing { + return; + } + if let Some(session) = self.session.as_mut() { + session.tick(); + if session.is_finished() { + self.finish_test(); + } + } + } + + fn start_test(&mut self) { + let mode = Mode::from_str(self.mode_tokens.iter().map(|s| s.as_str()).collect()) + .unwrap_or_else(|_| Mode::from_str(vec!["normal"]).unwrap()) + .add_duration(self.time); + + match TypingSession::new(&mode, &self.language) { + Ok(session) => { + self.session = Some(session); + self.screen = Screen::Typing; + } + Err(_) => self.screen = Screen::Home, + } + } + + fn open_stats(&mut self) { + let mut scores = Data::get_scores().unwrap_or_default(); + Score::sort_scores(&mut scores); + let averages = Data::get_averages().unwrap_or_else(|_| Data::default().averages); + self.stats = Some(StatsData { averages, scores }); + self.screen = Screen::Stats; + } + + fn open_settings(&mut self) { + self.settings = Some(SettingsState::new( + &self.theme_name, + &self.cursor_style, + &self.language, + &self.mode_tokens, + self.time, + )); + self.screen = Screen::Settings; + } + + fn apply_settings(&mut self) { + let Some((theme_name, cursor_style, language, mode_tokens, time, mode_default)) = + self.settings.as_ref().map(|s| { + ( + s.theme_name(), + s.cursor_style(), + s.language(), + s.mode_tokens(), + s.time(), + s.mode_default_string(), + ) + }) + else { + return; + }; + + self.theme_name = theme_name; + self.theme = theme::load(&self.theme_name); + self.cursor_style = cursor_style; + self.language = language; + self.mode_tokens = mode_tokens; + self.time = time; + self.refresh_record(); + let _ = save_settings( + &self.theme_name, + &self.cursor_style, + &self.language, + &mode_default, + self.time, + ); + } + + fn finish_test(&mut self) { + if let Some(session) = self.session.as_ref() { + let wpm = session.stats.wpm() as u32; + self.previous_record = Data::get_record(self.time).unwrap_or(0); + self.new_record = wpm > self.previous_record; + + let score = Score::new(wpm, session.stats.raw_wpm() as u32, session.stats.accuracy() as f32); + let _ = Data::save_data(score, self.time); + self.refresh_record(); + } + self.screen = Screen::Results; + self.results_opened = Some(Instant::now()); + } + + fn handle_event(&mut self, event: Event) { + if let Event::Key(key) = event { + if key.kind != KeyEventKind::Press { + return; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.should_quit = true; + return; + } + match self.screen { + Screen::Home => self.handle_home_key(key), + Screen::Typing => self.handle_typing_key(key), + Screen::Results => self.handle_results_key(key), + Screen::Settings => self.handle_settings_key(key), + Screen::Stats => self.handle_stats_key(key), + } + } + } + + fn handle_home_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + KeyCode::Char('s') => self.open_settings(), + KeyCode::Char('p') => self.open_stats(), + _ => self.start_test(), + } + } + + fn handle_stats_key(&mut self, key: KeyEvent) { + if matches!(key.code, KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('p')) { + self.stats = None; + self.screen = Screen::Home; + } + } + + fn handle_typing_key(&mut self, key: KeyEvent) { + let Some(session) = self.session.as_mut() else { + self.screen = Screen::Home; + return; + }; + match key.code { + KeyCode::Esc => { + if self.direct { + self.should_quit = true; + } else { + self.session = None; + self.screen = Screen::Home; + } + return; + } + KeyCode::Backspace => session.backspace(), + KeyCode::Char(' ') => session.space(), + KeyCode::Char(c) => session.type_char(c), + _ => {} + } + if session.is_finished() { + self.finish_test(); + } + } + + fn handle_results_key(&mut self, _key: KeyEvent) { + if let Some(opened) = self.results_opened { + if opened.elapsed() < Duration::from_millis(600) { + return; + } + } + + if self.direct { + self.should_quit = true; + return; + } + match _key.code { + KeyCode::Enter => self.start_test(), + KeyCode::Char('q') | KeyCode::Esc => { + self.session = None; + self.screen = Screen::Home; + } + _ => {} + } + } + + fn handle_settings_key(&mut self, key: KeyEvent) { + let post = { + let Some(st) = self.settings.as_mut() else { + self.screen = Screen::Home; + return; + }; + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + st.move_down(); + Post::None + } + KeyCode::Char('k') | KeyCode::Up => { + st.move_up(); + Post::None + } + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Char(' ') => { + if st.open { + st.confirm(); + Post::Apply + } else { + st.open(); + Post::None + } + } + KeyCode::Char('h') => { + if st.open { + st.close(); + } + Post::None + } + KeyCode::Esc | KeyCode::Char('q') => { + if st.open { + st.close(); + Post::None + } else { + Post::Leave + } + } + _ => Post::None, + } + }; + + match post { + Post::Apply => self.apply_settings(), + Post::Leave => { + self.settings = None; + self.screen = Screen::Home; + } + Post::None => {} + } + } +} + +enum Post { + None, + Apply, + Leave, +} + +fn cursor_shape(name: &str) -> SetCursorStyle { + let blink = name.contains("blink"); + if name.contains("underline") { + if blink { + SetCursorStyle::BlinkingUnderScore + } else { + SetCursorStyle::SteadyUnderScore + } + } else if name.contains("bar") { + if blink { + SetCursorStyle::BlinkingBar + } else { + SetCursorStyle::SteadyBar + } + } else if blink { + SetCursorStyle::BlinkingBlock + } else { + SetCursorStyle::SteadyBlock + } +} + +pub fn run( + theme: Theme, + cursor_style: String, + language: String, + mode_tokens: Vec, + time: u64, + direct: bool, +) -> Result<()> { + let mut tui = Tui::new()?; + tui.enter()?; + + let mut app = App::new(theme, cursor_style, language, mode_tokens, time, direct); + if direct { + app.start_test(); + } + + let result = (|| -> Result<()> { + while !app.should_quit { + app.tick(); + if app.screen == Screen::Typing { + let _ = execute!(stdout(), cursor_shape(&app.cursor_style)); + } + tui.terminal.draw(|frame| ui::render(frame, &app))?; + if let Some(event) = events::next(Duration::from_millis(100))? { + app.handle_event(event); + } + } + Ok(()) + })(); + + tui.exit()?; + result +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..c9526b1 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,65 @@ +mod parser; + +use anyhow::{Context, Result}; +use clap::Parser; +use parser::Cli; + +use crate::app; +use crate::config; +use crate::mode::Mode; +use crate::scores::progress::display; +use crate::theme; + +pub fn run() -> Result<()> { + let cli = Cli::parse(); + + if cli.config { + config::create_config()?; + config::open_config()?; + return Ok(()); + } + + if cli.stats { + display::draw()?; + return Ok(()); + } + + let language = config::language::Language::new().lang; + + let theme_name = config::toml_parser::get_config() + .lock() + .ok() + .and_then(|c| c.get_theme()) + .unwrap_or_else(|| theme::DEFAULT_THEME.to_string()); + let theme = theme::load(&theme_name); + + let cursor_style = config::toml_parser::get_config() + .lock() + .ok() + .and_then(|c| c.get_cursor()) + .unwrap_or_else(|| "block".to_string()); + + let config_time = config::toml_parser::get_config() + .lock() + .ok() + .and_then(|c| c.get_game()) + .and_then(|g| g.time); + let time = cli.time.or(config_time).unwrap_or(30); + + let mode_tokens: Vec = if !cli.mode.is_empty() { + cli.mode.clone() + } else { + config::mode_settings::ModeSettings::new() + .default_modes + .iter() + .map(|m| m.token().to_string()) + .collect() + }; + + Mode::from_str(mode_tokens.iter().map(|s| s.as_str()).collect()) + .context("Failed to parse mode")?; + + let direct = cli.time.is_some() || !cli.mode.is_empty(); + + app::run(theme, cursor_style, language, mode_tokens, time, direct) +} diff --git a/src/cli/parser.rs b/src/cli/parser.rs new file mode 100644 index 0000000..473b122 --- /dev/null +++ b/src/cli/parser.rs @@ -0,0 +1,53 @@ +use clap::Parser; + +#[derive(Parser)] +#[command(name = "typy")] +#[command(version)] +#[command(author = "Pazl27")] +#[command(about = "A Monkeytype-style typing test for your terminal.")] +#[command(long_about = "typy is a Monkeytype-style typing test for your terminal. + +USAGE MODES: + • Interactive: typy - Open the app (home, settings, stats) + • Quick run: typy -t 60 - Start a 60s test right away, then exit + • With mode: typy -m punctuation - Start a test with the given mode(s) + • Stats: typy -s - Show statistics for your past games + • Config: typy -c - Create and open the config file + +For more information check: https://github.com/Pazl27/typy-cli")] +pub(crate) struct Cli { + #[arg( + short = 't', + long = "time", + value_name = "SECONDS", + help_heading = "Game options", + help = "Duration of the test in seconds.\nStarts a test immediately and exits when it finishes." + )] + pub(crate) time: Option, + + #[arg( + short = 'm', + long = "mode", + value_name = "MODE", + num_args = 1.., + help_heading = "Game options", + help = "Mode(s) to play: normal, uppercase, punctuation.\nStarts a test immediately." + )] + pub(crate) mode: Vec, + + #[arg( + short = 's', + long = "stats", + help_heading = "Utility", + help = "Display statistics for your past games." + )] + pub(crate) stats: bool, + + #[arg( + short = 'c', + long = "config", + help_heading = "Utility", + help = "Create the config file if missing and open it in $EDITOR." + )] + pub(crate) config: bool, +} diff --git a/src/config/config_tables/cursor_style.rs b/src/config/config_tables/cursor_style.rs deleted file mode 100644 index 825fd44..0000000 --- a/src/config/config_tables/cursor_style.rs +++ /dev/null @@ -1,42 +0,0 @@ -use crossterm::cursor::SetCursorStyle; - -use crate::config::toml_parser::get_config; -use crate::config::toml_parser::CursorTable; - -pub struct CursorKind { - pub style: SetCursorStyle, -} - -impl CursorKind { - pub fn new() -> Self { - let cursor_table: CursorTable = - get_config() - .lock() - .unwrap() - .get_cursor() - .unwrap_or(CursorTable { - style: Some("DefaultUserShape".to_owned()), - }); - - let cursor_kind = match cursor_table.style.as_deref() { - Some("DefaultUserShape") => SetCursorStyle::DefaultUserShape, - Some("BlinkingBlock") => SetCursorStyle::BlinkingBlock, - Some("SteadyBlock") => SetCursorStyle::SteadyBlock, - Some("BlinkingUnderScore") => SetCursorStyle::BlinkingUnderScore, - Some("SteadyUnderScore") => SetCursorStyle::SteadyUnderScore, - Some("BlinkingBar") => SetCursorStyle::BlinkingBar, - Some("SteadyBar") => SetCursorStyle::SteadyBar, - _ => SetCursorStyle::DefaultUserShape, - }; - - CursorKind { style: cursor_kind } - } -} - -impl Default for CursorKind { - fn default() -> Self { - CursorKind { - style: SetCursorStyle::DefaultUserShape, - } - } -} diff --git a/src/config/config_tables/graph_colors.rs b/src/config/config_tables/graph_colors.rs deleted file mode 100644 index 4c7f4d7..0000000 --- a/src/config/config_tables/graph_colors.rs +++ /dev/null @@ -1,73 +0,0 @@ -use tui::style::Color; - -use crate::config::toml_parser::get_config; - -pub struct Graph { - pub data: Color, - pub title: Color, - pub axis: Color, -} - -impl Graph { - pub fn new() -> Self { - let theme_colors: Graph = match get_config().lock().unwrap().get_graph() { - Some(colors) => { - let data = colors - .data - .and_then(|c| hex_to_rgb(&c)) - .unwrap_or(Color::Yellow); - let title = colors - .title - .and_then(|c| hex_to_rgb(&c)) - .unwrap_or(Color::Red); - let axis = colors - .axis - .and_then(|c| hex_to_rgb(&c)) - .unwrap_or(Color::White); - - Graph { data, title, axis } - } - None => Graph::default(), - }; - theme_colors - } -} - -impl Default for Graph { - fn default() -> Self { - Graph { - data: Color::Yellow, - title: Color::Red, - axis: Color::White, - } - } -} - -fn hex_to_rgb(hex: &str) -> Option { - if hex.len() == 7 && hex.starts_with('#') { - let r = u8::from_str_radix(&hex[1..3], 16).ok()?; - let g = u8::from_str_radix(&hex[3..5], 16).ok()?; - let b = u8::from_str_radix(&hex[5..7], 16).ok()?; - Some(Color::Rgb(r, g, b)) - } else { - None - } -} - -#[cfg(test)] -mod graph_tests { - use super::*; - - #[test] - fn test_hex_to_rgb() { - assert_eq!(hex_to_rgb("#FFFFFF"), Some(Color::Rgb(255, 255, 255))); - assert_eq!(hex_to_rgb("#000000"), Some(Color::Rgb(0, 0, 0))); - assert_eq!(hex_to_rgb("#FF0000"), Some(Color::Rgb(255, 0, 0))); - assert_eq!(hex_to_rgb("#00FF00"), Some(Color::Rgb(0, 255, 0))); - assert_eq!(hex_to_rgb("#0000FF"), Some(Color::Rgb(0, 0, 255))); - assert_eq!(hex_to_rgb("#123456"), Some(Color::Rgb(18, 52, 86))); - assert_eq!(hex_to_rgb("123456"), None); - assert_eq!(hex_to_rgb("#12345G"), None); - assert_eq!(hex_to_rgb("#12345"), None); - } -} diff --git a/src/config/config_tables/mod.rs b/src/config/config_tables/mod.rs index ca249f6..ddb7167 100644 --- a/src/config/config_tables/mod.rs +++ b/src/config/config_tables/mod.rs @@ -1,5 +1,2 @@ -pub mod cursor_style; -pub mod graph_colors; pub mod mode_settings; -pub mod theme; pub mod language; diff --git a/src/config/config_tables/theme.rs b/src/config/config_tables/theme.rs deleted file mode 100644 index 10ee285..0000000 --- a/src/config/config_tables/theme.rs +++ /dev/null @@ -1,117 +0,0 @@ -use crossterm::style::Color; - -use crate::config::toml_parser::get_config; - -#[derive(Debug)] -pub struct ThemeColors { - pub fg: Color, - pub missing: Color, - pub error: Color, - pub accent: Color, -} - -impl ThemeColors { - pub fn new() -> Self { - let theme_colors: ThemeColors = match get_config().lock().unwrap().get_theme() { - Some(colors) => { - let fg = colors - .fg - .and_then(|c| hex_to_rgb(&c)) - .unwrap_or(Color::White); - let missing = colors - .missing - .and_then(|c| hex_to_rgb(&c)) - .unwrap_or(Color::Grey); - let error = colors - .error - .and_then(|c| hex_to_rgb(&c)) - .unwrap_or(Color::Red); - let accent = colors - .accent - .and_then(|c| hex_to_rgb(&c)) - .unwrap_or(Color::Yellow); - - ThemeColors { - fg, - missing, - error, - accent, - } - } - None => ThemeColors::default(), - }; - theme_colors - } -} - -impl Default for ThemeColors { - fn default() -> Self { - ThemeColors { - fg: Color::White, - missing: Color::Grey, - error: Color::Red, - accent: Color::Yellow, - } - } -} - -fn hex_to_rgb(hex: &str) -> Option { - if hex.len() == 7 && hex.starts_with('#') { - let r = u8::from_str_radix(&hex[1..3], 16).ok()?; - let g = u8::from_str_radix(&hex[3..5], 16).ok()?; - let b = u8::from_str_radix(&hex[5..7], 16).ok()?; - Some(Color::Rgb { r, g, b }) - } else { - None - } -} - -#[cfg(test)] -mod theme_tests { - use super::*; - - #[test] - fn test_hex_to_rgb() { - assert_eq!( - hex_to_rgb("#ffffff"), - Some(Color::Rgb { - r: 255, - g: 255, - b: 255 - }) - ); - assert_eq!(hex_to_rgb("#000000"), Some(Color::Rgb { r: 0, g: 0, b: 0 })); - assert_eq!( - hex_to_rgb("#ff0000"), - Some(Color::Rgb { r: 255, g: 0, b: 0 }) - ); - assert_eq!( - hex_to_rgb("#00ff00"), - Some(Color::Rgb { r: 0, g: 255, b: 0 }) - ); - assert_eq!( - hex_to_rgb("#0000ff"), - Some(Color::Rgb { r: 0, g: 0, b: 255 }) - ); - assert_eq!( - hex_to_rgb("#123456"), - Some(Color::Rgb { - r: 18, - g: 52, - b: 86 - }) - ); - assert_eq!( - hex_to_rgb("#abcdef"), - Some(Color::Rgb { - r: 171, - g: 205, - b: 239 - }) - ); - assert_eq!(hex_to_rgb("#12345"), None); - assert_eq!(hex_to_rgb("#1234567"), None); - assert_eq!(hex_to_rgb("123456"), None); - assert_eq!(hex_to_rgb("#12345g"), None); - } -} diff --git a/src/config/config_utils.rs b/src/config/config_utils.rs index 48126bd..065b946 100644 --- a/src/config/config_utils.rs +++ b/src/config/config_utils.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use dirs::home_dir; -use std::{fs, io::Write, process::Command}; +use std::{fs, io::Write, path::PathBuf, process::Command}; +use toml_edit::{value, DocumentMut, Item, Table}; pub fn create_config() -> Result<()> { if let Some(home_path) = home_dir() { @@ -24,6 +25,46 @@ pub fn create_config() -> Result<()> { Ok(()) } +fn config_path() -> Result { + let home = home_dir().context("Failed to get home directory")?; + Ok(home.join(".config/typy/config.toml")) +} + +pub fn save_settings( + theme: &str, + cursor: &str, + language: &str, + mode: &str, + time: u64, +) -> Result<()> { + create_config()?; + let path = config_path()?; + + let text = fs::read_to_string(&path).context("Failed to read config file")?; + let mut doc = text + .parse::() + .context("Failed to parse config file")?; + + doc.as_table_mut().insert("theme", value(theme)); + doc.as_table_mut().insert("cursor", value(cursor)); + set_kv(&mut doc, "language", "lang", value(language)); + set_kv(&mut doc, "modes", "default_mode", value(mode)); + set_kv(&mut doc, "game", "time", value(time as i64)); + + fs::write(&path, doc.to_string()).context("Failed to write config file")?; + Ok(()) +} + +fn set_kv(doc: &mut DocumentMut, section: &str, key: &str, val: Item) { + let table = doc.as_table_mut(); + if !table.contains_key(section) { + table.insert(section, Item::Table(Table::new())); + } + if let Some(section_table) = table[section].as_table_mut() { + section_table.insert(key, val); + } +} + pub fn open_config() -> Result<()> { if let Some(home_path) = home_dir() { let config_dir = home_path.join(".config/typy"); diff --git a/src/config/toml_parser.rs b/src/config/toml_parser.rs index 715bedd..0481742 100644 --- a/src/config/toml_parser.rs +++ b/src/config/toml_parser.rs @@ -6,26 +6,6 @@ use std::path::PathBuf; use std::sync::Mutex; use toml; -#[derive(Serialize, Deserialize, Clone)] -pub struct ThemeTable { - pub fg: Option, - pub missing: Option, - pub error: Option, - pub accent: Option, -} - -#[derive(Serialize, Deserialize, Clone)] -pub struct GraphTable { - pub data: Option, - pub title: Option, - pub axis: Option, -} - -#[derive(Serialize, Deserialize, Clone)] -pub struct CursorTable { - pub style: Option, -} - #[derive(Serialize, Deserialize, Clone)] pub struct ModesTable { pub default_mode: Option, @@ -38,13 +18,18 @@ pub struct LanguageTable { pub lang: Option, } +#[derive(Serialize, Deserialize, Clone)] +pub struct GameTable { + pub time: Option, +} + #[derive(Serialize, Deserialize, Default)] pub struct ConfigToml { - theme: Option, - graph: Option, - cursor: Option, + theme: Option, + cursor: Option, modes: Option, language: Option, + game: Option, } impl ConfigToml { @@ -70,15 +55,11 @@ impl ConfigToml { config_toml } - pub fn get_theme(&self) -> Option { + pub fn get_theme(&self) -> Option { self.theme.clone() } - pub fn get_graph(&self) -> Option { - self.graph.clone() - } - - pub fn get_cursor(&self) -> Option { + pub fn get_cursor(&self) -> Option { self.cursor.clone() } @@ -89,14 +70,16 @@ impl ConfigToml { pub fn get_language(&self) -> Option { self.language.clone() } + + pub fn get_game(&self) -> Option { + self.game.clone() + } } -// Declare the static instance of ConfigToml using lazy_static lazy_static! { static ref CONFIG: Mutex = Mutex::new(ConfigToml::new()); } -// Helper function to access the static CONFIG pub fn get_config() -> &'static Mutex { &CONFIG } diff --git a/src/main.rs b/src/main.rs index e4b5899..f4fc833 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,67 +1,17 @@ +mod app; +mod cli; mod config; mod mode; mod scores; -mod terminal; +mod settings; +mod theme; +mod tui; +mod typing; +mod ui; mod word_provider; -use anyhow::{Context, Result}; -use clap::Parser; -use mode::Mode; -use scores::progress::display; - -#[derive(Parser)] -#[command(name = "typy")] -#[command(version = "0.1.0")] -#[command(author = "Pazl27")] -#[command( - about = "Monkeytype clone in the terminal for more information check: https://github.com/Pazl27/typy-cli" -)] -#[command(long_about = None)] -struct Cli { - #[arg( - short = 't', - long = "time", - default_value = "30", - help = "Duration of the game" - )] - time: u64, - - #[arg(short = 's', long = "stats", help = "Display game stats")] - stats: bool, - - #[arg(short = 'c', long = "config", help = "Create and open config file")] - config: bool, - - #[arg(short = 'm', long = "mode", num_args = 1.., help = "Sets the mode of the game")] - mode: Vec, -} +use anyhow::Result; fn main() -> Result<()> { - let cli = Cli::parse(); - - let duration: u64 = cli.time; - - let theme = config::theme::ThemeColors::new(); - - if cli.config { - config::create_config()?; - config::open_config()?; - return Ok(()); - } - - if cli.stats { - display::draw()?; - return Ok(()); - } - - let mut mode_strs: Vec<&str> = cli.mode.iter().map(|s| s.as_str()).collect(); - mode_strs.is_empty().then(|| mode_strs.clear()); - - let mode = Mode::from_str(mode_strs) - .context("Failed to parse mode")? - .add_duration(duration); - - terminal::run(mode, theme)?; - - Ok(()) + cli::run() } diff --git a/src/mode/mode_selector.rs b/src/mode/mode_selector.rs index 5b496a7..afcd417 100644 --- a/src/mode/mode_selector.rs +++ b/src/mode/mode_selector.rs @@ -11,6 +11,16 @@ pub enum ModeType { Punctuation, } +impl ModeType { + pub fn token(&self) -> &'static str { + match self { + ModeType::Normal => "normal", + ModeType::Uppercase => "uppercase", + ModeType::Punctuation => "punctuation", + } + } +} + impl FromStr for ModeType { type Err = (); @@ -45,7 +55,6 @@ impl Mode { } } - // If no specific mode is provided, default to normal modes.is_empty().then(|| { settings.default_modes.iter().for_each(|m| { modes.push(m.clone()); @@ -144,7 +153,6 @@ mod mode_tests { let mode = Mode::from_str(vec!["uppercase"]).unwrap(); let mut list = vec![vec!["hello".to_string(), "world".to_string()]]; mode.transform(&mut list); - // Since the transformation is random, we can't assert exact values, but we can check the structure assert_eq!(list.len(), 1); assert_eq!(list[0].len(), 2); } @@ -154,7 +162,6 @@ mod mode_tests { let mode = Mode::from_str(vec!["punctuation"]).unwrap(); let mut list = vec![vec!["hello".to_string(), "world".to_string()]]; mode.transform(&mut list); - // Since the transformation is random, we can't assert exact values, but we can check the structure assert_eq!(list.len(), 1); assert_eq!(list[0].len(), 2); } diff --git a/src/scores/finish_overview.rs b/src/scores/finish_overview.rs deleted file mode 100644 index ec2e86b..0000000 --- a/src/scores/finish_overview.rs +++ /dev/null @@ -1,69 +0,0 @@ -use crate::scores::graph; -use anyhow::{Context, Result}; -use crossterm::cursor::MoveTo; -use crossterm::event::{read, Event, KeyEvent}; -use crossterm::style::SetForegroundColor; -use crossterm::terminal::{Clear, ClearType}; -use crossterm::ExecutableCommand; - -use crate::config::theme::ThemeColors; -use crate::scores::Stats; -use crate::terminal; - -pub fn show_stats(mut stdout: &std::io::Stdout, stats: Stats, theme: &ThemeColors) -> Result<()> { - stdout - .execute(Clear(ClearType::All)) - .context("Failed to clear terminal")?; - - // Draw infos - stdout - .execute(MoveTo(15, 16)) - .context("Failed to move cursor")?; - stdout - .execute(SetForegroundColor(theme.missing)) - .context("Failed to set foreground color")?; - print!("WPM"); - stdout - .execute(MoveTo(15, 17)) - .context("Failed to move cursor")?; - stdout - .execute(SetForegroundColor(theme.accent)) - .context("Failed to set foreground color")?; - print!("{:02}", stats.wpm() as i32); - stdout - .execute(MoveTo(15, 20)) - .context("Failed to move cursor")?; - stdout - .execute(SetForegroundColor(theme.missing)) - .context("Failed to set foreground color")?; - print!("RAW"); - stdout - .execute(MoveTo(15, 21)) - .context("Failed to move cursor")?; - stdout - .execute(SetForegroundColor(theme.accent)) - .context("Failed to set foreground color")?; - print!("{:02}", stats.raw_wpm() as i32); - stdout - .execute(MoveTo(37, 25)) - .context("Failed to move cursor")?; - stdout - .execute(SetForegroundColor(theme.accent)) - .context("Failed to set foreground color")?; - print!("ACCURACY: {:.2}%", stats.accuracy()); - - graph::draw_graph(stats.lps).context("Failed to draw graph")?; - - loop { - if let Ok(Event::Key(KeyEvent { - code, modifiers, .. - })) = read() - { - if terminal::close_typy(&code, &modifiers).is_some() { - break; - } - } - } - - Ok(()) -} diff --git a/src/scores/graph.rs b/src/scores/graph.rs deleted file mode 100644 index 5f17551..0000000 --- a/src/scores/graph.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::io; -use tui::backend::CrosstermBackend; -use tui::layout::Rect; -use tui::style::Style; -use tui::symbols::{self}; -use tui::text::Span; -use tui::widgets::Chart; -use tui::widgets::Dataset; -use tui::widgets::{Axis, GraphType}; -use tui::Terminal; - -use crate::config::graph_colors::Graph; -use anyhow::Result; - -pub fn draw_graph(data: Vec) -> Result<(), io::Error> { - let stdout = io::stdout(); - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let graph_colors = Graph::new(); - - terminal.draw(|f| { - let size = f.size(); - - let converted_data = data - .iter() - .enumerate() - .map(|(i, &x)| (i as f64, x as f64)) - .collect::>(); - - let datasets = vec![Dataset::default() - .marker(symbols::Marker::Braille) - .graph_type(GraphType::Line) - .style(Style::default().fg(graph_colors.data)) - .data(&converted_data)]; - - let end = data.len().to_string(); - let max_y = data.iter().max().unwrap().to_string(); - let chart = Chart::new(datasets) - .x_axis( - Axis::default() - .title(Span::styled( - "time in s", - Style::default().fg(graph_colors.title), - )) - .style(Style::default().fg(graph_colors.axis)) - .bounds([0.0, 10.0]) - .labels( - ["0", end.as_str()] - .iter() - .cloned() - .map(Span::from) - .collect(), - ), - ) - .y_axis( - Axis::default() - .title(Span::styled( - "letters", - Style::default().fg(graph_colors.title), - )) - .style(Style::default().fg(graph_colors.axis)) - .bounds([0.0, 10.0]) - .labels( - ["0", max_y.as_str()] - .iter() - .cloned() - .map(Span::from) - .collect(), - ), - ); - let area = Rect::new( - (size.width.saturating_sub(100)) / 2, - (size.height.saturating_sub(10)) / 2, - 100, - 10, - ); - - f.render_widget(chart, area); - })?; - - Ok(()) -} diff --git a/src/scores/mod.rs b/src/scores/mod.rs index 7709753..0506e47 100644 --- a/src/scores/mod.rs +++ b/src/scores/mod.rs @@ -1,5 +1,3 @@ -pub mod finish_overview; -mod graph; pub mod progress; mod stats; diff --git a/src/scores/progress/data.rs b/src/scores/progress/data.rs index 623cf64..3fcaf99 100644 --- a/src/scores/progress/data.rs +++ b/src/scores/progress/data.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use serde_json::to_writer_pretty; +use std::collections::BTreeMap; use std::fs::{self, File}; #[derive(Debug, Serialize, Deserialize)] @@ -44,22 +45,42 @@ pub struct Score { pub struct Data { pub scores: Vec, pub averages: Averages, + #[serde(default)] + pub records: BTreeMap, } impl Data { - fn new(scores: Vec, averages: Averages) -> Self { - Data { scores, averages } + fn new(scores: Vec, averages: Averages, records: BTreeMap) -> Self { + Data { + scores, + averages, + records, + } } - pub fn save_data(score: Score) -> Result<()> { + pub fn save_data(score: Score, duration: u64) -> Result<()> { + let wpm = score.wpm; let scores = Score::update_scores(&score)?; + let mut records = Data::get_records()?; + let best = records.entry(duration).or_insert(0); + if wpm > *best { + *best = wpm; + } let averages = Averages::new(score)?; - let data = Data::new(scores, averages); + let data = Data::new(scores, averages, records); Self::write_to_file(data)?; Ok(()) } + pub fn get_records() -> Result> { + Ok(Data::get_data()?.records) + } + + pub fn get_record(duration: u64) -> Result { + Ok(Data::get_records()?.get(&duration).copied().unwrap_or(0)) + } + pub fn get_data() -> Result { let mut path = dirs::home_dir().context("Failed to get home directory")?; path.push(".local/share/typy/scores.json"); @@ -126,6 +147,7 @@ impl Default for Data { sum_all: 0.0, }, }, + records: BTreeMap::new(), } } } diff --git a/src/scores/progress/display.rs b/src/scores/progress/display.rs index 8bad02c..b7b2ec7 100644 --- a/src/scores/progress/display.rs +++ b/src/scores/progress/display.rs @@ -1,18 +1,24 @@ use std::io::{stdout, Write}; use std::time::Duration; -use crate::terminal; - use super::*; use anyhow::{Context, Result}; use comfy_table::presets::UTF8_FULL; use comfy_table::*; use crossterm::cursor::MoveTo; -use crossterm::event::{poll, read, Event, KeyEvent}; +use crossterm::event::{poll, read, Event, KeyCode, KeyEvent, KeyModifiers}; use crossterm::style::ResetColor; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, size, Clear, ClearType}; use crossterm::{cursor, ExecutableCommand}; +fn should_close(code: &KeyCode, modifiers: &KeyModifiers) -> Option<()> { + match code { + KeyCode::Esc | KeyCode::Char('q') => Some(()), + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => Some(()), + _ => None, + } +} + const TABLE_WIDTH: u16 = 48; pub fn draw() -> Result<()> { @@ -29,7 +35,7 @@ pub fn draw() -> Result<()> { code, modifiers, .. })) = read().context("Failed to read event") { - if let Some(()) = terminal::close_typy(&code, &modifiers) { + if let Some(()) = should_close(&code, &modifiers) { break; } } diff --git a/src/scores/stats.rs b/src/scores/stats.rs index 262448b..a1ca97b 100644 --- a/src/scores/stats.rs +++ b/src/scores/stats.rs @@ -1,9 +1,14 @@ -const AVERAGE_WORD_LENGTH: i32 = 5; +const AVERAGE_WORD_LENGTH: f64 = 5.0; pub struct Stats { pub lps: Vec, pub letter_count: i32, - pub incorrect_letters: i32, + correct: i32, + incorrect: i32, + extra: i32, + missed: i32, + spaces: i32, + elapsed_secs: f64, } impl Stats { @@ -11,7 +16,12 @@ impl Stats { Stats { lps: Vec::new(), letter_count: 0, - incorrect_letters: 0, + correct: 0, + incorrect: 0, + extra: 0, + missed: 0, + spaces: 0, + elapsed_secs: 0.0, } } @@ -20,29 +30,110 @@ impl Stats { self.letter_count = 0; } - fn total_letters(&self) -> i32 { - self.lps.iter().sum() + pub fn finalize( + &mut self, + correct: i32, + incorrect: i32, + extra: i32, + missed: i32, + spaces: i32, + elapsed_secs: f64, + ) { + self.correct = correct; + self.incorrect = incorrect; + self.extra = extra; + self.missed = missed; + self.spaces = spaces; + self.elapsed_secs = elapsed_secs; } - fn total_seconds(&self) -> i32 { - self.lps.len() as i32 + fn minutes(&self) -> f64 { + self.elapsed_secs / 60.0 } - fn minutes(&self) -> f64 { - self.total_seconds() as f64 / 60.0 + fn words_per(&self, chars: f64) -> f64 { + let minutes = self.minutes(); + if minutes <= 0.0 { + 0.0 + } else { + (chars / AVERAGE_WORD_LENGTH) / minutes + } } pub fn raw_wpm(&self) -> f64 { - (self.total_letters() / AVERAGE_WORD_LENGTH) as f64 / self.minutes() + let typed = self.correct + self.incorrect + self.extra + self.spaces; + self.words_per(typed as f64) } pub fn wpm(&self) -> f64 { - const AVERAGE_WORD_LENGTH: i32 = 5; - ((self.total_letters() - self.incorrect_letters) / AVERAGE_WORD_LENGTH) as f64 - / self.minutes() + let net = self.correct + self.spaces; + self.words_per(net as f64) } pub fn accuracy(&self) -> f64 { - 100.0 - (self.incorrect_letters as f64 / self.total_letters() as f64) * 100.0 + let total = self.correct + self.incorrect + self.extra + self.missed; + if total == 0 { + 0.0 + } else { + self.correct as f64 / total as f64 * 100.0 + } + } +} + +#[cfg(test)] +mod stats_tests { + use super::*; + + fn finalized( + correct: i32, + incorrect: i32, + extra: i32, + missed: i32, + spaces: i32, + elapsed: f64, + ) -> Stats { + let mut s = Stats::new(); + s.finalize(correct, incorrect, extra, missed, spaces, elapsed); + s + } + + #[test] + fn perfect_run_counts_spaces() { + let s = finalized(150, 0, 0, 0, 30, 30.0); + assert_eq!(s.wpm().round() as i32, 72); + assert_eq!(s.raw_wpm().round() as i32, 72); + assert!((s.accuracy() - 100.0).abs() < 1e-9); + } + + #[test] + fn errors_lower_net_and_accuracy_not_raw() { + let s = finalized(140, 10, 0, 0, 30, 30.0); + assert_eq!(s.wpm().round() as i32, 68); + assert_eq!(s.raw_wpm().round() as i32, 72); + assert!((s.accuracy() - 93.3333).abs() < 0.01); + } + + #[test] + fn missed_chars_lower_accuracy() { + let s = finalized(100, 0, 0, 20, 20, 30.0); + assert!((s.accuracy() - 83.3333).abs() < 0.01); + } + + #[test] + fn no_integer_truncation() { + let s = finalized(149, 0, 0, 0, 0, 60.0); + assert!((s.wpm() - 29.8).abs() < 1e-9); + } + + #[test] + fn zero_time_and_empty_are_safe() { + let empty = Stats::new(); + assert_eq!(empty.wpm(), 0.0); + assert_eq!(empty.raw_wpm(), 0.0); + assert_eq!(empty.accuracy(), 0.0); + + let zero_time = finalized(10, 0, 0, 0, 2, 0.0); + assert_eq!(zero_time.wpm(), 0.0); + assert_eq!(zero_time.accuracy(), 100.0); } } diff --git a/src/settings/mod.rs b/src/settings/mod.rs new file mode 100644 index 0000000..cf563d1 --- /dev/null +++ b/src/settings/mod.rs @@ -0,0 +1,191 @@ +use std::collections::BTreeSet; + +use crate::theme::available_themes; +use crate::word_provider::available_languages; + +const TIME_OPTIONS: &[u64] = &[15, 30, 60, 120]; + +const CURSOR_OPTIONS: &[&str] = &[ + "block", + "blinking block", + "underline", + "blinking underline", + "bar", + "blinking bar", +]; + +fn mode_options() -> Vec<(&'static str, Vec<&'static str>)> { + vec![ + ("normal", vec!["normal"]), + ("uppercase", vec!["uppercase"]), + ("punctuation", vec!["punctuation"]), + ("uppercase + punctuation", vec!["uppercase", "punctuation"]), + ] +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Field { + Theme, + Cursor, + Language, + Mode, + Time, +} + +pub struct Row { + pub label: &'static str, + pub field: Field, + pub options: Vec, + pub selected: usize, +} + +pub struct SettingsState { + pub rows: Vec, + pub cursor: usize, + pub open: bool, + pub dropdown_cursor: usize, +} + +impl SettingsState { + pub fn new( + theme: &str, + cursor: &str, + language: &str, + mode_tokens: &[String], + time: u64, + ) -> Self { + let themes = available_themes(); + let theme_sel = themes.iter().position(|t| t == theme).unwrap_or(0); + + let cursor_options: Vec = CURSOR_OPTIONS.iter().map(|c| c.to_string()).collect(); + let cursor_sel = cursor_options.iter().position(|c| c == cursor).unwrap_or(0); + + let languages = available_languages(); + let language_sel = languages.iter().position(|l| l == language).unwrap_or(0); + + let modes = mode_options(); + let mode_labels: Vec = modes.iter().map(|(l, _)| l.to_string()).collect(); + let mode_sel = modes + .iter() + .position(|(_, toks)| tokens_match(toks, mode_tokens)) + .unwrap_or(0); + + let time_labels: Vec = TIME_OPTIONS.iter().map(|t| t.to_string()).collect(); + let time_sel = TIME_OPTIONS.iter().position(|&t| t == time).unwrap_or(1); + + let rows = vec![ + Row { + label: "theme", + field: Field::Theme, + options: themes, + selected: theme_sel, + }, + Row { + label: "cursor", + field: Field::Cursor, + options: cursor_options, + selected: cursor_sel, + }, + Row { + label: "language", + field: Field::Language, + options: languages, + selected: language_sel, + }, + Row { + label: "mode", + field: Field::Mode, + options: mode_labels, + selected: mode_sel, + }, + Row { + label: "time", + field: Field::Time, + options: time_labels, + selected: time_sel, + }, + ]; + + SettingsState { + rows, + cursor: 0, + open: false, + dropdown_cursor: 0, + } + } + + pub fn move_down(&mut self) { + if self.open { + let len = self.rows[self.cursor].options.len(); + self.dropdown_cursor = (self.dropdown_cursor + 1).min(len.saturating_sub(1)); + } else { + self.cursor = (self.cursor + 1).min(self.rows.len().saturating_sub(1)); + } + } + + pub fn move_up(&mut self) { + if self.open { + self.dropdown_cursor = self.dropdown_cursor.saturating_sub(1); + } else { + self.cursor = self.cursor.saturating_sub(1); + } + } + + pub fn open(&mut self) { + self.open = true; + self.dropdown_cursor = self.rows[self.cursor].selected; + } + + pub fn close(&mut self) { + self.open = false; + } + + pub fn confirm(&mut self) { + self.rows[self.cursor].selected = self.dropdown_cursor; + self.open = false; + } + + fn option_of(&self, field: Field) -> &str { + let row = self + .rows + .iter() + .find(|r| r.field == field) + .expect("settings row missing"); + &row.options[row.selected] + } + + pub fn theme_name(&self) -> String { + self.option_of(Field::Theme).to_string() + } + + pub fn cursor_style(&self) -> String { + self.option_of(Field::Cursor).to_string() + } + + pub fn language(&self) -> String { + self.option_of(Field::Language).to_string() + } + + pub fn time(&self) -> u64 { + self.option_of(Field::Time).parse().unwrap_or(30) + } + + pub fn mode_tokens(&self) -> Vec { + let label = self.option_of(Field::Mode); + mode_options() + .into_iter() + .find(|(l, _)| *l == label) + .map(|(_, toks)| toks.iter().map(|s| s.to_string()).collect()) + .unwrap_or_else(|| vec!["normal".to_string()]) + } + + pub fn mode_default_string(&self) -> String { + self.mode_tokens().join(", ") + } +} + +fn tokens_match(preset: &[&str], active: &[String]) -> bool { + let a: BTreeSet = preset.iter().map(|s| s.to_string()).collect(); + let b: BTreeSet = active.iter().cloned().collect(); + a == b +} diff --git a/src/terminal/game.rs b/src/terminal/game.rs deleted file mode 100644 index 59f1131..0000000 --- a/src/terminal/game.rs +++ /dev/null @@ -1,244 +0,0 @@ -use super::keyboard::{handle_input, InputAction}; -use anyhow::{Context, Result}; -use crossterm::cursor::{self, SetCursorStyle}; -use crossterm::event::poll; -use crossterm::{ - cursor::MoveTo, - event::{read, Event, KeyEvent}, - style::{ResetColor, SetForegroundColor}, - terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, - ExecutableCommand, -}; -use std::io::stdout; -use std::io::Write; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{mpsc, Arc, Mutex}; -use std::thread; -use std::time::{Duration, Instant}; - -use crate::config::cursor_style::CursorKind; -use crate::config::language; -use crate::config::theme::ThemeColors; -use crate::mode::Mode; -use crate::scores::finish_overview; -use crate::scores::progress::{Data, Score}; -use crate::scores::Stats; -use crate::word_provider; - -pub struct Player { - pub position_x: i32, - pub position_y: i32, -} - -impl Player { - fn new() -> Self { - Player { - position_x: 0, - position_y: 0, - } - } -} - -pub struct Game { - pub list: Vec>, - pub player: Player, - pub jump_position: i32, - pub selected_word_index: i32, - quit: bool, -} - -impl Game { - fn new(list: Vec>) -> Self { - Game { - list, - player: Player::new(), - jump_position: 0, - selected_word_index: 0, - quit: false, - } - } - - pub fn get_word_string(&self, index: i32) -> String { - self.list.get(index as usize).unwrap().join(" ") - } -} - -pub fn run(mode: Mode, theme: ThemeColors) -> Result<()> { - let mut stdout = stdout(); - - let language = language::Language::new(); - let mut game = Game::new( - word_provider::get_words(&language.lang).context("Failed to get words from file")?, - ); - - mode.transform(&mut game.list); - - let mut stats = Stats::new(); - - setup_terminal(&stdout).context("Failed to setup terminal")?; - - let (x, y) = super::calc_middle_for_text().context("Failed to calculate terminal size")?; - - for (i, words) in game.list.iter().enumerate() { - print_words(x, y + i as u16, words, &stdout, &theme)?; - stdout - .execute(MoveTo(x, y)) - .context("Failed to move cursor")?; - } - - let timer_expired = Arc::new(AtomicBool::new(false)); - let timer_expired_clone = Arc::clone(&timer_expired); - let remaining_time = Arc::new(Mutex::new(mode.duration)); - let remaining_time_clone = Arc::clone(&remaining_time); - let mut remaining_prev: u64 = 0; - - let (tx, _) = mpsc::channel(); - - let timer_thread = thread::spawn(move || { - if let Err(e) = start_timer(mode.duration, timer_expired_clone, remaining_time_clone) { - tx.send(e).expect("Failed to send error from timer thread"); - } - }); - - loop { - if game.player.position_y == game.list.len() as i32 { - break; - } - - stdout - .execute(MoveTo( - x + game.player.position_x as u16, - y + game.player.position_y as u16, - )) - .context("Failed to move cursor")?; - - if timer_expired.load(Ordering::Relaxed) { - break; - } - - { - let remaining = *remaining_time - .lock() - .map_err(|e| anyhow::anyhow!("Failed to lock remaining time: {}", e))?; - stdout - .execute(MoveTo(x, y - 2)) - .context("Failed to move cursor")?; - stdout - .execute(SetForegroundColor(theme.accent)) - .context("Failed to set foreground color")?; - print!("{:02}", remaining); - stdout.flush().context("Failed to flush stdout")?; - stdout - .execute(MoveTo( - x + game.player.position_x as u16, - y + game.player.position_y as u16, - )) - .context("Failed to move cursor")?; - if remaining != remaining_prev { - stats.add_letters(); - } - remaining_prev = remaining; - } - - if poll(Duration::from_millis(5)).context("Failed to poll for events")? { - if let Ok(Event::Key(KeyEvent { - code, modifiers, .. - })) = read().context("Failed to read event") - { - if let Some(()) = super::close_typy(&code, &modifiers) { - timer_expired.store(true, Ordering::Relaxed); - game.quit = true; - break; - } - match handle_input(&mut game, &stdout, code, &mut stats, &theme, x, y)? { - InputAction::Continue => continue, - InputAction::Break => break, - InputAction::None => {} - } - } - } - } - - if !game.quit { - stdout.execute(cursor::Hide)?; - let score = Score::new( - stats.wpm() as u32, - stats.raw_wpm() as u32, - stats.accuracy() as f32, - ); - Data::save_data(score).context("Failed to save data")?; - finish_overview::show_stats(&stdout, stats, &theme).context("Failed to show stats")?; - } else { - reset_terminal(&stdout).context("Failed to reset terminal")?; - } - timer_thread - .join() - .map_err(|e| anyhow::anyhow!("Failed to join timer thread: {:?}", e))?; - Ok(()) -} - -fn setup_terminal(mut stdout: &std::io::Stdout) -> Result<()> { - let cursor_kind = CursorKind::new(); - - enable_raw_mode()?; - stdout.execute(Clear(ClearType::All))?; - stdout.execute(cursor_kind.style)?; - - Ok(()) -} - -fn reset_terminal(mut stdout: &std::io::Stdout) -> Result<()> { - disable_raw_mode()?; - stdout.execute(cursor::Show)?; - stdout.execute(ResetColor)?; - stdout.execute(Clear(ClearType::All))?; - stdout.execute(MoveTo(0, 0))?; - stdout.execute(SetCursorStyle::DefaultUserShape)?; - stdout.flush()?; - - Ok(()) -} - -fn print_words( - x: u16, - y: u16, - words: &[String], - mut stdout: &std::io::Stdout, - theme: &ThemeColors, -) -> Result<()> { - stdout - .execute(MoveTo(x, y)) - .context("Failed to move cursor")?; - stdout - .execute(SetForegroundColor(theme.missing)) - .context("Failed to set foreground color")?; - words.iter().for_each(|word| { - print!("{} ", word); - }); - - Ok(()) -} - -fn start_timer( - duration: u64, - timer_expired: Arc, - remaining_time: Arc>, -) -> Result<()> { - let start = Instant::now(); - while start.elapsed().as_secs() < duration { - if timer_expired.load(Ordering::Relaxed) { - break; - } - let remaining = duration - start.elapsed().as_secs(); - { - let mut remaining_time = remaining_time - .lock() - .map_err(|e| anyhow::anyhow!("Failed to lock remaining time: {}", e))?; - *remaining_time = remaining; - } - thread::sleep(Duration::from_secs(1)); - } - timer_expired.store(true, Ordering::Relaxed); - - Ok(()) -} diff --git a/src/terminal/keyboard.rs b/src/terminal/keyboard.rs deleted file mode 100644 index a1cf78b..0000000 --- a/src/terminal/keyboard.rs +++ /dev/null @@ -1,299 +0,0 @@ -use std::io::Write; - -use anyhow::{Context, Result}; -use crossterm::event::KeyCode; -use crossterm::style::{Attribute, SetForegroundColor}; -use crossterm::ExecutableCommand; -use crossterm::{cursor::MoveTo, style::SetAttribute}; - -use crate::{config::theme::ThemeColors, scores::Stats}; - -use super::Game; - -const MAX_WORD_LENGTH: usize = 100; - -pub enum InputAction { - Continue, - Break, - None, -} - -pub fn handle_input( - game: &mut Game, - mut stdout: &std::io::Stdout, - code: KeyCode, - stats: &mut Stats, - theme: &ThemeColors, - x: u16, - y: u16, -) -> Result { - if let KeyCode::Char(c) = code { - if c == ' ' { - match handle_space(game, stdout, x, y)? { - InputAction::Continue => return Ok(InputAction::Continue), - InputAction::Break => return Ok(InputAction::Break), - InputAction::None => {} - }; - } - // check the typed letter - if game.player.position_x - < game.get_word_string(game.player.position_y).chars().count() as i32 - { - match handle_chars(game, stats, theme, stdout, c, x, y)? { - InputAction::Continue => return Ok(InputAction::Continue), - InputAction::Break => return Ok(InputAction::Break), - InputAction::None => {} - } - } else if game.get_word_string(game.player.position_y).len() < MAX_WORD_LENGTH { - let _ = add_incorrect_char(game, theme, stdout, c, x, y)?; - game.player.position_x += 1; - } - - stdout.flush().context("Failed to flush stdout")?; - } - Ok(InputAction::None) -} - -fn handle_space(game: &mut Game, stdout: &std::io::Stdout, x: u16, y: u16) -> Result { - if let InputAction::Continue = handle_space_at_start(game)? { - return Ok(InputAction::Continue); - } - - if let InputAction::Continue = handle_start_of_line(game)? { - return Ok(InputAction::Continue); - } - - if let InputAction::Continue = handle_end_of_line(game, stdout, x, y)? { - return Ok(InputAction::Continue); - } - - if game.jump_position + 1 == game.player.position_x && game.jump_position != 0 { - return Ok(InputAction::Continue); - } - - handle_jump_position(game, stdout, x, y)?; - - Ok(InputAction::None) -} - -fn handle_start_of_line(game: &Game) -> Result { - if game.player.position_x == 0 { - return Ok(InputAction::Continue); - } - Ok(InputAction::None) -} - -fn handle_end_of_line( - game: &mut Game, - mut stdout: &std::io::Stdout, - x: u16, - y: u16, -) -> Result { - if game.selected_word_index - == game - .list - .get(game.player.position_y as usize) - .context("Failed to get word from list")? - .len() as i32 - - 1 - { - if game.player.position_y == game.list.len() as i32 { - return Ok(InputAction::Break); - } - - game.player.position_x = 0; - game.player.position_y += 1; - game.jump_position = 1; - game.selected_word_index = 0; - - stdout - .execute(MoveTo( - x + game.player.position_x as u16, - y + game.player.position_y as u16, - )) - .context("Failed to move cursor")?; - return Ok(InputAction::Continue); - } - Ok(InputAction::None) -} - -fn handle_space_at_start(game: &Game) -> Result { - if game - .get_word_string(game.player.position_y) - .chars() - .nth((game.player.position_x - 1) as usize) - .context("Failed to get character from word")? - == ' ' - { - return Ok(InputAction::Continue); - } - Ok(InputAction::None) -} - -fn handle_jump_position( - game: &mut Game, - mut stdout: &std::io::Stdout, - x: u16, - y: u16, -) -> Result<()> { - game.jump_position = game - .list - .get(game.player.position_y as usize) - .context("Failed to get word from list")? - .iter() - .take(game.selected_word_index as usize + 1) - .map(|word| word.chars().count() + 1) - .sum::() as i32 - - 1; - game.player.position_x = game.jump_position; - stdout - .execute(MoveTo( - x + game.player.position_x as u16, - y + game.player.position_y as u16, - )) - .context("Failed to move cursor")?; - game.selected_word_index += 1; - Ok(()) -} - -fn handle_chars( - game: &mut Game, - stats: &mut Stats, - theme: &ThemeColors, - stdout: &std::io::Stdout, - c: char, - x: u16, - y: u16, -) -> Result { - let expected_char = game - .get_word_string(game.player.position_y) - .chars() - .nth(game.player.position_x as usize) - .context("Failed to get character from word")?; - - if c == expected_char { - handle_correct_char(game, theme, stdout, c, x, y)?; - } else if game - .get_word_string(game.player.position_y) - .chars() - .nth(game.player.position_x as usize) - .context("Failed to get character from word")? - == ' ' - { - if let InputAction::Continue = add_incorrect_char(game, theme, stdout, c, x, y)? { - return Ok(InputAction::Continue); - } - } else { - handle_incorrect_char(game, theme, stdout, expected_char, x, y)?; - } - - update_game_state(game, stats, c)?; - - Ok(InputAction::None) -} - -fn handle_correct_char( - game: &Game, - theme: &ThemeColors, - mut stdout: &std::io::Stdout, - c: char, - x: u16, - y: u16, -) -> Result<()> { - stdout - .execute(SetForegroundColor(theme.fg)) - .context("Failed to set foreground color")?; - stdout - .execute(MoveTo( - x + game.player.position_x as u16, - y + game.player.position_y as u16, - )) - .context("Failed to move cursor")?; - print!("{}", c); - Ok(()) -} - -fn handle_incorrect_char( - game: &Game, - theme: &ThemeColors, - mut stdout: &std::io::Stdout, - c: char, - x: u16, - y: u16, -) -> Result<()> { - stdout - .execute(SetForegroundColor(theme.error)) - .context("Failed to set foreground color")?; - stdout - .execute(MoveTo( - x + game.player.position_x as u16, - y + game.player.position_y as u16, - )) - .context("Failed to move cursor")?; - print!("{}", c); - Ok(()) -} - -fn add_incorrect_char( - game: &mut Game, - theme: &ThemeColors, - mut stdout: &std::io::Stdout, - c: char, - x: u16, - y: u16, -) -> Result { - let position_x = game.player.position_x; - let words = game.get_word_string(game.player.position_y); - - if words.len() >= MAX_WORD_LENGTH { - return Ok(InputAction::Continue); - } - - let before = words.chars().take(position_x as usize).collect::(); - let after = words.chars().skip(position_x as usize).collect::(); - - stdout.execute(MoveTo( - game.player.position_x as u16 + x, - game.player.position_y as u16 + y, - ))?; - - stdout.execute(SetForegroundColor(theme.error))?; - stdout.execute(SetAttribute(Attribute::Underlined))?; - print!("{}", c); - stdout.execute(SetAttribute(Attribute::Reset))?; - stdout.execute(SetForegroundColor(theme.missing))?; - print!("{}", after); - - let new_line = format!("{}{}{}", before, c, after); - game.list[game.player.position_y as usize] = - new_line.split_whitespace().map(String::from).collect(); - Ok(InputAction::None) -} - -fn update_game_state(game: &mut Game, stats: &mut Stats, c: char) -> Result<()> { - if c == game - .get_word_string(game.player.position_y) - .chars() - .nth(game.player.position_x as usize) - .context("Failed to get character from word")? - { - stats.letter_count += 1; - } else { - stats.incorrect_letters += 1; - stats.letter_count += 1; - } - - if game - .get_word_string(game.player.position_y) - .chars() - .nth(game.player.position_x as usize) - .context("Failed to get character from word")? - == ' ' - && c != ' ' - { - game.selected_word_index += 1; - } - game.player.position_x += 1; - - Ok(()) -} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs deleted file mode 100644 index 43199c3..0000000 --- a/src/terminal/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod game; -mod keyboard; -mod terminal_utils; - -pub use game::{run, Game}; -pub use terminal_utils::{calc_middle_for_text, close_typy}; diff --git a/src/terminal/terminal_utils.rs b/src/terminal/terminal_utils.rs deleted file mode 100644 index ede532e..0000000 --- a/src/terminal/terminal_utils.rs +++ /dev/null @@ -1,20 +0,0 @@ -use anyhow::{Context, Result}; -use crossterm::event::{KeyCode, KeyModifiers}; - -pub const LINE_LENGTH: i32 = 70; - -pub fn close_typy(code: &KeyCode, modifiers: &KeyModifiers) -> Option<()> { - match code { - KeyCode::Esc => Some(()), - KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => Some(()), - _ => None, - } -} - -pub fn calc_middle_for_text() -> Result<(u16, u16)> { - let (cols, rows) = crossterm::terminal::size().context("Failed to get terminal size")?; - let x = cols / 2 - (LINE_LENGTH / 2) as u16; - let y = rows / 2 - 1; - - Ok((x, y)) -} diff --git a/src/theme/mod.rs b/src/theme/mod.rs new file mode 100644 index 0000000..76ed692 --- /dev/null +++ b/src/theme/mod.rs @@ -0,0 +1,160 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use dirs::home_dir; +use include_dir::{include_dir, Dir}; +use ratatui::style::Color; +use serde::Deserialize; + +static BUILTIN: Dir = include_dir!("$CARGO_MANIFEST_DIR/resources/themes"); + +pub const DEFAULT_THEME: &str = "Catppuccin Mocha"; + +#[derive(Clone)] +pub struct Theme { + pub name: String, + pub fg: Color, + pub missing: Color, + pub error: Color, + pub accent: Color, + pub graph_data: Color, + pub graph_title: Color, + pub graph_axis: Color, +} + +#[derive(Deserialize)] +struct ThemeSpec { + name: Option, + fg: Option, + missing: Option, + error: Option, + accent: Option, + graph_data: Option, + graph_title: Option, + graph_axis: Option, +} + +impl ThemeSpec { + fn into_theme(self, fallback_name: &str) -> Theme { + let fg = parse_color(self.fg).unwrap_or(Color::White); + let missing = parse_color(self.missing).unwrap_or(Color::Gray); + let error = parse_color(self.error).unwrap_or(Color::Red); + let accent = parse_color(self.accent).unwrap_or(Color::Yellow); + Theme { + name: self.name.unwrap_or_else(|| fallback_name.to_string()), + fg, + missing, + error, + accent, + graph_data: parse_color(self.graph_data).unwrap_or(accent), + graph_title: parse_color(self.graph_title).unwrap_or(error), + graph_axis: parse_color(self.graph_axis).unwrap_or(missing), + } + } +} + +pub fn load(name: &str) -> Theme { + if let Some(theme) = user_themes().into_iter().find(|t| t.name == name) { + return theme; + } + if let Some(theme) = builtin_themes().into_iter().find(|t| t.name == name) { + return theme; + } + builtin_themes() + .into_iter() + .find(|t| t.name == DEFAULT_THEME) + .unwrap_or_else(fallback_theme) +} + +pub fn available_themes() -> Vec { + let mut names = BTreeSet::new(); + for theme in builtin_themes() { + names.insert(theme.name); + } + for theme in user_themes() { + names.insert(theme.name); + } + names.into_iter().collect() +} + +fn builtin_themes() -> Vec { + BUILTIN + .files() + .filter_map(|file| { + let text = file.contents_utf8()?; + let spec: ThemeSpec = toml::from_str(text).ok()?; + let stem = file.path().file_stem()?.to_str()?; + Some(spec.into_theme(stem)) + }) + .collect() +} + +fn user_themes() -> Vec { + let Some(path) = home_dir().map(|p| p.join(".config/typy/theme.toml")) else { + return Vec::new(); + }; + let Ok(text) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + let Ok(map) = toml::from_str::>(&text) else { + return Vec::new(); + }; + map.into_iter() + .map(|(key, spec)| spec.into_theme(&key)) + .collect() +} + +fn fallback_theme() -> Theme { + Theme { + name: DEFAULT_THEME.to_string(), + fg: Color::Rgb(205, 214, 244), + missing: Color::Rgb(108, 112, 134), + error: Color::Rgb(243, 139, 168), + accent: Color::Rgb(249, 226, 175), + graph_data: Color::Rgb(166, 227, 161), + graph_title: Color::Rgb(203, 166, 247), + graph_axis: Color::Rgb(88, 91, 112), + } +} + +fn parse_color(hex: Option) -> Option { + let hex = hex?; + if hex.len() == 7 && hex.starts_with('#') { + let r = u8::from_str_radix(&hex[1..3], 16).ok()?; + let g = u8::from_str_radix(&hex[3..5], 16).ok()?; + let b = u8::from_str_radix(&hex[5..7], 16).ok()?; + Some(Color::Rgb(r, g, b)) + } else { + None + } +} + +#[cfg(test)] +mod theme_tests { + use super::*; + + #[test] + fn test_parse_color() { + assert_eq!( + parse_color(Some("#ffffff".to_string())), + Some(Color::Rgb(255, 255, 255)) + ); + assert_eq!( + parse_color(Some("#123456".to_string())), + Some(Color::Rgb(18, 52, 86)) + ); + assert_eq!(parse_color(Some("#12345".to_string())), None); + assert_eq!(parse_color(Some("123456".to_string())), None); + assert_eq!(parse_color(None), None); + } + + #[test] + fn test_builtins_load() { + let themes = builtin_themes(); + assert!(themes.iter().any(|t| t.name == DEFAULT_THEME)); + } + + #[test] + fn test_default_available() { + assert!(available_themes().contains(&DEFAULT_THEME.to_string())); + } +} diff --git a/src/tui/events.rs b/src/tui/events.rs new file mode 100644 index 0000000..ee241bf --- /dev/null +++ b/src/tui/events.rs @@ -0,0 +1,12 @@ +use std::time::Duration; + +use anyhow::Result; +use crossterm::event::{self, Event}; + +pub fn next(timeout: Duration) -> Result> { + if event::poll(timeout)? { + Ok(Some(event::read()?)) + } else { + Ok(None) + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..d0c0862 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,56 @@ +pub mod events; + +use std::io::{stdout, Stdout}; + +use anyhow::Result; +use crossterm::cursor; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +pub type Backend = CrosstermBackend; + +pub struct Tui { + pub terminal: Terminal, +} + +impl Tui { + pub fn new() -> Result { + let terminal = Terminal::new(CrosstermBackend::new(stdout()))?; + Ok(Self { terminal }) + } + + pub fn enter(&mut self) -> Result<()> { + enable_raw_mode()?; + execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; + Self::install_panic_hook(); + self.terminal.clear()?; + Ok(()) + } + + pub fn exit(&mut self) -> Result<()> { + Self::restore() + } + + fn restore() -> Result<()> { + disable_raw_mode()?; + execute!( + stdout(), + LeaveAlternateScreen, + cursor::SetCursorStyle::DefaultUserShape, + cursor::Show + )?; + Ok(()) + } + + fn install_panic_hook() { + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = Self::restore(); + hook(info); + })); + } +} diff --git a/src/typing/engine.rs b/src/typing/engine.rs new file mode 100644 index 0000000..37b74c2 --- /dev/null +++ b/src/typing/engine.rs @@ -0,0 +1,198 @@ +use std::time::Instant; + +use anyhow::{Context, Result}; + +use crate::mode::Mode; +use crate::scores::Stats; +use crate::word_provider; + +pub struct Word { + pub target: Vec, + pub typed: Vec, +} + +impl Word { + fn new(text: &str) -> Self { + Word { + target: text.chars().collect(), + typed: Vec::new(), + } + } +} + +pub struct TypingSession { + pub words: Vec, + pub cursor_word: usize, + pub stats: Stats, + pub duration: u64, + start: Option, + sampled_secs: u64, + finished: bool, +} + +impl TypingSession { + pub fn new(mode: &Mode, language: &str) -> Result { + let min_words = (mode.duration as usize).saturating_mul(5).max(60); + let mut list = word_provider::get_words(language, min_words) + .context("Failed to get words from file")?; + mode.transform(&mut list); + + let words = list + .into_iter() + .flatten() + .map(|w| Word::new(&w)) + .collect::>(); + + Ok(TypingSession { + words, + cursor_word: 0, + stats: Stats::new(), + duration: mode.duration, + start: None, + sampled_secs: 0, + finished: false, + }) + } + + pub fn is_finished(&self) -> bool { + self.finished + } + + pub fn remaining_secs(&self) -> u64 { + match self.start { + None => self.duration, + Some(start) => self + .duration + .saturating_sub(start.elapsed().as_secs()), + } + } + + fn current(&mut self) -> Option<&mut Word> { + self.words.get_mut(self.cursor_word) + } + + pub fn type_char(&mut self, c: char) { + if self.finished { + return; + } + if self.start.is_none() { + self.start = Some(Instant::now()); + } + + let idx = self.cursor_word; + let Some(word) = self.current() else { + return; + }; + word.typed.push(c); + + self.stats.letter_count += 1; + + if idx == self.words.len() - 1 { + let word = &self.words[idx]; + if word.typed.len() >= word.target.len() { + self.finish(); + } + } + } + + pub fn space(&mut self) { + if self.finished { + return; + } + if self.cursor_word + 1 >= self.words.len() { + self.finish(); + } else { + self.cursor_word += 1; + } + } + + pub fn backspace(&mut self) { + if self.finished { + return; + } + match self.current() { + Some(word) if !word.typed.is_empty() => { + word.typed.pop(); + } + _ => { + if self.cursor_word > 0 { + self.cursor_word -= 1; + } + } + } + } + + pub fn tick(&mut self) { + let Some(start) = self.start else { + return; + }; + if self.finished { + return; + } + + let elapsed = start.elapsed().as_secs(); + while self.sampled_secs < elapsed { + self.stats.add_letters(); + self.sampled_secs += 1; + } + + if elapsed >= self.duration { + self.finish(); + } + } + + fn finish(&mut self) { + if self.finished { + return; + } + self.stats.add_letters(); + let (correct, incorrect, extra, missed) = self.tally(); + let elapsed = self + .start + .map(|s| s.elapsed().as_secs_f64()) + .unwrap_or(0.0); + self.stats + .finalize(correct, incorrect, extra, missed, self.cursor_word as i32, elapsed); + self.finished = true; + } + + fn tally(&self) -> (i32, i32, i32, i32) { + let mut correct = 0; + let mut incorrect = 0; + let mut extra = 0; + let mut missed = 0; + + let last = self.cursor_word.min(self.words.len().saturating_sub(1)); + for (wi, word) in self.words.iter().enumerate().take(last + 1) { + let common = word.target.len().min(word.typed.len()); + for i in 0..common { + if word.typed[i] == word.target[i] { + correct += 1; + } else { + incorrect += 1; + } + } + if word.typed.len() > word.target.len() { + extra += (word.typed.len() - word.target.len()) as i32; + } + if wi < self.cursor_word && word.typed.len() < word.target.len() { + missed += (word.target.len() - word.typed.len()) as i32; + } + } + + (correct, incorrect, extra, missed) + } + + pub fn live_wpm(&self) -> u32 { + let Some(start) = self.start else { + return 0; + }; + let minutes = start.elapsed().as_secs_f64() / 60.0; + if minutes <= 0.0 { + return 0; + } + let (correct, _, _, _) = self.tally(); + let chars = correct as f64 + self.cursor_word as f64; + ((chars / 5.0) / minutes).max(0.0) as u32 + } +} diff --git a/src/typing/mod.rs b/src/typing/mod.rs new file mode 100644 index 0000000..053f7fb --- /dev/null +++ b/src/typing/mod.rs @@ -0,0 +1,3 @@ +mod engine; + +pub use engine::{TypingSession, Word}; diff --git a/src/ui/home.rs b/src/ui/home.rs new file mode 100644 index 0000000..6ca7878 --- /dev/null +++ b/src/ui/home.rs @@ -0,0 +1,101 @@ +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::app::App; +use crate::theme::Theme; + +const BANNER: &[&str] = &[ + " _ ", + " | |_ _ _ _ __ _ _ ", + " | __| | | | '_ \\| | | |", + " | |_| |_| | |_) | |_| |", + " \\__|\\__, | .__/ \\__, |", + " |___/|_| |___/ ", +]; + +pub fn render(frame: &mut Frame, app: &App) { + let theme = &app.theme; + let area = frame.area(); + + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(area); + + render_hero(frame, root[0], app, theme); + render_command_bar(frame, root[1], theme); +} + +fn render_hero(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) { + let mut lines: Vec = BANNER + .iter() + .map(|row| { + Line::from(Span::styled( + *row, + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + )) + }) + .collect(); + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("{} · {}s", app.language, app.time), + Style::default().fg(theme.missing), + ))); + if app.record > 0 { + lines.push(Line::from(vec![ + Span::styled("best ", Style::default().fg(theme.missing)), + Span::styled( + format!("{} wpm", app.record), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("press ", Style::default().fg(theme.missing)), + Span::styled( + "any key", + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), + ), + Span::styled(" to start typing", Style::default().fg(theme.missing)), + ])); + + let block = super::centered_vertical(area, lines.len() as u16); + frame.render_widget( + Paragraph::new(lines).alignment(Alignment::Center), + block, + ); +} + +fn render_command_bar(frame: &mut Frame, area: Rect, theme: &Theme) { + let key = |k: &'static str| { + Span::styled( + k, + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ) + }; + let label = |t: &'static str| Span::styled(t, Style::default().fg(theme.missing)); + + let line = Line::from(vec![ + key(" s "), + label("settings "), + key(" p "), + label("stats "), + key(" q "), + label("quit"), + ]); + frame.render_widget( + Paragraph::new(line).alignment(Alignment::Center), + area, + ); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..bb4d17f --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,33 @@ +mod home; +mod results; +mod settings; +mod stats; +mod typing; + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::Frame; + +use crate::app::{App, Screen}; + +pub fn render(frame: &mut Frame, app: &App) { + match app.screen { + Screen::Home => home::render(frame, app), + Screen::Typing => typing::render(frame, app), + Screen::Results => results::render(frame, app), + Screen::Settings => settings::render(frame, app), + Screen::Stats => stats::render(frame, app), + } +} + +pub(crate) fn centered_vertical(area: Rect, height: u16) -> Rect { + let height = height.min(area.height); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length((area.height.saturating_sub(height)) / 2), + Constraint::Length(height), + Constraint::Min(0), + ]) + .split(area); + chunks[1] +} diff --git a/src/ui/results.rs b/src/ui/results.rs new file mode 100644 index 0000000..083524d --- /dev/null +++ b/src/ui/results.rs @@ -0,0 +1,207 @@ +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::symbols; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Paragraph}; +use ratatui::Frame; + +use crate::app::App; +use crate::scores::Stats; +use crate::theme::Theme; + +const PANEL_WIDTH: u16 = 64; +const PANEL_HEIGHT: u16 = 20; +const GRAPH_HEIGHT: u16 = 10; + +pub fn render(frame: &mut Frame, app: &App) { + let theme = &app.theme; + let Some(session) = app.session.as_ref() else { + return; + }; + let stats = &session.stats; + + let panel = centered_rect(frame.area(), PANEL_WIDTH, PANEL_HEIGHT); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(4), + Constraint::Length(1), + Constraint::Length(GRAPH_HEIGHT), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(panel); + + render_banner(frame, rows[0], app, theme); + render_headline(frame, rows[2], stats, theme); + render_graph(frame, rows[4], stats, theme); + render_footer(frame, rows[6], theme, app.direct); +} + +fn render_banner(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) { + let line = if app.new_record { + Line::from(vec![ + Span::styled( + "\u{2726} new best! ", + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{} \u{2192} {} wpm", app.previous_record, app.record), + Style::default().fg(theme.accent), + ), + ]) + } else if app.record > 0 { + Line::from(Span::styled( + format!("best {} wpm", app.record), + Style::default().fg(theme.missing), + )) + } else { + Line::from("") + }; + frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), area); +} + +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + let width = width.min(area.width); + let height = height.min(area.height); + Rect { + x: area.x + (area.width - width) / 2, + y: area.y + (area.height - height) / 2, + width, + height, + } +} + +fn render_headline(frame: &mut Frame, area: Rect, stats: &Stats, theme: &Theme) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(34), + Constraint::Percentage(33), + Constraint::Percentage(33), + ]) + .split(area); + + metric(frame, cols[0], "wpm", &safe(stats.wpm()).to_string(), theme); + metric( + frame, + cols[1], + "acc", + &format!("{}%", safe(stats.accuracy())), + theme, + ); + metric(frame, cols[2], "raw", &safe(stats.raw_wpm()).to_string(), theme); +} + +fn metric(frame: &mut Frame, area: Rect, label: &str, value: &str, theme: &Theme) { + let text = vec![ + Line::from(Span::styled( + label.to_string(), + Style::default().fg(theme.missing), + )), + Line::from(Span::styled( + value.to_string(), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + )), + ]; + frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area); +} + +fn render_graph(frame: &mut Frame, area: Rect, stats: &Stats, theme: &Theme) { + let data: Vec<(f64, f64)> = stats + .lps + .iter() + .enumerate() + .map(|(i, &v)| (i as f64, v as f64)) + .collect(); + + if data.is_empty() { + frame.render_widget( + Paragraph::new(Span::styled( + "not enough data for a graph", + Style::default().fg(theme.missing), + )) + .alignment(Alignment::Center), + area, + ); + return; + } + + let x_max = (data.len().saturating_sub(1)).max(1) as f64; + let y_max = stats.lps.iter().copied().max().unwrap_or(1).max(1) as f64; + + let datasets = vec![Dataset::default() + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(theme.graph_data)) + .data(&data)]; + + let chart = Chart::new(datasets) + .block(Block::default()) + .x_axis( + Axis::default() + .title(Span::styled( + "seconds", + Style::default().fg(theme.graph_title), + )) + .style(Style::default().fg(theme.graph_axis)) + .bounds([0.0, x_max]) + .labels(vec![ + Span::raw("0"), + Span::raw(format!("{}", x_max as u32)), + ]), + ) + .y_axis( + Axis::default() + .title(Span::styled( + "letters", + Style::default().fg(theme.graph_title), + )) + .style(Style::default().fg(theme.graph_axis)) + .bounds([0.0, y_max]) + .labels(vec![ + Span::raw("0"), + Span::raw(format!("{}", y_max as u32)), + ]), + ); + + frame.render_widget(chart, area); +} + +fn render_footer(frame: &mut Frame, area: Rect, theme: &Theme, direct: bool) { + let key = |k: &'static str| { + Span::styled( + k, + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ) + }; + let label = |t: &'static str| Span::styled(t, Style::default().fg(theme.missing)); + let line = if direct { + Line::from(label("press any key to exit")) + } else { + Line::from(vec![ + key(" enter "), + label("restart "), + key(" q "), + label("home"), + ]) + }; + frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), area); +} + +fn safe(value: f64) -> u32 { + if value.is_finite() && value > 0.0 { + value.round() as u32 + } else { + 0 + } +} diff --git a/src/ui/settings.rs b/src/ui/settings.rs new file mode 100644 index 0000000..a1aa3a5 --- /dev/null +++ b/src/ui/settings.rs @@ -0,0 +1,156 @@ +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; +use ratatui::Frame; + +use crate::app::App; +use crate::settings::SettingsState; +use crate::theme::Theme; + +const PANEL_WIDTH: u16 = 52; +const VALUE_COL: usize = 16; + +pub fn render(frame: &mut Frame, app: &App) { + let theme = &app.theme; + let Some(state) = app.settings.as_ref() else { + return; + }; + + let panel = render_panel(frame, state, theme); + + if state.open { + render_popup(frame, panel, state, theme); + } +} + +fn render_panel(frame: &mut Frame, state: &SettingsState, theme: &Theme) -> Rect { + let mut lines: Vec = Vec::new(); + for (i, row) in state.rows.iter().enumerate() { + let active = i == state.cursor; + let marker = if active { "> " } else { " " }; + let value = &row.options[row.selected]; + + let label_style = if active { + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.fg) + }; + + let mut label = format!("{marker}{}", row.label); + while label.chars().count() < VALUE_COL { + label.push(' '); + } + + lines.push(Line::from(vec![ + Span::styled(label, label_style), + Span::styled(format!("{value} \u{25be}"), Style::default().fg(theme.fg)), + ])); + } + lines.push(Line::from("")); + lines.push(hint_line(theme)); + + let height = lines.len() as u16 + 2; + let panel = centered_rect(frame.area(), PANEL_WIDTH, height); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.missing)) + .title(Span::styled( + " settings ", + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + )); + + frame.render_widget(Paragraph::new(lines).block(block), panel); + panel +} + +fn render_popup(frame: &mut Frame, panel: Rect, state: &SettingsState, theme: &Theme) { + let row = &state.rows[state.cursor]; + + let width = popup_width(row.options.iter().map(|s| s.as_str()), row.label); + let max_visible = frame.area().height.saturating_sub(4).min(10); + let height = (row.options.len() as u16 + 2).min(max_visible.max(3)); + + let area = popup_rect(frame.area(), panel, state.cursor, width, height); + + let items: Vec = row + .options + .iter() + .map(|opt| ListItem::new(opt.clone())) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.accent)) + .title(Span::styled( + format!(" {} ", row.label), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + )); + + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), + ) + .highlight_symbol(""); + + let mut list_state = ListState::default(); + list_state.select(Some(state.dropdown_cursor)); + + frame.render_widget(Clear, area); + frame.render_stateful_widget(list, area, &mut list_state); +} + +fn hint_line(theme: &Theme) -> Line<'static> { + Line::from(Span::styled( + "j/k move enter select esc close", + Style::default().fg(theme.missing), + )) +} + +fn popup_width<'a>(options: impl Iterator, label: &str) -> u16 { + let longest = options + .map(|s| s.chars().count()) + .chain(std::iter::once(label.chars().count() + 2)) + .max() + .unwrap_or(10); + (longest as u16 + 4).clamp(12, 40) +} + +fn popup_rect(screen: Rect, panel: Rect, row: usize, width: u16, height: u16) -> Rect { + let width = width.min(screen.width); + let height = height.min(screen.height); + + let anchor_x = panel.x + 1 + VALUE_COL as u16; + let x = anchor_x.min((screen.x + screen.width).saturating_sub(width)); + + let anchor_y = panel.y + 2 + row as u16; + let y = anchor_y.min((screen.y + screen.height).saturating_sub(height)); + + Rect { + x, + y, + width, + height, + } +} + +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + let width = width.min(area.width); + let height = height.min(area.height); + Rect { + x: area.x + (area.width - width) / 2, + y: area.y + (area.height - height) / 2, + width, + height, + } +} diff --git a/src/ui/stats.rs b/src/ui/stats.rs new file mode 100644 index 0000000..0068291 --- /dev/null +++ b/src/ui/stats.rs @@ -0,0 +1,147 @@ +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table}; +use ratatui::Frame; + +use crate::app::{App, StatsData}; +use crate::scores::progress::Averages; +use crate::theme::Theme; + +const PANEL_WIDTH: u16 = 60; + +pub fn render(frame: &mut Frame, app: &App) { + let theme = &app.theme; + let Some(data) = app.stats.as_ref() else { + return; + }; + + let rows = data.scores.len() as u16; + let height = (rows + 9).min(frame.area().height); + let panel = centered_rect(frame.area(), PANEL_WIDTH, height); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.missing)) + .title(Span::styled( + " stats ", + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + )); + let inner = block.inner(panel); + frame.render_widget(block, panel); + + let areas = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(inner); + + render_averages(frame, areas[0], &data.averages, theme); + render_scores(frame, areas[2], data, theme); + render_footer(frame, areas[3], theme); +} + +fn render_averages(frame: &mut Frame, area: Rect, averages: &Averages, theme: &Theme) { + let muted = Style::default().fg(theme.missing); + let value = Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD); + let line = Line::from(vec![ + Span::styled("avg ", muted), + Span::styled(format!("{:.1} wpm", averages.wpm_avg.avg), value), + Span::styled(" ", muted), + Span::styled(format!("{:.1} raw", averages.raw_avg.avg), value), + Span::styled(" ", muted), + Span::styled(format!("{:.1}% acc", averages.accuracy_avg.avg), value), + ]); + frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), area); +} + +fn render_scores(frame: &mut Frame, area: Rect, data: &StatsData, theme: &Theme) { + if data.scores.is_empty() { + frame.render_widget( + Paragraph::new(Span::styled( + "no games played yet", + Style::default().fg(theme.missing), + )) + .alignment(Alignment::Center), + area, + ); + return; + } + + let header_style = Style::default() + .fg(theme.missing) + .add_modifier(Modifier::BOLD); + let header = Row::new( + ["date", "time", "wpm", "raw", "acc"] + .into_iter() + .map(|h| Cell::from(Line::from(h).alignment(Alignment::Center))), + ) + .style(header_style); + + let avg = &data.averages; + let rows = data.scores.iter().map(|score| { + let wpm_color = good_bad(score.wpm as f32, avg.wpm_avg.avg, theme); + let raw_color = good_bad(score.raw as f32, avg.raw_avg.avg, theme); + let acc_color = good_bad(score.accuracy, avg.accuracy_avg.avg, theme); + + Row::new(vec![ + centered(score.get_date(), theme.fg), + centered(score.get_time(), theme.fg), + centered(score.wpm.to_string(), wpm_color), + centered(score.raw.to_string(), raw_color), + centered(format!("{:.1}%", score.accuracy), acc_color), + ]) + }); + + let widths = [ + Constraint::Length(12), + Constraint::Length(10), + Constraint::Length(7), + Constraint::Length(7), + Constraint::Length(9), + ]; + + frame.render_widget(Table::new(rows, widths).header(header), area); +} + +fn render_footer(frame: &mut Frame, area: Rect, theme: &Theme) { + frame.render_widget( + Paragraph::new(Span::styled( + "esc back", + Style::default().fg(theme.missing), + )) + .alignment(Alignment::Center), + area, + ); +} + +fn centered(text: String, color: ratatui::style::Color) -> Cell<'static> { + Cell::from(Line::from(text).alignment(Alignment::Center)).style(Style::default().fg(color)) +} + +fn good_bad(value: f32, average: f32, theme: &Theme) -> ratatui::style::Color { + if value >= average { + theme.graph_data + } else { + theme.error + } +} + +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + let width = width.min(area.width); + let height = height.min(area.height); + Rect { + x: area.x + (area.width - width) / 2, + y: area.y + (area.height - height) / 2, + width, + height, + } +} diff --git a/src/ui/typing.rs b/src/ui/typing.rs new file mode 100644 index 0000000..de026ea --- /dev/null +++ b/src/ui/typing.rs @@ -0,0 +1,167 @@ +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::app::App; +use crate::theme::Theme; +use crate::typing::{TypingSession, Word}; + +pub fn render(frame: &mut Frame, app: &App) { + let theme = &app.theme; + let Some(session) = app.session.as_ref() else { + return; + }; + let area = frame.area(); + + let width = area.width.saturating_mul(6) / 10; + let column = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length((area.width.saturating_sub(width)) / 2), + Constraint::Length(width), + Constraint::Min(0), + ]) + .split(area)[1]; + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(column); + + render_language(frame, rows[1], &app.language, theme); + render_status(frame, rows[3], session, theme); + render_words(frame, rows[5], session, theme); + render_hint(frame, area, theme); +} + +fn render_language(frame: &mut Frame, area: Rect, language: &str, theme: &Theme) { + let line = Line::from(vec![ + Span::styled( + language.to_string(), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + ]); + frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), area); +} + +fn render_status(frame: &mut Frame, area: Rect, session: &TypingSession, theme: &Theme) { + let line = Line::from(vec![ + Span::styled( + format!("{:>2}s", session.remaining_secs()), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" ", Style::default()), + Span::styled( + format!("{} wpm", live_wpm(session)), + Style::default().fg(theme.missing), + ), + ]); + frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), area); +} + +fn render_words(frame: &mut Frame, area: Rect, session: &TypingSession, theme: &Theme) { + let width = area.width.max(1) as usize; + + let mut lines: Vec = Vec::new(); + let mut current: Vec = Vec::new(); + let mut col = 0usize; + let mut caret_line = 0usize; + let mut caret_col = 0u16; + + for (wi, word) in session.words.iter().enumerate() { + let word_len = word.target.len().max(word.typed.len()); + + if col > 0 && col + 1 + word_len > width { + lines.push(Line::from(std::mem::take(&mut current))); + col = 0; + } + if col > 0 { + current.push(Span::raw(" ")); + col += 1; + } + + if wi == session.cursor_word { + caret_line = lines.len(); + let within = word.typed.len().min(word_len); + caret_col = (col + within) as u16; + } + + for i in 0..word_len { + let (ch, style) = char_style(word, i, theme); + current.push(Span::styled(ch.to_string(), style)); + } + col += word_len; + } + if !current.is_empty() { + lines.push(Line::from(current)); + } + + let window_start = caret_line.saturating_sub(1); + let visible: Vec = lines + .into_iter() + .skip(window_start) + .take(area.height as usize) + .collect(); + frame.render_widget(Paragraph::new(visible), area); + + let cursor_row = (caret_line - window_start) as u16; + if cursor_row < area.height { + let x = area.x + caret_col.min(area.width.saturating_sub(1)); + let y = area.y + cursor_row; + frame.set_cursor_position((x, y)); + } +} + +fn char_style(word: &Word, i: usize, theme: &Theme) -> (char, Style) { + if i < word.typed.len() { + if i < word.target.len() { + let ok = word.typed[i] == word.target[i]; + ( + word.target[i], + Style::default().fg(if ok { theme.fg } else { theme.error }), + ) + } else { + ( + word.typed[i], + Style::default() + .fg(theme.error) + .add_modifier(Modifier::UNDERLINED), + ) + } + } else { + (word.target[i], Style::default().fg(theme.missing)) + } +} + +fn render_hint(frame: &mut Frame, area: Rect, theme: &Theme) { + let bar = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(area)[1]; + frame.render_widget( + Paragraph::new(Span::styled( + "esc cancel", + Style::default().fg(theme.missing), + )) + .alignment(Alignment::Center), + bar, + ); +} + +fn live_wpm(session: &TypingSession) -> u32 { + session.live_wpm() +} diff --git a/src/word_provider/finder.rs b/src/word_provider/finder.rs index 34d76fd..6fc4638 100644 --- a/src/word_provider/finder.rs +++ b/src/word_provider/finder.rs @@ -12,13 +12,13 @@ use rand::seq::IndexedRandom; static WORDS_DIR: LazyLock> = LazyLock::new(|| { if cfg!(test) { - Some(PathBuf::from("./resources/")) + Some(PathBuf::from("./resources/lang/")) } else { home_dir().map(|p| p.join(".local/share/typy")) } }); const WORDS_URL: &str = - "https://raw.githubusercontent.com/Pazl27/typy-cli/refs/heads/master/resources/"; + "https://raw.githubusercontent.com/Pazl27/typy-cli/refs/heads/master/resources/lang/"; pub fn find(language: &str, lenght: i32) -> Result> { let Some(words_file) = WORDS_DIR @@ -28,7 +28,6 @@ pub fn find(language: &str, lenght: i32) -> Result> { bail!("Unable to find home directory"); }; - // Download words file if not already present if !words_file.exists() { create_dir_all(words_file.parent().unwrap())?; let language_url = format!("{WORDS_URL}{language}.txt"); @@ -90,7 +89,7 @@ mod finder_tests { #[test] fn test_read_file() { - let words = read_file("./resources/english.txt").unwrap(); + let words = read_file("./resources/lang/english.txt").unwrap(); assert_eq!(words.len(), 7776); } diff --git a/src/word_provider/mod.rs b/src/word_provider/mod.rs index 1d05b32..cc8e99b 100644 --- a/src/word_provider/mod.rs +++ b/src/word_provider/mod.rs @@ -2,30 +2,63 @@ mod finder; use anyhow::Result; use finder::find; +use std::collections::BTreeSet; +use std::path::PathBuf; const LENGTH: i32 = 70; -pub fn get_words(language: &str) -> Result>> { +pub fn get_words(language: &str, min_words: usize) -> Result>> { let mut words = Vec::new(); - for _ in 0..3 { - words.push(find(language, LENGTH)?); + let mut count = 0; + while count < min_words { + let line = find(language, LENGTH)?; + count += line.len(); + words.push(line); } Ok(words) } +pub fn available_languages() -> Vec { + let mut dirs: Vec = Vec::new(); + if let Some(home) = dirs::home_dir() { + dirs.push(home.join(".local/share/typy")); + } + dirs.push(PathBuf::from("./resources/lang")); + + let mut languages = BTreeSet::new(); + for dir in dirs { + let Ok(entries) = std::fs::read_dir(dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("txt") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + languages.insert(stem.to_string()); + } + } + } + } + + if languages.is_empty() { + languages.insert("english".to_string()); + } + languages.into_iter().collect() +} + #[cfg(test)] mod word_provider_tests { use super::*; #[test] fn test_get_words() { - let words = get_words("english"); + let words = get_words("english", 150).unwrap(); - for word in &words.unwrap() { - let mut length = 0; - for w in word { - length += w.chars().count() as i32; - } + let total: usize = words.iter().map(|line| line.len()).sum(); + assert!(total >= 150); + + for line in &words { + let length: i32 = line.iter().map(|w| w.chars().count() as i32).sum(); assert!(length <= LENGTH); } }