Skip to content

feat: add adaptive symbol typing trainer - #144

Open
av-leschinskiy wants to merge 21 commits into
ergohaven:mainfrom
av-leschinskiy:feat/adaptive-symbol-trainer-pr
Open

feat: add adaptive symbol typing trainer#144
av-leschinskiy wants to merge 21 commits into
ergohaven:mainfrom
av-leschinskiy:feat/adaptive-symbol-trainer-pr

Conversation

@av-leschinskiy

Copy link
Copy Markdown

Problem

The typing trainer only uses language word lists, which makes it hard to practise the symbols placed across custom keyboard layers.

Solution

  • add a symbols material toggle with time or fixed-count sessions (25, 50, 100);
  • derive printable characters from every loaded layout layer, including Shift variants, and de-duplicate them;
  • prioritise symbols with a higher observed error rate while retaining a base chance for every symbol;
  • persist adaptive statistics separately in ~/.config/entropy/typing_trainer_symbol_stats.json;
  • wrap continuous symbol sequences and avoid starting a session when no printable symbols are available.

Screenshot

Symbols material mode

Verification

  • cargo test --all-targets
  • python3 scripts/check_i18n.py
  • rustfmt --check for changed Rust files
  • manual test with an Ergohaven K:04 layout

@kissetfall kissetfall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trainer mechanics and tests are a solid start, but the generated pool does not yet represent what the loaded keyboard actually types.

typing_trainer_symbols::direct_and_shifted_symbols duplicates a hard-coded US/QWERTY output map. With a Russian active layout, for example, KC_A produces ф while the trainer expects a / A, so the exercise becomes incorrect or impossible. printable_symbols_from_layout also accepts only KeyBinding::Vial and skips every KeyBinding::Rmk, including native Universal Symbols.

Please move printable-output derivation into the existing keycode/legend owner (or add a shared helper there), make it use the selected key legend/input mapping, and handle supported RMK-native printable actions. Add regression coverage for at least Russian output and native Universal Symbols, alongside English/QWERTY.

Please also avoid rebuilding the pool from every layer, sorting it, and cloning the complete adaptive stats map on every UI frame. Recompute/cache this data when the layout or relevant setting changes. Finally, clarify the user-facing material definition: the current “Symbols” pool includes letters and digits as well as punctuation; either make that explicit in the UI or filter it to the intended material.

A synthetic merge onto current main passed 570/570 tests, i18n, and Clippy, so these are behavior/ownership blockers rather than build failures.

@av-leschinskiy

av-leschinskiy commented Aug 13, 2026

Copy link
Copy Markdown
Author

Reworked all four points, then reviewed the result again locally and fixed what that review turned up. The branch is rebased onto current main and the history is linear.

Printable output moved to the keycode owner. keycode::printable_output(value, KeyOutputLayout) is the single source for "what does this key type": plain HID keycodes, Shift-wrapped ones (LSFT(kc)), and the tap half of mod-tap / layer-tap keys — a letter living only on a home-row mod used to vanish from the exercise. KeyOutputLayout is deliberately a separate type from KeyLegendLayout: legends may show two alphabets at once, output is a single active input mapping; From<KeyLegendLayout> bridges them. The legend table could not be reused verbatim — for KC_SLASH it prints / with ?/. alongside, while the Russian output of that key is . and ,.

RMK-native actions handled. key_binding_printable_output sits next to key_binding_label_with_macro_names and decodes a binding the way labels decode it: Vial keycodes, RMK mod-tap, plain Action::Key / Action::KeyWithModifier, and Universal Symbols — including those bound inside Tap / TapHold, which user_id alone would have dropped. universal_symbols::printable_output takes the input layout too: punctuation is typed in both layouts, the four Russian letters only while the Russian layout is active, matching what their own tooltip promises.

Input mapping is user-selectable. The language dropdown is shown in symbol training as well and doubles as the input mapping the pool is derived from, so an exercise never asks for characters the active layout cannot type. Switching material also moves the count to the nearest preset the new material offers — otherwise the dropdown showed 50 while the run generated 10.

No per-frame work. The pool is rebuilt only when the keymap or the selected language changed. I kept that as a comparison against a cached snapshot rather than invalidating at each of the ~20 places that assign or mutate layout, so a newly added mutation site cannot silently desync the pool. A pool change no longer restarts a run that is already in progress: layers keep arriving in the background right after a device connects and would otherwise wipe the typed text and the timer. Adaptive statistics live in TypingTrainerState alone — no longer cloned per frame or per keystroke — and are now flushed when the trainer page is left and on exit, so an abandoned session keeps the weights it gathered.

Material wording. The pool is deliberately every printable key across the layers, letters and digits included, so the toggle is labelled "layer keys" with a tooltip saying exactly that. Its width was also missing from the controls container, which clipped the toggle away entirely — symbol training could not be enabled at all.

One fix outside the feature came along: sticky_tap_dance_index passed keycode - 0x5700 to then_some, whose argument is always evaluated, so any ordinary keycode overflowed the subtraction and panicked debug builds on the first keypress. It is a separate commit.

Regression coverage: Russian output (letters, shifted digits, KC_SLASH./,, KC_LBRCх/Х), English/QWERTY, Shift-wrapped keycodes, mod-tap and layer-tap taps with layer operations excluded, native Universal Symbols in both layouts and inside tap-hold, count normalisation across materials, a late-arriving layer during a session, statistics dirty tracking, and the tap dance index guard.

Verification: cargo test --all-targets 592/592, python3 scripts/check_i18n.py, cargo clippy --all-targets (no new warnings), rustfmt clean for the changed files, plus a manual run against an Ergohaven K:04.

@av-leschinskiy
av-leschinskiy force-pushed the feat/adaptive-symbol-trainer-pr branch from aeb57d6 to d04c1cd Compare August 13, 2026 08:23

@kissetfall kissetfall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the previous ownership, layout-mapping, RMK-action, caching, persistence, and material-copy blockers are addressed. I rechecked the current head against main: 592/592 tests pass, i18n passes, and Clippy completes without new warnings. Two behavior gaps remain before merge.

  1. Symbol training advertises both time and fixed-count sessions, but typing_trainer_focus_status returns the character counter unconditionally whenever symbol material is selected. In Time mode the selected 15/30/60/120-second duration is therefore no longer visible; the UI shows a per-chunk 0..word_count counter which resets as the timed run advances. Please keep the remaining-time display for TypingTrainerMode::Time and use character progress only for the fixed-count mode, with regression coverage for both combinations.

  2. The material is described as every printable character across all layers, but the shared output decoder still drops printable keys already represented in Entropy's keycode catalog. At minimum, KC_NONUS_HASH / KC_NONUS_BSLASH and numpad arithmetic keys such as /, *, -, and + never enter the pool. Please extend the owning output decoder with the correct input-layout semantics and add coverage, or narrow the user-facing promise if those keys are intentionally unsupported.

The screenshot in the PR is also from the earlier UI (it omits the language/input-layout selector and uses the old symbols label), so please refresh it with the final state.

The current GitHub Build workflow is still action_required; after these fixes, please run the full matrix before merge.

The trainer built its pool from a private US/QWERTY table and only looked
at Vial bindings: with a Russian layout KC_A demanded 'a' instead of 'ф',
and native Universal Symbols never reached the exercise at all.

The key-to-typed-characters mapping now lives with the legend owner
(keycode.rs) and follows the selected input mapping; binding decoding sits
next to label decoding and covers RMK actions. The pool is rebuilt when the
keymap or the language changes instead of every frame, and statistics are no
longer cloned on every keystroke. The language stays available in symbol
training because it selects the input mapping, and the material is named
after layer keys, stating that letters and digits are included.
The argument of then_some is always evaluated, so the tap dance index was
computed for keycodes below the range as well — the subtraction overflowed
and brought down debug builds on the very first keypress.
Mod-tap and layer-tap keys type their tap keycode, yet the pool dropped them
entirely: a letter living only on a home-row mod disappeared from the
exercise. Native Universal Symbols inside tap and tap-hold actions were lost
for the same reason.

The firmware types the Universal Symbols Russian letters only while the
Russian layout is active, so they do not belong in the English pool.
The controls container width was computed without the material toggle, so it
was clipped away and symbol training could not be enabled at all.

Switching material moves the count to the nearest preset of the new set;
otherwise the dropdown showed one value while the run used another.

Layers arrive from the device in the background and every new layer rebuilt
the pool, wiping the typed text and the timer: a running exercise is no
longer restarted.

Statistics were persisted only for a finished run, so an abandoned session
lost the weights it had gathered.
The name english was left over from the first, English-only table and read
wrong in the Russian arms.
@av-leschinskiy
av-leschinskiy force-pushed the feat/adaptive-symbol-trainer-pr branch from d04c1cd to 0609309 Compare August 13, 2026 10:58
`printable_symbols_from_layout` draws `№` into the symbol pool for the
Russian layout (KC_3 with Shift), but `typing_trainer_accepts_char`
refused it: U+2116 is neither alphanumeric nor ASCII punctuation, so
`type_char` returned at its guard and the caret never advanced. The
exercise looked frozen and could only be moved on by typing a wrong
character.

Accept the character instead of dropping it from the pool — the layout
really does type it. The new pool-wide test walks every keycode and
Universal Symbol in both input layouts, so a future addition that the
trainer cannot accept fails the suite instead of stalling a session.
The input handler recorded an attempt before calling `type_char`, which
bails on a finished run, on a character the trainer does not accept and
on an empty pool. In Time mode the timer expires inside the draw pass,
so every character a fast typist commits on the following frames was
counted against the same un-advanced expected character. That symbol's
weight saturated at the 200 cap in `weighted_symbol_text`, it was then
over-sampled in every later session, and the skew was persisted to
`typing_trainer_symbol_stats.json`.

Move the recording into `type_char`, past the guards and under the
bounds check that appends the character, so statistics can only count
keystrokes the run really consumed.
The fallback that keeps a generated text from being one repeated
character popped the last character and pushed `symbols[1]`. When the
repeated character already was `symbols[1]` — half the runs of a
two-symbol pool — the text stayed uniform and the guard did nothing. At
`count == 1` the text is trivially uniform, so the fixup also overrode
the weighted draw with a fixed pool entry.

Pick a symbol that differs from the repeated one and skip the fixup for
single-character texts.
`(0x0032, _) => typed('#', '~')` claimed the ISO key types `#`/`~` in
the Russian layout too, where neither character exists. The neighbouring
ISO key at 0x0064 is already layout-aware, so this was an oversight: on
an ISO board `#` and `~` entered the Russian symbol pool but could never
be produced, every occurrence was a guaranteed error, and the adaptive
weighting then sampled them about twice as often as anything else.

Give the key the Russian output of its ANSI sibling KC_BSLS.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants