diff --git a/Cargo.lock b/Cargo.lock index 8f81670..4b966bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -168,10 +177,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.15" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -227,7 +237,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -328,7 +338,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.98", ] [[package]] @@ -339,7 +349,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -371,7 +381,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -411,6 +421,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" version = "1.0.7" @@ -812,7 +828,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -890,7 +906,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -1086,7 +1102,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -1265,6 +1281,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.12.12" @@ -1434,34 +1479,46 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.217" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.139" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -1487,9 +1544,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -1558,6 +1615,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strsim" version = "0.11.1" @@ -1583,7 +1646,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.98", ] [[package]] @@ -1603,6 +1666,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1620,7 +1694,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -1660,22 +1734,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.11" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.11" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -1816,6 +1890,98 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree-sitter" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-highlight" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd7a0c48d503cf4e0a57a2453424eaef2fce4b4269f13e3579e52f0d0c9e5cc8" +dependencies = [ + "regex", + "streaming-iterator", + "thiserror", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -1841,6 +2007,14 @@ dependencies = [ "serde_json", "toml", "toml_edit", + "tree-sitter", + "tree-sitter-c", + "tree-sitter-cpp", + "tree-sitter-go", + "tree-sitter-highlight", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", ] [[package]] @@ -1965,7 +2139,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.98", "wasm-bindgen-shared", ] @@ -2000,7 +2174,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2217,7 +2391,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", "synstructure", ] @@ -2248,7 +2422,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -2259,7 +2433,7 @@ checksum = "d3931cb58c62c13adec22e38686b559c86a30565e16ad6e8510a337cedc611e1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -2279,7 +2453,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", "synstructure", ] @@ -2308,5 +2482,11 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index be9cdca..72be7d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,11 @@ anyhow = "1.0.95" chrono = { version = "0.4", features = ["serde"] } comfy-table = "7.1.4" reqwest = { version = "0.12.12", features = ["blocking"] } +tree-sitter = "0.26.11" +tree-sitter-highlight = "0.26.11" +tree-sitter-rust = "0.24.2" +tree-sitter-python = "0.25.0" +tree-sitter-go = "0.25.0" +tree-sitter-c = "0.24.2" +tree-sitter-cpp = "0.23.4" +tree-sitter-javascript = "0.25.0" diff --git a/flake.nix b/flake.nix index eee869b..c12568e 100644 --- a/flake.nix +++ b/flake.nix @@ -69,6 +69,8 @@ nativeBuildInputs = with pkgs; [ rust-bin.stable.latest.default pkg-config + # Pinned Python for scripts/generate-snippets.py (stdlib only). + python312 ]; # Let pkg-config find openssl for the `reqwest` build. PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; diff --git a/resources/code/snippets.json b/resources/code/snippets.json new file mode 100644 index 0000000..313a551 --- /dev/null +++ b/resources/code/snippets.json @@ -0,0 +1,695 @@ +[ + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "lib/main/build-site.js", + "url": "https://github.com/lodash/lodash/blob/main/lib/main/build-site.js#L75-L84", + "content": "function removeMarkyAttributes($) {\n $('[id^=\"user-content-\"]')\n .attr('class', null)\n .attr('id', null);\n $(':header:not(h3) > a').each(function() {\n const $a = $(this);\n $a.replaceWith($a.html());\n });\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/compare.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/compare.go#L48-L53", + "content": "func (cmp *Cmp) Clone() Cmp {\n if cmp.c == nil {\n return Cmp{}\n }\n return Cmp{c: cloneCompare(cmp.c)}\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/retry.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/retry.go#L136-L140", + "content": "func RetryLeaseClient(c *Client) pb.LeaseClient {\n return &retryLeaseClient{\n lc: pb.NewLeaseClient(c.conn),\n }\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "pkg/flags/uint32.go", + "url": "https://github.com/etcd-io/etcd/blob/main/pkg/flags/uint32.go#L33-L37", + "content": "func (i *uint32Value) Set(s string) error {\n v, err := strconv.ParseUint(s, 0, 32)\n *i = uint32Value(v)\n return err\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/compare.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/compare.go#L180-L185", + "content": "func mustInt64orLeaseID(val any) int64 {\n if v, ok := val.(LeaseID); ok {\n return int64(v)\n }\n return mustInt64(val)\n}" + }, + { + "language": "rust", + "repo": "rust-lang/cargo", + "path": "crates/build-rs/src/input.rs", + "url": "https://github.com/rust-lang/cargo/blob/master/crates/build-rs/src/input.rs#L121-L128", + "content": "pub fn cargo_feature(name: &str) -> bool {\n if !is_feature_name(name) {\n panic!(\"invalid feature name {name:?}\")\n }\n let name = name.to_uppercase().replace('-', \"_\");\n let key = format!(\"CARGO_FEATURE_{name}\");\n ENV.is_present(&key)\n}" + }, + { + "language": "python", + "repo": "tiangolo/fastapi", + "path": "fastapi/_compat/v2.py", + "url": "https://github.com/tiangolo/fastapi/blob/master/fastapi/_compat/v2.py#L349-L356", + "content": "def is_scalar_field(field: ModelField) -> bool:\n from fastapi import params\n return shared.field_annotation_is_scalar(\n field.field_info.annotation\n ) and not isinstance(field.field_info, params.Body)" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml-quants.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml-quants.c#L621-L626", + "content": "static inline int nearest_int(float fval) {\n assert(fabsf(fval) <= 4194303.f);\n float val = fval + 12582912.f;\n int i; memcpy(&i, &val, sizeof(int));\n return (i & 0x007fffff) - 0x00400000;\n}" + }, + { + "language": "cpp", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.cpp", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.cpp#L8-L14", + "content": "GGML_NORETURN static void ggml_uncaught_exception() {\n ggml_print_backtrace();\n if (previous_terminate_handler) {\n previous_terminate_handler();\n }\n abort();\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/watch.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/watch.go#L446-L454", + "content": "func (w *watchGRPCStream) close() (err error) {\n w.cancel()\n <-w.donec\n select {\n case err = <-w.errc:\n default:\n }\n return ContextError(w.ctx, err)\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/etcdserver/api/v3discovery/discovery.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/etcdserver/api/v3discovery/discovery.go#L503-L509", + "content": "func (cls *clusterInfo) getPeerURLs() []string {\n var peerURLs []string\n for _, peer := range cls.members {\n peerURLs = append(peerURLs, peer.peerURLsMap)\n }\n return peerURLs\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/etcdserver/txn/txn.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/etcdserver/txn/txn.go#L230-L239", + "content": "func compareInt64(a, b int64) int {\n switch {\n case a < b:\n return -1\n case a > b:\n return 1\n default:\n return 0\n }\n}" + }, + { + "language": "python", + "repo": "tiangolo/fastapi", + "path": "fastapi/encoders.py", + "url": "https://github.com/tiangolo/fastapi/blob/master/fastapi/encoders.py#L59-L83", + "content": "def decimal_encoder(dec_value: Decimal) -> int | float:\n exponent = dec_value.as_tuple().exponent\n if isinstance(exponent, int) and exponent >= 0:\n return int(dec_value)\n else:\n return float(dec_value)" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "pkg/flags/selective_string.go", + "url": "https://github.com/etcd-io/etcd/blob/main/pkg/flags/selective_string.go#L46-L53", + "content": "func (ss *SelectiveStringValue) Valids() []string {\n s := make([]string, 0, len(ss.valids))\n for k := range ss.valids {\n s = append(s, k)\n }\n sort.Strings(s)\n return s\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.c#L1357-L1361", + "content": "bool ggml_is_quantized(enum ggml_type type) {\n assert(type >= 0);\n assert(type < GGML_TYPE_COUNT);\n return type_traits[type].is_quantized;\n}" + }, + { + "language": "python", + "repo": "pallets/flask", + "path": "src/flask/logging.py", + "url": "https://github.com/pallets/flask/blob/main/src/flask/logging.py#L58-L80", + "content": "def create_logger(app: App) -> logging.Logger:\n logger = logging.getLogger(app.name)\n if app.debug and not logger.level:\n logger.setLevel(logging.DEBUG)\n if not has_level_handler(logger):\n logger.addHandler(default_handler)\n return logger" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml-hexagon/htp/act-ops.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml-hexagon/htp/act-ops.c#L769-L777", + "content": "int op_activations(struct htp_ops_context * octx) {\n switch (octx->src[0]->type) {\n case HTP_TYPE_F32:\n return execute_op_activations_f32(octx);\n default:\n return HTP_STATUS_NO_SUPPORT;\n }\n}" + }, + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "lib/main/build-dist.js", + "url": "https://github.com/lodash/lodash/blob/main/lib/main/build-dist.js#L24-L29", + "content": "function build() {\n async.series([\n file.copy(baseLodash, distLodash),\n file.min(distLodash)\n ], util.pitch);\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/retry.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/retry.go#L101-L105", + "content": "func RetryKVClient(c *Client) pb.KVClient {\n return &retryKVClient{\n kc: pb.NewKVClient(c.conn),\n }\n}" + }, + { + "language": "python", + "repo": "pallets/flask", + "path": "src/flask/helpers.py", + "url": "https://github.com/pallets/flask/blob/main/src/flask/helpers.py#L36-L50", + "content": "def get_load_dotenv(default: bool = True) -> bool:\n val = os.environ.get(\"FLASK_SKIP_DOTENV\")\n if not val:\n return default\n return val.lower() in (\"0\", \"false\", \"no\")" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/watch.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/watch.go#L1011-L1020", + "content": "func (w *watchGRPCStream) joinSubstreams() {\n for _, ws := range w.substreams {\n <-ws.donec\n }\n for _, ws := range w.resuming {\n if ws != nil {\n <-ws.donec\n }\n }\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.c#L1652-L1660", + "content": "void ggml_reset(struct ggml_context * ctx) {\n if (ctx == NULL) {\n return;\n }\n ctx->n_objects = 0;\n ctx->objects_begin = NULL;\n ctx->objects_end = NULL;\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.c#L1326-L1330", + "content": "int64_t ggml_blck_size(enum ggml_type type) {\n assert(type >= 0);\n assert(type < GGML_TYPE_COUNT);\n return type_traits[type].blck_size;\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/proxy/grpcproxy/watch_ranges.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/proxy/grpcproxy/watch_ranges.go#L36-L47", + "content": "func (wrs *watchRanges) add(w *watcher) {\n wrs.mu.Lock()\n defer wrs.mu.Unlock()\n if wbs := wrs.bcasts[w.wr]; wbs != nil {\n wbs.add(w)\n return\n }\n wbs := newWatchBroadcasts(wrs.wp)\n wrs.bcasts[w.wr] = wbs\n wbs.add(w)\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml-et/et-kernels/src/rope_f32.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml-et/et-kernels/src/rope_f32.c#L145-L155", + "content": "static inline uint64_t rope_ps_enter_fullmask(void) {\n uint64_t old_mask;\n __asm__ volatile(\n \"mova.x.m %0 \\n\\t\"\n \"li t0, -1 \\n\\t\"\n \"mova.m.x t0 \\n\\t\"\n : \"=r\"(old_mask)\n :\n : \"t0\", \"memory\");\n return old_mask;\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "cache/predicate.go", + "url": "https://github.com/etcd-io/etcd/blob/main/cache/predicate.go#L44-L52", + "content": "func KeyPredForRange(start, end []byte) KeyPredicate {\n if len(end) == 0 {\n return ExactKey(start)\n }\n if len(end) == 1 && end[0] == 0 {\n return FromKey(start)\n }\n return Range(start, end)\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml-hexagon/htp/main.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml-hexagon/htp/main.c#L294-L300", + "content": "static void vtcm_free(struct htp_context * ctx) {\n if (ctx->vtcm_rctx) {\n HAP_compute_res_release(ctx->vtcm_rctx);\n ctx->vtcm_base = 0;\n ctx->vtcm_rctx = 0;\n }\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.c#L1332-L1336", + "content": "size_t ggml_type_size(enum ggml_type type) {\n assert(type >= 0);\n assert(type < GGML_TYPE_COUNT);\n return type_traits[type].type_size;\n}" + }, + { + "language": "python", + "repo": "tiangolo/fastapi", + "path": "scripts/notify_translations.py", + "url": "https://github.com/tiangolo/fastapi/blob/master/scripts/notify_translations.py#L198-L207", + "content": "def get_graphql_response(\n *,\n settings: Settings,\n query: str,\n after: str | None = None,\n category_id: str | None = None,\n discussion_number: int | None = None,\n discussion_id: str | None = None,\n comment_id: str | None = None,\n body: str | None = None," + }, + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "dist/lodash.fp.js", + "url": "https://github.com/lodash/lodash/blob/main/dist/lodash.fp.js#L124-L132", + "content": " function cloneArray(array) {\n var length = array ? array.length : 0,\n result = Array(length);\n while (length--) {\n result[length] = array[length];\n }\n return result;\n }" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.c#L1351-L1355", + "content": "const char * ggml_type_name(enum ggml_type type) {\n assert(type >= 0);\n assert(type < GGML_TYPE_COUNT);\n return type_traits[type].type_name;\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml-hexagon/htp/hmx-queue.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml-hexagon/htp/hmx-queue.c#L17-L23", + "content": "static inline void hmx_lock(hmx_queue_t q)\n{\n if (!q->hmx_locked) {\n HAP_compute_res_hmx_lock(q->hap_rctx);\n q->hmx_locked = true;\n }\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/storage/mvcc/watcher.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/storage/mvcc/watcher.go#L178-L188", + "content": "func (ws *watchStream) Close() {\n ws.mu.Lock()\n defer ws.mu.Unlock()\n for _, cancel := range ws.cancels {\n cancel()\n }\n ws.closed = true\n close(ws.ch)\n watchStreamGauge.Dec()\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "pkg/flags/uint32.go", + "url": "https://github.com/etcd-io/etcd/blob/main/pkg/flags/uint32.go#L25-L29", + "content": "func NewUint32Value(v uint32) *uint32Value {\n val := new(uint32Value)\n *val = uint32Value(v)\n return val\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/watch.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/watch.go#L713-L721", + "content": "func (w *watchGRPCStream) nextResume() *watcherStream {\n for len(w.resuming) != 0 {\n if w.resuming[0] != nil {\n return w.resuming[0]\n }\n w.resuming = w.resuming[1:len(w.resuming)]\n }\n return nil\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/compare.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/compare.go#L168-L176", + "content": "func mustInt64(val any) int64 {\n if v, ok := val.(int64); ok {\n return v\n }\n if v, ok := val.(int); ok {\n return int64(v)\n }\n panic(\"bad value\")\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.c#L7860-L7863", + "content": "void ggml_set_param(struct ggml_tensor * tensor) {\n GGML_ASSERT(tensor->op == GGML_OP_NONE);\n tensor->flags |= GGML_TENSOR_FLAG_PARAM;\n}" + }, + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "dist/lodash.fp.js", + "url": "https://github.com/lodash/lodash/blob/main/dist/lodash.fp.js#L141-L145", + "content": " function createCloner(func) {\n return function(object) {\n return func({}, object);\n };\n }" + }, + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "lib/fp/build-dist.js", + "url": "https://github.com/lodash/lodash/blob/main/lib/fp/build-dist.js#L48-L54", + "content": "function build() {\n async.series([\n _.partial(webpack, mappingConfig),\n _.partial(webpack, fpConfig),\n file.min(path.join(distPath, filename))\n ], util.pitch);\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/proxy/grpcproxy/watch_ranges.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/proxy/grpcproxy/watch_ranges.go#L62-L69", + "content": "func (wrs *watchRanges) stop() {\n wrs.mu.Lock()\n defer wrs.mu.Unlock()\n for _, wb := range wrs.bcasts {\n wb.stop()\n }\n wrs.bcasts = nil\n}" + }, + { + "language": "rust", + "repo": "rust-lang/cargo", + "path": "crates/build-rs/src/input.rs", + "url": "https://github.com/rust-lang/cargo/blob/master/crates/build-rs/src/input.rs#L145-L152", + "content": "fn cargo_cfg_var(cfg: &str) -> String {\n if !is_ascii_ident(cfg) {\n panic!(\"invalid configuration option {cfg:?}\")\n }\n let cfg = cfg.to_uppercase().replace('-', \"_\");\n let key = format!(\"CARGO_CFG_{cfg}\");\n key\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml-quants.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml-quants.c#L3691-L3695", + "content": "static inline int iq3_data_index(int grid_size) {\n (void)grid_size;\n GGML_ASSERT(grid_size == 256 || grid_size == 512);\n return grid_size == 256 ? 0 : 1;\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "pkg/httputil/httputil.go", + "url": "https://github.com/etcd-io/etcd/blob/main/pkg/httputil/httputil.go#L40-L49", + "content": "func GetHostname(req *http.Request) string {\n if req == nil {\n return \"\"\n }\n h, _, err := net.SplitHostPort(req.Host)\n if err != nil {\n return req.Host\n }\n return h\n}" + }, + { + "language": "python", + "repo": "pallets/flask", + "path": "src/flask/helpers.py", + "url": "https://github.com/pallets/flask/blob/main/src/flask/helpers.py#L151-L199", + "content": "def make_response(*args: t.Any) -> Response:\n if not args:\n return current_app.response_class()\n if len(args) == 1:\n args = args[0]\n return current_app.make_response(args)" + }, + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "lib/fp/build-doc.js", + "url": "https://github.com/lodash/lodash/blob/main/lib/fp/build-doc.js#L30-L36", + "content": "function toArgOrder(indexes) {\n const reordered = [];\n _.each(indexes, (newIndex, index) => {\n reordered[newIndex] = argNames[index];\n });\n return '`(' + reordered.join(', ') + ')`';\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "pkg/flags/selective_string.go", + "url": "https://github.com/etcd-io/etcd/blob/main/pkg/flags/selective_string.go#L95-L102", + "content": "func (ss *SelectiveStringsValue) Valids() []string {\n s := make([]string, 0, len(ss.valids))\n for k := range ss.valids {\n s = append(s, k)\n }\n sort.Strings(s)\n return s\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "pkg/httputil/httputil.go", + "url": "https://github.com/etcd-io/etcd/blob/main/pkg/httputil/httputil.go#L32-L35", + "content": "func GracefulClose(resp *http.Response) {\n io.Copy(io.Discard, resp.Body)\n resp.Body.Close()\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/compare.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/compare.go#L187-L192", + "content": "func cloneCompare(c *pb.Compare) *pb.Compare {\n if c == nil {\n return nil\n }\n return proto.Clone(c).(*pb.Compare)\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/storage/mvcc/watcher.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/storage/mvcc/watcher.go#L206-L210", + "content": "func (ws *watchStream) RequestProgressAll() bool {\n ws.mu.Lock()\n defer ws.mu.Unlock()\n return ws.watchable.progressAll(ws.watchers)\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/etcdserver/api/v3discovery/discovery.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/etcdserver/api/v3discovery/discovery.go#L428-L433", + "content": "func (d *discovery) close() error {\n if d.c != nil {\n return d.c.Close()\n }\n return nil\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/retry.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/retry.go#L167-L171", + "content": "func RetryClusterClient(c *Client) pb.ClusterClient {\n return &retryClusterClient{\n cc: pb.NewClusterClient(c.conn),\n }\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.c#L6531-L6535", + "content": "static void ggml_hash_map_free(struct hash_map * map) {\n ggml_hash_set_free(&map->set);\n GGML_FREE(map->vals);\n GGML_FREE(map);\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/compare.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/compare.go#L124-L129", + "content": "func (cmp *Cmp) KeyBytes() []byte {\n if cmp == nil {\n return nil\n }\n return cmp.c.GetKey()\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/etcdserver/api/v3rpc/health.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/etcdserver/api/v3rpc/health.go#L53-L58", + "content": "func (hc *healthNotifier) defragStarted() {\n if !hc.stopGRPCServiceOnDefrag {\n return\n }\n hc.stopServe(\"defrag is active\")\n}" + }, + { + "language": "python", + "repo": "pallets/flask", + "path": "src/flask/json/__init__.py", + "url": "https://github.com/pallets/flask/blob/main/src/flask/json/__init__.py#L108-L137", + "content": "def load(fp: t.IO[t.AnyStr], **kwargs: t.Any) -> t.Any:\n if current_app:\n return current_app.json.load(fp, **kwargs)\n return _json.load(fp, **kwargs)" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml-hexagon/htp/hmx-queue.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml-hexagon/htp/hmx-queue.c#L158-L170", + "content": "void hmx_queue_free(hmx_queue_t q) {\n if (!q) {\n return;\n }\n hmx_queue_flush(q);\n hmx_queue_signal(q, HMX_QUEUE_KILL);\n hmx_queue_flush(q);\n int status;\n qurt_thread_join(q->thread, &status);\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/etcdserver/api/v3discovery/discovery.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/etcdserver/api/v3discovery/discovery.go#L477-L485", + "content": "func (cls *clusterInfo) exist(mKey string) bool {\n for _, m := range cls.members {\n if mKey == m.peerRegKey {\n return true\n }\n }\n return false\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/retry.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/retry.go#L36-L45", + "content": "func (rp retryPolicy) String() string {\n switch rp {\n case repeatable:\n return \"repeatable\"\n case nonRepeatable:\n return \"nonRepeatable\"\n default:\n return \"UNKNOWN\"\n }\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/retry.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/retry.go#L241-L245", + "content": "func RetryAuthClient(c *Client) pb.AuthClient {\n return &retryAuthClient{\n ac: pb.NewAuthClient(c.conn),\n }\n}" + }, + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "lib/main/build-modules.js", + "url": "https://github.com/lodash/lodash/blob/main/lib/main/build-modules.js#L27-L32", + "content": "function build(target) {\n const actions = _.map(filePairs, pair =>\n file.copy(pair[0], path.join(target, pair[1])));\n async.series(actions, util.pitch);\n}" + }, + { + "language": "rust", + "repo": "rust-lang/cargo", + "path": "src/compiler/unused_deps.rs", + "url": "https://github.com/rust-lang/cargo/blob/master/src/compiler/unused_deps.rs#L165-L172", + "content": "fn unit_desc(unit: &Unit) -> String {\n format!(\n \"{}/{}+{:?}\",\n unit.target.name(),\n unit.target.kind().description(),\n unit.mode,\n )\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "cache/predicate.go", + "url": "https://github.com/etcd-io/etcd/blob/main/cache/predicate.go#L37-L42", + "content": "func Range(start, end []byte) KeyPredicate {\n return func(k []byte) bool {\n return bytes.Compare(k, start) >= 0 &&\n bytes.Compare(k, end) < 0\n }\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/compare.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/compare.go#L160-L165", + "content": "func (cmp Cmp) WithPrefix() Cmp {\n cmp = cmp.Clone()\n cmp.ensureCompare()\n cmp.c.RangeEnd = getPrefix(cmp.c.GetKey())\n return cmp\n}" + }, + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "fp/_baseConvert.js", + "url": "https://github.com/lodash/lodash/blob/main/fp/_baseConvert.js#L31-L35", + "content": "function baseAry(func, n) {\n return n == 2\n ? function(a, b) { return func(a, b); }\n : function(a) { return func(a); };\n}" + }, + { + "language": "cpp", + "repo": "ggerganov/llama.cpp", + "path": "tools/mtmd/mtmd-helper.cpp", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/tools/mtmd/mtmd-helper.cpp#L628-L634", + "content": "bool mtmd_helper_support_video(mtmd_context * ctx) {\n#ifdef MTMD_VIDEO\n return mtmd_support_vision(ctx);\n#else\n return false;\n#endif\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/storage/mvcc/watcher.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/storage/mvcc/watcher.go#L190-L194", + "content": "func (ws *watchStream) Rev() int64 {\n ws.mu.Lock()\n defer ws.mu.Unlock()\n return ws.watchable.rev()\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/watch.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/watch.go#L456-L464", + "content": "func (w *watcher) closeStream(wgs *watchGRPCStream) {\n w.mu.Lock()\n close(wgs.donec)\n wgs.cancel()\n if w.streams != nil {\n delete(w.streams, wgs.ctxKey)\n }\n w.mu.Unlock()\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.c#L7865-L7869", + "content": "void ggml_set_loss(struct ggml_tensor * tensor) {\n GGML_ASSERT(ggml_is_scalar(tensor));\n GGML_ASSERT(tensor->type == GGML_TYPE_F32);\n tensor->flags |= GGML_TENSOR_FLAG_LOSS;\n}" + }, + { + "language": "rust", + "repo": "rust-lang/cargo", + "path": "crates/build-rs/src/input.rs", + "url": "https://github.com/rust-lang/cargo/blob/master/crates/build-rs/src/input.rs#L139-L142", + "content": "pub fn cargo_cfg(cfg: &str) -> Option> {\n let var = cargo_cfg_var(cfg);\n ENV.get(&var).map(|v| to_strings(v, ','))\n}" + }, + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "lib/main/build-site.js", + "url": "https://github.com/lodash/lodash/blob/main/lib/main/build-site.js#L92-L95", + "content": "function renameLodashId($) {\n $('#_').attr('id', 'lodash');\n $('[href=\"#_\"]').attr('href', '#lodash');\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.c#L1553-L1561", + "content": "bool ggml_is_empty(const struct ggml_tensor * tensor) {\n for (int i = 0; i < GGML_MAX_DIMS; ++i) {\n if (tensor->ne[i] == 0) {\n return true;\n }\n }\n return false;\n}" + }, + { + "language": "rust", + "repo": "rust-lang/cargo", + "path": "src/util/mod.rs", + "url": "https://github.com/rust-lang/cargo/blob/master/src/util/mod.rs#L119-L129", + "content": "pub fn indented_lines(text: &str) -> String {\n text.lines()\n .map(|line| {\n if line.is_empty() {\n String::from(\"\\n\")\n } else {\n format!(\" {}\\n\", line)\n }\n })\n .collect()\n}" + }, + { + "language": "rust", + "repo": "rust-lang/cargo", + "path": "src/ops/resolve.rs", + "url": "https://github.com/rust-lang/cargo/blob/master/src/ops/resolve.rs#L591-L597", + "content": "pub fn get_resolved_packages<'gctx>(\n resolve: &Resolve,\n registry: PackageRegistry<'gctx>,\n) -> CargoResult> {\n let ids: Vec = resolve.iter().collect();\n registry.get(&ids)\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/pkg/logutil/zap.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/pkg/logutil/zap.go#L80-L93", + "content": "func mergePaths(old []string) []string {\n if len(old) == 0 {\n return []string{}\n }\n if slices.Contains(old, \"/dev/null\") {\n return []string{\"/dev/null\"}\n }\n dup := slices.Clone(old)\n slices.Sort(dup)\n return slices.Compact(dup)\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml-hexagon/htp/dma-queue.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml-hexagon/htp/dma-queue.c#L9-L19", + "content": "static inline uint32_t pow2_ceil(uint32_t x) {\n if (x <= 1) {\n return 1;\n }\n int p = 2;\n x--;\n while (x >>= 1) {\n p <<= 1;\n }\n return p;\n}" + }, + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "dist/lodash.fp.js", + "url": "https://github.com/lodash/lodash/blob/main/dist/lodash.fp.js#L111-L115", + "content": " function baseAry(func, n) {\n return n == 2\n ? function(a, b) { return func(a, b); }\n : function(a) { return func(a); };\n }" + }, + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "dist/lodash.fp.js", + "url": "https://github.com/lodash/lodash/blob/main/dist/lodash.fp.js#L380-L384", + "content": " function castCurry(name, func, n) {\n return (forceCurry || (config.curry && n > 1))\n ? curry(func, n)\n : func;\n }" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/compare.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/compare.go#L146-L149", + "content": "func (cmp *Cmp) WithValueBytes(v []byte) {\n cmp.ensureCompare()\n cmp.c.TargetUnion.(*pb.Compare_Value).Value = v\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml-hexagon/htp/main.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml-hexagon/htp/main.c#L245-L251", + "content": "static void vtcm_release(struct htp_context * ctx) {\n if (ctx->vtcm_valid) {\n ctx->vtcm_valid = false;\n ctx->vtcm_needs_release = false;\n HAP_compute_res_release_cached(ctx->vtcm_rctx);\n }\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml-hexagon/htp/cumsum-ops.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml-hexagon/htp/cumsum-ops.c#L256-L265", + "content": "int op_cumsum(struct htp_ops_context * octx) {\n const struct htp_tensor * dst = octx->dst;\n switch (dst->type) {\n case HTP_TYPE_F32:\n return op_cumsum_f32(octx);\n default:\n return HTP_STATUS_NO_SUPPORT;\n }\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/proxy/grpcproxy/watch_ranges.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/proxy/grpcproxy/watch_ranges.go#L29-L34", + "content": "func newWatchRanges(wp *watchProxy) *watchRanges {\n return &watchRanges{\n wp: wp,\n bcasts: make(map[watchRange]*watchBroadcasts),\n }\n}" + }, + { + "language": "javascript", + "repo": "lodash/lodash", + "path": "fp/_baseConvert.js", + "url": "https://github.com/lodash/lodash/blob/main/fp/_baseConvert.js#L44-L52", + "content": "function cloneArray(array) {\n var length = array ? array.length : 0,\n result = Array(length);\n while (length--) {\n result[length] = array[length];\n }\n return result;\n}" + }, + { + "language": "python", + "repo": "pallets/flask", + "path": "src/flask/json/__init__.py", + "url": "https://github.com/pallets/flask/blob/main/src/flask/json/__init__.py#L13-L46", + "content": "def dumps(obj: t.Any, **kwargs: t.Any) -> str:\n if current_app:\n return current_app.json.dumps(obj, **kwargs)\n kwargs.setdefault(\"default\", _default)\n return _json.dumps(obj, **kwargs)" + }, + { + "language": "cpp", + "repo": "ggerganov/llama.cpp", + "path": "tools/export-lora/export-lora.cpp", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/tools/export-lora/export-lora.cpp#L32-L37", + "content": "static void zeros(std::ofstream & file, size_t n) {\n char zero = 0;\n for (size_t i = 0; i < n; ++i) {\n file.write(&zero, 1);\n }\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/config/config.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/config/config.go#L306-L311", + "content": "func (c *ServerConfig) WALDir() string {\n if c.DedicatedWALDir != \"\" {\n return c.DedicatedWALDir\n }\n return datadir.ToWALDir(c.DataDir)\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "pkg/flags/selective_string.go", + "url": "https://github.com/etcd-io/etcd/blob/main/pkg/flags/selective_string.go#L32-L38", + "content": "func (ss *SelectiveStringValue) Set(s string) error {\n if _, ok := ss.valids[s]; ok {\n ss.v = s\n return nil\n }\n return errors.New(\"invalid value\")\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/pkg/transport/limit_listen.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/pkg/transport/limit_listen.go#L58-L62", + "content": "func (l *limitListenerConn) Close() error {\n err := l.Conn.Close()\n l.releaseOnce.Do(l.release)\n return err\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.c#L7504-L7508", + "content": "void ggml_graph_clear(struct ggml_cgraph * cgraph) {\n cgraph->n_leafs = 0;\n cgraph->n_nodes = 0;\n ggml_hash_set_reset(&cgraph->visited_hash_set);\n}" + }, + { + "language": "python", + "repo": "tiangolo/fastapi", + "path": "scripts/doc_parsing_utils.py", + "url": "https://github.com/tiangolo/fastapi/blob/master/scripts/doc_parsing_utils.py#L475-L481", + "content": "def get_code_block_lang(line: str) -> str:\n match = CODE_BLOCK_LANG_RE.match(line)\n if match:\n return match.group(1)\n return \"\"" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml.c#L1417-L1424", + "content": "int ggml_n_dims(const struct ggml_tensor * tensor) {\n for (int i = GGML_MAX_DIMS - 1; i >= 1; --i) {\n if (tensor->ne[i] > 1) {\n return i + 1;\n }\n }\n return 1;\n}" + }, + { + "language": "python", + "repo": "pallets/flask", + "path": "src/flask/json/__init__.py", + "url": "https://github.com/pallets/flask/blob/main/src/flask/json/__init__.py#L77-L107", + "content": "def loads(s: str | bytes, **kwargs: t.Any) -> t.Any:\n if current_app:\n return current_app.json.loads(s, **kwargs)\n return _json.loads(s, **kwargs)" + }, + { + "language": "python", + "repo": "pallets/flask", + "path": "src/flask/logging.py", + "url": "https://github.com/pallets/flask/blob/main/src/flask/logging.py#L16-L30", + "content": "def wsgi_errors_stream() -> t.TextIO:\n if request:\n return request.environ[\"wsgi.errors\"]\n return sys.stderr" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "client/v3/compare.go", + "url": "https://github.com/etcd-io/etcd/blob/main/client/v3/compare.go#L152-L157", + "content": "func (cmp Cmp) WithRange(end string) Cmp {\n cmp = cmp.Clone()\n cmp.ensureCompare()\n cmp.c.RangeEnd = []byte(end)\n return cmp\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/etcdmain/grpc_proxy.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/etcdmain/grpc_proxy.go#L122-L130", + "content": "func newGRPCProxyCommand() *cobra.Command {\n lpc := &cobra.Command{\n Use: \"grpc-proxy \",\n Short: \"grpc-proxy related command\",\n }\n lpc.AddCommand(newGRPCProxyStartCommand())\n return lpc\n}" + }, + { + "language": "c", + "repo": "ggerganov/llama.cpp", + "path": "ggml/src/ggml-hexagon/htp/hmx-queue.c", + "url": "https://github.com/ggerganov/llama.cpp/blob/master/ggml/src/ggml-hexagon/htp/hmx-queue.c#L25-L31", + "content": "static inline void hmx_unlock(hmx_queue_t q)\n{\n if (q->hmx_locked) {\n HAP_compute_res_hmx_unlock(q->hap_rctx);\n q->hmx_locked = false;\n }\n}" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "server/storage/mvcc/watcher.go", + "url": "https://github.com/etcd-io/etcd/blob/main/server/storage/mvcc/watcher.go#L196-L204", + "content": "func (ws *watchStream) RequestProgress(id WatchID) {\n ws.mu.Lock()\n w, ok := ws.watchers[id]\n ws.mu.Unlock()\n if !ok {\n return\n }\n ws.watchable.progress(w)\n}" + }, + { + "language": "rust", + "repo": "rust-lang/cargo", + "path": "crates/build-rs/src/input.rs", + "url": "https://github.com/rust-lang/cargo/blob/master/crates/build-rs/src/input.rs#L88-L96", + "content": "pub fn cargo_manifest_path() -> PathBuf {\n ENV.get(\"CARGO_MANIFEST_PATH\")\n .map(to_path)\n .unwrap_or_else(|| {\n let mut path = cargo_manifest_dir();\n path.push(\"Cargo.toml\");\n path\n })\n}" + }, + { + "language": "python", + "repo": "tiangolo/fastapi", + "path": "scripts/translate.py", + "url": "https://github.com/tiangolo/fastapi/blob/master/scripts/translate.py#L336-L343", + "content": "def remove_all_removable() -> None:\n all_removable = list_all_removable()\n for removable_path in all_removable:\n removable_path.unlink()\n print(f\"Removed: {removable_path}\")\n print(\"Done removing all removable paths\")" + }, + { + "language": "go", + "repo": "etcd-io/etcd", + "path": "cache/cache.go", + "url": "https://github.com/etcd-io/etcd/blob/main/cache/cache.go#L331-L337", + "content": "func (c *Cache) getWatch() error {\n getResp, err := c.get(c.internalCtx)\n if err != nil {\n return err\n }\n return c.watch(getResp.Header.Revision + 1)\n}" + } +] diff --git a/scripts/generate-snippets.py b/scripts/generate-snippets.py new file mode 100755 index 0000000..43b31c3 --- /dev/null +++ b/scripts/generate-snippets.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Generate resources/code/snippets.json for typy's coding mode. + +Extracts short, comment-free functions from a curated set of GitHub repos, +mirroring speedtyper.dev's approach (whole-function nodes, size filtered). + +Usage (inside the dev shell, which pins Python): + nix develop -c python3 scripts/generate-snippets.py > resources/code/snippets.json + +Set GITHUB_TOKEN for a higher GitHub API rate limit. Uses only the stdlib. +""" + +import json, os, re, random, sys, urllib.request + +TOKEN = os.environ.get("GITHUB_TOKEN", "") +random.seed(7) + +REPOS = { + "rust": ["rust-lang/cargo"], + "python": ["pallets/flask", "tiangolo/fastapi"], + "go": ["etcd-io/etcd"], + "javascript": ["lodash/lodash"], + "c": ["ggerganov/llama.cpp"], + "cpp": ["ggerganov/llama.cpp"], +} +EXT = {"rust": ".rs", "python": ".py", "go": ".go", "javascript": ".js", "c": ".c", "cpp": ".cpp"} +EXCLUDE = ["test", "docs", "example", "migration", ".github", "benchmark", "vendor", + "third_party", "3rdparty", "/build/", "generated", ".min.", "fuzz", + ".pb.", "_pb2", ".gen.", ".g.dart", "mock", "snapshot"] + +FILES_PER_REPO = 40 +PER_LANG_CAP = 45 +MIN_CHARS, MAX_CHARS = 100, 300 +MAX_LINES = 11 +MAX_LINE_LEN = 55 + +def api(url): + req = urllib.request.Request(url, headers={"User-Agent": "typy-gen", + "Accept": "application/vnd.github+json", + **({"Authorization": f"token {TOKEN}"} if TOKEN else {})}) + with urllib.request.urlopen(req, timeout=30) as r: + return json.load(r) + +def raw(repo, branch, path): + url = f"https://raw.githubusercontent.com/{repo}/{branch}/{path}" + req = urllib.request.Request(url, headers={"User-Agent": "typy-gen"}) + with urllib.request.urlopen(req, timeout=30) as r: + return r.read().decode("utf-8", "replace") + +def cut_inline(line, marker): + q = None; i = 0 + while i < len(line): + c = line[i] + if q: + if c == "\\": i += 2; continue + if c == q: q = None + else: + if c in "\"'": q = c + elif line[i:i+len(marker)] == marker: return line[:i] + i += 1 + return line + +def strip_comments(code, lang): + marker = "#" if lang == "python" else "//" + out = []; in_block = False; block_end = "" + for line in code.split("\n"): + st = line.strip() + if in_block: + if block_end in line: in_block = False + continue + if lang == "python" and (st.startswith('"""') or st.startswith("'''")): + d = st[:3] + if not (len(st) > 3 and d in st[3:]): in_block = True; block_end = d + continue + if st.startswith("/*"): + if "*/" not in st[2:]: in_block = True; block_end = "*/" + continue + cp = cut_inline(line, marker).rstrip() + if cp.strip() == "": + continue + out.append(cp) + return "\n".join(out) + +def normalize(code): + code = code.replace("\t", " ") + lines = [l.rstrip() for l in code.split("\n")] + while lines and not lines[0].strip(): lines.pop(0) + while lines and not lines[-1].strip(): lines.pop() + return "\n".join(lines) + +def indent(line): return len(line) - len(line.lstrip(" ")) + +def sig(line, lang): + s = line.strip() + if lang == "rust": return re.match(r"(pub(\([^)]*\))?\s+)?(default\s+)?(async\s+)?(unsafe\s+)?fn\s+\w", s) + if lang == "go": return re.match(r"func\s+(\([^)]*\)\s+)?\w", s) + if lang == "javascript": return re.match(r"(export\s+)?(default\s+)?(async\s+)?function\s+\w", s) + if lang in ("c", "cpp"): + if s.startswith(("if", "for", "while", "switch", "return", "else", "#", "//", "template", "typedef")): return False + return re.match(r"[A-Za-z_][\w:<>,*&\s]*[ \t*&]+[A-Za-z_]\w*\s*\([^;{]*\)\s*(const)?\s*\{?\s*$", s) + return False + +def extract(code, lang): + lines = code.split("\n") + out = [] + i = 0 + while i < len(lines): + line = lines[i] + if lang == "python": + if indent(line) == 0 and re.match(r"(async\s+)?def\s+\w", line.strip()): + depth = 0; k = i + while k < len(lines): + depth += lines[k].count("(") - lines[k].count(")") + if depth <= 0 and lines[k].rstrip().endswith(":"): break + k += 1 + if k - i > 8: break + sig_end = k + j = sig_end + 1; body = lines[i:sig_end + 1] + while j < len(lines): + if lines[j].strip() == "": body.append(lines[j]); j += 1; continue + if indent(lines[j]) > 0: body.append(lines[j]); j += 1 + else: break + out.append((i + 1, j, "\n".join(body))); i = j; continue + else: + if indent(line) == 0 and sig(line, lang): + depth = 0; started = False; j = i; body = [] + while j < len(lines): + body.append(lines[j]) + for ch in lines[j]: + if ch == "{": depth += 1; started = True + elif ch == "}": depth -= 1 + if started and depth <= 0: break + j += 1 + if j - i > 60: break + if started and depth <= 0: + out.append((i + 1, j + 1, "\n".join(body))); i = j + 1; continue + i += 1 + return out + +def ok(text): + if not (MIN_CHARS <= len(text) <= MAX_CHARS): return False + ls = text.split("\n") + if len(ls) < 4 or len(ls) > MAX_LINES: return False + if any(len(l) > MAX_LINE_LEN for l in ls): return False + return True + +def excluded(path): + return any(e in path for e in EXCLUDE) + +snippets = [] +for lang, repos in REPOS.items(): + ext = EXT[lang] + got = 0 + for repo in repos: + try: + branch = api(f"https://api.github.com/repos/{repo}")["default_branch"] + tree = api(f"https://api.github.com/repos/{repo}/git/trees/{branch}?recursive=1")["tree"] + except Exception as e: + print(f" ! {repo}: {e}", file=sys.stderr); continue + paths = [t["path"] for t in tree if t["type"] == "blob" + and t["path"].endswith(ext) and not excluded(t["path"])] + random.shuffle(paths) + for path in paths[:FILES_PER_REPO]: + if got >= PER_LANG_CAP: break + try: src = raw(repo, branch, path) + except Exception: continue + for start, end, block in extract(src, lang): + clean = normalize(strip_comments(block, lang)) + if ok(clean): + snippets.append({ + "language": lang, "repo": repo, "path": path, + "url": f"https://github.com/{repo}/blob/{branch}/{path}#L{start}-L{end}", + "content": clean, + }) + got += 1 + if got >= PER_LANG_CAP: break + print(f"{lang}: {got} snippets", file=sys.stderr) + +random.shuffle(snippets) +print(json.dumps(snippets, indent=1)) +print(f"TOTAL {len(snippets)} snippets", file=sys.stderr) diff --git a/src/app/input.rs b/src/app/input.rs new file mode 100644 index 0000000..e153cbd --- /dev/null +++ b/src/app/input.rs @@ -0,0 +1,155 @@ +use std::time::Duration; + +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +use super::{App, Game, Screen}; + +enum Post { + None, + Apply, + Leave, +} + +impl App { + pub(super) fn handle_event(&mut self, event: Event) { + if let Event::Key(key) = event { + if key.kind != KeyEventKind::Press { + return; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.should_quit = true; + return; + } + match self.screen { + Screen::Home => self.handle_home_key(key), + Screen::Typing => self.handle_typing_key(key), + Screen::Results => self.handle_results_key(key), + Screen::Settings => self.handle_settings_key(key), + Screen::Stats => self.handle_stats_key(key), + } + } + } + + fn handle_home_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + KeyCode::Char('s') => self.open_settings(), + KeyCode::Char('p') => self.open_stats(), + _ => self.start_test(), + } + } + + fn handle_stats_key(&mut self, key: KeyEvent) { + if matches!(key.code, KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('p')) { + self.stats = None; + self.screen = Screen::Home; + } + } + + fn handle_typing_key(&mut self, key: KeyEvent) { + if key.code == KeyCode::Esc { + if self.direct { + self.should_quit = true; + } else { + self.session = None; + self.screen = Screen::Home; + } + return; + } + + let Some(game) = self.session.as_mut() else { + self.screen = Screen::Home; + return; + }; + match game { + Game::Words(s) => match key.code { + KeyCode::Backspace => s.backspace(), + KeyCode::Char(' ') => s.space(), + KeyCode::Char(c) => s.type_char(c), + _ => {} + }, + Game::Code(s) => match key.code { + KeyCode::Backspace => s.backspace(), + KeyCode::Enter => s.newline(), + KeyCode::Char(c) => s.type_char(c), + _ => {} + }, + } + if game.is_finished() { + self.finish_test(); + } + } + + fn handle_results_key(&mut self, key: KeyEvent) { + if let Some(opened) = self.results_opened { + if opened.elapsed() < Duration::from_millis(600) { + return; + } + } + + if self.direct { + self.should_quit = true; + return; + } + match key.code { + KeyCode::Enter => self.start_test(), + KeyCode::Char('q') | KeyCode::Esc => { + self.session = None; + self.screen = Screen::Home; + } + _ => {} + } + } + + fn handle_settings_key(&mut self, key: KeyEvent) { + let post = { + let Some(st) = self.settings.as_mut() else { + self.screen = Screen::Home; + return; + }; + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + st.move_down(); + Post::None + } + KeyCode::Char('k') | KeyCode::Up => { + st.move_up(); + Post::None + } + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Char(' ') => { + if st.open { + st.confirm(); + Post::Apply + } else { + st.open(); + Post::None + } + } + KeyCode::Char('h') => { + if st.open { + st.close(); + } + Post::None + } + KeyCode::Esc | KeyCode::Char('q') => { + if st.open { + st.close(); + Post::None + } else { + Post::Leave + } + } + _ => Post::None, + } + }; + + match post { + Post::Apply => self.apply_settings(), + Post::Leave => { + self.settings = None; + self.screen = Screen::Home; + } + Post::None => {} + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index fda0168..b2010f9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,19 +1,20 @@ +mod input; +mod update; + use std::io::stdout; use std::time::{Duration, Instant}; use anyhow::Result; -use crossterm::cursor::SetCursorStyle; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::execute; -use crate::config::save_settings; -use crate::mode::Mode; -use crate::scores::progress::{Averages, Data, Score}; -use crate::settings::SettingsState; -use crate::theme::{self, Theme}; -use crate::tui::{events, Tui}; -use crate::typing::TypingSession; -use crate::ui; +use crate::domain::{CursorStyle, GameMode}; +use crate::persistence::scores::progress::{Averages, Data, Score}; +use crate::persistence::scores::Stats; +use crate::view::settings::SettingsState; +use crate::view::theme::Theme; +use crate::view::terminal::{events, Tui}; +use crate::session::{CodeSession, TypingSession}; +use crate::view::ui; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { @@ -29,6 +30,34 @@ pub struct StatsData { pub scores: Vec, } +pub enum Game { + Words(TypingSession), + Code(CodeSession), +} + +impl Game { + pub fn is_finished(&self) -> bool { + match self { + Game::Words(s) => s.is_finished(), + Game::Code(s) => s.is_finished(), + } + } + + fn tick(&mut self) { + match self { + Game::Words(s) => s.tick(), + Game::Code(s) => s.tick(), + } + } + + pub fn stats(&self) -> &Stats { + match self { + Game::Words(s) => &s.stats, + Game::Code(s) => &s.stats, + } + } +} + pub struct App { pub screen: Screen, pub should_quit: bool, @@ -38,23 +67,29 @@ pub struct App { pub language: String, pub mode_tokens: Vec, pub time: u64, - pub session: Option, + pub code_mode: bool, + pub code_language: String, + pub session: Option, pub settings: Option, pub stats: Option, pub direct: bool, pub record: u32, pub previous_record: u32, pub new_record: bool, + pub error: Option, results_opened: Option, } impl App { + #[allow(clippy::too_many_arguments)] pub fn new( theme: Theme, cursor_style: String, language: String, mode_tokens: Vec, time: u64, + code_mode: bool, + code_language: String, direct: bool, ) -> Self { App { @@ -66,6 +101,8 @@ impl App { language, mode_tokens, time, + code_mode, + code_language, session: None, settings: None, stats: None, @@ -73,6 +110,7 @@ impl App { record: Data::get_record(time).unwrap_or(0), previous_record: 0, new_record: false, + error: None, results_opened: None, } } @@ -81,270 +119,52 @@ impl App { self.record = Data::get_record(self.time).unwrap_or(0); } - fn tick(&mut self) { - if self.screen != Screen::Typing { - return; - } - if let Some(session) = self.session.as_mut() { - session.tick(); - if session.is_finished() { - self.finish_test(); - } - } - } - - fn start_test(&mut self) { - let mode = Mode::from_str(self.mode_tokens.iter().map(|s| s.as_str()).collect()) - .unwrap_or_else(|_| Mode::from_str(vec!["normal"]).unwrap()) - .add_duration(self.time); - - match TypingSession::new(&mode, &self.language) { - Ok(session) => { - self.session = Some(session); - self.screen = Screen::Typing; - } - Err(_) => self.screen = Screen::Home, - } - } - - fn open_stats(&mut self) { - let mut scores = Data::get_scores().unwrap_or_default(); - Score::sort_scores(&mut scores); - let averages = Data::get_averages().unwrap_or_else(|_| Data::default().averages); - self.stats = Some(StatsData { averages, scores }); - self.screen = Screen::Stats; - } - - fn open_settings(&mut self) { - self.settings = Some(SettingsState::new( - &self.theme_name, - &self.cursor_style, - &self.language, - &self.mode_tokens, - self.time, - )); - self.screen = Screen::Settings; - } - - fn apply_settings(&mut self) { - let Some((theme_name, cursor_style, language, mode_tokens, time, mode_default)) = - self.settings.as_ref().map(|s| { - ( - s.theme_name(), - s.cursor_style(), - s.language(), - s.mode_tokens(), - s.time(), - s.mode_default_string(), - ) - }) - else { - return; - }; - - self.theme_name = theme_name; - self.theme = theme::load(&self.theme_name); - self.cursor_style = cursor_style; - self.language = language; - self.mode_tokens = mode_tokens; - self.time = time; - self.refresh_record(); - let _ = save_settings( - &self.theme_name, - &self.cursor_style, - &self.language, - &mode_default, - self.time, - ); - } - - fn finish_test(&mut self) { - if let Some(session) = self.session.as_ref() { - let wpm = session.stats.wpm() as u32; - self.previous_record = Data::get_record(self.time).unwrap_or(0); - self.new_record = wpm > self.previous_record; - - let score = Score::new(wpm, session.stats.raw_wpm() as u32, session.stats.accuracy() as f32); - let _ = Data::save_data(score, self.time); - self.refresh_record(); - } - self.screen = Screen::Results; - self.results_opened = Some(Instant::now()); - } - - fn handle_event(&mut self, event: Event) { - if let Event::Key(key) = event { - if key.kind != KeyEventKind::Press { - return; - } - if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { - self.should_quit = true; - return; - } - match self.screen { - Screen::Home => self.handle_home_key(key), - Screen::Typing => self.handle_typing_key(key), - Screen::Results => self.handle_results_key(key), - Screen::Settings => self.handle_settings_key(key), - Screen::Stats => self.handle_stats_key(key), - } - } - } - - fn handle_home_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, - KeyCode::Char('s') => self.open_settings(), - KeyCode::Char('p') => self.open_stats(), - _ => self.start_test(), - } - } - - fn handle_stats_key(&mut self, key: KeyEvent) { - if matches!(key.code, KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('p')) { - self.stats = None; - self.screen = Screen::Home; - } - } - - fn handle_typing_key(&mut self, key: KeyEvent) { - let Some(session) = self.session.as_mut() else { - self.screen = Screen::Home; - return; - }; - match key.code { - KeyCode::Esc => { - if self.direct { - self.should_quit = true; - } else { - self.session = None; - self.screen = Screen::Home; - } - return; - } - KeyCode::Backspace => session.backspace(), - KeyCode::Char(' ') => session.space(), - KeyCode::Char(c) => session.type_char(c), - _ => {} - } - if session.is_finished() { - self.finish_test(); + fn game_mode(&self) -> GameMode { + if self.code_mode { + GameMode::Code + } else { + GameMode::Text } } - fn handle_results_key(&mut self, _key: KeyEvent) { - if let Some(opened) = self.results_opened { - if opened.elapsed() < Duration::from_millis(600) { - return; - } - } - - if self.direct { - self.should_quit = true; + fn tick(&mut self) { + if self.screen != Screen::Typing { return; } - match _key.code { - KeyCode::Enter => self.start_test(), - KeyCode::Char('q') | KeyCode::Esc => { - self.session = None; - self.screen = Screen::Home; - } - _ => {} - } - } - - fn handle_settings_key(&mut self, key: KeyEvent) { - let post = { - let Some(st) = self.settings.as_mut() else { - self.screen = Screen::Home; - return; - }; - match key.code { - KeyCode::Char('j') | KeyCode::Down => { - st.move_down(); - Post::None - } - KeyCode::Char('k') | KeyCode::Up => { - st.move_up(); - Post::None - } - KeyCode::Enter | KeyCode::Char('l') | KeyCode::Char(' ') => { - if st.open { - st.confirm(); - Post::Apply - } else { - st.open(); - Post::None - } - } - KeyCode::Char('h') => { - if st.open { - st.close(); - } - Post::None - } - KeyCode::Esc | KeyCode::Char('q') => { - if st.open { - st.close(); - Post::None - } else { - Post::Leave - } - } - _ => Post::None, - } - }; - - match post { - Post::Apply => self.apply_settings(), - Post::Leave => { - self.settings = None; - self.screen = Screen::Home; + if let Some(game) = self.session.as_mut() { + game.tick(); + if game.is_finished() { + self.finish_test(); } - Post::None => {} } } -} -enum Post { - None, - Apply, - Leave, -} - -fn cursor_shape(name: &str) -> SetCursorStyle { - let blink = name.contains("blink"); - if name.contains("underline") { - if blink { - SetCursorStyle::BlinkingUnderScore - } else { - SetCursorStyle::SteadyUnderScore - } - } else if name.contains("bar") { - if blink { - SetCursorStyle::BlinkingBar - } else { - SetCursorStyle::SteadyBar - } - } else if blink { - SetCursorStyle::BlinkingBlock - } else { - SetCursorStyle::SteadyBlock - } } +#[allow(clippy::too_many_arguments)] pub fn run( theme: Theme, cursor_style: String, language: String, mode_tokens: Vec, time: u64, + code_mode: bool, + code_language: String, direct: bool, ) -> Result<()> { let mut tui = Tui::new()?; tui.enter()?; - let mut app = App::new(theme, cursor_style, language, mode_tokens, time, direct); + let mut app = App::new( + theme, + cursor_style, + language, + mode_tokens, + time, + code_mode, + code_language, + direct, + ); if direct { app.start_test(); } @@ -353,7 +173,10 @@ pub fn run( while !app.should_quit { app.tick(); if app.screen == Screen::Typing { - let _ = execute!(stdout(), cursor_shape(&app.cursor_style)); + let style = CursorStyle::from_token(&app.cursor_style) + .unwrap_or(CursorStyle::Block) + .to_set_cursor_style(); + let _ = execute!(stdout(), style); } tui.terminal.draw(|frame| ui::render(frame, &app))?; if let Some(event) = events::next(Duration::from_millis(100))? { diff --git a/src/app/update.rs b/src/app/update.rs new file mode 100644 index 0000000..e0f4cb4 --- /dev/null +++ b/src/app/update.rs @@ -0,0 +1,123 @@ +use std::time::Instant; + +use crate::persistence::config::save_settings; +use crate::domain::ModeType; +use crate::session::modifiers::Mode; +use crate::persistence::scores::progress::{Data, Score}; +use crate::view::settings::SettingsState; +use crate::view::theme; +use crate::session::CodeSession; + +use super::{App, Game, Screen, StatsData, TypingSession}; + +impl App { + pub(super) fn start_test(&mut self) { + let game = if self.code_mode { + CodeSession::new(&self.code_language).map(Game::Code) + } else { + let mode = Mode::from_str(self.mode_tokens.iter().map(|s| s.as_str()).collect()) + .unwrap_or_else(|_| Mode::from_str(vec![ModeType::Normal.token()]).unwrap()) + .add_duration(self.time); + TypingSession::new(&mode, &self.language).map(Game::Words) + }; + + match game { + Ok(game) => { + self.error = None; + self.session = Some(game); + self.screen = Screen::Typing; + } + Err(e) => { + self.error = Some(format!("{e}")); + self.screen = Screen::Home; + } + } + } + + pub(super) fn open_stats(&mut self) { + let mut scores = Data::get_scores().unwrap_or_default(); + Score::sort_scores(&mut scores); + let averages = Data::get_averages().unwrap_or_else(|_| Data::default().averages); + self.stats = Some(StatsData { averages, scores }); + self.screen = Screen::Stats; + } + + pub(super) fn open_settings(&mut self) { + self.settings = Some(SettingsState::new( + &self.theme_name, + &self.cursor_style, + self.code_mode, + &self.code_language, + &self.language, + &self.mode_tokens, + self.time, + )); + self.screen = Screen::Settings; + } + + pub(super) fn apply_settings(&mut self) { + let Some(( + theme_name, + cursor_style, + code_mode, + code_language, + language, + mode_tokens, + time, + mode_default, + )) = self.settings.as_ref().map(|s| { + ( + s.theme_name(), + s.cursor_style(), + s.code_mode(), + s.code_language(), + s.language(), + s.mode_tokens(), + s.time(), + s.mode_default_string(), + ) + }) else { + return; + }; + + self.theme_name = theme_name; + self.cursor_style = cursor_style; + self.code_mode = code_mode; + self.code_language = code_language; + self.language = language; + self.mode_tokens = mode_tokens; + self.time = time; + + self.theme = theme::load(&self.theme_name); + self.refresh_record(); + let _ = save_settings( + &self.theme_name, + &self.cursor_style, + self.game_mode().content_token(), + &self.code_language, + &self.language, + &mode_default, + self.time, + ); + } + + pub(super) fn finish_test(&mut self) { + if let Some(game) = self.session.as_ref() { + let stats = game.stats(); + let wpm = stats.wpm() as u32; + let score = Score::new(wpm, stats.raw_wpm() as u32, stats.accuracy() as f32); + + if self.code_mode { + self.new_record = false; + let _ = Data::save_data(score, 0); + } else { + self.previous_record = Data::get_record(self.time).unwrap_or(0); + self.new_record = wpm > self.previous_record; + let _ = Data::save_data(score, self.time); + self.refresh_record(); + } + } + self.screen = Screen::Results; + self.results_opened = Some(Instant::now()); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c9526b1..5cc9d49 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,14 +1,17 @@ mod parser; -use anyhow::{Context, Result}; +use anyhow::{bail, Result}; use clap::Parser; use parser::Cli; use crate::app; -use crate::config; -use crate::mode::Mode; -use crate::scores::progress::display; -use crate::theme; +use crate::content::code; +use crate::persistence::config; +use crate::persistence::config::constants::defaults; +use crate::domain::{CursorStyle, GameMode}; +use crate::view::progress as display; +use crate::view::theme; +use crate::content::words; pub fn run() -> Result<()> { let cli = Cli::parse(); @@ -24,8 +27,6 @@ pub fn run() -> Result<()> { return Ok(()); } - let language = config::language::Language::new().lang; - let theme_name = config::toml_parser::get_config() .lock() .ok() @@ -37,29 +38,106 @@ pub fn run() -> Result<()> { .lock() .ok() .and_then(|c| c.get_cursor()) - .unwrap_or_else(|| "block".to_string()); + .unwrap_or_else(|| CursorStyle::Block.token().to_string()); - let config_time = config::toml_parser::get_config() + let config_content = config::toml_parser::get_config() .lock() .ok() - .and_then(|c| c.get_game()) - .and_then(|g| g.time); - let time = cli.time.or(config_time).unwrap_or(30); + .and_then(|c| c.get_content()) + .unwrap_or_default(); - let mode_tokens: Vec = if !cli.mode.is_empty() { - cli.mode.clone() - } else { + let direct = cli.mode.is_some() + || cli.time.is_some() + || !cli.modifiers.is_empty() + || cli.language.is_some(); + + let code_mode = match cli.mode { + Some(GameMode::Text) => false, + Some(GameMode::Code) => true, + None => !direct && GameMode::is_code_content(&config_content), + }; + + if code_mode && !cli.modifiers.is_empty() { + bail!("`-x/--modifiers` only applies to text mode, not code."); + } + + let (word_language, code_language) = resolve_languages(&cli, code_mode)?; + + let mode_tokens: Vec = if cli.modifiers.is_empty() { config::mode_settings::ModeSettings::new() .default_modes .iter() .map(|m| m.token().to_string()) .collect() + } else { + cli.modifiers + .iter() + .map(|m| m.token().to_string()) + .collect() }; - Mode::from_str(mode_tokens.iter().map(|s| s.as_str()).collect()) - .context("Failed to parse mode")?; + let time = cli + .time + .or_else(|| { + config::toml_parser::get_config() + .lock() + .ok() + .and_then(|c| c.get_game()) + .and_then(|g| g.time) + }) + .unwrap_or(defaults::TIME); - let direct = cli.time.is_some() || !cli.mode.is_empty(); + app::run( + theme, + cursor_style, + word_language, + mode_tokens, + time, + code_mode, + code_language, + direct, + ) +} - app::run(theme, cursor_style, language, mode_tokens, time, direct) +fn resolve_languages(cli: &Cli, code_mode: bool) -> Result<(String, String)> { + let config_word = config::language::Language::new().lang; + let config_code = config::toml_parser::get_config() + .lock() + .ok() + .and_then(|c| c.get_code_language()) + .unwrap_or_else(|| defaults::CODE_LANGUAGE.to_string()); + + if code_mode { + let code_language = match &cli.language { + Some(lang) => { + let mut valid = code::available_languages(); + valid.push(defaults::CODE_LANGUAGE.to_string()); + if !valid.contains(lang) { + bail!( + "Unknown code language '{lang}'. Available: {}.", + valid.join(", ") + ); + } + lang.clone() + } + None => config_code, + }; + Ok((config_word, code_language)) + } else { + let word_language = match &cli.language { + Some(lang) => { + let valid = words::available_languages(); + if !valid.contains(lang) { + bail!( + "Unknown language '{lang}'. Available: {}. \ + Add your own by placing `.txt` in ~/.local/share/typy/.", + valid.join(", ") + ); + } + lang.clone() + } + None => config_word, + }; + Ok((word_language, config_code)) + } } diff --git a/src/cli/parser.rs b/src/cli/parser.rs index 473b122..6fe168c 100644 --- a/src/cli/parser.rs +++ b/src/cli/parser.rs @@ -1,4 +1,27 @@ -use clap::Parser; +use clap::{Parser, ValueEnum}; + +pub(crate) use crate::domain::GameMode; + +#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)] +pub(crate) enum Modifier { + Punctuation, + Numbers, + Uppercase, +} + +impl Modifier { + pub(crate) fn mode_type(self) -> crate::domain::ModeType { + match self { + Modifier::Punctuation => crate::domain::ModeType::Punctuation, + Modifier::Numbers => crate::domain::ModeType::Numbers, + Modifier::Uppercase => crate::domain::ModeType::Uppercase, + } + } + + pub(crate) fn token(self) -> &'static str { + self.mode_type().token() + } +} #[derive(Parser)] #[command(name = "typy")] @@ -9,31 +32,51 @@ use clap::Parser; USAGE MODES: • Interactive: typy - Open the app (home, settings, stats) - • Quick run: typy -t 60 - Start a 60s test right away, then exit - • With mode: typy -m punctuation - Start a test with the given mode(s) - • Stats: typy -s - Show statistics for your past games - • Config: typy -c - Create and open the config file + • Words: typy -m text -t 60 - A 60s words test, then exit + • With options: typy -x punctuation numbers + • Code: typy -m code -l rust - Type a Rust snippet (or -l any) + • Language: typy -l german - Words test in German + • Stats: typy -s + • Config: typy -c For more information check: https://github.com/Pazl27/typy-cli")] pub(crate) struct Cli { #[arg( - short = 't', - long = "time", - value_name = "SECONDS", + short = 'm', + long = "mode", + value_name = "MODE", help_heading = "Game options", - help = "Duration of the test in seconds.\nStarts a test immediately and exits when it finishes." + help = "What to type: text (words) or code. Default: text." )] - pub(crate) time: Option, + pub(crate) mode: Option, #[arg( - short = 'm', - long = "mode", - value_name = "MODE", - num_args = 1.., + short = 'l', + long = "language", + value_name = "LANG", help_heading = "Game options", - help = "Mode(s) to play: normal, uppercase, punctuation.\nStarts a test immediately." + help = "Language to use (works for text and code).\nText: english, german, ... Code: rust, python, go, c, cpp, javascript, any." + )] + pub(crate) language: Option, + + #[arg( + short = 'x', + long = "modifiers", + value_name = "MOD", + num_args = 1.., + help_heading = "Text options", + help = "Extra text options (text mode only): punctuation, numbers, uppercase.\nCombine freely, e.g. -x punctuation numbers." )] - pub(crate) mode: Vec, + pub(crate) modifiers: Vec, + + #[arg( + short = 't', + long = "time", + value_name = "SECONDS", + help_heading = "Text options", + help = "Test duration in seconds (text mode)." + )] + pub(crate) time: Option, #[arg( short = 's', diff --git a/src/config/mod.rs b/src/config/mod.rs deleted file mode 100644 index 989c261..0000000 --- a/src/config/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod config_tables; -mod config_utils; -pub mod toml_parser; - -pub use config_tables::*; -pub use config_utils::*; diff --git a/src/content/code/mod.rs b/src/content/code/mod.rs new file mode 100644 index 0000000..ad2a3c4 --- /dev/null +++ b/src/content/code/mod.rs @@ -0,0 +1,92 @@ +use anyhow::{bail, Context, Result}; +use dirs::home_dir; +use rand::rng; +use rand::seq::IndexedRandom; +use reqwest::blocking::Client; +use serde::Deserialize; +use std::fs::{create_dir_all, read_to_string, write}; +use std::path::PathBuf; + +const DATASET_URL: &str = + "https://raw.githubusercontent.com/Pazl27/typy-cli/refs/heads/master/resources/code/snippets.json"; + +#[derive(Debug, Clone, Deserialize)] +pub struct Snippet { + pub language: String, + pub path: String, + pub url: String, + pub content: String, +} + +pub fn available_languages() -> Vec { + ["c", "cpp", "go", "javascript", "python", "rust"] + .iter() + .map(|s| s.to_string()) + .collect() +} + +pub fn get_snippet(selector: &str) -> Result { + let dataset = load_dataset()?; + if dataset.is_empty() { + bail!("No code snippets available"); + } + + let pool: Vec<&Snippet> = if selector.is_empty() + || selector == crate::persistence::config::constants::defaults::CODE_LANGUAGE + { + dataset.iter().collect() + } else { + let filtered: Vec<&Snippet> = dataset.iter().filter(|s| s.language == selector).collect(); + if filtered.is_empty() { + dataset.iter().collect() + } else { + filtered + } + }; + + let mut rng = rng(); + let snippet = pool.choose(&mut rng).context("No code snippets available")?; + Ok((*snippet).clone()) +} + +fn cache_path() -> Result { + let home = home_dir().context("Failed to get home directory")?; + Ok(home.join(".local/share/typy/code/snippets.json")) +} + +fn load_dataset() -> Result> { + if let Ok(text) = read_to_string("./resources/code/snippets.json") { + if let Ok(dataset) = serde_json::from_str(&text) { + return Ok(dataset); + } + } + + let cache = cache_path()?; + if let Ok(text) = read_to_string(&cache) { + if let Ok(dataset) = serde_json::from_str(&text) { + return Ok(dataset); + } + } + + let text = download_dataset()?; + if let Some(parent) = cache.parent() { + create_dir_all(parent).ok(); + } + write(&cache, &text).ok(); + serde_json::from_str(&text).context("Failed to parse downloaded snippets") +} + +fn download_dataset() -> Result { + let client = Client::builder() + .user_agent("typy-cli") + .build() + .context("Failed to build HTTP client")?; + let resp = client + .get(DATASET_URL) + .send() + .context("Failed to download code snippets (check your internet connection)")?; + if !resp.status().is_success() { + bail!("Failed to download code snippets ({})", resp.status()); + } + resp.text().context("Failed to read snippet download") +} diff --git a/src/content/highlight/capture.rs b/src/content/highlight/capture.rs new file mode 100644 index 0000000..9d494b5 --- /dev/null +++ b/src/content/highlight/capture.rs @@ -0,0 +1,23 @@ +use super::Token; + +pub const HIGHLIGHT_NAMES: &[&str] = &[ + "keyword", + "string", + "comment", + "number", + "function", + "type", + "constant", + "operator", + "variable", +]; + +pub fn token_for(index: usize) -> Token { + match HIGHLIGHT_NAMES.get(index).copied() { + Some("keyword") => Token::Keyword, + Some("string") => Token::StringLit, + Some("comment") => Token::Comment, + Some("number") | Some("constant") => Token::Number, + _ => Token::Plain, + } +} diff --git a/src/content/highlight/highlighter.rs b/src/content/highlight/highlighter.rs new file mode 100644 index 0000000..d75b69b --- /dev/null +++ b/src/content/highlight/highlighter.rs @@ -0,0 +1,117 @@ +use tree_sitter_highlight::{Highlighter, HighlightEvent}; + +use super::capture; +use super::languages; +use super::Token; + +pub fn highlight(source: &str, language: &str) -> Vec { + let char_count = source.chars().count(); + let mut tokens = vec![Token::Plain; char_count]; + + let Some(config) = languages::config_for(language) else { + return tokens; + }; + + let bytes = source.as_bytes(); + let byte_to_char = byte_to_char_map(source, char_count); + + let mut highlighter = Highlighter::new(); + let Ok(events) = highlighter.highlight(config, bytes, None, |_| None) else { + return tokens; + }; + + let mut current = Token::Plain; + for event in events.flatten() { + match event { + HighlightEvent::HighlightStart(highlight) => { + current = capture::token_for(highlight.0); + } + HighlightEvent::HighlightEnd => { + current = Token::Plain; + } + HighlightEvent::Source { start, end } => { + if current == Token::Plain { + continue; + } + for token in tokens + .iter_mut() + .take(byte_to_char[end]) + .skip(byte_to_char[start]) + { + *token = current; + } + } + } + } + + tokens +} + +fn byte_to_char_map(source: &str, char_count: usize) -> Vec { + let mut map = vec![char_count; source.len() + 1]; + for (char_index, (byte_index, _)) in source.char_indices().enumerate() { + map[byte_index] = char_index; + } + map[source.len()] = char_count; + map +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn highlights_rust_keyword_and_string() { + let src = "fn main() { let x = \"hi\"; }"; + let t = highlight(src, "rust"); + assert_eq!(t.len(), src.chars().count()); + assert_eq!(t[0], Token::Keyword); + assert_eq!(t[1], Token::Keyword); + let q = src.chars().position(|c| c == '"').unwrap(); + assert_eq!(t[q], Token::StringLit); + } + + #[test] + fn every_registered_language_builds_and_highlights() { + let cases = [ + ("python", "def f():\n return 1\n"), + ("go", "func main() { return }"), + ("c", "int main() { return 0; }"), + ("cpp", "int main() { return 0; }"), + ("javascript", "function f() { return 1; }"), + ]; + for (language, src) in cases { + let t = highlight(src, language); + assert_eq!(t.len(), src.chars().count(), "{language}"); + assert!( + t.iter().any(|&x| x == Token::Keyword), + "{language} produced no keywords" + ); + } + } + + #[test] + fn aliases_resolve() { + assert!(highlight("const x = 1;", "js") + .iter() + .any(|&t| t == Token::Keyword)); + assert!(highlight("int f() { return 0; }", "c++") + .iter() + .any(|&t| t == Token::Keyword)); + } + + #[test] + fn unknown_language_is_plain() { + let src = "fn main() {}"; + let t = highlight(src, "cobol"); + assert!(t.iter().all(|&x| x == Token::Plain)); + } + + #[test] + fn multibyte_source_stays_aligned() { + let src = "let \u{00e9} = 1;"; + let t = highlight(src, "rust"); + assert_eq!(t.len(), src.chars().count()); + assert_eq!(t[0], Token::Keyword); + } +} diff --git a/src/content/highlight/languages/c.rs b/src/content/highlight/languages/c.rs new file mode 100644 index 0000000..72a9504 --- /dev/null +++ b/src/content/highlight/languages/c.rs @@ -0,0 +1,18 @@ +use std::sync::LazyLock; + +use tree_sitter_highlight::HighlightConfiguration; + +use super::super::capture::HIGHLIGHT_NAMES; + +pub static CONFIG: LazyLock = LazyLock::new(|| { + let mut config = HighlightConfiguration::new( + tree_sitter_c::LANGUAGE.into(), + "c", + tree_sitter_c::HIGHLIGHT_QUERY, + "", + "", + ) + .expect("valid c highlight configuration"); + config.configure(HIGHLIGHT_NAMES); + config +}); diff --git a/src/content/highlight/languages/cpp.rs b/src/content/highlight/languages/cpp.rs new file mode 100644 index 0000000..dbd9729 --- /dev/null +++ b/src/content/highlight/languages/cpp.rs @@ -0,0 +1,18 @@ +use std::sync::LazyLock; + +use tree_sitter_highlight::HighlightConfiguration; + +use super::super::capture::HIGHLIGHT_NAMES; + +pub static CONFIG: LazyLock = LazyLock::new(|| { + let query = format!( + "{}\n{}", + tree_sitter_c::HIGHLIGHT_QUERY, + tree_sitter_cpp::HIGHLIGHT_QUERY + ); + let mut config = + HighlightConfiguration::new(tree_sitter_cpp::LANGUAGE.into(), "cpp", &query, "", "") + .expect("valid cpp highlight configuration"); + config.configure(HIGHLIGHT_NAMES); + config +}); diff --git a/src/content/highlight/languages/go.rs b/src/content/highlight/languages/go.rs new file mode 100644 index 0000000..8133a5f --- /dev/null +++ b/src/content/highlight/languages/go.rs @@ -0,0 +1,18 @@ +use std::sync::LazyLock; + +use tree_sitter_highlight::HighlightConfiguration; + +use super::super::capture::HIGHLIGHT_NAMES; + +pub static CONFIG: LazyLock = LazyLock::new(|| { + let mut config = HighlightConfiguration::new( + tree_sitter_go::LANGUAGE.into(), + "go", + tree_sitter_go::HIGHLIGHTS_QUERY, + "", + "", + ) + .expect("valid go highlight configuration"); + config.configure(HIGHLIGHT_NAMES); + config +}); diff --git a/src/content/highlight/languages/javascript.rs b/src/content/highlight/languages/javascript.rs new file mode 100644 index 0000000..5b83708 --- /dev/null +++ b/src/content/highlight/languages/javascript.rs @@ -0,0 +1,18 @@ +use std::sync::LazyLock; + +use tree_sitter_highlight::HighlightConfiguration; + +use super::super::capture::HIGHLIGHT_NAMES; + +pub static CONFIG: LazyLock = LazyLock::new(|| { + let mut config = HighlightConfiguration::new( + tree_sitter_javascript::LANGUAGE.into(), + "javascript", + tree_sitter_javascript::HIGHLIGHT_QUERY, + "", + "", + ) + .expect("valid javascript highlight configuration"); + config.configure(HIGHLIGHT_NAMES); + config +}); diff --git a/src/content/highlight/languages/mod.rs b/src/content/highlight/languages/mod.rs new file mode 100644 index 0000000..f5f7cac --- /dev/null +++ b/src/content/highlight/languages/mod.rs @@ -0,0 +1,28 @@ +mod c; +mod cpp; +mod go; +mod javascript; +mod python; +mod rust; + +use tree_sitter_highlight::HighlightConfiguration; + +const ALIASES: &[(&str, &str)] = &[("c++", "cpp"), ("js", "javascript"), ("ts", "javascript")]; + +pub fn config_for(language: &str) -> Option<&'static HighlightConfiguration> { + let key = ALIASES + .iter() + .find(|(alias, _)| *alias == language) + .map(|(_, canonical)| *canonical) + .unwrap_or(language); + + match key { + "rust" => Some(&rust::CONFIG), + "python" => Some(&python::CONFIG), + "go" => Some(&go::CONFIG), + "c" => Some(&c::CONFIG), + "cpp" => Some(&cpp::CONFIG), + "javascript" => Some(&javascript::CONFIG), + _ => None, + } +} diff --git a/src/content/highlight/languages/python.rs b/src/content/highlight/languages/python.rs new file mode 100644 index 0000000..80de5f2 --- /dev/null +++ b/src/content/highlight/languages/python.rs @@ -0,0 +1,18 @@ +use std::sync::LazyLock; + +use tree_sitter_highlight::HighlightConfiguration; + +use super::super::capture::HIGHLIGHT_NAMES; + +pub static CONFIG: LazyLock = LazyLock::new(|| { + let mut config = HighlightConfiguration::new( + tree_sitter_python::LANGUAGE.into(), + "python", + tree_sitter_python::HIGHLIGHTS_QUERY, + "", + "", + ) + .expect("valid python highlight configuration"); + config.configure(HIGHLIGHT_NAMES); + config +}); diff --git a/src/content/highlight/languages/rust.rs b/src/content/highlight/languages/rust.rs new file mode 100644 index 0000000..89a59b7 --- /dev/null +++ b/src/content/highlight/languages/rust.rs @@ -0,0 +1,18 @@ +use std::sync::LazyLock; + +use tree_sitter_highlight::HighlightConfiguration; + +use super::super::capture::HIGHLIGHT_NAMES; + +pub static CONFIG: LazyLock = LazyLock::new(|| { + let mut config = HighlightConfiguration::new( + tree_sitter_rust::LANGUAGE.into(), + "rust", + tree_sitter_rust::HIGHLIGHTS_QUERY, + "", + "", + ) + .expect("valid rust highlight configuration"); + config.configure(HIGHLIGHT_NAMES); + config +}); diff --git a/src/content/highlight/mod.rs b/src/content/highlight/mod.rs new file mode 100644 index 0000000..8ccd54a --- /dev/null +++ b/src/content/highlight/mod.rs @@ -0,0 +1,28 @@ +mod capture; +mod highlighter; +mod languages; + +use ratatui::style::Color; + +use crate::view::theme::Theme; + +pub use highlighter::highlight; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Token { + Keyword, + StringLit, + Comment, + Number, + Plain, +} + +pub fn token_color(token: Token, theme: &Theme) -> Color { + match token { + Token::Keyword => theme.keyword, + Token::StringLit => theme.string, + Token::Comment => theme.comment, + Token::Number => theme.number, + Token::Plain => theme.fg, + } +} diff --git a/src/content/mod.rs b/src/content/mod.rs new file mode 100644 index 0000000..8cb7515 --- /dev/null +++ b/src/content/mod.rs @@ -0,0 +1,3 @@ +pub mod code; +pub mod highlight; +pub mod words; diff --git a/src/word_provider/finder.rs b/src/content/words/finder.rs similarity index 100% rename from src/word_provider/finder.rs rename to src/content/words/finder.rs diff --git a/src/word_provider/mod.rs b/src/content/words/mod.rs similarity index 93% rename from src/word_provider/mod.rs rename to src/content/words/mod.rs index cc8e99b..1707f3a 100644 --- a/src/word_provider/mod.rs +++ b/src/content/words/mod.rs @@ -5,6 +5,8 @@ use finder::find; use std::collections::BTreeSet; use std::path::PathBuf; +use crate::persistence::config::constants::defaults; + const LENGTH: i32 = 70; pub fn get_words(language: &str, min_words: usize) -> Result>> { @@ -41,7 +43,7 @@ pub fn available_languages() -> Vec { } if languages.is_empty() { - languages.insert("english".to_string()); + languages.insert(defaults::LANGUAGE.to_string()); } languages.into_iter().collect() } diff --git a/src/domain/cursor.rs b/src/domain/cursor.rs new file mode 100644 index 0000000..1c718b3 --- /dev/null +++ b/src/domain/cursor.rs @@ -0,0 +1,50 @@ +use crossterm::cursor::SetCursorStyle; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CursorStyle { + Block, + BlinkingBlock, + Underline, + BlinkingUnderline, + Bar, + BlinkingBar, +} + +impl CursorStyle { + pub const ALL: [CursorStyle; 6] = [ + CursorStyle::Block, + CursorStyle::BlinkingBlock, + CursorStyle::Underline, + CursorStyle::BlinkingUnderline, + CursorStyle::Bar, + CursorStyle::BlinkingBar, + ]; + + pub fn token(self) -> &'static str { + match self { + CursorStyle::Block => "block", + CursorStyle::BlinkingBlock => "blinking block", + CursorStyle::Underline => "underline", + CursorStyle::BlinkingUnderline => "blinking underline", + CursorStyle::Bar => "bar", + CursorStyle::BlinkingBar => "blinking bar", + } + } + + pub fn from_token(token: &str) -> Option { + CursorStyle::ALL + .into_iter() + .find(|style| style.token() == token) + } + + pub fn to_set_cursor_style(self) -> SetCursorStyle { + match self { + CursorStyle::Block => SetCursorStyle::SteadyBlock, + CursorStyle::BlinkingBlock => SetCursorStyle::BlinkingBlock, + CursorStyle::Underline => SetCursorStyle::SteadyUnderScore, + CursorStyle::BlinkingUnderline => SetCursorStyle::BlinkingUnderScore, + CursorStyle::Bar => SetCursorStyle::SteadyBar, + CursorStyle::BlinkingBar => SetCursorStyle::BlinkingBar, + } + } +} diff --git a/src/domain/game_mode.rs b/src/domain/game_mode.rs new file mode 100644 index 0000000..a24235f --- /dev/null +++ b/src/domain/game_mode.rs @@ -0,0 +1,20 @@ +use clap::ValueEnum; + +#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)] +pub enum GameMode { + Text, + Code, +} + +impl GameMode { + pub fn content_token(self) -> &'static str { + match self { + GameMode::Text => "words", + GameMode::Code => "code", + } + } + + pub fn is_code_content(content: &str) -> bool { + content == GameMode::Code.content_token() + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..6b177c7 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,7 @@ +mod cursor; +mod game_mode; +mod modifier; + +pub use cursor::CursorStyle; +pub use game_mode::GameMode; +pub use modifier::ModeType; diff --git a/src/domain/modifier.rs b/src/domain/modifier.rs new file mode 100644 index 0000000..ef620a7 --- /dev/null +++ b/src/domain/modifier.rs @@ -0,0 +1,34 @@ +use std::str::FromStr; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ModeType { + Normal, + Uppercase, + Punctuation, + Numbers, +} + +impl ModeType { + pub fn token(self) -> &'static str { + match self { + ModeType::Normal => "normal", + ModeType::Uppercase => "uppercase", + ModeType::Punctuation => "punctuation", + ModeType::Numbers => "numbers", + } + } +} + +impl FromStr for ModeType { + type Err = (); + + fn from_str(input: &str) -> Result { + match input { + "normal" => Ok(ModeType::Normal), + "uppercase" => Ok(ModeType::Uppercase), + "punctuation" => Ok(ModeType::Punctuation), + "numbers" => Ok(ModeType::Numbers), + _ => Err(()), + } + } +} diff --git a/src/main.rs b/src/main.rs index f4fc833..a3c6c6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,10 @@ mod app; mod cli; -mod config; -mod mode; -mod scores; -mod settings; -mod theme; -mod tui; -mod typing; -mod ui; -mod word_provider; +mod content; +mod domain; +mod persistence; +mod session; +mod view; use anyhow::Result; diff --git a/src/mode/mod.rs b/src/mode/mod.rs deleted file mode 100644 index e99f9b4..0000000 --- a/src/mode/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod mode_selector; - -pub use mode_selector::{Mode, ModeType}; diff --git a/src/persistence/config/constants/defaults.rs b/src/persistence/config/constants/defaults.rs new file mode 100644 index 0000000..10bd810 --- /dev/null +++ b/src/persistence/config/constants/defaults.rs @@ -0,0 +1,3 @@ +pub const LANGUAGE: &str = "english"; +pub const CODE_LANGUAGE: &str = "any"; +pub const TIME: u64 = 30; diff --git a/src/persistence/config/constants/keys.rs b/src/persistence/config/constants/keys.rs new file mode 100644 index 0000000..1eb4122 --- /dev/null +++ b/src/persistence/config/constants/keys.rs @@ -0,0 +1,10 @@ +pub const THEME: &str = "theme"; +pub const CURSOR: &str = "cursor"; +pub const CONTENT: &str = "content"; +pub const CODE_LANGUAGE: &str = "code_language"; +pub const LANGUAGE: &str = "language"; +pub const LANG: &str = "lang"; +pub const MODES: &str = "modes"; +pub const DEFAULT_MODE: &str = "default_mode"; +pub const GAME: &str = "game"; +pub const TIME: &str = "time"; diff --git a/src/persistence/config/constants/mod.rs b/src/persistence/config/constants/mod.rs new file mode 100644 index 0000000..40cef64 --- /dev/null +++ b/src/persistence/config/constants/mod.rs @@ -0,0 +1,2 @@ +pub mod defaults; +pub mod keys; diff --git a/src/persistence/config/mod.rs b/src/persistence/config/mod.rs new file mode 100644 index 0000000..ab17495 --- /dev/null +++ b/src/persistence/config/mod.rs @@ -0,0 +1,7 @@ +pub mod constants; +mod tables; +pub mod toml_parser; +mod utils; + +pub use tables::*; +pub use utils::*; diff --git a/src/config/config_tables/language.rs b/src/persistence/config/tables/language.rs similarity index 59% rename from src/config/config_tables/language.rs rename to src/persistence/config/tables/language.rs index 8c6fdd1..df255df 100644 --- a/src/config/config_tables/language.rs +++ b/src/persistence/config/tables/language.rs @@ -1,4 +1,5 @@ -use crate::config::toml_parser::get_config; +use crate::persistence::config::constants::defaults; +use crate::persistence::config::toml_parser::get_config; pub struct Language { pub lang: String, @@ -8,7 +9,9 @@ impl Language { pub fn new() -> Self { let theme_colors: Language = match get_config().lock().unwrap().get_language() { Some(language) => { - let lang = language.lang.unwrap_or("english".to_string()); + let lang = language + .lang + .unwrap_or_else(|| defaults::LANGUAGE.to_string()); Language { lang } } @@ -21,7 +24,7 @@ impl Language { impl Default for Language { fn default() -> Self { Language { - lang: "english".to_string(), + lang: defaults::LANGUAGE.to_string(), } } } diff --git a/src/config/config_tables/mod.rs b/src/persistence/config/tables/mod.rs similarity index 100% rename from src/config/config_tables/mod.rs rename to src/persistence/config/tables/mod.rs diff --git a/src/config/config_tables/mode_settings.rs b/src/persistence/config/tables/mode_settings.rs similarity index 90% rename from src/config/config_tables/mode_settings.rs rename to src/persistence/config/tables/mode_settings.rs index 620b715..6e79ce9 100644 --- a/src/config/config_tables/mode_settings.rs +++ b/src/persistence/config/tables/mode_settings.rs @@ -1,5 +1,5 @@ -use crate::config::toml_parser::get_config; -use crate::mode::ModeType; +use crate::persistence::config::toml_parser::get_config; +use crate::session::modifiers::ModeType; use std::str::FromStr; #[derive(Debug)] @@ -7,6 +7,7 @@ pub struct ModeSettings { pub default_modes: Vec, pub uppercase_chance: f32, pub punctuation_chance: f32, + pub numbers_chance: f32, } impl ModeSettings { @@ -42,6 +43,7 @@ impl ModeSettings { default_modes, uppercase_chance, punctuation_chance, + numbers_chance: 0.2, } } None => ModeSettings::default(), @@ -56,6 +58,7 @@ impl Default for ModeSettings { default_modes: vec![ModeType::Normal], uppercase_chance: 0.2, punctuation_chance: 0.2, + numbers_chance: 0.2, } } } diff --git a/src/config/toml_parser.rs b/src/persistence/config/toml_parser.rs similarity index 89% rename from src/config/toml_parser.rs rename to src/persistence/config/toml_parser.rs index 0481742..71530cd 100644 --- a/src/config/toml_parser.rs +++ b/src/persistence/config/toml_parser.rs @@ -27,6 +27,8 @@ pub struct GameTable { pub struct ConfigToml { theme: Option, cursor: Option, + content: Option, + code_language: Option, modes: Option, language: Option, game: Option, @@ -63,6 +65,14 @@ impl ConfigToml { self.cursor.clone() } + pub fn get_content(&self) -> Option { + self.content.clone() + } + + pub fn get_code_language(&self) -> Option { + self.code_language.clone() + } + pub fn get_modes(&self) -> Option { self.modes.clone() } diff --git a/src/config/config_utils.rs b/src/persistence/config/utils.rs similarity index 82% rename from src/config/config_utils.rs rename to src/persistence/config/utils.rs index 065b946..21621dd 100644 --- a/src/config/config_utils.rs +++ b/src/persistence/config/utils.rs @@ -3,6 +3,8 @@ use dirs::home_dir; use std::{fs, io::Write, path::PathBuf, process::Command}; use toml_edit::{value, DocumentMut, Item, Table}; +use crate::persistence::config::constants::keys; + pub fn create_config() -> Result<()> { if let Some(home_path) = home_dir() { let config_dir = home_path.join(".config/typy"); @@ -33,6 +35,8 @@ fn config_path() -> Result { pub fn save_settings( theme: &str, cursor: &str, + content: &str, + code_language: &str, language: &str, mode: &str, time: u64, @@ -45,11 +49,14 @@ pub fn save_settings( .parse::() .context("Failed to parse config file")?; - doc.as_table_mut().insert("theme", value(theme)); - doc.as_table_mut().insert("cursor", value(cursor)); - set_kv(&mut doc, "language", "lang", value(language)); - set_kv(&mut doc, "modes", "default_mode", value(mode)); - set_kv(&mut doc, "game", "time", value(time as i64)); + doc.as_table_mut().insert(keys::THEME, value(theme)); + doc.as_table_mut().insert(keys::CURSOR, value(cursor)); + doc.as_table_mut().insert(keys::CONTENT, value(content)); + doc.as_table_mut() + .insert(keys::CODE_LANGUAGE, value(code_language)); + set_kv(&mut doc, keys::LANGUAGE, keys::LANG, value(language)); + set_kv(&mut doc, keys::MODES, keys::DEFAULT_MODE, value(mode)); + set_kv(&mut doc, keys::GAME, keys::TIME, value(time as i64)); fs::write(&path, doc.to_string()).context("Failed to write config file")?; Ok(()) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs new file mode 100644 index 0000000..cb3cb6e --- /dev/null +++ b/src/persistence/mod.rs @@ -0,0 +1,2 @@ +pub mod config; +pub mod scores; diff --git a/src/scores/mod.rs b/src/persistence/scores/mod.rs similarity index 100% rename from src/scores/mod.rs rename to src/persistence/scores/mod.rs diff --git a/src/scores/progress/data.rs b/src/persistence/scores/progress/data.rs similarity index 100% rename from src/scores/progress/data.rs rename to src/persistence/scores/progress/data.rs diff --git a/src/scores/progress/mod.rs b/src/persistence/scores/progress/mod.rs similarity index 62% rename from src/scores/progress/mod.rs rename to src/persistence/scores/progress/mod.rs index 35ca48b..ea11103 100644 --- a/src/scores/progress/mod.rs +++ b/src/persistence/scores/progress/mod.rs @@ -1,4 +1,3 @@ mod data; -pub mod display; pub use data::*; diff --git a/src/scores/stats.rs b/src/persistence/scores/stats.rs similarity index 100% rename from src/scores/stats.rs rename to src/persistence/scores/stats.rs diff --git a/src/session/code.rs b/src/session/code.rs new file mode 100644 index 0000000..1ceb17a --- /dev/null +++ b/src/session/code.rs @@ -0,0 +1,194 @@ +use std::time::Instant; + +use anyhow::{Context, Result}; + +use crate::content::code; +use crate::persistence::scores::Stats; +use crate::content::highlight::{self, Token}; + +pub struct CodeSession { + pub language: String, + pub path: String, + pub url: String, + pub target: Vec, + pub highlights: Vec, + pub typeable: Vec, + pub typed: Vec>, + pub cursor: usize, + pub stats: Stats, + start: Option, + sampled_secs: u64, + finished: bool, +} + +impl CodeSession { + pub fn new(selector: &str) -> Result { + let snippet = code::get_snippet(selector).context("Failed to get code snippet")?; + let target: Vec = snippet.content.chars().collect(); + let highlights = highlight::highlight(&snippet.content, &snippet.language); + let typeable = compute_typeable(&target); + let len = target.len(); + + let mut session = CodeSession { + language: snippet.language, + path: snippet.path, + url: snippet.url, + target, + highlights, + typeable, + typed: vec![None; len], + cursor: 0, + stats: Stats::new(), + start: None, + sampled_secs: 0, + finished: false, + }; + session.cursor = session.next_typeable(0); + if session.cursor >= len { + session.finished = true; + } + Ok(session) + } + + pub fn is_finished(&self) -> bool { + self.finished + } + + fn next_typeable(&self, from: usize) -> usize { + let mut i = from; + while i < self.target.len() && !self.typeable[i] { + i += 1; + } + i + } + + pub fn type_char(&mut self, c: char) { + if self.finished || self.cursor >= self.target.len() { + return; + } + if self.target[self.cursor] == '\n' { + return; + } + if self.start.is_none() { + self.start = Some(Instant::now()); + } + self.typed[self.cursor] = Some(c); + self.stats.letter_count += 1; + self.advance(); + } + + pub fn newline(&mut self) { + if self.finished || self.cursor >= self.target.len() { + return; + } + if self.target[self.cursor] != '\n' { + return; + } + if self.start.is_none() { + self.start = Some(Instant::now()); + } + self.typed[self.cursor] = Some('\n'); + self.stats.letter_count += 1; + self.advance(); + } + + fn advance(&mut self) { + self.cursor = self.next_typeable(self.cursor + 1); + if self.cursor >= self.target.len() { + self.finish(); + } + } + + pub fn backspace(&mut self) { + if self.finished { + return; + } + let mut i = self.cursor; + while i > 0 { + i -= 1; + if self.typeable[i] { + self.typed[i] = None; + self.cursor = i; + return; + } + } + } + + pub fn tick(&mut self) { + let Some(start) = self.start else { + return; + }; + if self.finished { + return; + } + let elapsed = start.elapsed().as_secs(); + while self.sampled_secs < elapsed { + self.stats.add_letters(); + self.sampled_secs += 1; + } + } + + fn finish(&mut self) { + if self.finished { + return; + } + self.stats.add_letters(); + let (correct, incorrect, missed, newlines) = self.tally(); + let elapsed = self.start.map(|s| s.elapsed().as_secs_f64()).unwrap_or(0.0); + self.stats + .finalize(correct, incorrect, 0, missed, newlines, elapsed); + self.finished = true; + } + + fn tally(&self) -> (i32, i32, i32, i32) { + let mut correct = 0; + let mut incorrect = 0; + let mut missed = 0; + let mut newlines = 0; + + for i in 0..self.target.len() { + if !self.typeable[i] { + continue; + } + let target = self.target[i]; + match self.typed[i] { + Some(_) if target == '\n' => newlines += 1, + Some(c) if c == target => correct += 1, + Some(_) => incorrect += 1, + None if target != '\n' => missed += 1, + None => {} + } + } + + (correct, incorrect, missed, newlines) + } + + pub fn live_wpm(&self) -> u32 { + let Some(start) = self.start else { + return 0; + }; + let minutes = start.elapsed().as_secs_f64() / 60.0; + if minutes <= 0.0 { + return 0; + } + let (correct, _, _, newlines) = self.tally(); + (((correct + newlines) as f64 / 5.0) / minutes).max(0.0) as u32 + } +} + +fn compute_typeable(target: &[char]) -> Vec { + let mut typeable = vec![true; target.len()]; + let mut at_line_start = true; + for (i, &c) in target.iter().enumerate() { + if c == '\n' { + at_line_start = true; + continue; + } + if at_line_start && c == ' ' { + typeable[i] = false; + continue; + } + at_line_start = false; + } + typeable +} diff --git a/src/typing/engine.rs b/src/session/engine.rs similarity index 96% rename from src/typing/engine.rs rename to src/session/engine.rs index 37b74c2..79d8c04 100644 --- a/src/typing/engine.rs +++ b/src/session/engine.rs @@ -2,9 +2,9 @@ use std::time::Instant; use anyhow::{Context, Result}; -use crate::mode::Mode; -use crate::scores::Stats; -use crate::word_provider; +use crate::session::modifiers::Mode; +use crate::persistence::scores::Stats; +use crate::content::words; pub struct Word { pub target: Vec, @@ -33,7 +33,7 @@ pub struct TypingSession { impl TypingSession { pub fn new(mode: &Mode, language: &str) -> Result { let min_words = (mode.duration as usize).saturating_mul(5).max(60); - let mut list = word_provider::get_words(language, min_words) + let mut list = words::get_words(language, min_words) .context("Failed to get words from file")?; mode.transform(&mut list); diff --git a/src/session/mod.rs b/src/session/mod.rs new file mode 100644 index 0000000..16f62a6 --- /dev/null +++ b/src/session/mod.rs @@ -0,0 +1,6 @@ +mod code; +mod engine; +pub mod modifiers; + +pub use code::CodeSession; +pub use engine::{TypingSession, Word}; diff --git a/src/mode/mode_selector.rs b/src/session/modifiers.rs similarity index 79% rename from src/mode/mode_selector.rs rename to src/session/modifiers.rs index afcd417..23a03d6 100644 --- a/src/mode/mode_selector.rs +++ b/src/session/modifiers.rs @@ -2,37 +2,9 @@ use anyhow::Result; use rand::Rng; use std::str::FromStr; -use crate::config::mode_settings::ModeSettings; +use crate::persistence::config::mode_settings::ModeSettings; -#[derive(Debug, PartialEq, Clone)] -pub enum ModeType { - Normal, - Uppercase, - Punctuation, -} - -impl ModeType { - pub fn token(&self) -> &'static str { - match self { - ModeType::Normal => "normal", - ModeType::Uppercase => "uppercase", - ModeType::Punctuation => "punctuation", - } - } -} - -impl FromStr for ModeType { - type Err = (); - - fn from_str(input: &str) -> Result { - match input { - "uppercase" => Ok(ModeType::Uppercase), - "punctuation" => Ok(ModeType::Punctuation), - "normal" => Ok(ModeType::Normal), - _ => Err(()), - } - } -} +pub use crate::domain::ModeType; #[derive(Debug)] pub struct Mode { @@ -47,17 +19,15 @@ impl Mode { let settings = ModeSettings::new(); for mode_str in mode_strs { - match mode_str { - "normal" => modes.push(ModeType::Normal), - "uppercase" => modes.push(ModeType::Uppercase), - "punctuation" => modes.push(ModeType::Punctuation), - _ => return Err(anyhow::anyhow!("Invalid mode: {}", mode_str)), + match ModeType::from_str(mode_str) { + Ok(mode) => modes.push(mode), + Err(_) => return Err(anyhow::anyhow!("Invalid mode: {}", mode_str)), } } modes.is_empty().then(|| { settings.default_modes.iter().for_each(|m| { - modes.push(m.clone()); + modes.push(*m); }); }); @@ -113,6 +83,16 @@ impl Mode { } } } + ModeType::Numbers => { + for sublist in list.iter_mut() { + for item in sublist.iter_mut() { + if rng.random_bool(self.settings.numbers_chance.into()) { + let n: u32 = rng.random_range(0..10000); + *item = n.to_string(); + } + } + } + } ModeType::Normal => {} } } diff --git a/src/settings/mod.rs b/src/settings/mod.rs deleted file mode 100644 index cf563d1..0000000 --- a/src/settings/mod.rs +++ /dev/null @@ -1,191 +0,0 @@ -use std::collections::BTreeSet; - -use crate::theme::available_themes; -use crate::word_provider::available_languages; - -const TIME_OPTIONS: &[u64] = &[15, 30, 60, 120]; - -const CURSOR_OPTIONS: &[&str] = &[ - "block", - "blinking block", - "underline", - "blinking underline", - "bar", - "blinking bar", -]; - -fn mode_options() -> Vec<(&'static str, Vec<&'static str>)> { - vec![ - ("normal", vec!["normal"]), - ("uppercase", vec!["uppercase"]), - ("punctuation", vec!["punctuation"]), - ("uppercase + punctuation", vec!["uppercase", "punctuation"]), - ] -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Field { - Theme, - Cursor, - Language, - Mode, - Time, -} - -pub struct Row { - pub label: &'static str, - pub field: Field, - pub options: Vec, - pub selected: usize, -} - -pub struct SettingsState { - pub rows: Vec, - pub cursor: usize, - pub open: bool, - pub dropdown_cursor: usize, -} - -impl SettingsState { - pub fn new( - theme: &str, - cursor: &str, - language: &str, - mode_tokens: &[String], - time: u64, - ) -> Self { - let themes = available_themes(); - let theme_sel = themes.iter().position(|t| t == theme).unwrap_or(0); - - let cursor_options: Vec = CURSOR_OPTIONS.iter().map(|c| c.to_string()).collect(); - let cursor_sel = cursor_options.iter().position(|c| c == cursor).unwrap_or(0); - - let languages = available_languages(); - let language_sel = languages.iter().position(|l| l == language).unwrap_or(0); - - let modes = mode_options(); - let mode_labels: Vec = modes.iter().map(|(l, _)| l.to_string()).collect(); - let mode_sel = modes - .iter() - .position(|(_, toks)| tokens_match(toks, mode_tokens)) - .unwrap_or(0); - - let time_labels: Vec = TIME_OPTIONS.iter().map(|t| t.to_string()).collect(); - let time_sel = TIME_OPTIONS.iter().position(|&t| t == time).unwrap_or(1); - - let rows = vec![ - Row { - label: "theme", - field: Field::Theme, - options: themes, - selected: theme_sel, - }, - Row { - label: "cursor", - field: Field::Cursor, - options: cursor_options, - selected: cursor_sel, - }, - Row { - label: "language", - field: Field::Language, - options: languages, - selected: language_sel, - }, - Row { - label: "mode", - field: Field::Mode, - options: mode_labels, - selected: mode_sel, - }, - Row { - label: "time", - field: Field::Time, - options: time_labels, - selected: time_sel, - }, - ]; - - SettingsState { - rows, - cursor: 0, - open: false, - dropdown_cursor: 0, - } - } - - pub fn move_down(&mut self) { - if self.open { - let len = self.rows[self.cursor].options.len(); - self.dropdown_cursor = (self.dropdown_cursor + 1).min(len.saturating_sub(1)); - } else { - self.cursor = (self.cursor + 1).min(self.rows.len().saturating_sub(1)); - } - } - - pub fn move_up(&mut self) { - if self.open { - self.dropdown_cursor = self.dropdown_cursor.saturating_sub(1); - } else { - self.cursor = self.cursor.saturating_sub(1); - } - } - - pub fn open(&mut self) { - self.open = true; - self.dropdown_cursor = self.rows[self.cursor].selected; - } - - pub fn close(&mut self) { - self.open = false; - } - - pub fn confirm(&mut self) { - self.rows[self.cursor].selected = self.dropdown_cursor; - self.open = false; - } - - fn option_of(&self, field: Field) -> &str { - let row = self - .rows - .iter() - .find(|r| r.field == field) - .expect("settings row missing"); - &row.options[row.selected] - } - - pub fn theme_name(&self) -> String { - self.option_of(Field::Theme).to_string() - } - - pub fn cursor_style(&self) -> String { - self.option_of(Field::Cursor).to_string() - } - - pub fn language(&self) -> String { - self.option_of(Field::Language).to_string() - } - - pub fn time(&self) -> u64 { - self.option_of(Field::Time).parse().unwrap_or(30) - } - - pub fn mode_tokens(&self) -> Vec { - let label = self.option_of(Field::Mode); - mode_options() - .into_iter() - .find(|(l, _)| *l == label) - .map(|(_, toks)| toks.iter().map(|s| s.to_string()).collect()) - .unwrap_or_else(|| vec!["normal".to_string()]) - } - - pub fn mode_default_string(&self) -> String { - self.mode_tokens().join(", ") - } -} - -fn tokens_match(preset: &[&str], active: &[String]) -> bool { - let a: BTreeSet = preset.iter().map(|s| s.to_string()).collect(); - let b: BTreeSet = active.iter().cloned().collect(); - a == b -} diff --git a/src/typing/mod.rs b/src/typing/mod.rs deleted file mode 100644 index 053f7fb..0000000 --- a/src/typing/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod engine; - -pub use engine::{TypingSession, Word}; diff --git a/src/ui/typing.rs b/src/ui/typing.rs deleted file mode 100644 index de026ea..0000000 --- a/src/ui/typing.rs +++ /dev/null @@ -1,167 +0,0 @@ -use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::Paragraph; -use ratatui::Frame; - -use crate::app::App; -use crate::theme::Theme; -use crate::typing::{TypingSession, Word}; - -pub fn render(frame: &mut Frame, app: &App) { - let theme = &app.theme; - let Some(session) = app.session.as_ref() else { - return; - }; - let area = frame.area(); - - let width = area.width.saturating_mul(6) / 10; - let column = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length((area.width.saturating_sub(width)) / 2), - Constraint::Length(width), - Constraint::Min(0), - ]) - .split(area)[1]; - - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(0), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(3), - Constraint::Min(0), - ]) - .split(column); - - render_language(frame, rows[1], &app.language, theme); - render_status(frame, rows[3], session, theme); - render_words(frame, rows[5], session, theme); - render_hint(frame, area, theme); -} - -fn render_language(frame: &mut Frame, area: Rect, language: &str, theme: &Theme) { - let line = Line::from(vec![ - Span::styled( - language.to_string(), - Style::default() - .fg(theme.accent) - .add_modifier(Modifier::BOLD), - ), - ]); - frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), area); -} - -fn render_status(frame: &mut Frame, area: Rect, session: &TypingSession, theme: &Theme) { - let line = Line::from(vec![ - Span::styled( - format!("{:>2}s", session.remaining_secs()), - Style::default() - .fg(theme.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" ", Style::default()), - Span::styled( - format!("{} wpm", live_wpm(session)), - Style::default().fg(theme.missing), - ), - ]); - frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), area); -} - -fn render_words(frame: &mut Frame, area: Rect, session: &TypingSession, theme: &Theme) { - let width = area.width.max(1) as usize; - - let mut lines: Vec = Vec::new(); - let mut current: Vec = Vec::new(); - let mut col = 0usize; - let mut caret_line = 0usize; - let mut caret_col = 0u16; - - for (wi, word) in session.words.iter().enumerate() { - let word_len = word.target.len().max(word.typed.len()); - - if col > 0 && col + 1 + word_len > width { - lines.push(Line::from(std::mem::take(&mut current))); - col = 0; - } - if col > 0 { - current.push(Span::raw(" ")); - col += 1; - } - - if wi == session.cursor_word { - caret_line = lines.len(); - let within = word.typed.len().min(word_len); - caret_col = (col + within) as u16; - } - - for i in 0..word_len { - let (ch, style) = char_style(word, i, theme); - current.push(Span::styled(ch.to_string(), style)); - } - col += word_len; - } - if !current.is_empty() { - lines.push(Line::from(current)); - } - - let window_start = caret_line.saturating_sub(1); - let visible: Vec = lines - .into_iter() - .skip(window_start) - .take(area.height as usize) - .collect(); - frame.render_widget(Paragraph::new(visible), area); - - let cursor_row = (caret_line - window_start) as u16; - if cursor_row < area.height { - let x = area.x + caret_col.min(area.width.saturating_sub(1)); - let y = area.y + cursor_row; - frame.set_cursor_position((x, y)); - } -} - -fn char_style(word: &Word, i: usize, theme: &Theme) -> (char, Style) { - if i < word.typed.len() { - if i < word.target.len() { - let ok = word.typed[i] == word.target[i]; - ( - word.target[i], - Style::default().fg(if ok { theme.fg } else { theme.error }), - ) - } else { - ( - word.typed[i], - Style::default() - .fg(theme.error) - .add_modifier(Modifier::UNDERLINED), - ) - } - } else { - (word.target[i], Style::default().fg(theme.missing)) - } -} - -fn render_hint(frame: &mut Frame, area: Rect, theme: &Theme) { - let bar = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(1)]) - .split(area)[1]; - frame.render_widget( - Paragraph::new(Span::styled( - "esc cancel", - Style::default().fg(theme.missing), - )) - .alignment(Alignment::Center), - bar, - ); -} - -fn live_wpm(session: &TypingSession) -> u32 { - session.live_wpm() -} diff --git a/src/view/mod.rs b/src/view/mod.rs new file mode 100644 index 0000000..33e47f3 --- /dev/null +++ b/src/view/mod.rs @@ -0,0 +1,5 @@ +pub mod progress; +pub mod settings; +pub mod terminal; +pub mod theme; +pub mod ui; diff --git a/src/scores/progress/display.rs b/src/view/progress.rs similarity index 98% rename from src/scores/progress/display.rs rename to src/view/progress.rs index b7b2ec7..8448c6b 100644 --- a/src/scores/progress/display.rs +++ b/src/view/progress.rs @@ -1,7 +1,7 @@ use std::io::{stdout, Write}; use std::time::Duration; -use super::*; +use crate::persistence::scores::progress::{Averages, Data, Score}; use anyhow::{Context, Result}; use comfy_table::presets::UTF8_FULL; use comfy_table::*; diff --git a/src/view/settings/constants/mod.rs b/src/view/settings/constants/mod.rs new file mode 100644 index 0000000..94a590c --- /dev/null +++ b/src/view/settings/constants/mod.rs @@ -0,0 +1,2 @@ +pub mod text; +pub mod values; diff --git a/src/view/settings/constants/text.rs b/src/view/settings/constants/text.rs new file mode 100644 index 0000000..268717c --- /dev/null +++ b/src/view/settings/constants/text.rs @@ -0,0 +1,14 @@ +pub const LABEL_MODE: &str = "mode"; +pub const LABEL_THEME: &str = "theme"; +pub const LABEL_CURSOR: &str = "cursor"; +pub const LABEL_CODE_LANGUAGE: &str = "code lang"; +pub const LABEL_PUNCTUATION: &str = "punctuation"; +pub const LABEL_NUMBERS: &str = "numbers"; +pub const LABEL_UPPERCASE: &str = "uppercase"; +pub const LABEL_LANGUAGE: &str = "language"; +pub const LABEL_TIME: &str = "time"; + +pub const OPTION_OFF: &str = "off"; +pub const OPTION_ON: &str = "on"; +pub const OPTION_TYPING: &str = "typing"; +pub const OPTION_CODING: &str = "coding"; diff --git a/src/view/settings/constants/values.rs b/src/view/settings/constants/values.rs new file mode 100644 index 0000000..91f00a4 --- /dev/null +++ b/src/view/settings/constants/values.rs @@ -0,0 +1 @@ +pub const TIME_OPTIONS: &[u64] = &[15, 30, 60, 120]; diff --git a/src/view/settings/mod.rs b/src/view/settings/mod.rs new file mode 100644 index 0000000..c4ffa90 --- /dev/null +++ b/src/view/settings/mod.rs @@ -0,0 +1,266 @@ +mod constants; + +use crate::content::code; +use crate::persistence::config::constants::defaults; +use crate::domain::{CursorStyle, ModeType}; +use crate::view::theme::available_themes; +use crate::content::words::available_languages; + +use constants::text; +use constants::values::TIME_OPTIONS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Field { + Mode, + Theme, + Cursor, + CodeLanguage, + Punctuation, + Numbers, + Uppercase, + Language, + Time, +} + +pub struct Row { + pub label: &'static str, + pub field: Field, + pub options: Vec, + pub selected: usize, +} + +pub struct SettingsState { + coding: bool, + theme: String, + cursor_style: String, + code_language: String, + punctuation: bool, + numbers: bool, + uppercase: bool, + language: String, + time: u64, + + themes: Vec, + cursors: Vec, + code_languages: Vec, + languages: Vec, + + pub rows: Vec, + pub cursor: usize, + pub open: bool, + pub dropdown_cursor: usize, +} + +impl SettingsState { + #[allow(clippy::too_many_arguments)] + pub fn new( + theme: &str, + cursor: &str, + coding: bool, + code_language: &str, + language: &str, + mode_tokens: &[String], + time: u64, + ) -> Self { + let has = |mode: ModeType| mode_tokens.iter().any(|t| t == mode.token()); + + let mut code_languages = vec![defaults::CODE_LANGUAGE.to_string()]; + code_languages.extend(code::available_languages()); + + let code_language = if code_language.is_empty() { + defaults::CODE_LANGUAGE.to_string() + } else { + code_language.to_string() + }; + + let mut state = SettingsState { + coding, + theme: theme.to_string(), + cursor_style: cursor.to_string(), + code_language, + punctuation: has(ModeType::Punctuation), + numbers: has(ModeType::Numbers), + uppercase: has(ModeType::Uppercase), + language: language.to_string(), + time, + themes: available_themes(), + cursors: CursorStyle::ALL + .iter() + .map(|c| c.token().to_string()) + .collect(), + code_languages, + languages: available_languages(), + rows: Vec::new(), + cursor: 0, + open: false, + dropdown_cursor: 0, + }; + state.rebuild_rows(); + state + } + + fn rebuild_rows(&mut self) { + let onoff = || vec![text::OPTION_OFF.to_string(), text::OPTION_ON.to_string()]; + let pick = |options: &[String], value: &str| { + options.iter().position(|o| o == value).unwrap_or(0) + }; + + let mut rows = vec![ + Row { + label: text::LABEL_MODE, + field: Field::Mode, + options: vec![ + text::OPTION_TYPING.to_string(), + text::OPTION_CODING.to_string(), + ], + selected: self.coding as usize, + }, + Row { + label: text::LABEL_THEME, + field: Field::Theme, + options: self.themes.clone(), + selected: pick(&self.themes, &self.theme), + }, + Row { + label: text::LABEL_CURSOR, + field: Field::Cursor, + options: self.cursors.clone(), + selected: pick(&self.cursors, &self.cursor_style), + }, + ]; + + if self.coding { + rows.push(Row { + label: text::LABEL_CODE_LANGUAGE, + field: Field::CodeLanguage, + options: self.code_languages.clone(), + selected: pick(&self.code_languages, &self.code_language), + }); + } else { + rows.push(Row { + label: text::LABEL_PUNCTUATION, + field: Field::Punctuation, + options: onoff(), + selected: self.punctuation as usize, + }); + rows.push(Row { + label: text::LABEL_NUMBERS, + field: Field::Numbers, + options: onoff(), + selected: self.numbers as usize, + }); + rows.push(Row { + label: text::LABEL_UPPERCASE, + field: Field::Uppercase, + options: onoff(), + selected: self.uppercase as usize, + }); + rows.push(Row { + label: text::LABEL_LANGUAGE, + field: Field::Language, + options: self.languages.clone(), + selected: pick(&self.languages, &self.language), + }); + rows.push(Row { + label: text::LABEL_TIME, + field: Field::Time, + options: TIME_OPTIONS.iter().map(|t| t.to_string()).collect(), + selected: TIME_OPTIONS.iter().position(|&t| t == self.time).unwrap_or(1), + }); + } + + self.rows = rows; + if self.cursor >= self.rows.len() { + self.cursor = self.rows.len().saturating_sub(1); + } + } + + pub fn move_down(&mut self) { + if self.open { + let len = self.rows[self.cursor].options.len(); + self.dropdown_cursor = (self.dropdown_cursor + 1).min(len.saturating_sub(1)); + } else { + self.cursor = (self.cursor + 1).min(self.rows.len().saturating_sub(1)); + } + } + + pub fn move_up(&mut self) { + if self.open { + self.dropdown_cursor = self.dropdown_cursor.saturating_sub(1); + } else { + self.cursor = self.cursor.saturating_sub(1); + } + } + + pub fn open(&mut self) { + self.open = true; + self.dropdown_cursor = self.rows[self.cursor].selected; + } + + pub fn close(&mut self) { + self.open = false; + } + + pub fn confirm(&mut self) { + let field = self.rows[self.cursor].field; + let idx = self.dropdown_cursor; + match field { + Field::Mode => self.coding = idx == 1, + Field::Theme => self.theme = self.themes[idx].clone(), + Field::Cursor => self.cursor_style = self.cursors[idx].clone(), + Field::CodeLanguage => self.code_language = self.code_languages[idx].clone(), + Field::Punctuation => self.punctuation = idx == 1, + Field::Numbers => self.numbers = idx == 1, + Field::Uppercase => self.uppercase = idx == 1, + Field::Language => self.language = self.languages[idx].clone(), + Field::Time => self.time = TIME_OPTIONS.get(idx).copied().unwrap_or(30), + } + self.open = false; + self.rebuild_rows(); + } + + pub fn theme_name(&self) -> String { + self.theme.clone() + } + + pub fn cursor_style(&self) -> String { + self.cursor_style.clone() + } + + pub fn code_mode(&self) -> bool { + self.coding + } + + pub fn code_language(&self) -> String { + self.code_language.clone() + } + + pub fn language(&self) -> String { + self.language.clone() + } + + pub fn time(&self) -> u64 { + self.time + } + + pub fn mode_tokens(&self) -> Vec { + let mut tokens = Vec::new(); + if self.uppercase { + tokens.push(ModeType::Uppercase.token().to_string()); + } + if self.punctuation { + tokens.push(ModeType::Punctuation.token().to_string()); + } + if self.numbers { + tokens.push(ModeType::Numbers.token().to_string()); + } + if tokens.is_empty() { + tokens.push(ModeType::Normal.token().to_string()); + } + tokens + } + + pub fn mode_default_string(&self) -> String { + self.mode_tokens().join(", ") + } +} diff --git a/src/tui/events.rs b/src/view/terminal/events.rs similarity index 100% rename from src/tui/events.rs rename to src/view/terminal/events.rs diff --git a/src/tui/mod.rs b/src/view/terminal/mod.rs similarity index 100% rename from src/tui/mod.rs rename to src/view/terminal/mod.rs diff --git a/src/theme/mod.rs b/src/view/theme/mod.rs similarity index 83% rename from src/theme/mod.rs rename to src/view/theme/mod.rs index 76ed692..4ac3423 100644 --- a/src/theme/mod.rs +++ b/src/view/theme/mod.rs @@ -19,6 +19,10 @@ pub struct Theme { pub graph_data: Color, pub graph_title: Color, pub graph_axis: Color, + pub keyword: Color, + pub string: Color, + pub comment: Color, + pub number: Color, } #[derive(Deserialize)] @@ -31,6 +35,10 @@ struct ThemeSpec { graph_data: Option, graph_title: Option, graph_axis: Option, + keyword: Option, + string: Option, + comment: Option, + number: Option, } impl ThemeSpec { @@ -39,15 +47,21 @@ impl ThemeSpec { let missing = parse_color(self.missing).unwrap_or(Color::Gray); let error = parse_color(self.error).unwrap_or(Color::Red); let accent = parse_color(self.accent).unwrap_or(Color::Yellow); + let graph_data = parse_color(self.graph_data).unwrap_or(accent); + let graph_title = parse_color(self.graph_title).unwrap_or(error); Theme { name: self.name.unwrap_or_else(|| fallback_name.to_string()), fg, missing, error, accent, - graph_data: parse_color(self.graph_data).unwrap_or(accent), - graph_title: parse_color(self.graph_title).unwrap_or(error), + graph_data, + graph_title, graph_axis: parse_color(self.graph_axis).unwrap_or(missing), + keyword: parse_color(self.keyword).unwrap_or(accent), + string: parse_color(self.string).unwrap_or(graph_data), + comment: parse_color(self.comment).unwrap_or(missing), + number: parse_color(self.number).unwrap_or(graph_title), } } } @@ -113,6 +127,10 @@ fn fallback_theme() -> Theme { graph_data: Color::Rgb(166, 227, 161), graph_title: Color::Rgb(203, 166, 247), graph_axis: Color::Rgb(88, 91, 112), + keyword: Color::Rgb(203, 166, 247), + string: Color::Rgb(166, 227, 161), + comment: Color::Rgb(108, 112, 134), + number: Color::Rgb(250, 179, 135), } } diff --git a/src/view/ui/constants/mod.rs b/src/view/ui/constants/mod.rs new file mode 100644 index 0000000..481c63a --- /dev/null +++ b/src/view/ui/constants/mod.rs @@ -0,0 +1 @@ +pub mod text; diff --git a/src/view/ui/constants/text.rs b/src/view/ui/constants/text.rs new file mode 100644 index 0000000..d40a434 --- /dev/null +++ b/src/view/ui/constants/text.rs @@ -0,0 +1,23 @@ +pub const BANNER: &[&str] = &[ + " _ ", + " | |_ _ _ _ __ _ _ ", + " | __| | | | '_ \\| | | |", + " | |_| |_| | |_) | |_| |", + " \\__|\\__, | .__/ \\__, |", + " |___/|_| |___/ ", +]; + +pub const SUBTITLE_CODING: &str = "coding"; +pub const LABEL_BEST: &str = "best "; +pub const SUFFIX_WPM: &str = " wpm"; + +pub const HINT_PRESS: &str = "press "; +pub const HINT_ANY_KEY: &str = "any key"; +pub const HINT_TO_START: &str = " to start typing"; + +pub const KEY_SETTINGS: &str = " s "; +pub const KEY_STATS: &str = " p "; +pub const KEY_QUIT: &str = " q "; +pub const LABEL_SETTINGS: &str = "settings "; +pub const LABEL_STATS: &str = "stats "; +pub const LABEL_QUIT: &str = "quit"; diff --git a/src/ui/home.rs b/src/view/ui/home.rs similarity index 61% rename from src/ui/home.rs rename to src/view/ui/home.rs index 6ca7878..c07b66e 100644 --- a/src/ui/home.rs +++ b/src/view/ui/home.rs @@ -5,16 +5,10 @@ use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::app::App; -use crate::theme::Theme; +use crate::persistence::config::constants::defaults; +use crate::view::theme::Theme; -const BANNER: &[&str] = &[ - " _ ", - " | |_ _ _ _ __ _ _ ", - " | __| | | | '_ \\| | | |", - " | |_| |_| | |_) | |_| |", - " \\__|\\__, | .__/ \\__, |", - " |___/|_| |___/ ", -]; +use super::constants::text; pub fn render(frame: &mut Frame, app: &App) { let theme = &app.theme; @@ -30,7 +24,7 @@ pub fn render(frame: &mut Frame, app: &App) { } fn render_hero(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) { - let mut lines: Vec = BANNER + let mut lines: Vec = text::BANNER .iter() .map(|row| { Line::from(Span::styled( @@ -43,15 +37,24 @@ fn render_hero(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) { .collect(); lines.push(Line::from("")); + let subtitle = if app.code_mode { + if app.code_language.is_empty() || app.code_language == defaults::CODE_LANGUAGE { + text::SUBTITLE_CODING.to_string() + } else { + format!("{} · {}", text::SUBTITLE_CODING, app.code_language) + } + } else { + format!("{} · {}s", app.language, app.time) + }; lines.push(Line::from(Span::styled( - format!("{} · {}s", app.language, app.time), + subtitle, Style::default().fg(theme.missing), ))); - if app.record > 0 { + if !app.code_mode && app.record > 0 { lines.push(Line::from(vec![ - Span::styled("best ", Style::default().fg(theme.missing)), + Span::styled(text::LABEL_BEST, Style::default().fg(theme.missing)), Span::styled( - format!("{} wpm", app.record), + format!("{}{}", app.record, text::SUFFIX_WPM), Style::default() .fg(theme.accent) .add_modifier(Modifier::BOLD), @@ -60,13 +63,20 @@ fn render_hero(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) { } lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("press ", Style::default().fg(theme.missing)), + Span::styled(text::HINT_PRESS, Style::default().fg(theme.missing)), Span::styled( - "any key", + text::HINT_ANY_KEY, Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), ), - Span::styled(" to start typing", Style::default().fg(theme.missing)), + Span::styled(text::HINT_TO_START, Style::default().fg(theme.missing)), ])); + if let Some(error) = &app.error { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + error.clone(), + Style::default().fg(theme.error), + ))); + } let block = super::centered_vertical(area, lines.len() as u16); frame.render_widget( @@ -87,12 +97,12 @@ fn render_command_bar(frame: &mut Frame, area: Rect, theme: &Theme) { let label = |t: &'static str| Span::styled(t, Style::default().fg(theme.missing)); let line = Line::from(vec![ - key(" s "), - label("settings "), - key(" p "), - label("stats "), - key(" q "), - label("quit"), + key(text::KEY_SETTINGS), + label(text::LABEL_SETTINGS), + key(text::KEY_STATS), + label(text::LABEL_STATS), + key(text::KEY_QUIT), + label(text::LABEL_QUIT), ]); frame.render_widget( Paragraph::new(line).alignment(Alignment::Center), diff --git a/src/ui/mod.rs b/src/view/ui/mod.rs similarity index 98% rename from src/ui/mod.rs rename to src/view/ui/mod.rs index bb4d17f..2566062 100644 --- a/src/ui/mod.rs +++ b/src/view/ui/mod.rs @@ -1,3 +1,4 @@ +mod constants; mod home; mod results; mod settings; diff --git a/src/ui/results.rs b/src/view/ui/results.rs similarity index 98% rename from src/ui/results.rs rename to src/view/ui/results.rs index 083524d..ae7656b 100644 --- a/src/ui/results.rs +++ b/src/view/ui/results.rs @@ -6,8 +6,8 @@ use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Paragraph}; use ratatui::Frame; use crate::app::App; -use crate::scores::Stats; -use crate::theme::Theme; +use crate::persistence::scores::Stats; +use crate::view::theme::Theme; const PANEL_WIDTH: u16 = 64; const PANEL_HEIGHT: u16 = 20; @@ -18,7 +18,7 @@ pub fn render(frame: &mut Frame, app: &App) { let Some(session) = app.session.as_ref() else { return; }; - let stats = &session.stats; + let stats = session.stats(); let panel = centered_rect(frame.area(), PANEL_WIDTH, PANEL_HEIGHT); diff --git a/src/ui/settings.rs b/src/view/ui/settings.rs similarity index 98% rename from src/ui/settings.rs rename to src/view/ui/settings.rs index a1aa3a5..6a6e054 100644 --- a/src/ui/settings.rs +++ b/src/view/ui/settings.rs @@ -5,8 +5,8 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragra use ratatui::Frame; use crate::app::App; -use crate::settings::SettingsState; -use crate::theme::Theme; +use crate::view::settings::SettingsState; +use crate::view::theme::Theme; const PANEL_WIDTH: u16 = 52; const VALUE_COL: usize = 16; diff --git a/src/ui/stats.rs b/src/view/ui/stats.rs similarity index 98% rename from src/ui/stats.rs rename to src/view/ui/stats.rs index 0068291..f893af6 100644 --- a/src/ui/stats.rs +++ b/src/view/ui/stats.rs @@ -5,8 +5,8 @@ use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table}; use ratatui::Frame; use crate::app::{App, StatsData}; -use crate::scores::progress::Averages; -use crate::theme::Theme; +use crate::persistence::scores::progress::Averages; +use crate::view::theme::Theme; const PANEL_WIDTH: u16 = 60; diff --git a/src/view/ui/typing.rs b/src/view/ui/typing.rs new file mode 100644 index 0000000..da4a3ee --- /dev/null +++ b/src/view/ui/typing.rs @@ -0,0 +1,347 @@ +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::app::{App, Game}; +use crate::view::theme::Theme; +use crate::content::highlight; +use crate::session::{CodeSession, TypingSession, Word}; + +pub fn render(frame: &mut Frame, app: &App) { + let theme = &app.theme; + let Some(session) = app.session.as_ref() else { + return; + }; + match session { + Game::Words(s) => render_words_mode(frame, app, s, theme), + Game::Code(s) => render_code_mode(frame, s, theme), + } +} + +fn render_words_mode(frame: &mut Frame, app: &App, session: &TypingSession, theme: &Theme) { + let area = frame.area(); + + let width = area.width.saturating_mul(6) / 10; + let column = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length((area.width.saturating_sub(width)) / 2), + Constraint::Length(width), + Constraint::Min(0), + ]) + .split(area)[1]; + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(column); + + render_language(frame, rows[1], &app.language, theme); + render_status(frame, rows[3], session, theme); + render_words(frame, rows[5], session, theme); + render_hint(frame, area, theme); +} + +fn render_code_mode(frame: &mut Frame, session: &CodeSession, theme: &Theme) { + let area = frame.area(); + + let (line_count, max_line) = code_dimensions(&session.target); + let width = (max_line as u16 + 1).clamp(20, area.width.saturating_sub(4).max(20)); + let code_height = (line_count as u16).clamp(1, area.height.saturating_sub(6).max(1)); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(code_height), + Constraint::Min(0), + ]) + .split(area); + + let code_area = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length((area.width.saturating_sub(width)) / 2), + Constraint::Length(width), + Constraint::Min(0), + ]) + .split(rows[3])[1]; + + render_code_status(frame, rows[1], session, theme); + render_code(frame, code_area, session, theme); + render_code_footer(frame, area, session, theme); +} + +fn code_dimensions(target: &[char]) -> (usize, usize) { + let mut lines = 1; + let mut max = 0; + let mut current = 0; + for &c in target { + if c == '\n' { + lines += 1; + max = max.max(current); + current = 0; + } else { + current += 1; + } + } + max = max.max(current); + (lines, max) +} + +fn render_code_status(frame: &mut Frame, area: Rect, session: &CodeSession, theme: &Theme) { + let file_name = session.path.rsplit('/').next().unwrap_or(&session.path); + let line = Line::from(vec![ + Span::styled( + file_name.to_string(), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" · {} · ", session.language), Style::default().fg(theme.missing)), + Span::styled( + format!("{} wpm", session.live_wpm()), + Style::default().fg(theme.missing), + ), + ]); + frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), area); +} + +fn render_code_footer(frame: &mut Frame, area: Rect, session: &CodeSession, theme: &Theme) { + let bottom = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(area)[1]; + + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(12)]) + .split(bottom); + + frame.render_widget( + Paragraph::new(Span::styled( + session.url.clone(), + Style::default().fg(theme.missing), + )) + .alignment(Alignment::Left), + cols[0], + ); + frame.render_widget( + Paragraph::new(Span::styled( + "esc cancel", + Style::default().fg(theme.missing), + )) + .alignment(Alignment::Right), + cols[1], + ); +} + +fn render_code(frame: &mut Frame, area: Rect, session: &CodeSession, theme: &Theme) { + let mut lines_chars: Vec> = vec![Vec::new()]; + let mut line_starts: Vec = vec![0]; + for (i, &ch) in session.target.iter().enumerate() { + if ch == '\n' { + lines_chars.push(Vec::new()); + line_starts.push(i + 1); + } else { + lines_chars.last_mut().unwrap().push(ch); + } + } + + let mut caret_line = lines_chars.len().saturating_sub(1); + let mut caret_col = 0u16; + for (k, &start) in line_starts.iter().enumerate() { + let end = if k + 1 < line_starts.len() { + line_starts[k + 1] - 1 + } else { + session.target.len() + }; + if session.cursor >= start && session.cursor <= end { + caret_line = k; + caret_col = (session.cursor - start) as u16; + break; + } + } + + let mut rendered: Vec = Vec::new(); + for (k, chars) in lines_chars.iter().enumerate() { + let start = line_starts[k]; + let mut spans: Vec = Vec::new(); + for (j, &ch) in chars.iter().enumerate() { + let idx = start + j; + let color = highlight::token_color(session.highlights[idx], theme); + spans.push(Span::styled(ch.to_string(), code_char_style(session, idx, color, theme))); + } + rendered.push(Line::from(spans)); + } + + let h = area.height as usize; + let total = rendered.len(); + let start_line = if total <= h { + 0 + } else { + caret_line.saturating_sub(h / 2).min(total - h) + }; + let visible: Vec = rendered.into_iter().skip(start_line).take(h).collect(); + frame.render_widget(Paragraph::new(visible), area); + + let cursor_row = caret_line.saturating_sub(start_line) as u16; + if cursor_row < area.height { + let x = area.x + caret_col.min(area.width.saturating_sub(1)); + let y = area.y + cursor_row; + frame.set_cursor_position((x, y)); + } +} + +fn code_char_style( + session: &CodeSession, + idx: usize, + syntax: ratatui::style::Color, + theme: &Theme, +) -> Style { + if !session.typeable[idx] { + return Style::default().fg(theme.missing); + } + if idx < session.cursor { + match session.typed[idx] { + Some(c) if c == session.target[idx] => Style::default().fg(syntax), + _ => Style::default().fg(theme.error), + } + } else { + Style::default().fg(theme.missing) + } +} + +fn render_language(frame: &mut Frame, area: Rect, language: &str, theme: &Theme) { + let line = Line::from(vec![ + Span::styled( + language.to_string(), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + ]); + frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), area); +} + +fn render_status(frame: &mut Frame, area: Rect, session: &TypingSession, theme: &Theme) { + let line = Line::from(vec![ + Span::styled( + format!("{:>2}s", session.remaining_secs()), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" ", Style::default()), + Span::styled( + format!("{} wpm", live_wpm(session)), + Style::default().fg(theme.missing), + ), + ]); + frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), area); +} + +fn render_words(frame: &mut Frame, area: Rect, session: &TypingSession, theme: &Theme) { + let width = area.width.max(1) as usize; + + let mut lines: Vec = Vec::new(); + let mut current: Vec = Vec::new(); + let mut col = 0usize; + let mut caret_line = 0usize; + let mut caret_col = 0u16; + + for (wi, word) in session.words.iter().enumerate() { + let word_len = word.target.len().max(word.typed.len()); + + if col > 0 && col + 1 + word_len > width { + lines.push(Line::from(std::mem::take(&mut current))); + col = 0; + } + if col > 0 { + current.push(Span::raw(" ")); + col += 1; + } + + if wi == session.cursor_word { + caret_line = lines.len(); + let within = word.typed.len().min(word_len); + caret_col = (col + within) as u16; + } + + for i in 0..word_len { + let (ch, style) = char_style(word, i, theme); + current.push(Span::styled(ch.to_string(), style)); + } + col += word_len; + } + if !current.is_empty() { + lines.push(Line::from(current)); + } + + let window_start = caret_line.saturating_sub(1); + let visible: Vec = lines + .into_iter() + .skip(window_start) + .take(area.height as usize) + .collect(); + frame.render_widget(Paragraph::new(visible), area); + + let cursor_row = (caret_line - window_start) as u16; + if cursor_row < area.height { + let x = area.x + caret_col.min(area.width.saturating_sub(1)); + let y = area.y + cursor_row; + frame.set_cursor_position((x, y)); + } +} + +fn char_style(word: &Word, i: usize, theme: &Theme) -> (char, Style) { + if i < word.typed.len() { + if i < word.target.len() { + let ok = word.typed[i] == word.target[i]; + ( + word.target[i], + Style::default().fg(if ok { theme.fg } else { theme.error }), + ) + } else { + ( + word.typed[i], + Style::default() + .fg(theme.error) + .add_modifier(Modifier::UNDERLINED), + ) + } + } else { + (word.target[i], Style::default().fg(theme.missing)) + } +} + +fn render_hint(frame: &mut Frame, area: Rect, theme: &Theme) { + let bar = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(area)[1]; + frame.render_widget( + Paragraph::new(Span::styled( + "esc cancel", + Style::default().fg(theme.missing), + )) + .alignment(Alignment::Center), + bar, + ); +} + +fn live_wpm(session: &TypingSession) -> u32 { + session.live_wpm() +}