This commit is contained in:
2005 2024-04-28 11:30:15 +02:00
commit b7299f7c1c
46 changed files with 198648 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
# Rust
target/
# Nix
result
# Direnv
.direnv/
# Editor
.vim/
.nvimrc

1909
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

38
Cargo.toml Normal file
View file

@ -0,0 +1,38 @@
[package]
name = "dttyper"
description = "Terminal-based typing test."
version = "1.4.2"
readme = "README.md"
repository = "https://git.berryez.xyz/berry/dttyper.git"
homepage = "https://git.berryez.xyz/berry/dttyper"
license = "MIT"
authors = [
"Max Niederman <max@maxniederman.com>",
"berry <neurofen@fedora.email>",
]
edition = "2021"
[dependencies]
reqwest = { version = "0.11.25", features = ["blocking"] }
structopt = "^0.3"
dirs = "^5.0"
crossterm = "^0.27"
rust-embed = "^8.2"
toml = "^0.8"
serde_json = "1.0.114"
chrono = "0.4.35"
[dependencies.ratatui]
version = "^0.25"
[dependencies.rand]
version = "^0.8"
features = ["alloc"]
[dependencies.serde]
version = "^1.0"
features = ["derive"]
[build-dependencies]
dirs = "^5.0"

21
LICENSE.md Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Max Niederman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

236
README.md Normal file
View file

@ -0,0 +1,236 @@
# dttyper
dttyper is a terminal-based typing test built with Rust and tui-rs forked from [ttyper](https://github.com/max-niederman/ttyper), that exports each tests into an influx database.
![Recording](./resources/recording.gif)
## installation
```sh
cargo build
cargo install --path .
```
# Develop (using crate2nix)
a. Enter dev shell via nix
```bash
nix develop
```
b. modify code and then run with nix
```bash
nix run
```
## usage
For usage instructions, you can run `dttyper --help`:
```
dttyper
Terminal-based typing test.
USAGE:
dttyper [FLAGS] [OPTIONS] [contents]
FLAGS:
-d, --debug
-h, --help Prints help information
--list-languages List installed languages
--no-backtrack Disable backtracking to completed words
-V, --version Prints version information
OPTIONS:
-c, --config <config> Use config file
-l, --language <language> Specify test language
--language-file <language-file> Specify test language in file
-w, --words <words> Specify word count [default: 50]
ARGS:
<contents>
```
### examples
| command | test contents |
| :------------------------------ | ----------------------------------------: |
| `dttyper` | 50 of the 200 most common english words |
| `dttyper -w 100` | 100 of the 200 most common English words |
| `dttyper -w 100 -l english1000` | 100 of the 1000 most common English words |
| `dttyper --language-file lang` | 50 random words from the file `lang` |
| `dttyper text.txt` | contents of `text.txt` split at newlines |
## languages
The following languages are available by default:
| name | description |
| :----------------- | ----------------------------------: |
| `c` | The C programming language |
| `csharp` | The C# programming language |
| `english100` | 100 most common English words |
| `english200` | 200 most common English words |
| `english1000` | 1000 most common English words |
| `english5000` | 5000 most common English words |
| `english10000` | 10000 most common English words |
| `english-advanced` | Advanced English words |
| `english-pirate` | 50 pirate speak English words |
| `german` | 207 most common German words |
| `german1000` | 1000 most common German words |
| `german10000` | 10000 most common German words |
| `go` | The Go programming language |
| `html` | HyperText Markup Language |
| `java` | The Java programming language |
| `javascript` | The Javascript programming language |
| `norwegian` | 200 most common Norwegian words |
| `php` | The PHP programming language |
| `portuguese` | 100 most common Portuguese words |
| `python` | The Python programming language |
| `qt` | The QT GUI framework |
| `ruby` | The Ruby programming language |
| `rust` | The Rust programming language |
| `spanish` | 100 most common Spanish words |
| `ukrainian` | 100 most common Ukrainian words |
Additional languages can be added by creating a file in `DTTYPER_CONFIG_DIR/language` with a word on each line. On Linux, the config directory is `$HOME/.config/dttyper`; on Windows, it's `C:\Users\user\AppData\Roaming\dttyper`; and on macOS it's `$HOME/Library/Application Support/dttyper`.
# Statistics review
A [grafana panel](./panel.json) can be imported to view your reports!
![GrafanaStats](./resources/grafana.png)
## config
Configuration is specified by the `config.toml` file in the config directory (e.g. `$HOME/.config/dttyper/config.toml`).
The default values with explanations are below:
```toml
default_language = "english1000"
server = "http://localhost:8086"
token = "token"
bucket = "dttyper"
org = "dttyper"
keyboard = "Generic"
[theme]
# default style (this includes empty cells)
default = "none"
# title text styling
title = "white;bold"
## test styles ##
# input box border
input_border = "cyan"
# prompt box border
prompt_border = "green"
# correctly typed words
prompt_correct = "green"
# incorrectly typed words
prompt_incorrect = "red"
# untyped words
prompt_untyped = "gray"
# correctly typed letters in current word
prompt_current_correct = "green;bold"
# incorrectly typed letters in current word
prompt_current_incorrect = "red;bold"
# untyped letters in current word
prompt_current_untyped = "blue;bold"
# cursor character
prompt_cursor = "none;underlined"
## results styles ##
# overview text
results_overview = "cyan;bold"
# overview border
results_overview_border = "cyan"
# worst keys text
results_worst_keys = "cyan;bold"
# worst keys border
results_worst_keys_border = "cyan"
# results chart default (includes plotted data)
results_chart = "cyan"
# results chart x-axis label
results_chart_x = "cyan"
# results chart y-axis label
results_chart_y = "gray;italic"
# restart/quit prompt in results ui
results_restart_prompt = "gray;italic"
```
### style format
The configuration uses a custom style format which can specify most [ANSI escape styling codes](<https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters>), encoded as a string.
Styles begin with the color specification, which can be a single color (the foreground), or two colors separated by a colon (the foreground and background). Colors can be one of sixteen specified by your terminal, a 24-bit hex color code, `none`, or `reset`.
After the colors, you can optionally specify modifiers separated by a semicolon. A list of modifiers is below:
- `bold`
- `crossed_out`
- `dim`
- `hidden`
- `italic`
- `rapid_blink`
- `slow_blink`
- `reversed`
- `underlined`
Some examples:
- `blue:white;italic` specifies italic blue text on a white background.
- `none;italic;bold;underlined` specifies underlined, italicized, and bolded text with no set color or background.
- `00ff00:000000` specifies text of color `#00ff00` (pure green) on a background of `#000000` (pure black).
In [extended Backus-Naur form](https://en.wikipedia.org/wiki/Extended_Backus%E2%80%93Naur_form):
```ebnf
style = colors, { ";", modifier }, [ ";" ] ;
colors = color, [ ":", color ] ;
color = "none"
| "reset"
| "black"
| "white"
| "red"
| "green"
| "yellow"
| "blue"
| "magenta"
| "cyan"
| "gray"
| "darkgray"
| "lightred"
| "lightgreen"
| "lightyellow"
| "lightblue"
| "lightmagenta"
| "lightcyan"
| 6 * hex digit ;
hex digit = ? hexadecimal digit; 1-9, a-z, and A-Z ? ;
modifier = "bold"
| "crossed_out"
| "dim"
| "hidden"
| "italic"
| "rapid_blink"
| "slow_blink"
| "reversed"
| "underlined" ;
```
If you're familiar with [serde](https://serde.rs), you can also read [the deserialization code](./src/config.rs).

54
build.rs Normal file
View file

@ -0,0 +1,54 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
fn copy<U: AsRef<Path>, V: AsRef<Path>>(from: U, to: V) -> std::io::Result<()> {
let mut stack = vec![PathBuf::from(from.as_ref())];
let output_root = PathBuf::from(to.as_ref());
let input_root = PathBuf::from(from.as_ref()).components().count();
while let Some(working_path) = stack.pop() {
// Generate a relative path
let src: PathBuf = working_path.components().skip(input_root).collect();
// Create a destination if missing
let dest = if src.components().count() == 0 {
output_root.clone()
} else {
output_root.join(&src)
};
if fs::metadata(&dest).is_err() {
fs::create_dir_all(&dest)?;
}
for entry in fs::read_dir(working_path)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Some(filename) = path.file_name() {
let dest_path = dest.join(filename);
fs::copy(&path, &dest_path)?;
}
}
}
Ok(())
}
#[allow(unused_must_use)]
fn main() -> std::io::Result<()> {
let install_path = dirs::config_dir()
.expect("Couldn't find a configuration directory to install to.")
.join("dttyper");
fs::create_dir_all(&install_path);
let resources_path = env::current_dir()
.expect("Couldn't find the source directory.")
.join("resources")
.join("runtime");
copy(resources_path, &install_path);
Ok(())
}

121
flake.lock Normal file
View file

@ -0,0 +1,121 @@
{
"nodes": {
"advisory-db": {
"flake": false,
"locked": {
"lastModified": 1711896428,
"narHash": "sha256-cZfXcw6dkd+00dOnD0tD/GLX7gEU/piVUF8SOKRIjf4=",
"owner": "rustsec",
"repo": "advisory-db",
"rev": "799ff4a10673405b2334f6653519fb092aa99845",
"type": "github"
},
"original": {
"owner": "rustsec",
"repo": "advisory-db",
"type": "github"
}
},
"crane": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1712015038,
"narHash": "sha256-opeWL/FPV7nnbfUavSWIDy+N5bUshF2CyJK6beVvjv4=",
"owner": "ipetkov",
"repo": "crane",
"rev": "b245ee3472cbfd82394047b536e117a32b4c7850",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": []
},
"locked": {
"lastModified": 1712038998,
"narHash": "sha256-bVIEz07/SLxPRRo+1G0cUd26KhoCj8yQc8myhf/93FM=",
"owner": "nix-community",
"repo": "fenix",
"rev": "b1b59b4d908d3e64a7e923a7b434e94e03626ec0",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1711715736,
"narHash": "sha256-9slQ609YqT9bT/MNX9+5k5jltL9zgpn36DpFB7TkttM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "807c549feabce7eddbf259dbdcec9e0600a0660d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"advisory-db": "advisory-db",
"crane": "crane",
"fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

134
flake.nix Normal file
View file

@ -0,0 +1,134 @@
{
description = "Build a cargo project";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
crane = {
url = "github:ipetkov/crane";
inputs.nixpkgs.follows = "nixpkgs";
};
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
inputs.rust-analyzer-src.follows = "";
};
flake-utils.url = "github:numtide/flake-utils";
advisory-db = {
url = "github:rustsec/advisory-db";
flake = false;
};
};
outputs = { self, nixpkgs, crane, fenix, flake-utils, advisory-db, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs) lib;
craneLib = crane.lib.${system};
src = craneLib.cleanCargoSource (craneLib.path ./.);
# Common arguments can be set here to avoid repeating them later
commonArgs = {
inherit src;
strictDeps = true;
buildInputs = [
# Add additional build inputs here
] ++ lib.optionals pkgs.stdenv.isDarwin [
# Additional darwin specific inputs can be set here
pkgs.libiconv
];
# Additional environment variables can be set directly
# MY_CUSTOM_VAR = "some value";
};
craneLibLLvmTools = craneLib.overrideToolchain
(fenix.packages.${system}.complete.withComponents [
"cargo"
"llvm-tools"
"rustc"
]);
# Build *just* the cargo dependencies, so we can reuse
# all of that work (e.g. via cachix) when running in CI
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
# Build the actual crate itself, reusing the dependency
# artifacts from above.
dttyper = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{
checks = {
# Build the crate as part of `nix flake check` for convenience
inherit dttyper;
# Run clippy (and deny all warnings) on the crate source,
# again, reusing the dependency artifacts from above.
#
# Note that this is done as a separate derivation so that
# we can block the CI if there are issues here, but not
# prevent downstream consumers from building our crate by itself.
dttyper-clippy = craneLib.cargoClippy (commonArgs // {
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
});
dttyper-doc = craneLib.cargoDoc (commonArgs // {
inherit cargoArtifacts;
});
# Check formatting
dttyper-fmt = craneLib.cargoFmt {
inherit src;
};
# Audit dependencies
dttyper-audit = craneLib.cargoAudit {
inherit src advisory-db;
};
# Audit licenses
dttyper-deny = craneLib.cargoDeny {
inherit src;
};
# Run tests with cargo-nextest
# Consider setting `doCheck = false` on `dttyper` if you do not want
# the tests to run twice
dttyper-nextest = craneLib.cargoNextest (commonArgs // {
inherit cargoArtifacts;
partitions = 1;
partitionType = "count";
});
};
packages = {
default = dttyper;
} // lib.optionalAttrs (!pkgs.stdenv.isDarwin) {
dttyper-llvm-coverage = craneLibLLvmTools.cargoLlvmCov (commonArgs // {
inherit cargoArtifacts;
});
};
apps.default = flake-utils.lib.mkApp {
drv = dttyper;
};
devShells.default = craneLib.devShell {
checks = self.checks.${system};
OPENSSL_NO_VENDOR = 1;
packages = [
pkgs.openssl
];
};
});
}

976
panel.json Normal file
View file

@ -0,0 +1,976 @@
{
"__inputs": [
{
"name": "DS_INFLUXDB",
"label": "influxdb",
"description": "",
"type": "datasource",
"pluginId": "influxdb",
"pluginName": "InfluxDB"
}
],
"__elements": {},
"__requires": [
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "10.3.3"
},
{
"type": "datasource",
"id": "influxdb",
"name": "InfluxDB",
"version": "1.0.0"
},
{
"type": "panel",
"id": "piechart",
"name": "Pie chart",
"version": ""
},
{
"type": "panel",
"id": "stat",
"name": "Stat",
"version": ""
},
{
"type": "panel",
"id": "table",
"name": "Table",
"version": ""
},
{
"type": "panel",
"id": "timeseries",
"name": "Time series",
"version": ""
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 19,
"title": "Stats",
"type": "row"
},
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "yellow",
"value": null
}
]
},
"unitScale": true
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 4,
"x": 0,
"y": 1
},
"id": 9,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "10.3.3",
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"query": "from(bucket: \"dttyper\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"test\")\n |> filter(fn: (r) => r[\"_field\"] == \"correct_types\")\n |> aggregateWindow(every: 999d, fn: sum, createEmpty: false)\n |> group()\n |> sum(column: \"_value\")",
"refId": "A"
}
],
"title": "Correct words typed",
"type": "stat"
},
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 30
},
{
"color": "#EAB839",
"value": 40
},
{
"color": "#6ED0E0",
"value": 50
},
{
"color": "yellow",
"value": 60
},
{
"color": "dark-orange",
"value": 70
},
{
"color": "#1F78C1",
"value": 80
},
{
"color": "green",
"value": 90
},
{
"color": "dark-green",
"value": 95
}
]
},
"unit": "percent",
"unitScale": true
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 3,
"x": 4,
"y": 1
},
"id": 18,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "10.3.3",
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"query": "from(bucket: \"dttyper\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"test\")\n |> filter(fn: (r) => r[\"_field\"] == \"accuracy\")\n |> aggregateWindow(every: 999d, fn: mean, createEmpty: false)\n |> group()\n |> mean(column: \"_value\")",
"refId": "A"
}
],
"title": "Overall accuracy %",
"type": "stat"
},
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
}
]
},
"unitScale": true
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 4,
"x": 7,
"y": 1
},
"id": 22,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "10.3.3",
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"query": "from(bucket: \"dttyper\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"test\")\n |> filter(fn: (r) => r[\"_field\"] == \"incorrect_types\")\n |> aggregateWindow(every: 999d, fn: sum, createEmpty: false)\n |> group()\n |> sum(column: \"_value\")",
"refId": "A"
}
],
"title": "Incorrectly typed word",
"type": "stat"
},
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unitScale": true
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 4,
"x": 11,
"y": 1
},
"id": 12,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "10.3.3",
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"query": "from(bucket: \"dttyper\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"test\")\n // get dummy field\n |> filter(fn: (r) => r[\"_field\"] == \"incorrect_types\")\n |> group()\n |> count()",
"refId": "A"
}
],
"title": "Tests completed",
"type": "stat"
},
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "dark-red",
"value": 40
},
{
"color": "red",
"value": 50
},
{
"color": "#EAB839",
"value": 60
},
{
"color": "#6ED0E0",
"value": 70
},
{
"color": "#EF843C",
"value": 80
},
{
"color": "#E24D42",
"value": 90
},
{
"color": "#1F78C1",
"value": 100
},
{
"color": "green",
"value": 110
},
{
"color": "#BA43A9",
"value": 120
},
{
"color": "dark-purple",
"value": 130
},
{
"color": "#705DA0",
"value": 140
},
{
"color": "#508642",
"value": 150
}
]
},
"unitScale": true
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 3,
"x": 15,
"y": 1
},
"id": 16,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"max"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "10.3.3",
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"query": "from(bucket: \"dttyper\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"test\")\n |> filter(fn: (r) => r[\"_field\"] == \"wpm\")\n |> aggregateWindow(every: v.windowPeriod, fn: max, createEmpty: false)\n |> group()",
"refId": "A"
}
],
"title": "Highest wpm",
"type": "stat"
},
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "dark-red",
"value": 40
},
{
"color": "red",
"value": 50
},
{
"color": "#EAB839",
"value": 60
},
{
"color": "#6ED0E0",
"value": 70
},
{
"color": "#EF843C",
"value": 80
},
{
"color": "#E24D42",
"value": 90
},
{
"color": "#1F78C1",
"value": 100
},
{
"color": "green",
"value": 110
},
{
"color": "#BA43A9",
"value": 120
},
{
"color": "dark-purple",
"value": 130
},
{
"color": "#705DA0",
"value": 140
},
{
"color": "#508642",
"value": 150
}
]
},
"unitScale": true
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 3,
"x": 18,
"y": 1
},
"id": 21,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "10.3.3",
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"query": "from(bucket: \"dttyper\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"test\")\n |> filter(fn: (r) => r[\"_field\"] == \"wpm\")\n |> aggregateWindow(every: v.windowPeriod, fn: min, createEmpty: false)\n |> group()",
"refId": "A"
}
],
"title": "Average WPM",
"type": "stat"
},
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "dark-red",
"value": 40
},
{
"color": "red",
"value": 50
},
{
"color": "#EAB839",
"value": 60
},
{
"color": "#6ED0E0",
"value": 70
},
{
"color": "#EF843C",
"value": 80
},
{
"color": "#E24D42",
"value": 90
},
{
"color": "#1F78C1",
"value": 100
},
{
"color": "green",
"value": 110
},
{
"color": "#BA43A9",
"value": 120
},
{
"color": "dark-purple",
"value": 130
},
{
"color": "#705DA0",
"value": 140
},
{
"color": "#508642",
"value": 150
}
]
},
"unitScale": true
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 3,
"x": 21,
"y": 1
},
"id": 13,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"min"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "10.3.3",
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"query": "from(bucket: \"dttyper\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"test\")\n |> filter(fn: (r) => r[\"_field\"] == \"wpm\")\n |> aggregateWindow(every: 999d, fn: min, createEmpty: false)\n |> group()",
"refId": "A"
}
],
"title": "Lowest wpm ",
"type": "stat"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 5
},
"id": 3,
"panels": [],
"title": "Global",
"type": "row"
},
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unitScale": true
},
"overrides": []
},
"gridPos": {
"h": 11,
"w": 24,
"x": 0,
"y": 6
},
"id": 20,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"query": "from(bucket: \"dttyper\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"test\")\n |> filter(fn: (r) => r[\"_field\"] == \"wpm\")",
"refId": "A"
}
],
"title": "WPM overtime",
"type": "timeseries"
},
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
}
},
"mappings": [],
"unitScale": true
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 10,
"x": 0,
"y": 17
},
"id": 23,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"pieType": "pie",
"reduceOptions": {
"calcs": [
"mean"
],
"fields": "",
"values": false
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "10.3.3",
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"query": "from(bucket: \"dttyper\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"test\")\n |> filter(fn: (r) => r[\"_field\"] == \"wpm\")",
"refId": "A"
}
],
"title": "WPM per test",
"transformations": [],
"type": "piechart"
},
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unitScale": true
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 14,
"x": 10,
"y": 17
},
"id": 24,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"frameIndex": 2,
"showHeader": true
},
"pluginVersion": "10.3.3",
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${DS_INFLUXDB}"
},
"query": "from(bucket: \"dttyper\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"test\")",
"refId": "A"
}
],
"title": "History",
"type": "table"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 26
},
"id": 2,
"panels": [],
"title": "Query",
"type": "row"
}
],
"refresh": false,
"schemaVersion": 39,
"tags": [
"wpm",
"keyboard",
"dtyper"
],
"templating": {
"list": []
},
"time": {
"from": "now-7d",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "",
"title": "Ttyper",
"uid": "dd78f974-f0dc-456c-8a59-199df1f81eaf",
"version": 34,
"weekStart": ""
}

BIN
resources/grafana.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

BIN
resources/recording.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -0,0 +1,52 @@
int
char
unsigned
float
void
main
union
long
double
printf
sprintf
if
else
struct
fork
switch
for
define
return
include
case
&&
||
break
bool
static
public
enum
typedef
private
exit
<stdio.h>
scanf
NULL
malloc
calloc
free
realloc
<string.h>
fgets
strcmp
strcpy
fputs
stdout
EOF
getc
while
fclose
fopen
do
fscanf
extern

View file

@ -0,0 +1,139 @@
--i
-=
!=
*=
/=
&&
&=
#define
#include
#pragma
%=
^=
++i
+=
<<
<<=
<=
<iostream>
<map>
<string>
<vector>
==
>=
>>
>>=
|=
||
alignas
alignof
and
and_eq
asm
atomic_cancel
atomic_commit
atomic_noexcept
auto
bitand
bitor
bool
break
case
catch
char
char16_t
char32_t
char8_t
class
co_await
co_return
co_yield
compl
concept
const
const_cast
consteval
constexpr
constinit
continue
decltype
default
delete
do
double
dynamic_cast
else
enum
explicit
export
extern
false
final
float
for
friend
goto
i--
i++
if
import
inline
int
long
module
mutable
namespace
new
noexcept
not
not_eq
nullptr
operator
or
or_eq
override
private
protected
public
reflexpr
register
reinterpret_cast
requires
return
short
signed
sizeof
sizeof(long long)
static
static_assert
static_cast
std::cout
std::endl
std::map
std::move
std::string
std::vector
struct
switch
synchronized
template
template<class T>
template<typename T>
this
thread_local
throw
true
try
typedef
typeid
typename
union
unsigned
using
virtual
void
volatile
wchar_t
while
xor
xor_eq

View file

@ -0,0 +1,108 @@
abstract
as
base
bool
break
byte
case
catch
char
checked
class
const
continue
decimal
default
delegate
do
double
else
enum
even
explicit
extern
false
finally
fixed
float
for
foreach
goto
if
implicit
in
init
int
interface
internal
is
lock
long
namespace
new
null
object
operator
out
override
params
private
protected
public
readonly
ref
return
sbyte
sealed
short
sizeof
stackalloc
static
string
struct
switch
this
throw
true
try
typeof
uint
ulong
unchecked
unsafe
ushort
using
virtual
void
volatile
add
alias
ascending
async
await
by
descending
dynamic
equals
from
get
global
group
into
join
let
nameof
notnull
on
orderby
partial
remove
select
set
unmanaged
value
var
when
where
with
yield

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,300 @@
ou
te
me
ve
li
ed
ng
ne
at
ll
of
ha
he
in
st
ce
le
as
ti
or
se
om
is
al
nt
co
ro
io
re
it
on
to
es
th
ar
en
hi
er
ea
si
nd
ri
ma
be
ic
de
ra
ch
ur
an
con
lit
pre
din
tha
art
ore
lan
tri
der
ren
orm
tho
ion
eri
ran
ove
ast
mor
wit
can
anc
rie
ies
res
rou
ard
nes
ime
ght
age
hat
one
sio
hin
spe
cou
ure
ntr
pla
hei
omp
ers
ass
ont
rec
nde
ant
she
ith
iti
ese
out
ver
tte
oth
sin
and
nal
men
whe
rit
but
ear
int
nte
are
cha
wor
tic
ave
ter
nat
sta
rat
nts
cti
sen
ame
ces
lar
oun
rin
whi
ide
ven
end
ted
rea
you
ive
pos
oug
par
uld
ati
ble
les
ica
era
tur
ind
ons
ric
ned
ate
ing
ste
ust
ort
ssi
all
fro
the
tes
ess
tiv
han
hou
ent
ial
ell
ome
ere
ona
eat
dis
tra
ugh
ich
eas
str
ist
ins
tio
den
igh
und
wer
rom
use
ice
thi
abl
ern
man
ten
ple
hav
lat
red
lly
enc
ite
lin
our
was
sti
nti
tin
tor
tat
hic
tan
hen
not
eme
had
inc
oul
eve
per
sed
ene
app
cal
pro
any
for
ect
een
ine
ill
por
ral
com
hey
nce
ity
ord
ose
her
ndi
ain
ous
tim
act
min
est
his
form
ctio
ally
hich
ring
whic
that
ting
nter
ions
othe
thei
ould
were
thin
tive
ence
over
sion
ever
heir
ture
inte
ight
some
cont
this
ents
ment
part
ough
rati
ding
ther
cons
here
ning
ance
ecti
comp
from
tion
ated
they
ical
atio
with
able
have
pres

View file

@ -0,0 +1,50 @@
ahoy
anchor
arrr
avast
aye
beast
bilge
blackbeard
blimey
bombard
booty
bounty
buccaneer
bumboo
capsize
cutlass
doubloon
doughboy
eyepatch
freebooter
grog
hardtack
hogshead
hornswoggle
island
keel
landlubber
man-o-war
marooned
matey
mutiny
parley
peg-leg
picaroon
pillage
plank
plunder
poop-deck
privateer
rigging
rullock
rumfustian
sail
scallywag
scupper
scurvy
sea
seamen
swashbuckler
treasure

View file

@ -0,0 +1,151 @@
The manager of the fruit stand always sat and only sold vegetables.
Always bring cinnamon buns on a deep-sea diving expedition.
He spiked his hair green to support his iguana.
As he waited for the shower to warm, he noticed that he could hear water change temperature.
The door slammed on the watermelon.
The two walked down the slot canyon oblivious to the sound of thunder in the distance.
We should play with legos at camp.
The delicious aroma from the kitchen was ruined by cigarette smoke.
Everyone says they love nature until they realize how dangerous she can be.
Eating eggs on Thursday for choir practice was recommended.
Thigh-high in the water, the fishermans hope for dinner soon turned to despair.
The beauty of the African sunset disguised the danger lurking nearby.
He learned the hardest lesson of his life and had the scars, both physical and mental, to prove it.
Seek success, but always be prepared for random cats.
She advised him to come back at once.
This book is sure to liquefy your brain.
He found his art never progressed when he literally used his sweat and tears.
He is no James Bond; his name is Roger Moore.
He excelled at firing people nicely.
There was no ice cream in the freezer, nor did they have money to go to the store.
When I cook spaghetti, I like to boil it a few minutes past al dente so the noodles are super slippery.
He went back to the video to see what had been recorded and was shocked at what he saw.
He strives to keep the best lawn in the neighborhood.
Even with the snow falling outside, she felt it appropriate to wear her bikini.
He turned in the research paper on Friday; otherwise, he would have not passed the class.
8% of 25 is the same as 25% of 8 and one of them is much easier to do in your head.
Had he known what was going to happen, he would have never stepped into the shower.
Tomatoes make great weapons when water balloons arent available.
The rain pelted the windshield as the darkness engulfed us.
There should have been a time and a place, but this wasn't it.
Peter found road kill an excellent way to save money on dinner.
Kevin embraced his ability to be at the wrong place at the wrong time.
Flesh-colored yoga pants were far worse than even he feared.
This made him feel like an old-style rootbeer float smells.
She was amazed by the large chunks of ice washing up on the beach.
It caught him off guard that space smelled of seared steak.
You realize you're not alone as you sit in your bedroom massaging your calves after a long day of playing tug-of-war with Grandpa Joe in the hospital.
To the surprise of everyone, the Rapture happened yesterday but it didn't quite go as expected.
Pink horses galloped across the sea.
The complicated school homework left the parents trying to help their kids quite confused.
It was obvious she was hot, sweaty, and tired.
I really want to go to work, but I am too sick to drive.
While all her friends were positive that Mary had a sixth sense, she knew she actually had a seventh sense.
I only enjoy window shopping when the windows are transparent.
I want more detailed information.
Their argument could be heard across the parking lot.
Writing a list of random sentences is harder than I initially thought it would be.
He picked up trash in his spare time to dump in his neighbor's yard.
He had a vague sense that trees gave birth to dinosaurs.
He colored deep space a soft yellow.
I'm worried by the fact that my daughter looks to the local carpet seller as a role model.
It caught him off guard that space smelled of seared steak.
Art doesn't have to be intentional.
She found it strange that people use their cellphones to actually talk to one another.
He wondered why at 18 he was old enough to go to war, but not old enough to buy cigarettes.
The father died during childbirth.
Kevin embraced his ability to be at the wrong place at the wrong time.
The fact that there's a stairway to heaven and a highway to hell explains life well.
You've been eyeing me all day and waiting for your move like a lion stalking a gazelle in a savannah.
Twin 4-month-olds slept in the shade of the palm tree while the mother tanned in the sun.
The paintbrush was angry at the color the artist chose to use.
The fifty mannequin heads floating in the pool kind of freaked them out.
He told us a very exciting adventure story.
The Great Dane looked more like a horse than a dog.
Jeanne wished she has chosen the red button.
The sudden rainstorm washed crocodiles into the ocean.
He put heat on the wound to see what would grow.
The bread dough reminded her of Santa Clauses belly.
He decided to fake his disappearance to avoid jail.
Let me help you with your baggage.
The stranger officiates the meal.
As the rental car rolled to a stop on the dark road, her fear increased by the moment.
The sunblock was handed to the girl before practice, but the burned skin was proof she did not apply it.
Happiness can be found in the depths of chocolate pudding.
Of course, she loves her pink bunny slippers.
After fighting off the alligator, Brian still had to face the anaconda.
She couldn't decide of the glass was half empty or half full so she drank it.
I want more detailed information.
Gary didn't understand why Doug went upstairs to get one dollar bills when he invited him to go cow tipping.
She works two jobs to make ends meet; at least, that was her reason for not having time to join us.
I cheated while playing the darts tournament by using a longbow.
All they could see was the blue water surrounding their sailboat.
He was the type of guy who liked Christmas lights on his house in the middle of July.
It was a slippery slope and he was willing to slide all the way to the deepest depths.
The fish listened intently to what the frogs had to say.
The book is in front of the table.
You have every right to be angry, but that doesn't give you the right to be mean.
He waited for the stop sign to turn to a go sign.
The gloves protect my feet from excess work.
Nancy thought the best way to create a welcoming home was to line it with barbed wire.
Best friends are like old tomatoes and shoelaces.
He fumbled in the darkness looking for the light switch, but when he finally found it there was someone already there.
They say that dogs are man's best friend, but this cat was setting out to sabotage that theory.
Some bathing suits just shouldnt be worn by some people.
Dolores wouldn't have eaten the meal if she had known what it actually was.
She did her best to help him.
Pantyhose and heels are an interesting choice of attire for the beach.
Jason didnt understand why his parents wouldnt let him sell his little sister at the garage sale.
Smoky the Bear secretly started the fires.
He wondered if she would appreciate his toenail collection.
The thick foliage and intertwined vines made the hike nearly impossible.
Purple is the best city in the forest.
Nothing seemed out of place except the washing machine in the bar.
He didn't understand why the bird wanted to ride the bicycle.
Jerry liked to look at paintings while eating garlic ice cream.
This book is sure to liquefy your brain.
She saw the brake lights, but not in time.
I'm worried by the fact that my daughter looks to the local carpet seller as a role model.
Whenever he saw a red flag warning at the beach he grabbed his surfboard.
She wanted to be rescued, but only if it was Tuesday and raining.
Patricia found the meaning of life in a bowl of Cheerios.
There was no telling what thoughts would come from the machine.
The beach was crowded with snow leopards.
He found the chocolate covered roaches quite tasty.
She opened up her third bottle of wine of the night.
The urgent care center was flooded with patients after the news of a new deadly virus was made public.
Lucifer was surprised at the amount of life at Death Valley.
When he had to picnic on the beach, he purposely put sand in other peoples food.
The book is in front of the table.
Gary didn't understand why Doug went upstairs to get one dollar bills when he invited him to go cow tipping.
She traveled because it cost the same as therapy and was a lot more enjoyable.
Eating eggs on Thursday for choir practice was recommended.
Normal activities took extraordinary amounts of concentration at the high altitude.
If eating three-egg omelets causes weight-gain, budgie eggs are a good substitute.
His mind was blown that there was nothing in space except space itself.
The clouds formed beautiful animals in the sky that eventually created a tornado to wreak havoc.
That must be the tenth time I've been arrested for selling deep-fried cigars.
Never underestimate the willingness of the greedy to throw you under the bus.
The team members were hard to tell apart since they all wore their hair in a ponytail.
When I cook spaghetti, I like to boil it a few minutes past al dente so the noodles are super slippery.
Their argument could be heard across the parking lot.
He decided that the time had come to be stronger than any of the excuses he'd used until then.
All she wanted was the answer, but she had no idea how much she would hate it.
If my calculator had a history, it would be more embarrassing than my browser history.
Just go ahead and press that button.
I caught my squirrel rustling through my gym bag.
On each full moon
It was the scarcity that fueled his creativity.
Two seats were vacant.
The tree fell unexpectedly short.
Getting up at dawn is for the birds.
Please put on these earmuffs because I can't you hear.
Her scream silenced the rowdy teenagers.
Traveling became almost extinct during the pandemic.
I can't believe this is the eighth time I'm smashing open my piggy bank on the same day!
For some unfathomable reason, the response team didn't consider a lack of milk for my cereal as a proper emergency.
The beauty of the African sunset disguised the danger lurking nearby.
Acres of almond trees lined the interstate highway which complimented the crazy driving nuts.
The fish listened intently to what the frogs had to say.
She wondered what his eyes were saying beneath his mirrored sunglasses.

View file

@ -0,0 +1,200 @@
the
be
of
and
a
to
in
he
have
it
that
for
they
I
with
as
not
on
she
at
by
this
we
you
do
but
from
or
which
one
would
all
will
there
say
who
make
when
can
more
if
no
man
out
other
so
what
time
up
go
about
than
into
could
state
only
new
year
some
take
come
these
know
see
use
get
like
then
first
any
work
now
may
such
give
over
think
most
even
find
day
also
after
way
many
must
look
before
great
back
through
long
where
much
should
well
people
down
own
just
because
good
each
those
feel
seem
how
high
too
place
little
world
very
still
nation
hand
old
life
tell
write
become
here
show
house
both
between
need
mean
call
develop
under
last
right
move
thing
general
school
never
same
another
begin
while
number
part
turn
real
leave
might
want
point
form
off
child
few
small
since
against
ask
late
home
interest
large
person
end
open
public
follow
during
present
without
again
hold
govern
around
possible
head
consider
word
program
problem
however
lead
system
set
order
eye
plan
run
keep
face
fact
group
play
stand
increase
early
course
change
help
line

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,200 @@
the
of
and
to
in
for
is
on
that
by
this
with
you
it
not
or
be
are
from
at
as
your
all
have
new
more
an
was
we
will
home
can
us
about
if
page
my
has
search
free
but
our
one
other
do
no
information
time
they
site
he
up
may
what
which
their
news
out
use
any
there
see
only
so
his
when
contact
here
business
who
web
also
now
help
get
pm
view
online
first
am
been
would
how
were
me
services
some
these
click
its
like
service
than
find
price
date
back
top
people
had
list
name
just
over
state
year
day
into
email
two
health
world
re
next
used
go
work
last
most
products
music
buy
data
make
them
should
product
system
post
her
city
add
policy
number
such
please
available
copyright
support
message
after
best
software
then
jan
good
video
well
where
info
rights
public
books
high
school
through
each
links
she
review
years
order
very
privacy
book
items
company
read
group
need
many
user
said
de
does
set
under
general
research
university
january
mail
full
map
reviews
program
life
know
games
way
days
management
part
could
great
united
hotel
real
item
international

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,207 @@
die
der
und
in
zu
den
das
nicht
von
sie
ist
des
sich
mit
dem
dass
er
es
ein
ich
auf
so
eine
auch
als
an
nach
wie
im
für
man
aber
aus
durch
wenn
nur
war
noch
werden
bei
hat
wir
was
wird
sein
einen
welche
sind
oder
zur
um
haben
einer
mir
über
ihm
diese
einem
ihr
uns
da
zum
kann
doch
vor
dieser
mich
ihn
du
hatte
seine
mehr
am
denn
nun
unter
sehr
selbst
schon
hier
bis
habe
ihre
dann
ihnen
seiner
alle
wieder
meine
Zeit
gegen
vom
ganz
einzelnen
wo
muss
ohne
eines
können
sei
ja
wurde
jetzt
immer
seinen
wohl
dieses
ihren
würde
diesen
sondern
weil
welcher
nichts
diesem
alles
waren
will
Herr
viel
mein
also
soll
worden
lassen
dies
machen
ihrer
weiter
Leben
recht
etwas
keine
seinem
ob
dir
allen
großen
Jahre
Weise
müssen
welches
wäre
erst
einmal
Mann
hätte
zwei
dich
allein
Herren
während
Paragraph
anders
Liebe
kein
damit
gar
Hand
Herrn
euch
sollte
konnte
ersten
deren
zwischen
wollen
denen
dessen
sagen
bin
Menschen
gut
darauf
wurden
weiß
gewesen
Seite
bald
weit
große
solche
hatten
eben
andern
beiden
macht
sehen
ganze
anderen
lange
wer
ihrem
zwar
gemacht
dort
kommen
Welt
heute
Frau
werde
derselben
ganzen
deutschen
lässt
vielleicht
meiner

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
break
default
func
interface
select
case
defer
go
map
struct
chan
else
goto
package
switch
const
fallthrough
if
range
type
continue
for
import
return
var

View file

@ -0,0 +1,329 @@
action
alt
class
fill
for
height
href
href
href
href
id
kind
max
media
method
min
name
src
src
src
srclang
srcset
stroke
stroke-width
style
stylesheet
stylesheet
title
type
type
value
width
<!DOCTYPE
<!--
-->
<a
</a>
<a
</a>
<a
</a>
<a
</a>
<abbr
</abbr>
<address>
</address>
<area
<article>
</article>
<aside>
</aside>
<audio
</audio>
<b>
</b>
<b>
</b>
<b>
</b>
<b>
</b>
<base
<bdi>
</bdi>
<bdo
</bdo>
<blockquote
</blockquote>
<body>
</body>
<body>
</body>
<body>
</body>
<body>
</body>
<br>
<br>
<br>
<br>
<button
</button>
<canvas
</canvas>
<caption>
</caption>
<cite>
</cite>
<code>
</code>
<colgroup>
<col
</colgroup>
<data
</data>
<datalist
</datalist>
<dd>
</dd>
<dl>
</dl>
<del>
</del>
<details>
</details>
<dfn
</dfn>
<dialog
</dialog>
<div
</div>
<div
</div>
<div
</div>
<div
</div>
<dt>
</dt>
<em>
</em>
<embed
<fieldset>
</fieldset>
<figcaption>
</figcaption>
<figure>
</figure>
<form
</form>
<footer>
</footer>
<label
</label>
<header>
</header>
<head>
</head>
<head>
</head>
<head>
</head>
<head>
</head>
<html
</html>
<html
</html>
<html
</html>
<html
</html>
<hr>
<h1>
</h1>
<h2>
</h2>
<h3>
</h3>
<h4>
</h4>
<h5>
</h5>
<h6>
</h6>
<i>
</i>
<i>
</i>
<i>
</i>
<iframe
</iframe>
<img
<img
<img
<img
<input
<ins>
</ins>
<kbd>
</kbd>
<legend>
</legend>
<li>
</li>
<li>
</li>
<li>
</li>
<li>
</li>
<link
<link
<main>
</main>
<map
</map>
<mark>
</mark>
<meta
<meter
</meter>
<nav>
</nav>
<noscript>
</noscript>
<object
</object>
<ol>
</ol>
<ol>
</ol>
<ol>
</ol>
<ol>
</ol>
<optgroup
</optgroup>
<option
</option>
<output
</output>
<p>
</p>
<p>
</p>
<p>
</p>
<p>
</p>
<p>
</p>
<p>
</p>
<param
<picture>
</picture>
<pre>
</pre>
<progress
</progress>
<q>
</q>
<rp>
</rp>
<ruby>
</ruby>
<rt>
</rt>
<s>
</s>
<samp>
</samp>
<script>
</script>
<script>
</script>
<section>
</section>
<select
</select>
<source
<span
</span>
<span
</span>
<span
</span>
<span
</span>
<strong>
<strong>
<strong>
<strong>
</strong>
<style
<style>
<style
<style>
<sub>
</sub>
<summary>
</summary>
<sup>
</sup>
<svg
</svg>
<table>
</table>
<tbody>
</tbody>
<td>
</td>
<template>
</template>
<textarea
</textarea>
<tfoot>
</tfoot>
<thead>
</thead>
<th>
</th>
<time
<time>
</time>
<title>
</title>
<title>
</title>
<track
<u>
</u>
<ul>
</ul>
<ul>
</ul>
<ul>
</ul>
<ul>
</ul>
<var>
</var>
<video
</video>
<wbr>
</wbr>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
abstract
assert
boolean
break
byte
case
catch
char
class
continue
default
do
double
else
enum
extends
final
finally
float
for
if
implements
import
instanceof
interface
long
native
new
null
package
private
protected
public
return
short
static
strictfp
super
switch
synchronized
this
throw
throws
transient
try
void
volatile
while
valueOf
from
parse
get
contains
remove
clear
put
set
with
throwas
build
add
subtract
append
length

View file

@ -0,0 +1,51 @@
this
function
if
var
return
the
to
value
else
for
true
length
false
null
of
in
element
event
and
object
console
object
jQuery
node
while
do
if
break
continue
attributes
childNodes
firstChild
nodeName
nodeType
onclick
ondbclick
onmousedown
onmouseenter
onmouseup
onkeyup
onkeydown
onkeypress
oninput
oninvalid
onreset
onselect
ondrag
try
catch
throw
finally

View file

@ -0,0 +1,200 @@
i
og
det
er
til
som
en
å
for
av
at
har
med
de
ikke
den
han
om
et
fra
men
vi
var
jeg
seg
sier
vil
kan
ble
skal
etter
også
ut
år
da
dette
blir
ved
mot
hadde
to
hun
over
være
ha
går
opp
andre
eller
bare
sin
mer
inn
før
bli
vært
enn
alle
kroner
noe
når
noen
selv
denne
flere
mange
får
du
dag
der
mener
nye
fikk
norge
under
første
sa
siden
prosent
tre
kommer
ingen
man
siste
mellom
mye
oslo
hele
kunne
slik
norske
hva
både
millioner
oss
dem
store
gang
skulle
kom
hans
her
helt
tidligere
ville
hvor
blant
sine
politiet
ta
meg
sammen
blitt
igjen
rundt
mens
nok
godt
saken
fått
ned
alt
uten
norsk
tid
ham
ny
samme
gikk
sitt
tilbake
annet
foto
ser
hvis
se
gjøre
ifølge
fire
tror
stor
gjennom
litt
tatt
folk
fordi
gjør
står
disse
derfor
langt
like
neste
komme
mest
fjor
god
tar
bedre
fortsatt
allerede
viser
gi
si
gamle
stavanger
ønsker
måtte
vet
fram
først
grunn
ligger
fem
gjort
likevel
tok
gir
kveld
kanskje
del
aldri
heller
satt
leder
beste
hvordan
svært
fredag
jo
dermed
hatt
bør
lørdag

View file

@ -0,0 +1,74 @@
array_key_exists
array_keys
array_map
array_merge
array_pop
array_shift
array_unshift
array_values
array_walk
basename
class_exists
compact
count
date
declare
defined
dirname
echo
end
explode
file_exists
file_get_contents
function_exists
get_class
implode
in_array
intval
is_array
is_bool
is_dir
is_file
is_int
is_null
is_numeric
is_object
is_string
json_encode
max
mb_strlen
mb_strpos
mb_strtolower
min
preg_match
preg_replace
print_r
realpath
reset
rtrim
sprintf
str_repeat
str_replace
str_starts_with
substr
time
trim
uniqid
unlink
var_dump
<?php
if
else
elseif
switch
case
match
for
foreach
while
do
break
continue
return
=>
->

View file

@ -0,0 +1,100 @@
pra
também
vocês
quê
algo
obrigado
dia
esse
lhe
este
ir
deus
essa
oh
melhor
ainda
temos
cara
sem
pai
sempre
vida
vez
homem
estamos
talvez
mãe
anos
alguém
depois
verdade
claro
boa
nem
pouco
ficar
coisas
tinha
dois
falar
deve
antes
pelo
faz
parece
todo
dele
pessoas
fora
lugar
apenas
ei
fazendo
ninguém
dinheiro
acha
comigo
mundo
preciso
qual
grande
estar
alguma
hoje
trabalho
suas
dar
seja
disso
fez
nome
será
novo
filho
outro
qualquer
quanto
saber
vão
meus
queria
ok
podemos
nossa
poderia
outra
olá
precisa
venha
nosso
mulher
sinto
desculpe
toda
diga
hora
daqui
amor

View file

@ -0,0 +1,164 @@
abs
all
and
any
append
as
ascii
assert
bin
bool
break
bytearray
bytes
callable
capitalise
capitalize
casefold
ceil
center
chr
class
classmethod
clear
compile
complex
continue
copy
count
def
del
delattr
dict
dir
divmod
elif
else
encode
endswith
enumerate
eval
except
exec
exp
expandtabs
extend
fabs
factorial
False
filter
finally
find
float
for
format
from
fromkeys
frozenset
get
getattr
global
globals
hasattr
hash
help
hex
id
if
import
in
index
input
insert
int
is
isalnum
isalpha
isdecimal
isdigit
isidentifie
isinstance
islower
isnumeric
isprintable
isspace
issubclass
istitle
isupper
items
iter
join
keys
lambda
len
list
ljust
locals
lower
lstrip
maketrans
map
max
memoryview
min
next
None
not
object
oct
open
or
ord
partition
pass
pop
popitem
pow
print
property
raise
range
remove
replace
repr
return
reverse
reversed
rfind
rindex
rjust
round
rpartition
rsplit
rstrip
set
setattr
setdefault
slice
sort
sorted
split
splitlines
sqrt
startswith
staticmethod
str
strip
sum
super
swapcase
title
translate
True
try
tuple
type
update
upper
values
vars
while
with
yield
zfill
zip

View file

@ -0,0 +1,67 @@
actionAt
actionTriggered
addAction
allowedAreasChanged
animated
build
centralWidget
children
connect
debug
dockNestingEnabled
dockOptions
documentMode
iconSize
insertSeperator
isMovable
make
modal
movableChanged
Orientation
paintEvent
parent
pos
private
protected
public
QLabel
qmake
QPropertyAnimation
QPushButton
QRect
QSize
QString
QStyleFactory
Qt
~QToolBar
QToolBar
release
removeToolBarBreak
resizeDocks
setAnimated
setCorner
setMenuBar
setMovable
setTabPosition
signals
sizeHint
slots
statusTip
styleSheet
tabShape
toolButtonStyle
toolButtonStyleChanged
toolTip
toolTipDuration
topLevelChanged
unifiedTitleAndToolBarOnMac
updatesEnabled
visible
whatIsThis
width
windowFilePath
windowFlags
windowIcon
windowModality
windowOpacity
windowTitle

View file

@ -0,0 +1,118 @@
BEGIN
class
ensure
nil
self
when
END
def
false
not
super
while
alias
defined?
for
or
then
yield
and
do
if
redo
true
__LINE__
begin
else
in
rescue
undef
__FILE__
break
elsif
module
retry
unless
__ENCODING__
case
end
next
return
until
BasicObject
Object
ARGF.class
Array
Binding
Dir
Encoding
Encoding::Converter
Enumerator
Enumerator::ArithmeticSequence
Enumerator::Chain
Enumerator::Lazy
Enumerator::Yielder
FalseClass
Fiber
File::Stat
Hash
IO
File
MatchData
Method
Module
Class
NilClass
Numeric
Complex
Float
Integer
Rational
ObjectSpace::WeakMap
Proc
Process::Status
Random
Range
Regexp
RubyVM
RubyVM::AbstractSyntaxTree::Node
RubyVM::InstructionSequence
String
Struct
Process::Tms
Symbol
Thread
Thread::Backtrace::Location
Thread::ConditionVariable
Thread::Mutex
Thread::Queue
Thread::SizedQueue
ThreadGroup
Time
TracePoint
TrueClass
UnboundMethod
Comparable
Enumerable
Errno
File::Constants
FileTest
GC
GC::Profiler
IO::WaitReadable
IO::WaitWritable
Kernel
Marshal
Math
ObjectSpace
Process
Process::GID
Process::Sys
Process::UID
RubyVM::AbstractSyntaxTree
RubyVM::MJIT
Signal
Warning
ARGF
ENV
main

View file

@ -0,0 +1,98 @@
as
async
await
break
const
continue
crate
dyn
else
enum
extern
false
fn
for
if
impl
in
let
loop
match
=>
->
Option
Result
Some
None
Ok
Err
mod
move
mut
&mut
pub
ref
return
Self
self
static
struct
super
trait
true
type
union
unsafe
use
where
while
panic!()
println!
dbg!
u8
u16
u32
u64
u128
usize
i8
i16
i32
i64
i128
isize
bool
char
&str
Vec::new()
Vec<_>
vec![]
HashMap
String
Vec::new()
collect::<Vec<_>>()
::<>
box
lazy_static
iter()
filter(|x| x.is_ok())
<'_>
map
BTreeMap
VecDeque
LinkedList
HashSet
&'static
&'_
..
..=
?
macro_rules!
{:?}
format!
unwrap()
#[derive(Debug, Clone)]
#[test]
///
//!
//

View file

@ -0,0 +1,104 @@
como
hola
eche
no
se
niño
piña
monda
sopas
san
yo
mondongo
eñe
peo
presente
méxico
mágico
exponente
número
otra
otro
ni
sur
historia
maestro
negro
blanco
sopas
fácil
difícil
que
muchacho
calor
agua
hielo
vapor
fuego
gas
aire
atmósfera
tierra
piso
suelo
metal
metálico
hierro
oro
plata
plomo
sal
barro
escritorio
silla
mesa
cama
dormitorio
habitación
cuarto
oficina
panel
puerta
ventana
entrada
hogar
casa
apartamento
departamento
edificio
construcción
elevador
ascensor
escalera
cultura
autor
actuación
espectador
espectáculo
entretenimiento
arte
cine
dibujo
pintura
música
religión
dios
artículo
educación
escuela
instituto
colegio
universidad
clase
curso
estudio
formación
análisis
investigación
conocimiento
idea
información
dato
forma
manera
champeta

View file

@ -0,0 +1,100 @@
або
але
багато
батько
без
більше
будь
вибачте
вино
вівторок
він
вісім
вона
вони
вчора
гарний
говорити
два
де
дев'ять
день
десять
дитина
дівчина
побачення
добре
друг
подруга
дружина
дуже
дякую
жінка
завтра
зараз
знову
зробити
інший
кава
кафе
кінотеатр
кішка
класно
книга
коли
комп'ютер
ласка
ліворуч
любити
магазин
мама
ми
можна
музика
не
неділя
ні
нічого
обід
один
пиво
повтори
подобатися
понеділок
потім
праворуч
працювати
рахунок
ресторан
ринок
розуміти
середа
сім
скільки
справи
субота
сьогодні
так
також
там
тільки
тому
три
трохи
трошки
тут
фільм
хлопець
хотіти
хто
це
цікаво
чай
четвер
чоловік
чому
чотири
чудово
шість
що
я

308
src/config.rs Normal file
View file

@ -0,0 +1,308 @@
use ratatui::style::{Color, Modifier, Style};
use serde::{
de::{self, IntoDeserializer},
Deserialize,
};
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Config {
pub default_language: String,
pub theme: Theme,
pub keyboard: String,
// server
pub server: String,
pub bucket: String,
pub org: String,
pub token: String,
}
impl Default for Config {
fn default() -> Self {
Self {
default_language: "english1000".into(),
theme: Theme::default(),
keyboard: "Generic".into(),
server: "http://localhost:3000".into(),
bucket: "dttyper".into(),
org: "dttyper".into(),
token: "none".into(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Theme {
#[serde(deserialize_with = "deserialize_style")]
pub default: Style,
#[serde(deserialize_with = "deserialize_style")]
pub title: Style,
// test widget
#[serde(deserialize_with = "deserialize_style")]
pub input_border: Style,
#[serde(deserialize_with = "deserialize_style")]
pub prompt_border: Style,
#[serde(deserialize_with = "deserialize_style")]
pub prompt_correct: Style,
#[serde(deserialize_with = "deserialize_style")]
pub prompt_incorrect: Style,
#[serde(deserialize_with = "deserialize_style")]
pub prompt_untyped: Style,
#[serde(deserialize_with = "deserialize_style")]
pub prompt_current_correct: Style,
#[serde(deserialize_with = "deserialize_style")]
pub prompt_current_incorrect: Style,
#[serde(deserialize_with = "deserialize_style")]
pub prompt_current_untyped: Style,
#[serde(deserialize_with = "deserialize_style")]
pub prompt_cursor: Style,
// results widget
#[serde(deserialize_with = "deserialize_style")]
pub results_overview: Style,
#[serde(deserialize_with = "deserialize_style")]
pub results_overview_border: Style,
#[serde(deserialize_with = "deserialize_style")]
pub results_worst_keys: Style,
#[serde(deserialize_with = "deserialize_style")]
pub results_worst_keys_border: Style,
#[serde(deserialize_with = "deserialize_style")]
pub results_chart: Style,
#[serde(deserialize_with = "deserialize_style")]
pub results_chart_x: Style,
#[serde(deserialize_with = "deserialize_style")]
pub results_chart_y: Style,
#[serde(deserialize_with = "deserialize_style")]
pub results_restart_prompt: Style,
}
impl Default for Theme {
fn default() -> Self {
Self {
default: Style::default(),
title: Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
input_border: Style::default().fg(Color::Cyan),
prompt_border: Style::default().fg(Color::Green),
prompt_correct: Style::default().fg(Color::Green),
prompt_incorrect: Style::default().fg(Color::Red),
prompt_untyped: Style::default().fg(Color::Gray),
prompt_current_correct: Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
prompt_current_incorrect: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
prompt_current_untyped: Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
prompt_cursor: Style::default().add_modifier(Modifier::UNDERLINED),
results_overview: Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
results_overview_border: Style::default().fg(Color::Cyan),
results_worst_keys: Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
results_worst_keys_border: Style::default().fg(Color::Cyan),
results_chart: Style::default().fg(Color::Cyan),
results_chart_x: Style::default().fg(Color::Cyan),
results_chart_y: Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
results_restart_prompt: Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC),
}
}
}
fn deserialize_style<'de, D>(deserializer: D) -> Result<Style, D::Error>
where
D: de::Deserializer<'de>,
{
struct StyleVisitor;
impl<'de> de::Visitor<'de> for StyleVisitor {
type Value = Style;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string describing a text style")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
let (colors, modifiers) = value.split_once(';').unwrap_or((value, ""));
let (fg, bg) = colors.split_once(':').unwrap_or((colors, "none"));
let mut style = Style {
fg: match fg {
"none" | "" => None,
_ => Some(deserialize_color(fg.into_deserializer())?),
},
bg: match bg {
"none" | "" => None,
_ => Some(deserialize_color(bg.into_deserializer())?),
},
..Default::default()
};
for modifier in modifiers.split_terminator(';') {
style = style.add_modifier(match modifier {
"bold" => Modifier::BOLD,
"crossed_out" => Modifier::CROSSED_OUT,
"dim" => Modifier::DIM,
"hidden" => Modifier::HIDDEN,
"italic" => Modifier::ITALIC,
"rapid_blink" => Modifier::RAPID_BLINK,
"slow_blink" => Modifier::SLOW_BLINK,
"reversed" => Modifier::REVERSED,
"underlined" => Modifier::UNDERLINED,
_ => {
return Err(E::invalid_value(
de::Unexpected::Str(modifier),
&"a style modifier",
))
}
});
}
Ok(style)
}
}
deserializer.deserialize_str(StyleVisitor)
}
fn deserialize_color<'de, D>(deserializer: D) -> Result<Color, D::Error>
where
D: de::Deserializer<'de>,
{
struct ColorVisitor;
impl<'de> de::Visitor<'de> for ColorVisitor {
type Value = Color;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a color name or hexadecimal color code")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
match value {
"reset" => Ok(Color::Reset),
"black" => Ok(Color::Black),
"white" => Ok(Color::White),
"red" => Ok(Color::Red),
"green" => Ok(Color::Green),
"yellow" => Ok(Color::Yellow),
"blue" => Ok(Color::Blue),
"magenta" => Ok(Color::Magenta),
"cyan" => Ok(Color::Cyan),
"gray" => Ok(Color::Gray),
"darkgray" => Ok(Color::DarkGray),
"lightred" => Ok(Color::LightRed),
"lightgreen" => Ok(Color::LightGreen),
"lightyellow" => Ok(Color::LightYellow),
"lightblue" => Ok(Color::LightBlue),
"lightmagenta" => Ok(Color::LightMagenta),
"lightcyan" => Ok(Color::LightCyan),
_ => {
if value.len() == 6 {
let parse_error = |_| E::custom("color code was not valid hexadecimal");
Ok(Color::Rgb(
u8::from_str_radix(&value[0..2], 16).map_err(parse_error)?,
u8::from_str_radix(&value[2..4], 16).map_err(parse_error)?,
u8::from_str_radix(&value[4..6], 16).map_err(parse_error)?,
))
} else {
Err(E::invalid_value(
de::Unexpected::Str(value),
&"a color name or hexadecimal color code",
))
}
}
}
}
}
deserializer.deserialize_str(ColorVisitor)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserializes_basic_colors() {
fn color(string: &str) -> Color {
deserialize_color(de::IntoDeserializer::<de::value::Error>::into_deserializer(
string,
))
.expect("failed to deserialize color")
}
assert_eq!(color("black"), Color::Black);
assert_eq!(color("000000"), Color::Rgb(0, 0, 0));
assert_eq!(color("ffffff"), Color::Rgb(0xff, 0xff, 0xff));
assert_eq!(color("FFFFFF"), Color::Rgb(0xff, 0xff, 0xff));
}
#[test]
fn deserializes_styles() {
fn style(string: &str) -> Style {
deserialize_style(de::IntoDeserializer::<de::value::Error>::into_deserializer(
string,
))
.expect("failed to deserialize style")
}
assert_eq!(style("none"), Style::default());
assert_eq!(style("none:none"), Style::default());
assert_eq!(style("none:none;"), Style::default());
assert_eq!(style("black"), Style::default().fg(Color::Black));
assert_eq!(
style("black:white"),
Style::default().fg(Color::Black).bg(Color::White)
);
assert_eq!(
style("none;bold"),
Style::default().add_modifier(Modifier::BOLD)
);
assert_eq!(
style("none;bold;italic;underlined;"),
Style::default()
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::ITALIC)
.add_modifier(Modifier::UNDERLINED)
);
assert_eq!(
style("00ff00:000000;bold;dim;italic;slow_blink"),
Style::default()
.fg(Color::Rgb(0, 0xff, 0))
.bg(Color::Rgb(0, 0, 0))
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::DIM)
.add_modifier(Modifier::ITALIC)
.add_modifier(Modifier::SLOW_BLINK)
);
}
}

354
src/main.rs Normal file
View file

@ -0,0 +1,354 @@
mod config;
mod save;
mod test;
mod ui;
use config::Config;
use save::save_result;
use test::{results::Results, Test};
use crossterm::{
self, cursor,
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute, terminal,
};
use rand::{seq::SliceRandom, thread_rng};
use ratatui::{backend::CrosstermBackend, terminal::Terminal};
use rust_embed::RustEmbed;
use std::{
ffi::OsString,
fs,
io::{self, BufRead},
num,
path::PathBuf,
str,
};
use structopt::StructOpt;
#[derive(RustEmbed)]
#[folder = "resources/runtime"]
struct Resources;
#[derive(Debug, StructOpt)]
#[structopt(name = "dttyper", about = "Terminal-based typing test with exporting")]
struct Opt {
#[structopt(parse(from_os_str))]
contents: Option<PathBuf>,
#[structopt(short, long)]
debug: bool,
/// Specify word count
#[structopt(short, long, default_value = "15")]
words: num::NonZeroUsize,
/// Use config file
#[structopt(short, long)]
config: Option<PathBuf>,
/// Specify test language in file
#[structopt(long, parse(from_os_str))]
language_file: Option<PathBuf>,
/// Specify test language
#[structopt(short, long)]
language: Option<String>,
/// List installed languages
#[structopt(long)]
list_languages: bool,
/// Disable backtracking to completed words
#[structopt(long)]
no_backtrack: bool,
/// Enable sudden death mode to restart on first error
#[structopt(long)]
sudden_death: bool,
}
impl Opt {
fn gen_contents(&self) -> Option<Vec<String>> {
match &self.contents {
Some(path) => {
let lines: Vec<String> = if path.as_os_str() == "-" {
std::io::stdin()
.lock()
.lines()
.map_while(Result::ok)
.collect()
} else {
let file = fs::File::open(path).expect("Error reading language file.");
io::BufReader::new(file)
.lines()
.map_while(Result::ok)
.collect()
};
Some(lines.iter().map(String::from).collect())
}
None => {
let lang_name = self
.language
.clone()
.unwrap_or_else(|| self.config().default_language);
let bytes: Vec<u8> = self
.language_file
.as_ref()
.map(fs::read)
.and_then(Result::ok)
.or_else(|| fs::read(self.language_dir().join(&lang_name)).ok())
.or_else(|| {
Resources::get(&format!("language/{}", &lang_name))
.map(|f| f.data.into_owned())
})?;
let mut rng = thread_rng();
let mut language: Vec<&str> = str::from_utf8(&bytes)
.expect("Language file had non-utf8 encoding.")
.lines()
.collect();
language.shuffle(&mut rng);
let mut contents: Vec<_> = language
.into_iter()
.cycle()
.take(self.words.get())
.map(ToOwned::to_owned)
.collect();
// TODO insert quote logic here :P
contents.shuffle(&mut rng);
Some(contents)
}
}
}
/// Configuration
fn config(&self) -> Config {
fs::read(
self.config
.clone()
.unwrap_or_else(|| self.config_dir().join("config.toml")),
)
.map(|bytes| {
toml::from_str(str::from_utf8(&bytes).unwrap_or_default())
.expect("Configuration was ill-formed.")
})
.unwrap_or_default()
}
/// Installed languages under config directory
fn languages(&self) -> io::Result<impl Iterator<Item = OsString>> {
let builtin = Resources::iter().filter_map(|name| {
name.strip_prefix("language/")
.map(ToOwned::to_owned)
.map(OsString::from)
});
let configured = self
.language_dir()
.read_dir()
.into_iter()
.flatten()
.map_while(Result::ok)
.map(|e| e.file_name());
Ok(builtin.chain(configured))
}
/// Config directory
fn config_dir(&self) -> PathBuf {
dirs::config_dir()
.expect("Failed to find config directory.")
.join("dttyper")
}
/// Language directory under config directory
fn language_dir(&self) -> PathBuf {
self.config_dir().join("language")
}
}
enum State {
Test(Test),
Results(Results),
}
impl State {
fn render_into<B: ratatui::backend::Backend>(
&self,
terminal: &mut Terminal<B>,
config: &Config,
) -> io::Result<()> {
match self {
State::Test(test) => {
terminal.draw(|f| {
f.render_widget(config.theme.apply_to(test), f.size());
})?;
}
State::Results(results) => {
terminal.draw(|f| {
f.render_widget(config.theme.apply_to(results), f.size());
})?;
}
}
Ok(())
}
}
fn main() -> io::Result<()> {
let opt = Opt::from_args();
if opt.debug {
dbg!(&opt);
}
let config = opt.config();
if opt.debug {
dbg!(&config);
}
if opt.list_languages {
opt.languages()
.unwrap()
.for_each(|name| println!("{}", name.to_str().expect("Ill-formatted language name.")));
return Ok(());
}
// TODO add server checking
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
terminal::enable_raw_mode()?;
execute!(
io::stdout(),
cursor::Hide,
cursor::SavePosition,
terminal::EnterAlternateScreen,
)?;
terminal.clear()?;
let mut state = State::Test(Test::new(
opt.gen_contents().expect(
"Couldn't get test contents. Make sure the specified language actually exists.",
),
!opt.no_backtrack,
opt.sudden_death,
));
state.render_into(&mut terminal, &config)?;
loop {
let event = event::read()?;
// handle exit controls
match event {
Event::Key(KeyEvent {
code: KeyCode::Char('c'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
..
}) => break,
Event::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
..
}) => match state {
State::Test(ref test) => {
state = State::Results(Results::from(test));
}
State::Results(_) => break,
},
_ => {}
}
match state {
State::Test(ref mut test) => {
if let Event::Key(key) = event {
test.handle_key(key);
if test.complete {
save_result(
&Results::from(&*test),
config.server.clone(),
config.token.clone(),
config.keyboard.clone(),
match Opt::from_args().language {
None => "english".to_string(),
Some(lang) => lang,
},
config.bucket.clone(),
config.org.clone(),
test.clone(),
);
state = State::Results(Results::from(&*test));
}
}
}
State::Results(ref result) => {
match event {
Event::Key(KeyEvent {
code: KeyCode::Char('r'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
..
}) => {
state = State::Test(Test::new(
opt.gen_contents().expect(
"Couldn't get test contents. Make sure the specified language actually exists.",
),
!opt.no_backtrack,
opt.sudden_death
));
}
Event::Key(KeyEvent {
code: KeyCode::Char('p'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
..
}) => {
if result.missed_words.is_empty() {
continue;
}
// repeat each missed word 5 times
let mut practice_words: Vec<String> = (result.missed_words)
.iter()
.flat_map(|w| vec![w.clone(); 5])
.collect();
practice_words.shuffle(&mut thread_rng());
state = State::Test(Test::new(
practice_words,
!opt.no_backtrack,
opt.sudden_death,
));
}
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
..
}) => break,
_ => {}
}
}
}
state.render_into(&mut terminal, &config)?;
}
terminal::disable_raw_mode()?;
execute!(
io::stdout(),
cursor::RestorePosition,
cursor::Show,
terminal::LeaveAlternateScreen,
)?;
Ok(())
}

88
src/save.rs Normal file
View file

@ -0,0 +1,88 @@
use std::{str::FromStr, thread, time::Duration};
use crossterm::terminal;
use crate::test::{results::Results, Test, TestWord};
pub fn save_result(
result: &Results,
url: String,
token: String,
keyboard: String,
test_type: String,
bucket: String,
org: String,
test: Test,
) {
// Creating a duplicate of the data
let rr = Results {
timing: result.timing.clone(),
accuracy: result.accuracy.clone(),
missed_words: result.missed_words.clone(),
};
// HTTP client
let client = reqwest::blocking::Client::new();
// parsing language into quotes
let language = format!("\"{}\"", test_type);
// total words typed
let words_typed = test.words.len();
// filtering out incorrectly typed words
let words_typed_incorrectly = test
.clone()
.words
.into_iter()
.filter(|word| word.events.iter().any(|o| o.correct == Some(false)))
.collect::<Vec<TestWord>>();
// filtering out correctly typed words and checking if they are not already an instance of the incorrectly typed ones
// since the user may type the word incorrectly the first time and go back to change it
// then turning it into a string
let words_typed_correctly = test
.clone()
.words
.into_iter()
.filter(|word| {
word.events.iter().any(|o| o.correct == Some(true))
&& !words_typed_incorrectly.contains(word)
})
.map(|w| w.text.clone())
.collect::<Vec<String>>();
// turning incorrect words into a string
let words_typed_incorrectly = words_typed_incorrectly
.iter()
.map(|w| w.text.clone())
.collect::<Vec<String>>();
// building influxdb insert query
let data = format!("test,language={},keyboard={} words={},incorrect_types={},correct_types={},wpm={},accuracy={},words_typed_correctly={},words_typed_incorrectly={} {}",
language.replace('"', ""),
"generic",
words_typed,
words_typed_incorrectly.len(),
words_typed_correctly.len(),
rr.timing.overall_cps as f64 * 12.0 * ( rr.accuracy.overall.numerator as f64 / rr.accuracy.overall.denominator as f64), //float
( rr.accuracy.overall.numerator as f64 / rr.accuracy.overall.denominator as f64) * 100.0, // accuracy
format!("\"{}\"", words_typed_correctly.join(",")),
format!("\"{}\"", words_typed_incorrectly.join(",")),
chrono::Utc::now().timestamp()
);
let mut server_url = url.clone();
server_url.push_str("/api/v2/write");
let mut url = reqwest::Url::from_str(server_url.as_str()).unwrap();
url.set_query(Some(
format!("org={}&bucket={}&precision=s", org, bucket).as_str(),
));
// TODO error handle
client
.post(url)
.body(data)
.header("Authorization", format!("Token {}", token))
.send();
}

172
src/test/mod.rs Normal file
View file

@ -0,0 +1,172 @@
pub mod results;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::fmt;
use std::time::Instant;
#[derive(Clone, PartialEq)]
pub struct TestEvent {
pub time: Instant,
pub key: KeyEvent,
pub correct: Option<bool>,
}
pub fn is_missed_word_event(event: &TestEvent) -> bool {
event.correct != Some(true)
}
impl fmt::Debug for TestEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TestEvent")
.field("time", &String::from("Instant { ... }"))
.field("key", &self.key)
.finish()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TestWord {
pub text: String,
pub progress: String,
pub events: Vec<TestEvent>,
}
impl From<String> for TestWord {
fn from(string: String) -> Self {
TestWord {
text: string,
progress: String::new(),
events: Vec::new(),
}
}
}
impl From<&str> for TestWord {
fn from(string: &str) -> Self {
Self::from(string.to_string())
}
}
#[derive(Debug, Clone)]
pub struct Test {
pub words: Vec<TestWord>,
pub current_word: usize,
pub complete: bool,
pub backtracking_enabled: bool,
pub sudden_death_enabled: bool,
}
impl Test {
pub fn new(words: Vec<String>, backtracking_enabled: bool, sudden_death_enabled: bool) -> Self {
Self {
words: words.into_iter().map(TestWord::from).collect(),
current_word: 0,
complete: false,
backtracking_enabled,
sudden_death_enabled,
}
}
pub fn handle_key(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
let word = &mut self.words[self.current_word];
match key.code {
KeyCode::Char(' ') | KeyCode::Enter => {
if word.text.chars().nth(word.progress.len()) == Some(' ') {
word.progress.push(' ');
word.events.push(TestEvent {
time: Instant::now(),
correct: Some(true),
key,
})
} else if !word.progress.is_empty() || word.text.is_empty() {
let correct = word.text == word.progress;
if self.sudden_death_enabled && !correct {
self.reset();
} else {
word.events.push(TestEvent {
time: Instant::now(),
correct: Some(correct),
key,
});
self.next_word();
}
}
}
KeyCode::Backspace => {
if word.progress.is_empty() && self.backtracking_enabled {
self.last_word();
} else {
word.events.push(TestEvent {
time: Instant::now(),
correct: Some(!word.text.starts_with(&word.progress[..])),
key,
});
word.progress.pop();
}
}
// CTRL-BackSpace and CTRL-W
KeyCode::Char('h') | KeyCode::Char('w')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
if self.words[self.current_word].progress.is_empty() {
self.last_word();
}
let word = &mut self.words[self.current_word];
word.events.push(TestEvent {
time: Instant::now(),
correct: None,
key,
});
word.progress.clear();
}
KeyCode::Char(c) => {
word.progress.push(c);
let correct = word.text.starts_with(&word.progress[..]);
if self.sudden_death_enabled && !correct {
self.reset();
} else {
word.events.push(TestEvent {
time: Instant::now(),
correct: Some(correct),
key,
});
if word.progress == word.text && self.current_word == self.words.len() - 1 {
self.complete = true;
self.current_word = 0;
}
}
}
_ => {}
};
}
fn last_word(&mut self) {
if self.current_word != 0 {
self.current_word -= 1;
}
}
fn next_word(&mut self) {
if self.current_word == self.words.len() - 1 {
self.complete = true;
self.current_word = 0;
} else {
self.current_word += 1;
}
}
fn reset(&mut self) {
self.words.iter_mut().for_each(|word: &mut TestWord| {
word.progress.clear();
word.events.clear();
});
self.current_word = 0;
self.complete = false;
}
}

160
src/test/results.rs Normal file
View file

@ -0,0 +1,160 @@
use super::{is_missed_word_event, Test};
use crossterm::event::KeyEvent;
use serde::Serialize;
use std::collections::HashMap;
use std::{cmp, fmt};
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)]
pub struct Fraction {
pub numerator: usize,
pub denominator: usize,
}
impl Fraction {
pub const fn new(numerator: usize, denominator: usize) -> Self {
Self {
numerator,
denominator,
}
}
}
impl From<Fraction> for f64 {
fn from(f: Fraction) -> Self {
f.numerator as f64 / f.denominator as f64
}
}
impl cmp::Ord for Fraction {
fn cmp(&self, other: &Self) -> cmp::Ordering {
f64::from(*self).partial_cmp(&f64::from(*other)).unwrap()
}
}
impl PartialOrd for Fraction {
fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
Some(self.cmp(other))
}
}
impl fmt::Display for Fraction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.numerator, self.denominator)
}
}
pub trait PartialResults {
fn progress(&self) -> Fraction;
}
impl PartialResults for Test {
fn progress(&self) -> Fraction {
Fraction {
numerator: self.current_word + 1,
denominator: self.words.len(),
}
}
}
#[derive(Clone)]
pub struct TimingData {
pub overall_cps: f64,
pub per_event: Vec<f64>,
pub per_key: HashMap<KeyEvent, f64>,
}
#[derive(Clone)]
pub struct AccuracyData {
pub overall: Fraction,
pub per_key: HashMap<KeyEvent, Fraction>,
}
#[derive(Clone)]
pub struct Results {
pub timing: TimingData,
pub accuracy: AccuracyData,
pub missed_words: Vec<String>,
}
impl From<&Test> for Results {
fn from(test: &Test) -> Self {
let events: Vec<&super::TestEvent> =
test.words.iter().flat_map(|w| w.events.iter()).collect();
Self {
timing: calc_timing(&events),
accuracy: calc_accuracy(&events),
missed_words: calc_missed_words(test),
}
}
}
fn calc_timing(events: &[&super::TestEvent]) -> TimingData {
let mut timing = TimingData {
overall_cps: -1.0,
per_event: Vec::new(),
per_key: HashMap::new(),
};
// map of keys to a two-tuple (total time, clicks) for counting average
let mut keys: HashMap<KeyEvent, (f64, usize)> = HashMap::new();
for win in events.windows(2) {
let event_dur = win[1]
.time
.checked_duration_since(win[0].time)
.map(|d| d.as_secs_f64());
if let Some(event_dur) = event_dur {
timing.per_event.push(event_dur);
let key = keys.entry(win[1].key).or_insert((0.0, 0));
key.0 += event_dur;
key.1 += 1;
}
}
timing.per_key = keys
.into_iter()
.map(|(key, (total, count))| (key, total / count as f64))
.collect();
timing.overall_cps = timing.per_event.len() as f64 / timing.per_event.iter().sum::<f64>();
timing
}
fn calc_accuracy(events: &[&super::TestEvent]) -> AccuracyData {
let mut acc = AccuracyData {
overall: Fraction::new(0, 0),
per_key: HashMap::new(),
};
events
.iter()
.filter(|event| event.correct.is_some())
.for_each(|event| {
let key = acc
.per_key
.entry(event.key)
.or_insert_with(|| Fraction::new(0, 0));
acc.overall.denominator += 1;
key.denominator += 1;
if event.correct.unwrap() {
acc.overall.numerator += 1;
key.numerator += 1;
}
});
acc
}
fn calc_missed_words(test: &Test) -> Vec<String> {
test.words
.iter()
.filter(|word| word.events.iter().any(is_missed_word_event))
.map(|word| word.text.clone())
.collect()
}

512
src/ui.rs Normal file
View file

@ -0,0 +1,512 @@
use crate::config::Theme;
use super::test::{results, Test, TestWord};
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
symbols::Marker,
text::{Line, Span, Text},
widgets::{Axis, Block, BorderType, Borders, Chart, Dataset, GraphType, Paragraph, Widget},
};
use results::Fraction;
// Convert CPS to WPM (clicks per second)
const WPM_PER_CPS: f64 = 12.0;
// Width of the moving average window for the WPM chart
const WPM_SMA_WIDTH: usize = 10;
#[derive(Clone)]
struct SizedBlock<'a> {
block: Block<'a>,
area: Rect,
}
impl SizedBlock<'_> {
fn render(self, buf: &mut Buffer) {
self.block.render(self.area, buf)
}
}
trait UsedWidget: Widget {}
impl UsedWidget for Paragraph<'_> {}
trait DrawInner<T> {
fn draw_inner(&self, content: T, buf: &mut Buffer);
}
impl DrawInner<&Line<'_>> for SizedBlock<'_> {
fn draw_inner(&self, content: &Line, buf: &mut Buffer) {
let inner = self.block.inner(self.area);
buf.set_line(inner.x, inner.y, content, inner.width);
}
}
impl<T: UsedWidget> DrawInner<T> for SizedBlock<'_> {
fn draw_inner(&self, content: T, buf: &mut Buffer) {
let inner = self.block.inner(self.area);
content.render(inner, buf);
}
}
pub trait ThemedWidget {
fn render(self, area: Rect, buf: &mut Buffer, theme: &Theme);
}
pub struct Themed<'t, W: ?Sized> {
theme: &'t Theme,
widget: W,
}
impl<'t, W: ThemedWidget> Widget for Themed<'t, W> {
fn render(self, area: Rect, buf: &mut Buffer) {
self.widget.render(area, buf, self.theme)
}
}
impl Theme {
pub fn apply_to<W>(&self, widget: W) -> Themed<'_, W> {
Themed {
theme: self,
widget,
}
}
}
impl ThemedWidget for &Test {
fn render(self, area: Rect, buf: &mut Buffer, theme: &Theme) {
buf.set_style(area, theme.default);
// Chunks
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(6)])
.split(area);
// Sections
let input = SizedBlock {
block: Block::default()
.title(Line::from(vec![Span::styled("Input", theme.title)]))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme.input_border),
area: chunks[0],
};
input.draw_inner(
&Line::from(self.words[self.current_word].progress.clone()),
buf,
);
input.render(buf);
let target_lines: Vec<Line> = {
let words = words_to_spans(&self.words, self.current_word, theme);
let mut lines: Vec<Line> = Vec::new();
let mut current_line: Vec<Span> = Vec::new();
let mut current_width = 0;
for word in words {
let word_width: usize = word.iter().map(|s| s.width()).sum();
if current_width + word_width > chunks[1].width as usize - 2 {
current_line.push(Span::raw("\n"));
lines.push(Line::from(current_line.clone()));
current_line.clear();
current_width = 0;
}
current_line.extend(word);
current_width += word_width;
}
lines.push(Line::from(current_line));
lines
};
let target = Paragraph::new(target_lines).block(
Block::default()
.title(Span::styled("Prompt", theme.title))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme.prompt_border),
);
target.render(chunks[1], buf);
}
}
fn words_to_spans<'a>(
words: &'a [TestWord],
current_word: usize,
theme: &'a Theme,
) -> Vec<Vec<Span<'a>>> {
let mut spans = Vec::new();
for word in &words[..current_word] {
let parts = split_typed_word(word);
spans.push(word_parts_to_spans(parts, theme));
}
let parts_current = split_current_word(&words[current_word]);
spans.push(word_parts_to_spans(parts_current, theme));
for word in &words[current_word + 1..] {
let parts = vec![(word.text.clone(), Status::Untyped)];
spans.push(word_parts_to_spans(parts, theme));
}
spans
}
#[derive(PartialEq, Clone, Copy, Debug)]
enum Status {
Correct,
Incorrect,
CurrentUntyped,
CurrentCorrect,
CurrentIncorrect,
Cursor,
Untyped,
Overtyped,
}
fn split_current_word(word: &TestWord) -> Vec<(String, Status)> {
let mut parts = Vec::new();
let mut cur_string = String::new();
let mut cur_status = Status::Untyped;
let mut progress = word.progress.chars();
for tc in word.text.chars() {
let p = progress.next();
let status = match p {
None => Status::CurrentUntyped,
Some(c) => match c {
c if c == tc => Status::CurrentCorrect,
_ => Status::CurrentIncorrect,
},
};
if status == cur_status {
cur_string.push(tc);
} else {
if !cur_string.is_empty() {
parts.push((cur_string, cur_status));
cur_string = String::new();
}
cur_string.push(tc);
cur_status = status;
// first currentuntyped is cursor
if status == Status::CurrentUntyped {
parts.push((cur_string, Status::Cursor));
cur_string = String::new();
}
}
}
if !cur_string.is_empty() {
parts.push((cur_string, cur_status));
}
let overtyped = progress.collect::<String>();
if !overtyped.is_empty() {
parts.push((overtyped, Status::Overtyped));
}
parts
}
fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> {
let mut parts = Vec::new();
let mut cur_string = String::new();
let mut cur_status = Status::Untyped;
let mut progress = word.progress.chars();
for tc in word.text.chars() {
let p = progress.next();
let status = match p {
None => Status::Untyped,
Some(c) => match c {
c if c == tc => Status::Correct,
_ => Status::Incorrect,
},
};
if status == cur_status {
cur_string.push(tc);
} else {
if !cur_string.is_empty() {
parts.push((cur_string, cur_status));
cur_string = String::new();
}
cur_string.push(tc);
cur_status = status;
}
}
if !cur_string.is_empty() {
parts.push((cur_string, cur_status));
}
let overtyped = progress.collect::<String>();
if !overtyped.is_empty() {
parts.push((overtyped, Status::Overtyped));
}
parts
}
fn word_parts_to_spans(parts: Vec<(String, Status)>, theme: &Theme) -> Vec<Span<'_>> {
let mut spans = Vec::new();
for (text, status) in parts {
let style = match status {
Status::Correct => theme.prompt_correct,
Status::Incorrect => theme.prompt_incorrect,
Status::Untyped => theme.prompt_untyped,
Status::CurrentUntyped => theme.prompt_current_untyped,
Status::CurrentCorrect => theme.prompt_current_correct,
Status::CurrentIncorrect => theme.prompt_current_incorrect,
Status::Cursor => theme.prompt_current_untyped.patch(theme.prompt_cursor),
Status::Overtyped => theme.prompt_incorrect,
};
spans.push(Span::styled(text, style));
}
spans.push(Span::styled(" ", theme.prompt_untyped));
spans
}
impl ThemedWidget for &results::Results {
fn render(self, area: Rect, buf: &mut Buffer, theme: &Theme) {
buf.set_style(area, theme.default);
// Chunks
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(area);
let res_chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1) // Graph looks tremendously better with just a little margin
.constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
.split(chunks[0]);
let info_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(res_chunks[0]);
let msg = if self.missed_words.is_empty() {
"Press 'q' to quit or 'r' for another test"
} else {
"Press 'q' to quit, 'r' for another test or 'p' to practice missed words"
};
let exit = Span::styled(msg, theme.results_restart_prompt);
buf.set_span(chunks[1].x, chunks[1].y, &exit, chunks[1].width);
// Sections
let mut overview_text = Text::styled("", theme.results_overview);
overview_text.extend([
Line::from(format!(
"Adjusted WPM: {:.1}",
self.timing.overall_cps * WPM_PER_CPS * f64::from(self.accuracy.overall)
)),
Line::from(format!(
"Accuracy: {:.1}%",
f64::from(self.accuracy.overall) * 100f64
)),
Line::from(format!(
"Raw WPM: {:.1}",
self.timing.overall_cps * WPM_PER_CPS
)),
Line::from(format!("Correct Keypresses: {}", self.accuracy.overall)),
]);
let overview = Paragraph::new(overview_text).block(
Block::default()
.title(Span::styled("Overview", theme.title))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme.results_overview_border),
);
overview.render(info_chunks[0], buf);
let mut worst_keys: Vec<(&KeyEvent, &Fraction)> = self
.accuracy
.per_key
.iter()
.filter(|(key, _)| matches!(key.code, KeyCode::Char(_)))
.collect();
worst_keys.sort_unstable_by_key(|x| x.1);
let mut worst_text = Text::styled("", theme.results_worst_keys);
worst_text.extend(
worst_keys
.iter()
.filter_map(|(key, acc)| {
if let KeyCode::Char(character) = key.code {
let key_accuracy = f64::from(**acc) * 100.0;
if key_accuracy != 100.0 {
Some(format!("- {} at {:.1}% accuracy", character, key_accuracy))
} else {
None
}
} else {
None
}
})
.take(5)
.map(Line::from),
);
let worst = Paragraph::new(worst_text).block(
Block::default()
.title(Span::styled("Worst Keys", theme.title))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme.results_worst_keys_border),
);
worst.render(info_chunks[1], buf);
let wpm_sma: Vec<(f64, f64)> = self
.timing
.per_event
.windows(WPM_SMA_WIDTH)
.enumerate()
.map(|(i, window)| {
(
(i + WPM_SMA_WIDTH) as f64,
window.len() as f64 / window.iter().copied().sum::<f64>() * WPM_PER_CPS,
)
})
.collect();
// Render the chart if possible
if !wpm_sma.is_empty() {
let wpm_sma_min = wpm_sma
.iter()
.map(|(_, x)| x)
.fold(f64::INFINITY, |a, &b| a.min(b));
let wpm_sma_max = wpm_sma
.iter()
.map(|(_, x)| x)
.fold(f64::NEG_INFINITY, |a, &b| a.max(b));
let wpm_datasets = vec![Dataset::default()
.name("WPM")
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(theme.results_chart)
.data(&wpm_sma)];
let y_label_min = wpm_sma_min as u16;
let y_label_max = (wpm_sma_max as u16).max(y_label_min + 6);
let wpm_chart = Chart::new(wpm_datasets)
.block(Block::default().title(vec![Span::styled("Chart", theme.title)]))
.x_axis(
Axis::default()
.title(Span::styled("Keypresses", theme.results_chart_x))
.bounds([0.0, self.timing.per_event.len() as f64]),
)
.y_axis(
Axis::default()
.title(Span::styled(
"WPM (10-keypress rolling average)",
theme.results_chart_y,
))
.bounds([wpm_sma_min, wpm_sma_max])
.labels(
(y_label_min..y_label_max)
.step_by(5)
.map(|n| Span::raw(format!("{}", n)))
.collect(),
),
);
wpm_chart.render(res_chunks[1], buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
mod split_words {
use super::Status::*;
use super::*;
struct TestCase {
word: &'static str,
progress: &'static str,
expected: Vec<(&'static str, Status)>,
}
fn setup(test_case: TestCase) -> (TestWord, Vec<(String, Status)>) {
let mut word = TestWord::from(test_case.word);
word.progress = test_case.progress.to_string();
let expected = test_case
.expected
.iter()
.map(|(s, v)| (s.to_string(), *v))
.collect::<Vec<_>>();
(word, expected)
}
#[test]
fn typed_words_split() {
let cases = vec![
TestCase {
word: "monkeytype",
progress: "monkeytype",
expected: vec![("monkeytype", Correct)],
},
TestCase {
word: "monkeytype",
progress: "monkeXtype",
expected: vec![("monke", Correct), ("y", Incorrect), ("type", Correct)],
},
TestCase {
word: "monkeytype",
progress: "monkeas",
expected: vec![("monke", Correct), ("yt", Incorrect), ("ype", Untyped)],
},
];
for case in cases {
let (word, expected) = setup(case);
let got = split_typed_word(&word);
assert_eq!(got, expected);
}
}
#[test]
fn current_word_split() {
let cases = vec![
TestCase {
word: "monkeytype",
progress: "monkeytype",
expected: vec![("monkeytype", CurrentCorrect)],
},
TestCase {
word: "monkeytype",
progress: "monke",
expected: vec![
("monke", CurrentCorrect),
("y", Cursor),
("type", CurrentUntyped),
],
},
TestCase {
word: "monkeytype",
progress: "monkeXt",
expected: vec![
("monke", CurrentCorrect),
("y", CurrentIncorrect),
("t", CurrentCorrect),
("y", Cursor),
("pe", CurrentUntyped),
],
},
];
for case in cases {
let (word, expected) = setup(case);
let got = split_current_word(&word);
assert_eq!(got, expected);
}
}
}
}