From f65e3d84135c502cdba3e227fae5ba2b1c0d61d8 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Tue, 10 Feb 2026 14:29:23 -0500 Subject: [PATCH] First one-shot pass --- Cargo.lock | 2119 +++++++++++++++++++++++++ Cargo.toml | 13 + assets/themes/catppuccin-latte.toml | 23 + assets/themes/catppuccin-mocha.toml | 23 + assets/themes/dracula.toml | 23 + assets/themes/gruvbox-dark.toml | 23 + assets/themes/nord.toml | 23 + assets/themes/one-dark.toml | 23 + assets/themes/solarized-dark.toml | 23 + assets/themes/tokyo-night.toml | 23 + src/app.rs | 249 +++ src/config.rs | 82 + src/engine/filter.rs | 20 + src/engine/key_stats.rs | 120 ++ src/engine/learning_rate.rs | 59 + src/engine/letter_unlock.rs | 151 ++ src/engine/mod.rs | 5 + src/engine/scoring.rs | 45 + src/event.rs | 49 + src/generator/code_syntax.rs | 122 ++ src/generator/github_code.rs | 41 + src/generator/mod.rs | 12 + src/generator/passage.rs | 49 + src/generator/phonetic.rs | 169 ++ src/generator/transition_table.rs | 98 ++ src/keyboard/finger.rs | 50 + src/keyboard/layout.rs | 51 + src/keyboard/mod.rs | 2 + src/main.rs | 410 ++++- src/session/input.rs | 58 + src/session/lesson.rs | 108 ++ src/session/mod.rs | 3 + src/session/result.rs | 53 + src/store/json_store.rs | 75 + src/store/mod.rs | 2 + src/store/schema.rs | 61 + src/ui/components/chart.rs | 67 + src/ui/components/dashboard.rs | 118 ++ src/ui/components/keyboard_diagram.rs | 86 + src/ui/components/menu.rs | 150 ++ src/ui/components/mod.rs | 8 + src/ui/components/progress_bar.rs | 53 + src/ui/components/stats_dashboard.rs | 119 ++ src/ui/components/stats_sidebar.rs | 87 + src/ui/components/typing_area.rs | 61 + src/ui/layout.rs | 53 + src/ui/mod.rs | 3 + src/ui/theme.rs | 146 ++ 48 files changed, 5409 insertions(+), 2 deletions(-) create mode 100644 Cargo.lock create mode 100644 assets/themes/catppuccin-latte.toml create mode 100644 assets/themes/catppuccin-mocha.toml create mode 100644 assets/themes/dracula.toml create mode 100644 assets/themes/gruvbox-dark.toml create mode 100644 assets/themes/nord.toml create mode 100644 assets/themes/one-dark.toml create mode 100644 assets/themes/solarized-dark.toml create mode 100644 assets/themes/tokyo-night.toml create mode 100644 src/app.rs create mode 100644 src/config.rs create mode 100644 src/engine/filter.rs create mode 100644 src/engine/key_stats.rs create mode 100644 src/engine/learning_rate.rs create mode 100644 src/engine/letter_unlock.rs create mode 100644 src/engine/mod.rs create mode 100644 src/engine/scoring.rs create mode 100644 src/event.rs create mode 100644 src/generator/code_syntax.rs create mode 100644 src/generator/github_code.rs create mode 100644 src/generator/mod.rs create mode 100644 src/generator/passage.rs create mode 100644 src/generator/phonetic.rs create mode 100644 src/generator/transition_table.rs create mode 100644 src/keyboard/finger.rs create mode 100644 src/keyboard/layout.rs create mode 100644 src/keyboard/mod.rs create mode 100644 src/session/input.rs create mode 100644 src/session/lesson.rs create mode 100644 src/session/mod.rs create mode 100644 src/session/result.rs create mode 100644 src/store/json_store.rs create mode 100644 src/store/mod.rs create mode 100644 src/store/schema.rs create mode 100644 src/ui/components/chart.rs create mode 100644 src/ui/components/dashboard.rs create mode 100644 src/ui/components/keyboard_diagram.rs create mode 100644 src/ui/components/menu.rs create mode 100644 src/ui/components/mod.rs create mode 100644 src/ui/components/progress_bar.rs create mode 100644 src/ui/components/stats_dashboard.rs create mode 100644 src/ui/components/stats_sidebar.rs create mode 100644 src/ui/components/typing_area.rs create mode 100644 src/ui/layout.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/theme.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3154234 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2119 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[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.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.3", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "keydr" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "crossterm 0.28.1", + "dirs", + "rand", + "ratatui", + "rust-embed", + "serde", + "serde_json", + "thiserror 2.0.18", + "toml", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str", + "hashbrown", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm 0.28.1", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.114", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[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.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.10.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/Cargo.toml b/Cargo.toml index 63bfda2..17845f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,5 +2,18 @@ name = "keydr" version = "0.1.0" edition = "2024" +description = "Terminal typing tutor with adaptive learning" [dependencies] +ratatui = { version = "0.30", features = ["crossterm_0_28"] } +crossterm = "0.28" +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" +rand = { version = "0.8", features = ["small_rng"] } +dirs = "6.0" +rust-embed = "8.5" +chrono = { version = "0.4", features = ["serde"] } +anyhow = "1.0" +thiserror = "2.0" diff --git a/assets/themes/catppuccin-latte.toml b/assets/themes/catppuccin-latte.toml new file mode 100644 index 0000000..60e114c --- /dev/null +++ b/assets/themes/catppuccin-latte.toml @@ -0,0 +1,23 @@ +name = "catppuccin-latte" + +[colors] +bg = "#eff1f5" +fg = "#4c4f69" +text_correct = "#40a02b" +text_incorrect = "#d20f39" +text_incorrect_bg = "#f5c2cf" +text_pending = "#9ca0b0" +text_cursor_bg = "#dc8a78" +text_cursor_fg = "#eff1f5" +focused_key = "#df8e1d" +accent = "#1e66f5" +accent_dim = "#ccd0da" +border = "#ccd0da" +border_focused = "#1e66f5" +header_bg = "#e6e9ef" +header_fg = "#4c4f69" +bar_filled = "#1e66f5" +bar_empty = "#e6e9ef" +error = "#d20f39" +warning = "#df8e1d" +success = "#40a02b" diff --git a/assets/themes/catppuccin-mocha.toml b/assets/themes/catppuccin-mocha.toml new file mode 100644 index 0000000..194d7bb --- /dev/null +++ b/assets/themes/catppuccin-mocha.toml @@ -0,0 +1,23 @@ +name = "catppuccin-mocha" + +[colors] +bg = "#1e1e2e" +fg = "#cdd6f4" +text_correct = "#a6e3a1" +text_incorrect = "#f38ba8" +text_incorrect_bg = "#45273a" +text_pending = "#585b70" +text_cursor_bg = "#f5e0dc" +text_cursor_fg = "#1e1e2e" +focused_key = "#f9e2af" +accent = "#89b4fa" +accent_dim = "#45475a" +border = "#45475a" +border_focused = "#89b4fa" +header_bg = "#313244" +header_fg = "#cdd6f4" +bar_filled = "#89b4fa" +bar_empty = "#313244" +error = "#f38ba8" +warning = "#f9e2af" +success = "#a6e3a1" diff --git a/assets/themes/dracula.toml b/assets/themes/dracula.toml new file mode 100644 index 0000000..b7521bf --- /dev/null +++ b/assets/themes/dracula.toml @@ -0,0 +1,23 @@ +name = "dracula" + +[colors] +bg = "#282a36" +fg = "#f8f8f2" +text_correct = "#50fa7b" +text_incorrect = "#ff5555" +text_incorrect_bg = "#44242a" +text_pending = "#6272a4" +text_cursor_bg = "#f1fa8c" +text_cursor_fg = "#282a36" +focused_key = "#f1fa8c" +accent = "#bd93f9" +accent_dim = "#44475a" +border = "#44475a" +border_focused = "#bd93f9" +header_bg = "#44475a" +header_fg = "#f8f8f2" +bar_filled = "#bd93f9" +bar_empty = "#44475a" +error = "#ff5555" +warning = "#f1fa8c" +success = "#50fa7b" diff --git a/assets/themes/gruvbox-dark.toml b/assets/themes/gruvbox-dark.toml new file mode 100644 index 0000000..1fce962 --- /dev/null +++ b/assets/themes/gruvbox-dark.toml @@ -0,0 +1,23 @@ +name = "gruvbox-dark" + +[colors] +bg = "#282828" +fg = "#ebdbb2" +text_correct = "#b8bb26" +text_incorrect = "#fb4934" +text_incorrect_bg = "#462726" +text_pending = "#665c54" +text_cursor_bg = "#fabd2f" +text_cursor_fg = "#282828" +focused_key = "#fabd2f" +accent = "#83a598" +accent_dim = "#3c3836" +border = "#504945" +border_focused = "#83a598" +header_bg = "#3c3836" +header_fg = "#ebdbb2" +bar_filled = "#83a598" +bar_empty = "#3c3836" +error = "#fb4934" +warning = "#fabd2f" +success = "#b8bb26" diff --git a/assets/themes/nord.toml b/assets/themes/nord.toml new file mode 100644 index 0000000..356d063 --- /dev/null +++ b/assets/themes/nord.toml @@ -0,0 +1,23 @@ +name = "nord" + +[colors] +bg = "#2e3440" +fg = "#eceff4" +text_correct = "#a3be8c" +text_incorrect = "#bf616a" +text_incorrect_bg = "#3f2e31" +text_pending = "#4c566a" +text_cursor_bg = "#ebcb8b" +text_cursor_fg = "#2e3440" +focused_key = "#ebcb8b" +accent = "#88c0d0" +accent_dim = "#3b4252" +border = "#4c566a" +border_focused = "#88c0d0" +header_bg = "#3b4252" +header_fg = "#eceff4" +bar_filled = "#88c0d0" +bar_empty = "#3b4252" +error = "#bf616a" +warning = "#ebcb8b" +success = "#a3be8c" diff --git a/assets/themes/one-dark.toml b/assets/themes/one-dark.toml new file mode 100644 index 0000000..098aa01 --- /dev/null +++ b/assets/themes/one-dark.toml @@ -0,0 +1,23 @@ +name = "one-dark" + +[colors] +bg = "#282c34" +fg = "#abb2bf" +text_correct = "#98c379" +text_incorrect = "#e06c75" +text_incorrect_bg = "#3e2a2d" +text_pending = "#5c6370" +text_cursor_bg = "#e5c07b" +text_cursor_fg = "#282c34" +focused_key = "#e5c07b" +accent = "#61afef" +accent_dim = "#3e4451" +border = "#3e4451" +border_focused = "#61afef" +header_bg = "#21252b" +header_fg = "#abb2bf" +bar_filled = "#61afef" +bar_empty = "#21252b" +error = "#e06c75" +warning = "#e5c07b" +success = "#98c379" diff --git a/assets/themes/solarized-dark.toml b/assets/themes/solarized-dark.toml new file mode 100644 index 0000000..27e3f3d --- /dev/null +++ b/assets/themes/solarized-dark.toml @@ -0,0 +1,23 @@ +name = "solarized-dark" + +[colors] +bg = "#002b36" +fg = "#839496" +text_correct = "#859900" +text_incorrect = "#dc322f" +text_incorrect_bg = "#2a1a1a" +text_pending = "#586e75" +text_cursor_bg = "#b58900" +text_cursor_fg = "#002b36" +focused_key = "#b58900" +accent = "#268bd2" +accent_dim = "#073642" +border = "#586e75" +border_focused = "#268bd2" +header_bg = "#073642" +header_fg = "#93a1a1" +bar_filled = "#268bd2" +bar_empty = "#073642" +error = "#dc322f" +warning = "#b58900" +success = "#859900" diff --git a/assets/themes/tokyo-night.toml b/assets/themes/tokyo-night.toml new file mode 100644 index 0000000..4300823 --- /dev/null +++ b/assets/themes/tokyo-night.toml @@ -0,0 +1,23 @@ +name = "tokyo-night" + +[colors] +bg = "#1a1b26" +fg = "#c0caf5" +text_correct = "#9ece6a" +text_incorrect = "#f7768e" +text_incorrect_bg = "#3b2232" +text_pending = "#565f89" +text_cursor_bg = "#e0af68" +text_cursor_fg = "#1a1b26" +focused_key = "#e0af68" +accent = "#7aa2f7" +accent_dim = "#292e42" +border = "#3b4261" +border_focused = "#7aa2f7" +header_bg = "#24283b" +header_fg = "#c0caf5" +bar_filled = "#7aa2f7" +bar_empty = "#24283b" +error = "#f7768e" +warning = "#e0af68" +success = "#9ece6a" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..2f82860 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,249 @@ +use rand::rngs::SmallRng; +use rand::SeedableRng; + +use crate::config::Config; +use crate::engine::filter::CharFilter; +use crate::engine::key_stats::KeyStatsStore; +use crate::engine::letter_unlock::LetterUnlock; +use crate::engine::scoring; +use crate::generator::code_syntax::CodeSyntaxGenerator; +use crate::generator::passage::PassageGenerator; +use crate::generator::phonetic::PhoneticGenerator; +use crate::generator::TextGenerator; +use crate::generator::transition_table::TransitionTable; + +use crate::session::input::{self, KeystrokeEvent}; +use crate::session::lesson::LessonState; +use crate::session::result::LessonResult; +use crate::store::json_store::JsonStore; +use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData}; +use crate::ui::components::menu::Menu; +use crate::ui::theme::Theme; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AppScreen { + Menu, + Lesson, + LessonResult, + StatsDashboard, + #[allow(dead_code)] + Settings, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LessonMode { + Adaptive, + Code, + Passage, +} + +pub struct App { + pub screen: AppScreen, + pub lesson_mode: LessonMode, + pub lesson: Option, + pub lesson_events: Vec, + pub last_result: Option, + pub lesson_history: Vec, + pub menu: Menu<'static>, + pub theme: &'static Theme, + pub config: Config, + pub key_stats: KeyStatsStore, + pub letter_unlock: LetterUnlock, + pub profile: ProfileData, + pub store: Option, + pub should_quit: bool, + rng: SmallRng, + transition_table: TransitionTable, +} + +impl App { + pub fn new() -> Self { + let config = Config::load().unwrap_or_default(); + let loaded_theme = Theme::load(&config.theme).unwrap_or_default(); + let theme: &'static Theme = Box::leak(Box::new(loaded_theme)); + let menu = Menu::new(theme); + + let store = JsonStore::new().ok(); + + let (key_stats, letter_unlock, profile, lesson_history) = if let Some(ref s) = store { + let ksd = s.load_key_stats(); + let pd = s.load_profile(); + let lhd = s.load_lesson_history(); + + let lu = if pd.unlocked_letters.is_empty() { + LetterUnlock::new() + } else { + LetterUnlock::from_included(pd.unlocked_letters.clone()) + }; + + (ksd.stats, lu, pd, lhd.lessons) + } else { + ( + KeyStatsStore::default(), + LetterUnlock::new(), + ProfileData::default(), + Vec::new(), + ) + }; + + let mut key_stats_with_target = key_stats; + key_stats_with_target.target_cpm = config.target_cpm(); + + let transition_table = TransitionTable::build_english(); + + Self { + screen: AppScreen::Menu, + lesson_mode: LessonMode::Adaptive, + lesson: None, + lesson_events: Vec::new(), + last_result: None, + lesson_history, + menu, + theme, + config, + key_stats: key_stats_with_target, + letter_unlock, + profile, + store, + should_quit: false, + rng: SmallRng::from_entropy(), + transition_table, + } + } + + pub fn start_lesson(&mut self) { + let text = self.generate_text(); + self.lesson = Some(LessonState::new(&text)); + self.lesson_events.clear(); + self.screen = AppScreen::Lesson; + } + + fn generate_text(&mut self) -> String { + let word_count = self.config.word_count; + let mode = self.lesson_mode; + + match mode { + LessonMode::Adaptive => { + let filter = CharFilter::new(self.letter_unlock.included.clone()); + let focused = self.letter_unlock.focused; + let table = self.transition_table.clone(); + let rng = SmallRng::from_rng(&mut self.rng).unwrap(); + let mut generator = PhoneticGenerator::new(table, rng); + generator.generate(&filter, focused, word_count) + } + LessonMode::Code => { + let filter = CharFilter::new(('a'..='z').collect()); + let lang = self + .config + .code_languages + .first() + .cloned() + .unwrap_or_else(|| "rust".to_string()); + let rng = SmallRng::from_rng(&mut self.rng).unwrap(); + let mut generator = CodeSyntaxGenerator::new(rng, &lang); + generator.generate(&filter, None, word_count) + } + LessonMode::Passage => { + let filter = CharFilter::new(('a'..='z').collect()); + let mut generator = PassageGenerator::new(); + generator.generate(&filter, None, word_count) + } + } + } + + pub fn type_char(&mut self, ch: char) { + if let Some(ref mut lesson) = self.lesson { + if let Some(event) = input::process_char(lesson, ch) { + self.lesson_events.push(event); + } + + if lesson.is_complete() { + self.finish_lesson(); + } + } + } + + pub fn backspace(&mut self) { + if let Some(ref mut lesson) = self.lesson { + input::process_backspace(lesson); + } + } + + fn finish_lesson(&mut self) { + if let Some(ref lesson) = self.lesson { + let result = LessonResult::from_lesson(lesson, &self.lesson_events); + + if self.lesson_mode == LessonMode::Adaptive { + for kt in &result.per_key_times { + if kt.correct { + self.key_stats.update_key(kt.key, kt.time_ms); + } + } + self.letter_unlock.update(&self.key_stats); + } + + let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count()); + let score = scoring::compute_score(&result, complexity); + self.profile.total_score += score; + self.profile.total_lessons += 1; + self.profile.unlocked_letters = self.letter_unlock.included.clone(); + + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + if self.profile.last_practice_date.as_deref() != Some(&today) { + if let Some(ref last) = self.profile.last_practice_date { + let yesterday = (chrono::Utc::now() - chrono::Duration::days(1)) + .format("%Y-%m-%d") + .to_string(); + if last == &yesterday { + self.profile.streak_days += 1; + } else { + self.profile.streak_days = 1; + } + } else { + self.profile.streak_days = 1; + } + self.profile.best_streak = + self.profile.best_streak.max(self.profile.streak_days); + self.profile.last_practice_date = Some(today); + } + + self.lesson_history.push(result.clone()); + if self.lesson_history.len() > 500 { + self.lesson_history.remove(0); + } + + self.last_result = Some(result); + self.screen = AppScreen::LessonResult; + + self.save_data(); + } + } + + fn save_data(&self) { + if let Some(ref store) = self.store { + let _ = store.save_profile(&self.profile); + let _ = store.save_key_stats(&KeyStatsData { + schema_version: 1, + stats: self.key_stats.clone(), + }); + let _ = store.save_lesson_history(&LessonHistoryData { + schema_version: 1, + lessons: self.lesson_history.clone(), + }); + } + } + + pub fn retry_lesson(&mut self) { + self.start_lesson(); + } + + pub fn go_to_menu(&mut self) { + self.screen = AppScreen::Menu; + self.lesson = None; + self.lesson_events.clear(); + } + + pub fn go_to_stats(&mut self) { + self.screen = AppScreen::StatsDashboard; + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..508d7a4 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,82 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Config { + #[serde(default = "default_target_wpm")] + pub target_wpm: u32, + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default = "default_keyboard_layout")] + pub keyboard_layout: String, + #[serde(default = "default_code_languages")] + pub code_languages: Vec, + #[serde(default = "default_word_count")] + pub word_count: usize, +} + +fn default_target_wpm() -> u32 { + 35 +} +fn default_theme() -> String { + "catppuccin-mocha".to_string() +} +fn default_keyboard_layout() -> String { + "qwerty".to_string() +} +fn default_code_languages() -> Vec { + vec!["rust".to_string()] +} +fn default_word_count() -> usize { + 20 +} + +impl Default for Config { + fn default() -> Self { + Self { + target_wpm: default_target_wpm(), + theme: default_theme(), + keyboard_layout: default_keyboard_layout(), + code_languages: default_code_languages(), + word_count: default_word_count(), + } + } +} + +impl Config { + pub fn load() -> Result { + let path = Self::config_path(); + if path.exists() { + let content = fs::read_to_string(&path)?; + let config: Config = toml::from_str(&content)?; + Ok(config) + } else { + Ok(Config::default()) + } + } + + #[allow(dead_code)] + pub fn save(&self) -> Result<()> { + let path = Self::config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(self)?; + fs::write(&path, content)?; + Ok(()) + } + + fn config_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("keydr") + .join("config.toml") + } + + pub fn target_cpm(&self) -> f64 { + self.target_wpm as f64 * 5.0 + } +} diff --git a/src/engine/filter.rs b/src/engine/filter.rs new file mode 100644 index 0000000..5e158d4 --- /dev/null +++ b/src/engine/filter.rs @@ -0,0 +1,20 @@ +pub struct CharFilter { + pub allowed: Vec, +} + +impl CharFilter { + pub fn new(allowed: Vec) -> Self { + Self { allowed } + } + + pub fn is_allowed(&self, ch: char) -> bool { + self.allowed.contains(&ch) || ch == ' ' + } + + #[allow(dead_code)] + pub fn filter_text(&self, text: &str) -> String { + text.chars() + .filter(|&ch| self.is_allowed(ch)) + .collect() + } +} diff --git a/src/engine/key_stats.rs b/src/engine/key_stats.rs new file mode 100644 index 0000000..fb5210a --- /dev/null +++ b/src/engine/key_stats.rs @@ -0,0 +1,120 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +const EMA_ALPHA: f64 = 0.1; +const DEFAULT_TARGET_CPM: f64 = 175.0; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct KeyStat { + pub filtered_time_ms: f64, + pub best_time_ms: f64, + pub confidence: f64, + pub sample_count: usize, + pub recent_times: Vec, +} + +impl Default for KeyStat { + fn default() -> Self { + Self { + filtered_time_ms: 1000.0, + best_time_ms: f64::MAX, + confidence: 0.0, + sample_count: 0, + recent_times: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct KeyStatsStore { + pub stats: HashMap, + pub target_cpm: f64, +} + +impl Default for KeyStatsStore { + fn default() -> Self { + Self { + stats: HashMap::new(), + target_cpm: DEFAULT_TARGET_CPM, + } + } +} + +impl KeyStatsStore { + pub fn update_key(&mut self, key: char, time_ms: f64) { + let stat = self.stats.entry(key).or_default(); + stat.sample_count += 1; + + if stat.sample_count == 1 { + stat.filtered_time_ms = time_ms; + } else { + stat.filtered_time_ms = + EMA_ALPHA * time_ms + (1.0 - EMA_ALPHA) * stat.filtered_time_ms; + } + + stat.best_time_ms = stat.best_time_ms.min(stat.filtered_time_ms); + + let target_time_ms = 60000.0 / self.target_cpm; + stat.confidence = target_time_ms / stat.filtered_time_ms; + + stat.recent_times.push(time_ms); + if stat.recent_times.len() > 30 { + stat.recent_times.remove(0); + } + } + + pub fn get_confidence(&self, key: char) -> f64 { + self.stats + .get(&key) + .map(|s| s.confidence) + .unwrap_or(0.0) + } + + #[allow(dead_code)] + pub fn get_stat(&self, key: char) -> Option<&KeyStat> { + self.stats.get(&key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_initial_confidence_is_zero() { + let store = KeyStatsStore::default(); + assert_eq!(store.get_confidence('a'), 0.0); + } + + #[test] + fn test_update_key_creates_stat() { + let mut store = KeyStatsStore::default(); + store.update_key('e', 300.0); + assert!(store.get_confidence('e') > 0.0); + assert_eq!(store.stats.get(&'e').unwrap().sample_count, 1); + } + + #[test] + fn test_ema_converges() { + let mut store = KeyStatsStore::default(); + // Type key fast many times - confidence should increase + for _ in 0..50 { + store.update_key('t', 200.0); + } + let conf = store.get_confidence('t'); + // At 175 CPM target, target_time = 60000/175 = 342.8ms + // With 200ms typing time, confidence = 342.8/200 = 1.71 + assert!(conf > 1.0, "confidence should be > 1.0 for fast typing, got {conf}"); + } + + #[test] + fn test_slow_typing_low_confidence() { + let mut store = KeyStatsStore::default(); + for _ in 0..50 { + store.update_key('a', 1000.0); + } + let conf = store.get_confidence('a'); + // target_time = 342.8ms, typing at 1000ms -> conf = 342.8/1000 = 0.34 + assert!(conf < 1.0, "confidence should be < 1.0 for slow typing, got {conf}"); + } +} diff --git a/src/engine/learning_rate.rs b/src/engine/learning_rate.rs new file mode 100644 index 0000000..39e55ba --- /dev/null +++ b/src/engine/learning_rate.rs @@ -0,0 +1,59 @@ +#[allow(dead_code)] +pub fn polynomial_regression(times: &[f64]) -> Option { + if times.len() < 3 { + return None; + } + + let n = times.len(); + let xs: Vec = (0..n).map(|i| i as f64).collect(); + + let x_mean: f64 = xs.iter().sum::() / n as f64; + let y_mean: f64 = times.iter().sum::() / n as f64; + + let mut ss_xy = 0.0; + let mut ss_xx = 0.0; + let mut ss_yy = 0.0; + + for i in 0..n { + let dx = xs[i] - x_mean; + let dy = times[i] - y_mean; + ss_xy += dx * dy; + ss_xx += dx * dx; + ss_yy += dy * dy; + } + + if ss_xx < 1e-10 || ss_yy < 1e-10 { + return None; + } + + let slope = ss_xy / ss_xx; + let r_squared = (ss_xy * ss_xy) / (ss_xx * ss_yy); + + if r_squared < 0.5 { + return None; + } + + let predicted_next = y_mean + slope * (n as f64 - x_mean); + Some(predicted_next.max(0.0)) +} + +#[allow(dead_code)] +pub fn learning_rate_description(times: &[f64]) -> &'static str { + match polynomial_regression(times) { + Some(predicted) => { + if times.is_empty() { + return "No data"; + } + let current = times.last().unwrap(); + let improvement = (current - predicted) / current * 100.0; + if improvement > 5.0 { + "Improving" + } else if improvement < -5.0 { + "Slowing down" + } else { + "Steady" + } + } + None => "Not enough data", + } +} diff --git a/src/engine/letter_unlock.rs b/src/engine/letter_unlock.rs new file mode 100644 index 0000000..2e7299d --- /dev/null +++ b/src/engine/letter_unlock.rs @@ -0,0 +1,151 @@ +use crate::engine::key_stats::KeyStatsStore; + +pub const FREQUENCY_ORDER: &[char] = &[ + 'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y', + 'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z', +]; + +const MIN_LETTERS: usize = 6; + +#[derive(Clone, Debug)] +pub struct LetterUnlock { + pub included: Vec, + pub focused: Option, +} + +impl LetterUnlock { + pub fn new() -> Self { + let included = FREQUENCY_ORDER[..MIN_LETTERS].to_vec(); + Self { + included, + focused: None, + } + } + + pub fn from_included(included: Vec) -> Self { + let mut lu = Self { + included, + focused: None, + }; + lu.focused = None; + lu + } + + pub fn update(&mut self, stats: &KeyStatsStore) { + let all_confident = self + .included + .iter() + .all(|&ch| stats.get_confidence(ch) >= 1.0); + + if all_confident { + for &letter in FREQUENCY_ORDER { + if !self.included.contains(&letter) { + self.included.push(letter); + break; + } + } + } + + while self.included.len() < MIN_LETTERS { + for &letter in FREQUENCY_ORDER { + if !self.included.contains(&letter) { + self.included.push(letter); + break; + } + } + } + + self.focused = self + .included + .iter() + .filter(|&&ch| stats.get_confidence(ch) < 1.0) + .min_by(|&&a, &&b| { + stats + .get_confidence(a) + .partial_cmp(&stats.get_confidence(b)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .copied(); + } + + #[allow(dead_code)] + pub fn is_unlocked(&self, ch: char) -> bool { + self.included.contains(&ch) + } + + pub fn unlocked_count(&self) -> usize { + self.included.len() + } + + pub fn total_letters(&self) -> usize { + FREQUENCY_ORDER.len() + } + + pub fn progress(&self) -> f64 { + self.unlocked_count() as f64 / self.total_letters() as f64 + } +} + +impl Default for LetterUnlock { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::key_stats::KeyStatsStore; + + #[test] + fn test_initial_unlock_has_min_letters() { + let lu = LetterUnlock::new(); + assert_eq!(lu.unlocked_count(), 6); + assert_eq!(&lu.included, &['e', 't', 'a', 'o', 'i', 'n']); + } + + #[test] + fn test_no_unlock_without_confidence() { + let mut lu = LetterUnlock::new(); + let stats = KeyStatsStore::default(); + lu.update(&stats); + assert_eq!(lu.unlocked_count(), 6); + } + + #[test] + fn test_unlock_when_all_confident() { + let mut lu = LetterUnlock::new(); + let mut stats = KeyStatsStore::default(); + // Make all included keys confident by typing fast + for &ch in &['e', 't', 'a', 'o', 'i', 'n'] { + for _ in 0..50 { + stats.update_key(ch, 200.0); + } + } + lu.update(&stats); + assert_eq!(lu.unlocked_count(), 7); + assert!(lu.included.contains(&'s')); + } + + #[test] + fn test_focused_key_is_weakest() { + let mut lu = LetterUnlock::new(); + let mut stats = KeyStatsStore::default(); + // Make most keys confident except 'o' + for &ch in &['e', 't', 'a', 'i', 'n'] { + for _ in 0..50 { + stats.update_key(ch, 200.0); + } + } + stats.update_key('o', 1000.0); // slow on 'o' + lu.update(&stats); + assert_eq!(lu.focused, Some('o')); + } + + #[test] + fn test_progress_ratio() { + let lu = LetterUnlock::new(); + let expected = 6.0 / 26.0; + assert!((lu.progress() - expected).abs() < 0.001); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs new file mode 100644 index 0000000..7e696f9 --- /dev/null +++ b/src/engine/mod.rs @@ -0,0 +1,5 @@ +pub mod filter; +pub mod key_stats; +pub mod learning_rate; +pub mod letter_unlock; +pub mod scoring; diff --git a/src/engine/scoring.rs b/src/engine/scoring.rs new file mode 100644 index 0000000..243f578 --- /dev/null +++ b/src/engine/scoring.rs @@ -0,0 +1,45 @@ +use crate::session::result::LessonResult; + +pub fn compute_score(result: &LessonResult, complexity: f64) -> f64 { + let speed = result.cpm; + let errors = result.incorrect as f64; + let length = result.total_chars as f64; + (speed * complexity) / (errors + 1.0) * (length / 50.0) +} + +pub fn compute_complexity(unlocked_count: usize) -> f64 { + (unlocked_count as f64 / 26.0).max(0.1) +} + +pub fn level_from_score(total_score: f64) -> u32 { + let level = (total_score / 100.0).sqrt() as u32; + level.max(1) +} + +#[allow(dead_code)] +pub fn score_to_next_level(total_score: f64) -> f64 { + let current_level = level_from_score(total_score); + let next_level_score = ((current_level + 1) as f64).powi(2) * 100.0; + next_level_score - total_score +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_level_starts_at_one() { + assert_eq!(level_from_score(0.0), 1); + } + + #[test] + fn test_level_increases_with_score() { + assert!(level_from_score(1000.0) > level_from_score(100.0)); + } + + #[test] + fn test_complexity_scales_with_letters() { + assert!(compute_complexity(26) > compute_complexity(6)); + assert!((compute_complexity(26) - 1.0).abs() < 0.001); + } +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..2be4f6f --- /dev/null +++ b/src/event.rs @@ -0,0 +1,49 @@ +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyEvent}; + +pub enum AppEvent { + Key(KeyEvent), + Tick, + Resize(#[allow(dead_code)] u16, #[allow(dead_code)] u16), +} + +pub struct EventHandler { + rx: mpsc::Receiver, + _tx: mpsc::Sender, +} + +impl EventHandler { + pub fn new(tick_rate: Duration) -> Self { + let (tx, rx) = mpsc::channel(); + let _tx = tx.clone(); + + thread::spawn(move || loop { + if event::poll(tick_rate).unwrap_or(false) { + match event::read() { + Ok(Event::Key(key)) => { + if tx.send(AppEvent::Key(key)).is_err() { + return; + } + } + Ok(Event::Resize(w, h)) => { + if tx.send(AppEvent::Resize(w, h)).is_err() { + return; + } + } + _ => {} + } + } else if tx.send(AppEvent::Tick).is_err() { + return; + } + }); + + Self { rx, _tx } + } + + pub fn next(&self) -> anyhow::Result { + Ok(self.rx.recv()?) + } +} diff --git a/src/generator/code_syntax.rs b/src/generator/code_syntax.rs new file mode 100644 index 0000000..74f36db --- /dev/null +++ b/src/generator/code_syntax.rs @@ -0,0 +1,122 @@ +use rand::rngs::SmallRng; +use rand::Rng; + +use crate::engine::filter::CharFilter; +use crate::generator::TextGenerator; + +pub struct CodeSyntaxGenerator { + rng: SmallRng, + language: String, +} + +impl CodeSyntaxGenerator { + pub fn new(rng: SmallRng, language: &str) -> Self { + Self { + rng, + language: language.to_string(), + } + } + + fn rust_snippets() -> Vec<&'static str> { + vec![ + "fn main() { println!(\"hello\"); }", + "let mut x = 0; x += 1;", + "for i in 0..10 { println!(\"{}\", i); }", + "if x > 0 { return true; }", + "match val { Some(x) => x, None => 0 }", + "struct Point { x: f64, y: f64 }", + "impl Point { fn new(x: f64, y: f64) -> Self { Self { x, y } } }", + "let v: Vec = vec![1, 2, 3];", + "fn add(a: i32, b: i32) -> i32 { a + b }", + "use std::collections::HashMap;", + "pub fn process(input: &str) -> Result { Ok(input.to_string()) }", + "let result = items.iter().filter(|x| x > &0).map(|x| x * 2).collect::>();", + "enum Color { Red, Green, Blue }", + "trait Display { fn show(&self) -> String; }", + "while let Some(item) = stack.pop() { process(item); }", + "#[derive(Debug, Clone)] struct Config { name: String, value: i32 }", + ] + } + + fn python_snippets() -> Vec<&'static str> { + vec![ + "def main(): print(\"hello\")", + "for i in range(10): print(i)", + "if x > 0: return True", + "class Point: def __init__(self, x, y): self.x = x", + "import os; path = os.path.join(\"a\", \"b\")", + "result = [x * 2 for x in items if x > 0]", + "with open(\"file.txt\") as f: data = f.read()", + "def add(a: int, b: int) -> int: return a + b", + "try: result = process(data) except ValueError as e: print(e)", + "from collections import defaultdict", + "lambda x: x * 2 + 1", + "dict_comp = {k: v for k, v in pairs.items()}", + ] + } + + fn javascript_snippets() -> Vec<&'static str> { + vec![ + "const x = 42; console.log(x);", + "function add(a, b) { return a + b; }", + "const arr = [1, 2, 3].map(x => x * 2);", + "if (x > 0) { return true; }", + "for (let i = 0; i < 10; i++) { console.log(i); }", + "class Point { constructor(x, y) { this.x = x; this.y = y; } }", + "const { name, age } = person;", + "async function fetch(url) { const res = await get(url); return res.json(); }", + "const obj = { ...defaults, ...overrides };", + "try { parse(data); } catch (e) { console.error(e); }", + "export default function handler(req, res) { res.send(\"ok\"); }", + "const result = items.filter(x => x > 0).reduce((a, b) => a + b, 0);", + ] + } + + fn go_snippets() -> Vec<&'static str> { + vec![ + "func main() { fmt.Println(\"hello\") }", + "for i := 0; i < 10; i++ { fmt.Println(i) }", + "if err != nil { return err }", + "type Point struct { X float64; Y float64 }", + "func add(a, b int) int { return a + b }", + "import \"fmt\"", + "result := make([]int, 0, 10)", + "switch val { case 1: return \"one\" default: return \"other\" }", + "go func() { ch <- result }()", + "defer file.Close()", + ] + } + + fn get_snippets(&self) -> Vec<&'static str> { + match self.language.as_str() { + "rust" => Self::rust_snippets(), + "python" => Self::python_snippets(), + "javascript" | "js" => Self::javascript_snippets(), + "go" => Self::go_snippets(), + _ => Self::rust_snippets(), + } + } +} + +impl TextGenerator for CodeSyntaxGenerator { + fn generate( + &mut self, + _filter: &CharFilter, + _focused: Option, + word_count: usize, + ) -> String { + let snippets = self.get_snippets(); + let mut result = Vec::new(); + let target_words = word_count; + let mut current_words = 0; + + while current_words < target_words { + let idx = self.rng.gen_range(0..snippets.len()); + let snippet = snippets[idx]; + current_words += snippet.split_whitespace().count(); + result.push(snippet); + } + + result.join(" ") + } +} diff --git a/src/generator/github_code.rs b/src/generator/github_code.rs new file mode 100644 index 0000000..a0dc3ac --- /dev/null +++ b/src/generator/github_code.rs @@ -0,0 +1,41 @@ +use crate::engine::filter::CharFilter; +use crate::generator::TextGenerator; + +#[allow(dead_code)] +pub struct GitHubCodeGenerator { + cached_snippets: Vec, + current_idx: usize, +} + +impl GitHubCodeGenerator { + #[allow(dead_code)] + pub fn new() -> Self { + Self { + cached_snippets: Vec::new(), + current_idx: 0, + } + } +} + +impl Default for GitHubCodeGenerator { + fn default() -> Self { + Self::new() + } +} + +impl TextGenerator for GitHubCodeGenerator { + fn generate( + &mut self, + _filter: &CharFilter, + _focused: Option, + _word_count: usize, + ) -> String { + if self.cached_snippets.is_empty() { + return "// GitHub code fetching not yet configured. Use settings to add a repository." + .to_string(); + } + let snippet = self.cached_snippets[self.current_idx % self.cached_snippets.len()].clone(); + self.current_idx += 1; + snippet + } +} diff --git a/src/generator/mod.rs b/src/generator/mod.rs new file mode 100644 index 0000000..dbedc73 --- /dev/null +++ b/src/generator/mod.rs @@ -0,0 +1,12 @@ +pub mod code_syntax; +pub mod github_code; +pub mod passage; +pub mod phonetic; +pub mod transition_table; + +use crate::engine::filter::CharFilter; + +pub trait TextGenerator { + fn generate(&mut self, filter: &CharFilter, focused: Option, word_count: usize) + -> String; +} diff --git a/src/generator/passage.rs b/src/generator/passage.rs new file mode 100644 index 0000000..45ee574 --- /dev/null +++ b/src/generator/passage.rs @@ -0,0 +1,49 @@ +use crate::engine::filter::CharFilter; +use crate::generator::TextGenerator; + +const PASSAGES: &[&str] = &[ + "the quick brown fox jumps over the lazy dog and then runs across the field while the sun sets behind the distant hills", + "it was the best of times it was the worst of times it was the age of wisdom it was the age of foolishness", + "in the beginning there was nothing but darkness and then the light appeared slowly spreading across the vast empty space", + "she walked along the narrow path through the forest listening to the birds singing in the trees above her head", + "the old man sat on the bench watching the children play in the park while the autumn leaves fell softly around him", + "there is nothing either good or bad but thinking makes it so for the mind is its own place and in itself can make a heaven of hell", + "to be or not to be that is the question whether it is nobler in the mind to suffer the slings and arrows of outrageous fortune", + "all that glitters is not gold and not all those who wander are lost for the old that is strong does not wither", + "the river flowed quietly through the green valley and the mountains rose high on either side covered with trees and snow", + "a long time ago in a land far away there lived a wise king who ruled his people with kindness and justice", + "the rain fell steadily on the roof making a soft drumming sound that filled the room and made everything feel calm", + "she opened the door and stepped outside into the cool morning air breathing deeply as the first light of dawn appeared", + "he picked up the book and began to read turning the pages slowly as the story drew him deeper and deeper into its world", + "the stars shone brightly in the clear night sky and the moon cast a silver light over the sleeping town below", + "they gathered around the fire telling stories and laughing while the wind howled outside and the snow piled up against the door", +]; + +pub struct PassageGenerator { + current_idx: usize, +} + +impl PassageGenerator { + pub fn new() -> Self { + Self { current_idx: 0 } + } +} + +impl Default for PassageGenerator { + fn default() -> Self { + Self::new() + } +} + +impl TextGenerator for PassageGenerator { + fn generate( + &mut self, + _filter: &CharFilter, + _focused: Option, + _word_count: usize, + ) -> String { + let passage = PASSAGES[self.current_idx % PASSAGES.len()]; + self.current_idx += 1; + passage.to_string() + } +} diff --git a/src/generator/phonetic.rs b/src/generator/phonetic.rs new file mode 100644 index 0000000..493824e --- /dev/null +++ b/src/generator/phonetic.rs @@ -0,0 +1,169 @@ +use rand::rngs::SmallRng; +use rand::Rng; + +use crate::engine::filter::CharFilter; +use crate::generator::transition_table::TransitionTable; +use crate::generator::TextGenerator; + +pub struct PhoneticGenerator { + table: TransitionTable, + rng: SmallRng, +} + +impl PhoneticGenerator { + pub fn new(table: TransitionTable, rng: SmallRng) -> Self { + Self { table, rng } + } + + fn pick_weighted_from( + rng: &mut SmallRng, + options: &[(char, f64)], + filter: &CharFilter, + ) -> Option { + let filtered: Vec<(char, f64)> = options + .iter() + .filter(|(ch, _)| filter.is_allowed(*ch)) + .copied() + .collect(); + + if filtered.is_empty() { + return None; + } + + let total: f64 = filtered.iter().map(|(_, w)| w).sum(); + if total <= 0.0 { + return None; + } + + let mut roll = rng.gen_range(0.0..total); + for (ch, weight) in &filtered { + roll -= weight; + if roll <= 0.0 { + return Some(*ch); + } + } + + Some(filtered.last().unwrap().0) + } + + fn generate_word(&mut self, filter: &CharFilter, focused: Option) -> String { + let min_len = 3; + let max_len = 10; + let mut word = String::new(); + + let start_char = if let Some(focus) = focused { + if self.rng.gen_bool(0.4) { + let probs = self.table.get_next_probs(' ', focus).cloned(); + if let Some(probs) = probs { + let filtered: Vec<(char, f64)> = probs + .iter() + .filter(|(ch, _)| filter.is_allowed(*ch)) + .copied() + .collect(); + if !filtered.is_empty() { + word.push(focus); + Self::pick_weighted_from(&mut self.rng, &filtered, filter) + } else { + None + } + } else { + Some(focus) + } + } else { + None + } + } else { + None + }; + + if word.is_empty() { + let starters: Vec<(char, f64)> = filter + .allowed + .iter() + .map(|&ch| { + ( + ch, + if ch == 'e' || ch == 't' || ch == 'a' { + 3.0 + } else { + 1.0 + }, + ) + }) + .collect(); + + if let Some(ch) = Self::pick_weighted_from(&mut self.rng, &starters, filter) { + word.push(ch); + } else { + return "the".to_string(); + } + } + + if let Some(ch) = start_char { + word.push(ch); + } + + while word.len() < max_len { + let chars: Vec = word.chars().collect(); + let len = chars.len(); + + let (prev, curr) = if len >= 2 { + (chars[len - 2], chars[len - 1]) + } else { + (' ', chars[len - 1]) + }; + + let space_prob = 1.3f64.powi(word.len() as i32 - min_len as i32); + if word.len() >= min_len + && self + .rng + .gen_bool((space_prob / (space_prob + 5.0)).min(0.8)) + { + break; + } + + let probs = self.table.get_next_probs(prev, curr).cloned(); + if let Some(probs) = probs { + if let Some(next) = Self::pick_weighted_from(&mut self.rng, &probs, filter) { + word.push(next); + } else { + break; + } + } else { + let vowels: Vec<(char, f64)> = ['a', 'e', 'i', 'o', 'u'] + .iter() + .filter(|&&v| filter.is_allowed(v)) + .map(|&v| (v, 1.0)) + .collect(); + if let Some(v) = Self::pick_weighted_from(&mut self.rng, &vowels, filter) { + word.push(v); + } else { + break; + } + } + } + + if word.is_empty() { + "the".to_string() + } else { + word + } + } +} + +impl TextGenerator for PhoneticGenerator { + fn generate( + &mut self, + filter: &CharFilter, + focused: Option, + word_count: usize, + ) -> String { + let mut words: Vec = Vec::new(); + + for _ in 0..word_count { + words.push(self.generate_word(filter, focused)); + } + + words.join(" ") + } +} diff --git a/src/generator/transition_table.rs b/src/generator/transition_table.rs new file mode 100644 index 0000000..09d8d50 --- /dev/null +++ b/src/generator/transition_table.rs @@ -0,0 +1,98 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TransitionTable { + pub transitions: HashMap<(char, char), Vec<(char, f64)>>, +} + +impl TransitionTable { + pub fn new() -> Self { + Self { + transitions: HashMap::new(), + } + } + + pub fn add(&mut self, prev: char, curr: char, next: char, weight: f64) { + self.transitions + .entry((prev, curr)) + .or_default() + .push((next, weight)); + } + + pub fn get_next_probs(&self, prev: char, curr: char) -> Option<&Vec<(char, f64)>> { + self.transitions.get(&(prev, curr)) + } + + pub fn build_english() -> Self { + let mut table = Self::new(); + + let common_patterns: &[(&str, f64)] = &[ + ("the", 10.0), ("and", 8.0), ("ing", 7.0), ("tion", 6.0), ("ent", 5.0), + ("ion", 5.0), ("her", 4.0), ("for", 4.0), ("are", 4.0), ("his", 4.0), + ("hat", 3.0), ("tha", 3.0), ("ere", 3.0), ("ate", 3.0), ("ith", 3.0), + ("ver", 3.0), ("all", 3.0), ("not", 3.0), ("ess", 3.0), ("est", 3.0), + ("rea", 3.0), ("sta", 3.0), ("ted", 3.0), ("com", 3.0), ("con", 3.0), + ("oun", 2.5), ("pro", 2.5), ("oth", 2.5), ("igh", 2.5), ("ore", 2.5), + ("our", 2.5), ("ine", 2.5), ("ove", 2.5), ("ome", 2.5), ("use", 2.5), + ("ble", 2.0), ("ful", 2.0), ("ous", 2.0), ("str", 2.0), ("tri", 2.0), + ("ght", 2.0), ("whi", 2.0), ("who", 2.0), ("hen", 2.0), ("ter", 2.0), + ("man", 2.0), ("men", 2.0), ("ner", 2.0), ("per", 2.0), ("pre", 2.0), + ("ran", 2.0), ("lin", 2.0), ("kin", 2.0), ("din", 2.0), ("sin", 2.0), + ("out", 2.0), ("ind", 2.0), ("ith", 2.0), ("ber", 2.0), ("der", 2.0), + ("end", 2.0), ("hin", 2.0), ("old", 2.0), ("ear", 2.0), ("ain", 2.0), + ("ant", 2.0), ("urn", 2.0), ("ell", 2.0), ("ill", 2.0), ("ade", 2.0), + ("igh", 2.0), ("ong", 2.0), ("ung", 2.0), ("ast", 2.0), ("ist", 2.0), + ("ust", 2.0), ("ost", 2.0), ("ard", 2.0), ("ord", 2.0), ("art", 2.0), + ("ort", 2.0), ("ect", 2.0), ("act", 2.0), ("ack", 2.0), ("ick", 2.0), + ("ock", 2.0), ("uck", 2.0), ("ash", 2.0), ("ish", 2.0), ("ush", 2.0), + ("anc", 1.5), ("enc", 1.5), ("inc", 1.5), ("onc", 1.5), ("unc", 1.5), + ("unt", 1.5), ("int", 1.5), ("ont", 1.5), ("ent", 1.5), ("ment", 1.5), + ("ness", 1.5), ("less", 1.5), ("able", 1.5), ("ible", 1.5), ("ting", 1.5), + ("ring", 1.5), ("sing", 1.5), ("king", 1.5), ("ning", 1.5), ("ling", 1.5), + ("wing", 1.5), ("ding", 1.5), ("ping", 1.5), ("ging", 1.5), ("ving", 1.5), + ("bing", 1.5), ("ming", 1.5), ("fing", 1.0), ("hing", 1.0), ("cing", 1.0), + ]; + + for &(pattern, weight) in common_patterns { + let chars: Vec = pattern.chars().collect(); + for window in chars.windows(3) { + table.add(window[0], window[1], window[2], weight); + } + } + + let vowels = ['a', 'e', 'i', 'o', 'u']; + let consonants = [ + 'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v', + 'w', 'x', 'y', 'z', + ]; + + for &c in &consonants { + for &v in &vowels { + table.add(' ', c, v, 1.0); + table.add(v, c, 'e', 0.5); + for &v2 in &vowels { + table.add(c, v, v2.to_ascii_lowercase(), 0.3); + } + for &c2 in &consonants { + table.add(v, c, c2, 0.2); + } + } + } + + for &v in &vowels { + for &c in &consonants { + table.add(' ', v, c, 0.5); + } + } + + table + } +} + +impl Default for TransitionTable { + fn default() -> Self { + Self::new() + } +} diff --git a/src/keyboard/finger.rs b/src/keyboard/finger.rs new file mode 100644 index 0000000..2d36771 --- /dev/null +++ b/src/keyboard/finger.rs @@ -0,0 +1,50 @@ +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Hand { + Left, + Right, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Finger { + Pinky, + Ring, + Middle, + Index, + Thumb, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FingerAssignment { + pub hand: Hand, + pub finger: Finger, +} + +impl FingerAssignment { + pub fn new(hand: Hand, finger: Finger) -> Self { + Self { hand, finger } + } +} + +#[allow(dead_code)] +pub fn qwerty_finger(ch: char) -> FingerAssignment { + use Finger::*; + use Hand::*; + + match ch { + 'q' | 'a' | 'z' | '1' => FingerAssignment::new(Left, Pinky), + 'w' | 's' | 'x' | '2' => FingerAssignment::new(Left, Ring), + 'e' | 'd' | 'c' | '3' => FingerAssignment::new(Left, Middle), + 'r' | 'f' | 'v' | 't' | 'g' | 'b' | '4' | '5' => FingerAssignment::new(Left, Index), + 'y' | 'h' | 'n' | 'u' | 'j' | 'm' | '6' | '7' => FingerAssignment::new(Right, Index), + 'i' | 'k' | ',' | '8' => FingerAssignment::new(Right, Middle), + 'o' | 'l' | '.' | '9' => FingerAssignment::new(Right, Ring), + 'p' | ';' | '/' | '0' | '-' | '=' | '[' | ']' | '\'' | '\\' => { + FingerAssignment::new(Right, Pinky) + } + ' ' => FingerAssignment::new(Right, Thumb), + _ => FingerAssignment::new(Right, Index), + } +} diff --git a/src/keyboard/layout.rs b/src/keyboard/layout.rs new file mode 100644 index 0000000..6d7d8b9 --- /dev/null +++ b/src/keyboard/layout.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +#[allow(dead_code)] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct KeyboardLayout { + pub name: String, + pub rows: Vec>, +} + +impl KeyboardLayout { + pub fn qwerty() -> Self { + Self { + name: "QWERTY".to_string(), + rows: vec![ + vec!['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], + vec!['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'], + vec!['z', 'x', 'c', 'v', 'b', 'n', 'm'], + ], + } + } + + #[allow(dead_code)] + pub fn dvorak() -> Self { + Self { + name: "Dvorak".to_string(), + rows: vec![ + vec!['\'', ',', '.', 'p', 'y', 'f', 'g', 'c', 'r', 'l'], + vec!['a', 'o', 'e', 'u', 'i', 'd', 'h', 't', 'n', 's'], + vec![';', 'q', 'j', 'k', 'x', 'b', 'm', 'w', 'v', 'z'], + ], + } + } + + #[allow(dead_code)] + pub fn colemak() -> Self { + Self { + name: "Colemak".to_string(), + rows: vec![ + vec!['q', 'w', 'f', 'p', 'g', 'j', 'l', 'u', 'y'], + vec!['a', 'r', 's', 't', 'd', 'h', 'n', 'e', 'i', 'o'], + vec!['z', 'x', 'c', 'v', 'b', 'k', 'm'], + ], + } + } +} + +impl Default for KeyboardLayout { + fn default() -> Self { + Self::qwerty() + } +} diff --git a/src/keyboard/mod.rs b/src/keyboard/mod.rs new file mode 100644 index 0000000..6c160fa --- /dev/null +++ b/src/keyboard/mod.rs @@ -0,0 +1,2 @@ +pub mod finger; +pub mod layout; diff --git a/src/main.rs b/src/main.rs index e7a11a9..76d048d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,409 @@ -fn main() { - println!("Hello, world!"); +mod app; +mod config; +mod engine; +mod event; +mod generator; +mod keyboard; +mod session; +mod store; +mod ui; + +use std::io; +use std::time::Duration; + +use anyhow::Result; +use clap::Parser; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; +use ratatui::Terminal; + +use app::{App, AppScreen, LessonMode}; +use session::result::LessonResult; +use event::{AppEvent, EventHandler}; +use ui::components::dashboard::Dashboard; +use ui::components::keyboard_diagram::KeyboardDiagram; +use ui::components::progress_bar::ProgressBar; +use ui::components::stats_dashboard::StatsDashboard; +use ui::components::stats_sidebar::StatsSidebar; +use ui::components::typing_area::TypingArea; +use ui::layout::AppLayout; + +#[derive(Parser)] +#[command(name = "keydr", version, about = "Terminal typing tutor with adaptive learning")] +struct Cli { + #[arg(short, long, help = "Theme name")] + theme: Option, + + #[arg(short, long, help = "Keyboard layout (qwerty, dvorak, colemak)")] + layout: Option, + + #[arg(short, long, help = "Number of words per lesson")] + words: Option, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let mut app = App::new(); + + if let Some(words) = cli.words { + app.config.word_count = words; + } + if let Some(theme_name) = cli.theme { + if let Some(theme) = ui::theme::Theme::load(&theme_name) { + let theme: &'static ui::theme::Theme = Box::leak(Box::new(theme)); + app.theme = theme; + app.menu.theme = theme; + } + } + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let events = EventHandler::new(Duration::from_millis(100)); + + let result = run_app(&mut terminal, &mut app, &events); + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + if let Err(err) = result { + eprintln!("Error: {err:?}"); + } + + Ok(()) +} + +fn run_app( + terminal: &mut Terminal>, + app: &mut App, + events: &EventHandler, +) -> Result<()> { + loop { + terminal.draw(|frame| render(frame, app))?; + + match events.next()? { + AppEvent::Key(key) => handle_key(app, key), + AppEvent::Tick => {} + AppEvent::Resize(_, _) => {} + } + + if app.should_quit { + return Ok(()); + } + } +} + +fn handle_key(app: &mut App, key: KeyEvent) { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + app.should_quit = true; + return; + } + + match app.screen { + AppScreen::Menu => handle_menu_key(app, key), + AppScreen::Lesson => handle_lesson_key(app, key), + AppScreen::LessonResult => handle_result_key(app, key), + AppScreen::StatsDashboard => handle_stats_key(app, key), + AppScreen::Settings => handle_settings_key(app, key), + } +} + +fn handle_menu_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, + KeyCode::Char('1') => { + app.lesson_mode = LessonMode::Adaptive; + app.start_lesson(); + } + KeyCode::Char('2') => { + app.lesson_mode = LessonMode::Code; + app.start_lesson(); + } + KeyCode::Char('3') => { + app.lesson_mode = LessonMode::Passage; + app.start_lesson(); + } + KeyCode::Char('s') => app.go_to_stats(), + KeyCode::Up | KeyCode::Char('k') => app.menu.prev(), + KeyCode::Down | KeyCode::Char('j') => app.menu.next(), + KeyCode::Enter => match app.menu.selected { + 0 => { + app.lesson_mode = LessonMode::Adaptive; + app.start_lesson(); + } + 1 => { + app.lesson_mode = LessonMode::Code; + app.start_lesson(); + } + 2 => { + app.lesson_mode = LessonMode::Passage; + app.start_lesson(); + } + 3 => app.go_to_stats(), + _ => {} + }, + _ => {} + } +} + +fn handle_lesson_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + let has_progress = app.lesson.as_ref().is_some_and(|l| l.cursor > 0); + if has_progress { + if let Some(ref lesson) = app.lesson { + let result = LessonResult::from_lesson(lesson, &app.lesson_events); + app.last_result = Some(result); + } + app.screen = AppScreen::LessonResult; + } else { + app.go_to_menu(); + } + } + KeyCode::Backspace => app.backspace(), + KeyCode::Char(ch) => app.type_char(ch), + _ => {} + } +} + +fn handle_result_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Char('r') => app.retry_lesson(), + KeyCode::Char('q') | KeyCode::Esc => app.go_to_menu(), + KeyCode::Char('s') => app.go_to_stats(), + _ => {} + } +} + +fn handle_stats_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), + _ => {} + } +} + +fn handle_settings_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Esc => app.go_to_menu(), + _ => {} + } +} + +fn render(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let colors = &app.theme.colors; + + let bg = Block::default().style(Style::default().bg(colors.bg())); + frame.render_widget(bg, area); + + match app.screen { + AppScreen::Menu => render_menu(frame, app), + AppScreen::Lesson => render_lesson(frame, app), + AppScreen::LessonResult => render_result(frame, app), + AppScreen::StatsDashboard => render_stats(frame, app), + AppScreen::Settings => render_settings(frame, app), + } +} + +fn render_menu(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let colors = &app.theme.colors; + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + + let streak_text = if app.profile.streak_days > 0 { + format!(" | {} day streak", app.profile.streak_days) + } else { + String::new() + }; + let header_info = format!( + " Level {} | Score {:.0} | {}/{} letters{}", + crate::engine::scoring::level_from_score(app.profile.total_score), + app.profile.total_score, + app.letter_unlock.unlocked_count(), + app.letter_unlock.total_letters(), + streak_text, + ); + let header = Paragraph::new(Line::from(vec![ + Span::styled( + " keydr ", + Style::default() + .fg(colors.header_fg()) + .bg(colors.header_bg()) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + &*header_info, + Style::default() + .fg(colors.text_pending()) + .bg(colors.header_bg()), + ), + ])) + .style(Style::default().bg(colors.header_bg())); + frame.render_widget(header, layout[0]); + + let menu_area = ui::layout::centered_rect(50, 80, layout[1]); + frame.render_widget(&app.menu, menu_area); + + let footer = Paragraph::new(Line::from(vec![Span::styled( + " [1-3] Start [s] Stats [q] Quit ", + Style::default().fg(colors.text_pending()), + )])); + frame.render_widget(footer, layout[2]); +} + +fn render_lesson(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let colors = &app.theme.colors; + + if let Some(ref lesson) = app.lesson { + let app_layout = AppLayout::new(area); + + let mode_name = match app.lesson_mode { + LessonMode::Adaptive => "Adaptive", + LessonMode::Code => "Code", + LessonMode::Passage => "Passage", + }; + let header_title = format!(" {mode_name} Practice "); + let focus_text = if let Some(focused) = app.letter_unlock.focused { + format!(" | Focus: '{focused}'") + } else { + String::new() + }; + let header = Paragraph::new(Line::from(vec![ + Span::styled( + &*header_title, + Style::default() + .fg(colors.header_fg()) + .bg(colors.header_bg()) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + &*focus_text, + Style::default() + .fg(colors.focused_key()) + .bg(colors.header_bg()), + ), + ])) + .style(Style::default().bg(colors.header_bg())); + frame.render_widget(header, app_layout.header); + + let main_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(5), + Constraint::Length(3), + Constraint::Length(4), + ]) + .split(app_layout.main); + + let typing = TypingArea::new(lesson, app.theme); + frame.render_widget(typing, main_layout[0]); + + let progress = ProgressBar::new( + "Letter Progress", + app.letter_unlock.progress(), + app.theme, + ); + frame.render_widget(progress, main_layout[1]); + + let kbd = KeyboardDiagram::new( + app.letter_unlock.focused, + &app.letter_unlock.included, + app.theme, + ); + frame.render_widget(kbd, main_layout[2]); + + let sidebar = StatsSidebar::new(lesson, app.theme); + frame.render_widget(sidebar, app_layout.sidebar); + + let footer = Paragraph::new(Line::from(Span::styled( + " [ESC] End lesson [Backspace] Delete ", + Style::default().fg(colors.text_pending()), + ))); + frame.render_widget(footer, app_layout.footer); + } +} + +fn render_result(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + + if let Some(ref result) = app.last_result { + let centered = ui::layout::centered_rect(60, 70, area); + let dashboard = Dashboard::new(result, app.theme); + frame.render_widget(dashboard, centered); + } +} + +fn render_stats(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let dashboard = StatsDashboard::new(&app.lesson_history, app.theme); + frame.render_widget(dashboard, area); +} + +fn render_settings(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let colors = &app.theme.colors; + + let block = Block::bordered() + .title(" Settings ") + .border_style(Style::default().fg(colors.accent())); + + let target_wpm = format!(" Target WPM: {}", app.config.target_wpm); + let theme_name = format!(" Theme: {}", app.config.theme); + let layout_name = format!(" Layout: {}", app.config.keyboard_layout); + let languages = format!(" Languages: {}", app.config.code_languages.join(", ")); + + let lines = vec![ + Line::from(""), + Line::from(Span::styled( + " Settings coming soon...", + Style::default().fg(colors.text_pending()), + )), + Line::from(""), + Line::from(Span::styled( + &*target_wpm, + Style::default().fg(colors.fg()), + )), + Line::from(Span::styled( + &*theme_name, + Style::default().fg(colors.fg()), + )), + Line::from(Span::styled( + &*layout_name, + Style::default().fg(colors.fg()), + )), + Line::from(Span::styled( + &*languages, + Style::default().fg(colors.fg()), + )), + Line::from(""), + Line::from(Span::styled( + " [ESC] Back", + Style::default().fg(colors.accent()), + )), + ]; + + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, area); } diff --git a/src/session/input.rs b/src/session/input.rs new file mode 100644 index 0000000..c474a55 --- /dev/null +++ b/src/session/input.rs @@ -0,0 +1,58 @@ +use std::time::Instant; + +use crate::session::lesson::LessonState; + +#[derive(Clone, Debug)] +pub enum CharStatus { + Correct, + Incorrect(char), +} + +#[derive(Clone, Debug)] +pub struct KeystrokeEvent { + pub expected: char, + #[allow(dead_code)] + pub actual: char, + pub timestamp: Instant, + pub correct: bool, +} + +pub fn process_char(lesson: &mut LessonState, ch: char) -> Option { + if lesson.is_complete() { + return None; + } + + if lesson.started_at.is_none() { + lesson.started_at = Some(Instant::now()); + } + + let expected = lesson.target[lesson.cursor]; + let correct = ch == expected; + + let event = KeystrokeEvent { + expected, + actual: ch, + timestamp: Instant::now(), + correct, + }; + + if correct { + lesson.input.push(CharStatus::Correct); + } else { + lesson.input.push(CharStatus::Incorrect(ch)); + } + lesson.cursor += 1; + + if lesson.is_complete() { + lesson.finished_at = Some(Instant::now()); + } + + Some(event) +} + +pub fn process_backspace(lesson: &mut LessonState) { + if lesson.cursor > 0 { + lesson.cursor -= 1; + lesson.input.pop(); + } +} diff --git a/src/session/lesson.rs b/src/session/lesson.rs new file mode 100644 index 0000000..67f6a7a --- /dev/null +++ b/src/session/lesson.rs @@ -0,0 +1,108 @@ +use std::time::Instant; + +use crate::session::input::CharStatus; + +pub struct LessonState { + pub target: Vec, + pub input: Vec, + pub cursor: usize, + pub started_at: Option, + pub finished_at: Option, +} + +impl LessonState { + pub fn new(text: &str) -> Self { + Self { + target: text.chars().collect(), + input: Vec::new(), + cursor: 0, + started_at: None, + finished_at: None, + } + } + + pub fn is_complete(&self) -> bool { + self.cursor >= self.target.len() + } + + pub fn elapsed_secs(&self) -> f64 { + match (self.started_at, self.finished_at) { + (Some(start), Some(end)) => end.duration_since(start).as_secs_f64(), + (Some(start), None) => start.elapsed().as_secs_f64(), + _ => 0.0, + } + } + + pub fn correct_count(&self) -> usize { + self.input + .iter() + .filter(|s| matches!(s, CharStatus::Correct)) + .count() + } + + pub fn incorrect_count(&self) -> usize { + self.input + .iter() + .filter(|s| matches!(s, CharStatus::Incorrect(_))) + .count() + } + + pub fn wpm(&self) -> f64 { + let elapsed = self.elapsed_secs(); + if elapsed < 0.1 { + return 0.0; + } + let chars = self.correct_count() as f64; + (chars / 5.0) / (elapsed / 60.0) + } + + pub fn accuracy(&self) -> f64 { + let total = self.input.len(); + if total == 0 { + return 100.0; + } + (self.correct_count() as f64 / total as f64) * 100.0 + } + + pub fn cpm(&self) -> f64 { + let elapsed = self.elapsed_secs(); + if elapsed < 0.1 { + return 0.0; + } + self.correct_count() as f64 / (elapsed / 60.0) + } + + pub fn progress(&self) -> f64 { + if self.target.is_empty() { + return 0.0; + } + self.cursor as f64 / self.target.len() as f64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_lesson() { + let lesson = LessonState::new("hello"); + assert_eq!(lesson.target.len(), 5); + assert_eq!(lesson.cursor, 0); + assert!(!lesson.is_complete()); + assert_eq!(lesson.progress(), 0.0); + } + + #[test] + fn test_accuracy_starts_at_100() { + let lesson = LessonState::new("test"); + assert_eq!(lesson.accuracy(), 100.0); + } + + #[test] + fn test_empty_lesson_progress() { + let lesson = LessonState::new(""); + assert!(lesson.is_complete()); + assert_eq!(lesson.progress(), 0.0); + } +} diff --git a/src/session/mod.rs b/src/session/mod.rs new file mode 100644 index 0000000..2e4a99d --- /dev/null +++ b/src/session/mod.rs @@ -0,0 +1,3 @@ +pub mod input; +pub mod lesson; +pub mod result; diff --git a/src/session/result.rs b/src/session/result.rs new file mode 100644 index 0000000..8624a36 --- /dev/null +++ b/src/session/result.rs @@ -0,0 +1,53 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::session::input::KeystrokeEvent; +use crate::session::lesson::LessonState; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LessonResult { + pub wpm: f64, + pub cpm: f64, + pub accuracy: f64, + pub correct: usize, + pub incorrect: usize, + pub total_chars: usize, + pub elapsed_secs: f64, + pub timestamp: DateTime, + pub per_key_times: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct KeyTime { + pub key: char, + pub time_ms: f64, + pub correct: bool, +} + +impl LessonResult { + pub fn from_lesson(lesson: &LessonState, events: &[KeystrokeEvent]) -> Self { + let per_key_times: Vec = events + .windows(2) + .map(|pair| { + let dt = pair[1].timestamp.duration_since(pair[0].timestamp); + KeyTime { + key: pair[1].expected, + time_ms: dt.as_secs_f64() * 1000.0, + correct: pair[1].correct, + } + }) + .collect(); + + Self { + wpm: lesson.wpm(), + cpm: lesson.cpm(), + accuracy: lesson.accuracy(), + correct: lesson.correct_count(), + incorrect: lesson.incorrect_count(), + total_chars: lesson.target.len(), + elapsed_secs: lesson.elapsed_secs(), + timestamp: Utc::now(), + per_key_times, + } + } +} diff --git a/src/store/json_store.rs b/src/store/json_store.rs new file mode 100644 index 0000000..f6e662c --- /dev/null +++ b/src/store/json_store.rs @@ -0,0 +1,75 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +use anyhow::Result; +use serde::{de::DeserializeOwned, Serialize}; + +use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData}; + +pub struct JsonStore { + base_dir: PathBuf, +} + +impl JsonStore { + pub fn new() -> Result { + let base_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("keydr"); + fs::create_dir_all(&base_dir)?; + Ok(Self { base_dir }) + } + + fn file_path(&self, name: &str) -> PathBuf { + self.base_dir.join(name) + } + + fn load(&self, name: &str) -> T { + let path = self.file_path(name); + if path.exists() { + match fs::read_to_string(&path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => T::default(), + } + } else { + T::default() + } + } + + fn save(&self, name: &str, data: &T) -> Result<()> { + let path = self.file_path(name); + let tmp_path = path.with_extension("tmp"); + + let json = serde_json::to_string_pretty(data)?; + let mut file = fs::File::create(&tmp_path)?; + file.write_all(json.as_bytes())?; + file.sync_all()?; + + fs::rename(&tmp_path, &path)?; + Ok(()) + } + + pub fn load_profile(&self) -> ProfileData { + self.load("profile.json") + } + + pub fn save_profile(&self, data: &ProfileData) -> Result<()> { + self.save("profile.json", data) + } + + pub fn load_key_stats(&self) -> KeyStatsData { + self.load("key_stats.json") + } + + pub fn save_key_stats(&self, data: &KeyStatsData) -> Result<()> { + self.save("key_stats.json", data) + } + + pub fn load_lesson_history(&self) -> LessonHistoryData { + self.load("lesson_history.json") + } + + pub fn save_lesson_history(&self, data: &LessonHistoryData) -> Result<()> { + self.save("lesson_history.json", data) + } +} diff --git a/src/store/mod.rs b/src/store/mod.rs new file mode 100644 index 0000000..8639bfb --- /dev/null +++ b/src/store/mod.rs @@ -0,0 +1,2 @@ +pub mod json_store; +pub mod schema; diff --git a/src/store/schema.rs b/src/store/schema.rs new file mode 100644 index 0000000..b8d0f0b --- /dev/null +++ b/src/store/schema.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +use crate::engine::key_stats::KeyStatsStore; +use crate::session::result::LessonResult; + +const SCHEMA_VERSION: u32 = 1; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ProfileData { + pub schema_version: u32, + pub unlocked_letters: Vec, + pub total_score: f64, + pub total_lessons: u32, + pub streak_days: u32, + pub best_streak: u32, + pub last_practice_date: Option, +} + +impl Default for ProfileData { + fn default() -> Self { + Self { + schema_version: SCHEMA_VERSION, + unlocked_letters: Vec::new(), + total_score: 0.0, + total_lessons: 0, + streak_days: 0, + best_streak: 0, + last_practice_date: None, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct KeyStatsData { + pub schema_version: u32, + pub stats: KeyStatsStore, +} + +impl Default for KeyStatsData { + fn default() -> Self { + Self { + schema_version: SCHEMA_VERSION, + stats: KeyStatsStore::default(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LessonHistoryData { + pub schema_version: u32, + pub lessons: Vec, +} + +impl Default for LessonHistoryData { + fn default() -> Self { + Self { + schema_version: SCHEMA_VERSION, + lessons: Vec::new(), + } + } +} diff --git a/src/ui/components/chart.rs b/src/ui/components/chart.rs new file mode 100644 index 0000000..62c9eda --- /dev/null +++ b/src/ui/components/chart.rs @@ -0,0 +1,67 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::symbols; +use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget}; + +use crate::ui::theme::Theme; + +pub struct WpmChart<'a> { + pub data: &'a [(f64, f64)], + pub theme: &'a Theme, +} + +impl<'a> WpmChart<'a> { + pub fn new(data: &'a [(f64, f64)], theme: &'a Theme) -> Self { + Self { data, theme } + } +} + +impl Widget for WpmChart<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let colors = &self.theme.colors; + + if self.data.is_empty() { + let block = Block::bordered() + .title(" WPM Over Time ") + .border_style(Style::default().fg(colors.border())); + block.render(area, buf); + return; + } + + let max_x = self.data.last().map(|(x, _)| *x).unwrap_or(1.0); + let max_y = self + .data + .iter() + .map(|(_, y)| *y) + .fold(0.0f64, f64::max) + .max(10.0); + + let dataset = Dataset::default() + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(colors.accent())) + .data(self.data); + + let chart = Chart::new(vec![dataset]) + .block( + Block::bordered() + .title(" WPM Over Time ") + .border_style(Style::default().fg(colors.border())), + ) + .x_axis( + Axis::default() + .title("Lesson") + .style(Style::default().fg(colors.text_pending())) + .bounds([0.0, max_x]), + ) + .y_axis( + Axis::default() + .title("WPM") + .style(Style::default().fg(colors.text_pending())) + .bounds([0.0, max_y * 1.1]), + ); + + chart.render(area, buf); + } +} diff --git a/src/ui/components/dashboard.rs b/src/ui/components/dashboard.rs new file mode 100644 index 0000000..8beb136 --- /dev/null +++ b/src/ui/components/dashboard.rs @@ -0,0 +1,118 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Widget}; + +use crate::session::result::LessonResult; +use crate::ui::theme::Theme; + +pub struct Dashboard<'a> { + pub result: &'a LessonResult, + pub theme: &'a Theme, +} + +impl<'a> Dashboard<'a> { + pub fn new(result: &'a LessonResult, theme: &'a Theme) -> Self { + Self { result, theme } + } +} + +impl Widget for Dashboard<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let colors = &self.theme.colors; + + let block = Block::bordered() + .title(" Lesson Complete ") + .border_style(Style::default().fg(colors.accent())) + .style(Style::default().bg(colors.bg())); + let inner = block.inner(area); + block.render(area, buf); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(2), + ]) + .split(inner); + + let title = Paragraph::new(Line::from(Span::styled( + "Results", + Style::default() + .fg(colors.accent()) + .add_modifier(Modifier::BOLD), + ))) + .alignment(Alignment::Center); + title.render(layout[0], buf); + + let wpm_text = format!("{:.0} WPM", self.result.wpm); + let cpm_text = format!(" ({:.0} CPM)", self.result.cpm); + let wpm_line = Line::from(vec![ + Span::styled(" Speed: ", Style::default().fg(colors.fg())), + Span::styled( + &*wpm_text, + Style::default() + .fg(colors.accent()) + .add_modifier(Modifier::BOLD), + ), + Span::styled(&*cpm_text, Style::default().fg(colors.text_pending())), + ]); + Paragraph::new(wpm_line).render(layout[1], buf); + + let acc_color = if self.result.accuracy >= 95.0 { + colors.success() + } else if self.result.accuracy >= 85.0 { + colors.warning() + } else { + colors.error() + }; + let acc_text = format!("{:.1}%", self.result.accuracy); + let acc_detail = format!( + " ({}/{} correct)", + self.result.correct, self.result.total_chars + ); + let acc_line = Line::from(vec![ + Span::styled(" Accuracy: ", Style::default().fg(colors.fg())), + Span::styled( + &*acc_text, + Style::default().fg(acc_color).add_modifier(Modifier::BOLD), + ), + Span::styled(&*acc_detail, Style::default().fg(colors.text_pending())), + ]); + Paragraph::new(acc_line).render(layout[2], buf); + + let time_text = format!("{:.1}s", self.result.elapsed_secs); + let time_line = Line::from(vec![ + Span::styled(" Time: ", Style::default().fg(colors.fg())), + Span::styled(&*time_text, Style::default().fg(colors.fg())), + ]); + Paragraph::new(time_line).render(layout[3], buf); + + let error_text = format!("{}", self.result.incorrect); + let chars_line = Line::from(vec![ + Span::styled(" Errors: ", Style::default().fg(colors.fg())), + Span::styled( + &*error_text, + Style::default().fg(if self.result.incorrect == 0 { + colors.success() + } else { + colors.error() + }), + ), + ]); + Paragraph::new(chars_line).render(layout[4], buf); + + let help = Paragraph::new(Line::from(vec![ + Span::styled(" [r] Retry ", Style::default().fg(colors.accent())), + Span::styled("[q] Menu ", Style::default().fg(colors.accent())), + Span::styled("[s] Stats", Style::default().fg(colors.accent())), + ])); + help.render(layout[6], buf); + } +} diff --git a/src/ui/components/keyboard_diagram.rs b/src/ui/components/keyboard_diagram.rs new file mode 100644 index 0000000..60fc276 --- /dev/null +++ b/src/ui/components/keyboard_diagram.rs @@ -0,0 +1,86 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::widgets::{Block, Widget}; + +use crate::ui::theme::Theme; + +pub struct KeyboardDiagram<'a> { + pub focused_key: Option, + pub unlocked_keys: &'a [char], + pub theme: &'a Theme, +} + +impl<'a> KeyboardDiagram<'a> { + pub fn new( + focused_key: Option, + unlocked_keys: &'a [char], + theme: &'a Theme, + ) -> Self { + Self { + focused_key, + unlocked_keys, + theme, + } + } +} + +const ROWS: &[&[char]] = &[ + &['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], + &['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'], + &['z', 'x', 'c', 'v', 'b', 'n', 'm'], +]; + +impl Widget for KeyboardDiagram<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let colors = &self.theme.colors; + + let block = Block::bordered() + .title(" Keyboard ") + .border_style(Style::default().fg(colors.border())) + .style(Style::default().bg(colors.bg())); + let inner = block.inner(area); + block.render(area, buf); + + if inner.height < 3 || inner.width < 20 { + return; + } + + let key_width: u16 = 4; + let offsets: &[u16] = &[1, 2, 4]; + + for (row_idx, row) in ROWS.iter().enumerate() { + let y = inner.y + row_idx as u16; + if y >= inner.y + inner.height { + break; + } + + let offset = offsets.get(row_idx).copied().unwrap_or(0); + + for (col_idx, &key) in row.iter().enumerate() { + let x = inner.x + offset + col_idx as u16 * key_width; + if x + 3 > inner.x + inner.width { + break; + } + + let is_unlocked = self.unlocked_keys.contains(&key); + let is_focused = self.focused_key == Some(key); + + let style = if is_focused { + Style::default() + .fg(colors.bg()) + .bg(colors.focused_key()) + } else if is_unlocked { + Style::default().fg(colors.fg()).bg(colors.accent_dim()) + } else { + Style::default() + .fg(colors.text_pending()) + .bg(colors.bg()) + }; + + let display = format!("[{key}]"); + buf.set_string(x, y, &display, style); + } + } + } +} diff --git a/src/ui/components/menu.rs b/src/ui/components/menu.rs new file mode 100644 index 0000000..bb32d5b --- /dev/null +++ b/src/ui/components/menu.rs @@ -0,0 +1,150 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Widget}; + +use crate::ui::theme::Theme; + +pub struct MenuItem { + pub key: String, + pub label: String, + pub description: String, +} + +pub struct Menu<'a> { + pub items: Vec, + pub selected: usize, + pub theme: &'a Theme, +} + +impl<'a> Menu<'a> { + pub fn new(theme: &'a Theme) -> Self { + Self { + items: vec![ + MenuItem { + key: "1".to_string(), + label: "Adaptive Practice".to_string(), + description: "Phonetic words with adaptive letter unlocking".to_string(), + }, + MenuItem { + key: "2".to_string(), + label: "Code Practice".to_string(), + description: "Practice typing code syntax".to_string(), + }, + MenuItem { + key: "3".to_string(), + label: "Passage Mode".to_string(), + description: "Type passages from books".to_string(), + }, + MenuItem { + key: "s".to_string(), + label: "Statistics".to_string(), + description: "View your typing statistics".to_string(), + }, + MenuItem { + key: "c".to_string(), + label: "Settings".to_string(), + description: "Configure keydr".to_string(), + }, + ], + selected: 0, + theme, + } + } + + pub fn next(&mut self) { + self.selected = (self.selected + 1) % self.items.len(); + } + + pub fn prev(&mut self) { + if self.selected > 0 { + self.selected -= 1; + } else { + self.selected = self.items.len() - 1; + } + } +} + +impl Widget for &Menu<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let colors = &self.theme.colors; + + let block = Block::bordered() + .border_style(Style::default().fg(colors.border())) + .style(Style::default().bg(colors.bg())); + let inner = block.inner(area); + block.render(area, buf); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(inner); + + let title_lines = vec![ + Line::from(""), + Line::from(Span::styled( + "keydr", + Style::default() + .fg(colors.accent()) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "Terminal Typing Tutor", + Style::default().fg(colors.fg()), + )), + Line::from(""), + ]; + + let title = Paragraph::new(title_lines).alignment(Alignment::Center); + title.render(layout[0], buf); + + let menu_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + self.items + .iter() + .map(|_| Constraint::Length(3)) + .collect::>(), + ) + .split(layout[2]); + + for (i, item) in self.items.iter().enumerate() { + let is_selected = i == self.selected; + let indicator = if is_selected { ">" } else { " " }; + + let label_text = format!(" {indicator} [{key}] {label}", key = item.key, label = item.label); + let desc_text = format!(" {}", item.description); + + let lines = vec![ + Line::from(Span::styled( + &*label_text, + Style::default() + .fg(if is_selected { + colors.accent() + } else { + colors.fg() + }) + .add_modifier(if is_selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + )), + Line::from(Span::styled( + &*desc_text, + Style::default().fg(colors.text_pending()), + )), + ]; + + let p = Paragraph::new(lines); + if i < menu_layout.len() { + p.render(menu_layout[i], buf); + } + } + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs new file mode 100644 index 0000000..5fa7a03 --- /dev/null +++ b/src/ui/components/mod.rs @@ -0,0 +1,8 @@ +pub mod chart; +pub mod dashboard; +pub mod keyboard_diagram; +pub mod menu; +pub mod progress_bar; +pub mod stats_dashboard; +pub mod stats_sidebar; +pub mod typing_area; diff --git a/src/ui/components/progress_bar.rs b/src/ui/components/progress_bar.rs new file mode 100644 index 0000000..59c68f8 --- /dev/null +++ b/src/ui/components/progress_bar.rs @@ -0,0 +1,53 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::widgets::{Block, Widget}; + +use crate::ui::theme::Theme; + +pub struct ProgressBar<'a> { + pub label: String, + pub ratio: f64, + pub theme: &'a Theme, +} + +impl<'a> ProgressBar<'a> { + pub fn new(label: &str, ratio: f64, theme: &'a Theme) -> Self { + Self { + label: label.to_string(), + ratio: ratio.clamp(0.0, 1.0), + theme, + } + } +} + +impl Widget for ProgressBar<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let colors = &self.theme.colors; + + let block = Block::bordered() + .title(format!(" {} ", self.label)) + .border_style(Style::default().fg(colors.border())); + let inner = block.inner(area); + block.render(area, buf); + + if inner.width == 0 || inner.height == 0 { + return; + } + + let filled_width = (self.ratio * inner.width as f64) as u16; + let label = format!("{:.0}%", self.ratio * 100.0); + + for x in inner.x..inner.x + inner.width { + let style = if x < inner.x + filled_width { + Style::default().fg(colors.bg()).bg(colors.bar_filled()) + } else { + Style::default().fg(colors.fg()).bg(colors.bar_empty()) + }; + buf[(x, inner.y)].set_style(style); + } + + let label_x = inner.x + (inner.width.saturating_sub(label.len() as u16)) / 2; + buf.set_string(label_x, inner.y, &label, Style::default().fg(colors.fg())); + } +} diff --git a/src/ui/components/stats_dashboard.rs b/src/ui/components/stats_dashboard.rs new file mode 100644 index 0000000..56a498d --- /dev/null +++ b/src/ui/components/stats_dashboard.rs @@ -0,0 +1,119 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Widget}; + +use crate::session::result::LessonResult; +use crate::ui::components::chart::WpmChart; +use crate::ui::theme::Theme; + +pub struct StatsDashboard<'a> { + pub history: &'a [LessonResult], + pub theme: &'a Theme, +} + +impl<'a> StatsDashboard<'a> { + pub fn new(history: &'a [LessonResult], theme: &'a Theme) -> Self { + Self { history, theme } + } +} + +impl Widget for StatsDashboard<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let colors = &self.theme.colors; + + let block = Block::bordered() + .title(" Statistics ") + .border_style(Style::default().fg(colors.accent())) + .style(Style::default().bg(colors.bg())); + let inner = block.inner(area); + block.render(area, buf); + + if self.history.is_empty() { + let msg = Paragraph::new(Line::from(Span::styled( + "No lessons completed yet. Start typing!", + Style::default().fg(colors.text_pending()), + ))); + msg.render(inner, buf); + return; + } + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(8), + Constraint::Min(10), + Constraint::Length(2), + ]) + .split(inner); + + let avg_wpm = + self.history.iter().map(|r| r.wpm).sum::() / self.history.len() as f64; + let best_wpm = self + .history + .iter() + .map(|r| r.wpm) + .fold(0.0f64, f64::max); + let avg_accuracy = + self.history.iter().map(|r| r.accuracy).sum::() / self.history.len() as f64; + let total_lessons = self.history.len(); + + let total_str = format!("{total_lessons}"); + let avg_wpm_str = format!("{avg_wpm:.0}"); + let best_wpm_str = format!("{best_wpm:.0}"); + let avg_acc_str = format!("{avg_accuracy:.1}%"); + + let summary = vec![ + Line::from(vec![ + Span::styled(" Lessons: ", Style::default().fg(colors.fg())), + Span::styled( + &*total_str, + Style::default() + .fg(colors.accent()) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())), + Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())), + ]), + Line::from(vec![ + Span::styled(" Best WPM: ", Style::default().fg(colors.fg())), + Span::styled( + &*best_wpm_str, + Style::default() + .fg(colors.success()) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" Avg Accuracy: ", Style::default().fg(colors.fg())), + Span::styled( + &*avg_acc_str, + Style::default().fg(if avg_accuracy >= 95.0 { + colors.success() + } else { + colors.warning() + }), + ), + ]), + ]; + + Paragraph::new(summary).render(layout[0], buf); + + let chart_data: Vec<(f64, f64)> = self + .history + .iter() + .enumerate() + .map(|(i, r)| (i as f64, r.wpm)) + .collect(); + WpmChart::new(&chart_data, self.theme).render(layout[1], buf); + + let help = Paragraph::new(Line::from(Span::styled( + " [ESC] Back to menu", + Style::default().fg(colors.accent()), + ))); + help.render(layout[2], buf); + } +} diff --git a/src/ui/components/stats_sidebar.rs b/src/ui/components/stats_sidebar.rs new file mode 100644 index 0000000..334d29b --- /dev/null +++ b/src/ui/components/stats_sidebar.rs @@ -0,0 +1,87 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Widget}; + +use crate::session::lesson::LessonState; +use crate::ui::theme::Theme; + +pub struct StatsSidebar<'a> { + lesson: &'a LessonState, + theme: &'a Theme, +} + +impl<'a> StatsSidebar<'a> { + pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self { + Self { lesson, theme } + } +} + +impl Widget for StatsSidebar<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let colors = &self.theme.colors; + + let wpm = self.lesson.wpm(); + let accuracy = self.lesson.accuracy(); + let progress = self.lesson.progress() * 100.0; + let correct = self.lesson.correct_count(); + let incorrect = self.lesson.incorrect_count(); + let elapsed = self.lesson.elapsed_secs(); + + let wpm_str = format!("{wpm:.0}"); + let acc_str = format!("{accuracy:.1}%"); + let prog_str = format!("{progress:.0}%"); + let correct_str = format!("{correct}"); + let incorrect_str = format!("{incorrect}"); + let elapsed_str = format!("{elapsed:.1}s"); + + let lines = vec![ + Line::from(vec![ + Span::styled("WPM: ", Style::default().fg(colors.fg())), + Span::styled(&*wpm_str, Style::default().fg(colors.accent())), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Accuracy: ", Style::default().fg(colors.fg())), + Span::styled( + &*acc_str, + Style::default().fg(if accuracy >= 95.0 { + colors.success() + } else if accuracy >= 85.0 { + colors.warning() + } else { + colors.error() + }), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Progress: ", Style::default().fg(colors.fg())), + Span::styled(&*prog_str, Style::default().fg(colors.accent())), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Correct: ", Style::default().fg(colors.fg())), + Span::styled(&*correct_str, Style::default().fg(colors.success())), + ]), + Line::from(vec![ + Span::styled("Errors: ", Style::default().fg(colors.fg())), + Span::styled(&*incorrect_str, Style::default().fg(colors.error())), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Time: ", Style::default().fg(colors.fg())), + Span::styled(&*elapsed_str, Style::default().fg(colors.fg())), + ]), + ]; + + let block = Block::bordered() + .title(" Stats ") + .border_style(Style::default().fg(colors.border())) + .style(Style::default().bg(colors.bg())); + + let paragraph = Paragraph::new(lines).block(block); + paragraph.render(area, buf); + } +} diff --git a/src/ui/components/typing_area.rs b/src/ui/components/typing_area.rs new file mode 100644 index 0000000..b0c5179 --- /dev/null +++ b/src/ui/components/typing_area.rs @@ -0,0 +1,61 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; + +use crate::session::input::CharStatus; +use crate::session::lesson::LessonState; +use crate::ui::theme::Theme; + +pub struct TypingArea<'a> { + lesson: &'a LessonState, + theme: &'a Theme, +} + +impl<'a> TypingArea<'a> { + pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self { + Self { lesson, theme } + } +} + +impl Widget for TypingArea<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let colors = &self.theme.colors; + let mut spans: Vec = Vec::new(); + + for (i, &target_ch) in self.lesson.target.iter().enumerate() { + if i < self.lesson.cursor { + let style = match &self.lesson.input[i] { + CharStatus::Correct => Style::default().fg(colors.text_correct()), + CharStatus::Incorrect(_) => Style::default() + .fg(colors.text_incorrect()) + .bg(colors.text_incorrect_bg()) + .add_modifier(Modifier::UNDERLINED), + }; + let display = match &self.lesson.input[i] { + CharStatus::Incorrect(actual) => *actual, + _ => target_ch, + }; + spans.push(Span::styled(display.to_string(), style)); + } else if i == self.lesson.cursor { + let style = Style::default() + .fg(colors.text_cursor_fg()) + .bg(colors.text_cursor_bg()); + spans.push(Span::styled(target_ch.to_string(), style)); + } else { + let style = Style::default().fg(colors.text_pending()); + spans.push(Span::styled(target_ch.to_string(), style)); + } + } + + let line = Line::from(spans); + let block = Block::bordered() + .border_style(Style::default().fg(colors.border())) + .style(Style::default().bg(colors.bg())); + + let paragraph = Paragraph::new(line).block(block).wrap(Wrap { trim: false }); + + paragraph.render(area, buf); + } +} diff --git a/src/ui/layout.rs b/src/ui/layout.rs new file mode 100644 index 0000000..7d2a909 --- /dev/null +++ b/src/ui/layout.rs @@ -0,0 +1,53 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +pub struct AppLayout { + pub header: Rect, + pub main: Rect, + pub sidebar: Rect, + pub footer: Rect, +} + +impl AppLayout { + pub fn new(area: Rect) -> Self { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(3), + ]) + .split(area); + + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) + .split(vertical[1]); + + Self { + header: vertical[0], + main: horizontal[0], + sidebar: horizontal[1], + footer: vertical[2], + } + } +} + +pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..9beedbd --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,3 @@ +pub mod components; +pub mod layout; +pub mod theme; diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..4480ccd --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,146 @@ +use std::fs; + +use ratatui::style::Color; +use rust_embed::Embed; +use serde::{Deserialize, Serialize}; + +#[derive(Embed)] +#[folder = "assets/themes/"] +struct ThemeAssets; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Theme { + pub name: String, + pub colors: ThemeColors, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ThemeColors { + pub bg: String, + pub fg: String, + pub text_correct: String, + pub text_incorrect: String, + pub text_incorrect_bg: String, + pub text_pending: String, + pub text_cursor_bg: String, + pub text_cursor_fg: String, + pub focused_key: String, + pub accent: String, + pub accent_dim: String, + pub border: String, + pub border_focused: String, + pub header_bg: String, + pub header_fg: String, + pub bar_filled: String, + pub bar_empty: String, + pub error: String, + pub warning: String, + pub success: String, +} + +impl Theme { + pub fn load(name: &str) -> Option { + // Try user themes dir + if let Some(config_dir) = dirs::config_dir() { + let user_theme_path = config_dir.join("keydr").join("themes").join(format!("{name}.toml")); + if let Ok(content) = fs::read_to_string(&user_theme_path) { + if let Ok(theme) = toml::from_str::(&content) { + return Some(theme); + } + } + } + + // Try bundled themes + let filename = format!("{name}.toml"); + if let Some(file) = ThemeAssets::get(&filename) { + if let Ok(content) = std::str::from_utf8(file.data.as_ref()) { + if let Ok(theme) = toml::from_str::(content) { + return Some(theme); + } + } + } + + None + } + + pub fn available_themes() -> Vec { + ThemeAssets::iter() + .filter_map(|f| { + f.strip_suffix(".toml").map(|n| n.to_string()) + }) + .collect() + } +} + +impl Default for Theme { + fn default() -> Self { + Self::load("catppuccin-mocha").unwrap_or_else(|| Self { + name: "default".to_string(), + colors: ThemeColors::default(), + }) + } +} + +impl Default for ThemeColors { + fn default() -> Self { + Self { + bg: "#1e1e2e".to_string(), + fg: "#cdd6f4".to_string(), + text_correct: "#a6e3a1".to_string(), + text_incorrect: "#f38ba8".to_string(), + text_incorrect_bg: "#45273a".to_string(), + text_pending: "#585b70".to_string(), + text_cursor_bg: "#f5e0dc".to_string(), + text_cursor_fg: "#1e1e2e".to_string(), + focused_key: "#f9e2af".to_string(), + accent: "#89b4fa".to_string(), + accent_dim: "#45475a".to_string(), + border: "#45475a".to_string(), + border_focused: "#89b4fa".to_string(), + header_bg: "#313244".to_string(), + header_fg: "#cdd6f4".to_string(), + bar_filled: "#89b4fa".to_string(), + bar_empty: "#313244".to_string(), + error: "#f38ba8".to_string(), + warning: "#f9e2af".to_string(), + success: "#a6e3a1".to_string(), + } + } +} + +impl ThemeColors { + pub fn parse_color(hex: &str) -> Color { + let hex = hex.trim_start_matches('#'); + if hex.len() == 6 { + if let (Ok(r), Ok(g), Ok(b)) = ( + u8::from_str_radix(&hex[0..2], 16), + u8::from_str_radix(&hex[2..4], 16), + u8::from_str_radix(&hex[4..6], 16), + ) { + return Color::Rgb(r, g, b); + } + } + Color::White + } + + pub fn bg(&self) -> Color { Self::parse_color(&self.bg) } + pub fn fg(&self) -> Color { Self::parse_color(&self.fg) } + pub fn text_correct(&self) -> Color { Self::parse_color(&self.text_correct) } + pub fn text_incorrect(&self) -> Color { Self::parse_color(&self.text_incorrect) } + pub fn text_incorrect_bg(&self) -> Color { Self::parse_color(&self.text_incorrect_bg) } + pub fn text_pending(&self) -> Color { Self::parse_color(&self.text_pending) } + pub fn text_cursor_bg(&self) -> Color { Self::parse_color(&self.text_cursor_bg) } + pub fn text_cursor_fg(&self) -> Color { Self::parse_color(&self.text_cursor_fg) } + pub fn focused_key(&self) -> Color { Self::parse_color(&self.focused_key) } + pub fn accent(&self) -> Color { Self::parse_color(&self.accent) } + pub fn accent_dim(&self) -> Color { Self::parse_color(&self.accent_dim) } + pub fn border(&self) -> Color { Self::parse_color(&self.border) } + pub fn border_focused(&self) -> Color { Self::parse_color(&self.border_focused) } + pub fn header_bg(&self) -> Color { Self::parse_color(&self.header_bg) } + pub fn header_fg(&self) -> Color { Self::parse_color(&self.header_fg) } + pub fn bar_filled(&self) -> Color { Self::parse_color(&self.bar_filled) } + pub fn bar_empty(&self) -> Color { Self::parse_color(&self.bar_empty) } + pub fn error(&self) -> Color { Self::parse_color(&self.error) } + pub fn warning(&self) -> Color { Self::parse_color(&self.warning) } + pub fn success(&self) -> Color { Self::parse_color(&self.success) } +}