mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 06:00:22 +02:00
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.
122 lines
5.7 KiB
JavaScript
122 lines
5.7 KiB
JavaScript
// 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 };
|
||
})()
|