Files
lhns--steam-frame-nix/modules/vr-keyboard/corrector.js
T
Pierre Kisters ee020d39d5 VR keyboard: swipe typing, suggestions, Backspace drag (keyboard.vr.*)
Opt-in with steamFrame.keyboard.vr.enable; swipe, autocorrect and
completion suggestions, Backspace drag and haptics are on under it.
- vr-keyboard/patch.js (Steam UI): swipe decoder, a model of what the
  keyboard typed, suggestions that only replace what they typed, Backspace
  drag with word detents and retyping; checks its Steam internals by
  signature (signatures.json: vr-keyboard) and stays stock otherwise.
- vr-keyboard/panel.js (SteamVR systemui) + relay.mjs (user service
  vr-keyboard-relay): the suggestion strip as a dashboard panel below or
  above the keyboard (signatures: vr-keyboard-panel).
- Dictionary built from nixpkgs' wordfreq and hunspellDicts; default: the
  keyboard.layout language (de, fr, es, it, nl, pt, sv) plus English.
- Tests: keyboard.vr.checks and the flake check vr-keyboard.
2026-09-28 04:42:13 +02:00

122 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// corrector.js: suggestions for tap-typed words -- corrections of a finished
// word that isn't in the dictionary, completions of the word being typed.
// Evaluates to { VERSION, create }.
//
// Words are compared by their folded form (lowercase, no ' or -, ä ö ü ß as
// ae oe ue ss), so umlaut spellings and missing apostrophes are free.
// Corrections: weighted Damerau-Levenshtein <= maxDistance (neighbouring keys
// and swapped letters 0.5, other edits 1) against all words of similar
// length, prefiltered by letter counts (an edit changes them by at most 2);
// ranked by distance - 0.1 * zipf. A valid word still gets suggestions if a
// same-folded variant is >= 1 zipf more frequent ("cant" -> "can't").
// Memory: one folded string + 26 bytes per word.
(() => {
const VERSION = 2;
const fold = (w) => w.toLowerCase().replace(/['’-]/g, '')
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss');
const VARIANT_GAP = 1.0;
function create(dict) {
const n = dict.words.length;
const folded = dict.words.map(fold);
const lower = new Map(), byFold = new Map(), byLen = [];
const bags = new Uint8Array(n * 26);
for (let i = 0; i < n; i++) {
const f = folded[i], l = dict.words[i].toLowerCase();
if (!lower.has(l) || dict.freq[i] > dict.freq[lower.get(l)]) lower.set(l, i);
(byFold.get(f) ?? byFold.set(f, []).get(f)).push(i);
(byLen[f.length] ??= []).push(i);
for (let j = 0; j < f.length; j++) { const c = f.charCodeAt(j) - 97; if (c >= 0 && c < 26) bags[i * 26 + c]++; }
}
const sorted = Int32Array.from({ length: n }, (_, i) => i).sort((a, b) => (folded[a] < folded[b] ? -1 : folded[a] > folded[b] ? 1 : 0));
// Substitution costs: 0.5 between keys closer than 1.3 key widths.
const sub = new Float64Array(128 * 128).fill(1);
function setLayout(centres) {
sub.fill(1);
const ks = Object.keys(centres || {}).filter((k) => k.charCodeAt(0) < 128);
for (const a of ks) for (const b of ks) {
if (a !== b && Math.hypot(centres[a][0] - centres[b][0], centres[a][1] - centres[b][1]) < 1.3) sub[a.charCodeAt(0) * 128 + b.charCodeAt(0)] = 0.5;
}
}
// Weighted optimal-string-alignment distance; Infinity once a row exceeds max.
const R0 = new Float64Array(64), R1 = new Float64Array(64), R2 = new Float64Array(64);
function distance(s, t, max) {
const m = s.length, k = t.length;
if (Math.abs(m - k) > max || m > 62 || k > 62) return Infinity;
let prev2 = R2, prev = R0, cur = R1;
for (let j = 0; j <= k; j++) prev[j] = j;
for (let i = 1; i <= m; i++) {
cur[0] = i;
let rowMin = i;
for (let j = 1; j <= k; j++) {
const a = s.charCodeAt(i - 1), b = t.charCodeAt(j - 1);
let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a === b ? 0 : sub[(a & 127) * 128 + (b & 127)]));
if (i > 1 && j > 1 && a === t.charCodeAt(j - 2) && s.charCodeAt(i - 2) === b) v = Math.min(v, prev2[j - 2] + 0.5);
cur[j] = v;
if (v < rowMin) rowMin = v;
}
if (rowMin > max) return Infinity;
[prev2, prev, cur] = [prev, cur, prev2];
}
return prev[k];
}
// Casing as typed: ALL CAPS, Capitalised, else the dictionary's ("haus" -> "Haus").
function shapeLike(typed, w) {
if (typed.length > 1 && typed === typed.toUpperCase() && typed !== typed.toLowerCase()) return w.toUpperCase();
if (typed[0] !== typed[0].toLowerCase()) return w[0].toUpperCase() + w.slice(1);
return w;
}
// Up to max distinct words (as shaped), in the given order, without typed.
function pickWords(typed, indices, max) {
const out = [], seen = new Set([typed.toLowerCase()]);
for (const i of indices) {
if (out.length >= max) break;
const w = shapeLike(typed, dict.words[i]);
if (!seen.has(w.toLowerCase())) { seen.add(w.toLowerCase()); out.push(w); }
}
return out;
}
function corrections(typed, { maxDistance = 2, max = 4 } = {}) {
const f = fold(typed);
if (max <= 0 || f.length < 2 || !/^[a-z]+$/.test(f)) return [];
const own = lower.get(typed.toLowerCase());
const cost = new Map(); // word index -> distance
for (const i of byFold.get(f) || []) if (own === undefined || dict.freq[i] >= dict.freq[own] + VARIANT_GAP) cost.set(i, 0.1);
if (own === undefined) {
const bag = new Int16Array(26);
for (let j = 0; j < f.length; j++) bag[f.charCodeAt(j) - 97]++;
for (let len = Math.max(1, f.length - maxDistance); len <= f.length + maxDistance; len++) {
for (const i of byLen[len] || []) {
if (cost.has(i)) continue;
let diff = 0;
for (let c = 0, o = i * 26; c < 26 && diff <= 2 * maxDistance; c++) diff += Math.abs(bags[o + c] - bag[c]);
if (diff > 2 * maxDistance) continue;
const d = distance(f, folded[i], maxDistance);
if (d <= maxDistance) cost.set(i, d);
}
}
}
const score = (i) => cost.get(i) - 0.1 * dict.freq[i];
return pickWords(typed, [...cost.keys()].sort((a, b) => score(a) - score(b)), max);
}
function completions(prefix, { max = 5 } = {}) {
const f = fold(prefix);
if (max <= 0 || !/^[a-z]+$/.test(f)) return [];
let lo = 0, hi = n;
while (lo < hi) { const mid = (lo + hi) >> 1; if (folded[sorted[mid]] < f) lo = mid + 1; else hi = mid; }
const hits = [];
for (let j = lo; j < n && folded[sorted[j]].startsWith(f); j++) hits.push(sorted[j]);
return pickWords(prefix, hits.sort((a, b) => dict.freq[b] - dict.freq[a]), max);
}
return { corrections, completions, setLayout, distance };
}
return { VERSION, create, fold };
})()