Multilingual dictionaries and keyboard layouts
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1226,6 +1226,7 @@ dependencies = [
|
|||||||
"criterion",
|
"criterion",
|
||||||
"crossterm 0.28.1",
|
"crossterm 0.28.1",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"icu_normalizer",
|
||||||
"rand",
|
"rand",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
reqwest = { version = "0.12", features = ["blocking"], optional = true }
|
reqwest = { version = "0.12", features = ["blocking"], optional = true }
|
||||||
|
icu_normalizer = { version = "2.1", default-features = false, features = ["compiled_data"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -8,10 +8,15 @@
|
|||||||
- Upstream license: GNU Affero General Public License v3.0
|
- Upstream license: GNU Affero General Public License v3.0
|
||||||
- Local upstream license copy (for local research clone): `clones/keybr.com/LICENSE`
|
- Local upstream license copy (for local research clone): `clones/keybr.com/LICENSE`
|
||||||
|
|
||||||
1. `assets/words-en.json`
|
1. `assets/dictionaries/words-*.json` (seeded Latin-script set)
|
||||||
- Source: `clones/keybr.com/packages/keybr-content-words/lib/data/words-en.json`
|
- Sources: `clones/keybr.com/packages/keybr-content-words/lib/data/words-<lang>.json`
|
||||||
- Status: included in this repository and used at runtime by `src/generator/dictionary.rs`
|
- Included language keys: `en, de, es, fr, it, pt, nl, sv, da, nb, fi, pl, cs, ro, hr, hu, lt, lv, sl, et, tr`
|
||||||
|
- Status: included in this repository and available to `src/generator/dictionary.rs`
|
||||||
- Modifications: none (byte-identical at the time of import)
|
- Modifications: none (byte-identical at the time of import)
|
||||||
|
- Integrity metadata:
|
||||||
|
- `assets/dictionaries/manifest.tsv` (language/file/source mapping)
|
||||||
|
- `assets/dictionaries/SHA256SUMS` (checksum manifest)
|
||||||
|
- `assets/dictionaries/words-<lang>.json.license` (per-file provenance/license sidecar)
|
||||||
|
|
||||||
## Local research clones (not committed to this repository)
|
## Local research clones (not committed to this repository)
|
||||||
|
|
||||||
@@ -55,7 +60,7 @@ architecture/algorithm ideas:
|
|||||||
- keybr-code
|
- keybr-code
|
||||||
|
|
||||||
For these references, no direct code/data inclusion is claimed in this repository
|
For these references, no direct code/data inclusion is claimed in this repository
|
||||||
except the explicitly documented `assets/words-en.json` import from keybr.com.
|
except the explicitly documented `assets/dictionaries/words-*.json` imports from keybr.com.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
21
assets/dictionaries/SHA256SUMS
Normal file
21
assets/dictionaries/SHA256SUMS
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
30a78612b478f8f9101e200b96ddf2807720a2b513ec6d05a73abdde99354407 words-cs.json
|
||||||
|
8098e39c9deb00db59d85f82c9bc791536b51c8fa2a5b688f771f120e83bbc26 words-da.json
|
||||||
|
014d7ff2f7756b1a0775b975e325bf75076770f0d4e6f9ebed771fa6aacb7ed5 words-de.json
|
||||||
|
067adf66de5f0a7ca17f3bf187bab378d8ad71e87856e4a25a208905404b949a words-en.json
|
||||||
|
fffcb910f0012e62215bfa2a8ed34ecc3d54cbf04a658c3bce5bee8148abf634 words-es.json
|
||||||
|
bfd0d22dbc129c3d693d5afbf39aaa5506c0c723bf5bb51ef10edd2af3f1c71d words-et.json
|
||||||
|
2530c4a37311fb93d6f687edb08534eca71f4c775e1a01fca405c783361386fa words-fi.json
|
||||||
|
3b177fcca8f275cce555ac954fcbbb945b14626a8a235993ff9e9d9767005517 words-fr.json
|
||||||
|
f439f8bf16f65f8600642906a0967dc1f99992f6a2f3b830bd77554ccb6a07de words-hr.json
|
||||||
|
44ec5436364a162dc7774be3c40a4678247aa2909eaceefac7f49b3bc00811f5 words-hu.json
|
||||||
|
03361069ce40d08fa931709ce402811d0f484c32d03878706ad4dcc5e709b01a words-it.json
|
||||||
|
9239f4042d67127859b3a56da29a6f3df4cd458776483adf561b668f3e646579 words-lt.json
|
||||||
|
ad24ebd9a36c012ebb8db3db78af5e6038d26b64b3f173885c4a606ab17d3d49 words-lv.json
|
||||||
|
be83a2cff75097db957575425b4dec658006c9b9e43fdcb7a6eb92701818b752 words-nb.json
|
||||||
|
0f701c9e5c891dd557a0f4f3a6903b4c9762a2a898f749caccb59efbee189271 words-nl.json
|
||||||
|
d99e00fb85890847ba783354e148ef835d44faee95c4d7ec227d589cf5b072d3 words-pl.json
|
||||||
|
fa3009988d7be559a78b6b2c2198628750de77d77e0ee360d8bf5cc8eab84368 words-pt.json
|
||||||
|
76ec930a9b6aaa8092f2179b0918d71ec61f139843d985f5f25eae07bd7093fc words-ro.json
|
||||||
|
2960c6db414abb22505a4f78d8292df2b45d7332144302296055fc5a8ee07e23 words-sl.json
|
||||||
|
154e1b905d10130fee0160d3e2f30bd6445e8da1e3251475df37be364a81bd17 words-sv.json
|
||||||
|
95f6e867ef64d6a1ddd90f82d574d38b4a2be19d550f613ee87fc3e1701a0d8e words-tr.json
|
||||||
22
assets/dictionaries/manifest.tsv
Normal file
22
assets/dictionaries/manifest.tsv
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# language_key file license_file source
|
||||||
|
en words-en.json words-en.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-en.json
|
||||||
|
de words-de.json words-de.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-de.json
|
||||||
|
es words-es.json words-es.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-es.json
|
||||||
|
fr words-fr.json words-fr.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-fr.json
|
||||||
|
it words-it.json words-it.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-it.json
|
||||||
|
pt words-pt.json words-pt.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-pt.json
|
||||||
|
nl words-nl.json words-nl.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-nl.json
|
||||||
|
sv words-sv.json words-sv.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-sv.json
|
||||||
|
da words-da.json words-da.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-da.json
|
||||||
|
nb words-nb.json words-nb.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-nb.json
|
||||||
|
fi words-fi.json words-fi.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-fi.json
|
||||||
|
pl words-pl.json words-pl.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-pl.json
|
||||||
|
cs words-cs.json words-cs.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-cs.json
|
||||||
|
ro words-ro.json words-ro.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-ro.json
|
||||||
|
hr words-hr.json words-hr.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-hr.json
|
||||||
|
hu words-hu.json words-hu.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-hu.json
|
||||||
|
lt words-lt.json words-lt.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-lt.json
|
||||||
|
lv words-lv.json words-lv.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-lv.json
|
||||||
|
sl words-sl.json words-sl.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-sl.json
|
||||||
|
et words-et.json words-et.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-et.json
|
||||||
|
tr words-tr.json words-tr.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-tr.json
|
||||||
|
10002
assets/dictionaries/words-cs.json
Normal file
10002
assets/dictionaries/words-cs.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-cs.json.license
Normal file
7
assets/dictionaries/words-cs.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-cs.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-da.json
Normal file
10002
assets/dictionaries/words-da.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-da.json.license
Normal file
7
assets/dictionaries/words-da.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-da.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-de.json
Normal file
10002
assets/dictionaries/words-de.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-de.json.license
Normal file
7
assets/dictionaries/words-de.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-de.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-es.json
Normal file
10002
assets/dictionaries/words-es.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-es.json.license
Normal file
7
assets/dictionaries/words-es.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-es.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
9682
assets/dictionaries/words-et.json
Normal file
9682
assets/dictionaries/words-et.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-et.json.license
Normal file
7
assets/dictionaries/words-et.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-et.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-fi.json
Normal file
10002
assets/dictionaries/words-fi.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-fi.json.license
Normal file
7
assets/dictionaries/words-fi.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-fi.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-fr.json
Normal file
10002
assets/dictionaries/words-fr.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-fr.json.license
Normal file
7
assets/dictionaries/words-fr.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-fr.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-hr.json
Normal file
10002
assets/dictionaries/words-hr.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-hr.json.license
Normal file
7
assets/dictionaries/words-hr.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-hr.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
8649
assets/dictionaries/words-hu.json
Normal file
8649
assets/dictionaries/words-hu.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-hu.json.license
Normal file
7
assets/dictionaries/words-hu.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-hu.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-it.json
Normal file
10002
assets/dictionaries/words-it.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-it.json.license
Normal file
7
assets/dictionaries/words-it.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-it.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-lt.json
Normal file
10002
assets/dictionaries/words-lt.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-lt.json.license
Normal file
7
assets/dictionaries/words-lt.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-lt.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-lv.json
Normal file
10002
assets/dictionaries/words-lv.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-lv.json.license
Normal file
7
assets/dictionaries/words-lv.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-lv.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-nb.json
Normal file
10002
assets/dictionaries/words-nb.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-nb.json.license
Normal file
7
assets/dictionaries/words-nb.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-nb.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-nl.json
Normal file
10002
assets/dictionaries/words-nl.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-nl.json.license
Normal file
7
assets/dictionaries/words-nl.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-nl.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-pl.json
Normal file
10002
assets/dictionaries/words-pl.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-pl.json.license
Normal file
7
assets/dictionaries/words-pl.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-pl.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-pt.json
Normal file
10002
assets/dictionaries/words-pt.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-pt.json.license
Normal file
7
assets/dictionaries/words-pt.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-pt.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-ro.json
Normal file
10002
assets/dictionaries/words-ro.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-ro.json.license
Normal file
7
assets/dictionaries/words-ro.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-ro.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
2767
assets/dictionaries/words-sl.json
Normal file
2767
assets/dictionaries/words-sl.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-sl.json.license
Normal file
7
assets/dictionaries/words-sl.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-sl.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
10002
assets/dictionaries/words-sv.json
Normal file
10002
assets/dictionaries/words-sv.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-sv.json.license
Normal file
7
assets/dictionaries/words-sv.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-sv.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
8888
assets/dictionaries/words-tr.json
Normal file
8888
assets/dictionaries/words-tr.json
Normal file
File diff suppressed because it is too large
Load Diff
7
assets/dictionaries/words-tr.json.license
Normal file
7
assets/dictionaries/words-tr.json.license
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This file is sourced from keybr.com:
|
||||||
|
clones/keybr.com/packages/keybr-content-words/lib/data/words-tr.json
|
||||||
|
|
||||||
|
Upstream project: https://github.com/aradzie/keybr.com
|
||||||
|
Upstream license: GNU Affero General Public License v3.0
|
||||||
|
|
||||||
|
Local project license: AGPL-3.0-only (see /LICENSE).
|
||||||
@@ -5,9 +5,19 @@ This repository includes AGPL-licensed upstream material and is licensed as
|
|||||||
|
|
||||||
## What is included in-repo
|
## What is included in-repo
|
||||||
|
|
||||||
- `assets/words-en.json` is imported from keybr.com and tracked in
|
- `assets/dictionaries/words-*.json` are imported from keybr.com and tracked in
|
||||||
`THIRD_PARTY_NOTICES.md`.
|
`THIRD_PARTY_NOTICES.md`.
|
||||||
- `assets/words-en.json.license` records source and license for the imported file.
|
- `assets/dictionaries/words-<lang>.json.license` records source and license for
|
||||||
|
each imported dictionary file.
|
||||||
|
- `assets/dictionaries/manifest.tsv` maps language keys to imported files/sources.
|
||||||
|
- `assets/dictionaries/SHA256SUMS` stores dictionary checksums for integrity verification.
|
||||||
|
- `scripts/validate_dictionary_manifest.sh` validates manifest entries, sidecars,
|
||||||
|
and checksums.
|
||||||
|
- `scripts/derive_primary_letter_sequences.py` derives per-language primary-letter
|
||||||
|
sequence seed data from dictionary frequency.
|
||||||
|
- `assets/dictionaries/primary-letter-sequences.tsv` stores the current derived output.
|
||||||
|
- `docs/unicode-normalization-policy.md` documents NFC normalization policy and
|
||||||
|
equivalence expectations.
|
||||||
|
|
||||||
## What is research-only
|
## What is research-only
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
# keydr Multilingual Dictionary + Keyboard Layout Internationalization Plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We currently use an English-only dictionary and an ASCII-centric adaptive model:
|
||||||
|
|
||||||
|
- Dictionary is hardcoded to `assets/words-en.json` in `src/generator/dictionary.rs`.
|
||||||
|
- Dictionary ingestion filters to ASCII lowercase only (`is_ascii_lowercase`).
|
||||||
|
- Transition table building (`src/generator/transition_table.rs`) skips non-ASCII words.
|
||||||
|
- Adaptive drill generation in `src/app.rs` builds lowercase filter from `is_ascii_lowercase`.
|
||||||
|
- Skill tree lowercase branch is fixed to English `a-z` frequency in `src/engine/skill_tree.rs`.
|
||||||
|
- Keyboard rendering/hit-testing logic has hardcoded row offsets and row count assumptions in `src/ui/components/keyboard_diagram.rs` and `src/ui/components/stats_dashboard.rs`.
|
||||||
|
|
||||||
|
## Explicit product decision: clean break
|
||||||
|
|
||||||
|
This app is currently work-in-progress and has no real user base. We explicitly do
|
||||||
|
not need to preserve old config/state/export compatibility for this change. If data
|
||||||
|
must be recreated from scratch, that is acceptable.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Add user-selectable dictionary language (default `en`) using keybr-provided dictionary files.
|
||||||
|
2. Add user-selectable keyboard layout profiles for multiple languages.
|
||||||
|
3. Ensure keyboard visualizations, explorer, and stats heatmaps render correctly for variable row shapes and non-English keycaps.
|
||||||
|
4. Use a clean-break implementation with no backward-compatibility requirements.
|
||||||
|
5. Maintain license compliance for newly imported dictionaries.
|
||||||
|
|
||||||
|
## Non-goals (first delivery)
|
||||||
|
|
||||||
|
1. Full IME/dead-key composition support.
|
||||||
|
2. Full rewrite of adaptive model for every script from day one.
|
||||||
|
3. Perfect locale-specific pedagogy for all languages in phase 1.
|
||||||
|
4. Backward compatibility for old config/profile/export data.
|
||||||
|
|
||||||
|
## Execution constraints (must be explicit before implementation)
|
||||||
|
|
||||||
|
1. **Unicode normalization policy:** Use NFC as canonical storage/matching form for dictionary ingestion, generated text, keystroke comparison, and persisted stats keys. Do not use NFKC in phase 1 to avoid compatibility-fold surprises.
|
||||||
|
2. **Character equivalence policy:** Equality is by normalized scalar sequence (NFC), not by glyph appearance. Composed/decomposed equivalents must compare equal after normalization.
|
||||||
|
3. **Clean-break schema cutover policy:** This rollout uses hard reset semantics for old unscoped stats/profile files. On first run of the new schema version, old files are ignored (optionally archived with `.legacy` suffix); no partial migration path.
|
||||||
|
4. **Capability gating policy:** Only language/layout pairs marked supported in the registry capability matrix are selectable in UI during phased rollout.
|
||||||
|
5. **Performance envelope policy:** Keyboard geometry recomputation must be bounded and cached by profile key + render mode + viewport size.
|
||||||
|
|
||||||
|
## Upstream data availability
|
||||||
|
|
||||||
|
`keybr-content-words` includes dictionaries for:
|
||||||
|
|
||||||
|
`ar, be, cs, da, de, el, en, es, et, fa, fi, fr, he, hr, hu, it, ja, lt, lv, nb, nl, pl, pt, ro, ru, sl, sv, th, tr, uk`
|
||||||
|
|
||||||
|
Recommended rollout strategy:
|
||||||
|
|
||||||
|
- Initial support for Latin-script languages first (`en, de, es, fr, it, pt, nl, sv, da, nb, fi, pl, cs, ro, hr, hu, lt, lv, sl, et, tr`).
|
||||||
|
- Later support for non-Latin scripts (`el, ru, uk, be, ar, fa, he, ja, th`) after script-specific input/model behavior is in place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Architectural Decisions
|
||||||
|
|
||||||
|
### 1) Language Pack registry
|
||||||
|
|
||||||
|
Add a registry module (e.g. `src/l10n/language_pack.rs`) containing:
|
||||||
|
|
||||||
|
- `language_key`
|
||||||
|
- `display_name`
|
||||||
|
- `script`
|
||||||
|
- `dictionary_asset_id`
|
||||||
|
- `supported_keyboard_layout_keys`
|
||||||
|
- `primary_letter_sequence` (for ranked progression)
|
||||||
|
- `starter_weights` and optional `vowel_set` for generator fallback behavior
|
||||||
|
- `support_level` (`full`, `experimental`, `blocked`)
|
||||||
|
- `normalization_form` (phase 1 fixed to `NFC`)
|
||||||
|
- `input_capabilities` (for example `direct_letters_only`, `needs_ime`)
|
||||||
|
|
||||||
|
This becomes the single source of truth for language behavior.
|
||||||
|
|
||||||
|
### 2) Runtime dictionary/generator rebuild is required
|
||||||
|
|
||||||
|
Changing `dictionary_language` must immediately take effect without restart.
|
||||||
|
|
||||||
|
Implement `App::rebuild_language_assets(&mut self)` that rebuilds:
|
||||||
|
|
||||||
|
- `Dictionary`
|
||||||
|
- `TransitionTable`
|
||||||
|
- any cached generator state derived from language assets
|
||||||
|
- focused-character transforms derived from language rules
|
||||||
|
- drill-generation allowlists that depend on language pack data
|
||||||
|
|
||||||
|
Call it whenever language or language-dependent layout changes in settings.
|
||||||
|
|
||||||
|
`rebuild_language_assets` must also refresh capitalization/case behavior inputs used by adaptive generation.
|
||||||
|
|
||||||
|
`rebuild_language_assets` invalidation contract (required):
|
||||||
|
|
||||||
|
- always invalidate and rebuild `Dictionary` and `TransitionTable`
|
||||||
|
- clear adaptive cross-drill dictionary history cache
|
||||||
|
- clear/refresh any cached language-specific focus mapping
|
||||||
|
- do **not** mutate in-progress drill text
|
||||||
|
- all newly generated drills after rebuild must use new language assets
|
||||||
|
|
||||||
|
### 3) Asset loading strategy: compile-time embedded assets
|
||||||
|
|
||||||
|
For Phase 1 scope, dictionaries will be embedded at compile-time (generated asset map + `include_str!`/equivalent), not runtime file discovery.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- deterministic packaging
|
||||||
|
- no runtime path resolution complexity
|
||||||
|
- simpler cross-platform behavior
|
||||||
|
|
||||||
|
Tradeoff: larger binary size, acceptable for this phase.
|
||||||
|
|
||||||
|
### 4) Transition table fallback strategy
|
||||||
|
|
||||||
|
`TransitionTable::build_english()` will be gated to `language_key == "en"` only.
|
||||||
|
|
||||||
|
For non-English languages:
|
||||||
|
|
||||||
|
- use dictionary-derived transition table only
|
||||||
|
- if sparse, degrade gracefully to simple dictionary sampling behavior rather than English fallback model
|
||||||
|
|
||||||
|
### 5) Keyboard geometry refactor strategy
|
||||||
|
|
||||||
|
`src/ui/components/keyboard_diagram.rs` is a substantial refactor (all render and hit-test paths).
|
||||||
|
|
||||||
|
Implement shared `KeyboardGeometry` computed once per render context and consumed by:
|
||||||
|
|
||||||
|
- compact/full/fallback renderers
|
||||||
|
- all key hit-testing paths
|
||||||
|
- shift hit-testing paths
|
||||||
|
|
||||||
|
No duplicate hardcoded offsets should remain.
|
||||||
|
|
||||||
|
Performance constraints for geometry:
|
||||||
|
|
||||||
|
- geometry cache key: `(layout_key, render_mode, viewport_width, viewport_height)`
|
||||||
|
- recompute only when cache key changes
|
||||||
|
- hit-testing must be O(number_of_keys) or better per event with no per-key allocation
|
||||||
|
- include a benchmark/smoke check to detect regressions in repeated render/hit-test loops
|
||||||
|
|
||||||
|
### 6) Finger assignment source of truth
|
||||||
|
|
||||||
|
Finger assignment must be profile metadata, not inferred by QWERTY column heuristics.
|
||||||
|
|
||||||
|
Each keyboard profile defines finger mapping for each physical key position.
|
||||||
|
|
||||||
|
### 7) Stats isolation strategy
|
||||||
|
|
||||||
|
Stats are language-scoped and layout-scoped.
|
||||||
|
|
||||||
|
Adopt per-scope storage files (for example):
|
||||||
|
|
||||||
|
- `key_stats_<language>_<layout>.json`
|
||||||
|
- `key_stats_ranked_<language>_<layout>.json`
|
||||||
|
- optional scoped drill history files
|
||||||
|
|
||||||
|
No mixed-language key stats in a single store.
|
||||||
|
|
||||||
|
Profile/scoring scoping policy:
|
||||||
|
|
||||||
|
- `skill_tree` progress is language-scoped (at minimum by `language_key`).
|
||||||
|
- `total_score`, `total_drills`, `streak_days`, and `best_streak` remain global.
|
||||||
|
- `ProfileData` will separate global fields from language-scoped progression state.
|
||||||
|
|
||||||
|
Scoped-file discovery mechanism:
|
||||||
|
|
||||||
|
- registry-driven + current-config driven only
|
||||||
|
- app loads current scope directly and only enumerates scopes from supported language/layout registry pairs
|
||||||
|
- no unconstrained glob-based discovery of arbitrary stale files
|
||||||
|
|
||||||
|
Import/export strategy for scoped stats:
|
||||||
|
|
||||||
|
- export bundles all supported scoped stats files present in the data dir
|
||||||
|
- each bundle entry includes explicit `language_key` and `layout_key` metadata
|
||||||
|
- import applies two-phase commit per scoped target file
|
||||||
|
- export/import also includes language-scoped `skill_tree` progress entries with `language_key` metadata
|
||||||
|
|
||||||
|
Atomicity requirements for scoped import:
|
||||||
|
|
||||||
|
- stage writes to `<target>.tmp`
|
||||||
|
- flush file contents (`sync_all`) before rename
|
||||||
|
- rename temp file onto target atomically where supported
|
||||||
|
- on any failure, remove temp file and keep existing target untouched
|
||||||
|
- no commit of partially imported scope bundles
|
||||||
|
|
||||||
|
### 8) Settings architecture
|
||||||
|
|
||||||
|
Current index-based settings handling is fragile.
|
||||||
|
|
||||||
|
Phase 1 includes refactor from positional integer indices to enum/struct-based settings entries before adding multilingual controls.
|
||||||
|
|
||||||
|
Profile key validation must be registry-backed. Do not rely on `KeyboardModel::from_name()` fallback behavior.
|
||||||
|
|
||||||
|
Validation error taxonomy (typed, stable):
|
||||||
|
|
||||||
|
- `UnknownLanguage`
|
||||||
|
- `UnknownLayout`
|
||||||
|
- `UnsupportedLanguageLayoutPair`
|
||||||
|
- `LanguageBlockedBySupportLevel`
|
||||||
|
|
||||||
|
UI must show deterministic user-facing error text for each class (used by tests).
|
||||||
|
|
||||||
|
In-progress drill behavior on language/layout change:
|
||||||
|
|
||||||
|
- language/layout changes rebuild assets immediately for future generation
|
||||||
|
- current in-progress drill text is not mutated mid-drill
|
||||||
|
- new language/layout applies on the next drill generation
|
||||||
|
|
||||||
|
### 9) Unicode handling architecture
|
||||||
|
|
||||||
|
Define one shared Unicode utility module used by dictionary ingestion, generators, and input matching:
|
||||||
|
|
||||||
|
- normalize all dictionary entries to NFC at load time
|
||||||
|
- normalize typed characters before comparison against expected text
|
||||||
|
- normalize persisted per-key identifiers before write/read
|
||||||
|
- provide helper tests for composed/decomposed equivalence (for example `é` vs `e + ◌́`)
|
||||||
|
|
||||||
|
### 10) Rollout capability matrix architecture
|
||||||
|
|
||||||
|
Add a single registry-backed capability matrix keyed by `(language_key, layout_key)`:
|
||||||
|
|
||||||
|
- `enabled`: selectable and fully supported
|
||||||
|
- `preview`: selectable with warning banner
|
||||||
|
- `disabled`: visible but not selectable
|
||||||
|
|
||||||
|
Phase-gating must read this matrix in settings and selection screens; no ad-hoc checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phased Implementation
|
||||||
|
|
||||||
|
## Phase 0: Data + compliance groundwork
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
1. Import selected dictionaries to `assets/dictionaries/words-<lang>.json`.
|
||||||
|
2. Add sidecar license/provenance files for each imported dictionary.
|
||||||
|
3. Update `THIRD_PARTY_NOTICES.md` with imported assets.
|
||||||
|
4. Add validation script for dictionary manifest/checksums.
|
||||||
|
5. Define language pack registry seed data (including temporary `primary_letter_sequence` values).
|
||||||
|
6. Add `support_level` and capability-matrix seed entries for every language/layout pair.
|
||||||
|
7. Add a build-time utility that derives letter frequency sequence from each dictionary (seed data source of truth; manual overrides allowed but documented).
|
||||||
|
8. Write `docs/unicode-normalization-policy.md` (NFC/equivalence rules + examples).
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
1. All imported dictionaries listed in third-party notices.
|
||||||
|
2. Sidecar license/provenance file exists for each imported dictionary.
|
||||||
|
3. Manifest validation script passes.
|
||||||
|
4. Build-time frequency derivation utility emits reproducible output for seeded languages.
|
||||||
|
5. Unicode policy doc exists and includes composed/decomposed test cases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Settings and configuration foundation
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
1. Add `dictionary_language` to `Config`.
|
||||||
|
2. Refactor settings implementation from raw indices to typed settings entries (enum/descriptor model).
|
||||||
|
3. Add settings controls for:
|
||||||
|
- dictionary language
|
||||||
|
- canonical keyboard layout profile key
|
||||||
|
4. Implement explicit invalid combination handling (reject with message), not silent fallback.
|
||||||
|
5. Wire language/layout change actions to `App::rebuild_language_assets(&mut self)`.
|
||||||
|
6. Introduce clean-break schema/version update for config/profile/store formats with hard-reset behavior for old files.
|
||||||
|
7. Replace `from_name` wildcard fallback paths with explicit lookup failure handling tied to registry validation.
|
||||||
|
8. Update import/export schema and transaction flow for scoped stats bundles.
|
||||||
|
9. Split profile persistence into global fields + language-scoped skill tree progress map.
|
||||||
|
10. Enforce capability-matrix gating in settings/selectors (`enabled/preview/disabled` states).
|
||||||
|
11. Add typed validation errors and stable user-facing status messages.
|
||||||
|
|
||||||
|
### Code areas
|
||||||
|
|
||||||
|
- `src/config.rs`
|
||||||
|
- `src/main.rs` (settings UI rendering and input handling)
|
||||||
|
- `src/app.rs` (settings action handlers, rebuild trigger)
|
||||||
|
- `src/store/schema.rs`
|
||||||
|
- `src/store/json_store.rs`
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
1. Unit tests for config defaults/validation.
|
||||||
|
2. Unit tests for settings navigation/editing after index refactor.
|
||||||
|
3. Runtime test: changing dictionary language updates generated drills without restart.
|
||||||
|
4. Runtime test: invalid language/layout pair is rejected with visible error/status.
|
||||||
|
5. Export/import test: scoped stats for multiple language/layout pairs round-trip correctly.
|
||||||
|
6. Runtime test: changing language mid-drill preserves current drill text and applies new language on next drill.
|
||||||
|
7. Schema cutover test: old-format files are ignored/archived and never partially loaded.
|
||||||
|
8. UI test: disabled/preview capability-matrix entries render and behave correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Dictionary, transition table, and generator internationalization
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
1. Refactor `Dictionary::load(language_key)` with embedded asset map.
|
||||||
|
2. Remove ASCII-only filtering from dictionary ingestion and transition building.
|
||||||
|
3. Extend `phonetic.rs` to remove English hardcoding:
|
||||||
|
- replace hardcoded starter biases with language-pack starter data or derived frequencies
|
||||||
|
- replace fallback `"the"` with language-aware fallback (for example: top dictionary word)
|
||||||
|
- make vowel recovery optional/parameterized by language pack
|
||||||
|
- remove `is_ascii_lowercase` focus filtering and rely on allowed-character logic
|
||||||
|
4. Implement transition fallback policy:
|
||||||
|
- `build_english()` only for English
|
||||||
|
- non-English graceful degradation path without English fallback table
|
||||||
|
5. Address adaptive and non-adaptive mode filters:
|
||||||
|
- remove hardcoded `('a'..='z')` filters in code/passage modes
|
||||||
|
- use language-pack allowed sets where applicable
|
||||||
|
6. Refactor capitalization pipeline to Unicode-aware behavior:
|
||||||
|
- replace ASCII-only case checks/conversions in `capitalize.rs`
|
||||||
|
- use Unicode case mapping and language-pack constraints
|
||||||
|
- ensure non-ASCII letters (for example `ä/Ä`, `é/É`) are handled correctly
|
||||||
|
7. Implement shared normalization utility and apply it consistently in:
|
||||||
|
- dictionary load path
|
||||||
|
- generated text comparison/matching paths
|
||||||
|
- persisted key identity paths
|
||||||
|
8. Multilingual audit checklist (required pass/fail):
|
||||||
|
- `rg -n "is_ascii" src/app.rs src/generator/*.rs` has no unreviewed hits affecting multilingual behavior
|
||||||
|
- every remaining `is_ascii*` hit has a documented justification comment or issue reference
|
||||||
|
|
||||||
|
### Code areas
|
||||||
|
|
||||||
|
- `src/generator/dictionary.rs`
|
||||||
|
- `src/generator/transition_table.rs`
|
||||||
|
- `src/generator/phonetic.rs`
|
||||||
|
- `src/generator/capitalize.rs`
|
||||||
|
- `src/app.rs` (adaptive/code/passage filter construction)
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
1. Unit tests for dictionary loading per supported language.
|
||||||
|
2. Unit tests for transition table generation with non-English characters.
|
||||||
|
3. Unit tests for phonetic fallback behavior per language pack.
|
||||||
|
4. Unit tests for capitalization correctness on non-ASCII letters.
|
||||||
|
5. Regression tests for English output quality.
|
||||||
|
6. Unit tests for NFC normalization and composed/decomposed equivalence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Keyboard layout profile system
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
1. Replace ad-hoc constructors with canonical keyboard profile registry.
|
||||||
|
2. Add language-relevant profiles (`de_qwertz`, `fr_azerty`, etc.).
|
||||||
|
3. Add profile metadata:
|
||||||
|
- key rows and shifted/base pairs
|
||||||
|
- geometry hints
|
||||||
|
- modifier placement metadata
|
||||||
|
- per-key finger assignments
|
||||||
|
4. Remove legacy alias layer and enforce canonical profile keys.
|
||||||
|
5. Evaluate `src/keyboard/layout.rs` usage:
|
||||||
|
- if unused, delete it
|
||||||
|
- otherwise fold it into the new profile registry without duplicate sources of truth
|
||||||
|
|
||||||
|
### Code areas
|
||||||
|
|
||||||
|
- `src/keyboard/model.rs`
|
||||||
|
- `src/keyboard/layout.rs`
|
||||||
|
- `src/keyboard/display.rs` (if locale labels/short labels need extension)
|
||||||
|
- `src/config.rs`
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
1. Unit tests for all canonical profile keys.
|
||||||
|
2. Unit tests for profile completeness and unique key mapping.
|
||||||
|
3. Unit tests for finger assignment coverage/consistency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Keyboard visualization and hit-testing refactor
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
1. Implement shared `KeyboardGeometry` used by all keyboard rendering modes.
|
||||||
|
2. Rewrite keyboard diagram rendering paths to use shared geometry.
|
||||||
|
3. Rewrite all keyboard hit-testing paths to use shared geometry.
|
||||||
|
4. Refactor stats dashboard keyboard heatmap/timing rendering to use profile geometry metadata.
|
||||||
|
5. Ensure explorer and selection logic works for variable row counts and locale keycaps.
|
||||||
|
6. Update sentinel boundary tests if new files must reference sentinel constants.
|
||||||
|
7. Remove ASCII shift-display guards in keyboard rendering:
|
||||||
|
- replace `is_ascii_alphabetic()`-based shifted display checks
|
||||||
|
- use profile-defined shiftability (`base != shifted` or explicit shiftable set)
|
||||||
|
8. Audit and replace ASCII-specific input-handling logic in `main.rs`:
|
||||||
|
- caps-lock inference
|
||||||
|
- depressed-key normalization
|
||||||
|
- shift guidance and shifted-key detection in keyboard UI paths
|
||||||
|
9. Add geometry cache and recompute guards keyed by `(layout_key, render_mode, viewport)` with benchmark coverage.
|
||||||
|
|
||||||
|
### Code areas
|
||||||
|
|
||||||
|
- `src/ui/components/keyboard_diagram.rs`
|
||||||
|
- `src/ui/components/stats_dashboard.rs`
|
||||||
|
- `src/main.rs` keyboard explorer handlers
|
||||||
|
- `src/main.rs` input handling (`handle_key`, caps/shift logic, keyboard guidance/render helpers)
|
||||||
|
- `src/app.rs` explorer state/focus use
|
||||||
|
- `src/keyboard/display.rs` tests
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
1. Snapshot/golden tests for compact/full/fallback rendering per profile.
|
||||||
|
2. Hit-test roundtrip tests per profile.
|
||||||
|
3. Manual keyboard explorer smoke tests for US + non-US profiles.
|
||||||
|
4. Sentinel boundary tests pass with updated policy.
|
||||||
|
5. Manual test: shifted rendering works for non-ASCII letter keys where profile defines shifted forms.
|
||||||
|
6. Manual test: caps/shift guidance and depressed-key behavior are correct for non-ASCII key input.
|
||||||
|
7. Benchmark/smoke test: repeated render + hit-test loops meet baseline without per-frame geometry rebuild when cache key is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Skill tree and ranked progression internationalization
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
1. Replace fixed English lowercase progression with language-pack `primary_letter_sequence`.
|
||||||
|
2. Replace hardcoded "lowercase as background" branch logic with language-pack primary-letter background behavior.
|
||||||
|
3. Remove UI copy assumptions of "26 lowercase" and `a-z`.
|
||||||
|
4. Ensure ranked gating uses language-pack readiness (sequence + profile support).
|
||||||
|
5. Define letter-frequency derivation approach:
|
||||||
|
- derive initial sequence from dictionary frequency data (build-time utility), not hand-curated long-term
|
||||||
|
6. Milestone-copy audit checklist (required pass/fail):
|
||||||
|
- grep for hardcoded milestone language in `main.rs` (`26`, `a-z`, `A-Z`, `lowercase`)
|
||||||
|
- replace with language-pack-aware dynamic copy
|
||||||
|
- add tests asserting copy adjusts with different sequence lengths
|
||||||
|
|
||||||
|
### Code areas
|
||||||
|
|
||||||
|
- `src/engine/skill_tree.rs`
|
||||||
|
- `src/app.rs` (focus/background/filter logic)
|
||||||
|
- `src/main.rs` (milestone/help copy)
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
1. Tests for progression with multiple language sequences.
|
||||||
|
2. Tests for background-branch selection correctness.
|
||||||
|
3. Snapshot tests for milestone text across languages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: UX polish, test parameterization, and rollout
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
1. Add dedicated language/layout selector screens where needed.
|
||||||
|
- Implemented in `src/main.rs` + `src/app.rs` with `DictionaryLanguageSelect` and `KeyboardLayoutSelect`.
|
||||||
|
2. Add explicit support-matrix messaging for partially supported scripts.
|
||||||
|
- Implemented in selector + settings UI copy in `src/main.rs` (`preview`/`disabled` state messaging).
|
||||||
|
3. Add parameterized test helpers:
|
||||||
|
- language-aware allowed key sets
|
||||||
|
- expected progression counts
|
||||||
|
- profile fixtures
|
||||||
|
- Implemented via cross-language/layout fixtures and property tests in `src/l10n/language_pack.rs`, `src/engine/skill_tree.rs`, and `src/ui/components/keyboard_diagram.rs`.
|
||||||
|
4. Document that Phase 2 may temporarily allow language/dictionary mismatch with keyboard visuals until Phase 3/4 is complete.
|
||||||
|
5. Add explicit note in docs that Phase 2 mismatch window is expected and resolved by Phase 4.
|
||||||
|
- Implemented in `docs/multilingual-rollout-notes.md`.
|
||||||
|
6. Add cross-language property tests:
|
||||||
|
- key uniqueness per profile
|
||||||
|
- hit-test round-trip invariants
|
||||||
|
- progression monotonicity per language sequence
|
||||||
|
- Implemented in `src/keyboard/model.rs`, `src/ui/components/keyboard_diagram.rs`, and `src/engine/skill_tree.rs`.
|
||||||
|
|
||||||
|
### Code areas
|
||||||
|
|
||||||
|
- `src/main.rs`
|
||||||
|
- `src/app.rs`
|
||||||
|
- test modules across `src/*`
|
||||||
|
- `docs/`
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
1. End-to-end manual flows for language switch + layout switch + drill generation + keyboard explorer + stats.
|
||||||
|
2. Performance checks for embedded dictionary footprint and startup latency.
|
||||||
|
3. Test suite passes with parameterized language/profile cases.
|
||||||
|
4. Property/invariant tests pass for key uniqueness, hit-test round-trip, and progression monotonicity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File-by-file Impact Matrix
|
||||||
|
|
||||||
|
### Core config and app wiring
|
||||||
|
|
||||||
|
- `src/config.rs`
|
||||||
|
- add `dictionary_language` and canonical `keyboard_layout` profile key validation
|
||||||
|
- `src/app.rs`
|
||||||
|
- add `rebuild_language_assets`
|
||||||
|
- remove ASCII-only filters and audit residual ASCII assumptions (`rg is_ascii` pass)
|
||||||
|
- wire settings actions to runtime rebuild
|
||||||
|
- `src/main.rs`
|
||||||
|
- refactor settings UI to typed entries
|
||||||
|
- add/update selectors and error/status handling
|
||||||
|
- audit/replace ASCII-specific input/caps/shift handling
|
||||||
|
|
||||||
|
### Generators and adaptive engine
|
||||||
|
|
||||||
|
- `src/generator/dictionary.rs`
|
||||||
|
- dynamic, language-aware load via embedded registry
|
||||||
|
- `src/generator/transition_table.rs`
|
||||||
|
- non-ASCII support and explicit English-only fallback gating
|
||||||
|
- `src/generator/phonetic.rs`
|
||||||
|
- remove hardcoded English starter/vowel/fallback assumptions
|
||||||
|
- `src/generator/capitalize.rs`
|
||||||
|
- replace ASCII-only casing logic with Unicode-aware capitalization rules
|
||||||
|
|
||||||
|
### Skill progression
|
||||||
|
|
||||||
|
- `src/engine/skill_tree.rs`
|
||||||
|
- language-pack primary sequence
|
||||||
|
- language-pack background branch behavior
|
||||||
|
|
||||||
|
### Keyboard modeling and visualization
|
||||||
|
|
||||||
|
- `src/keyboard/model.rs`
|
||||||
|
- canonical profile registry with per-key finger mapping
|
||||||
|
- `src/keyboard/layout.rs`
|
||||||
|
- delete or fold into model registry
|
||||||
|
- `src/ui/components/keyboard_diagram.rs`
|
||||||
|
- shared geometry + full hit-test rewrite
|
||||||
|
- `src/ui/components/stats_dashboard.rs`
|
||||||
|
- geometry-driven keyboard heatmap/timing rendering
|
||||||
|
- `src/keyboard/display.rs`
|
||||||
|
- sentinel boundary test updates as needed
|
||||||
|
|
||||||
|
### Persistence/schema
|
||||||
|
|
||||||
|
- `src/store/schema.rs`
|
||||||
|
- clean-break schema/version bump as needed
|
||||||
|
- split profile data into global fields + language-scoped skill tree progress
|
||||||
|
- `src/store/json_store.rs`
|
||||||
|
- scoped stats storage by language/layout
|
||||||
|
- scoped file discovery based on supported registry pairs
|
||||||
|
- export/import scoped bundle handling with language/layout metadata
|
||||||
|
- export/import language-scoped skill tree progress entries
|
||||||
|
|
||||||
|
### Assets/compliance/docs
|
||||||
|
|
||||||
|
- `assets/dictionaries/*`
|
||||||
|
- `assets/dictionaries/*.license`
|
||||||
|
- `THIRD_PARTY_NOTICES.md`
|
||||||
|
- `docs/license-compliance.md`
|
||||||
|
- `docs/unicode-normalization-policy.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
1. **Risk:** Non-Latin scripts break assumptions in multiple modules.
|
||||||
|
- **Mitigation:** staged rollout by script; support matrix gating.
|
||||||
|
2. **Risk:** Keyboard visualization regressions during geometry rewrite.
|
||||||
|
- **Mitigation:** shared geometry abstraction + dedicated hit-test/render tests.
|
||||||
|
3. **Risk:** Clean-break schema reset discards local data.
|
||||||
|
- **Mitigation:** explicitly documented and accepted by product decision.
|
||||||
|
4. **Risk:** Settings refactor increases short-term scope.
|
||||||
|
- **Mitigation:** do it early to avoid repeated index-cascade bugs.
|
||||||
|
5. **Risk:** Embedded dictionary set increases binary size/startup memory.
|
||||||
|
- **Mitigation:** track size/startup metrics per release and switch to hybrid packaging if thresholds are exceeded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
1. Language switch updates dictionary-driven generation without restart.
|
||||||
|
2. Keyboard profiles are canonical and language-aware; no legacy alias dependency.
|
||||||
|
3. Keyboard diagram, explorer, and stats views are geometry-driven and correct for supported profiles.
|
||||||
|
4. Ranked progression uses language-pack primary sequences and background logic.
|
||||||
|
5. Code/passage/adaptive modes no longer depend on hardcoded `a-z` filters.
|
||||||
|
6. Stats are isolated by language/layout scope.
|
||||||
|
7. Skill tree progression is language-scoped while streak/score totals remain global.
|
||||||
|
8. Third-party attributions and license sidecars cover all imported dictionary assets.
|
||||||
|
9. Automated tests cover runtime rebuild, generator behavior, keyboard geometry/hit-testing, progression invariants, and parameterized language/profile cases.
|
||||||
|
10. Unicode normalization policy is implemented and tested across ingestion, generation, input matching, and persisted stats keys.
|
||||||
|
11. Clean-break schema cutover behavior is deterministic (hard-reset semantics) and covered by automated tests.
|
||||||
|
12. Capability matrix gating is enforced consistently across settings/selectors and covered by UI/runtime tests.
|
||||||
763
src/app.rs
763
src/app.rs
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ use keydr::store::schema::{
|
|||||||
DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData,
|
DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCHEMA_VERSION: u32 = 2;
|
const SCHEMA_VERSION: u32 = 3;
|
||||||
const TARGET_CPM: f64 = 175.0;
|
const TARGET_CPM: f64 = 175.0;
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
@@ -271,6 +271,28 @@ fn last_practice_date_from_drills(drills: &[DrillResult]) -> Option<String> {
|
|||||||
.map(|d| d.timestamp.format("%Y-%m-%d").to_string())
|
.map(|d| d.timestamp.format("%Y-%m-%d").to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_profile_data(
|
||||||
|
skill_tree: SkillTreeProgress,
|
||||||
|
total_score: f64,
|
||||||
|
total_drills: u32,
|
||||||
|
streak_days: u32,
|
||||||
|
best_streak: u32,
|
||||||
|
last_practice_date: Option<String>,
|
||||||
|
) -> ProfileData {
|
||||||
|
let mut skill_tree_by_language = HashMap::new();
|
||||||
|
skill_tree_by_language.insert("en".to_string(), skill_tree.clone());
|
||||||
|
ProfileData {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
skill_tree,
|
||||||
|
skill_tree_by_language,
|
||||||
|
total_score,
|
||||||
|
total_drills,
|
||||||
|
streak_days,
|
||||||
|
best_streak,
|
||||||
|
last_practice_date,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Profile Builders ─────────────────────────────────────────────────────
|
// ── Profile Builders ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn build_profile_01() -> ExportData {
|
fn build_profile_01() -> ExportData {
|
||||||
@@ -278,15 +300,7 @@ fn build_profile_01() -> ExportData {
|
|||||||
make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 0)]);
|
make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 0)]);
|
||||||
|
|
||||||
make_export(
|
make_export(
|
||||||
ProfileData {
|
make_profile_data(skill_tree, 0.0, 0, 0, 0, None),
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
skill_tree,
|
|
||||||
total_score: 0.0,
|
|
||||||
total_drills: 0,
|
|
||||||
streak_days: 0,
|
|
||||||
best_streak: 0,
|
|
||||||
last_practice_date: None,
|
|
||||||
},
|
|
||||||
KeyStatsStore::default(),
|
KeyStatsStore::default(),
|
||||||
KeyStatsStore::default(),
|
KeyStatsStore::default(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -340,15 +354,14 @@ fn build_profile_02() -> ExportData {
|
|||||||
|
|
||||||
// total_score: level_from_score(x) = (x/100).sqrt() => for level 2: score ~400
|
// total_score: level_from_score(x) = (x/100).sqrt() => for level 2: score ~400
|
||||||
make_export(
|
make_export(
|
||||||
ProfileData {
|
make_profile_data(
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
skill_tree,
|
skill_tree,
|
||||||
total_score: 350.0,
|
350.0,
|
||||||
total_drills: 15,
|
15,
|
||||||
streak_days: 3,
|
3,
|
||||||
best_streak: 3,
|
3,
|
||||||
last_practice_date: last_practice_date_from_drills(&drills),
|
last_practice_date_from_drills(&drills),
|
||||||
},
|
),
|
||||||
stats,
|
stats,
|
||||||
ranked_stats,
|
ranked_stats,
|
||||||
drills,
|
drills,
|
||||||
@@ -402,15 +415,14 @@ fn build_profile_03() -> ExportData {
|
|||||||
|
|
||||||
// level ~3: score ~900
|
// level ~3: score ~900
|
||||||
make_export(
|
make_export(
|
||||||
ProfileData {
|
make_profile_data(
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
skill_tree,
|
skill_tree,
|
||||||
total_score: 900.0,
|
900.0,
|
||||||
total_drills: 50,
|
50,
|
||||||
streak_days: 7,
|
7,
|
||||||
best_streak: 7,
|
7,
|
||||||
last_practice_date: last_practice_date_from_drills(&drills),
|
last_practice_date_from_drills(&drills),
|
||||||
},
|
),
|
||||||
stats,
|
stats,
|
||||||
ranked_stats,
|
ranked_stats,
|
||||||
drills,
|
drills,
|
||||||
@@ -461,15 +473,14 @@ fn build_profile_03_near_lowercase_complete() -> ExportData {
|
|||||||
);
|
);
|
||||||
|
|
||||||
make_export(
|
make_export(
|
||||||
ProfileData {
|
make_profile_data(
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
skill_tree,
|
skill_tree,
|
||||||
total_score: 1800.0,
|
1800.0,
|
||||||
total_drills: 90,
|
90,
|
||||||
streak_days: 10,
|
10,
|
||||||
best_streak: 12,
|
12,
|
||||||
last_practice_date: last_practice_date_from_drills(&drills),
|
last_practice_date_from_drills(&drills),
|
||||||
},
|
),
|
||||||
stats,
|
stats,
|
||||||
ranked_stats,
|
ranked_stats,
|
||||||
drills,
|
drills,
|
||||||
@@ -516,15 +527,14 @@ fn build_profile_04() -> ExportData {
|
|||||||
|
|
||||||
// level ~5: score ~2500
|
// level ~5: score ~2500
|
||||||
make_export(
|
make_export(
|
||||||
ProfileData {
|
make_profile_data(
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
skill_tree,
|
skill_tree,
|
||||||
total_score: 2500.0,
|
2500.0,
|
||||||
total_drills: 100,
|
100,
|
||||||
streak_days: 14,
|
14,
|
||||||
best_streak: 14,
|
14,
|
||||||
last_practice_date: last_practice_date_from_drills(&drills),
|
last_practice_date_from_drills(&drills),
|
||||||
},
|
),
|
||||||
stats,
|
stats,
|
||||||
ranked_stats,
|
ranked_stats,
|
||||||
drills,
|
drills,
|
||||||
@@ -601,15 +611,14 @@ fn build_profile_05() -> ExportData {
|
|||||||
|
|
||||||
// level ~7: score ~5000
|
// level ~7: score ~5000
|
||||||
make_export(
|
make_export(
|
||||||
ProfileData {
|
make_profile_data(
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
skill_tree,
|
skill_tree,
|
||||||
total_score: 5000.0,
|
5000.0,
|
||||||
total_drills: 200,
|
200,
|
||||||
streak_days: 21,
|
21,
|
||||||
best_streak: 21,
|
21,
|
||||||
last_practice_date: last_practice_date_from_drills(&drills),
|
last_practice_date_from_drills(&drills),
|
||||||
},
|
),
|
||||||
stats,
|
stats,
|
||||||
ranked_stats,
|
ranked_stats,
|
||||||
drills,
|
drills,
|
||||||
@@ -695,15 +704,14 @@ fn build_profile_06() -> ExportData {
|
|||||||
|
|
||||||
// level ~12: score ~15000
|
// level ~12: score ~15000
|
||||||
make_export(
|
make_export(
|
||||||
ProfileData {
|
make_profile_data(
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
skill_tree,
|
skill_tree,
|
||||||
total_score: 15000.0,
|
15000.0,
|
||||||
total_drills: 500,
|
500,
|
||||||
streak_days: 45,
|
45,
|
||||||
best_streak: 60,
|
60,
|
||||||
last_practice_date: last_practice_date_from_drills(&drills),
|
last_practice_date_from_drills(&drills),
|
||||||
},
|
),
|
||||||
stats,
|
stats,
|
||||||
ranked_stats,
|
ranked_stats,
|
||||||
drills,
|
drills,
|
||||||
@@ -776,15 +784,14 @@ fn build_profile_07() -> ExportData {
|
|||||||
|
|
||||||
// level ~18: score ~35000
|
// level ~18: score ~35000
|
||||||
make_export(
|
make_export(
|
||||||
ProfileData {
|
make_profile_data(
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
skill_tree,
|
skill_tree,
|
||||||
total_score: 35000.0,
|
35000.0,
|
||||||
total_drills: 800,
|
800,
|
||||||
streak_days: 90,
|
90,
|
||||||
best_streak: 90,
|
90,
|
||||||
last_practice_date: last_practice_date_from_drills(&drills),
|
last_practice_date_from_drills(&drills),
|
||||||
},
|
),
|
||||||
stats,
|
stats,
|
||||||
ranked_stats,
|
ranked_stats,
|
||||||
drills,
|
drills,
|
||||||
|
|||||||
109
src/config.rs
109
src/config.rs
@@ -1,6 +1,11 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::keyboard::model::KeyboardModel;
|
||||||
|
use crate::l10n::language_pack::{
|
||||||
|
LanguageLayoutValidationError, dictionary_languages_for_layout, supported_dictionary_languages,
|
||||||
|
validate_language_layout_pair,
|
||||||
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -16,6 +21,8 @@ pub struct Config {
|
|||||||
pub word_count: usize,
|
pub word_count: usize,
|
||||||
#[serde(default = "default_code_language")]
|
#[serde(default = "default_code_language")]
|
||||||
pub code_language: String,
|
pub code_language: String,
|
||||||
|
#[serde(default = "default_dictionary_language")]
|
||||||
|
pub dictionary_language: String,
|
||||||
#[serde(default = "default_passage_book")]
|
#[serde(default = "default_passage_book")]
|
||||||
pub passage_book: String,
|
pub passage_book: String,
|
||||||
#[serde(default = "default_passage_downloads_enabled")]
|
#[serde(default = "default_passage_downloads_enabled")]
|
||||||
@@ -51,6 +58,9 @@ fn default_word_count() -> usize {
|
|||||||
fn default_code_language() -> String {
|
fn default_code_language() -> String {
|
||||||
"rust".to_string()
|
"rust".to_string()
|
||||||
}
|
}
|
||||||
|
fn default_dictionary_language() -> String {
|
||||||
|
"en".to_string()
|
||||||
|
}
|
||||||
fn default_passage_book() -> String {
|
fn default_passage_book() -> String {
|
||||||
"all".to_string()
|
"all".to_string()
|
||||||
}
|
}
|
||||||
@@ -97,6 +107,7 @@ impl Default for Config {
|
|||||||
keyboard_layout: default_keyboard_layout(),
|
keyboard_layout: default_keyboard_layout(),
|
||||||
word_count: default_word_count(),
|
word_count: default_word_count(),
|
||||||
code_language: default_code_language(),
|
code_language: default_code_language(),
|
||||||
|
dictionary_language: default_dictionary_language(),
|
||||||
passage_book: default_passage_book(),
|
passage_book: default_passage_book(),
|
||||||
passage_downloads_enabled: default_passage_downloads_enabled(),
|
passage_downloads_enabled: default_passage_downloads_enabled(),
|
||||||
passage_download_dir: default_passage_download_dir(),
|
passage_download_dir: default_passage_download_dir(),
|
||||||
@@ -149,11 +160,14 @@ impl Config {
|
|||||||
self.target_wpm = self.target_wpm.clamp(10, 200);
|
self.target_wpm = self.target_wpm.clamp(10, 200);
|
||||||
self.word_count = self.word_count.clamp(5, 100);
|
self.word_count = self.word_count.clamp(5, 100);
|
||||||
self.normalize_code_language(valid_language_keys);
|
self.normalize_code_language(valid_language_keys);
|
||||||
|
self.normalize_keyboard_layout();
|
||||||
|
self.normalize_dictionary_language();
|
||||||
|
self.normalize_language_layout_pair();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate `code_language` against known options, resetting to default if invalid.
|
/// Validate `code_language` against known options, resetting to default if invalid.
|
||||||
/// Call after deserialization to handle stale/renamed keys from old configs.
|
/// Call after deserialization to handle stale/renamed keys from old configs.
|
||||||
pub fn normalize_code_language(&mut self, valid_keys: &[&str]) {
|
fn normalize_code_language(&mut self, valid_keys: &[&str]) {
|
||||||
// Backwards compatibility: old "shell" key is now "bash".
|
// Backwards compatibility: old "shell" key is now "bash".
|
||||||
if self.code_language == "shell" {
|
if self.code_language == "shell" {
|
||||||
self.code_language = "bash".to_string();
|
self.code_language = "bash".to_string();
|
||||||
@@ -162,6 +176,48 @@ impl Config {
|
|||||||
self.code_language = default_code_language();
|
self.code_language = default_code_language();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate `dictionary_language` against supported keys.
|
||||||
|
fn normalize_dictionary_language(&mut self) {
|
||||||
|
if !supported_dictionary_languages().contains(&self.dictionary_language.as_str()) {
|
||||||
|
self.dictionary_language = default_dictionary_language();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate `keyboard_layout` against canonical profile keys.
|
||||||
|
fn normalize_keyboard_layout(&mut self) {
|
||||||
|
if !KeyboardModel::supported_layout_keys().contains(&self.keyboard_layout.as_str()) {
|
||||||
|
self.keyboard_layout = default_keyboard_layout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the language/layout combination is explicitly supported.
|
||||||
|
fn normalize_language_layout_pair(&mut self) {
|
||||||
|
match self.validate_language_layout_pair() {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(LanguageLayoutValidationError::UnknownLanguage(_))
|
||||||
|
| Err(LanguageLayoutValidationError::LanguageBlockedBySupportLevel(_)) => {
|
||||||
|
self.dictionary_language = default_dictionary_language();
|
||||||
|
}
|
||||||
|
Err(LanguageLayoutValidationError::UnknownLayout(_)) => {
|
||||||
|
self.keyboard_layout = default_keyboard_layout();
|
||||||
|
}
|
||||||
|
Err(LanguageLayoutValidationError::UnsupportedLanguageLayoutPair { .. }) => {
|
||||||
|
if let Some(first_supported) =
|
||||||
|
dictionary_languages_for_layout(&self.keyboard_layout).first()
|
||||||
|
{
|
||||||
|
self.dictionary_language = (*first_supported).to_string();
|
||||||
|
} else {
|
||||||
|
self.keyboard_layout = default_keyboard_layout();
|
||||||
|
self.dictionary_language = default_dictionary_language();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_language_layout_pair(&self) -> Result<(), LanguageLayoutValidationError> {
|
||||||
|
validate_language_layout_pair(&self.dictionary_language, &self.keyboard_layout).map(|_| ())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -175,6 +231,7 @@ mod tests {
|
|||||||
assert_eq!(config.code_downloads_enabled, false);
|
assert_eq!(config.code_downloads_enabled, false);
|
||||||
assert_eq!(config.code_snippets_per_repo, 200);
|
assert_eq!(config.code_snippets_per_repo, 200);
|
||||||
assert_eq!(config.code_onboarding_done, false);
|
assert_eq!(config.code_onboarding_done, false);
|
||||||
|
assert_eq!(config.dictionary_language, "en");
|
||||||
assert!(!config.code_download_dir.is_empty());
|
assert!(!config.code_download_dir.is_empty());
|
||||||
assert!(config.code_download_dir.contains("code"));
|
assert!(config.code_download_dir.contains("code"));
|
||||||
}
|
}
|
||||||
@@ -191,6 +248,7 @@ code_language = "go"
|
|||||||
assert_eq!(config.target_wpm, 60);
|
assert_eq!(config.target_wpm, 60);
|
||||||
assert_eq!(config.theme, "monokai");
|
assert_eq!(config.theme, "monokai");
|
||||||
assert_eq!(config.code_language, "go");
|
assert_eq!(config.code_language, "go");
|
||||||
|
assert_eq!(config.dictionary_language, "en");
|
||||||
// New fields should have defaults
|
// New fields should have defaults
|
||||||
assert_eq!(config.code_downloads_enabled, false);
|
assert_eq!(config.code_downloads_enabled, false);
|
||||||
assert_eq!(config.code_snippets_per_repo, 200);
|
assert_eq!(config.code_snippets_per_repo, 200);
|
||||||
@@ -215,6 +273,7 @@ code_language = "go"
|
|||||||
config.code_onboarding_done,
|
config.code_onboarding_done,
|
||||||
deserialized.code_onboarding_done
|
deserialized.code_onboarding_done
|
||||||
);
|
);
|
||||||
|
assert_eq!(config.dictionary_language, deserialized.dictionary_language);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -252,4 +311,52 @@ code_language = "go"
|
|||||||
config.normalize_code_language(&valid_keys);
|
config.normalize_code_language(&valid_keys);
|
||||||
assert_eq!(config.code_language, "bash");
|
assert_eq!(config.code_language, "bash");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_dictionary_language_invalid_key_resets() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.dictionary_language = "zz".to_string();
|
||||||
|
config.normalize_dictionary_language();
|
||||||
|
assert_eq!(config.dictionary_language, "en");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_keyboard_layout_invalid_key_resets() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.keyboard_layout = "foo".to_string();
|
||||||
|
config.normalize_keyboard_layout();
|
||||||
|
assert_eq!(config.keyboard_layout, "qwerty");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_language_layout_pair_resets_invalid_pair() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.dictionary_language = "de".to_string();
|
||||||
|
config.keyboard_layout = "dvorak".to_string();
|
||||||
|
config.normalize_language_layout_pair();
|
||||||
|
assert_eq!(config.dictionary_language, "en");
|
||||||
|
assert_eq!(config.keyboard_layout, "dvorak");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_language_layout_pair_returns_typed_error() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.dictionary_language = "de".to_string();
|
||||||
|
config.keyboard_layout = "dvorak".to_string();
|
||||||
|
let err = config.validate_language_layout_pair().unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
LanguageLayoutValidationError::UnsupportedLanguageLayoutPair { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_language_layout_pair_unknown_language_resets_language_only() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.dictionary_language = "zz".to_string();
|
||||||
|
config.keyboard_layout = "qwerty".to_string();
|
||||||
|
config.normalize_language_layout_pair();
|
||||||
|
assert_eq!(config.dictionary_language, "en");
|
||||||
|
assert_eq!(config.keyboard_layout, "qwerty");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::engine::key_stats::KeyStatsStore;
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
use crate::keyboard::display::{BACKSPACE, SPACE};
|
use crate::keyboard::display::{BACKSPACE, SPACE};
|
||||||
|
use crate::l10n::language_pack::{
|
||||||
|
DEFAULT_LATIN_PRIMARY_SEQUENCE, normalized_primary_letter_sequence,
|
||||||
|
};
|
||||||
|
|
||||||
/// Events returned by `SkillTree::update` describing what changed.
|
/// Events returned by `SkillTree::update` describing what changed.
|
||||||
pub struct SkillTreeUpdate {
|
pub struct SkillTreeUpdate {
|
||||||
@@ -87,6 +90,8 @@ pub struct BranchDefinition {
|
|||||||
pub levels: &'static [LevelDefinition],
|
pub levels: &'static [LevelDefinition],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lowercase metadata remains for static branch lookup/UI labels. Runtime
|
||||||
|
// progression and unlock counts are driven by `SkillTree::primary_letters`.
|
||||||
const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition {
|
const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition {
|
||||||
name: "Frequency Order",
|
name: "Frequency Order",
|
||||||
keys: &[
|
keys: &[
|
||||||
@@ -169,12 +174,12 @@ const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[
|
|||||||
pub const ALL_BRANCHES: &[BranchDefinition] = &[
|
pub const ALL_BRANCHES: &[BranchDefinition] = &[
|
||||||
BranchDefinition {
|
BranchDefinition {
|
||||||
id: BranchId::Lowercase,
|
id: BranchId::Lowercase,
|
||||||
name: "Lowercase a-z",
|
name: "Primary Letters",
|
||||||
levels: LOWERCASE_LEVELS,
|
levels: LOWERCASE_LEVELS,
|
||||||
},
|
},
|
||||||
BranchDefinition {
|
BranchDefinition {
|
||||||
id: BranchId::Capitals,
|
id: BranchId::Capitals,
|
||||||
name: "Capitals A-Z",
|
name: "Capital Letters",
|
||||||
levels: CAPITALS_LEVELS,
|
levels: CAPITALS_LEVELS,
|
||||||
},
|
},
|
||||||
BranchDefinition {
|
BranchDefinition {
|
||||||
@@ -272,13 +277,14 @@ impl Default for SkillTreeProgress {
|
|||||||
pub enum DrillScope {
|
pub enum DrillScope {
|
||||||
/// Global adaptive: all InProgress + Complete branches
|
/// Global adaptive: all InProgress + Complete branches
|
||||||
Global,
|
Global,
|
||||||
/// Branch-specific drill: specific branch + a-z background
|
/// Branch-specific drill: specific branch + primary-letter background
|
||||||
Branch(BranchId),
|
Branch(BranchId),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SkillTree {
|
pub struct SkillTree {
|
||||||
pub progress: SkillTreeProgress,
|
pub progress: SkillTreeProgress,
|
||||||
pub total_unique_keys: usize,
|
pub total_unique_keys: usize,
|
||||||
|
primary_letters: Vec<char>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of lowercase letters to start with before unlocking one-at-a-time
|
/// Number of lowercase letters to start with before unlocking one-at-a-time
|
||||||
@@ -287,26 +293,49 @@ const ALWAYS_UNLOCKED_KEYS: &[char] = &[SPACE, BACKSPACE];
|
|||||||
|
|
||||||
impl SkillTree {
|
impl SkillTree {
|
||||||
pub fn new(progress: SkillTreeProgress) -> Self {
|
pub fn new(progress: SkillTreeProgress) -> Self {
|
||||||
let total_unique_keys = Self::compute_total_unique_keys();
|
Self::new_with_primary_sequence(progress, DEFAULT_LATIN_PRIMARY_SEQUENCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_primary_sequence(progress: SkillTreeProgress, sequence: &str) -> Self {
|
||||||
|
let primary_letters = Self::normalize_primary_sequence(sequence);
|
||||||
|
let total_unique_keys = Self::compute_total_unique_keys(&primary_letters);
|
||||||
Self {
|
Self {
|
||||||
progress,
|
progress,
|
||||||
total_unique_keys,
|
total_unique_keys,
|
||||||
|
primary_letters,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_total_unique_keys() -> usize {
|
fn normalize_primary_sequence(sequence: &str) -> Vec<char> {
|
||||||
|
let normalized = normalized_primary_letter_sequence(sequence);
|
||||||
|
if normalized.is_empty() {
|
||||||
|
DEFAULT_LATIN_PRIMARY_SEQUENCE.chars().collect()
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_total_unique_keys(primary_letters: &[char]) -> usize {
|
||||||
let mut all_keys: HashSet<char> = HashSet::new();
|
let mut all_keys: HashSet<char> = HashSet::new();
|
||||||
for branch in ALL_BRANCHES {
|
for branch in ALL_BRANCHES {
|
||||||
|
if branch.id == BranchId::Lowercase {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for level in branch.levels {
|
for level in branch.levels {
|
||||||
for &key in level.keys {
|
for &key in level.keys {
|
||||||
all_keys.insert(key);
|
all_keys.insert(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
all_keys.extend(primary_letters.iter().copied());
|
||||||
all_keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied());
|
all_keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied());
|
||||||
all_keys.len()
|
all_keys.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn primary_letters(&self) -> &[char] {
|
||||||
|
&self.primary_letters
|
||||||
|
}
|
||||||
|
|
||||||
pub fn branch_status(&self, id: BranchId) -> &BranchStatus {
|
pub fn branch_status(&self, id: BranchId) -> &BranchStatus {
|
||||||
self.progress
|
self.progress
|
||||||
.branches
|
.branches
|
||||||
@@ -366,8 +395,12 @@ impl SkillTree {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
BranchStatus::Complete => {
|
BranchStatus::Complete => {
|
||||||
for level in branch_def.levels {
|
if branch_def.id == BranchId::Lowercase {
|
||||||
keys.extend_from_slice(level.keys);
|
keys.extend(self.primary_letters.iter().copied());
|
||||||
|
} else {
|
||||||
|
for level in branch_def.levels {
|
||||||
|
keys.extend_from_slice(level.keys);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -379,16 +412,13 @@ impl SkillTree {
|
|||||||
fn branch_unlocked_keys(&self, id: BranchId) -> Vec<char> {
|
fn branch_unlocked_keys(&self, id: BranchId) -> Vec<char> {
|
||||||
let mut keys = ALWAYS_UNLOCKED_KEYS.to_vec();
|
let mut keys = ALWAYS_UNLOCKED_KEYS.to_vec();
|
||||||
|
|
||||||
// Always include a-z background keys
|
// Always include primary-letter background keys
|
||||||
if id != BranchId::Lowercase {
|
if id != BranchId::Lowercase {
|
||||||
let lowercase_def = get_branch_definition(BranchId::Lowercase);
|
|
||||||
let lowercase_bp = self.branch_progress(BranchId::Lowercase);
|
let lowercase_bp = self.branch_progress(BranchId::Lowercase);
|
||||||
match lowercase_bp.status {
|
match lowercase_bp.status {
|
||||||
BranchStatus::InProgress => keys.extend(self.lowercase_unlocked_keys()),
|
BranchStatus::InProgress => keys.extend(self.lowercase_unlocked_keys()),
|
||||||
BranchStatus::Complete => {
|
BranchStatus::Complete => {
|
||||||
for level in lowercase_def.levels {
|
keys.extend(self.primary_letters.iter().copied());
|
||||||
keys.extend_from_slice(level.keys);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -422,9 +452,8 @@ impl SkillTree {
|
|||||||
|
|
||||||
/// Get the progressively-unlocked lowercase keys (mirrors old LetterUnlock logic).
|
/// Get the progressively-unlocked lowercase keys (mirrors old LetterUnlock logic).
|
||||||
fn lowercase_unlocked_keys(&self) -> Vec<char> {
|
fn lowercase_unlocked_keys(&self) -> Vec<char> {
|
||||||
let def = get_branch_definition(BranchId::Lowercase);
|
|
||||||
let bp = self.branch_progress(BranchId::Lowercase);
|
let bp = self.branch_progress(BranchId::Lowercase);
|
||||||
let all_keys = def.levels[0].keys;
|
let all_keys = self.primary_letters();
|
||||||
|
|
||||||
match bp.status {
|
match bp.status {
|
||||||
BranchStatus::Complete => all_keys.to_vec(),
|
BranchStatus::Complete => all_keys.to_vec(),
|
||||||
@@ -470,8 +499,12 @@ impl SkillTree {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
BranchStatus::Complete => {
|
BranchStatus::Complete => {
|
||||||
for level in branch_def.levels {
|
if branch_def.id == BranchId::Lowercase {
|
||||||
focus_candidates.extend_from_slice(level.keys);
|
focus_candidates.extend(self.primary_letters.iter().copied());
|
||||||
|
} else {
|
||||||
|
for level in branch_def.levels {
|
||||||
|
focus_candidates.extend_from_slice(level.keys);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -645,11 +678,11 @@ impl SkillTree {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let all_keys = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
let all_keys = self.primary_letters.clone();
|
||||||
let current_count = LOWERCASE_MIN_KEYS + bp.current_level;
|
let current_count = LOWERCASE_MIN_KEYS + bp.current_level;
|
||||||
|
|
||||||
if current_count >= all_keys.len() {
|
if current_count >= all_keys.len() {
|
||||||
// All 26 keys unlocked, check if all confident
|
// All primary letters unlocked, check if all confident
|
||||||
let all_confident = all_keys.iter().all(|&ch| stats.get_confidence(ch) >= 1.0);
|
let all_confident = all_keys.iter().all(|&ch| stats.get_confidence(ch) >= 1.0);
|
||||||
if all_confident {
|
if all_confident {
|
||||||
let bp_mut = self.branch_progress_mut(BranchId::Lowercase);
|
let bp_mut = self.branch_progress_mut(BranchId::Lowercase);
|
||||||
@@ -718,10 +751,16 @@ impl SkillTree {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
BranchStatus::Complete => {
|
BranchStatus::Complete => {
|
||||||
for level in branch_def.levels {
|
if branch_def.id == BranchId::Lowercase {
|
||||||
for &key in level.keys {
|
for &key in self.primary_letters() {
|
||||||
keys.insert(key);
|
keys.insert(key);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
for level in branch_def.levels {
|
||||||
|
for &key in level.keys {
|
||||||
|
keys.insert(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -749,7 +788,13 @@ impl SkillTree {
|
|||||||
let def = get_branch_definition(id);
|
let def = get_branch_definition(id);
|
||||||
let bp = self.branch_progress(id);
|
let bp = self.branch_progress(id);
|
||||||
match bp.status {
|
match bp.status {
|
||||||
BranchStatus::Complete => def.levels.iter().map(|l| l.keys.len()).sum(),
|
BranchStatus::Complete => {
|
||||||
|
if id == BranchId::Lowercase {
|
||||||
|
self.primary_letters().len()
|
||||||
|
} else {
|
||||||
|
def.levels.iter().map(|l| l.keys.len()).sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
BranchStatus::InProgress => {
|
BranchStatus::InProgress => {
|
||||||
if id == BranchId::Lowercase {
|
if id == BranchId::Lowercase {
|
||||||
self.lowercase_unlocked_count()
|
self.lowercase_unlocked_count()
|
||||||
@@ -772,6 +817,15 @@ impl SkillTree {
|
|||||||
def.levels.iter().map(|l| l.keys.len()).sum()
|
def.levels.iter().map(|l| l.keys.len()).sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Total keys defined in a branch for this tree configuration.
|
||||||
|
pub fn branch_total_keys_for(&self, id: BranchId) -> usize {
|
||||||
|
if id == BranchId::Lowercase {
|
||||||
|
self.primary_letters().len()
|
||||||
|
} else {
|
||||||
|
Self::branch_total_keys(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Count of unique confident keys across all branches.
|
/// Count of unique confident keys across all branches.
|
||||||
pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize {
|
pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize {
|
||||||
let mut keys: HashSet<char> = HashSet::new();
|
let mut keys: HashSet<char> = HashSet::new();
|
||||||
@@ -780,7 +834,15 @@ impl SkillTree {
|
|||||||
keys.insert(ch);
|
keys.insert(ch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for &ch in self.primary_letters() {
|
||||||
|
if stats.get_confidence(ch) >= 1.0 {
|
||||||
|
keys.insert(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
for branch_def in ALL_BRANCHES {
|
for branch_def in ALL_BRANCHES {
|
||||||
|
if branch_def.id == BranchId::Lowercase {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for level in branch_def.levels {
|
for level in branch_def.levels {
|
||||||
for &ch in level.keys {
|
for &ch in level.keys {
|
||||||
if stats.get_confidence(ch) >= 1.0 {
|
if stats.get_confidence(ch) >= 1.0 {
|
||||||
@@ -794,12 +856,19 @@ impl SkillTree {
|
|||||||
|
|
||||||
/// Count of confident keys in a branch.
|
/// Count of confident keys in a branch.
|
||||||
pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize {
|
pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize {
|
||||||
let def = get_branch_definition(id);
|
if id == BranchId::Lowercase {
|
||||||
def.levels
|
self.primary_letters()
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|l| l.keys.iter())
|
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
|
||||||
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
|
.count()
|
||||||
.count()
|
} else {
|
||||||
|
let def = get_branch_definition(id);
|
||||||
|
def.levels
|
||||||
|
.iter()
|
||||||
|
.flat_map(|l| l.keys.iter())
|
||||||
|
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,6 +881,7 @@ impl Default for SkillTree {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::l10n::language_pack::language_packs;
|
||||||
|
|
||||||
fn make_stats_confident(stats: &mut KeyStatsStore, keys: &[char]) {
|
fn make_stats_confident(stats: &mut KeyStatsStore, keys: &[char]) {
|
||||||
for &ch in keys {
|
for &ch in keys {
|
||||||
@@ -851,6 +921,21 @@ mod tests {
|
|||||||
assert!(keys.contains(&BACKSPACE));
|
assert!(keys.contains(&BACKSPACE));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_custom_primary_sequence_drives_lowercase_progression() {
|
||||||
|
let tree = SkillTree::new_with_primary_sequence(SkillTreeProgress::default(), "abcde");
|
||||||
|
let keys = tree.unlocked_keys(DrillScope::Global);
|
||||||
|
|
||||||
|
// With a shorter primary sequence, all primary letters are immediately unlocked.
|
||||||
|
assert!(keys.contains(&'a'));
|
||||||
|
assert!(keys.contains(&'e'));
|
||||||
|
assert_eq!(tree.primary_letters(), &['a', 'b', 'c', 'd', 'e']);
|
||||||
|
assert_eq!(
|
||||||
|
tree.branch_total_keys_for(BranchId::Lowercase),
|
||||||
|
tree.primary_letters().len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_lowercase_progressive_unlock() {
|
fn test_lowercase_progressive_unlock() {
|
||||||
let mut tree = SkillTree::default();
|
let mut tree = SkillTree::default();
|
||||||
@@ -871,9 +956,9 @@ mod tests {
|
|||||||
let mut tree = SkillTree::default();
|
let mut tree = SkillTree::default();
|
||||||
let mut stats = KeyStatsStore::default();
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
// Make all 26 lowercase keys confident
|
// Make all primary letters confident.
|
||||||
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
let all_primary = tree.primary_letters().to_vec();
|
||||||
make_stats_confident(&mut stats, all_lowercase);
|
make_stats_confident(&mut stats, &all_primary);
|
||||||
|
|
||||||
// Need to repeatedly update as each unlock requires all current keys confident
|
// Need to repeatedly update as each unlock requires all current keys confident
|
||||||
for _ in 0..30 {
|
for _ in 0..30 {
|
||||||
@@ -1041,8 +1126,8 @@ mod tests {
|
|||||||
bp.current_level = 1;
|
bp.current_level = 1;
|
||||||
|
|
||||||
let keys = tree.unlocked_keys(DrillScope::Branch(BranchId::Capitals));
|
let keys = tree.unlocked_keys(DrillScope::Branch(BranchId::Capitals));
|
||||||
// Should include all 26 lowercase + Capitals L1 (8) + Capitals L2 (10)
|
// Should include full primary-letter background + Capitals L1 (8) + Capitals L2 (10)
|
||||||
assert!(keys.contains(&'e')); // lowercase background
|
assert!(keys.contains(&tree.primary_letters()[0])); // primary-letter background
|
||||||
assert!(keys.contains(&'T')); // Capitals L1
|
assert!(keys.contains(&'T')); // Capitals L1
|
||||||
assert!(keys.contains(&'J')); // Capitals L2 (current level)
|
assert!(keys.contains(&'J')); // Capitals L2 (current level)
|
||||||
assert!(!keys.contains(&'O')); // Capitals L3 (locked)
|
assert!(!keys.contains(&'O')); // Capitals L3 (locked)
|
||||||
@@ -1096,6 +1181,73 @@ mod tests {
|
|||||||
assert!(branches.len() - 1 < branches.len());
|
assert!(branches.len() - 1 < branches.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progression_is_monotonic_for_all_language_pack_sequences() {
|
||||||
|
for pack in language_packs() {
|
||||||
|
let mut tree = SkillTree::new_with_primary_sequence(
|
||||||
|
SkillTreeProgress::default(),
|
||||||
|
pack.primary_letter_sequence,
|
||||||
|
);
|
||||||
|
let primary = tree.primary_letters().to_vec();
|
||||||
|
assert!(
|
||||||
|
!primary.is_empty(),
|
||||||
|
"primary sequence should be non-empty for {}",
|
||||||
|
pack.language_key
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
let mut previous_count = tree.lowercase_unlocked_count();
|
||||||
|
assert!(
|
||||||
|
previous_count <= primary.len(),
|
||||||
|
"initial unlock count must be bounded for {}",
|
||||||
|
pack.language_key
|
||||||
|
);
|
||||||
|
|
||||||
|
// Master keys in configured sequence order and verify unlocked count never decreases.
|
||||||
|
for &ch in &primary {
|
||||||
|
make_stats_confident(&mut stats, &[ch]);
|
||||||
|
for _ in 0..3 {
|
||||||
|
tree.update(&stats, None);
|
||||||
|
let current_count = tree.lowercase_unlocked_count();
|
||||||
|
assert!(
|
||||||
|
current_count >= previous_count,
|
||||||
|
"unlock count regressed for {}: {} -> {}",
|
||||||
|
pack.language_key,
|
||||||
|
previous_count,
|
||||||
|
current_count
|
||||||
|
);
|
||||||
|
previous_count = current_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..30 {
|
||||||
|
tree.update(&stats, None);
|
||||||
|
let current_count = tree.lowercase_unlocked_count();
|
||||||
|
assert!(
|
||||||
|
current_count >= previous_count,
|
||||||
|
"unlock count regressed in completion pass for {}: {} -> {}",
|
||||||
|
pack.language_key,
|
||||||
|
previous_count,
|
||||||
|
current_count
|
||||||
|
);
|
||||||
|
previous_count = current_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tree.lowercase_unlocked_count(),
|
||||||
|
primary.len(),
|
||||||
|
"all primary letters should unlock for {}",
|
||||||
|
pack.language_key
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::Lowercase),
|
||||||
|
BranchStatus::Complete,
|
||||||
|
"lowercase branch should complete for {}",
|
||||||
|
pack.language_key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_update_returns_newly_unlocked() {
|
fn test_update_returns_newly_unlocked() {
|
||||||
let mut tree = SkillTree::default();
|
let mut tree = SkillTree::default();
|
||||||
@@ -1166,8 +1318,8 @@ mod tests {
|
|||||||
let mut tree = SkillTree::default();
|
let mut tree = SkillTree::default();
|
||||||
let mut stats = KeyStatsStore::default();
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
let all_primary = tree.primary_letters().to_vec();
|
||||||
make_stats_confident(&mut stats, all_lowercase);
|
make_stats_confident(&mut stats, &all_primary);
|
||||||
|
|
||||||
// Run updates to advance through progressive unlock
|
// Run updates to advance through progressive unlock
|
||||||
let mut found_available = false;
|
let mut found_available = false;
|
||||||
@@ -1262,8 +1414,8 @@ mod tests {
|
|||||||
|
|
||||||
// Set all branches to InProgress at last level with all keys confident
|
// Set all branches to InProgress at last level with all keys confident
|
||||||
// First complete lowercase
|
// First complete lowercase
|
||||||
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
let all_primary = tree.primary_letters().to_vec();
|
||||||
make_stats_confident(&mut stats, all_lowercase);
|
make_stats_confident(&mut stats, &all_primary);
|
||||||
for _ in 0..30 {
|
for _ in 0..30 {
|
||||||
tree.update(&stats, None);
|
tree.update(&stats, None);
|
||||||
}
|
}
|
||||||
@@ -1359,8 +1511,8 @@ mod tests {
|
|||||||
let mut tree = SkillTree::default();
|
let mut tree = SkillTree::default();
|
||||||
let mut stats = KeyStatsStore::default();
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
let all_primary = tree.primary_letters().to_vec();
|
||||||
make_stats_confident(&mut stats, all_lowercase);
|
make_stats_confident(&mut stats, &all_primary);
|
||||||
|
|
||||||
for _ in 0..30 {
|
for _ in 0..30 {
|
||||||
let result = tree.update(&stats, None);
|
let result = tree.update(&stats, None);
|
||||||
@@ -1382,8 +1534,8 @@ mod tests {
|
|||||||
let mut tree = SkillTree::default();
|
let mut tree = SkillTree::default();
|
||||||
let mut stats = KeyStatsStore::default();
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
let all_primary = tree.primary_letters().to_vec();
|
||||||
make_stats_confident(&mut stats, all_lowercase);
|
make_stats_confident(&mut stats, &all_primary);
|
||||||
|
|
||||||
for _ in 0..30 {
|
for _ in 0..30 {
|
||||||
let result = tree.update(&stats, None);
|
let result = tree.update(&stats, None);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use rand::rngs::SmallRng;
|
use rand::rngs::SmallRng;
|
||||||
|
|
||||||
|
fn lowercase_eq(a: char, b: char) -> bool {
|
||||||
|
a.to_lowercase().eq(b.to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
/// Post-processing pass that capitalizes words in generated text.
|
/// Post-processing pass that capitalizes words in generated text.
|
||||||
/// Only capitalizes using letters from `unlocked_capitals`.
|
/// Only capitalizes using letters from `unlocked_capitals`.
|
||||||
pub fn apply_capitalization(
|
pub fn apply_capitalization(
|
||||||
@@ -13,7 +17,7 @@ pub fn apply_capitalization(
|
|||||||
return text.to_string();
|
return text.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
let focused_upper = focused.filter(|ch| ch.is_ascii_uppercase());
|
let focused_upper = focused.filter(|ch| ch.is_uppercase());
|
||||||
let mut words: Vec<String> = text.split_whitespace().map(|w| w.to_string()).collect();
|
let mut words: Vec<String> = text.split_whitespace().map(|w| w.to_string()).collect();
|
||||||
if words.is_empty() {
|
if words.is_empty() {
|
||||||
return text.to_string();
|
return text.to_string();
|
||||||
@@ -72,7 +76,7 @@ pub fn apply_capitalization(
|
|||||||
if let Some(focused_upper) = focused_upper.filter(|ch| unlocked_capitals.contains(ch)) {
|
if let Some(focused_upper) = focused_upper.filter(|ch| unlocked_capitals.contains(ch)) {
|
||||||
let alpha_words = words
|
let alpha_words = words
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|w| w.chars().any(|ch| ch.is_ascii_alphabetic()))
|
.filter(|w| w.chars().any(|ch| ch.is_alphabetic()))
|
||||||
.count();
|
.count();
|
||||||
let min_focused = alpha_words.min(4);
|
let min_focused = alpha_words.min(4);
|
||||||
ensure_min_focused_occurrences(&mut words, focused_upper, min_focused);
|
ensure_min_focused_occurrences(&mut words, focused_upper, min_focused);
|
||||||
@@ -88,20 +92,20 @@ pub fn apply_capitalization(
|
|||||||
|
|
||||||
fn word_start_upper(word: &str) -> Option<char> {
|
fn word_start_upper(word: &str) -> Option<char> {
|
||||||
word.chars()
|
word.chars()
|
||||||
.find(|ch| ch.is_ascii_alphabetic())
|
.find(|ch| ch.is_alphabetic())
|
||||||
.map(|ch| ch.to_ascii_uppercase())
|
.and_then(|ch| ch.to_uppercase().next())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn capitalize_word_start(word: &mut String) -> Option<char> {
|
fn capitalize_word_start(word: &mut String) -> Option<char> {
|
||||||
let mut chars: Vec<char> = word.chars().collect();
|
let mut chars: Vec<char> = word.chars().collect();
|
||||||
for i in 0..chars.len() {
|
for i in 0..chars.len() {
|
||||||
if chars[i].is_ascii_lowercase() {
|
if chars[i].is_lowercase() {
|
||||||
chars[i] = chars[i].to_ascii_uppercase();
|
chars[i] = chars[i].to_uppercase().next().unwrap_or(chars[i]);
|
||||||
let upper = chars[i];
|
let upper = chars[i];
|
||||||
*word = chars.into_iter().collect();
|
*word = chars.into_iter().collect();
|
||||||
return Some(upper);
|
return Some(upper);
|
||||||
}
|
}
|
||||||
if chars[i].is_ascii_uppercase() {
|
if chars[i].is_uppercase() {
|
||||||
return Some(chars[i]);
|
return Some(chars[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,20 +115,20 @@ fn capitalize_word_start(word: &mut String) -> Option<char> {
|
|||||||
fn ends_sentence(word: &str) -> bool {
|
fn ends_sentence(word: &str) -> bool {
|
||||||
word.chars()
|
word.chars()
|
||||||
.rev()
|
.rev()
|
||||||
.find(|ch| !ch.is_ascii_whitespace())
|
.find(|ch| !ch.is_whitespace())
|
||||||
.is_some_and(|ch| matches!(ch, '.' | '?' | '!'))
|
.is_some_and(|ch| matches!(ch, '.' | '?' | '!'))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn word_starts_with_lower(word: &str, lower: char) -> bool {
|
fn word_starts_with_lower(word: &str, lower: char) -> bool {
|
||||||
word.chars()
|
word.chars()
|
||||||
.find(|ch| ch.is_ascii_alphabetic())
|
.find(|ch| ch.is_alphabetic())
|
||||||
.is_some_and(|ch| ch == lower)
|
.is_some_and(|ch| lowercase_eq(ch, lower))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn force_word_start_to_upper(word: &mut String, upper: char) -> bool {
|
fn force_word_start_to_upper(word: &mut String, upper: char) -> bool {
|
||||||
let mut chars: Vec<char> = word.chars().collect();
|
let mut chars: Vec<char> = word.chars().collect();
|
||||||
for i in 0..chars.len() {
|
for i in 0..chars.len() {
|
||||||
if chars[i].is_ascii_alphabetic() {
|
if chars[i].is_alphabetic() {
|
||||||
if chars[i] == upper {
|
if chars[i] == upper {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -137,7 +141,7 @@ fn force_word_start_to_upper(word: &mut String, upper: char) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_min_focused_occurrences(words: &mut Vec<String>, focused_upper: char, min_count: usize) {
|
fn ensure_min_focused_occurrences(words: &mut Vec<String>, focused_upper: char, min_count: usize) {
|
||||||
let focused_lower = focused_upper.to_ascii_lowercase();
|
let focused_lower = focused_upper.to_lowercase().next().unwrap_or(focused_upper);
|
||||||
let mut count = words
|
let mut count = words
|
||||||
.iter()
|
.iter()
|
||||||
.map(|w| w.chars().filter(|&ch| ch == focused_upper).count())
|
.map(|w| w.chars().filter(|&ch| ch == focused_upper).count())
|
||||||
@@ -173,8 +177,8 @@ fn ensure_min_focused_occurrences(words: &mut Vec<String>, focused_upper: char,
|
|||||||
}
|
}
|
||||||
let next_starts_focused = words[i + 1]
|
let next_starts_focused = words[i + 1]
|
||||||
.chars()
|
.chars()
|
||||||
.find(|ch| ch.is_ascii_alphabetic())
|
.find(|ch| ch.is_alphabetic())
|
||||||
.is_some_and(|ch| ch.eq_ignore_ascii_case(&focused_lower));
|
.is_some_and(|ch| lowercase_eq(ch, focused_lower));
|
||||||
if next_starts_focused {
|
if next_starts_focused {
|
||||||
capitalize_word_start(&mut words[i + 1]);
|
capitalize_word_start(&mut words[i + 1]);
|
||||||
let next = words.remove(i + 1);
|
let next = words.remove(i + 1);
|
||||||
@@ -204,7 +208,7 @@ fn ensure_min_total_capitals(
|
|||||||
) {
|
) {
|
||||||
let mut count = words
|
let mut count = words
|
||||||
.iter()
|
.iter()
|
||||||
.map(|w| w.chars().filter(|ch| ch.is_ascii_uppercase()).count())
|
.map(|w| w.chars().filter(|ch| ch.is_uppercase()).count())
|
||||||
.sum::<usize>();
|
.sum::<usize>();
|
||||||
if count >= min_count || unlocked_capitals.is_empty() {
|
if count >= min_count || unlocked_capitals.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -219,7 +223,7 @@ fn ensure_min_total_capitals(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if unlocked_capitals.contains(&upper)
|
if unlocked_capitals.contains(&upper)
|
||||||
&& word_starts_with_lower(word, upper.to_ascii_lowercase())
|
&& word_starts_with_lower(word, upper.to_lowercase().next().unwrap_or(upper))
|
||||||
{
|
{
|
||||||
if capitalize_word_start(word) == Some(upper) {
|
if capitalize_word_start(word) == Some(upper) {
|
||||||
count += 1;
|
count += 1;
|
||||||
|
|||||||
@@ -1,26 +1,87 @@
|
|||||||
use crate::engine::filter::CharFilter;
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::l10n::unicode::normalize_nfc;
|
||||||
|
|
||||||
const WORDS_EN: &str = include_str!("../../assets/words-en.json");
|
const WORDS_CS: &str = include_str!("../../assets/dictionaries/words-cs.json");
|
||||||
|
const WORDS_DA: &str = include_str!("../../assets/dictionaries/words-da.json");
|
||||||
|
const WORDS_DE: &str = include_str!("../../assets/dictionaries/words-de.json");
|
||||||
|
const WORDS_EN: &str = include_str!("../../assets/dictionaries/words-en.json");
|
||||||
|
const WORDS_ES: &str = include_str!("../../assets/dictionaries/words-es.json");
|
||||||
|
const WORDS_ET: &str = include_str!("../../assets/dictionaries/words-et.json");
|
||||||
|
const WORDS_FI: &str = include_str!("../../assets/dictionaries/words-fi.json");
|
||||||
|
const WORDS_FR: &str = include_str!("../../assets/dictionaries/words-fr.json");
|
||||||
|
const WORDS_HR: &str = include_str!("../../assets/dictionaries/words-hr.json");
|
||||||
|
const WORDS_HU: &str = include_str!("../../assets/dictionaries/words-hu.json");
|
||||||
|
const WORDS_IT: &str = include_str!("../../assets/dictionaries/words-it.json");
|
||||||
|
const WORDS_LT: &str = include_str!("../../assets/dictionaries/words-lt.json");
|
||||||
|
const WORDS_LV: &str = include_str!("../../assets/dictionaries/words-lv.json");
|
||||||
|
const WORDS_NB: &str = include_str!("../../assets/dictionaries/words-nb.json");
|
||||||
|
const WORDS_NL: &str = include_str!("../../assets/dictionaries/words-nl.json");
|
||||||
|
const WORDS_PL: &str = include_str!("../../assets/dictionaries/words-pl.json");
|
||||||
|
const WORDS_PT: &str = include_str!("../../assets/dictionaries/words-pt.json");
|
||||||
|
const WORDS_RO: &str = include_str!("../../assets/dictionaries/words-ro.json");
|
||||||
|
const WORDS_SL: &str = include_str!("../../assets/dictionaries/words-sl.json");
|
||||||
|
const WORDS_SV: &str = include_str!("../../assets/dictionaries/words-sv.json");
|
||||||
|
const WORDS_TR: &str = include_str!("../../assets/dictionaries/words-tr.json");
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
pub struct Dictionary {
|
pub struct Dictionary {
|
||||||
words: Vec<String>,
|
words: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Dictionary {
|
impl Dictionary {
|
||||||
pub fn load() -> Self {
|
fn raw_for_language(language_key: &str) -> Option<&'static str> {
|
||||||
let words: Vec<String> = serde_json::from_str(WORDS_EN).unwrap_or_default();
|
match language_key {
|
||||||
|
"cs" => Some(WORDS_CS),
|
||||||
// Filter to words of length >= 3 (matching keybr)
|
"da" => Some(WORDS_DA),
|
||||||
let words = words
|
"de" => Some(WORDS_DE),
|
||||||
.into_iter()
|
"en" => Some(WORDS_EN),
|
||||||
.filter(|w| w.len() >= 3 && w.chars().all(|c| c.is_ascii_lowercase()))
|
"es" => Some(WORDS_ES),
|
||||||
.collect();
|
"et" => Some(WORDS_ET),
|
||||||
|
"fi" => Some(WORDS_FI),
|
||||||
Self { words }
|
"fr" => Some(WORDS_FR),
|
||||||
|
"hr" => Some(WORDS_HR),
|
||||||
|
"hu" => Some(WORDS_HU),
|
||||||
|
"it" => Some(WORDS_IT),
|
||||||
|
"lt" => Some(WORDS_LT),
|
||||||
|
"lv" => Some(WORDS_LV),
|
||||||
|
"nb" => Some(WORDS_NB),
|
||||||
|
"nl" => Some(WORDS_NL),
|
||||||
|
"pl" => Some(WORDS_PL),
|
||||||
|
"pt" => Some(WORDS_PT),
|
||||||
|
"ro" => Some(WORDS_RO),
|
||||||
|
"sl" => Some(WORDS_SL),
|
||||||
|
"sv" => Some(WORDS_SV),
|
||||||
|
"tr" => Some(WORDS_TR),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn words_list(&self) -> Vec<String> {
|
pub fn supports_language(language_key: &str) -> bool {
|
||||||
self.words.clone()
|
Self::raw_for_language(language_key).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_load_for_language(language_key: &str) -> Option<Self> {
|
||||||
|
let raw = Self::raw_for_language(language_key)?;
|
||||||
|
let words: Vec<String> = serde_json::from_str(raw).unwrap_or_default();
|
||||||
|
|
||||||
|
// Filter to words of length >= 3 and normalize to NFC for consistent
|
||||||
|
// matching across composed/decomposed forms.
|
||||||
|
let words = words
|
||||||
|
.into_iter()
|
||||||
|
.map(|w| normalize_nfc(&w))
|
||||||
|
.filter(|w| w.chars().count() >= 3)
|
||||||
|
.filter(|w| !w.chars().any(|c| c.is_whitespace()))
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
|
||||||
|
Some(Self { words })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_for_language(language_key: &str) -> Self {
|
||||||
|
Self::try_load_for_language(language_key)
|
||||||
|
.unwrap_or_else(|| panic!("unsupported dictionary language: {language_key}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn words_list(&self) -> &[String] {
|
||||||
|
&self.words
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_matching(&self, filter: &CharFilter, focused: Option<char>) -> Vec<&str> {
|
pub fn find_matching(&self, filter: &CharFilter, focused: Option<char>) -> Vec<&str> {
|
||||||
@@ -43,10 +104,17 @@ impl Dictionary {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::l10n::language_pack::{language_packs, supported_dictionary_languages};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "unsupported dictionary language")]
|
||||||
|
fn load_for_language_unknown_panics() {
|
||||||
|
let _ = Dictionary::load_for_language("zz");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_matching_focused_is_sort_only() {
|
fn find_matching_focused_is_sort_only() {
|
||||||
let dictionary = Dictionary::load();
|
let dictionary = Dictionary::load_for_language("en");
|
||||||
let filter = CharFilter::new(('a'..='z').collect());
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
|
||||||
let without_focus = dictionary.find_matching(&filter, None);
|
let without_focus = dictionary.find_matching(&filter, None);
|
||||||
@@ -61,4 +129,34 @@ mod tests {
|
|||||||
assert_eq!(sorted_without, sorted_with);
|
assert_eq!(sorted_without, sorted_with);
|
||||||
assert_eq!(without_focus.len(), with_focus.len());
|
assert_eq!(without_focus.len(), with_focus.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_english_dictionaries_load_substantial_word_lists() {
|
||||||
|
for &lang in supported_dictionary_languages() {
|
||||||
|
if lang == "en" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dictionary = Dictionary::load_for_language(lang);
|
||||||
|
assert!(
|
||||||
|
dictionary.words_list().len() > 100,
|
||||||
|
"expected substantial dictionary for language {lang}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_registered_language_packs_have_embedded_dictionary_assets() {
|
||||||
|
for pack in language_packs() {
|
||||||
|
assert!(
|
||||||
|
Dictionary::supports_language(pack.language_key),
|
||||||
|
"language pack {} is missing an embedded dictionary asset",
|
||||||
|
pack.language_key
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
Dictionary::try_load_for_language(pack.language_key).is_some(),
|
||||||
|
"dictionary load failed for language pack {}",
|
||||||
|
pack.language_key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,15 +75,60 @@ impl PhoneticGenerator {
|
|||||||
filter: &CharFilter,
|
filter: &CharFilter,
|
||||||
focused_char: Option<char>,
|
focused_char: Option<char>,
|
||||||
focused_bigram: Option<[char; 2]>,
|
focused_bigram: Option<[char; 2]>,
|
||||||
|
starters: &[(char, f64)],
|
||||||
) -> String {
|
) -> String {
|
||||||
for _attempt in 0..5 {
|
for _attempt in 0..5 {
|
||||||
let word = self.try_generate_word(filter, focused_char, focused_bigram);
|
let word = self.try_generate_word(filter, focused_char, focused_bigram, starters);
|
||||||
if word.len() >= MIN_WORD_LEN {
|
if word.chars().count() >= MIN_WORD_LEN {
|
||||||
return word;
|
return word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback
|
self.default_fallback_word(filter)
|
||||||
"the".to_string()
|
}
|
||||||
|
|
||||||
|
fn default_fallback_word(&self, filter: &CharFilter) -> String {
|
||||||
|
let matching = self.dictionary.find_matching(filter, None);
|
||||||
|
if let Some(word) = matching.first() {
|
||||||
|
return (*word).to_string();
|
||||||
|
}
|
||||||
|
let mut chars: Vec<char> = filter
|
||||||
|
.allowed
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.collect();
|
||||||
|
chars.sort_unstable();
|
||||||
|
let fallback: String = chars.into_iter().take(3).collect();
|
||||||
|
if fallback.chars().count() >= MIN_WORD_LEN {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
let seed = fallback
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.or_else(|| filter.allowed.iter().copied().find(|c| !c.is_whitespace()))
|
||||||
|
.unwrap_or('x');
|
||||||
|
std::iter::repeat_n(seed, MIN_WORD_LEN).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn starter_weights(&self, filter: &CharFilter) -> Vec<(char, f64)> {
|
||||||
|
let mut weights = std::collections::HashMap::<char, f64>::new();
|
||||||
|
for word in self.dictionary.words_list() {
|
||||||
|
if let Some(first) = word.chars().next()
|
||||||
|
&& filter.is_allowed(first)
|
||||||
|
{
|
||||||
|
*weights.entry(first).or_insert(0.0) += 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if weights.is_empty() {
|
||||||
|
return filter
|
||||||
|
.allowed
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.map(|c| (c, 1.0))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
weights.into_iter().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_generate_word(
|
fn try_generate_word(
|
||||||
@@ -91,6 +136,7 @@ impl PhoneticGenerator {
|
|||||||
filter: &CharFilter,
|
filter: &CharFilter,
|
||||||
focused: Option<char>,
|
focused: Option<char>,
|
||||||
focused_bigram: Option<[char; 2]>,
|
focused_bigram: Option<[char; 2]>,
|
||||||
|
starters: &[(char, f64)],
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut word = Vec::new();
|
let mut word = Vec::new();
|
||||||
|
|
||||||
@@ -149,22 +195,10 @@ impl PhoneticGenerator {
|
|||||||
}
|
}
|
||||||
// Fallback: weighted random start
|
// Fallback: weighted random start
|
||||||
if word.is_empty() {
|
if word.is_empty() {
|
||||||
let starters: Vec<(char, f64)> = filter
|
|
||||||
.allowed
|
|
||||||
.iter()
|
|
||||||
.map(|&ch| {
|
|
||||||
let w = match ch {
|
|
||||||
'e' | 't' | 'a' => 3.0,
|
|
||||||
'o' | 'i' | 'n' | 's' => 2.0,
|
|
||||||
_ => 1.0,
|
|
||||||
};
|
|
||||||
(ch, w)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if let Some(ch) = Self::pick_weighted_from(&mut self.rng, &starters, filter) {
|
if let Some(ch) = Self::pick_weighted_from(&mut self.rng, &starters, filter) {
|
||||||
word.push(ch);
|
word.push(ch);
|
||||||
} else {
|
} else {
|
||||||
return "the".to_string();
|
return self.default_fallback_word(filter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,14 +258,16 @@ impl PhoneticGenerator {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to vowel
|
// Fallback to any allowed alphabetic character.
|
||||||
let vowels: Vec<(char, f64)> = ['a', 'e', 'i', 'o', 'u']
|
let next_chars: Vec<(char, f64)> = filter
|
||||||
|
.allowed
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|&&v| filter.is_allowed(v))
|
.copied()
|
||||||
.map(|&v| (v, 1.0))
|
.filter(|ch| ch.is_alphabetic())
|
||||||
|
.map(|ch| (ch, 1.0))
|
||||||
.collect();
|
.collect();
|
||||||
if let Some(v) = Self::pick_weighted_from(&mut self.rng, &vowels, filter) {
|
if let Some(next) = Self::pick_weighted_from(&mut self.rng, &next_chars, filter) {
|
||||||
word.push(v);
|
word.push(next);
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -357,6 +393,7 @@ impl TextGenerator for PhoneticGenerator {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
let starters = self.starter_weights(filter);
|
||||||
let pool_size = matching_words.len();
|
let pool_size = matching_words.len();
|
||||||
let use_dict = pool_size >= MIN_REAL_WORDS;
|
let use_dict = pool_size >= MIN_REAL_WORDS;
|
||||||
|
|
||||||
@@ -392,7 +429,7 @@ impl TextGenerator for PhoneticGenerator {
|
|||||||
|
|
||||||
// Pre-categorize words into tiers for dictionary picks
|
// Pre-categorize words into tiers for dictionary picks
|
||||||
let bigram_str = focused_bigram.map(|b| format!("{}{}", b[0], b[1]));
|
let bigram_str = focused_bigram.map(|b| format!("{}{}", b[0], b[1]));
|
||||||
let focus_char_lower = focused_char.filter(|ch| ch.is_ascii_lowercase());
|
let focus_char_lower = focused_char.filter(|ch| ch.is_lowercase());
|
||||||
|
|
||||||
let (bigram_indices, char_indices, other_indices) = if use_dict {
|
let (bigram_indices, char_indices, other_indices) = if use_dict {
|
||||||
let mut bi = Vec::new();
|
let mut bi = Vec::new();
|
||||||
@@ -436,7 +473,8 @@ impl TextGenerator for PhoneticGenerator {
|
|||||||
}
|
}
|
||||||
words.push(word);
|
words.push(word);
|
||||||
} else {
|
} else {
|
||||||
let word = self.generate_phonetic_word(filter, focused_char, focused_bigram);
|
let word =
|
||||||
|
self.generate_phonetic_word(filter, focused_char, focused_bigram, &starters);
|
||||||
recent.push(word.clone());
|
recent.push(word.clone());
|
||||||
if recent.len() > dedup_window {
|
if recent.len() > dedup_window {
|
||||||
recent.remove(0);
|
recent.remove(0);
|
||||||
@@ -456,13 +494,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn focused_key_biases_real_word_sampling() {
|
fn focused_key_biases_real_word_sampling() {
|
||||||
let dictionary = Dictionary::load();
|
let dictionary = Dictionary::load_for_language("en");
|
||||||
let table = TransitionTable::build_from_words(&dictionary.words_list());
|
let table = TransitionTable::build_from_words(dictionary.words_list());
|
||||||
let filter = CharFilter::new(('a'..='z').collect());
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
|
||||||
let mut focused_gen = PhoneticGenerator::new(
|
let mut focused_gen = PhoneticGenerator::new(
|
||||||
table.clone(),
|
table.clone(),
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(42),
|
SmallRng::seed_from_u64(42),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
);
|
);
|
||||||
@@ -474,7 +512,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut baseline_gen = PhoneticGenerator::new(
|
let mut baseline_gen = PhoneticGenerator::new(
|
||||||
table,
|
table,
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(42),
|
SmallRng::seed_from_u64(42),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
);
|
);
|
||||||
@@ -492,13 +530,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_phonetic_bigram_focus_increases_bigram_words() {
|
fn test_phonetic_bigram_focus_increases_bigram_words() {
|
||||||
let dictionary = Dictionary::load();
|
let dictionary = Dictionary::load_for_language("en");
|
||||||
let table = TransitionTable::build_from_words(&dictionary.words_list());
|
let table = TransitionTable::build_from_words(dictionary.words_list());
|
||||||
let filter = CharFilter::new(('a'..='z').collect());
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
|
||||||
let mut bigram_gen = PhoneticGenerator::new(
|
let mut bigram_gen = PhoneticGenerator::new(
|
||||||
table.clone(),
|
table.clone(),
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(42),
|
SmallRng::seed_from_u64(42),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
);
|
);
|
||||||
@@ -510,7 +548,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut baseline_gen = PhoneticGenerator::new(
|
let mut baseline_gen = PhoneticGenerator::new(
|
||||||
table,
|
table,
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(42),
|
SmallRng::seed_from_u64(42),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
);
|
);
|
||||||
@@ -528,13 +566,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_phonetic_dual_focus_no_excessive_repeats() {
|
fn test_phonetic_dual_focus_no_excessive_repeats() {
|
||||||
let dictionary = Dictionary::load();
|
let dictionary = Dictionary::load_for_language("en");
|
||||||
let table = TransitionTable::build_from_words(&dictionary.words_list());
|
let table = TransitionTable::build_from_words(dictionary.words_list());
|
||||||
let filter = CharFilter::new(('a'..='z').collect());
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
|
||||||
let mut generator = PhoneticGenerator::new(
|
let mut generator = PhoneticGenerator::new(
|
||||||
table,
|
table,
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(42),
|
SmallRng::seed_from_u64(42),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
);
|
);
|
||||||
@@ -561,8 +599,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cross_drill_history_suppresses_repeats() {
|
fn cross_drill_history_suppresses_repeats() {
|
||||||
let dictionary = Dictionary::load();
|
let dictionary = Dictionary::load_for_language("en");
|
||||||
let table = TransitionTable::build_from_words(&dictionary.words_list());
|
let table = TransitionTable::build_from_words(dictionary.words_list());
|
||||||
// Use a filter yielding a pool above FULL_DICT_THRESHOLD so dict_ratio=1.0
|
// Use a filter yielding a pool above FULL_DICT_THRESHOLD so dict_ratio=1.0
|
||||||
// (all words are dictionary picks, maximizing history suppression signal).
|
// (all words are dictionary picks, maximizing history suppression signal).
|
||||||
// Focus on 'k' to constrain the effective tier pool further.
|
// Focus on 'k' to constrain the effective tier pool further.
|
||||||
@@ -575,7 +613,7 @@ mod tests {
|
|||||||
// Drill 1: generate words and collect the set
|
// Drill 1: generate words and collect the set
|
||||||
let mut gen1 = PhoneticGenerator::new(
|
let mut gen1 = PhoneticGenerator::new(
|
||||||
table.clone(),
|
table.clone(),
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(100),
|
SmallRng::seed_from_u64(100),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
);
|
);
|
||||||
@@ -585,7 +623,7 @@ mod tests {
|
|||||||
// Drill 2 without history (baseline)
|
// Drill 2 without history (baseline)
|
||||||
let mut gen2_no_hist = PhoneticGenerator::new(
|
let mut gen2_no_hist = PhoneticGenerator::new(
|
||||||
table.clone(),
|
table.clone(),
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(200),
|
SmallRng::seed_from_u64(200),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
);
|
);
|
||||||
@@ -601,7 +639,7 @@ mod tests {
|
|||||||
// Drill 2 with history from drill 1
|
// Drill 2 with history from drill 1
|
||||||
let mut gen2_with_hist = PhoneticGenerator::new(
|
let mut gen2_with_hist = PhoneticGenerator::new(
|
||||||
table.clone(),
|
table.clone(),
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(200),
|
SmallRng::seed_from_u64(200),
|
||||||
words1.clone(),
|
words1.clone(),
|
||||||
);
|
);
|
||||||
@@ -626,8 +664,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hybrid_mode_produces_mixed_output() {
|
fn hybrid_mode_produces_mixed_output() {
|
||||||
let dictionary = Dictionary::load();
|
let dictionary = Dictionary::load_for_language("en");
|
||||||
let table = TransitionTable::build_from_words(&dictionary.words_list());
|
let table = TransitionTable::build_from_words(dictionary.words_list());
|
||||||
// Use a constrained filter to get a pool in the hybrid range (8-60).
|
// Use a constrained filter to get a pool in the hybrid range (8-60).
|
||||||
let allowed: Vec<char> = "abcdef ".chars().collect();
|
let allowed: Vec<char> = "abcdef ".chars().collect();
|
||||||
let filter = CharFilter::new(allowed);
|
let filter = CharFilter::new(allowed);
|
||||||
@@ -647,7 +685,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut generator = PhoneticGenerator::new(
|
let mut generator = PhoneticGenerator::new(
|
||||||
table,
|
table,
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(42),
|
SmallRng::seed_from_u64(42),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
);
|
);
|
||||||
@@ -676,8 +714,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn boundary_phonetic_only_below_threshold() {
|
fn boundary_phonetic_only_below_threshold() {
|
||||||
let dictionary = Dictionary::load();
|
let dictionary = Dictionary::load_for_language("en");
|
||||||
let table = TransitionTable::build_from_words(&dictionary.words_list());
|
let table = TransitionTable::build_from_words(dictionary.words_list());
|
||||||
// Very small filter — should yield < MIN_REAL_WORDS (8) dictionary matches.
|
// Very small filter — should yield < MIN_REAL_WORDS (8) dictionary matches.
|
||||||
// With pool < MIN_REAL_WORDS, use_dict=false so 0% intentional dictionary
|
// With pool < MIN_REAL_WORDS, use_dict=false so 0% intentional dictionary
|
||||||
// selections (the code never enters pick_tiered_word).
|
// selections (the code never enters pick_tiered_word).
|
||||||
@@ -697,7 +735,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut generator = PhoneticGenerator::new(
|
let mut generator = PhoneticGenerator::new(
|
||||||
table,
|
table,
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(42),
|
SmallRng::seed_from_u64(42),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
);
|
);
|
||||||
@@ -720,8 +758,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn boundary_full_dict_above_threshold() {
|
fn boundary_full_dict_above_threshold() {
|
||||||
let dictionary = Dictionary::load();
|
let dictionary = Dictionary::load_for_language("en");
|
||||||
let table = TransitionTable::build_from_words(&dictionary.words_list());
|
let table = TransitionTable::build_from_words(dictionary.words_list());
|
||||||
// Full alphabet — should yield 100+ dictionary matches
|
// Full alphabet — should yield 100+ dictionary matches
|
||||||
let filter = CharFilter::new(('a'..='z').collect());
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
|
||||||
@@ -741,7 +779,7 @@ mod tests {
|
|||||||
// All picks come from matching_words → 100% dictionary.
|
// All picks come from matching_words → 100% dictionary.
|
||||||
let mut generator = PhoneticGenerator::new(
|
let mut generator = PhoneticGenerator::new(
|
||||||
table,
|
table,
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(42),
|
SmallRng::seed_from_u64(42),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
);
|
);
|
||||||
@@ -759,8 +797,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn weighted_suppression_graceful_degradation() {
|
fn weighted_suppression_graceful_degradation() {
|
||||||
let dictionary = Dictionary::load();
|
let dictionary = Dictionary::load_for_language("en");
|
||||||
let table = TransitionTable::build_from_words(&dictionary.words_list());
|
let table = TransitionTable::build_from_words(dictionary.words_list());
|
||||||
// Use a small filter to get a small pool
|
// Use a small filter to get a small pool
|
||||||
let allowed: Vec<char> = "abcdefghijk ".chars().collect();
|
let allowed: Vec<char> = "abcdefghijk ".chars().collect();
|
||||||
let filter = CharFilter::new(allowed);
|
let filter = CharFilter::new(allowed);
|
||||||
@@ -780,7 +818,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut generator = PhoneticGenerator::new(
|
let mut generator = PhoneticGenerator::new(
|
||||||
table,
|
table,
|
||||||
Dictionary::load(),
|
Dictionary::load_for_language("en"),
|
||||||
SmallRng::seed_from_u64(42),
|
SmallRng::seed_from_u64(42),
|
||||||
history.clone(),
|
history.clone(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,10 +49,7 @@ impl TransitionTable {
|
|||||||
let prefix_len = 3; // order - 1
|
let prefix_len = 3; // order - 1
|
||||||
|
|
||||||
for (rank, word) in words.iter().enumerate() {
|
for (rank, word) in words.iter().enumerate() {
|
||||||
if word.len() < 3 {
|
if word.chars().count() < 3 {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !word.chars().all(|c| c.is_ascii_lowercase()) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,3 +235,48 @@ impl Default for TransitionTable {
|
|||||||
Self::new(4)
|
Self::new(4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::TransitionTable;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_from_words_supports_multibyte_utf8_words() {
|
||||||
|
let words = vec![
|
||||||
|
"árvore".to_string(),
|
||||||
|
"über".to_string(),
|
||||||
|
"mañana".to_string(),
|
||||||
|
"český".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = TransitionTable::build_from_words(&words);
|
||||||
|
let start_prefix = vec![' ', ' ', ' '];
|
||||||
|
let segment = table
|
||||||
|
.segment(&start_prefix)
|
||||||
|
.expect("expected start transitions");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
segment
|
||||||
|
.iter()
|
||||||
|
.any(|(ch, _)| ['á', 'ü', 'm', 'č'].contains(ch)),
|
||||||
|
"expected UTF-8 word starts in transition table"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn segment_backoff_works_with_unicode_prefixes() {
|
||||||
|
let mut table = TransitionTable::new(4);
|
||||||
|
table.add(&['ü'], 'b', 1.0);
|
||||||
|
|
||||||
|
// Prefix length is intentionally longer than order-1; `segment` should back off.
|
||||||
|
let query_prefix = vec!['x', 'x', 'ü'];
|
||||||
|
let segment = table
|
||||||
|
.segment(&query_prefix)
|
||||||
|
.expect("expected backoff match for unicode prefix");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
segment.iter().any(|(ch, _)| *ch == 'b'),
|
||||||
|
"expected continuation for 'ü' prefix"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
pub struct KeyboardLayout {
|
|
||||||
pub name: String,
|
|
||||||
pub rows: Vec<Vec<char>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeyboardLayout {
|
|
||||||
pub fn qwerty() -> Self {
|
|
||||||
Self {
|
|
||||||
name: "QWERTY".to_string(),
|
|
||||||
rows: vec![
|
|
||||||
vec!['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
|
||||||
vec!['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
|
||||||
vec!['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn dvorak() -> Self {
|
|
||||||
Self {
|
|
||||||
name: "Dvorak".to_string(),
|
|
||||||
rows: vec![
|
|
||||||
vec!['\'', ',', '.', 'p', 'y', 'f', 'g', 'c', 'r', 'l'],
|
|
||||||
vec!['a', 'o', 'e', 'u', 'i', 'd', 'h', 't', 'n', 's'],
|
|
||||||
vec![';', 'q', 'j', 'k', 'x', 'b', 'm', 'w', 'v', 'z'],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn colemak() -> Self {
|
|
||||||
Self {
|
|
||||||
name: "Colemak".to_string(),
|
|
||||||
rows: vec![
|
|
||||||
vec!['q', 'w', 'f', 'p', 'g', 'j', 'l', 'u', 'y'],
|
|
||||||
vec!['a', 'r', 's', 't', 'd', 'h', 'n', 'e', 'i', 'o'],
|
|
||||||
vec!['z', 'x', 'c', 'v', 'b', 'k', 'm'],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for KeyboardLayout {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::qwerty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
pub mod display;
|
pub mod display;
|
||||||
pub mod finger;
|
pub mod finger;
|
||||||
pub mod layout;
|
|
||||||
pub mod model;
|
pub mod model;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
608
src/l10n/language_pack.rs
Normal file
608
src/l10n/language_pack.rs
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
#![allow(dead_code)] // TODO(phase 1+): remove when all language-pack fields are consumed by runtime/UI.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use crate::keyboard::model::KeyboardModel;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Script {
|
||||||
|
Latin,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SupportLevel {
|
||||||
|
Full,
|
||||||
|
Blocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum CapabilityState {
|
||||||
|
Enabled,
|
||||||
|
// Reserved for selector UIs that show but disable unsupported entries.
|
||||||
|
// Validation APIs still return typed errors for disabled combinations.
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum LanguageLayoutValidationError {
|
||||||
|
UnknownLanguage(String),
|
||||||
|
UnknownLayout(String),
|
||||||
|
UnsupportedLanguageLayoutPair {
|
||||||
|
language_key: String,
|
||||||
|
layout_key: String,
|
||||||
|
},
|
||||||
|
LanguageBlockedBySupportLevel(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for LanguageLayoutValidationError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::UnknownLanguage(key) => write!(f, "Unknown language: {key}"),
|
||||||
|
Self::UnknownLayout(key) => write!(f, "Unknown keyboard layout: {key}"),
|
||||||
|
Self::UnsupportedLanguageLayoutPair {
|
||||||
|
language_key,
|
||||||
|
layout_key,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"Unsupported language/layout pair: {language_key} + {layout_key}"
|
||||||
|
),
|
||||||
|
Self::LanguageBlockedBySupportLevel(key) => {
|
||||||
|
write!(f, "Language is blocked by support level: {key}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RankedReadinessError {
|
||||||
|
InvalidLanguageLayout(LanguageLayoutValidationError),
|
||||||
|
MissingPrimaryLetterSequence(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RankedReadinessError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidLanguageLayout(err) => write!(f, "{err}"),
|
||||||
|
Self::MissingPrimaryLetterSequence(language_key) => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"Language '{language_key}' has no usable primary letter sequence"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct LanguagePack {
|
||||||
|
pub language_key: &'static str,
|
||||||
|
pub display_name: &'static str,
|
||||||
|
pub script: Script,
|
||||||
|
pub dictionary_asset_id: &'static str,
|
||||||
|
pub supported_keyboard_layout_keys: &'static [&'static str],
|
||||||
|
pub primary_letter_sequence: &'static str,
|
||||||
|
pub support_level: SupportLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const DEFAULT_LATIN_PRIMARY_SEQUENCE: &str = "etaoinshrdlcumwfgypbvkjxqz";
|
||||||
|
const DE_PRIMARY_SEQUENCE: &str = "entrishlagcubdmfokwzüpävößjqxy";
|
||||||
|
const ES_PRIMARY_SEQUENCE: &str = "aerosintcdlmupbgvófhíjáézqyxñú";
|
||||||
|
const FR_PRIMARY_SEQUENCE: &str = "erisantoucélpmdvgfbhqzxèyjç";
|
||||||
|
const IT_PRIMARY_SEQUENCE: &str = "aieortnsclmpdugvfbzhqkyxjw";
|
||||||
|
const PT_PRIMARY_SEQUENCE: &str = "aeorsitncdmulpvgbfhçãáqíxzjéóõêúâôà";
|
||||||
|
const NL_PRIMARY_SEQUENCE: &str = "enratiosldgkvuhpmbcjwfzyxq";
|
||||||
|
const SV_PRIMARY_SEQUENCE: &str = "aertnsldkigoämvbfuöphåyjcxw";
|
||||||
|
const DA_PRIMARY_SEQUENCE: &str = "ertnsildagokmfvubpæhøyjåcwzxq";
|
||||||
|
const NB_PRIMARY_SEQUENCE: &str = "ertnsilakogdmpvfubjøyhåæcw";
|
||||||
|
const FI_PRIMARY_SEQUENCE: &str = "aitneslkuäomvrphyjdögfbcwxzq";
|
||||||
|
const PL_PRIMARY_SEQUENCE: &str = "aiezornwsycpdkmtułjlbęgćąśhóżfńź";
|
||||||
|
const CS_PRIMARY_SEQUENCE: &str = "oelantipvdsurmkhíázcěbyřjčýšéžůúfťgňďxó";
|
||||||
|
const RO_PRIMARY_SEQUENCE: &str = "eiartnuclosăpmdgvbzfîâhjțșx";
|
||||||
|
const HR_PRIMARY_SEQUENCE: &str = "aitoernspjlkuvdmzbgcčšžćhfđ";
|
||||||
|
const HU_PRIMARY_SEQUENCE: &str = "etalnskriozáémgdvbyjhpuföóőícüúűwxq";
|
||||||
|
const LT_PRIMARY_SEQUENCE: &str = "iasteuknrolmpdvgėjyšbžąųįūčęzcfh";
|
||||||
|
const LV_PRIMARY_SEQUENCE: &str = "asiternlkopmuādīvzēgjbcšfūņļķģžhč";
|
||||||
|
const SL_PRIMARY_SEQUENCE: &str = "aeiotnrsvpkldjzmučbgcšžhf";
|
||||||
|
const ET_PRIMARY_SEQUENCE: &str = "aeistulmnkrovpdhgäjõüböfš";
|
||||||
|
const TR_PRIMARY_SEQUENCE: &str = "aeinrlımkdysutobşzügğcçöhpvfj";
|
||||||
|
|
||||||
|
const EN_LAYOUTS: &[&str] = &["qwerty", "dvorak", "colemak"];
|
||||||
|
const DE_LAYOUTS: &[&str] = &["de_qwertz", "qwerty"];
|
||||||
|
const FR_LAYOUTS: &[&str] = &["fr_azerty", "qwerty"];
|
||||||
|
const ES_LAYOUTS: &[&str] = &["es_intl", "qwerty"];
|
||||||
|
const IT_LAYOUTS: &[&str] = &["it_intl", "qwerty"];
|
||||||
|
const PT_LAYOUTS: &[&str] = &["pt_intl", "qwerty"];
|
||||||
|
const NL_LAYOUTS: &[&str] = &["nl_intl", "qwerty"];
|
||||||
|
const SV_LAYOUTS: &[&str] = &["sv_intl", "qwerty"];
|
||||||
|
const DA_LAYOUTS: &[&str] = &["da_intl", "qwerty"];
|
||||||
|
const NB_LAYOUTS: &[&str] = &["nb_intl", "qwerty"];
|
||||||
|
const FI_LAYOUTS: &[&str] = &["fi_intl", "qwerty"];
|
||||||
|
const PL_LAYOUTS: &[&str] = &["pl_intl", "qwerty"];
|
||||||
|
const CS_LAYOUTS: &[&str] = &["cs_intl", "qwerty"];
|
||||||
|
const RO_LAYOUTS: &[&str] = &["ro_intl", "qwerty"];
|
||||||
|
const HR_LAYOUTS: &[&str] = &["hr_intl", "qwerty"];
|
||||||
|
const HU_LAYOUTS: &[&str] = &["hu_intl", "qwerty"];
|
||||||
|
const LT_LAYOUTS: &[&str] = &["lt_intl", "qwerty"];
|
||||||
|
const LV_LAYOUTS: &[&str] = &["lv_intl", "qwerty"];
|
||||||
|
const SL_LAYOUTS: &[&str] = &["sl_intl", "qwerty"];
|
||||||
|
const ET_LAYOUTS: &[&str] = &["et_intl", "qwerty"];
|
||||||
|
const TR_LAYOUTS: &[&str] = &["tr_intl", "qwerty"];
|
||||||
|
|
||||||
|
// Seed registry for phase 0. Support levels will be tightened as keyboard
|
||||||
|
// profiles and Unicode handling phases are implemented.
|
||||||
|
static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "en",
|
||||||
|
display_name: "English",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-en",
|
||||||
|
supported_keyboard_layout_keys: EN_LAYOUTS,
|
||||||
|
primary_letter_sequence: DEFAULT_LATIN_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "de",
|
||||||
|
display_name: "German",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-de",
|
||||||
|
supported_keyboard_layout_keys: DE_LAYOUTS,
|
||||||
|
primary_letter_sequence: DE_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "es",
|
||||||
|
display_name: "Spanish",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-es",
|
||||||
|
supported_keyboard_layout_keys: ES_LAYOUTS,
|
||||||
|
primary_letter_sequence: ES_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "fr",
|
||||||
|
display_name: "French",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-fr",
|
||||||
|
supported_keyboard_layout_keys: FR_LAYOUTS,
|
||||||
|
primary_letter_sequence: FR_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "it",
|
||||||
|
display_name: "Italian",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-it",
|
||||||
|
supported_keyboard_layout_keys: IT_LAYOUTS,
|
||||||
|
primary_letter_sequence: IT_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "pt",
|
||||||
|
display_name: "Portuguese",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-pt",
|
||||||
|
supported_keyboard_layout_keys: PT_LAYOUTS,
|
||||||
|
primary_letter_sequence: PT_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "nl",
|
||||||
|
display_name: "Dutch",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-nl",
|
||||||
|
supported_keyboard_layout_keys: NL_LAYOUTS,
|
||||||
|
primary_letter_sequence: NL_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "sv",
|
||||||
|
display_name: "Swedish",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-sv",
|
||||||
|
supported_keyboard_layout_keys: SV_LAYOUTS,
|
||||||
|
primary_letter_sequence: SV_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "da",
|
||||||
|
display_name: "Danish",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-da",
|
||||||
|
supported_keyboard_layout_keys: DA_LAYOUTS,
|
||||||
|
primary_letter_sequence: DA_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "nb",
|
||||||
|
display_name: "Norwegian Bokmal",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-nb",
|
||||||
|
supported_keyboard_layout_keys: NB_LAYOUTS,
|
||||||
|
primary_letter_sequence: NB_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "fi",
|
||||||
|
display_name: "Finnish",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-fi",
|
||||||
|
supported_keyboard_layout_keys: FI_LAYOUTS,
|
||||||
|
primary_letter_sequence: FI_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "pl",
|
||||||
|
display_name: "Polish",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-pl",
|
||||||
|
supported_keyboard_layout_keys: PL_LAYOUTS,
|
||||||
|
primary_letter_sequence: PL_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "cs",
|
||||||
|
display_name: "Czech",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-cs",
|
||||||
|
supported_keyboard_layout_keys: CS_LAYOUTS,
|
||||||
|
primary_letter_sequence: CS_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "ro",
|
||||||
|
display_name: "Romanian",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-ro",
|
||||||
|
supported_keyboard_layout_keys: RO_LAYOUTS,
|
||||||
|
primary_letter_sequence: RO_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "hr",
|
||||||
|
display_name: "Croatian",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-hr",
|
||||||
|
supported_keyboard_layout_keys: HR_LAYOUTS,
|
||||||
|
primary_letter_sequence: HR_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "hu",
|
||||||
|
display_name: "Hungarian",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-hu",
|
||||||
|
supported_keyboard_layout_keys: HU_LAYOUTS,
|
||||||
|
primary_letter_sequence: HU_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "lt",
|
||||||
|
display_name: "Lithuanian",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-lt",
|
||||||
|
supported_keyboard_layout_keys: LT_LAYOUTS,
|
||||||
|
primary_letter_sequence: LT_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "lv",
|
||||||
|
display_name: "Latvian",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-lv",
|
||||||
|
supported_keyboard_layout_keys: LV_LAYOUTS,
|
||||||
|
primary_letter_sequence: LV_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "sl",
|
||||||
|
display_name: "Slovene",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-sl",
|
||||||
|
supported_keyboard_layout_keys: SL_LAYOUTS,
|
||||||
|
primary_letter_sequence: SL_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "et",
|
||||||
|
display_name: "Estonian",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-et",
|
||||||
|
supported_keyboard_layout_keys: ET_LAYOUTS,
|
||||||
|
primary_letter_sequence: ET_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
LanguagePack {
|
||||||
|
language_key: "tr",
|
||||||
|
display_name: "Turkish",
|
||||||
|
script: Script::Latin,
|
||||||
|
dictionary_asset_id: "words-tr",
|
||||||
|
supported_keyboard_layout_keys: TR_LAYOUTS,
|
||||||
|
primary_letter_sequence: TR_PRIMARY_SEQUENCE,
|
||||||
|
support_level: SupportLevel::Full,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn language_packs() -> &'static [LanguagePack] {
|
||||||
|
LANGUAGE_PACKS
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_language_pack(language_key: &str) -> Option<&'static LanguagePack> {
|
||||||
|
LANGUAGE_PACKS
|
||||||
|
.iter()
|
||||||
|
.find(|pack| pack.language_key == language_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn supported_dictionary_languages() -> &'static [&'static str] {
|
||||||
|
static SUPPORTED: OnceLock<Vec<&'static str>> = OnceLock::new();
|
||||||
|
SUPPORTED
|
||||||
|
.get_or_init(|| {
|
||||||
|
LANGUAGE_PACKS
|
||||||
|
.iter()
|
||||||
|
.filter(|pack| matches!(pack.support_level, SupportLevel::Full))
|
||||||
|
.map(|pack| pack.language_key)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.as_slice()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dictionary_languages_for_layout(layout_key: &str) -> Vec<&'static str> {
|
||||||
|
LANGUAGE_PACKS
|
||||||
|
.iter()
|
||||||
|
.filter_map(
|
||||||
|
|pack| match validate_language_layout_pair(pack.language_key, layout_key) {
|
||||||
|
Ok(CapabilityState::Enabled) => Some(pack.language_key),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_keyboard_layout_for_language(language_key: &str) -> Option<&'static str> {
|
||||||
|
let pack = find_language_pack(language_key)?;
|
||||||
|
pack.supported_keyboard_layout_keys.first().copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_language_layout_pair(
|
||||||
|
language_key: &str,
|
||||||
|
layout_key: &str,
|
||||||
|
) -> Result<CapabilityState, LanguageLayoutValidationError> {
|
||||||
|
let Some(pack) = find_language_pack(language_key) else {
|
||||||
|
return Err(LanguageLayoutValidationError::UnknownLanguage(
|
||||||
|
language_key.to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if !KeyboardModel::supported_layout_keys().contains(&layout_key) {
|
||||||
|
return Err(LanguageLayoutValidationError::UnknownLayout(
|
||||||
|
layout_key.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(pack.support_level, SupportLevel::Blocked) {
|
||||||
|
return Err(
|
||||||
|
LanguageLayoutValidationError::LanguageBlockedBySupportLevel(language_key.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CapabilityState::Enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalized_primary_letter_sequence(sequence: &str) -> Vec<char> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for ch in sequence.chars().filter(|ch| ch.is_alphabetic()) {
|
||||||
|
if !out.contains(&ch) {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_usable_primary_letter_sequence(sequence: &str) -> bool {
|
||||||
|
!normalized_primary_letter_sequence(sequence).is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ranked_adaptive_readiness(
|
||||||
|
language_key: &str,
|
||||||
|
layout_key: &str,
|
||||||
|
) -> Result<(), RankedReadinessError> {
|
||||||
|
validate_language_layout_pair(language_key, layout_key)
|
||||||
|
.map_err(RankedReadinessError::InvalidLanguageLayout)?;
|
||||||
|
let Some(pack) = find_language_pack(language_key) else {
|
||||||
|
return Err(RankedReadinessError::InvalidLanguageLayout(
|
||||||
|
LanguageLayoutValidationError::UnknownLanguage(language_key.to_string()),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if !has_usable_primary_letter_sequence(pack.primary_letter_sequence) {
|
||||||
|
return Err(RankedReadinessError::MissingPrimaryLetterSequence(
|
||||||
|
language_key.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn enabled_pairs() -> Vec<(&'static str, &'static str)> {
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
for pack in language_packs() {
|
||||||
|
for &layout_key in KeyboardModel::supported_layout_keys() {
|
||||||
|
if matches!(
|
||||||
|
validate_language_layout_pair(pack.language_key, layout_key),
|
||||||
|
Ok(CapabilityState::Enabled)
|
||||||
|
) {
|
||||||
|
pairs.push((pack.language_key, layout_key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn language_pack_keys_are_unique() {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for pack in language_packs() {
|
||||||
|
assert!(seen.insert(pack.language_key));
|
||||||
|
assert!(pack.primary_letter_sequence.len() >= 10);
|
||||||
|
assert!(!pack.dictionary_asset_id.is_empty());
|
||||||
|
assert!(!pack.supported_keyboard_layout_keys.is_empty());
|
||||||
|
assert!(matches!(pack.script, Script::Latin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn english_pack_exists_and_is_full() {
|
||||||
|
let en = find_language_pack("en").expect("missing en language pack");
|
||||||
|
assert_eq!(en.support_level, SupportLevel::Full);
|
||||||
|
assert_eq!(en.primary_letter_sequence, DEFAULT_LATIN_PRIMARY_SEQUENCE);
|
||||||
|
assert!(en.primary_letter_sequence.starts_with("etaoin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn german_pack_primary_sequence_contains_locale_letters() {
|
||||||
|
let de = find_language_pack("de").expect("missing de language pack");
|
||||||
|
assert!(de.primary_letter_sequence.contains('ä'));
|
||||||
|
assert!(de.primary_letter_sequence.contains('ö'));
|
||||||
|
assert!(de.primary_letter_sequence.contains('ü'));
|
||||||
|
assert!(de.primary_letter_sequence.contains('ß'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_english_packs_have_language_specific_primary_sequences() {
|
||||||
|
for pack in language_packs() {
|
||||||
|
if pack.language_key == "en" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
pack.primary_letter_sequence, DEFAULT_LATIN_PRIMARY_SEQUENCE,
|
||||||
|
"language {} should not reuse default English sequence",
|
||||||
|
pack.language_key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locale_letters_are_typeable_on_language_native_layouts() {
|
||||||
|
for pack in language_packs() {
|
||||||
|
if pack.language_key == "en" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let native_layout_key = match pack.language_key {
|
||||||
|
"de" => "de_qwertz".to_string(),
|
||||||
|
"fr" => "fr_azerty".to_string(),
|
||||||
|
key => format!("{key}_intl"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let model = KeyboardModel::from_key(&native_layout_key)
|
||||||
|
.expect("native layout key should map to a keyboard model");
|
||||||
|
for ch in normalized_primary_letter_sequence(pack.primary_letter_sequence) {
|
||||||
|
if ch.is_ascii_lowercase() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
model.physical_key_for(ch).is_some(),
|
||||||
|
"native layout {} should type locale letter '{}' for language {}",
|
||||||
|
native_layout_key,
|
||||||
|
ch,
|
||||||
|
pack.language_key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supported_dictionary_languages_are_registry_backed() {
|
||||||
|
for key in supported_dictionary_languages() {
|
||||||
|
assert!(find_language_pack(key).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supported_dictionary_languages_include_non_english_languages() {
|
||||||
|
let supported = supported_dictionary_languages();
|
||||||
|
assert!(supported.contains(&"en"));
|
||||||
|
assert!(supported.contains(&"de"));
|
||||||
|
assert!(supported.contains(&"es"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_language_layout_pair_unknown_language() {
|
||||||
|
let err = validate_language_layout_pair("zz", "qwerty").unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
LanguageLayoutValidationError::UnknownLanguage(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_language_layout_pair_unknown_layout() {
|
||||||
|
let err = validate_language_layout_pair("en", "foo").unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
LanguageLayoutValidationError::UnknownLayout(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_language_layout_pair_allows_cross_language_layout_pair() {
|
||||||
|
let state = validate_language_layout_pair("en", "de_qwertz")
|
||||||
|
.expect("cross-language/layout pair should be allowed");
|
||||||
|
assert_eq!(state, CapabilityState::Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dictionary_languages_for_layout_qwerty_contains_english() {
|
||||||
|
let keys = dictionary_languages_for_layout("qwerty");
|
||||||
|
assert!(keys.contains(&"en"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dictionary_languages_for_layout_contains_full_language_set_for_supported_layouts() {
|
||||||
|
let de = dictionary_languages_for_layout("de_qwertz");
|
||||||
|
assert_eq!(de.len(), supported_dictionary_languages().len());
|
||||||
|
assert!(de.contains(&"de"));
|
||||||
|
|
||||||
|
let fr = dictionary_languages_for_layout("fr_azerty");
|
||||||
|
assert_eq!(fr.len(), supported_dictionary_languages().len());
|
||||||
|
assert!(fr.contains(&"fr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_primary_sequence_filters_non_letters_and_dedupes() {
|
||||||
|
assert_eq!(
|
||||||
|
normalized_primary_letter_sequence("a1áa!bB"),
|
||||||
|
vec!['a', 'á', 'b', 'B']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usable_primary_sequence_requires_at_least_one_letter() {
|
||||||
|
assert!(!has_usable_primary_letter_sequence("12345!?"));
|
||||||
|
assert!(has_usable_primary_letter_sequence("é"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ranked_adaptive_readiness_rejects_invalid_layout() {
|
||||||
|
let err = ranked_adaptive_readiness("en", "not_a_layout").unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
RankedReadinessError::InvalidLanguageLayout(
|
||||||
|
LanguageLayoutValidationError::UnknownLayout(_)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ranked_adaptive_readiness_accepts_all_enabled_pairs() {
|
||||||
|
for (language_key, layout_key) in enabled_pairs() {
|
||||||
|
assert!(
|
||||||
|
ranked_adaptive_readiness(language_key, layout_key).is_ok(),
|
||||||
|
"expected readiness for pair: {language_key}+{layout_key}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/l10n/mod.rs
Normal file
2
src/l10n/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod language_pack;
|
||||||
|
pub mod unicode;
|
||||||
25
src/l10n/unicode.rs
Normal file
25
src/l10n/unicode.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use icu_normalizer::ComposingNormalizerBorrowed;
|
||||||
|
|
||||||
|
pub fn normalize_nfc(input: &str) -> String {
|
||||||
|
ComposingNormalizerBorrowed::new_nfc()
|
||||||
|
.normalize(input)
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_nfc_composes_equivalent_unicode_sequences() {
|
||||||
|
let composed = "é";
|
||||||
|
let decomposed = "e\u{0301}";
|
||||||
|
assert_eq!(normalize_nfc(composed), normalize_nfc(decomposed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_nfc_is_stable_for_precomposed_and_ascii() {
|
||||||
|
assert_eq!(normalize_nfc("Árvíztűrő"), "Árvíztűrő");
|
||||||
|
assert_eq!(normalize_nfc("abc"), "abc");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod keyboard;
|
pub mod keyboard;
|
||||||
|
pub mod l10n;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
|
|||||||
1379
src/main.rs
1379
src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,13 @@ pub struct JsonStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl JsonStore {
|
impl JsonStore {
|
||||||
|
const STORE_FILES: [&'static str; 4] = [
|
||||||
|
"profile.json",
|
||||||
|
"key_stats.json",
|
||||||
|
"key_stats_ranked.json",
|
||||||
|
"lesson_history.json",
|
||||||
|
];
|
||||||
|
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
let base_dir = dirs::data_dir()
|
let base_dir = dirs::data_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
@@ -34,6 +41,31 @@ impl JsonStore {
|
|||||||
self.base_dir.join(name)
|
self.base_dir.join(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn archive_legacy_data_files(&self) {
|
||||||
|
for name in Self::STORE_FILES {
|
||||||
|
let path = self.file_path(name);
|
||||||
|
if !path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let legacy_path = self.file_path(&format!("{name}.legacy"));
|
||||||
|
if let Err(e) = fs::remove_file(&legacy_path)
|
||||||
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"warning: failed to remove old legacy archive {}: {e}",
|
||||||
|
legacy_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Err(e) = fs::rename(&path, &legacy_path) {
|
||||||
|
eprintln!(
|
||||||
|
"warning: failed to archive legacy store file {} -> {}: {e}",
|
||||||
|
path.display(),
|
||||||
|
legacy_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn load<T: DeserializeOwned + Default>(&self, name: &str) -> T {
|
fn load<T: DeserializeOwned + Default>(&self, name: &str) -> T {
|
||||||
let path = self.file_path(name);
|
let path = self.file_path(name);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
@@ -236,15 +268,9 @@ impl JsonStore {
|
|||||||
/// Check for leftover .bak files from an interrupted import.
|
/// Check for leftover .bak files from an interrupted import.
|
||||||
/// Returns true if recovery files were found (and cleaned up).
|
/// Returns true if recovery files were found (and cleaned up).
|
||||||
pub fn check_interrupted_import(&self) -> bool {
|
pub fn check_interrupted_import(&self) -> bool {
|
||||||
let bak_names = [
|
|
||||||
"profile.json.bak",
|
|
||||||
"key_stats.json.bak",
|
|
||||||
"key_stats_ranked.json.bak",
|
|
||||||
"lesson_history.json.bak",
|
|
||||||
];
|
|
||||||
let mut found = false;
|
let mut found = false;
|
||||||
for name in &bak_names {
|
for name in Self::STORE_FILES {
|
||||||
let bak_path = self.base_dir.join(name);
|
let bak_path = self.file_path(&format!("{name}.bak"));
|
||||||
if bak_path.exists() {
|
if bak_path.exists() {
|
||||||
found = true;
|
found = true;
|
||||||
let _ = fs::remove_file(&bak_path);
|
let _ = fs::remove_file(&bak_path);
|
||||||
@@ -404,4 +430,19 @@ mod tests {
|
|||||||
// Should have been cleaned up
|
// Should have been cleaned up
|
||||||
assert!(!store.file_path("profile.json.bak").exists());
|
assert!(!store.file_path("profile.json.bak").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_archive_legacy_data_files_renames_known_store_files() {
|
||||||
|
let (_dir, store) = make_test_store();
|
||||||
|
|
||||||
|
fs::write(store.file_path("profile.json"), "{}").unwrap();
|
||||||
|
fs::write(store.file_path("key_stats.json"), "{}").unwrap();
|
||||||
|
|
||||||
|
store.archive_legacy_data_files();
|
||||||
|
|
||||||
|
assert!(!store.file_path("profile.json").exists());
|
||||||
|
assert!(store.file_path("profile.json.legacy").exists());
|
||||||
|
assert!(!store.file_path("key_stats.json").exists());
|
||||||
|
assert!(store.file_path("key_stats.json.legacy").exists());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::engine::key_stats::KeyStatsStore;
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
use crate::engine::skill_tree::SkillTreeProgress;
|
use crate::engine::skill_tree::SkillTreeProgress;
|
||||||
use crate::session::result::DrillResult;
|
use crate::session::result::DrillResult;
|
||||||
|
|
||||||
const SCHEMA_VERSION: u32 = 2;
|
pub const SCHEMA_VERSION: u32 = 3;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct ProfileData {
|
pub struct ProfileData {
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
|
/// Legacy single-scope progress mirror retained for import/export compatibility.
|
||||||
|
/// Always write this via `set_skill_tree_for_language`, never directly.
|
||||||
pub skill_tree: SkillTreeProgress,
|
pub skill_tree: SkillTreeProgress,
|
||||||
|
/// Language-scoped skill tree progression state keyed by dictionary language.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skill_tree_by_language: HashMap<String, SkillTreeProgress>,
|
||||||
pub total_score: f64,
|
pub total_score: f64,
|
||||||
#[serde(alias = "total_lessons")]
|
#[serde(alias = "total_lessons")]
|
||||||
pub total_drills: u32,
|
pub total_drills: u32,
|
||||||
@@ -25,6 +31,7 @@ impl Default for ProfileData {
|
|||||||
Self {
|
Self {
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
skill_tree: SkillTreeProgress::default(),
|
skill_tree: SkillTreeProgress::default(),
|
||||||
|
skill_tree_by_language: HashMap::new(),
|
||||||
total_score: 0.0,
|
total_score: 0.0,
|
||||||
total_drills: 0,
|
total_drills: 0,
|
||||||
streak_days: 0,
|
streak_days: 0,
|
||||||
@@ -39,6 +46,20 @@ impl ProfileData {
|
|||||||
pub fn needs_reset(&self) -> bool {
|
pub fn needs_reset(&self) -> bool {
|
||||||
self.schema_version != SCHEMA_VERSION
|
self.schema_version != SCHEMA_VERSION
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn skill_tree_for_language(&self, language_key: &str) -> SkillTreeProgress {
|
||||||
|
self.skill_tree_by_language
|
||||||
|
.get(language_key)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| self.skill_tree.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_skill_tree_for_language(&mut self, language_key: &str, progress: SkillTreeProgress) {
|
||||||
|
self.skill_tree_by_language
|
||||||
|
.insert(language_key.to_string(), progress.clone());
|
||||||
|
// Keep legacy mirror aligned with the current active scope.
|
||||||
|
self.skill_tree = progress;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
@@ -87,3 +108,50 @@ pub struct ExportData {
|
|||||||
pub ranked_key_stats: KeyStatsData,
|
pub ranked_key_stats: KeyStatsData,
|
||||||
pub drill_history: DrillHistoryData,
|
pub drill_history: DrillHistoryData,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_skill_tree_for_language_falls_back_to_legacy() {
|
||||||
|
let profile = ProfileData::default();
|
||||||
|
let scoped = profile.skill_tree_for_language("de");
|
||||||
|
let lowercase = scoped
|
||||||
|
.branches
|
||||||
|
.get("lowercase")
|
||||||
|
.expect("lowercase branch should exist");
|
||||||
|
assert_eq!(lowercase.current_level, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_set_skill_tree_for_language_updates_scoped_map() {
|
||||||
|
let mut profile = ProfileData::default();
|
||||||
|
let mut progress = SkillTreeProgress::default();
|
||||||
|
progress
|
||||||
|
.branches
|
||||||
|
.get_mut("lowercase")
|
||||||
|
.expect("lowercase branch should exist")
|
||||||
|
.current_level = 3;
|
||||||
|
profile.set_skill_tree_for_language("de", progress.clone());
|
||||||
|
|
||||||
|
let loaded = profile.skill_tree_for_language("de");
|
||||||
|
assert_eq!(
|
||||||
|
loaded
|
||||||
|
.branches
|
||||||
|
.get("lowercase")
|
||||||
|
.expect("lowercase branch should exist")
|
||||||
|
.current_level,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
profile
|
||||||
|
.skill_tree
|
||||||
|
.branches
|
||||||
|
.get("lowercase")
|
||||||
|
.expect("lowercase branch should exist")
|
||||||
|
.current_level,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
@@ -6,7 +7,7 @@ use ratatui::style::{Color, Modifier, Style};
|
|||||||
use ratatui::widgets::{Block, Widget};
|
use ratatui::widgets::{Block, Widget};
|
||||||
|
|
||||||
use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB};
|
use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB};
|
||||||
use crate::keyboard::model::KeyboardModel;
|
use crate::keyboard::model::{KeyboardModel, PhysicalKey};
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
pub struct KeyboardDiagram<'a> {
|
pub struct KeyboardDiagram<'a> {
|
||||||
@@ -21,6 +22,31 @@ pub struct KeyboardDiagram<'a> {
|
|||||||
pub caps_lock: bool,
|
pub caps_lock: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
enum KeyboardRenderMode {
|
||||||
|
Compact,
|
||||||
|
Full,
|
||||||
|
FullFallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct KeyboardGeometry {
|
||||||
|
key_width: u16,
|
||||||
|
row_offsets: Vec<u16>,
|
||||||
|
keyboard_width: u16,
|
||||||
|
start_inset: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
struct GeometryCacheKey {
|
||||||
|
layout_key: String,
|
||||||
|
mode: KeyboardRenderMode,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_GEOMETRY_CACHE_ENTRIES: usize = 128;
|
||||||
|
|
||||||
impl<'a> KeyboardDiagram<'a> {
|
impl<'a> KeyboardDiagram<'a> {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
next_key: Option<char>,
|
next_key: Option<char>,
|
||||||
@@ -73,6 +99,154 @@ impl<'a> KeyboardDiagram<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn geometry_cache() -> &'static Mutex<HashMap<GeometryCacheKey, KeyboardGeometry>> {
|
||||||
|
static CACHE: OnceLock<Mutex<HashMap<GeometryCacheKey, KeyboardGeometry>>> = OnceLock::new();
|
||||||
|
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rows_for_mode<'a>(model: &'a KeyboardModel, mode: KeyboardRenderMode) -> &'a [Vec<PhysicalKey>] {
|
||||||
|
match mode {
|
||||||
|
KeyboardRenderMode::Compact | KeyboardRenderMode::FullFallback => model.letter_rows(),
|
||||||
|
KeyboardRenderMode::Full => &model.rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_mode_for(inner: Rect, compact: bool) -> KeyboardRenderMode {
|
||||||
|
if compact {
|
||||||
|
KeyboardRenderMode::Compact
|
||||||
|
} else if inner.height >= 4 && inner.width >= 75 {
|
||||||
|
KeyboardRenderMode::Full
|
||||||
|
} else {
|
||||||
|
KeyboardRenderMode::FullFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_geometry(
|
||||||
|
inner: Rect,
|
||||||
|
model: &KeyboardModel,
|
||||||
|
mode: KeyboardRenderMode,
|
||||||
|
) -> Option<KeyboardGeometry> {
|
||||||
|
let rows = rows_for_mode(model, mode);
|
||||||
|
match mode {
|
||||||
|
KeyboardRenderMode::Compact => {
|
||||||
|
if inner.height < 3 || inner.width < 21 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let key_width = 3;
|
||||||
|
let row_offsets = vec![3, 4, 6];
|
||||||
|
let keyboard_width = rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(row_idx, row)| {
|
||||||
|
let offset = row_offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
offset + row.len() as u16 * key_width + 3
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
Some(KeyboardGeometry {
|
||||||
|
key_width,
|
||||||
|
row_offsets,
|
||||||
|
keyboard_width,
|
||||||
|
start_inset: inner.width.saturating_sub(keyboard_width) / 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
KeyboardRenderMode::Full => {
|
||||||
|
if inner.height < 4 || inner.width < 75 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let key_width = 5;
|
||||||
|
let row_offsets = vec![0, 5, 5, 6];
|
||||||
|
let keyboard_width = rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(row_idx, row)| {
|
||||||
|
let offset = row_offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
let row_end = offset + row.len() as u16 * key_width;
|
||||||
|
match row_idx {
|
||||||
|
0 => row_end + 6,
|
||||||
|
2 => row_end + 7,
|
||||||
|
3 => row_end + 6,
|
||||||
|
_ => row_end,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
Some(KeyboardGeometry {
|
||||||
|
key_width,
|
||||||
|
row_offsets,
|
||||||
|
keyboard_width,
|
||||||
|
start_inset: inner.width.saturating_sub(keyboard_width) / 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
KeyboardRenderMode::FullFallback => {
|
||||||
|
if inner.height < 3 || inner.width < 30 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let key_width = 5;
|
||||||
|
let row_offsets = vec![1, 3, 5];
|
||||||
|
let keyboard_width = rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(row_idx, row)| {
|
||||||
|
let offset = row_offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
offset + row.len() as u16 * key_width
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
Some(KeyboardGeometry {
|
||||||
|
key_width,
|
||||||
|
row_offsets,
|
||||||
|
keyboard_width,
|
||||||
|
start_inset: inner.width.saturating_sub(keyboard_width) / 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn geometry_for_mode(
|
||||||
|
inner: Rect,
|
||||||
|
model: &KeyboardModel,
|
||||||
|
mode: KeyboardRenderMode,
|
||||||
|
) -> Option<KeyboardGeometry> {
|
||||||
|
let key = GeometryCacheKey {
|
||||||
|
layout_key: model.layout_key.to_string(),
|
||||||
|
mode,
|
||||||
|
width: inner.width,
|
||||||
|
height: inner.height,
|
||||||
|
};
|
||||||
|
if let Some(geom) = geometry_cache()
|
||||||
|
.lock()
|
||||||
|
.expect("keyboard geometry cache poisoned")
|
||||||
|
.get(&key)
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
return Some(geom);
|
||||||
|
}
|
||||||
|
|
||||||
|
let built = build_geometry(inner, model, mode)?;
|
||||||
|
let mut cache = geometry_cache()
|
||||||
|
.lock()
|
||||||
|
.expect("keyboard geometry cache poisoned");
|
||||||
|
if cache.len() >= MAX_GEOMETRY_CACHE_ENTRIES {
|
||||||
|
// Bounded cache: simple full-clear avoids unbounded growth across long resize sessions.
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
cache.insert(key, built.clone());
|
||||||
|
Some(built)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn geometry_for(inner: Rect, model: &KeyboardModel, compact: bool) -> Option<KeyboardGeometry> {
|
||||||
|
geometry_for_mode(inner, model, render_mode_for(inner, compact))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_shifted_for_key(key: &PhysicalKey, shift_held: bool, caps_lock: bool) -> bool {
|
||||||
|
if key.base.is_alphabetic() {
|
||||||
|
shift_held ^ caps_lock
|
||||||
|
} else {
|
||||||
|
shift_held
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn brighten_color(color: Color) -> Color {
|
fn brighten_color(color: Color) -> Color {
|
||||||
match color {
|
match color {
|
||||||
Color::Rgb(r, g, b) => Color::Rgb(
|
Color::Rgb(r, g, b) => Color::Rgb(
|
||||||
@@ -297,30 +471,13 @@ impl KeyboardDiagram<'_> {
|
|||||||
fn render_compact(&self, inner: Rect, buf: &mut Buffer) {
|
fn render_compact(&self, inner: Rect, buf: &mut Buffer) {
|
||||||
let colors = &self.theme.colors;
|
let colors = &self.theme.colors;
|
||||||
let letter_rows = self.model.letter_rows();
|
let letter_rows = self.model.letter_rows();
|
||||||
let key_width: u16 = 3;
|
let Some(geometry) = geometry_for_mode(inner, self.model, KeyboardRenderMode::Compact)
|
||||||
let min_width: u16 = 21;
|
else {
|
||||||
|
|
||||||
if inner.height < 3 || inner.width < min_width {
|
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
|
let key_width = geometry.key_width;
|
||||||
let offsets: &[u16] = &[3, 4, 6];
|
let offsets = &geometry.row_offsets;
|
||||||
let keyboard_width = letter_rows
|
let start_x = inner.x + geometry.start_inset;
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(row_idx, row)| {
|
|
||||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
|
||||||
let row_end = offset + row.len() as u16 * key_width;
|
|
||||||
match row_idx {
|
|
||||||
0 => row_end + 3, // [B]
|
|
||||||
1 => row_end + 3, // [E]
|
|
||||||
2 => row_end + 3, // [S]
|
|
||||||
_ => row_end,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
|
|
||||||
|
|
||||||
for (row_idx, row) in letter_rows.iter().enumerate() {
|
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||||
let y = inner.y + row_idx as u16;
|
let y = inner.y + row_idx as u16;
|
||||||
@@ -353,12 +510,8 @@ impl KeyboardDiagram<'_> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caps lock inverts shift for alpha keys only
|
let show_shifted =
|
||||||
let show_shifted = if physical_key.base.is_ascii_alphabetic() {
|
show_shifted_for_key(physical_key, self.shift_held, self.caps_lock);
|
||||||
self.shift_held ^ self.caps_lock
|
|
||||||
} else {
|
|
||||||
self.shift_held
|
|
||||||
};
|
|
||||||
let display_char = if show_shifted {
|
let display_char = if show_shifted {
|
||||||
physical_key.shifted
|
physical_key.shifted
|
||||||
} else {
|
} else {
|
||||||
@@ -403,7 +556,7 @@ impl KeyboardDiagram<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Backspace at end of first row
|
// Backspace at end of first row
|
||||||
if inner.height >= 3 {
|
if inner.height >= 3 && !letter_rows.is_empty() {
|
||||||
let y = inner.y;
|
let y = inner.y;
|
||||||
let row_end_x = start_x + offsets[0] + letter_rows[0].len() as u16 * key_width;
|
let row_end_x = start_x + offsets[0] + letter_rows[0].len() as u16 * key_width;
|
||||||
if row_end_x + 3 <= inner.x + inner.width {
|
if row_end_x + 3 <= inner.x + inner.width {
|
||||||
@@ -418,33 +571,17 @@ impl KeyboardDiagram<'_> {
|
|||||||
|
|
||||||
fn render_full(&self, inner: Rect, buf: &mut Buffer) {
|
fn render_full(&self, inner: Rect, buf: &mut Buffer) {
|
||||||
let colors = &self.theme.colors;
|
let colors = &self.theme.colors;
|
||||||
let key_width: u16 = 5;
|
let Some(geometry) = geometry_for(inner, self.model, false) else {
|
||||||
let min_width: u16 = 75;
|
return;
|
||||||
|
};
|
||||||
if inner.height < 4 || inner.width < min_width {
|
if render_mode_for(inner, false) != KeyboardRenderMode::Full {
|
||||||
self.render_full_fallback(inner, buf);
|
self.render_full_fallback(inner, buf);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let key_width = geometry.key_width;
|
||||||
let offsets: &[u16] = &[0, 5, 5, 6];
|
let offsets = &geometry.row_offsets;
|
||||||
let keyboard_width = self
|
let keyboard_width = geometry.keyboard_width;
|
||||||
.model
|
let start_x = inner.x + geometry.start_inset;
|
||||||
.rows
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(row_idx, row)| {
|
|
||||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
|
||||||
let row_end = offset + row.len() as u16 * key_width;
|
|
||||||
match row_idx {
|
|
||||||
0 => row_end + 6, // [Bksp]
|
|
||||||
2 => row_end + 7, // [Enter]
|
|
||||||
3 => row_end + 6, // [Shft]
|
|
||||||
_ => row_end,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
|
|
||||||
|
|
||||||
for (row_idx, row) in self.model.rows.iter().enumerate() {
|
for (row_idx, row) in self.model.rows.iter().enumerate() {
|
||||||
let y = inner.y + row_idx as u16;
|
let y = inner.y + row_idx as u16;
|
||||||
@@ -496,12 +633,8 @@ impl KeyboardDiagram<'_> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caps lock inverts shift for alpha keys only
|
let show_shifted =
|
||||||
let show_shifted = if physical_key.base.is_ascii_alphabetic() {
|
show_shifted_for_key(physical_key, self.shift_held, self.caps_lock);
|
||||||
self.shift_held ^ self.caps_lock
|
|
||||||
} else {
|
|
||||||
self.shift_held
|
|
||||||
};
|
|
||||||
let display_char = if show_shifted {
|
let display_char = if show_shifted {
|
||||||
physical_key.shifted
|
physical_key.shifted
|
||||||
} else {
|
} else {
|
||||||
@@ -576,22 +709,13 @@ impl KeyboardDiagram<'_> {
|
|||||||
fn render_full_fallback(&self, inner: Rect, buf: &mut Buffer) {
|
fn render_full_fallback(&self, inner: Rect, buf: &mut Buffer) {
|
||||||
let colors = &self.theme.colors;
|
let colors = &self.theme.colors;
|
||||||
let letter_rows = self.model.letter_rows();
|
let letter_rows = self.model.letter_rows();
|
||||||
let key_width: u16 = 5;
|
let Some(geometry) = geometry_for_mode(inner, self.model, KeyboardRenderMode::FullFallback)
|
||||||
let offsets: &[u16] = &[1, 3, 5];
|
else {
|
||||||
let keyboard_width = letter_rows
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(row_idx, row)| {
|
|
||||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
|
||||||
offset + row.len() as u16 * key_width
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
|
|
||||||
|
|
||||||
if inner.height < 3 || inner.width < 30 {
|
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
|
let key_width = geometry.key_width;
|
||||||
|
let offsets = &geometry.row_offsets;
|
||||||
|
let start_x = inner.x + geometry.start_inset;
|
||||||
|
|
||||||
for (row_idx, row) in letter_rows.iter().enumerate() {
|
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||||
let y = inner.y + row_idx as u16;
|
let y = inner.y + row_idx as u16;
|
||||||
@@ -607,12 +731,8 @@ impl KeyboardDiagram<'_> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caps lock inverts shift for alpha keys only
|
let show_shifted =
|
||||||
let show_shifted = if physical_key.base.is_ascii_alphabetic() {
|
show_shifted_for_key(physical_key, self.shift_held, self.caps_lock);
|
||||||
self.shift_held ^ self.caps_lock
|
|
||||||
} else {
|
|
||||||
self.shift_held
|
|
||||||
};
|
|
||||||
let display_char = if show_shifted {
|
let display_char = if show_shifted {
|
||||||
physical_key.shifted
|
physical_key.shifted
|
||||||
} else {
|
} else {
|
||||||
@@ -641,30 +761,11 @@ fn rect_contains(area: Rect, x: u16, y: u16) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn key_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option<char> {
|
fn key_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option<char> {
|
||||||
|
let geometry = geometry_for_mode(inner, model, KeyboardRenderMode::Compact)?;
|
||||||
let letter_rows = model.letter_rows();
|
let letter_rows = model.letter_rows();
|
||||||
let key_width: u16 = 3;
|
let key_width = geometry.key_width;
|
||||||
let min_width: u16 = 21;
|
let offsets = &geometry.row_offsets;
|
||||||
if inner.height < 3 || inner.width < min_width {
|
let start_x = inner.x + geometry.start_inset;
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let offsets: &[u16] = &[3, 4, 6];
|
|
||||||
let keyboard_width = letter_rows
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(row_idx, row)| {
|
|
||||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
|
||||||
let row_end = offset + row.len() as u16 * key_width;
|
|
||||||
match row_idx {
|
|
||||||
0 => row_end + 3,
|
|
||||||
1 => row_end + 3,
|
|
||||||
2 => row_end + 3,
|
|
||||||
_ => row_end,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
|
|
||||||
|
|
||||||
for (row_idx, row) in letter_rows.iter().enumerate() {
|
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||||
let row_y = inner.y + row_idx as u16;
|
let row_y = inner.y + row_idx as u16;
|
||||||
@@ -727,30 +828,13 @@ fn key_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn shift_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> bool {
|
fn shift_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> bool {
|
||||||
let letter_rows = model.letter_rows();
|
let Some(geometry) = geometry_for_mode(inner, model, KeyboardRenderMode::Compact) else {
|
||||||
let key_width: u16 = 3;
|
|
||||||
let min_width: u16 = 21;
|
|
||||||
if inner.height < 3 || inner.width < min_width {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
};
|
||||||
|
let letter_rows = model.letter_rows();
|
||||||
let offsets: &[u16] = &[3, 4, 6];
|
let key_width = geometry.key_width;
|
||||||
let keyboard_width = letter_rows
|
let offsets = &geometry.row_offsets;
|
||||||
.iter()
|
let start_x = inner.x + geometry.start_inset;
|
||||||
.enumerate()
|
|
||||||
.map(|(row_idx, row)| {
|
|
||||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
|
||||||
let row_end = offset + row.len() as u16 * key_width;
|
|
||||||
match row_idx {
|
|
||||||
0 => row_end + 3,
|
|
||||||
1 => row_end + 3,
|
|
||||||
2 => row_end + 3,
|
|
||||||
_ => row_end,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
|
|
||||||
let shift_row_y = inner.y + 2;
|
let shift_row_y = inner.y + 2;
|
||||||
if y != shift_row_y {
|
if y != shift_row_y {
|
||||||
return false;
|
return false;
|
||||||
@@ -759,6 +843,9 @@ fn shift_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16)
|
|||||||
if rect_contains(left_shift, x, y) {
|
if rect_contains(left_shift, x, y) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if letter_rows.len() <= 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let offset = offsets[2];
|
let offset = offsets[2];
|
||||||
let row_end_x = start_x + offset + letter_rows[2].len() as u16 * key_width;
|
let row_end_x = start_x + offset + letter_rows[2].len() as u16 * key_width;
|
||||||
let right_shift = Rect::new(row_end_x, shift_row_y, 3, 1);
|
let right_shift = Rect::new(row_end_x, shift_row_y, 3, 1);
|
||||||
@@ -766,25 +853,11 @@ fn shift_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn key_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option<char> {
|
fn key_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option<char> {
|
||||||
let key_width: u16 = 5;
|
let geometry = geometry_for_mode(inner, model, KeyboardRenderMode::Full)?;
|
||||||
let offsets: &[u16] = &[0, 5, 5, 6];
|
let key_width = geometry.key_width;
|
||||||
let keyboard_width = model
|
let offsets = &geometry.row_offsets;
|
||||||
.rows
|
let keyboard_width = geometry.keyboard_width;
|
||||||
.iter()
|
let start_x = inner.x + geometry.start_inset;
|
||||||
.enumerate()
|
|
||||||
.map(|(row_idx, row)| {
|
|
||||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
|
||||||
let row_end = offset + row.len() as u16 * key_width;
|
|
||||||
match row_idx {
|
|
||||||
0 => row_end + 6,
|
|
||||||
2 => row_end + 7,
|
|
||||||
3 => row_end + 6,
|
|
||||||
_ => row_end,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
|
|
||||||
|
|
||||||
for (row_idx, row) in model.rows.iter().enumerate() {
|
for (row_idx, row) in model.rows.iter().enumerate() {
|
||||||
let row_y = inner.y + row_idx as u16;
|
let row_y = inner.y + row_idx as u16;
|
||||||
@@ -862,25 +935,12 @@ fn key_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> O
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn shift_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> bool {
|
fn shift_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> bool {
|
||||||
let key_width: u16 = 5;
|
let Some(geometry) = geometry_for_mode(inner, model, KeyboardRenderMode::Full) else {
|
||||||
let offsets: &[u16] = &[0, 5, 5, 6];
|
return false;
|
||||||
let keyboard_width = model
|
};
|
||||||
.rows
|
let key_width = geometry.key_width;
|
||||||
.iter()
|
let offsets = &geometry.row_offsets;
|
||||||
.enumerate()
|
let start_x = inner.x + geometry.start_inset;
|
||||||
.map(|(row_idx, row)| {
|
|
||||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
|
||||||
let row_end = offset + row.len() as u16 * key_width;
|
|
||||||
match row_idx {
|
|
||||||
0 => row_end + 6,
|
|
||||||
2 => row_end + 7,
|
|
||||||
3 => row_end + 6,
|
|
||||||
_ => row_end,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
|
|
||||||
let shift_row_y = inner.y + 3;
|
let shift_row_y = inner.y + 3;
|
||||||
if y != shift_row_y {
|
if y != shift_row_y {
|
||||||
return false;
|
return false;
|
||||||
@@ -890,6 +950,9 @@ fn shift_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) ->
|
|||||||
if rect_contains(left_shift, x, y) {
|
if rect_contains(left_shift, x, y) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if model.rows.len() <= 3 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let offset = offsets[3];
|
let offset = offsets[3];
|
||||||
let row_end_x = start_x + offset + model.rows[3].len() as u16 * key_width;
|
let row_end_x = start_x + offset + model.rows[3].len() as u16 * key_width;
|
||||||
let right_shift = Rect::new(row_end_x, shift_row_y, 6, 1);
|
let right_shift = Rect::new(row_end_x, shift_row_y, 6, 1);
|
||||||
@@ -902,23 +965,11 @@ fn key_at_full_fallback_position(
|
|||||||
x: u16,
|
x: u16,
|
||||||
y: u16,
|
y: u16,
|
||||||
) -> Option<char> {
|
) -> Option<char> {
|
||||||
|
let geometry = geometry_for_mode(inner, model, KeyboardRenderMode::FullFallback)?;
|
||||||
let letter_rows = model.letter_rows();
|
let letter_rows = model.letter_rows();
|
||||||
let key_width: u16 = 5;
|
let key_width = geometry.key_width;
|
||||||
let offsets: &[u16] = &[1, 3, 5];
|
let offsets = &geometry.row_offsets;
|
||||||
let keyboard_width = letter_rows
|
let start_x = inner.x + geometry.start_inset;
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(row_idx, row)| {
|
|
||||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
|
||||||
offset + row.len() as u16 * key_width
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
|
|
||||||
|
|
||||||
if inner.height < 3 || inner.width < 30 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (row_idx, row) in letter_rows.iter().enumerate() {
|
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||||
let row_y = inner.y + row_idx as u16;
|
let row_y = inner.y + row_idx as u16;
|
||||||
@@ -940,3 +991,183 @@ fn key_at_full_fallback_position(
|
|||||||
fn shift_at_full_fallback_position(_inner: Rect, _model: &KeyboardModel, _x: u16, _y: u16) -> bool {
|
fn shift_at_full_fallback_position(_inner: Rect, _model: &KeyboardModel, _x: u16, _y: u16) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn geometry_cache_len() -> usize {
|
||||||
|
geometry_cache()
|
||||||
|
.lock()
|
||||||
|
.expect("keyboard geometry cache poisoned")
|
||||||
|
.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn geometry_cache_clear() {
|
||||||
|
geometry_cache()
|
||||||
|
.lock()
|
||||||
|
.expect("keyboard geometry cache poisoned")
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn geometry_cache_matching_entries(
|
||||||
|
layout_key: &str,
|
||||||
|
mode: KeyboardRenderMode,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
) -> usize {
|
||||||
|
geometry_cache()
|
||||||
|
.lock()
|
||||||
|
.expect("keyboard geometry cache poisoned")
|
||||||
|
.keys()
|
||||||
|
.filter(|k| {
|
||||||
|
k.layout_key == layout_key && k.mode == mode && k.width == width && k.height == height
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
fn cache_test_lock() -> &'static Mutex<()> {
|
||||||
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_roundtrip_for_render_mode(
|
||||||
|
model: &KeyboardModel,
|
||||||
|
area: Rect,
|
||||||
|
compact: bool,
|
||||||
|
mode: KeyboardRenderMode,
|
||||||
|
) {
|
||||||
|
let inner = Block::bordered().inner(area);
|
||||||
|
let geometry =
|
||||||
|
geometry_for_mode(inner, model, mode).expect("expected geometry for test render mode");
|
||||||
|
let rows = rows_for_mode(model, mode);
|
||||||
|
|
||||||
|
for (row_idx, row) in rows.iter().enumerate() {
|
||||||
|
let row_y = inner.y + row_idx as u16;
|
||||||
|
let offset = geometry.row_offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
for (col_idx, key) in row.iter().enumerate() {
|
||||||
|
let key_x =
|
||||||
|
inner.x + geometry.start_inset + offset + col_idx as u16 * geometry.key_width;
|
||||||
|
let hit_x = key_x;
|
||||||
|
let hit_y = row_y;
|
||||||
|
let hit = KeyboardDiagram::key_at_position(area, model, compact, hit_x, hit_y);
|
||||||
|
assert_eq!(
|
||||||
|
hit,
|
||||||
|
Some(key.base),
|
||||||
|
"round-trip hit-test mismatch for layout={}, mode={mode:?}, row={row_idx}, col={col_idx}, key={}",
|
||||||
|
model.layout_key,
|
||||||
|
key.base
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn geometry_cache_reuses_entries_for_same_layout_mode_and_viewport() {
|
||||||
|
let _guard = cache_test_lock()
|
||||||
|
.lock()
|
||||||
|
.expect("cache test lock should not be poisoned");
|
||||||
|
geometry_cache_clear();
|
||||||
|
|
||||||
|
let model = KeyboardModel::from_key("qwerty").expect("qwerty model must exist");
|
||||||
|
let area = Rect::new(0, 0, 100, 10);
|
||||||
|
let inner = Block::bordered().inner(area);
|
||||||
|
let mode = render_mode_for(inner, false);
|
||||||
|
|
||||||
|
let _ = KeyboardDiagram::key_at_position(area, &model, false, 10, 2);
|
||||||
|
assert_eq!(
|
||||||
|
geometry_cache_matching_entries(model.layout_key, mode, inner.width, inner.height),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
for _ in 0..50 {
|
||||||
|
let _ = KeyboardDiagram::key_at_position(area, &model, false, 12, 2);
|
||||||
|
let _ = KeyboardDiagram::shift_at_position(area, &model, false, 5, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
geometry_cache_matching_entries(model.layout_key, mode, inner.width, inner.height),
|
||||||
|
1,
|
||||||
|
"expected exactly one cached geometry entry for repeated same key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn geometry_cache_distinguishes_layout_and_viewport_keys() {
|
||||||
|
let _guard = cache_test_lock()
|
||||||
|
.lock()
|
||||||
|
.expect("cache test lock should not be poisoned");
|
||||||
|
geometry_cache_clear();
|
||||||
|
|
||||||
|
let qwerty = KeyboardModel::from_key("qwerty").expect("qwerty model must exist");
|
||||||
|
let azerty = KeyboardModel::from_key("fr_azerty").expect("fr_azerty model must exist");
|
||||||
|
|
||||||
|
let _ = KeyboardDiagram::key_at_position(Rect::new(0, 0, 100, 10), &qwerty, false, 8, 2);
|
||||||
|
let after_first = geometry_cache_len();
|
||||||
|
assert!(after_first >= 1);
|
||||||
|
|
||||||
|
let _ = KeyboardDiagram::key_at_position(Rect::new(0, 0, 120, 10), &qwerty, false, 8, 2);
|
||||||
|
let _ = KeyboardDiagram::key_at_position(Rect::new(0, 0, 100, 10), &azerty, false, 8, 2);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
geometry_cache_len() >= after_first + 2,
|
||||||
|
"expected separate cached geometry entries for viewport/layout changes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn geometry_cache_is_bounded() {
|
||||||
|
let _guard = cache_test_lock()
|
||||||
|
.lock()
|
||||||
|
.expect("cache test lock should not be poisoned");
|
||||||
|
geometry_cache_clear();
|
||||||
|
|
||||||
|
let model = KeyboardModel::from_key("qwerty").expect("qwerty model must exist");
|
||||||
|
for i in 0..(MAX_GEOMETRY_CACHE_ENTRIES as u16 + 10) {
|
||||||
|
let width = 90 + i;
|
||||||
|
let area = Rect::new(0, 0, width, 10);
|
||||||
|
let _ = KeyboardDiagram::key_at_position(area, &model, false, 10, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
geometry_cache_len() <= MAX_GEOMETRY_CACHE_ENTRIES,
|
||||||
|
"geometry cache exceeded bounded capacity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hit_test_roundtrip_invariants_hold_for_all_layouts() {
|
||||||
|
let _guard = cache_test_lock()
|
||||||
|
.lock()
|
||||||
|
.expect("cache test lock should not be poisoned");
|
||||||
|
for &layout_key in KeyboardModel::supported_layout_keys() {
|
||||||
|
let model = KeyboardModel::from_key(layout_key).expect("profile should exist");
|
||||||
|
|
||||||
|
// Full render mode.
|
||||||
|
assert_roundtrip_for_render_mode(
|
||||||
|
&model,
|
||||||
|
Rect::new(0, 0, 100, 10),
|
||||||
|
false,
|
||||||
|
KeyboardRenderMode::Full,
|
||||||
|
);
|
||||||
|
// Full fallback mode (non-compact, but too small for full keyboard).
|
||||||
|
assert_roundtrip_for_render_mode(
|
||||||
|
&model,
|
||||||
|
Rect::new(0, 0, 60, 8),
|
||||||
|
false,
|
||||||
|
KeyboardRenderMode::FullFallback,
|
||||||
|
);
|
||||||
|
// Compact mode.
|
||||||
|
assert_roundtrip_for_render_mode(
|
||||||
|
&model,
|
||||||
|
Rect::new(0, 0, 60, 8),
|
||||||
|
true,
|
||||||
|
KeyboardRenderMode::Compact,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ impl<'a> SkillTreeWidget<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn locked_branch_notice(skill_tree: &SkillTreeEngine) -> String {
|
||||||
|
format!(
|
||||||
|
"Complete {} primary letters to unlock branches",
|
||||||
|
skill_tree.primary_letters().len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the list of selectable branch IDs (Lowercase first, then other branches).
|
/// Get the list of selectable branch IDs (Lowercase first, then other branches).
|
||||||
pub fn selectable_branches() -> Vec<BranchId> {
|
pub fn selectable_branches() -> Vec<BranchId> {
|
||||||
vec![
|
vec![
|
||||||
@@ -59,8 +66,21 @@ pub fn detail_line_count(branch_id: BranchId) -> usize {
|
|||||||
.sum::<usize>()
|
.sum::<usize>()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn detail_line_count_with_level_spacing(branch_id: BranchId, level_spacing: bool) -> usize {
|
pub fn detail_line_count_for_tree(skill_tree: &SkillTreeEngine, branch_id: BranchId) -> usize {
|
||||||
let base = detail_line_count(branch_id);
|
if branch_id == BranchId::Lowercase {
|
||||||
|
// 1 branch header + 1 level header + one line per primary letter.
|
||||||
|
1 + 1 + skill_tree.primary_letters().len()
|
||||||
|
} else {
|
||||||
|
detail_line_count(branch_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail_line_count_with_level_spacing_for_tree(
|
||||||
|
skill_tree: &SkillTreeEngine,
|
||||||
|
branch_id: BranchId,
|
||||||
|
level_spacing: bool,
|
||||||
|
) -> usize {
|
||||||
|
let base = detail_line_count_for_tree(skill_tree, branch_id);
|
||||||
if !level_spacing {
|
if !level_spacing {
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
@@ -68,6 +88,7 @@ pub fn detail_line_count_with_level_spacing(branch_id: BranchId, level_spacing:
|
|||||||
base + def.levels.len().saturating_sub(1)
|
base + def.levels.len().saturating_sub(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub fn use_expanded_level_spacing(detail_area_height: u16, branch_id: BranchId) -> bool {
|
pub fn use_expanded_level_spacing(detail_area_height: u16, branch_id: BranchId) -> bool {
|
||||||
let def = get_branch_definition(branch_id);
|
let def = get_branch_definition(branch_id);
|
||||||
let base = detail_line_count(branch_id);
|
let base = detail_line_count(branch_id);
|
||||||
@@ -75,6 +96,17 @@ pub fn use_expanded_level_spacing(detail_area_height: u16, branch_id: BranchId)
|
|||||||
(detail_area_height as usize) >= base + extra
|
(detail_area_height as usize) >= base + extra
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn use_expanded_level_spacing_for_tree(
|
||||||
|
skill_tree: &SkillTreeEngine,
|
||||||
|
detail_area_height: u16,
|
||||||
|
branch_id: BranchId,
|
||||||
|
) -> bool {
|
||||||
|
let def = get_branch_definition(branch_id);
|
||||||
|
let base = detail_line_count_for_tree(skill_tree, branch_id);
|
||||||
|
let extra = def.levels.len().saturating_sub(1);
|
||||||
|
(detail_area_height as usize) >= base + extra
|
||||||
|
}
|
||||||
|
|
||||||
pub fn use_side_by_side_layout(inner_width: u16) -> bool {
|
pub fn use_side_by_side_layout(inner_width: u16) -> bool {
|
||||||
inner_width >= 100
|
inner_width >= 100
|
||||||
}
|
}
|
||||||
@@ -107,37 +139,48 @@ impl Widget for SkillTreeWidget<'_> {
|
|||||||
|
|
||||||
// Layout: main split (branch list + detail) and footer (adaptive height)
|
// Layout: main split (branch list + detail) and footer (adaptive height)
|
||||||
let branches = selectable_branches();
|
let branches = selectable_branches();
|
||||||
let (footer_hints, footer_notice) = if self.selected < branches.len() {
|
let (footer_hints, footer_notice): (Vec<&str>, Option<String>) =
|
||||||
let bp = self.skill_tree.branch_progress(branches[self.selected]);
|
if self.selected < branches.len() {
|
||||||
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
|
let bp = self.skill_tree.branch_progress(branches[self.selected]);
|
||||||
(
|
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
|
||||||
vec![
|
(
|
||||||
"[↑↓/jk] Navigate",
|
vec![
|
||||||
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
"[↑↓/jk] Navigate",
|
||||||
"[q] Back",
|
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||||
],
|
"[q] Back",
|
||||||
Some("Complete a-z to unlock branches"),
|
],
|
||||||
)
|
Some(locked_branch_notice(self.skill_tree)),
|
||||||
} else if bp.status == BranchStatus::Available {
|
)
|
||||||
(
|
} else if bp.status == BranchStatus::Available {
|
||||||
vec![
|
(
|
||||||
"[Enter] Unlock",
|
vec![
|
||||||
"[↑↓/jk] Navigate",
|
"[Enter] Unlock",
|
||||||
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
"[↑↓/jk] Navigate",
|
||||||
"[q] Back",
|
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||||
],
|
"[q] Back",
|
||||||
None,
|
],
|
||||||
)
|
None,
|
||||||
} else if bp.status == BranchStatus::InProgress {
|
)
|
||||||
(
|
} else if bp.status == BranchStatus::InProgress {
|
||||||
vec![
|
(
|
||||||
"[Enter] Start Drill",
|
vec![
|
||||||
"[↑↓/jk] Navigate",
|
"[Enter] Start Drill",
|
||||||
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
"[↑↓/jk] Navigate",
|
||||||
"[q] Back",
|
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||||
],
|
"[q] Back",
|
||||||
None,
|
],
|
||||||
)
|
None,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
"[↑↓/jk] Navigate",
|
||||||
|
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||||
|
"[q] Back",
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
vec![
|
vec![
|
||||||
@@ -147,19 +190,10 @@ impl Widget for SkillTreeWidget<'_> {
|
|||||||
],
|
],
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
}
|
};
|
||||||
} else {
|
|
||||||
(
|
|
||||||
vec![
|
|
||||||
"[↑↓/jk] Navigate",
|
|
||||||
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
|
||||||
"[q] Back",
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let hint_lines = pack_hint_lines(&footer_hints, inner.width as usize);
|
let hint_lines = pack_hint_lines(&footer_hints, inner.width as usize);
|
||||||
let notice_lines = footer_notice
|
let notice_lines = footer_notice
|
||||||
|
.as_deref()
|
||||||
.map(|text| wrapped_line_count(text, inner.width as usize))
|
.map(|text| wrapped_line_count(text, inner.width as usize))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let show_notice = footer_notice.is_some()
|
let show_notice = footer_notice.is_some()
|
||||||
@@ -273,7 +307,7 @@ impl SkillTreeWidget<'_> {
|
|||||||
|
|
||||||
let bp = self.skill_tree.branch_progress(branch_id);
|
let bp = self.skill_tree.branch_progress(branch_id);
|
||||||
let def = get_branch_definition(branch_id);
|
let def = get_branch_definition(branch_id);
|
||||||
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
|
let total_keys = self.skill_tree.branch_total_keys_for(branch_id);
|
||||||
let confident_keys = self
|
let confident_keys = self
|
||||||
.skill_tree
|
.skill_tree
|
||||||
.branch_confident_keys(branch_id, self.key_stats);
|
.branch_confident_keys(branch_id, self.key_stats);
|
||||||
@@ -346,7 +380,10 @@ impl SkillTreeWidget<'_> {
|
|||||||
lines.push(Line::from(""));
|
lines.push(Line::from(""));
|
||||||
}
|
}
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
" \u{2500}\u{2500} Branches (available after a-z) \u{2500}\u{2500}",
|
format!(
|
||||||
|
" \u{2500}\u{2500} Branches (available after {} primary letters) \u{2500}\u{2500}",
|
||||||
|
self.skill_tree.primary_letters().len()
|
||||||
|
),
|
||||||
Style::default().fg(colors.text_pending()),
|
Style::default().fg(colors.text_pending()),
|
||||||
)));
|
)));
|
||||||
// If inter-branch spacing is enabled, the next branch will already
|
// If inter-branch spacing is enabled, the next branch will already
|
||||||
@@ -377,15 +414,15 @@ impl SkillTreeWidget<'_> {
|
|||||||
let branch_id = branches[self.selected];
|
let branch_id = branches[self.selected];
|
||||||
let bp = self.skill_tree.branch_progress(branch_id);
|
let bp = self.skill_tree.branch_progress(branch_id);
|
||||||
let def = get_branch_definition(branch_id);
|
let def = get_branch_definition(branch_id);
|
||||||
let expanded_level_spacing =
|
let expanded_level_spacing = allow_expanded_level_spacing
|
||||||
allow_expanded_level_spacing && use_expanded_level_spacing(area.height, branch_id);
|
&& use_expanded_level_spacing_for_tree(self.skill_tree, area.height, branch_id);
|
||||||
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
// Branch title with level info
|
// Branch title with level info
|
||||||
let level_text = if branch_id == BranchId::Lowercase {
|
let level_text = if branch_id == BranchId::Lowercase {
|
||||||
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
|
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
|
||||||
let total = SkillTreeEngine::branch_total_keys(BranchId::Lowercase);
|
let total = self.skill_tree.branch_total_keys_for(BranchId::Lowercase);
|
||||||
format!("Unlocked {unlocked}/{total} letters")
|
format!("Unlocked {unlocked}/{total} letters")
|
||||||
} else {
|
} else {
|
||||||
match bp.status {
|
match bp.status {
|
||||||
@@ -441,7 +478,12 @@ impl SkillTreeWidget<'_> {
|
|||||||
)));
|
)));
|
||||||
|
|
||||||
// Per-key mastery bars
|
// Per-key mastery bars
|
||||||
for &key in level.keys {
|
let level_keys: Vec<char> = if branch_id == BranchId::Lowercase {
|
||||||
|
self.skill_tree.primary_letters().to_vec()
|
||||||
|
} else {
|
||||||
|
level.keys.to_vec()
|
||||||
|
};
|
||||||
|
for &key in &level_keys {
|
||||||
let is_focused = focused == Some(key);
|
let is_focused = focused == Some(key);
|
||||||
let confidence = self.key_stats.get_confidence(key).min(1.0);
|
let confidence = self.key_stats.get_confidence(key).min(1.0);
|
||||||
let is_confident = confidence >= 1.0;
|
let is_confident = confidence >= 1.0;
|
||||||
|
|||||||
@@ -743,7 +743,7 @@ impl StatsDashboard<'_> {
|
|||||||
};
|
};
|
||||||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||||||
let all_rows = &self.keyboard_model.rows;
|
let all_rows = &self.keyboard_model.rows;
|
||||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
let offsets = self.keyboard_model.geometry_hints.row_offsets;
|
||||||
let kbd_width = all_rows
|
let kbd_width = all_rows
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -898,7 +898,7 @@ impl StatsDashboard<'_> {
|
|||||||
};
|
};
|
||||||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||||||
let all_rows = &self.keyboard_model.rows;
|
let all_rows = &self.keyboard_model.rows;
|
||||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
let offsets = self.keyboard_model.geometry_hints.row_offsets;
|
||||||
let kbd_width = all_rows
|
let kbd_width = all_rows
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
|
|||||||
Reference in New Issue
Block a user