mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 01:00:13 +02:00
- steam-keyboard-patch.nix -> vr-keyboard-extra-keys.nix (option
keyboard.vr.extraKeys), helper.mjs -> xdotool-helper.mjs;
homeManagerModules.steam-keyboard-patch stays as an alias.
- vr-keyboard: panel.js/unpatch-panel.js/relay.mjs ->
suggestions-panel/{patch,unpatch}.js + relay.mjs; decoder.js ->
swipe-decoder.js; build.nix split into dictionary.nix and check.nix.
- modules/lib -> modules/steam-ui-patches/lib (the UI patch library).
- docs/development.md: repository layout (what runs where), runtime names.
Patch names, user services and state files are unchanged.
230 lines
10 KiB
JavaScript
230 lines
10 KiB
JavaScript
// swipe-decoder.js: swipe path -> words. Evaluates to { VERSION, parseDict,
|
|
// layout, decode, ... }. SHARK2-style template matching (Kristensson & Zhai 2004):
|
|
// each word's ideal path runs through its key centres; candidates starting
|
|
// and ending near the path's ends, with every key near the path, are scored
|
|
// by point distances of the resampled paths (proportional and DTW), how
|
|
// closely the path passes their keys in order, path length and frequency.
|
|
// Distances are in key widths. ' and - are typed, not swiped.
|
|
(() => {
|
|
const VERSION = 3;
|
|
const N = 32; // resample points
|
|
const SKIP = new Set(["'", '-', '\u2019']);
|
|
// Cost weights and pruning limits (tuned with tests/swipe-decoder.test.mjs; distances in key widths).
|
|
const W = {
|
|
loc: 4.4, // mean point distance, proportional alignment
|
|
dtw: 2.0, // mean point distance, dynamic time warping
|
|
letters: 0.5, // mean letter-to-path distance, in order
|
|
extra: 1.0, // mean path-to-template distance
|
|
length: 0.5, // |log(path length / template length)|
|
|
freq: 0.3, // zipf frequency (0..8)
|
|
startSlack: 0.9, endSlack: 1.0, // start/end keys: beyond the nearest key
|
|
letterMax: 1.1, // max distance of any key from the path
|
|
smooth: 2, // path smoothing: +- samples of 0.1 key widths
|
|
shortlist: 150, // candidates that get the costly terms (dtw, extra)
|
|
};
|
|
|
|
// "word\tzipf*10\n..." -> { words, freq }
|
|
function parseDict(text) {
|
|
const words = [], freq = [];
|
|
for (const line of text.split('\n')) {
|
|
const i = line.indexOf('\t');
|
|
if (i <= 0) continue;
|
|
words.push(line.slice(0, i));
|
|
freq.push(+line.slice(i + 1) / 10);
|
|
}
|
|
return { words, freq };
|
|
}
|
|
|
|
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
function pathLength(pts) {
|
|
let l = 0;
|
|
for (let i = 1; i < pts.length; i++) l += dist(pts[i - 1], pts[i]);
|
|
return l;
|
|
}
|
|
// n points equally spaced along the polyline pts (flat [x0,y0,x1,y1,...]).
|
|
function resample(pts, n) {
|
|
const out = new Float64Array(n * 2);
|
|
if (pts.length === 1) { for (let i = 0; i < n; i++) { out[2 * i] = pts[0][0]; out[2 * i + 1] = pts[0][1]; } return out; }
|
|
const total = pathLength(pts);
|
|
if (total === 0) return resample([pts[0]], n);
|
|
const step = total / (n - 1);
|
|
let seg = 1, segStart = 0, segLen = dist(pts[0], pts[1]);
|
|
for (let i = 0; i < n; i++) {
|
|
const target = Math.min(i * step, total);
|
|
while (segStart + segLen < target && seg < pts.length - 1) { segStart += segLen; seg++; segLen = dist(pts[seg - 1], pts[seg]); }
|
|
const t = segLen > 0 ? (target - segStart) / segLen : 0;
|
|
out[2 * i] = pts[seg - 1][0] + (pts[seg][0] - pts[seg - 1][0]) * t;
|
|
out[2 * i + 1] = pts[seg - 1][1] + (pts[seg][1] - pts[seg - 1][1]) * t;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Keyboard layout: keys = { char: [centreX, centreY] } in any unit, unit =
|
|
// key width in that unit. A word's path uses its letters only (apostrophes
|
|
// and hyphens are typed but not swiped: "couldn't" = c-o-u-l-d-n-t, next to
|
|
// "couldnt" if the dictionary had it; both are separate candidates). Words
|
|
// with another character the layout lacks are left out. Returns an object for decode() (templates are built lazily).
|
|
function layout(dict, keys, unit) {
|
|
const centre = {};
|
|
for (const [c, p] of Object.entries(keys)) centre[c] = [p[0] / unit, p[1] / unit];
|
|
const chars = Object.keys(centre);
|
|
const buckets = new Map(); // first key + last key -> word indices
|
|
const seqs = new Array(dict.words.length); // collapsed key sequence per word
|
|
for (let i = 0; i < dict.words.length; i++) {
|
|
const w = dict.words[i].toLowerCase();
|
|
let seq = '', ok = true;
|
|
for (const c of w) {
|
|
if (SKIP.has(c)) continue; // "couldn't" is swiped as "couldnt"
|
|
if (!centre[c]) { ok = false; break; }
|
|
if (seq[seq.length - 1] !== c) seq += c;
|
|
}
|
|
if (!ok || [...seq].length < 2) continue;
|
|
seqs[i] = seq;
|
|
const cs = [...seq], b = cs[0] + cs[cs.length - 1];
|
|
let list = buckets.get(b);
|
|
if (!list) buckets.set(b, list = []);
|
|
list.push(i);
|
|
}
|
|
return { dict, centre, chars, buckets, seqs, templates: new Map() };
|
|
}
|
|
|
|
function template(lay, seq) {
|
|
let t = lay.templates.get(seq);
|
|
if (!t) {
|
|
const pts = [...seq].map((c) => lay.centre[c]);
|
|
t = { pts: resample(pts, N), length: pathLength(pts), keys: pts };
|
|
lay.templates.set(seq, t);
|
|
}
|
|
return t;
|
|
}
|
|
|
|
// Distance of (x, y) to the polyline pts ([[x, y], ...]).
|
|
function segDist(x, y, pts) {
|
|
let m = Infinity;
|
|
for (let i = 1; i < pts.length; i++) {
|
|
const [ax, ay] = pts[i - 1], [bx, by] = pts[i];
|
|
const dx = bx - ax, dy = by - ay, l2 = dx * dx + dy * dy;
|
|
const t = l2 ? Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / l2)) : 0;
|
|
m = Math.min(m, Math.hypot(x - ax - t * dx, y - ay - t * dy));
|
|
}
|
|
return m;
|
|
}
|
|
// Mean point distance of the resampled paths a and b under dynamic time
|
|
// warping (band of N/4), normalised by the warping path length.
|
|
const dtwRow = new Float64Array((N + 1) * (N + 1)), dtwLen = new Float64Array((N + 1) * (N + 1));
|
|
function dtw(a, b) {
|
|
const band = N >> 2, S = N + 1;
|
|
dtwRow.fill(Infinity); dtwRow[0] = 0; dtwLen[0] = 0;
|
|
for (let i = 1; i <= N; i++) {
|
|
for (let j = Math.max(1, i - band); j <= Math.min(N, i + band); j++) {
|
|
const d = Math.hypot(a[2 * i - 2] - b[2 * j - 2], a[2 * i - 1] - b[2 * j - 1]);
|
|
let k = (i - 1) * S + j - 1; // diagonal
|
|
if (dtwRow[(i - 1) * S + j] < dtwRow[k]) k = (i - 1) * S + j;
|
|
if (dtwRow[i * S + j - 1] < dtwRow[k]) k = i * S + j - 1;
|
|
dtwRow[i * S + j] = dtwRow[k] + d; dtwLen[i * S + j] = dtwLen[k] + 1;
|
|
}
|
|
}
|
|
return dtwRow[N * S + N] / dtwLen[N * S + N];
|
|
}
|
|
|
|
// Keys within `slack` key widths of the nearest key to p.
|
|
function nearKeys(lay, p, slack) {
|
|
const d = lay.chars.map((c) => [c, dist(lay.centre[c], p)]);
|
|
const min = Math.min(...d.map((x) => x[1]));
|
|
return d.filter((x) => x[1] <= min + slack).map((x) => x[0]);
|
|
}
|
|
|
|
const cost = (f) => W.loc * f.loc + W.dtw * f.dtw + W.letters * f.letters + W.extra * f.extra +
|
|
W.length * f.length - W.freq * f.freq;
|
|
// Laser shake adds length and wiggles: resample the path every 0.1 key
|
|
// widths and average over +-W.smooth samples (ends kept).
|
|
function smooth(path) {
|
|
const k = W.smooth;
|
|
if (!k || path.length < 3) return path;
|
|
const n = Math.max(2, Math.ceil(pathLength(path) / 0.1) + 1);
|
|
const r = resample(path, n), out = [];
|
|
for (let i = 0; i < n; i++) {
|
|
let x = 0, y = 0, c = 0;
|
|
for (let j = Math.max(0, i - k); j <= Math.min(n - 1, i + k); j++) { x += r[2 * j]; y += r[2 * j + 1]; c++; }
|
|
out.push(i === 0 || i === n - 1 ? [r[2 * i], r[2 * i + 1]] : [x / c, y / c]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// rawPath in the layout's unit before division by `unit`; returns
|
|
// [{ word, cost }] best first (opts.features: every candidate's cost terms).
|
|
function decode(lay, rawPath, opts = {}) {
|
|
const { max = 5, unit = 1 } = opts;
|
|
const path = smooth(rawPath.map((p) => [p[0] / unit, p[1] / unit]));
|
|
if (path.length < 2) return [];
|
|
const plen = pathLength(path);
|
|
const P = resample(path, N);
|
|
const dense = resample(path, Math.max(N, Math.ceil(plen * 8))); // ~8 points per key width
|
|
const denseN = dense.length / 2;
|
|
// Min distance from every key to the path (for pruning).
|
|
const keyDist = {};
|
|
for (const c of lay.chars) {
|
|
const [x, y] = lay.centre[c];
|
|
let m = Infinity;
|
|
for (let i = 0; i < denseN; i++) m = Math.min(m, Math.hypot(dense[2 * i] - x, dense[2 * i + 1] - y));
|
|
keyDist[c] = m;
|
|
}
|
|
const starts = nearKeys(lay, path[0], W.startSlack);
|
|
const ends = nearKeys(lay, path[path.length - 1], W.endSlack);
|
|
const out = [];
|
|
for (const s of starts) for (const e of ends) {
|
|
for (const i of lay.buckets.get(s + e) || []) {
|
|
const seq = lay.seqs[i];
|
|
let ok = true;
|
|
for (const c of seq) if (keyDist[c] > W.letterMax) { ok = false; break; }
|
|
if (!ok) continue;
|
|
const t = template(lay, seq);
|
|
let loc = 0;
|
|
for (let k = 0; k < N; k++) loc += Math.hypot(P[2 * k] - t.pts[2 * k], P[2 * k + 1] - t.pts[2 * k + 1]);
|
|
loc /= N;
|
|
// Letters in order: each key's nearest dense point at or after the
|
|
// previous letter's (greedy, monotone).
|
|
let from = 0, lettersCost = 0;
|
|
for (const [x, y] of t.keys) {
|
|
let best = Infinity, bi = from;
|
|
for (let j = from; j < denseN; j++) {
|
|
const d = Math.hypot(dense[2 * j] - x, dense[2 * j + 1] - y);
|
|
if (d < best) { best = d; bi = j; }
|
|
}
|
|
lettersCost += best; from = bi;
|
|
}
|
|
lettersCost /= t.keys.length;
|
|
out.push({ i, t, f: {
|
|
loc, letters: lettersCost, freq: lay.dict.freq[i],
|
|
length: Math.abs(Math.log((plen + 0.5) / (t.length + 0.5))), dtw: 0, extra: 0,
|
|
} });
|
|
}
|
|
}
|
|
// The costly terms only for the best candidates by the cheap ones.
|
|
let list = out;
|
|
if (!opts.features && out.length > W.shortlist) {
|
|
for (const c of out) c.cost = cost(c.f);
|
|
list = out.sort((a, b) => a.cost - b.cost).slice(0, W.shortlist);
|
|
}
|
|
for (const c of list) {
|
|
c.f.dtw = dtw(P, c.t.pts);
|
|
for (let k = 0; k < N; k++) c.f.extra += segDist(P[2 * k], P[2 * k + 1], c.t.keys);
|
|
c.f.extra /= N;
|
|
c.cost = cost(c.f);
|
|
}
|
|
if (opts.features) return list.map((c) => ({ word: lay.dict.words[c.i], f: c.f }));
|
|
list.sort((a, b) => a.cost - b.cost);
|
|
const res = [], seen = new Set();
|
|
for (const { i, cost } of list) {
|
|
const w = lay.dict.words[i];
|
|
if (seen.has(w)) continue;
|
|
seen.add(w);
|
|
res.push({ word: w, cost });
|
|
if (res.length >= max) break;
|
|
}
|
|
return res;
|
|
}
|
|
|
|
return { VERSION, N, W, cost, parseDict, layout, decode, resample };
|
|
})()
|