diff --git a/frontend/__tests__/utils/fingers.spec.ts b/frontend/__tests__/utils/fingers.spec.ts new file mode 100644 index 000000000000..997f7a1ad339 --- /dev/null +++ b/frontend/__tests__/utils/fingers.spec.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from "vitest"; +import { LayoutObject } from "@monkeytype/schemas/layouts"; +import { + buildDrillWords, + buildTrainingPool, + FingerFullNames, + FingerNames, + getFingerLetters, + mixWithNormalWords, + resolveLayoutName, + scoreWord, +} from "../../src/ts/utils/fingers"; + +const qwerty: LayoutObject = { + keymapShowTopRow: false, + type: "ansi", + keys: { + row1: [ + ["`", "~"], + ["1", "!"], + ["2", "@"], + ["3", "#"], + ["4", "$"], + ["5", "%"], + ["6", "^"], + ["7", "&"], + ["8", "*"], + ["9", "("], + ["0", ")"], + ["-", "_"], + ["=", "+"], + ], + row2: [ + ["q", "Q"], + ["w", "W"], + ["e", "E"], + ["r", "R"], + ["t", "T"], + ["y", "Y"], + ["u", "U"], + ["i", "I"], + ["o", "O"], + ["p", "P"], + ["[", "{"], + ["]", "}"], + ["\\", "|"], + ], + row3: [ + ["a", "A"], + ["s", "S"], + ["d", "D"], + ["f", "F"], + ["g", "G"], + ["h", "H"], + ["j", "J"], + ["k", "K"], + ["l", "L"], + [";", ":"], + ["'", '"'], + ], + row4: [ + ["z", "Z"], + ["x", "X"], + ["c", "C"], + ["v", "V"], + ["b", "B"], + ["n", "N"], + ["m", "M"], + [",", "<"], + [".", ">"], + ["/", "?"], + ], + row5: [[" "]], + }, +}; + +describe("fingers", () => { + it("has a full display name for every finger", () => { + for (const finger of FingerNames) { + const hand = finger.startsWith("left") ? "left" : "right"; + expect(FingerFullNames[finger].startsWith(hand)).toBe(true); + } + }); + + describe("resolveLayoutName", () => { + it("maps the default layout to qwerty", () => { + expect(resolveLayoutName("default")).toBe("qwerty"); + expect(resolveLayoutName("colemak")).toBe("colemak"); + }); + }); + + describe("getFingerLetters", () => { + it("maps the standard qwerty columns to the right fingers", () => { + const letters = getFingerLetters(qwerty); + + expect(letters.leftPinky.sort()).toEqual(["a", "q", "z"]); + expect(letters.leftRing.sort()).toEqual(["s", "w", "x"]); + expect(letters.leftMiddle.sort()).toEqual(["c", "d", "e"]); + expect(letters.leftIndex.sort()).toEqual(["b", "f", "g", "r", "t", "v"]); + expect(letters.rightIndex.sort()).toEqual(["h", "j", "m", "n", "u", "y"]); + expect(letters.rightMiddle.sort()).toEqual(["i", "k"]); + expect(letters.rightRing.sort()).toEqual(["l", "o"]); + expect(letters.rightPinky.sort()).toEqual(["p"]); + }); + + it("ignores digits, symbols and space", () => { + const letters = getFingerLetters(qwerty); + const all = Object.values(letters).flat(); + + for (const char of all) { + expect(char.toLowerCase()).not.toBe(char.toUpperCase()); + } + expect(all).not.toContain(";"); + expect(all).not.toContain(" "); + }); + + it("picks up letters on the number row (e.g. azerty accents)", () => { + const accented: LayoutObject = { + ...qwerty, + keys: { + ...qwerty.keys, + row1: [ + ...qwerty.keys.row1.slice(0, 2), + ["é", "2"], + ...qwerty.keys.row1.slice(3), + ], + }, + }; + + const letters = getFingerLetters(accented); + expect(letters.leftRing).toContain("é"); + }); + + it("shifts the bottom row for iso layouts", () => { + const iso: LayoutObject = { + keymapShowTopRow: false, + type: "iso", + keys: { + row1: qwerty.keys.row1, + row2: qwerty.keys.row2.slice(0, 12), + row3: [...qwerty.keys.row3, ["#", "~"]], + // iso keyboards have an extra key before z + row4: [["\\", "|"], ...qwerty.keys.row4], + row5: [[" "]], + }, + }; + + const letters = getFingerLetters(iso); + expect(letters.leftPinky.sort()).toEqual(["a", "q", "z"]); + expect(letters.leftRing.sort()).toEqual(["s", "w", "x"]); + }); + + it("maps matrix layouts positionally", () => { + const matrix: LayoutObject = { + keymapShowTopRow: false, + matrixShowRightColumn: false, + type: "matrix", + keys: { + row1: [], + row2: qwerty.keys.row2.slice(0, 10), + row3: qwerty.keys.row3.slice(0, 10), + row4: [["z"], ["x"], ["c"], ["v"]], + row5: [], + }, + }; + + const letters = getFingerLetters(matrix); + expect(letters.leftPinky.sort()).toEqual(["a", "q", "z"]); + expect(letters.leftRing.sort()).toEqual(["s", "w", "x"]); + expect(letters.rightPinky.sort()).toEqual(["p"]); + }); + }); + + describe("scoreWord", () => { + it("returns the fraction of letters typed with target fingers", () => { + const targets = new Set(["f", "j"]); + + expect(scoreWord("fjfj", targets)).toBe(1); + expect(scoreWord("fear", targets)).toBe(0.25); + expect(scoreWord("hello", targets)).toBe(0); + }); + + it("ignores non letter characters", () => { + const targets = new Set(["t", "d", "o", "n"]); + + // apostrophe is not a letter, so 4/4 letters hit + expect(scoreWord("don't", targets)).toBe(1); + expect(scoreWord("123", targets)).toBe(0); + expect(scoreWord("", targets)).toBe(0); + }); + + it("is case insensitive", () => { + expect(scoreWord("FJ", new Set(["f", "j"]))).toBe(1); + }); + }); + + describe("buildDrillWords", () => { + it("combines the letter with the other trained letters", () => { + expect(buildDrillWords("q", ["a", "z"], 4)).toEqual([ + "qa", + "qz", + "aq", + "zq", + ]); + }); + + it("repeats the letter when it is trained alone", () => { + expect(buildDrillWords("p", [], 3)).toEqual(["pp", "ppp", "pppp"]); + }); + }); + + describe("buildTrainingPool", () => { + it("falls back to letter drills when the language has no words", () => { + const { pool, drilledLetters } = buildTrainingPool( + ["hello", "world"], + new Set(["q", "z"]), + ); + + expect(pool.length).toBeGreaterThan(0); + expect(pool).toContain("qz"); + expect(pool).toContain("zq"); + expect(drilledLetters.sort()).toEqual(["q", "z"]); + // every entry still trains the selected letters + for (const word of new Set(pool)) { + expect(scoreWord(word, new Set(["q", "z"]))).toBeGreaterThan(0); + } + }); + + it("tops up letters with too few real words using drills", () => { + // one real z word, plenty of a words + const words = [ + ...Array.from({ length: 50 }, (_, i) => `ab${i}`), + "zebra", + ]; + const { pool, drilledLetters } = buildTrainingPool( + words, + new Set(["a", "z"]), + ); + + expect(pool).toContain("zebra"); + expect(pool).toContain("za"); + expect(drilledLetters).toContain("z"); + expect(drilledLetters).not.toContain("a"); + }); + + it("repeats stronger matches more often", () => { + const targets = new Set(["f", "j"]); + const { pool } = buildTrainingPool(["fjfj", "fear"], targets); + + const fullMatch = pool.filter((word) => word === "fjfj").length; + const weakMatch = pool.filter((word) => word === "fear").length; + + // fjfj covers both letter buckets with weight 5 each, + // fear only appears in the f bucket with weight 2 + expect(fullMatch).toBe(10); + expect(weakMatch).toBe(2); + expect(fullMatch).toBeGreaterThan(weakMatch); + }); + + it("caps the number of distinct words at the pool size", () => { + const words = Array.from({ length: 500 }, (_, i) => `fa${i}`); + const { pool } = buildTrainingPool(words, new Set(["f"]), 100); + + expect(new Set(pool).size).toBeLessThanOrEqual(100); + }); + + it("does not let common letters crowd out rare ones", () => { + // lots of words exercising "a", a single word each for "q" and "z" + const words = [ + ...Array.from({ length: 150 }, (_, i) => `ab${i}`), + "queen", + "zone", + ]; + + const { pool } = buildTrainingPool(words, new Set(["q", "a", "z"]), 100); + + expect(pool).toContain("queen"); + expect(pool).toContain("zone"); + // the "a" bucket only gets its per-letter share of the pool + const distinctAWords = new Set(pool.filter((w) => w.startsWith("ab"))); + expect(distinctAWords.size).toBeLessThanOrEqual(Math.ceil(100 / 3)); + }); + }); + + describe("mixWithNormalWords", () => { + const pool = ["qa", "az", "qaz"]; + const words = ["the", "be", "of"]; + + it("returns the pool untouched for frequency 1", () => { + expect(mixWithNormalWords(pool, words, 1)).toEqual(pool); + }); + + it("adds one normal word per training word for frequency 2", () => { + const mixed = mixWithNormalWords(pool, words, 2); + expect(mixed).toHaveLength(6); + expect(mixed.slice(3)).toEqual(["the", "be", "of"]); + }); + + it("adds two normal words per training word for frequency 3", () => { + const mixed = mixWithNormalWords(pool, words, 3); + expect(mixed).toHaveLength(9); + }); + }); +}); diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index ac9a39c45a32..9765dcfdd344 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -83,6 +83,15 @@ export const commands: CommandsSubgroup = { showModal("ShareTestSettings"); }, }, + { + id: "fingerTraining", + display: "Finger training...", + icon: "fa-hand-paper", + exec: (): void => { + showModal("FingerTraining"); + }, + shouldFocusTestUI: false, + }, //account ...TagsCommands, diff --git a/frontend/src/ts/components/modals/FingerTrainingModal.tsx b/frontend/src/ts/components/modals/FingerTrainingModal.tsx new file mode 100644 index 000000000000..caa0e191aee1 --- /dev/null +++ b/frontend/src/ts/components/modals/FingerTrainingModal.tsx @@ -0,0 +1,140 @@ +import { createResource, createSignal, For, JSXElement, Show } from "solid-js"; + +import { Config } from "../../config/store"; +import { restartTestEvent } from "../../events/test"; +import { getTrainingFingers } from "../../states/finger-training"; +import { hideModalAndClearChain, isModalOpen } from "../../states/modals"; +import * as FingerTraining from "../../test/finger-training"; +import { + FingerDisplayNames, + FingerName, + FingerNames, + getFingerLetters, + resolveLayoutName, +} from "../../utils/fingers"; +import { getLayout } from "../../utils/json-data"; +import { AnimatedModal } from "../common/AnimatedModal"; +import { Button } from "../common/Button"; +import { Slider } from "../common/Slider"; + +// module level so the selection is kept while the page is open +const [selectedFingers, setSelectedFingers] = createSignal([]); +const [frequency, setFrequency] = createSignal(1); + +const frequencyLabels: Record = { + 1: "every word", + 2: "every 2nd word", + 3: "every 3rd word", +}; + +export function FingerTrainingModal(): JSXElement { + const [starting, setStarting] = createSignal(false); + + const [fingerLetters, { refetch: refetchFingerLetters }] = createResource( + // only fetch once the modal has been opened, not at app boot + () => + isModalOpen("FingerTraining") + ? resolveLayoutName(Config.layout) + : undefined, + async (layoutName) => getFingerLetters(await getLayout(layoutName)), + ); + + const lettersFor = (finger: FingerName): string => + fingerLetters.state === "ready" ? fingerLetters()[finger].join(" ") : "..."; + + const toggleFinger = (finger: FingerName): void => { + setSelectedFingers((current) => + current.includes(finger) + ? current.filter((f) => f !== finger) + : [...current, finger], + ); + }; + + const start = async (): Promise => { + if (starting()) return; + setStarting(true); + const started = await FingerTraining.init(selectedFingers(), frequency()); + setStarting(false); + if (started) { + // clear the whole chain so closing does not bring the commandline back + hideModalAndClearChain("FingerTraining"); + restartTestEvent.dispatch({}); + } + }; + + const stopTraining = (): void => { + if (FingerTraining.stop()) { + hideModalAndClearChain("FingerTraining"); + restartTestEvent.dispatch({}); + } + }; + + const hand = (fingers: FingerName[], title: string): JSXElement => ( +
+
{title}
+
+ + {(finger) => ( + + )} + +
+
+ ); + + return ( + { + // a failed layout fetch would otherwise leave the letters stuck + if (fingerLetters.error !== undefined) void refetchFingerLetters(); + }} + > +
+

+ Pick the fingers you want to train. Tests will still use normal words, + but ones that exercise the selected fingers will show up + disproportionately more. Training stays active across restarts - stop + it here (or change the mode) to go back to your usual settings. +

+
+ {hand(FingerNames.slice(0, 4), "left hand")} + {hand(FingerNames.slice(4), "right hand")} +
+
+
training word frequency
+ setFrequency(value)} + text={(value) => `1/${value}`} + /> +
{frequencyLabels[frequency()]}
+
+ + 0}> + + +
+
+ ); +} diff --git a/frontend/src/ts/components/modals/Modals.tsx b/frontend/src/ts/components/modals/Modals.tsx index 81b34905e493..5928ddc5ec8b 100644 --- a/frontend/src/ts/components/modals/Modals.tsx +++ b/frontend/src/ts/components/modals/Modals.tsx @@ -6,6 +6,7 @@ import { CookiesModal } from "./CookiesModal"; import { CustomTestDurationModal } from "./CustomTestDurationModal"; import { CustomTextModal } from "./CustomTextModal"; import { CustomWordAmountModal } from "./CustomWordAmountModal"; +import { FingerTrainingModal } from "./FingerTrainingModal"; import { LastSignedOutResultModal } from "./LastSignedOutResultModal"; import { MobileTestConfigModal } from "./MobileTestConfigModal"; import { AddPresetModal } from "./preset/AddPresetModal"; @@ -42,6 +43,7 @@ export function Modals(): JSXElement { + ); } diff --git a/frontend/src/ts/components/pages/settings/SettingsPage.tsx b/frontend/src/ts/components/pages/settings/SettingsPage.tsx index 9d098917df63..5a9c748eaee0 100644 --- a/frontend/src/ts/components/pages/settings/SettingsPage.tsx +++ b/frontend/src/ts/components/pages/settings/SettingsPage.tsx @@ -96,6 +96,25 @@ export function SettingsPage(): JSXElement { + { + showModal("FingerTraining"); + }} + > + open + + } + />
diff --git a/frontend/src/ts/components/pages/test/modes-notice/TestModesNotice.tsx b/frontend/src/ts/components/pages/test/modes-notice/TestModesNotice.tsx index 11b2bae3222a..5156d45d13ed 100644 --- a/frontend/src/ts/components/pages/test/modes-notice/TestModesNotice.tsx +++ b/frontend/src/ts/components/pages/test/modes-notice/TestModesNotice.tsx @@ -8,7 +8,9 @@ import { getFormatting, showCommandLineForConfig, } from "../../../../states/core"; +import { getTrainingFingers } from "../../../../states/finger-training"; import { hotkeys } from "../../../../states/hotkeys"; +import { showModal } from "../../../../states/modals"; import { getFocus, getLoadedChallenge, @@ -20,6 +22,7 @@ import { } from "../../../../states/test"; import { getActiveFunboxNames } from "../../../../test/funbox/list"; import { cn } from "../../../../utils/cn"; +import { FingerFullNames } from "../../../../utils/fingers"; import { getLanguageDisplayString, replaceUnderscoresWithSpaces, @@ -56,6 +59,7 @@ export function TestModesNotice() { + @@ -321,6 +325,23 @@ function Funbox() { ); } +function FingerTrainingNotice() { + const fingers = createMemo(() => { + const active = getTrainingFingers(); + if (active.length === 0) return undefined; + return active.map((finger) => FingerFullNames[finger]).join(", "); + }); + + return ( + showModal("FingerTraining")} + text={`training: ${fingers()}`} + /> + ); +} + function ConfidenceMode() { return ( ([]); +export { getTrainingFingers }; + +// settings to restore when the session ends. the custom text part is also +// mirrored to localStorage because starting a session overwrites the +// persisted custom text - without the mirror, a page reload mid-session +// would lose the user's custom text forever. +let savedSettings: TrainingSavedSettings | null = null; + +const customTextBackupLS = new LocalStorageWithSchema({ + key: "fingerTrainingCustomTextBackup", + schema: CustomTextSettingsSchema.nullable(), + fallback: null, +}); + +export function isTrainingSessionActive(): boolean { + return savedSettings !== null; +} + +export function getTrainingSavedSettings(): TrainingSavedSettings | null { + return savedSettings; +} + +/** activates (or re-configures) the session; backs up the custom text once */ +export function startTrainingSession( + saved: TrainingSavedSettings, + fingers: FingerName[], +): void { + if (savedSettings === null && saved.customText !== null) { + customTextBackupLS.set(saved.customText); + } + savedSettings = saved; + setTrainingFingers(fingers); +} + +/** + * Ends the session and returns the settings to restore (null when no session + * was active). The caller decides how much of them to put back. + */ +export function consumeTrainingSession(): TrainingSavedSettings | null { + const saved = savedSettings; + if (saved === null) return null; + savedSettings = null; + setTrainingFingers([]); + customTextBackupLS.set(null); + return saved; +} + +// heal a session lost to a page reload: the mode change was nosave so the +// config came back on its own, but the persisted custom text still holds +// the training pool - put the user's real custom text back +const leftoverBackup = customTextBackupLS.get(); +if (leftoverBackup !== null) { + CustomText.setData(leftoverBackup); + customTextBackupLS.set(null); +} diff --git a/frontend/src/ts/states/modals.ts b/frontend/src/ts/states/modals.ts index a82240d98129..550f552401f9 100644 --- a/frontend/src/ts/states/modals.ts +++ b/frontend/src/ts/states/modals.ts @@ -32,7 +32,8 @@ export type ModalId = | "EditProfile" | "ViewApeKey" | "LastSignedOutResult" - | "StreakHourOffset"; + | "StreakHourOffset" + | "FingerTraining"; export type ModalVisibility = { visible: boolean; diff --git a/frontend/src/ts/test/custom-text.ts b/frontend/src/ts/test/custom-text.ts index 43271d92b57b..c8f8f6693c99 100644 --- a/frontend/src/ts/test/custom-text.ts +++ b/frontend/src/ts/test/custom-text.ts @@ -131,6 +131,10 @@ export function getData(): CustomTextSettings { return customTextSettings.get(); } +export function setData(settings: CustomTextSettings): void { + customTextSettings.set(settings); +} + export function getCustomText(name: string, long = false): string[] { if (long) { const customTextLong = getLocalStorageLong(); diff --git a/frontend/src/ts/test/finger-training.ts b/frontend/src/ts/test/finger-training.ts new file mode 100644 index 000000000000..b1765cef77cc --- /dev/null +++ b/frontend/src/ts/test/finger-training.ts @@ -0,0 +1,141 @@ +import { Mode } from "@monkeytype/schemas/shared"; + +import { Config } from "../config/store"; +import { setConfig } from "../config/setters"; +import { configEvent } from "../events/config"; +import { setCustomTextIndicator } from "../states/core"; +import { + consumeTrainingSession, + getTrainingSavedSettings, + startTrainingSession, +} from "../states/finger-training"; +import { showNoticeNotification } from "../states/notifications"; +import { + buildTrainingPool, + FingerName, + getFingerLetters, + mixWithNormalWords, + resolveLayoutName, +} from "../utils/fingers"; +import { getCurrentLanguage, getLayout } from "../utils/json-data"; +import * as CustomText from "./custom-text"; +import { before, resetBefore } from "./practise-words"; + +// distinguishes our own mode changes from the user's in the configEvent +// subscription below +let changingModeInternally = false; + +function setModeInternally(mode: Mode, nosave: boolean): boolean { + changingModeInternally = true; + const success = setConfig("mode", mode, nosave ? { nosave: true } : {}); + changingModeInternally = false; + return success; +} + +export async function init( + fingers: FingerName[], + // 1 = every word trains the fingers, 2 = every other word, 3 = every third + frequency = 1, +): Promise { + if (fingers.length === 0) return false; + + let targetLetters: Set; + let languageWords: string[]; + try { + const [layout, language] = await Promise.all([ + getLayout(resolveLayoutName(Config.layout)), + getCurrentLanguage(Config.language), + ]); + const fingerLetters = getFingerLetters(layout); + targetLetters = new Set(fingers.flatMap((finger) => fingerLetters[finger])); + languageWords = language.words; + } catch (error) { + console.error("Failed to start finger training", error); + showNoticeNotification("Could not load layout or language data"); + return false; + } + + if (targetLetters.size === 0) { + showNoticeNotification( + "The selected fingers have no letters on the current layout", + ); + return false; + } + + const { pool, drilledLetters } = buildTrainingPool( + languageWords, + targetLetters, + ); + + // when a practise words session is pending revert, absorb its original + // settings so stopping the training restores the real ones + const saved = getTrainingSavedSettings() ?? { + mode: before.mode ?? Config.mode, + customText: before.mode !== null ? before.customText : CustomText.getData(), + }; + const limitValue = + saved.mode === "words" && Config.words > 0 && Config.words <= 200 + ? Config.words + : 50; + + // nothing may be persisted before the mode switch is known to be allowed + // (it can be refused, e.g. mid-test with the no_quit funbox) + if (!setModeInternally("custom", true)) { + showNoticeNotification( + "Could not start finger training - unable to switch to custom mode right now", + ); + return false; + } + resetBefore(); + + CustomText.setData({ + text: mixWithNormalWords(pool, languageWords, frequency), + mode: "random", + limit: { value: limitValue, mode: "word" }, + pipeDelimiter: false, + }); + setCustomTextIndicator({ name: "finger training", isLong: false }); + startTrainingSession(saved, fingers); + + if (drilledLetters.length > 0) { + showNoticeNotification( + `Few or no words use ${drilledLetters.join(", ")} in the current language - added short letter drills. A larger word list (e.g. english 1k) gives more real words.`, + ); + } + + return true; +} + +export function stop(): boolean { + const saved = getTrainingSavedSettings(); + if (saved === null) return false; + + // restore the mode first - if it is refused (e.g. no_quit funbox mid-test) + // the session stays intact so stopping can be retried + if (!setModeInternally(saved.mode, false)) { + showNoticeNotification( + "Could not stop finger training - finish or restart the current test first", + ); + return false; + } + + consumeTrainingSession(); + if (saved.customText !== null) { + CustomText.setData(saved.customText); + } + setCustomTextIndicator(undefined); + showNoticeNotification("Reverting to previous settings."); + return true; +} + +// the user changing the mode themselves also ends the session: keep the mode +// they picked, but put the overwritten custom text and indicator back +configEvent.subscribe(({ key }) => { + if (key !== "mode" || changingModeInternally) return; + const saved = consumeTrainingSession(); + if (saved === null) return; + if (saved.customText !== null) { + CustomText.setData(saved.customText); + } + setCustomTextIndicator(undefined); +}); diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index ecc2a1162474..0dd32085461c 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -13,6 +13,7 @@ import { getWordBurstHistory, } from "./events/stats"; import { setCustomTextIndicator } from "../states/core"; +import { consumeTrainingSession } from "../states/finger-training"; import { lastEventLog } from "./test-state"; type Before = { @@ -149,12 +150,18 @@ export function init( } }); - const mode = before.mode ?? Config.mode; + // a finger training session may be active - absorb its original settings + // so this practise session reverts to those instead of the training state + const trainingSettings = consumeTrainingSession(); + + const mode = trainingSettings?.mode ?? before.mode ?? Config.mode; const punctuation = before.punctuation ?? Config.punctuation; const numbers = before.numbers ?? Config.numbers; let customText = null; - if (Config.mode === "custom") { + if (trainingSettings !== null) { + customText = trainingSettings.customText; + } else if (Config.mode === "custom") { customText = CustomText.getData(); } diff --git a/frontend/src/ts/utils/fingers.ts b/frontend/src/ts/utils/fingers.ts new file mode 100644 index 000000000000..7f898f1d63ce --- /dev/null +++ b/frontend/src/ts/utils/fingers.ts @@ -0,0 +1,331 @@ +import { LayoutObject } from "@monkeytype/schemas/layouts"; + +import { Keycode } from "../constants/keys"; +import { layoutKeyToKeycode } from "./key-converter"; +import { isLetter } from "./strings"; + +export const FingerNames = [ + "leftPinky", + "leftRing", + "leftMiddle", + "leftIndex", + "rightIndex", + "rightMiddle", + "rightRing", + "rightPinky", +] as const; + +export type FingerName = (typeof FingerNames)[number]; + +export const FingerDisplayNames: Record = { + leftPinky: "pinky", + leftRing: "ring", + leftMiddle: "middle", + leftIndex: "index", + rightIndex: "index", + rightMiddle: "middle", + rightRing: "ring", + rightPinky: "pinky", +}; + +export const FingerFullNames: Record = { + leftPinky: "left pinky", + leftRing: "left ring", + leftMiddle: "left middle", + leftIndex: "left index", + rightIndex: "right index", + rightMiddle: "right middle", + rightRing: "right ring", + rightPinky: "right pinky", +}; + +// standard touch typing finger ownership by physical key position. angle mod +// style fingerings (used with some *_iso layouts) are not modeled - those +// layouts get the standard physical-column interpretation. +const keycodeFingers: Partial> = { + Backquote: "leftPinky", + Digit1: "leftPinky", + Digit2: "leftRing", + Digit3: "leftMiddle", + Digit4: "leftIndex", + Digit5: "leftIndex", + Digit6: "rightIndex", + Digit7: "rightIndex", + Digit8: "rightMiddle", + Digit9: "rightRing", + Digit0: "rightPinky", + Minus: "rightPinky", + Equal: "rightPinky", + KeyQ: "leftPinky", + KeyW: "leftRing", + KeyE: "leftMiddle", + KeyR: "leftIndex", + KeyT: "leftIndex", + KeyY: "rightIndex", + KeyU: "rightIndex", + KeyI: "rightMiddle", + KeyO: "rightRing", + KeyP: "rightPinky", + BracketLeft: "rightPinky", + BracketRight: "rightPinky", + Backslash: "rightPinky", + KeyA: "leftPinky", + KeyS: "leftRing", + KeyD: "leftMiddle", + KeyF: "leftIndex", + KeyG: "leftIndex", + KeyH: "rightIndex", + KeyJ: "rightIndex", + KeyK: "rightMiddle", + KeyL: "rightRing", + Semicolon: "rightPinky", + Quote: "rightPinky", + IntlBackslash: "leftPinky", + KeyZ: "leftPinky", + KeyX: "leftRing", + KeyC: "leftMiddle", + KeyV: "leftIndex", + KeyB: "leftIndex", + KeyN: "rightIndex", + KeyM: "rightIndex", + Comma: "rightMiddle", + Period: "rightRing", + Slash: "rightPinky", +}; + +/** the layout name to load for a given config value */ +export function resolveLayoutName(layout: string): string { + return layout === "default" ? "qwerty" : layout; +} + +/** + * Derives which letters each finger is responsible for from a layout, using + * standard touch typing conventions. Key positions are resolved through the + * shared layoutKeyToKeycode converter (which owns the iso row quirks). Only + * letters are included - digits, symbols and space (thumbs) are ignored. + */ +export function getFingerLetters( + layout: LayoutObject, +): Record { + const result = Object.fromEntries( + FingerNames.map((finger) => [finger, [] as string[]]), + ) as Record; + + const rows = [ + layout.keys.row1, + layout.keys.row2, + layout.keys.row3, + layout.keys.row4, + ]; + for (const row of rows) { + for (const keyChars of row) { + for (const char of keyChars) { + if (!isLetter(char)) continue; + const keycode = layoutKeyToKeycode(char, layout); + // letter positions the converter can't name (right-edge extras like + // the iso key next to enter) are pinky territory + const finger = + (keycode === undefined ? undefined : keycodeFingers[keycode]) ?? + "rightPinky"; + const lower = char.toLowerCase(); + if (!result[finger].includes(lower)) { + result[finger].push(lower); + } + } + } + } + + return result; +} + +/** counts letters in a word and how many of them satisfy the target check */ +function countLetters( + lowercaseWord: string, + isTarget: (char: string) => boolean, +): { letters: number; hits: number } { + let letters = 0; + let hits = 0; + for (const char of lowercaseWord) { + if (!isLetter(char)) continue; + letters++; + if (isTarget(char)) hits++; + } + return { letters, hits }; +} + +/** + * fraction of a word's letters that are typed with the target fingers. + * non-letter characters are ignored, words without letters score 0. + */ +export function scoreWord(word: string, targetLetters: Set): number { + const { letters, hits } = countLetters(word.toLowerCase(), (char) => + targetLetters.has(char), + ); + if (letters === 0) return 0; + return hits / letters; +} + +/** + * short deterministic drill tokens for a letter that has no (or few) real + * words in the language, combined with the other letters being trained + */ +export function buildDrillWords( + letter: string, + otherLetters: string[], + count: number, +): string[] { + const drills: string[] = []; + if (otherLetters.length === 0) { + drills.push(letter.repeat(2), letter.repeat(3), letter.repeat(4)); + } else { + const patterns = [ + (other: string): string => `${letter}${other}`, + (other: string): string => `${other}${letter}`, + (other: string): string => `${letter}${other}${letter}`, + (other: string): string => `${other}${letter}${other}`, + ]; + for (const pattern of patterns) { + for (const other of otherLetters) { + drills.push(pattern(other)); + } + } + } + return drills.slice(0, Math.max(0, count)); +} + +type PoolEntry = { word: string; density: number; score: number }; + +/** insert into an array kept sorted by density desc, score desc, capped */ +function boundedInsert( + bucket: PoolEntry[], + entry: PoolEntry, + cap: number, +): void { + if ( + bucket.length === cap && + (bucket[cap - 1] as PoolEntry).density >= entry.density + ) { + return; + } + let low = 0; + let high = bucket.length; + while (low < high) { + const mid = (low + high) >> 1; + const other = bucket[mid] as PoolEntry; + if ( + other.density > entry.density || + (other.density === entry.density && other.score >= entry.score) + ) { + low = mid + 1; + } else { + high = mid; + } + } + bucket.splice(low, 0, entry); + if (bucket.length > cap) bucket.pop(); +} + +/** + * Builds a training word pool biased towards the target fingers. The pool is + * built per target letter so that rare letters (q, z, ...) get the same share + * of the pool as common ones (a, e, ...) instead of being crowded out by + * whatever letter happens to be frequent in the language. Within a letter's + * share, words denser in that letter rank higher, words that exercise the + * target fingers more overall are repeated more often, and letters the + * language barely covers are topped up with short drills (reported in + * drilledLetters so the caller can tell the user). + */ +export function buildTrainingPool( + words: string[], + targetLetters: Set, + poolSize = 100, +): { pool: string[]; drilledLetters: string[] } { + const letters = [...targetLetters]; + if (letters.length === 0) return { pool: [], drilledLetters: [] }; + const perLetter = Math.max(1, Math.ceil(poolSize / letters.length)); + + const letterIndex = new Map(letters.map((letter, i) => [letter, i])); + const buckets: PoolEntry[][] = letters.map(() => []); + const counts = new Array(letters.length); + + // single pass over the word list, keeping a bounded top-perLetter bucket + // per letter, so huge languages don't allocate or sort per-letter copies + for (const word of words) { + const lower = word.toLowerCase(); + counts.fill(0); + let letterCount = 0; + let targetCount = 0; + for (const char of lower) { + if (!isLetter(char)) continue; + letterCount++; + const index = letterIndex.get(char); + if (index !== undefined) { + counts[index] = (counts[index] as number) + 1; + targetCount++; + } + } + if (letterCount === 0 || targetCount === 0) continue; + const score = targetCount / letterCount; + for (let i = 0; i < letters.length; i++) { + const count = counts[i] as number; + if (count === 0) continue; + boundedInsert( + buckets[i] as PoolEntry[], + { word, density: count / letterCount, score }, + perLetter, + ); + } + } + + const pool: string[] = []; + const drilledLetters: string[] = []; + for (let i = 0; i < letters.length; i++) { + const letter = letters[i] as string; + const bucket = buckets[i] as PoolEntry[]; + + // when the language barely covers a letter, top the bucket up with + // short drills so the letter still gets trained + const minFill = Math.min(8, perLetter); + if (bucket.length < minFill) { + drilledLetters.push(letter); + const others = letters.filter((l) => l !== letter); + const drills = buildDrillWords(letter, others, minFill - bucket.length); + for (const word of drills) { + bucket.push({ + word, + density: 1, + score: scoreWord(word, targetLetters), + }); + } + } + + for (const { word, score } of bucket) { + // weight 1..5 depending on how focused the word is on the target fingers + const weight = 1 + Math.round(score * 4); + for (let j = 0; j < weight; j++) { + pool.push(word); + } + } + } + return { pool, drilledLetters }; +} + +/** + * Dilutes the training pool with normal words from the language so that on + * average every `frequency`-th drawn word is a training word (1 = every word + * is a training word). Languages are ordered by frequency, so cycling from + * the start fills with the most common words. + */ +export function mixWithNormalWords( + pool: string[], + words: string[], + frequency: number, +): string[] { + const fillCount = pool.length * (Math.max(1, Math.round(frequency)) - 1); + if (fillCount === 0 || words.length === 0) return pool; + const filler: string[] = []; + for (let i = 0; i < fillCount; i++) { + filler.push(words[i % words.length] as string); + } + return [...pool, ...filler]; +} diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index ebe1103b674b..4472fe41d756 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -398,6 +398,15 @@ export function isSpace(char: string): boolean { return SPACE_CODE_POINTS.has(codePoint); } +/** + * Checks if a character is a letter in any script. + * @param char The character to check. + * @returns True if the character is a unicode letter, false otherwise. + */ +export function isLetter(char: string): boolean { + return /\p{L}/u.test(char); +} + export function replaceUnderscoresWithSpaces(text: string): string { return text.replace(/_/g, " "); }