mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 04:00:12 +02:00
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.
This commit is contained in:
1 parent
c672cffc9e
commit
ee020d39d5
17 files changed
+2371
No files matched your search
@@ -16,6 +16,7 @@
|
||||
portal = ./modules/portal.nix;
|
||||
keyboard-layout = ./modules/keyboard-layout.nix;
|
||||
steam-keyboard-patch = ./modules/steam-keyboard-patch.nix;
|
||||
vr-keyboard = ./modules/vr-keyboard.nix;
|
||||
hidden-apps = ./modules/hidden-apps.nix;
|
||||
steam-ui-patches = ./modules/steam-ui-patches.nix;
|
||||
launcher-menu = ./modules/launcher-menu.nix;
|
||||
@@ -37,6 +38,21 @@
|
||||
default = { imports = builtins.attrValues modules; };
|
||||
};
|
||||
|
||||
# Tests of the VR keyboard (text model, corrector, swipe decoder on the
|
||||
# default German + English dictionary): nix flake check
|
||||
checks = nixpkgs.lib.genAttrs [ "aarch64-linux" "x86_64-linux" ] (system: {
|
||||
vr-keyboard = (import ./modules/vr-keyboard/build.nix {
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
dictionary = {
|
||||
languages = map (l: l // { keepFrequentAbove = 4.0; }) [
|
||||
{ language = "de"; hunspell = "de_DE"; words = 60000; frequencyOffset = 0.0; }
|
||||
{ language = "en"; hunspell = "en_US"; words = 40000; frequencyOffset = -0.3; }
|
||||
];
|
||||
contractions = true; extraWords = [ ]; extraWordsFrequency = 5.0; extraWordFiles = [ ]; excludeWords = [ ];
|
||||
};
|
||||
}).checks;
|
||||
});
|
||||
|
||||
# nix flake init -t github:lhns/steam-frame-nix
|
||||
templates.default = {
|
||||
path = ./template;
|
||||
|
||||
@@ -446,6 +446,46 @@
|
||||
"expects": ["SetDockLocation(e){", "SetJustFloatedFromDashboard("]
|
||||
}
|
||||
}
|
||||
},
|
||||
"vr-keyboard": {
|
||||
"bundle": "steamui",
|
||||
"modules": {
|
||||
"vrStatus": {
|
||||
"module": { "includes": ["get VRKeyboardStatus()"] },
|
||||
"exports": { "holder": { "type": "object", "getters": ["VRKeyboardStatus"] } },
|
||||
"expects": ["bIsOpen:", "sOverlayKey", "unAppID"]
|
||||
},
|
||||
"keyboardManager": {
|
||||
"module": { "includes": ["SendClientPasteCommand(){", "HandleVirtualKeyDown(", "GetEnterKeyLabel("] },
|
||||
"exports": { "VirtualKeyboardManager": { "type": "class", "protoMethods": ["HandleVirtualKeyDown"] } },
|
||||
"expects": ["m_ActiveElementProps", "m_strDeadKeyPending"]
|
||||
},
|
||||
"keyboardComponent": {
|
||||
"checkOnly": true,
|
||||
"module": { "includes": ["TypeKeyInternal(", "m_mapTouched", "CancelLongPressTimer(){", "DismissLongPress(){", "ClearHoldTarget(){",
|
||||
"toggleStates"] },
|
||||
"expects": ["\"data-key\"", "\"data-key-row\"", "OnCommitText(", "OnForwardKeyEvent(", "HandleTouchEnd(", "ElementFromTouch("]
|
||||
}
|
||||
}
|
||||
},
|
||||
"vr-keyboard-panel": {
|
||||
"bundle": "vrwebui-systemui",
|
||||
"modules": {
|
||||
"sgApp": {
|
||||
"checkOnly": true,
|
||||
"module": { "includes": ["window.SGApp=this", "addEmbeddedPanelUVs("] },
|
||||
"expects": ["removeEmbeddedPanelUVs(", "t.forceLayoutUpdate=this.forceLayoutUpdate", "getEmbeddedIndex()"]
|
||||
},
|
||||
"keyboardMount": {
|
||||
"checkOnly": true,
|
||||
"module": { "includes": ["m_sKeyboardOverlayKey", "GrabHandleTint", "k_nKeyboardGrabTransformOffset"] },
|
||||
"expects": ["mountedId", "m_nKeyboardFlags"]
|
||||
},
|
||||
"overlayKeys": {
|
||||
"checkOnly": true,
|
||||
"module": { "includes": ["\"valve.steam.gamepadui.keyboard\""] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
# Swipe typing, suggestions and a Backspace drag for Steam's VR keyboard.
|
||||
# - vr-keyboard/patch.js (Steam UI, 8080): swipe gestures (decoder.js), a
|
||||
# model of what the keyboard typed (textmodel.js), suggestions (swipe
|
||||
# alternatives, corrections and completions, corrector.js; never changing
|
||||
# text by themselves), Backspace drag (left: delete with a detent at word
|
||||
# borders; right: retype), haptic ticks.
|
||||
# - The suggestion strip: over the number row ("inside"), or a SteamVR
|
||||
# dashboard panel above/below the keyboard (panel.js in systemui, 8087),
|
||||
# fed by relay.mjs (user service vr-keyboard-relay).
|
||||
# - Dictionary: built by gen-dict.py from wordfreq frequency lists, filtered
|
||||
# and cased by Hunspell (nixpkgs' hunspellDicts).
|
||||
# Works alongside keyboard.vr.extraKeys (both hook the same keyboard; this
|
||||
# one resets its text model on the extra keys it can't follow). Tests: the
|
||||
# checks below (also flake check `vr-keyboard`).
|
||||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
cfg = config.steamFrame.keyboard.vr;
|
||||
inherit (lib) mkOption mkEnableOption types;
|
||||
uiLib = import ./lib { inherit pkgs; };
|
||||
|
||||
on = cfg.enable && (cfg.swipe.enable || cfg.autocorrect.enable || cfg.completions.enable || cfg.backspaceDrag.enable);
|
||||
panel = on && cfg.suggestions.position != "inside";
|
||||
|
||||
# Default dictionary: the language of keyboard.layout (if listed here) plus
|
||||
# English, else English only.
|
||||
layoutLanguages = {
|
||||
de = "de_DE"; fr = "fr-moderne"; es = "es_ES"; it = "it_IT"; nl = "nl_NL"; pt = "pt_PT"; sv = "sv_SE";
|
||||
};
|
||||
layoutLanguage = let l = config.steamFrame.keyboard.layout; in
|
||||
if l != null && layoutLanguages ? ${builtins.head (lib.splitString "," l)} then builtins.head (lib.splitString "," l) else null;
|
||||
defaultLanguages =
|
||||
lib.optional (layoutLanguage != null)
|
||||
{ language = layoutLanguage; hunspell = layoutLanguages.${layoutLanguage}; words = 60000; frequencyOffset = 0.0; }
|
||||
++ [ { language = "en"; hunspell = "en_US"; words = if layoutLanguage != null then 40000 else 60000;
|
||||
frequencyOffset = if layoutLanguage != null then -0.3 else 0.0; } ];
|
||||
|
||||
built = import ./vr-keyboard/build.nix { inherit pkgs; inherit (cfg) dictionary; };
|
||||
patch = pkgs.runCommand "vr-keyboard.js" { nativeBuildInputs = [ pkgs.nodejs ]; } ''
|
||||
: ${built.checks}
|
||||
cp ${uiLib.mkPatch {
|
||||
name = "vr-keyboard";
|
||||
src = ./vr-keyboard/patch.js;
|
||||
opts = {
|
||||
swipe = cfg.swipe.enable;
|
||||
inherit (cfg.text) autoSpace bufferChars resetAfterIdleSeconds;
|
||||
inherit (cfg.suggestions) position count;
|
||||
autocorrect = cfg.autocorrect.enable;
|
||||
inherit (cfg.autocorrect) maxEditDistance;
|
||||
completions = cfg.completions.enable;
|
||||
inherit (cfg.completions) minPrefix;
|
||||
pixelsPerChar = if cfg.backspaceDrag.enable then cfg.backspaceDrag.pixelsPerChar else 0;
|
||||
inherit (cfg.backspaceDrag) wordDetentPixels;
|
||||
inherit (cfg) haptics;
|
||||
};
|
||||
extraArgs = [ ./vr-keyboard/decoder.js ./vr-keyboard/textmodel.js ./vr-keyboard/corrector.js built.dictionary ];
|
||||
}} $out
|
||||
node --check $out
|
||||
'';
|
||||
|
||||
languageType = types.submodule {
|
||||
options = {
|
||||
language = mkOption { type = types.str; example = "fr"; description = "wordfreq language (de, en, fr, es, it, nl, pt, sv, ...)."; };
|
||||
hunspell = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
example = "fr-moderne";
|
||||
description = ''
|
||||
pkgs.hunspellDicts attribute that filters the words and sets their
|
||||
casing; null: all words of the frequency list, lowercase.
|
||||
'';
|
||||
};
|
||||
words = mkOption { type = types.ints.positive; description = "Most frequent words taken."; };
|
||||
frequencyOffset = mkOption {
|
||||
type = types.number;
|
||||
default = 0.0;
|
||||
description = "Added to the words' zipf frequency, e.g. -0.3 for a second language.";
|
||||
};
|
||||
keepFrequentAbove = mkOption {
|
||||
type = types.nullOr types.number;
|
||||
default = 4.0;
|
||||
description = ''
|
||||
Words Hunspell rejects are kept from this zipf frequency on ("ok",
|
||||
"lol"; 3+ letters unless zipf >= 5); null: Hunspell only.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
in {
|
||||
imports = [ ./session.nix ./steam-ui-patches.nix ./keyboard-layout.nix ];
|
||||
|
||||
options.steamFrame.keyboard.vr = {
|
||||
enable = mkEnableOption ''
|
||||
swipe typing, suggestions (swipe alternatives, corrections, completions)
|
||||
and a Backspace drag on Steam's VR keyboard (a Steam UI patch; the
|
||||
sub-features below are on by default)'';
|
||||
|
||||
swipe.enable = mkEnableOption "swipe typing" // { default = true; };
|
||||
|
||||
dictionary = {
|
||||
languages = mkOption {
|
||||
type = types.listOf languageType;
|
||||
default = defaultLanguages;
|
||||
defaultText = lib.literalMD ''
|
||||
the language of `steamFrame.keyboard.layout` (de, fr, es, it, nl, pt,
|
||||
sv; 60000 words) plus English (40000, frequency -0.3), else English
|
||||
only (60000)'';
|
||||
description = "Dictionary languages.";
|
||||
};
|
||||
contractions = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = ''Words with apostrophes ("couldn't", "geht's"), swiped by their letters.'';
|
||||
};
|
||||
extraWords = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [ "SteamVR" "E-Mail" ];
|
||||
description = "Words always included (casing as given; ' and - are typed, not swiped).";
|
||||
};
|
||||
extraWordsFrequency = mkOption { type = types.number; default = 5.0; description = "Zipf frequency of extra words."; };
|
||||
extraWordFiles = mkOption {
|
||||
type = types.listOf types.path;
|
||||
default = [ ];
|
||||
description = ''Word lists: one "word" or "word<TAB>zipf" per line.'';
|
||||
};
|
||||
excludeWords = mkOption { type = types.listOf types.str; default = [ ]; description = "Words never suggested (any casing)."; };
|
||||
};
|
||||
|
||||
text = {
|
||||
bufferChars = mkOption { type = types.ints.between 8 1024; default = 64; description = "Characters of typed text remembered."; };
|
||||
resetAfterIdleSeconds = mkOption {
|
||||
type = types.ints.unsigned;
|
||||
default = 30;
|
||||
description = "Forget the remembered text after this long without typing (0: never).";
|
||||
};
|
||||
autoSpace = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Space before a swiped word after a known non-space, non-opening character.";
|
||||
};
|
||||
};
|
||||
|
||||
suggestions = {
|
||||
position = mkOption {
|
||||
type = types.enum [ "below" "inside" "above" ];
|
||||
default = "below";
|
||||
description = ''
|
||||
Suggestion strip: a SteamVR dashboard panel "below" or "above" the
|
||||
keyboard, moving with it, or "inside" the keyboard over the number
|
||||
row.
|
||||
'';
|
||||
};
|
||||
count = mkOption { type = types.ints.between 1 8; default = 5; description = "Suggestions shown."; };
|
||||
};
|
||||
|
||||
autocorrect = {
|
||||
enable = mkEnableOption "correction suggestions for finished tapped words not in the dictionary" // { default = true; };
|
||||
maxEditDistance = mkOption { type = types.ints.between 1 3; default = 2; description = "Largest edit distance (neighbouring keys, swaps: 0.5)."; };
|
||||
};
|
||||
completions = {
|
||||
enable = mkEnableOption "completions of the tapped word" // { default = true; };
|
||||
minPrefix = mkOption { type = types.ints.between 1 10; default = 2; description = "Letters typed before completions show."; };
|
||||
};
|
||||
|
||||
backspaceDrag = {
|
||||
enable = mkEnableOption "Backspace drag: drag left to delete, back right to retype" // { default = true; };
|
||||
pixelsPerChar = mkOption { type = types.ints.positive; default = 25; description = "Travel per character (keyboard px; a key is ~60)."; };
|
||||
wordDetentPixels = mkOption {
|
||||
type = types.ints.unsigned;
|
||||
default = 90;
|
||||
description = "Detent at a word border: extra travel to delete across it (0: none).";
|
||||
};
|
||||
};
|
||||
|
||||
haptics = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Haptic ticks: Backspace-drag steps, a stronger one at word detents, suggestion picks.";
|
||||
};
|
||||
|
||||
checks = mkOption {
|
||||
type = types.package;
|
||||
readOnly = true;
|
||||
default = built.checks;
|
||||
defaultText = lib.literalMD "the tests, built with the configured dictionary";
|
||||
description = "The tests (also built with the patch).";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkMerge [
|
||||
{
|
||||
assertions = map (l: {
|
||||
assertion = l.hunspell == null || pkgs.hunspellDicts ? ${l.hunspell};
|
||||
message = "steamFrame.keyboard.vr.dictionary.languages: no pkgs.hunspellDicts.${toString l.hunspell}.";
|
||||
}) cfg.dictionary.languages;
|
||||
|
||||
steamFrame.uiPatches.patches = lib.optional on {
|
||||
name = "vr-keyboard";
|
||||
target.title = "SharedJSContext";
|
||||
inherit patch;
|
||||
unpatch = ./vr-keyboard/unpatch.js;
|
||||
} ++ lib.optional panel {
|
||||
name = "vr-keyboard-panel";
|
||||
endpoint = "http://127.0.0.1:8087";
|
||||
target.title = "systemui";
|
||||
patch = pkgs.writeText "vr-keyboard-panel.js" "(${builtins.readFile ./vr-keyboard/panel.js})()";
|
||||
unpatch = ./vr-keyboard/unpatch-panel.js;
|
||||
};
|
||||
steamFrame.session.services.${if panel then "restart" else "stop"} = [ "vr-keyboard-relay.service" ];
|
||||
}
|
||||
(lib.mkIf panel {
|
||||
systemd.user.services.vr-keyboard-relay = {
|
||||
Unit.Description = "Relay of the VR keyboard's suggestion strip (Steam UI <-> SteamVR dashboard)";
|
||||
Service = { ExecStart = "${pkgs.nodejs}/bin/node ${./vr-keyboard/relay.mjs}"; Restart = "always"; RestartSec = 5; };
|
||||
Install.WantedBy = [ "default.target" ];
|
||||
};
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# Build-time parts of the VR keyboard: the dictionary and the tests. Used by
|
||||
# vr-keyboard.nix and by the flake's checks.<system>.vr-keyboard.
|
||||
{ pkgs, dictionary }:
|
||||
let
|
||||
inherit (pkgs) lib;
|
||||
d = dictionary;
|
||||
words = pkgs.runCommand "vr-keyboard-dictionary.js" { } ''
|
||||
${pkgs.python3.withPackages (ps: [ ps.wordfreq ])}/bin/python3 ${./gen-dict.py} $out ${pkgs.hunspell}/bin/hunspell \
|
||||
${pkgs.writeText "vr-keyboard-dictionary.json" (builtins.toJSON {
|
||||
languages = map (l: {
|
||||
inherit (l) words keepFrequentAbove;
|
||||
lang = l.language;
|
||||
offset = l.frequencyOffset;
|
||||
dict = if l.hunspell == null then null else "${pkgs.hunspellDicts.${l.hunspell}}/share/hunspell/${l.hunspell}";
|
||||
}) d.languages;
|
||||
extra = map (w: [ w d.extraWordsFrequency ]) d.extraWords;
|
||||
extraFiles = map toString d.extraWordFiles;
|
||||
extraZipf = d.extraWordsFrequency;
|
||||
exclude = d.excludeWords;
|
||||
inherit (d) contractions;
|
||||
})}
|
||||
'';
|
||||
# The accuracy thresholds (German keyboard geometry, German and English
|
||||
# words) fail the build only for German + English dictionaries.
|
||||
strict = lib.sort lib.lessThan (map (l: l.language) d.languages) == [ "de" "en" ];
|
||||
in {
|
||||
dictionary = words;
|
||||
checks = pkgs.runCommand "vr-keyboard-checks" { nativeBuildInputs = [ pkgs.nodejs ]; } ''
|
||||
set -o pipefail
|
||||
node ${./tests/textmodel.test.mjs} ${./textmodel.js}
|
||||
node ${./tests/corrector.test.mjs} ${./corrector.js} ${./decoder.js} ${words} ${lib.optionalString (!strict) "|| echo warning: corrector test failed"}
|
||||
node ${./tests/decoder.test.mjs} ${./decoder.js} ${words} 100 1 | tee $out ${lib.optionalString (!strict) "|| true"}
|
||||
node -e '
|
||||
const bad = require("fs").readFileSync(process.argv[1], "utf8").split("\n")
|
||||
.filter((l) => /sigma 0.25/.test(l) && +l.match(/top-3 ([\d.]+)%/)[1] < 85);
|
||||
if (bad.length) { console.error("decoder below 85 % top-3:", bad); process.exit(process.argv[2] === "strict" ? 1 : 0); }' \
|
||||
$out ${if strict then "strict" else "warn"}
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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 };
|
||||
})()
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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/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 };
|
||||
})()
|
||||
@@ -0,0 +1,180 @@
|
||||
# Builds the dictionary at build time: the most frequent words per language
|
||||
# (wordfreq), kept if Hunspell accepts them, in Hunspell's spelling and casing
|
||||
# (wordfreq lowercases and folds ß: "strasse" is tried as "straße"; lowercase
|
||||
# only if accepted without compounding, else Capitalised; "essen"/"Essen"
|
||||
# both, the second slightly less frequent). Contractions Hunspell rejects
|
||||
# ("geht's") are kept by frequency, other rejected words if their zipf is
|
||||
# >= keepFrequentAbove (3+ letters, or zipf >= 5: "ok").
|
||||
# usage: python3 gen-dict.py <out.js> <hunspell> <config.json>
|
||||
# config: { languages: [{ lang, words, offset (zipf), dict (path or null),
|
||||
# keepFrequentAbove (zipf or null) }],
|
||||
# extra: [[word, zipf]], extraFiles: [path], extraZipf, exclude: [word],
|
||||
# contractions: bool }
|
||||
# Output: a JS string literal "word<TAB>zipf*10\n..." (most frequent first).
|
||||
import itertools, json, re, shutil, subprocess, sys
|
||||
import wordfreq
|
||||
|
||||
out, hunspell, config = sys.argv[1:]
|
||||
cfg = json.load(open(config))
|
||||
# Word letters: German/English a-z ä ö ü ß; other languages any lowercase
|
||||
# letter (accents). Contractions ("couldn't", "geht's"): letters around
|
||||
# apostrophes (wordfreq splits words at hyphens: no hyphenated words).
|
||||
def word_patterns(lang):
|
||||
L = "a-zäöüß" if lang in ("de", "en") else r"^\W\d_A-Z"
|
||||
return re.compile(rf"^[{L}]{{2,24}}$"), re.compile(rf"^[{L}]+(?:'[{L}]+)+$")
|
||||
SECONDARY = 5 # frequency penalty (zipf/10) of the other casing
|
||||
cap = lambda w: w[0].upper() + w[1:]
|
||||
|
||||
|
||||
def spellings(w):
|
||||
"""w, then the variants with some "ss" written as "ß" (wordfreq folds ß)."""
|
||||
idx = [m.start() for m in re.finditer("ss", w)][:3]
|
||||
out = [w]
|
||||
for r in range(1, len(idx) + 1):
|
||||
for combo in itertools.combinations(idx, r):
|
||||
s = w
|
||||
for i in sorted(combo, reverse=True):
|
||||
s = s[:i] + "ß" + s[i + 2:]
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def check(dic, words):
|
||||
"""The words Hunspell accepts (-G) with dictionary dic."""
|
||||
res = subprocess.run([hunspell, "-i", "utf-8", "-d", dic, "-G"],
|
||||
input="\n".join(words) + "\n", capture_output=True, text=True, check=True)
|
||||
return set(res.stdout.split("\n"))
|
||||
|
||||
|
||||
def strict_dict(dic, tmp):
|
||||
"""dic without compounding: German .dic files also list nouns in lowercase
|
||||
as compound parts ("wetter" for "Regenwetter"), which Hunspell then accepts
|
||||
on their own; the strict copy decides the casing. Also returns the
|
||||
capitalised stems (nouns, names)."""
|
||||
aff = open(dic + ".aff", encoding="latin-1").read()
|
||||
aff = "\n".join(l for l in aff.split("\n") if not l.startswith("COMPOUND"))
|
||||
open(tmp + ".aff", "w", encoding="latin-1").write(aff) # latin-1: bytes unchanged
|
||||
shutil.copyfile(dic + ".dic", tmp + ".dic")
|
||||
enc = re.search(r"^SET\s+(\S+)", aff, re.M)
|
||||
text = open(dic + ".dic", "rb").read().decode(enc.group(1) if enc else "latin-1", "replace")
|
||||
caps = {l.split("/")[0].strip() for l in text.split("\n")[1:] if l[:1].isupper()}
|
||||
return tmp, caps
|
||||
|
||||
|
||||
best = {}
|
||||
frequent_all = set() # kept by frequency only (keepFrequentAbove)
|
||||
verified = set() # accepted by some language's Hunspell
|
||||
unverified = set() # contractions Hunspell rejects ("geht's"), kept by frequency
|
||||
def add(form, z):
|
||||
if z > best.get(form, -1):
|
||||
best[form] = z
|
||||
|
||||
contractions = cfg.get("contractions", True)
|
||||
def top_words(lang, n):
|
||||
"""The n most frequent plain words, plus the contractions among them."""
|
||||
LETTERS, CONTRACTION = word_patterns(lang)
|
||||
out, plain = [], 0
|
||||
for w in wordfreq.iter_wordlist(lang):
|
||||
w = w.replace("\u2019", "'")
|
||||
if LETTERS.match(w):
|
||||
out.append(w); plain += 1
|
||||
if plain >= n:
|
||||
break
|
||||
elif contractions and CONTRACTION.match(w) and len(w) <= 24:
|
||||
out.append(w)
|
||||
return out
|
||||
|
||||
available = set(wordfreq.available_languages())
|
||||
for spec in cfg["languages"]:
|
||||
lang, n, dic = spec["lang"], int(spec["words"]), spec.get("dict")
|
||||
keep_above = spec.get("keepFrequentAbove")
|
||||
frequent = []
|
||||
bias = round(float(spec.get("offset", 0)) * 10)
|
||||
if lang not in available:
|
||||
sys.exit(f"wordfreq has no language {lang!r}; available: {' '.join(sorted(available))}")
|
||||
words = top_words(lang, n)
|
||||
if not dic:
|
||||
for w in words:
|
||||
add(w, round(wordfreq.zipf_frequency(w, lang) * 10) + bias)
|
||||
print(f"{lang}: {len(words)} words (no Hunspell filter)", file=sys.stderr)
|
||||
continue
|
||||
variants = {w: spellings(w) for w in words}
|
||||
query = [f for vs in variants.values() for v in vs for f in (v, cap(v))]
|
||||
ok = check(dic, query)
|
||||
sdic, caps = strict_dict(dic, f"strict-{lang}")
|
||||
strict = check(sdic, [f for f in query if f in ok])
|
||||
kept = 0
|
||||
for w in words:
|
||||
z = round(wordfreq.zipf_frequency(w, lang) * 10) + bias
|
||||
if not any(v in ok or cap(v) in ok for v in variants[w]):
|
||||
zipf = wordfreq.zipf_frequency(w, lang)
|
||||
if "'" in w: # Hunspell splits "geht's": keep by frequency
|
||||
add(w, z)
|
||||
unverified.add(w)
|
||||
kept += 1
|
||||
elif keep_above is not None and zipf >= keep_above and (len(w) >= 3 or zipf >= 5):
|
||||
add(w, z) # frequent, but not in Hunspell ("ok", "lol", "colour")
|
||||
frequent.append(w)
|
||||
frequent_all.add(w)
|
||||
continue
|
||||
for v in variants[w]:
|
||||
lo, up = v in ok, cap(v) in ok
|
||||
if not (lo or up):
|
||||
continue
|
||||
kept += 1
|
||||
verified.update(f for f in (v, cap(v)) if f in ok)
|
||||
slo, sup = v in strict, cap(v) in strict
|
||||
if slo:
|
||||
add(v, z)
|
||||
if cap(v) in caps: # "essen" / "Essen"
|
||||
add(cap(v), z - SECONDARY)
|
||||
elif sup or not lo: # "Wetter", "Haus", "Weihnachtsmarkt"
|
||||
add(cap(v), z)
|
||||
else:
|
||||
add(v, z)
|
||||
break
|
||||
print(f"{lang}: {kept} of {len(words)} words kept, {len(frequent)} more by frequency: {' '.join(frequent[:30])}", file=sys.stderr)
|
||||
|
||||
# An unverified contraction that another language has in a checked casing
|
||||
# ("i'm" from the German list, "I'm" from English): keep only the latter.
|
||||
checked = {}
|
||||
for w in best:
|
||||
if "'" in w and w not in unverified:
|
||||
checked.setdefault(w.lower(), w)
|
||||
for w in [w for w in unverified if w in best and w.lower() in checked and checked[w.lower()] != w]:
|
||||
k = checked[w.lower()]
|
||||
best[k] = max(best[k], best.pop(w))
|
||||
print(f"contractions: {sum(1 for w in best if chr(39) in w)} ({len(unverified)} not in Hunspell)", file=sys.stderr)
|
||||
|
||||
# A frequency-only word that is a contraction without its apostrophe ("dont",
|
||||
# "thats") would only compete with the real one: dropped.
|
||||
# Only words no Hunspell accepts ("is" stays although "i's" exists), and only
|
||||
# if the contraction is more frequent.
|
||||
contraction_freq = {}
|
||||
for w, z in best.items():
|
||||
if "'" in w:
|
||||
k = w.replace("'", "").lower()
|
||||
contraction_freq[k] = max(contraction_freq.get(k, -1), z)
|
||||
dropped = [w for w in frequent_all if w in best and w not in verified and best[w] < contraction_freq.get(w, -1)]
|
||||
for w in dropped:
|
||||
del best[w]
|
||||
print(f"frequency-only words dropped as contractions without apostrophe: {len(dropped)}", file=sys.stderr)
|
||||
|
||||
extra = [(w, z) for w, z in cfg.get("extra", [])]
|
||||
for path in cfg.get("extraFiles", []):
|
||||
for line in open(path, encoding="utf-8"):
|
||||
parts = line.strip().split("\t")
|
||||
if parts[0]:
|
||||
extra.append((parts[0], float(parts[1]) if len(parts) > 1 else None))
|
||||
for w, z in extra:
|
||||
z = cfg.get("extraZipf", 5.0) if z is None else z
|
||||
best[w] = max(best.get(w, -1), round(float(z) * 10))
|
||||
exclude = {w.lower() for w in cfg.get("exclude", [])}
|
||||
for w in [w for w in best if w.lower() in exclude]:
|
||||
del best[w]
|
||||
print(f"extra: {len(extra)}, excluded: {len(exclude)}", file=sys.stderr)
|
||||
|
||||
items = sorted(best.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps("\n".join(f"{w}\t{z}" for w, z in items), ensure_ascii=False))
|
||||
print(f"total: {len(items)} entries", file=sys.stderr)
|
||||
@@ -0,0 +1,200 @@
|
||||
// The suggestion strip as a SteamVR dashboard panel above or below Steam's
|
||||
// VR keyboard (suggestions.position "above" / "below"), injected into
|
||||
// SteamVR's systemui page (8087). State comes from the keyboard page through relay.mjs:
|
||||
// __sfuiKbdStrip.show({ seq, items, current, visible, style }); a click calls
|
||||
// the binding __sfuiStripPick('{"seq":n,"index":i}').
|
||||
//
|
||||
// systemui mounts the keyboard overlay (mountedId "...gamepadui.keyboard")
|
||||
// under an anchor transform, scaled by its sibling transform; the overlay
|
||||
// hangs from its top edge (SteamVR's grab handle sits in the page's
|
||||
// transparent bottom band). Our panel is a child of that anchor, as wide as
|
||||
// the keyboard, so it follows it: "above" at y = +GAP (origin bottom
|
||||
// centre); "below" at the keyboard's lower edge (origin top centre), in
|
||||
// front of the grab handle. Built like
|
||||
// frame-controls' own popups: a vsg-node panel whose buildNode publishes a
|
||||
// region of this page (SGApp embedded-UV slot) at a free spot of the page.
|
||||
(() => {
|
||||
const VERSION = 12;
|
||||
const G = window;
|
||||
const GAP = 0.012, DEPTH = 0.005; // m above the keyboard's top edge / in front of it
|
||||
// "below": the strip's top edge sits this much above the lower of the
|
||||
// keyboard's and the grab handle's lower edges (user-tuned), overlapping
|
||||
// the handle's hit area a little -- so the strip lies in front of the
|
||||
// handle (+z toward the viewer; the handle is at z 0.01) and gets the laser.
|
||||
const BELOW_OFFSET = 0.013, BELOW_DEPTH = 0.015;
|
||||
const O = { heightPx: 110, widthPx: 1700 }; // max page region for the strip texture (px)
|
||||
const old = G.__sfuiKbdStrip;
|
||||
if (old?.version === VERSION) return 'unchanged';
|
||||
const missing = [
|
||||
typeof G.SGApp?.addEmbeddedPanelUVs !== 'function' && 'SGApp.addEmbeddedPanelUVs',
|
||||
typeof G.SGApp?.removeEmbeddedPanelUVs !== 'function' && 'SGApp.removeEmbeddedPanelUVs',
|
||||
typeof G.VRHTML?.NextSGID !== 'function' && 'VRHTML.NextSGID',
|
||||
typeof G.VRHTML?.VROverlay?.TriggerOverlayHapticEffect !== 'function' && 'VRHTML.VROverlay.TriggerOverlayHapticEffect',
|
||||
typeof G.forceLayoutUpdate !== 'function' && 'forceLayoutUpdate',
|
||||
].filter(Boolean);
|
||||
if (missing.length) return `SteamVR internals changed, no strip panel: missing ${missing.join(', ')}`;
|
||||
const saved = old?.state ?? null;
|
||||
try { old?.dispose?.(); } catch { /* gone */ }
|
||||
|
||||
const S = G.__sfuiKbdStrip = { version: VERSION, state: saved, own: null, log: [] };
|
||||
const log = (...a) => { S.log.push([Math.round(performance.now()), ...a]); if (S.log.length > 100) S.log.shift(); };
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'sfui-kbd-strip-style';
|
||||
style.textContent = `
|
||||
.sfui-kbd-strip { position: fixed; display: flex; box-sizing: border-box; width: ${O.widthPx}px;
|
||||
background: rgb(35, 38, 46); z-index: 1; pointer-events: auto; }
|
||||
.sfui-kbd-strip > div { flex: 1 1 0; min-width: 0; display: flex; align-items: center; justify-content: center;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-sizing: border-box; }
|
||||
.sfui-kbd-strip > div:hover { filter: brightness(1.6); }`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const fiberProps = (e) => {
|
||||
const k = Object.keys(e).find((x) => x.startsWith('__reactFiber$'));
|
||||
let f = k && e[k];
|
||||
for (let i = 0; f && i < 6; f = f.return, i++) if (f.memoizedProps?.mountedId) return f.memoizedProps;
|
||||
return null;
|
||||
};
|
||||
// The keyboard's anchor transform (see header) and its overlay scale, or null.
|
||||
function keyboardAnchor() {
|
||||
for (const e of document.querySelectorAll('[vsg-type="mountedscenegraph"]')) {
|
||||
if (!String(fiberProps(e)?.mountedId ?? '').endsWith('gamepadui.keyboard')) continue;
|
||||
const scaled = e.parentElement, anchor = scaled?.parentElement;
|
||||
if (!anchor || anchor.tagName !== 'VSG-TRANSFORM') return null;
|
||||
const scale = parseFloat((scaled.getAttribute('scale') || '1').split(' ')[0]) || 1;
|
||||
let width = 1;
|
||||
try { width = VRHTML.VROverlay.GetWidthInMeters(VRHTML.VROverlay.FindOverlay('valve.steam.gamepadui.keyboard')) || 1; } catch { /* 1 m */ }
|
||||
// SteamVR's grab handle: a sibling transform (origin bottom centre) whose
|
||||
// y is where its panel ends; 0.66 m if not found.
|
||||
const handle = [...anchor.children].find((c) => c !== scaled && !c.querySelector?.('.sfui-kbd-own') && c.querySelector?.('[vsg-type="panel"]'));
|
||||
const handleBottom = -parseFloat((handle?.getAttribute('translation') || '0 -0.66').split(' ')[1]) || 0.66;
|
||||
return { anchor, width: width * scale, handleBottom };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// A free spot of the page for the texture region (below every panel).
|
||||
function freeSpot() {
|
||||
const rs = [...document.querySelectorAll('[vsg-type="panel"]')]
|
||||
.filter((p) => !p.classList.contains('sfui-kbd-own')).map((p) => p.getBoundingClientRect()).filter((r) => r.width && r.height);
|
||||
const W = Math.min(O.widthPx, innerWidth - 4), H = O.heightPx;
|
||||
for (let y = innerHeight - H - 4; y >= 0; y -= 16) {
|
||||
if (!rs.some((r) => r.top < y + H + 2 && r.bottom > y - 2 && r.left < W + 4)) return { x: 2, y, W };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mount(kb) {
|
||||
const app = G.SGApp, spot = freeSpot();
|
||||
if (!spot) { log('no free spot'); return null; }
|
||||
const outer = document.createElement('vsg-transform');
|
||||
for (const [k, v] of Object.entries({ translation: '0 0 0', rotation: '1 0 0 0', scale: '1 1 1', sgid: VRHTML.NextSGID() }))
|
||||
outer.setAttribute(k, String(v));
|
||||
const node = document.createElement('vsg-node');
|
||||
node.id = 'sfui-kbd-strip-panel';
|
||||
node.className = 'sfui-kbd-own';
|
||||
node.setAttribute('vsg-type', 'panel');
|
||||
const sgid = VRHTML.NextSGID();
|
||||
node.setAttribute('sgid', String(sgid));
|
||||
const content = document.createElement('div');
|
||||
content.className = 'sfui-kbd-strip';
|
||||
content.style.left = `${spot.x}px`; content.style.top = `${spot.y}px`; content.style.width = `${spot.W}px`;
|
||||
content.style.height = `${O.heightPx}px`;
|
||||
for (const t of ['mousedown', 'mouseup', 'click', 'dblclick', 'pointerdown', 'pointerup', 'contextmenu'])
|
||||
content.addEventListener(t, (e) => e.stopPropagation()); // nothing reaches the dashboard's handlers
|
||||
content.addEventListener('click', (e) => {
|
||||
const el = e.target.closest?.('[data-index]');
|
||||
if (!el || !S.state) return;
|
||||
log('pick', S.state.seq, +el.dataset.index);
|
||||
if (+el.dataset.index !== S.state.current && S.state.haptic) { // tick on this (the laser's) overlay
|
||||
try { VRHTML.VROverlay.TriggerOverlayHapticEffect(VRHTML.VROverlay.ThisOverlayHandle(), S.state.haptic); } catch (err) { log('haptic', String(err)); }
|
||||
}
|
||||
G.__sfuiStripPick?.(JSON.stringify({ seq: S.state.seq, index: +el.dataset.index }));
|
||||
});
|
||||
node.appendChild(content);
|
||||
outer.appendChild(node);
|
||||
const fp = { // what SGApp's embedded-UV table reads of a panel
|
||||
props: { debug_name: node.id }, isExternal: false, m_Rect: { x: 0, y: 0, width: 0, height: 0 }, idx: undefined,
|
||||
getSGID: () => sgid, getEmbeddedIndex: () => fp.idx, getCurrentRootElement: () => node,
|
||||
updateLayoutValues() { const r = content.getBoundingClientRect(); fp.m_Rect = { x: r.x, y: r.y, width: r.width, height: r.height }; },
|
||||
};
|
||||
fp.idx = app.addEmbeddedPanelUVs(fp);
|
||||
if (fp.idx == null) { log('no embedded UV slot'); return null; }
|
||||
node.buildNode = (ctx) => {
|
||||
if (!kb.anchor.isConnected) { queueMicrotask(() => unmount('keyboard gone')); return [ctx, null]; }
|
||||
const r = content.getBoundingClientRect(), W = innerWidth, H = innerHeight;
|
||||
return [{ ...ctx, currentPanel: fp, bInsideReparentedPanel: false }, { type: 'panel', properties: {
|
||||
id: `system.systemui::${node.id}`, sgid, key: 'system.systemui', debug_name: node.id,
|
||||
width: kb.width, origin: S.state?.position === 'below' ? [0, 1] : [0, -1], interactive: true, scrollable: false, visibility: 0,
|
||||
'only-visible-with-laser': false, 'lasermouse-filtering': 0, 'can-take-keyboard-focus': false,
|
||||
'sort-depth-bias': -1, 'scale-index': 0,
|
||||
'embedded-uv-index': fp.idx, uv_min: [r.x / W, r.y / H], uv_max: [(r.x + r.width) / W, (r.y + r.height) / H],
|
||||
} }];
|
||||
};
|
||||
kb.anchor.appendChild(outer);
|
||||
G.forceLayoutUpdate();
|
||||
log('mounted', spot.y, kb.width);
|
||||
return { outer, node, content, fp, anchor: kb.anchor };
|
||||
}
|
||||
function unmount(why) {
|
||||
const o = S.own;
|
||||
if (!o) return;
|
||||
S.own = null;
|
||||
o.outer.remove();
|
||||
try { G.SGApp.removeEmbeddedPanelUVs(o.fp); } catch (e) { log('remove UVs', String(e)); }
|
||||
G.forceLayoutUpdate();
|
||||
log('unmounted', why);
|
||||
}
|
||||
// The keyboard's key look (patch.js keyStyle), scaled from its page
|
||||
// (pageWidth px across the keyboard) to ours (content width across it).
|
||||
const FALLBACK = { pageWidth: 854, pageHeight: 280, keyHeight: 44, pad: [1, 1, 2, 2], background: 'rgb(14, 20, 27)', color: 'rgb(255, 255, 255)',
|
||||
fontFamily: '"Motiva Sans", Arial, Helvetica, sans-serif', fontSize: 16, fontWeight: '400', radius: 0,
|
||||
border: '0px none rgb(255, 255, 255)', boxShadow: 'none', board: 'rgb(35, 38, 46)', pressed: 'rgb(26, 159, 255)' };
|
||||
function applyStyle(content, st) {
|
||||
const k = { ...FALLBACK, ...(st || {}) };
|
||||
const f = content.getBoundingClientRect().width / (k.pageWidth || 854) || 2;
|
||||
const [pt, pr, pb, pl] = (Array.isArray(k.pad) && k.pad.length === 4 ? k.pad : FALLBACK.pad).map((v) => v * f);
|
||||
const h = Math.min(O.heightPx, Math.round((k.keyHeight + (k.pad?.[0] ?? 1) + (k.pad?.[2] ?? 2)) * f));
|
||||
Object.assign(content.style, { height: `${h}px`, background: k.board, padding: `${pt}px ${pr}px ${pb}px ${pl}px`, gap: `${pl + pr}px` });
|
||||
S.applied = { f, h, key: k };
|
||||
return { k, f };
|
||||
}
|
||||
function render() {
|
||||
const st = S.state;
|
||||
const kb = st?.visible && st.items?.length ? keyboardAnchor() : null;
|
||||
if (!kb) { unmount(st?.visible ? 'no keyboard' : 'hidden'); return; }
|
||||
if (S.own && (S.own.anchor !== kb.anchor || !S.own.outer.isConnected)) unmount('anchor changed');
|
||||
if (!S.own) S.own = mount(kb);
|
||||
if (!S.own) return;
|
||||
const { k, f } = applyStyle(S.own.content, st.style);
|
||||
// Placement (m, in the keyboard anchor's frame; the keyboard hangs from y = 0).
|
||||
const kbHeight = kb.width * (k.pageHeight || 280) / (k.pageWidth || 854);
|
||||
const below = st.position === 'below';
|
||||
const y = below ? -(Math.max(kbHeight, kb.handleBottom) - BELOW_OFFSET) : GAP;
|
||||
S.own.outer.setAttribute('translation', `0 ${y.toFixed(4)} ${below ? BELOW_DEPTH : DEPTH}`);
|
||||
S.placement = { position: st.position, y, kbHeight, handleBottom: kb.handleBottom };
|
||||
S.own.content.replaceChildren(...st.items.map((text, i) => {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = text;
|
||||
el.dataset.index = i;
|
||||
Object.assign(el.style, {
|
||||
background: k.background, color: k.color, fontFamily: k.fontFamily, fontSize: `${k.fontSize * f}px`,
|
||||
fontWeight: k.fontWeight, borderRadius: `${k.radius * f}px`, border: k.border, boxShadow: k.boxShadow,
|
||||
});
|
||||
// The word as it stands in the text: a thin bar in the keyboard's pressed colour.
|
||||
if (i === st.current) el.style.boxShadow = `inset 0 ${-Math.max(2, Math.round(3 * f))}px 0 ${k.pressed}`;
|
||||
el.addEventListener('mousedown', () => { el.style.background = k.pressed; });
|
||||
el.addEventListener('mouseleave', () => { el.style.background = k.background; });
|
||||
return el;
|
||||
}));
|
||||
G.forceLayoutUpdate();
|
||||
}
|
||||
S.show = (state) => { S.state = state; render(); return 'ok'; };
|
||||
// The keyboard can appear/disappear without a new state (dashboard toggled).
|
||||
S.timer = setInterval(() => {
|
||||
const want = !!(S.state?.visible && S.state.items?.length);
|
||||
if (want !== !!S.own || (S.own && !S.own.anchor.isConnected)) render();
|
||||
}, 500);
|
||||
S.dispose = () => { clearInterval(S.timer); unmount('dispose'); style.remove(); };
|
||||
render();
|
||||
return 'patched';
|
||||
})
|
||||
@@ -0,0 +1,522 @@
|
||||
// Gestures and suggestions for Steam's VR keyboard (vr-keyboard.nix),
|
||||
// injected into Steam's SharedJSContext (8080). The keyboard is a popup of
|
||||
// that context ("SteamVR - Keyboard"); everything works on its document.
|
||||
// mkPatch convention plus four more arguments: decoder.js, textmodel.js,
|
||||
// corrector.js and the dictionary text ("word<TAB>zipf*10\n...").
|
||||
//
|
||||
// - Swipe: the laser arrives as touch events; Steam types the key a touch
|
||||
// started on at release. Once a press leaves its first key we drop that
|
||||
// key from Steam's pending touches (m_mapTouched) and cancel its long press
|
||||
// (Steam's own touch-end cleanup, minus the typing), then decode the path.
|
||||
// - Output: one character or "Backspace" per HandleVirtualKeyDown, the path
|
||||
// of Steam's own keys (incl. extraKeys' xdotool fallback for non-ASCII,
|
||||
// which is async: we pause after such characters).
|
||||
// - Text model: an own-property hook on the manager's HandleVirtualKeyDown
|
||||
// feeds everything the keyboard emits to textmodel.js; one on
|
||||
// TypeKeyInternal resets it for keys the model can't follow (extraKeys'
|
||||
// xdotool keys, dead keys) and freezes suggestions at once for taps (their
|
||||
// character arrives later). Keyboard
|
||||
// closed/retargeted, another Steam text field, idle: reset. Suggestions
|
||||
// replace text only while the model proves the characters they replace
|
||||
// intact.
|
||||
// - Strip: in the page over the number row ("inside") or as a SteamVR panel
|
||||
// above/below the keyboard ("above"/"below", panel.js): state out via the
|
||||
// CDP binding __sfuiStripOut, picks back via
|
||||
// __sfuiSwipe.remote.pick(seq, index) (relay.mjs).
|
||||
// All Steam internals are checked first (sigs, instance members); if one is
|
||||
// missing the keyboard stays stock. Only passive listeners; never blocks
|
||||
// Steam's events. Debugging: __sfuiSwipeLog, __sfuiSwipePaths
|
||||
// (scripts/vr-keyboard-replay.mjs).
|
||||
((find, sigs, opts, hooks, D, T, C, DICT) => {
|
||||
const VERSION = 23;
|
||||
const G = window;
|
||||
const O = opts;
|
||||
|
||||
const log = (...a) => {
|
||||
const l = (G.__sfuiSwipeLog ??= []);
|
||||
l.push([Math.round(performance.now()), ...a]);
|
||||
if (l.length > 300) l.splice(0, l.length - 300);
|
||||
};
|
||||
|
||||
// Shared across injections: the parsed dictionary (and decoder layouts,
|
||||
// corrector index), one detach function per patched keyboard document.
|
||||
const dictId = `${DICT.length}:${DICT.slice(0, 80)}:${D.VERSION}:${C.VERSION}`;
|
||||
let S = G.__sfuiSwipe;
|
||||
if (S?.dictId !== dictId) {
|
||||
for (const detach of S?.docs?.values() || []) detach();
|
||||
S = G.__sfuiSwipe = { dictId, dict: D.parseDict(DICT), layouts: new Map(), corrector: null, docs: new Map() };
|
||||
}
|
||||
for (const [doc, detach] of S.docs) if (!doc.defaultView) { detach(); S.docs.delete(doc); }
|
||||
|
||||
let status, Manager;
|
||||
try {
|
||||
const req = find.getWebpackRequire('webpackChunksteamui');
|
||||
const mods = find.resolveAll(req, sigs);
|
||||
status = mods.vrStatus.exports.holder;
|
||||
Manager = mods.keyboardManager.exports.VirtualKeyboardManager;
|
||||
find.findModule(req, sigs.keyboardComponent.module, 'keyboardComponent');
|
||||
} catch (e) {
|
||||
return `Steam internals changed, keyboard left stock: ${e.message}`;
|
||||
}
|
||||
|
||||
const popup = [...(g_PopupManager.GetPopups?.() || [])].find((p) => p.window?.document.querySelector('[data-key]'));
|
||||
if (!popup) return 'no keyboard popup yet';
|
||||
const doc = popup.window.document;
|
||||
const stamp = `${VERSION} ${JSON.stringify(O)}`; // new code or options: re-attach
|
||||
if (doc.__sfuiSwipe === stamp && S.docs.has(doc)) return 'unchanged';
|
||||
S.docs.get(doc)?.();
|
||||
S.docs.delete(doc);
|
||||
const kbInst = (el) => find.findFiberUp(el, (f) => typeof f.stateNode?.TypeKeyInternal === 'function', 200)?.stateNode;
|
||||
const inst0 = kbInst(doc.querySelector('[data-key]'));
|
||||
const missing = [
|
||||
!inst0 && 'keyboard component',
|
||||
!(inst0?.props?.VirtualKeyboardManager instanceof Manager) && 'VirtualKeyboardManager instance',
|
||||
!(inst0?.m_mapTouched instanceof Set) && 'm_mapTouched',
|
||||
...['CancelLongPressTimer', 'DismissLongPress', 'ClearHoldTarget'].filter((m) => typeof inst0?.[m] !== 'function'),
|
||||
typeof inst0?.state?.toggleStates !== 'object' && 'toggleStates',
|
||||
].filter(Boolean);
|
||||
if (missing.length) return `Steam internals changed, keyboard left stock: missing ${missing.join(', ')}`;
|
||||
S.docs.set(doc, attach(popup.window, doc));
|
||||
doc.__sfuiSwipe = stamp;
|
||||
return `patched (${S.dict.words.length} words)`;
|
||||
|
||||
function attach(win, doc) {
|
||||
const cleanup = [];
|
||||
const isLetter = (k) => typeof k === 'string' && [...k].length === 1 && /\p{L}/u.test(k);
|
||||
const on = (v) => (v & 7) !== 0; // toggle state: 1 one-shot, 2 locked
|
||||
const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
||||
|
||||
// ---- trail and in-page strip -------------------------------------------------
|
||||
const inPage = O.position === 'inside'; // else a SteamVR panel (panel.js)
|
||||
const style = doc.createElement('style');
|
||||
style.textContent = `
|
||||
#sfui-swipe-trail { position: fixed; left: 0; top: 0; width: 100vw; height: 100vh; pointer-events: none; z-index: 10000; }
|
||||
#sfui-swipe-strip { position: fixed; left: 2px; right: 2px; z-index: 10001;
|
||||
background: rgb(35, 38, 46); display: none; gap: 4px; box-sizing: border-box; }
|
||||
#sfui-swipe-strip.shown { display: flex; }
|
||||
#sfui-swipe-strip > div { flex: 1 1 0; min-width: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: rgb(35, 38, 46); color: rgb(220, 222, 226); border-radius: 4px;
|
||||
font-family: "Motiva Sans", Arial, Helvetica, sans-serif; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
#sfui-swipe-strip > div.current { background: rgb(26, 105, 170); color: white; }
|
||||
#sfui-swipe-strip > div.hover { outline: 2px solid rgba(255, 255, 255, 0.6); outline-offset: -2px; }`;
|
||||
const canvas = doc.createElement('canvas');
|
||||
canvas.id = 'sfui-swipe-trail';
|
||||
const strip = doc.createElement('div');
|
||||
strip.id = 'sfui-swipe-strip';
|
||||
doc.head.appendChild(style);
|
||||
doc.body.append(canvas, strip);
|
||||
cleanup.push(() => { style.remove(); canvas.remove(); strip.remove(); });
|
||||
const ctx = canvas.getContext('2d');
|
||||
let fadeTimer = 0;
|
||||
cleanup.push(() => clearTimeout(fadeTimer));
|
||||
const clearTrail = () => ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
function drawTrail(pts, color, fadeMs = 0) {
|
||||
const dpr = win.devicePixelRatio || 1, w = Math.round(win.innerWidth * dpr), h = Math.round(win.innerHeight * dpr);
|
||||
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
|
||||
clearTimeout(fadeTimer);
|
||||
clearTrail();
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
Object.assign(ctx, { lineWidth: 6, lineCap: 'round', lineJoin: 'round', strokeStyle: color });
|
||||
ctx.beginPath();
|
||||
pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
if (fadeMs) fadeTimer = setTimeout(clearTrail, fadeMs);
|
||||
}
|
||||
|
||||
// ---- key geometry -> decoder layout (cached per key positions) -------------
|
||||
function currentLayout() {
|
||||
const keys = {}, widths = [];
|
||||
for (const el of doc.querySelectorAll('[data-key]')) {
|
||||
const k = el.getAttribute('data-key'), r = el.getBoundingClientRect();
|
||||
if (!isLetter(k) || !r.width) continue;
|
||||
keys[k.toLowerCase()] = [r.x + r.width / 2, r.y + r.height / 2];
|
||||
widths.push(r.width);
|
||||
}
|
||||
if (widths.length < 10) return null;
|
||||
const sig = Object.entries(keys).map(([k, [x, y]]) => `${k}${Math.round(x)},${Math.round(y)}`).join(' ');
|
||||
let lay = S.layouts.get(sig);
|
||||
if (!lay) {
|
||||
if (S.layouts.size >= 4) S.layouts.clear();
|
||||
const unit = widths.sort((a, b) => a - b)[widths.length >> 1];
|
||||
lay = { unit, keys, dec: D.layout(S.dict, keys, unit) };
|
||||
S.layouts.set(sig, lay);
|
||||
}
|
||||
return lay;
|
||||
}
|
||||
|
||||
// ---- text model, hooks, resets --------------------------------------------------
|
||||
const model = T.create({ size: O.bufferChars });
|
||||
S.model = model;
|
||||
let lastActivity = Date.now();
|
||||
const activity = () => { lastActivity = Date.now(); };
|
||||
let refreshQueued = false;
|
||||
const refreshLater = () => {
|
||||
if (refreshQueued) return;
|
||||
refreshQueued = true;
|
||||
queueMicrotask(() => { refreshQueued = false; showStrip(); });
|
||||
};
|
||||
const reset = (why, o) => { model.reset(why, o); log('reset', why); refreshLater(); };
|
||||
|
||||
// Own-property hooks calling the (possibly keyboard-patch-wrapped)
|
||||
// prototype method; re-installed if the instance changes.
|
||||
function hook(obj, name, make) {
|
||||
if (!obj || (own(obj, name) && obj[name].__sfuiSwipe === hook)) return;
|
||||
// The prototype's current method (the keyboard patch may re-wrap it).
|
||||
const f = make((self, args) => Object.getPrototypeOf(obj)[name].apply(self, args));
|
||||
f.__sfuiSwipe = hook;
|
||||
obj[name] = f;
|
||||
cleanup.push(() => { if (own(obj, name) && obj[name] === f) delete obj[name]; });
|
||||
}
|
||||
const TOGGLES = new Set(['Shift', 'CapsLock', 'Control', 'Alt', 'AltGr']);
|
||||
function hookInst(inst) {
|
||||
if (!inst) return;
|
||||
const mgr = inst.props?.VirtualKeyboardManager;
|
||||
hook(mgr, 'HandleVirtualKeyDown', (call) => function (key, ...rest) {
|
||||
if (this === mgr) {
|
||||
if (!emitting) log('k', key); // what the keyboard typed (not our own output)
|
||||
if (this.m_strDeadKeyPending) reset('dead key');
|
||||
else if (!model.observe(key)) log('reset', `key ${key}`);
|
||||
activity(); refreshLater();
|
||||
}
|
||||
return call(this, [key, ...rest]);
|
||||
});
|
||||
hook(inst, 'TypeKeyInternal', (call) => function (st) {
|
||||
const key = st?.strKey, ts = this.state?.toggleStates || {};
|
||||
if (typeof key === 'string' && !TOGGLES.has(key) && !/^(SwitchKeys_|IME_)/.test(key)) {
|
||||
// Extra keys typed by keyboard.vr.extraKeys with xdotool (chords, Esc,
|
||||
// arrows, AltGr Delete: text after the cursor) and dead keys: the
|
||||
// model can't follow them.
|
||||
if (on(ts.Control) || on(ts.Alt) || key.startsWith('VKX_') || st.strDeadKeyNext || (on(ts.Shift) && key.startsWith('Arrow'))) {
|
||||
reset(`key ${key}`);
|
||||
} else { model.freeze(); refreshLater(); }
|
||||
}
|
||||
activity();
|
||||
return call(this, [st]);
|
||||
});
|
||||
}
|
||||
hookInst(inst0);
|
||||
|
||||
const ids = new WeakMap();
|
||||
let nextId = 1;
|
||||
const idOf = (o) => (o && typeof o === 'object' ? (ids.get(o) ?? (ids.set(o, nextId), nextId++)) : 0);
|
||||
let lastTarget = null, idle = false;
|
||||
const poll = setInterval(() => {
|
||||
const s = status.VRKeyboardStatus;
|
||||
const target = [s?.bIsOpen, s?.sOverlayKey, s?.unAppID, idOf(inst0.props?.VirtualKeyboardManager?.m_ActiveElementProps)].join('|');
|
||||
if (lastTarget !== null && target !== lastTarget) reset(`target ${target}`, { boundary: true });
|
||||
lastTarget = target;
|
||||
const quiet = O.resetAfterIdleSeconds > 0 && Date.now() - lastActivity > O.resetAfterIdleSeconds * 1000;
|
||||
// Idle: the cursor may have moved; the next word starts a new known text.
|
||||
// (A boundary only matters for suggestions, whose replacements stay exact.)
|
||||
if (quiet && !idle) reset('idle', { boundary: true });
|
||||
idle = quiet;
|
||||
}, 500);
|
||||
cleanup.push(() => clearInterval(poll));
|
||||
|
||||
// ---- output: serialised operations ----------------------------------------------
|
||||
let queue = Promise.resolve(), busy = 0;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
function run(name, fn) {
|
||||
busy++;
|
||||
queue = queue.then(fn).catch((e) => log(`${name}-error`, String(e))).finally(() => { busy--; refreshLater(); });
|
||||
}
|
||||
let emitting = false;
|
||||
const emitter = (mgr) => (key) => {
|
||||
emitting = true;
|
||||
try { mgr.HandleVirtualKeyDown(key); } finally { emitting = false; }
|
||||
if (key.codePointAt(0) > 127 && G.__vrkbdKey) return sleep(120);
|
||||
};
|
||||
// SteamVR overlay haptic effects: 1 ButtonEnter (light), 3 Snap.
|
||||
const HAPTIC = { step: 1, border: 3, pick: 1 };
|
||||
const haptic = (effect) => O.haptics && win.SteamClient?.OpenVR?.TriggerOverlayHapticEffect?.(effect, 0);
|
||||
|
||||
// ---- suggestions: one strip, last event wins --------------------------------
|
||||
// cur: { kind, items, index (what the text shows), anchor } -- swipe (the
|
||||
// swiped word's alternatives), correct (a finished tapped word not in the
|
||||
// dictionary: itself, then corrections; anchor incl. the terminator),
|
||||
// complete (the typed prefix, then its completions). A pick
|
||||
// replaces the anchored text and keeps the strip, so picks can be
|
||||
// switched; the strip stays while its anchor is intact.
|
||||
let cur = null, computedAt = -1;
|
||||
const corrector = () => (S.corrector ??= C.create(S.dict));
|
||||
const valid = (c) => model.anchorIntact(c.anchor);
|
||||
function suggest() {
|
||||
if (cur && valid(cur)) return;
|
||||
cur = null;
|
||||
if (computedAt === model.version) return;
|
||||
computedAt = model.version;
|
||||
const mgr = inst0.props?.VirtualKeyboardManager;
|
||||
const w = O.autocorrect && model.endedWord();
|
||||
if (O.autocorrect && !w && /[\p{L}\p{N}][ .,!?;:]$/u.test(model.text)) log('word-end', 'start unknown');
|
||||
if (w) {
|
||||
const lay = currentLayout(); // neighbouring keys are cheap substitutions
|
||||
if (lay && S.correctorLayout !== lay) { corrector().setLayout(lay.dec.centre); S.correctorLayout = lay; }
|
||||
const c = corrector().corrections(w.word, { maxDistance: O.maxEditDistance, max: O.count - 1 });
|
||||
log('word-end', w.word, c.length ? c.join(' ') : 'ok');
|
||||
if (c.length) cur = { kind: 'correct', items: [w.word, ...c], index: 0, term: w.term, anchor: model.anchor(w.n), mgr };
|
||||
}
|
||||
const p = !cur && O.completions && model.currentWord();
|
||||
if (p && [...p.text].length >= O.minPrefix) {
|
||||
const c = corrector().completions(p.text, { max: O.count - 1 });
|
||||
if (c.length) cur = { kind: 'complete', items: [p.text, ...c], index: 0, anchor: model.anchor(p.n), mgr };
|
||||
}
|
||||
if (cur) log('suggest', cur.kind, cur.items.join(' '));
|
||||
}
|
||||
function showStrip() {
|
||||
if (busy) return; // shown again when the operation is done
|
||||
const dragging = down?.kind === 'backspace' && down.active; // no suggestions during a Backspace drag
|
||||
if (!dragging) suggest();
|
||||
const shown = !dragging && !!(cur && valid(cur));
|
||||
if (!inPage) { publish(shown); return; }
|
||||
strip.replaceChildren(...(shown ? cur.items : []).map((text, i) => {
|
||||
const el = doc.createElement('div');
|
||||
el.textContent = text;
|
||||
el.dataset.index = i;
|
||||
if (i === cur.index) el.className = 'current';
|
||||
return el;
|
||||
}));
|
||||
strip.classList.toggle('shown', shown);
|
||||
if (shown) placeStrip();
|
||||
}
|
||||
// The in-page strip covers exactly the number row (up to Backspace).
|
||||
function placeStrip() {
|
||||
const row0 = [...doc.querySelectorAll('[data-key-row="0"]')].map((el) => el.getBoundingClientRect());
|
||||
if (!row0.length) return;
|
||||
const top = Math.min(...row0.map((r) => r.top)), height = Math.max(...row0.map((r) => r.bottom)) - top;
|
||||
const bs = doc.querySelector('[data-key="Backspace"]')?.getBoundingClientRect();
|
||||
strip.style.right = bs?.width && bs.top < top + height ? `${Math.max(2, win.innerWidth - bs.x + 2)}px` : '2px';
|
||||
Object.assign(strip.style, { top: `${top}px`, height: `${height}px`, fontSize: `${Math.max(10, Math.min(18, height - 10))}px` });
|
||||
}
|
||||
|
||||
function commit(inst, results) {
|
||||
const ts = inst.state.toggleStates;
|
||||
const upper = on(ts.CapsLock) || (ts.Shift & 2) !== 0, first = !upper && on(ts.Shift);
|
||||
const shape = (w) => (upper ? w.toUpperCase() : first ? w[0].toUpperCase() + w.slice(1) : w);
|
||||
if (first && (ts.Shift & 3) === 1) { // release a one-shot Shift, like a typed key
|
||||
inst.setState((s) => ({ ...s, toggleStates: { ...s.toggleStates, Shift: s.toggleStates.Shift & 4 } }));
|
||||
}
|
||||
const mgr = inst.props.VirtualKeyboardManager;
|
||||
const entry = { kind: 'swipe', items: results.map((r) => shape(r.word)), index: 0, mgr, anchor: null };
|
||||
cur = null;
|
||||
run('commit', async () => {
|
||||
if (!O.autoSpace) model.reset('no auto-space');
|
||||
const r = await model.commitWord(emitter(mgr), entry.items[0]);
|
||||
entry.anchor = r.anchor;
|
||||
cur = entry;
|
||||
log('commit', entry.items[0], r.space ? 'space' : '');
|
||||
});
|
||||
}
|
||||
function pick(i) {
|
||||
const c = cur;
|
||||
if (!c || i === c.index || c.items[i] === undefined) return;
|
||||
run('pick', async () => {
|
||||
if (cur !== c || !valid(c)) return;
|
||||
const before = model.text.slice(-24);
|
||||
// A correction keeps the typed terminator; nothing is appended otherwise.
|
||||
const a = await model.replaceAnchored(c.anchor, emitter(c.mgr), c.items[i] + (c.kind === 'correct' ? c.term : ''));
|
||||
log('pick', c.kind, c.items[i], !!a, JSON.stringify(before), JSON.stringify(model.text.slice(-24)));
|
||||
if (a) {
|
||||
Object.assign(c, { anchor: a, index: i }); // every kind can be switched again
|
||||
if (inPage) haptic(HAPTIC.pick); // a panel ticks on its own overlay
|
||||
} else if (cur === c) cur = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- strip as a SteamVR panel ("above" / "below") -------------------------------
|
||||
// State { seq, items, current, visible, style } out via __sfuiStripOut;
|
||||
// style: a letter key's look, so the panel's buttons match the keys.
|
||||
let outSeq = 0, outLast = '', published = null, keyStyleCache = null;
|
||||
function keyStyle() {
|
||||
const key = doc.querySelector('[data-key="g"]') || doc.querySelector('[data-key="a"]');
|
||||
const face = key?.firstElementChild;
|
||||
if (!face) return null;
|
||||
const board = key.closest('[class*="Layout_"]');
|
||||
const sig = `${board?.className}|${face.className}|${win.innerWidth}`;
|
||||
if (keyStyleCache?.sig === sig) return keyStyleCache.style;
|
||||
const cs = win.getComputedStyle(face), ks = win.getComputedStyle(key);
|
||||
// Pressed colour: the theme's rule for the key face plus one class.
|
||||
const faceCls = String(face.className).split(/\s+/)[0], boardCls = String(board?.className || '').split(/\s+/).filter(Boolean);
|
||||
let pressed = null;
|
||||
for (const sh of doc.styleSheets) {
|
||||
let rules; try { rules = sh.cssRules; } catch { continue; }
|
||||
pressed = [...rules].find((r) => r.selectorText?.includes(`.${faceCls}.`) && !r.selectorText.includes('::') &&
|
||||
!r.selectorText.includes('KeyTheme_') && r.style?.backgroundColor && boardCls.some((c) => r.selectorText.includes(`.${c} `)))?.style.backgroundColor;
|
||||
if (pressed) break;
|
||||
}
|
||||
const style = {
|
||||
pageWidth: win.innerWidth, pageHeight: win.innerHeight, keyHeight: face.getBoundingClientRect().height,
|
||||
pad: [ks.paddingTop, ks.paddingRight, ks.paddingBottom, ks.paddingLeft].map((v) => parseFloat(v) || 0),
|
||||
background: cs.backgroundColor, color: cs.color, fontFamily: cs.fontFamily, fontSize: parseFloat(cs.fontSize) || 16,
|
||||
fontWeight: cs.fontWeight, radius: parseFloat(cs.borderRadius) || 0, border: cs.border, boxShadow: cs.boxShadow,
|
||||
board: board ? win.getComputedStyle(board).backgroundColor : undefined, pressed: pressed || undefined,
|
||||
};
|
||||
keyStyleCache = { sig, style };
|
||||
return style;
|
||||
}
|
||||
function publish(shown, force = false) {
|
||||
let st = null;
|
||||
if (shown) try { st = keyStyle(); } catch (e) { log('key-style-error', String(e)); }
|
||||
const body = JSON.stringify(shown ? { items: cur.items, current: cur.index, visible: true, style: st, haptic: O.haptics ? HAPTIC.pick : 0, position: O.position } : { items: [], current: -1, visible: false });
|
||||
if (body === outLast && !force) return;
|
||||
outLast = body;
|
||||
published = shown ? cur : null;
|
||||
try { G.__sfuiStripOut?.(JSON.stringify({ seq: ++outSeq, ...JSON.parse(body) })); } catch (e) { log('publish-error', String(e)); }
|
||||
}
|
||||
const remote = {
|
||||
pick(seq, i) { if (seq === outSeq && published && published === cur) pick(i); else log('stale-pick', seq, outSeq); },
|
||||
sync() { outLast = ''; showStrip(); return 'ok'; },
|
||||
};
|
||||
S.remote = remote;
|
||||
cleanup.push(() => {
|
||||
if (!inPage) publish(false, true);
|
||||
if (S.remote === remote) delete S.remote;
|
||||
});
|
||||
|
||||
// ---- gestures ------------------------------------------------------------------
|
||||
// down: { kind: 'strip', index } | { kind: 'backspace', ... } | { kind: 'key', ... }.
|
||||
// Touch is read from touch events (they go on after Chromium's
|
||||
// pointercancel); a mouse from pointer events, or mouse events if there
|
||||
// are no pointer events.
|
||||
let down = null, sawPointer = false;
|
||||
const keyOf = (t) => t?.closest?.('[data-key]');
|
||||
const stripIndexOf = (t) => {
|
||||
const el = t?.closest?.('#sfui-swipe-strip > div');
|
||||
return el && strip.contains(el) ? +el.dataset.index : -1;
|
||||
};
|
||||
const at = (x, y) => doc.elementFromPoint(x, y);
|
||||
function point(e) {
|
||||
if (e.type.startsWith('touch')) {
|
||||
const t = e.type === 'touchend' || e.type === 'touchcancel' ? e.changedTouches[0] : e.touches[0];
|
||||
return t && !(e.type === 'touchstart' && e.touches.length > 1) ? [t.clientX, t.clientY, t.target] : null;
|
||||
}
|
||||
if (e.type.startsWith('pointer')) { sawPointer = true; if (e.pointerType === 'touch') return null; }
|
||||
else if (sawPointer) return null;
|
||||
return [e.clientX, e.clientY, e.target];
|
||||
}
|
||||
// Keep Steam from typing the pressed key at release and from its long press.
|
||||
function takeOver(d) {
|
||||
const inst = d.inst;
|
||||
try {
|
||||
for (const el of [...inst.m_mapTouched]) if (d.keyEl.contains(el)) inst.m_mapTouched.delete(el);
|
||||
inst.CancelLongPressTimer();
|
||||
inst.DismissLongPress();
|
||||
inst.ClearHoldTarget();
|
||||
} catch (e) { log('takeover-error', String(e)); }
|
||||
}
|
||||
function onDown(e) {
|
||||
const p = point(e);
|
||||
if (!p) return;
|
||||
const [x, y, target] = p;
|
||||
const si = stripIndexOf(target);
|
||||
if (si !== -1) { down = { kind: 'strip', index: si }; return; }
|
||||
const keyEl = keyOf(target), key = keyEl?.getAttribute('data-key'), inst = keyEl && kbInst(keyEl);
|
||||
down = null;
|
||||
if (!inst) return;
|
||||
hookInst(inst);
|
||||
if (key === 'Backspace' && O.pixelsPerChar > 0) {
|
||||
down = { kind: 'backspace', keyEl, inst, x0: x, active: false, planned: 0,
|
||||
g: model.dragStart({ px: O.pixelsPerChar, detentPx: O.wordDetentPixels }) };
|
||||
} else if (O.swipe && isLetter(key)) {
|
||||
const lay = currentLayout();
|
||||
if (lay) down = { kind: 'key', key, keyEl, inst, lay, pts: [[x, y]], active: false };
|
||||
}
|
||||
}
|
||||
// Backspace drag: a character per px of leftward travel (the first at px;
|
||||
// releasing before is Steam's tap), a detent of detentPx at a word border;
|
||||
// back right retypes what this drag deleted while the model is sure.
|
||||
function backspaceMove(d, x) {
|
||||
const travel = d.x0 - x;
|
||||
if (!d.active) {
|
||||
if (travel < O.pixelsPerChar) return;
|
||||
d.active = true;
|
||||
takeOver(d);
|
||||
refreshLater(); // hides the strip
|
||||
}
|
||||
const target = d.g.target(travel), emit = emitter(d.inst.props.VirtualKeyboardManager);
|
||||
const tick = (r) => haptic(r.border ? HAPTIC.border : HAPTIC.step);
|
||||
for (; d.planned < target; d.planned++) run('backspace', async () => tick(await model.dragDelete(d.g, emit)));
|
||||
for (; d.planned > target && d.g.restorable; d.planned--) {
|
||||
run('restore', async () => {
|
||||
const r = await model.dragRestore(d.g, emit);
|
||||
if (r) tick(r); else d.planned = d.g.applied;
|
||||
});
|
||||
}
|
||||
}
|
||||
function onMove(e) {
|
||||
const p = point(e);
|
||||
if (!p || !down || down.kind === 'strip') return;
|
||||
const [x, y] = p;
|
||||
if (down.kind === 'backspace') { backspaceMove(down, x); return; }
|
||||
const q = down.pts[down.pts.length - 1];
|
||||
if (Math.hypot(x - q[0], y - q[1]) < 2) return;
|
||||
down.pts.push([x, y]);
|
||||
if (!down.active) {
|
||||
const s = down.pts[0];
|
||||
if (Math.hypot(x - s[0], y - s[1]) <= 0.6 * down.lay.unit || keyOf(at(x, y))?.getAttribute('data-key') === down.key) return;
|
||||
down.active = true;
|
||||
takeOver(down);
|
||||
}
|
||||
drawTrail(down.pts, 'rgba(26, 159, 255, 0.75)');
|
||||
}
|
||||
function onUp(e) {
|
||||
const p = point(e);
|
||||
if (!p) return;
|
||||
const [x, y] = p, d = down;
|
||||
down = null;
|
||||
if (d?.kind === 'strip') { // released anywhere on the strip: the button pressed
|
||||
if (strip.contains(at(x, y))) pick(d.index);
|
||||
return;
|
||||
}
|
||||
if (d?.kind === 'backspace' && d.active) {
|
||||
run('backspace-log', () => { // after the drag's queued steps
|
||||
const st = d.g.stats;
|
||||
log('backspace-drag', `known ${d.g.snap.length}`, `deleted ${st.deleted}`, `restored ${st.restored}`,
|
||||
`borders ${st.borders}`, `unknown ${st.unknown}`, st.stop || 'ok');
|
||||
});
|
||||
}
|
||||
if (d?.kind !== 'key' || !d.active) return; // taps are Steam's
|
||||
d.pts.push([x, y]);
|
||||
setTimeout(() => decode(d), 0); // after Steam has handled the release
|
||||
}
|
||||
function decode(d) {
|
||||
let results = [];
|
||||
try { results = D.decode(d.lay.dec, d.pts, { max: O.count, unit: d.lay.unit }); } catch (err) { log('decode-error', String(err)); }
|
||||
log('decode', results.map((r) => r.word).join(' '));
|
||||
const paths = (G.__sfuiSwipePaths ??= []);
|
||||
paths.push({ unit: d.lay.unit, keys: d.lay.keys, pts: d.pts.map(([x, y]) => [Math.round(x), Math.round(y)]), top: results.map((r) => r.word) });
|
||||
if (paths.length > 20) paths.shift();
|
||||
if (!results.length) { drawTrail(d.pts, 'rgba(255, 80, 80, 0.75)', 400); return; }
|
||||
fadeTimer = setTimeout(clearTrail, 150);
|
||||
commit(d.inst, results);
|
||||
}
|
||||
function onCancel(e) {
|
||||
if (e.type === 'pointercancel' && e.pointerType === 'touch') return; // touch events go on
|
||||
if (down?.active) clearTrail();
|
||||
down = null;
|
||||
}
|
||||
function onHover(e) {
|
||||
const si = stripIndexOf(e.target);
|
||||
for (const el of strip.children) el.classList.toggle('hover', +el.dataset.index === si);
|
||||
}
|
||||
|
||||
const L = { capture: true, passive: true };
|
||||
// No 'blur': the VR keyboard window blurs on every touch.
|
||||
for (const [type, f] of [
|
||||
['touchstart', onDown], ['pointerdown', onDown], ['mousedown', onDown],
|
||||
['touchmove', onMove], ['pointermove', onMove], ['mousemove', onMove],
|
||||
['touchend', onUp], ['pointerup', onUp], ['mouseup', onUp],
|
||||
['touchcancel', onCancel], ['pointercancel', onCancel],
|
||||
['pointerover', onHover], ['mouseover', onHover],
|
||||
]) {
|
||||
win.addEventListener(type, f, L);
|
||||
cleanup.push(() => win.removeEventListener(type, f, L));
|
||||
}
|
||||
log('attached', VERSION);
|
||||
return () => {
|
||||
for (const f of cleanup.reverse()) { try { f(); } catch { /* page gone */ } }
|
||||
delete doc.__sfuiSwipe;
|
||||
};
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
// relay.mjs: carries the suggestion strip between Steam's keyboard page
|
||||
// (SharedJSContext, 8080) and SteamVR's systemui page (8087) for
|
||||
// suggestions.position "above" / "below". CDP bindings: the keyboard page calls
|
||||
// __sfuiStripOut(state json) -> systemui __sfuiKbdStrip.show(state); the
|
||||
// panel calls __sfuiStripPick({ seq, index } json) -> keyboard page
|
||||
// __sfuiSwipe.remote.pick(seq, index). Only these two messages, validated.
|
||||
// On (re)connects and page reloads the keyboard page re-sends its state; if
|
||||
// the keyboard page goes away, the panel is hidden.
|
||||
const SIDES = {
|
||||
steam: { url: 'http://127.0.0.1:8080/json/list', title: 'SharedJSContext', binding: '__sfuiStripOut' },
|
||||
vr: { url: 'http://127.0.0.1:8087/json/list', title: 'systemui', binding: '__sfuiStripPick' },
|
||||
};
|
||||
const HIDDEN = { seq: 0, visible: false, current: -1, items: [] };
|
||||
const live = {}; // side -> evaluate(expression)
|
||||
let lastState = HIDDEN;
|
||||
|
||||
// Key style values: numbers, short CSS values that can't end a declaration.
|
||||
function cleanStyle(st) {
|
||||
if (!st || typeof st !== 'object') return null;
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(st)) {
|
||||
if (!/^[a-zA-Z]{1,20}$/.test(k)) continue;
|
||||
if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
|
||||
else if (typeof v === 'string' && v.length < 200 && !/[;{}<>\\]/.test(v)) out[k] = v;
|
||||
else if (Array.isArray(v) && v.length <= 4 && v.every((x) => typeof x === 'number' && Number.isFinite(x))) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const show = (state) => { lastState = state; live.vr?.(`window.__sfuiKbdStrip?.show(${JSON.stringify(state)})`); };
|
||||
const resync = () => live.steam?.('window.__sfuiSwipe?.remote?.sync()');
|
||||
|
||||
function forward(side, payload) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(payload); } catch { return; }
|
||||
if (side === 'steam' && Array.isArray(msg?.items) && typeof msg.seq === 'number') {
|
||||
show({ seq: msg.seq, visible: !!msg.visible, current: Number(msg.current) | 0,
|
||||
items: msg.items.slice(0, 8).map((s) => String(s).slice(0, 64)), style: cleanStyle(msg.style),
|
||||
haptic: Math.max(0, Math.min(5, Number(msg.haptic) | 0)), position: msg.position === 'below' ? 'below' : 'above' });
|
||||
} else if (side === 'vr' && typeof msg?.seq === 'number' && typeof msg.index === 'number') {
|
||||
live.steam?.(`window.__sfuiSwipe?.remote?.pick(${msg.seq | 0}, ${msg.index | 0})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(side) {
|
||||
const cfg = SIDES[side];
|
||||
const list = await (await fetch(cfg.url, { signal: AbortSignal.timeout(3000) })).json();
|
||||
const t = list.find((x) => x.title === cfg.title && x.webSocketDebuggerUrl);
|
||||
if (!t) throw new Error(`${cfg.title} not found`);
|
||||
const ws = new WebSocket(t.webSocketDebuggerUrl);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
let id = 0;
|
||||
const send = (method, params = {}) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ id: ++id, method, params })); };
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.method === 'Runtime.bindingCalled' && m.params.name === cfg.binding) forward(side, m.params.payload);
|
||||
else if (m.method === 'Runtime.executionContextCreated') setTimeout(resync, 3500); // the injector re-patches after ~3 s
|
||||
};
|
||||
const closed = new Promise((res) => { ws.onclose = res; });
|
||||
send('Runtime.enable');
|
||||
send('Runtime.addBinding', { name: cfg.binding });
|
||||
live[side] = (expression) => send('Runtime.evaluate', { expression });
|
||||
console.log(`${side}: connected`);
|
||||
if (side === 'vr') show(lastState);
|
||||
resync();
|
||||
await closed;
|
||||
delete live[side];
|
||||
if (side === 'steam') show(HIDDEN); // no keyboard page: no strip
|
||||
console.log(`${side}: disconnected`);
|
||||
}
|
||||
for (const side of Object.keys(SIDES)) {
|
||||
(async () => {
|
||||
for (let failed = false; ; await new Promise((r) => setTimeout(r, 3000))) {
|
||||
try { await connect(side); failed = false; } catch (e) { if (!failed) console.error(`${side}: ${e.message}`); failed = true; }
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Offline check of corrector.js on the built dictionary.
|
||||
// usage: node corrector.test.mjs <corrector.js> <decoder.js> <dictionary.js>
|
||||
import { readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const C = (0, eval)(readFileSync(process.argv[2], 'utf8'));
|
||||
const D = (0, eval)(readFileSync(process.argv[3], 'utf8'));
|
||||
const dict = D.parseDict(JSON.parse(readFileSync(process.argv[4], 'utf8')));
|
||||
let t = performance.now();
|
||||
const c = C.create(dict);
|
||||
console.log(`index: ${dict.words.length} words, ${(performance.now() - t).toFixed(0)} ms`);
|
||||
// German QWERTZ key centres in key widths (as patch.js passes them).
|
||||
const KEYS = { q: [93.8, 73.5], w: [154.4, 73.5], e: [214.9, 73.5], r: [275.5, 73.5], t: [336.1, 73.5], z: [396.7, 73.5], u: [457.2, 73.5], i: [517.8, 73.5], o: [578.4, 73.5], p: [638.9, 73.5], a: [114.1, 120.5], s: [174.5, 120.5], d: [235.0, 120.5], f: [295.4, 120.5], g: [355.8, 120.5], h: [416.2, 120.5], j: [476.7, 120.5], k: [537.1, 120.5], l: [597.5, 120.5], y: [205.6, 167.5], x: [260.7, 167.5], c: [315.8, 167.5], v: [370.9, 167.5], b: [426.0, 167.5], n: [481.1, 167.5], m: [536.2, 167.5] };
|
||||
c.setLayout(Object.fromEntries(Object.entries(KEYS).map(([k, [x, y]]) => [k, [x / 60.5, y / 60.5]])));
|
||||
let passed = 0;
|
||||
const ok = (name, fn) => { fn(); passed++; console.log('ok', name); };
|
||||
const top = (w, n = 3) => c.corrections(w).slice(0, n);
|
||||
const timed = [];
|
||||
const corr = (w) => { const s = performance.now(); const r = c.corrections(w); timed.push(performance.now() - s); return r; };
|
||||
|
||||
ok('corrections in the top 3', () => {
|
||||
for (const [typed, want] of [['hsllo', 'hallo'], ['teh', 'the'], ['schoen', 'schön'], ['strasse', 'Straße'], ['cant', "can't"],
|
||||
['dont', "don't"], ['vielleciht', 'vielleicht'], ['keyboad', 'keyboard'], ['Tastatrr', 'Tastatur'], ['gehst', null]]) {
|
||||
const r = corr(typed);
|
||||
if (want === null) { assert.deepEqual(r, [], typed); continue; }
|
||||
assert.ok(r.slice(0, 3).includes(want), `${typed} -> ${r.join(', ')}`);
|
||||
}
|
||||
});
|
||||
ok('no suggestions for valid words', () => {
|
||||
for (const w of ['hallo', 'Haus', 'haus', 'schön', 'Straße', "can't", 'the', 'keyboard', 'Wetter', 'über', 'gehen']) assert.deepEqual(corr(w), [], w);
|
||||
});
|
||||
ok('capitalisation kept', () => {
|
||||
assert.ok(top('Hsllo').includes('Hallo'), top('Hsllo').join());
|
||||
assert.ok(top('HSLLO').includes('HALLO'), top('HSLLO').join());
|
||||
assert.ok(top('Schoen').includes('Schön'));
|
||||
});
|
||||
ok('completions', () => {
|
||||
const s = performance.now();
|
||||
const r = c.completions('Hal');
|
||||
timed.push(performance.now() - s);
|
||||
assert.ok(r.includes('Hallo') || r.includes('Halle') || r.includes('Hälfte'), r.join());
|
||||
assert.ok(r.every((w) => w[0] === 'H'), r.join());
|
||||
assert.ok(c.completions('schö').includes('schön'), c.completions('schö').join());
|
||||
assert.ok(c.completions('could').includes("couldn't"), c.completions('could').join());
|
||||
assert.ok(c.completions('the').length > 0 && !c.completions('the').includes('the'));
|
||||
assert.deepEqual(c.completions('xqzv'), []);
|
||||
});
|
||||
ok('fast enough', () => {
|
||||
const words = ['hsllo', 'teh', 'vielleciht', 'Donaudampfschifffahrt', 'abcdefghijklmn', 'strasse', 'ich', 'Wettr', 'schreibn', 'kannst'];
|
||||
for (const w of words) corr(w);
|
||||
timed.sort((a, b) => a - b);
|
||||
const p = timed[Math.floor(timed.length * 0.9)], maxT = timed[timed.length - 1];
|
||||
console.log(` ${timed.length} lookups: median ${timed[timed.length >> 1].toFixed(1)} ms, p90 ${p.toFixed(1)} ms, max ${maxT.toFixed(1)} ms`);
|
||||
assert.ok(p < 60, `p90 ${p} ms`);
|
||||
});
|
||||
ok('max is respected (candidates = 1 leaves no room for corrections)', () => {
|
||||
assert.deepEqual(c.corrections('hsllo', { max: 0 }), []);
|
||||
assert.equal(c.corrections('hsllo', { max: 1 }).length, 1);
|
||||
assert.deepEqual(c.completions('Hal', { max: 0 }), []);
|
||||
});
|
||||
ok('single letters and non-letters: nothing', () => {
|
||||
assert.deepEqual(c.corrections('x'), []);
|
||||
assert.deepEqual(c.corrections('123'), []);
|
||||
assert.deepEqual(c.completions(''), []);
|
||||
});
|
||||
console.log(`${passed} corrector tests passed`);
|
||||
@@ -0,0 +1,106 @@
|
||||
// Offline check of decoder.js with synthetic swipes on Steam's German VR
|
||||
// keyboard (key centres measured in the keyboard popup, 854x280 CSS px).
|
||||
// usage: node decoder.test.mjs <decoder.js> <dictionary.js> [words-per-sample] [seed]
|
||||
// Each word's path goes through its key centres with random offsets (up to
|
||||
// about half a key), cuts corners (spline) and jitters. Prints top-1/top-3
|
||||
// accuracy for frequent German and English words and a list of fixed words.
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const [, , decoderPath, dictPath, perLang = '300', seedArg = '1'] = process.argv;
|
||||
const D = (0, eval)(readFileSync(decoderPath, 'utf8'));
|
||||
const dict = D.parseDict(JSON.parse(readFileSync(dictPath, 'utf8')));
|
||||
|
||||
const KEYS = { ß: [659.0, 26.5], q: [93.8, 73.5], w: [154.4, 73.5], e: [214.9, 73.5], r: [275.5, 73.5], t: [336.1, 73.5], z: [396.7, 73.5], u: [457.2, 73.5], i: [517.8, 73.5], o: [578.4, 73.5], p: [638.9, 73.5], ü: [699.5, 73.5], a: [114.1, 120.5], s: [174.5, 120.5], d: [235.0, 120.5], f: [295.4, 120.5], g: [355.8, 120.5], h: [416.2, 120.5], j: [476.7, 120.5], k: [537.1, 120.5], l: [597.5, 120.5], ö: [658.0, 120.5], ä: [718.4, 120.5], y: [205.6, 167.5], x: [260.7, 167.5], c: [315.8, 167.5], v: [370.9, 167.5], b: [426.0, 167.5], n: [481.1, 167.5], m: [536.2, 167.5] };
|
||||
const UNIT = 60.5; // key width (px)
|
||||
const t0 = Date.now();
|
||||
const lay = D.layout(dict, KEYS, UNIT);
|
||||
console.log(`dictionary: ${dict.words.length} entries, layout ${Date.now() - t0} ms`);
|
||||
|
||||
let seed = +seedArg;
|
||||
const rnd = () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; };
|
||||
const gauss = () => Math.sqrt(-2 * Math.log(rnd() + 1e-12)) * Math.cos(2 * Math.PI * rnd());
|
||||
const clamp = (v, m) => Math.max(-m, Math.min(m, v));
|
||||
|
||||
let JITTER = 1.5; // px per sample (laser shake)
|
||||
function swipe(word, sigma) {
|
||||
const seq = [];
|
||||
for (const c of word.toLowerCase()) if (KEYS[c] && seq[seq.length - 1] !== c) seq.push(c);
|
||||
const way = seq.map((c) => [KEYS[c][0] + clamp(gauss() * sigma, 0.45) * UNIT, KEYS[c][1] + clamp(gauss() * sigma, 0.4) * 47]);
|
||||
// Catmull-Rom through the waypoints, ~3 px steps, plus jitter.
|
||||
const pts = [];
|
||||
const P = (i) => way[Math.max(0, Math.min(way.length - 1, i))];
|
||||
for (let i = 0; i < way.length - 1; i++) {
|
||||
const [p0, p1, p2, p3] = [P(i - 1), P(i), P(i + 1), P(i + 2)];
|
||||
const steps = Math.max(2, Math.ceil(Math.hypot(p2[0] - p1[0], p2[1] - p1[1]) / 3));
|
||||
for (let s = 0; s < steps; s++) {
|
||||
const t = s / steps, t2 = t * t, t3 = t2 * t;
|
||||
const f = (a, b, c, d) => 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3);
|
||||
pts.push([f(p0[0], p1[0], p2[0], p3[0]) + gauss() * JITTER, f(p0[1], p1[1], p2[1], p3[1]) + gauss() * JITTER]);
|
||||
}
|
||||
}
|
||||
pts.push(way[way.length - 1]);
|
||||
return pts;
|
||||
}
|
||||
|
||||
function evaluate(words, sigma, label, show = false) {
|
||||
let top1 = 0, top3 = 0, ms = 0;
|
||||
const misses = [];
|
||||
for (const w of words) {
|
||||
const t = performance.now();
|
||||
const res = D.decode(lay, swipe(w, sigma), { max: 5, unit: UNIT });
|
||||
ms += performance.now() - t;
|
||||
const rank = res.findIndex((r) => r.word === w);
|
||||
if (rank === 0) top1++;
|
||||
if (rank >= 0 && rank < 3) top3++;
|
||||
if (show || rank < 0 || rank >= 3) misses.push(`${w} -> ${res.slice(0, 3).map((r) => r.word).join(', ')}`);
|
||||
}
|
||||
const pc = (n) => `${((100 * n) / words.length).toFixed(1)}%`;
|
||||
console.log(`${label} (n=${words.length}, sigma ${sigma}): top-1 ${pc(top1)}, top-3 ${pc(top3)}, ${(ms / words.length).toFixed(1)} ms/word`);
|
||||
return misses;
|
||||
}
|
||||
|
||||
// Frequent words per language: German ones contain an umlaut/ß or are in the
|
||||
// de list's typical forms; simplest: take dictionary entries by rank and tell
|
||||
// the language by a list check is not possible offline, so sample fixed ranges.
|
||||
const pick = (from, to, n) => {
|
||||
const pool = dict.words.slice(from, to).filter((w) => [...w].length >= 2 && [...w].every((c) => KEYS[c.toLowerCase()] || "'-".includes(c)));
|
||||
const out = [];
|
||||
for (let i = 0; i < n && pool.length; i++) out.push(pool.splice(Math.floor(rnd() * pool.length), 1)[0]);
|
||||
return out;
|
||||
};
|
||||
const n = +perLang;
|
||||
const frequent = pick(0, 5000, n), mid = pick(5000, 30000, n);
|
||||
const FIXED_DE = ['hallo', 'danke', 'bitte', 'Haus', 'schön', 'für', 'über', 'Straße', 'heute', 'morgen', 'wir', 'nicht', 'vielleicht', 'Tastatur', 'Wetter', 'gehen', 'können', 'möchte', 'Brille', 'spielen'];
|
||||
const FIXED_EN = ["couldn't", "don't", "can't", "geht's", 'hello', 'thanks', 'the', 'keyboard', 'swipe', 'typing', 'world', 'would', 'should', 'because', 'people', 'something', 'window', 'games', 'steam'];
|
||||
for (const sigma of [0.15, 0.25, 0.35]) {
|
||||
evaluate(frequent, sigma, 'rank <5000');
|
||||
evaluate(mid, sigma, 'rank 5000-30000');
|
||||
}
|
||||
const fixedMisses = evaluate([...FIXED_DE, ...FIXED_EN], 0.25, 'fixed de+en', true);
|
||||
console.log(fixedMisses.join('\n'));
|
||||
// Short words and contractions, several noisy paths each.
|
||||
const SHORT = ["let's", "it's", "I'm", "that's", "don't", "can't", "he's", "we're", "geht's", "gibt's", 'lets', 'its', 'the', 'und', 'ist'];
|
||||
const rep = []; for (let r = 0; r < 5; r++) rep.push(...SHORT);
|
||||
console.log(evaluate(rep, 0.25, 'short words x5').join('\n'));
|
||||
// Extra words from the configuration swipe like any other word.
|
||||
for (const w of ['config', 'GitHub', 'SteamVR']) {
|
||||
if (!dict.words.includes(w)) continue;
|
||||
const top = D.decode(lay, swipe(w, 0.15), { max: 3, unit: UNIT }).map((r) => r.word);
|
||||
console.log(`${w}: ${top.join(', ')}`);
|
||||
if (top[0] !== w) { console.error(`${w} is not top-1 (sigma 0.15)`); process.exitCode = 1; }
|
||||
}
|
||||
// Words that share their letters with a contraction stay (is / i's, well / we'll).
|
||||
{
|
||||
const must = (cond, msg) => { if (!cond) { console.error(msg); process.exitCode = 1; } };
|
||||
must(dict.words.includes('is'), '"is" missing from the dictionary');
|
||||
const is = D.decode(lay, swipe('is', 0), { max: 3, unit: UNIT }).map((r) => r.word);
|
||||
console.log(`is: ${is.join(', ')}`);
|
||||
must(is[0] === 'is', '"is" is not top-1 for an i-s swipe');
|
||||
const well = D.decode(lay, swipe('well', 0), { max: 5, unit: UNIT }).map((r) => r.word);
|
||||
console.log(`well: ${well.join(', ')}`);
|
||||
must(well.includes('well') && well.includes("we'll"), 'well and we\'ll not both offered');
|
||||
}
|
||||
// Shaky laser: 5 px jitter per sample.
|
||||
JITTER = 5;
|
||||
console.log(evaluate(rep, 0.25, 'short words x5, shaky').join('\n'));
|
||||
evaluate(frequent, 0.25, 'rank <5000, shaky');
|
||||
@@ -0,0 +1,354 @@
|
||||
// textmodel.js against a simulated text field. Like patch.js: every emitted
|
||||
// key reaches the model; a tapped key also freezes anchors first (the
|
||||
// TypeKeyInternal hook). usage: node textmodel.test.mjs <textmodel.js>
|
||||
import { readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const T = (0, eval)(readFileSync(process.argv[2], 'utf8'));
|
||||
let passed = 0;
|
||||
function setup(size = 64) {
|
||||
const m = T.create({ size });
|
||||
const f = { text: '' };
|
||||
const raw = (key) => {
|
||||
if (key === 'Backspace') f.text = [...f.text].slice(0, -1).join('');
|
||||
else if ([...key].length === 1) f.text += key;
|
||||
else if (key === 'Enter') f.text += '\n';
|
||||
m.observe(key);
|
||||
};
|
||||
const out = (key) => { m.freeze(); raw(key); }; // a tapped key
|
||||
// Our output, with a pause after non-ASCII characters (as in the patch).
|
||||
const emit = async (k) => { raw(k); if (k.codePointAt(0) > 127) await new Promise((r) => setTimeout(r, 1)); };
|
||||
const tap = (s) => { for (const c of s) out(c); };
|
||||
// The last swipe, as patch.js tracks it.
|
||||
let last = null;
|
||||
const swipe = async (w) => { const r = await m.commitWord(emit, w); last = r.anchor; return r; };
|
||||
const replace = async (w) => { const a = await m.replaceAnchored(last, emit, w); if (a) last = a; return !!a; };
|
||||
return { m, f, out, emit, tap, swipe, replace };
|
||||
}
|
||||
async function test(name, fn) { await fn(); passed++; console.log('ok', name); }
|
||||
|
||||
await test('switch 3 -> 7 -> 2 letters, no space at start', async () => {
|
||||
const { m, f, emit, swipe, replace } = setup();
|
||||
await swipe('die');
|
||||
assert.equal(f.text, 'die');
|
||||
assert.ok(await replace('dienten'));
|
||||
assert.equal(f.text, 'dienten');
|
||||
assert.ok(await replace('du'));
|
||||
assert.equal(f.text, 'du');
|
||||
});
|
||||
await test('auto-space after a swipe, replacement keeps it', async () => {
|
||||
const { m, f, emit, swipe, replace } = setup();
|
||||
await swipe('Hallo');
|
||||
await swipe('die');
|
||||
assert.equal(f.text, 'Hallo die');
|
||||
await replace('dienten'); await replace('du');
|
||||
assert.equal(f.text, 'Hallo du');
|
||||
});
|
||||
await test('umlauts and ß count as one character each', async () => {
|
||||
const { m, f, emit, tap, swipe, replace } = setup();
|
||||
tap('Ja');
|
||||
await swipe('schön');
|
||||
assert.equal(f.text, 'Ja schön');
|
||||
await replace('Straße'); assert.equal(f.text, 'Ja Straße');
|
||||
await replace('für'); assert.equal(f.text, 'Ja für');
|
||||
await replace('übergrößen'); assert.equal(f.text, 'Ja übergrößen');
|
||||
});
|
||||
await test('auto-space rules', async () => {
|
||||
for (const [before, space] of [['Hallo', true], ['Hallo ', false], ['Hallo.', true], ['Hallo,', true], ['wirklich?', true],
|
||||
['(', false], ['„', false], ['"', false], ['a-', false], ['', false]]) {
|
||||
const { m, f, emit, tap, swipe, replace } = setup();
|
||||
tap(before);
|
||||
await swipe('du');
|
||||
assert.equal(f.text, before + (space ? ' ' : '') + 'du', `after ${JSON.stringify(before)}`);
|
||||
}
|
||||
});
|
||||
await test('manual Backspace inside the word freezes it; buffer stays right', async () => {
|
||||
const { m, f, emit, out, swipe, replace } = setup();
|
||||
await swipe('Hallo'); await swipe('Welt');
|
||||
out('Backspace');
|
||||
assert.equal(f.text, 'Hallo Wel');
|
||||
assert.equal(await replace('du'), false);
|
||||
assert.equal(f.text, 'Hallo Wel');
|
||||
// Delete the rest of the word: the auto-space stays, the next swipe adds none.
|
||||
for (let i = 0; i < 3; i++) out('Backspace');
|
||||
assert.equal(f.text, 'Hallo ');
|
||||
await swipe('du');
|
||||
assert.equal(f.text, 'Hallo du');
|
||||
// And after deleting the space too, one space again.
|
||||
for (let i = 0; i < 3; i++) out('Backspace');
|
||||
await swipe('du');
|
||||
assert.equal(f.text, 'Hallo du');
|
||||
assert.ok(await replace('die')); assert.equal(f.text, 'Hallo die');
|
||||
});
|
||||
await test('a tap after the word freezes it; next swipe spaced', async () => {
|
||||
const { m, f, emit, tap, swipe, replace } = setup();
|
||||
await swipe('Hallo');
|
||||
tap('!');
|
||||
assert.equal(await replace('Halle'), false);
|
||||
assert.equal(f.text, 'Hallo!');
|
||||
await swipe('wie');
|
||||
assert.equal(f.text, 'Hallo! wie');
|
||||
tap(' ');
|
||||
await swipe('geht');
|
||||
assert.equal(f.text, 'Hallo! wie geht');
|
||||
});
|
||||
await test('tap, then Backspace of that tap: word still frozen (never mangles)', async () => {
|
||||
const { m, f, emit, tap, out, swipe, replace } = setup();
|
||||
await swipe('Hallo');
|
||||
tap('x'); out('Backspace');
|
||||
assert.equal(await replace('Halle'), false);
|
||||
assert.equal(f.text, 'Hallo');
|
||||
});
|
||||
await test('Enter resets: no space, no replacement', async () => {
|
||||
const { m, f, emit, out, swipe, replace } = setup();
|
||||
await swipe('Hallo');
|
||||
out('Enter');
|
||||
assert.equal(await replace('Halle'), false);
|
||||
await swipe('du');
|
||||
assert.equal(f.text, 'Hallo\ndu');
|
||||
});
|
||||
await test('reset (idle, keyboard closed, target change): no space, frozen', async () => {
|
||||
const { m, f, emit, swipe, replace } = setup();
|
||||
await swipe('Hallo');
|
||||
m.reset('idle');
|
||||
await swipe('du');
|
||||
assert.equal(f.text, 'Hallodu');
|
||||
assert.ok(await replace('die')); assert.equal(f.text, 'Hallodie');
|
||||
});
|
||||
await test('other keys (arrows, Tab) reset', async () => {
|
||||
const { m, f, emit, out, swipe, replace } = setup();
|
||||
await swipe('Hallo');
|
||||
out('ArrowLeft');
|
||||
assert.equal(await replace('Halle'), false);
|
||||
await swipe('du');
|
||||
assert.equal(f.text, 'Hallodu');
|
||||
});
|
||||
await test('buffer overflow: bounded, long word beyond the buffer frozen', async () => {
|
||||
const { m, f, emit, tap, swipe, replace } = setup(8);
|
||||
tap('abcdefghijklmnop');
|
||||
assert.equal(m.text, 'ijklmnop');
|
||||
await swipe('du');
|
||||
assert.equal(f.text, 'abcdefghijklmnop du');
|
||||
assert.equal(m.text.length, 8);
|
||||
assert.ok(await replace('die'));
|
||||
await swipe('Donaudampfschiff'); // 1 + 16 > 8: can't prove it intact
|
||||
assert.equal(await replace('x'), false);
|
||||
assert.equal(f.text, 'abcdefghijklmnop die Donaudampfschiff');
|
||||
});
|
||||
await test('deleting past the known text: unknown, no auto-space', async () => {
|
||||
const { m, f, emit, tap, out, swipe, replace } = setup();
|
||||
tap('ab'); out('Backspace'); out('Backspace'); out('Backspace');
|
||||
await swipe('du');
|
||||
assert.equal(f.text, 'du');
|
||||
});
|
||||
await test("contractions: apostrophes count as characters", async () => {
|
||||
const { m, f, emit, tap, swipe, replace } = setup();
|
||||
tap('I');
|
||||
await swipe("couldn't");
|
||||
assert.equal(f.text, "I couldn't");
|
||||
assert.ok(await replace('could')); assert.equal(f.text, 'I could');
|
||||
assert.ok(await replace("can't")); assert.equal(f.text, "I can't");
|
||||
assert.ok(await replace('E-Mail')); assert.equal(f.text, 'I E-Mail');
|
||||
tap("'");
|
||||
await swipe('geht');
|
||||
assert.equal(f.text, "I E-Mail'geht", "' may open a quote: no space");
|
||||
});
|
||||
// A Backspace drag as patch.js does it: characters deleted after `travel`
|
||||
// px, with px per character and a detent of detentPx before a word border.
|
||||
function dragDeletes(borders, travel, px = 25, detentPx = 90) {
|
||||
let need = 0, k = 0;
|
||||
for (;;) {
|
||||
need += px + (borders[k] ? detentPx : 0);
|
||||
if (need > travel) return k;
|
||||
k++;
|
||||
}
|
||||
}
|
||||
await test('Backspace drag: word borders', async () => {
|
||||
for (const [typed, expect] of [['Hallo wie', 'Hallo '], ['Hallo wie ', 'Hallo '], ['Hallo wie. ', 'Hallo '], ['Hallo wie?', 'Hallo '],
|
||||
["I couldn't", 'I '], ['per E-Mail', 'per '], ['Hallo', ''], ['Hallo, ', ''], ['a b ', 'a '], ['x(y', 'x(']]) {
|
||||
const { m, f, tap, out, swipe, replace } = setup();
|
||||
tap(typed);
|
||||
const b = m.wordBorders();
|
||||
const stop = b.indexOf(true);
|
||||
// Far past the word (up to 100 px more), but not the 25 + 90 px of the border.
|
||||
const n = dragDeletes(b, 25 * (stop === -1 ? b.length : stop) + 100);
|
||||
if (stop !== -1) assert.equal(n, stop, JSON.stringify(typed));
|
||||
for (let i = 0; i < Math.min(n, b.length); i++) out('Backspace');
|
||||
assert.equal(f.text, expect, JSON.stringify(typed));
|
||||
}
|
||||
});
|
||||
await test('Backspace drag: crossing a border with enough travel, consecutive drags', async () => {
|
||||
const { m, f, tap, out, swipe, replace } = setup();
|
||||
tap('Hallo wie geht');
|
||||
const drag = (travel) => { const n = dragDeletes(m.wordBorders(), travel); for (let i = 0; i < n; i++) out('Backspace'); };
|
||||
drag(200); // "geht" = 4 x 25 = 100 px; the space needs 25 + 90 more
|
||||
assert.equal(f.text, 'Hallo wie ');
|
||||
drag(25 + 90 + 3 * 25 + 10); // across the border: the space and "wie", stops before the next
|
||||
assert.equal(f.text, 'Hallo ');
|
||||
drag(25 + 25); // only what's before the cursor's space goes first: " " then "o"
|
||||
assert.equal(f.text, 'Hall');
|
||||
drag(1000); assert.equal(f.text, '');
|
||||
const b = m.wordBorders();
|
||||
assert.deepEqual(b, [], 'nothing known: no borders (plain per character)');
|
||||
f.text = 'unbekannt';
|
||||
drag(5 * 25); assert.equal(f.text, 'unbe', 'unknown text: plain per character');
|
||||
});
|
||||
await test('Backspace drag: swiped word and its auto-space', async () => {
|
||||
const { m, f, emit, out, swipe, replace } = setup();
|
||||
await swipe('Hallo'); await swipe('wie');
|
||||
const n = dragDeletes(m.wordBorders(), 150);
|
||||
for (let i = 0; i < n; i++) out('Backspace');
|
||||
assert.equal(f.text, 'Hallo ', 'the swiped word; its auto-space stays (border)');
|
||||
});
|
||||
// A drag gesture as patch.js runs it: move to `travel`, deletes/restores.
|
||||
async function dragTo(m, g, emit, travel) {
|
||||
const t = g.target(travel);
|
||||
while (g.applied < t) await m.dragDelete(g, emit);
|
||||
while (g.applied > t) if (!(await m.dragRestore(g, emit))) break;
|
||||
}
|
||||
await test('drag back right restores: 5 -> 3 -> 5 -> 0', async () => {
|
||||
const { m, f, tap, emit, swipe, replace } = setup();
|
||||
tap('Hallo Welt');
|
||||
const g = m.dragStart({ px: 25, detentPx: 90 });
|
||||
await dragTo(m, g, emit, 5 * 25 + 89); assert.equal(f.text, 'Hallo ', 'Welt, stuck before the space');
|
||||
await dragTo(m, g, emit, 25 + 5); assert.equal(f.text, 'Hallo Wel', 'restore to 1 deleted');
|
||||
await dragTo(m, g, emit, 3 * 25); assert.equal(f.text, 'Hallo W');
|
||||
await dragTo(m, g, emit, 5 * 25 + 90); assert.equal(f.text, 'Hallo', 'across the border');
|
||||
await dragTo(m, g, emit, -50); assert.equal(f.text, 'Hallo Welt', 'all restored, not more');
|
||||
assert.equal(m.text, 'Hallo Welt');
|
||||
});
|
||||
await test('drag restore across a word border (symmetric detent)', async () => {
|
||||
const { m, f, tap, emit, swipe, replace } = setup();
|
||||
tap('ab cd');
|
||||
const g = m.dragStart({ px: 25, detentPx: 90 });
|
||||
await dragTo(m, g, emit, 2 * 25 + 90 + 24); assert.equal(f.text, 'ab ', 'at the border: 25 + 90 px more needed');
|
||||
await dragTo(m, g, emit, 2 * 25 + 90 + 25); assert.equal(f.text, 'ab', 'over the border');
|
||||
await dragTo(m, g, emit, 2 * 25 + 90 + 24); assert.equal(f.text, 'ab ', 'back over the border (same distance)');
|
||||
await dragTo(m, g, emit, 0); assert.equal(f.text, 'ab cd');
|
||||
});
|
||||
await test('drag: after an unknown delete nothing is retyped; umlauts are', async () => {
|
||||
const { m, f, tap, emit, swipe, replace } = setup();
|
||||
f.text = 'alt '; // not typed by the keyboard
|
||||
tap('für');
|
||||
const g = m.dragStart({ px: 25, detentPx: 90 });
|
||||
await dragTo(m, g, emit, 3 * 25); assert.equal(f.text, 'alt ');
|
||||
await dragTo(m, g, emit, 3 * 25 + 114); assert.equal(f.text, 'alt ', 'stuck at the start of the known text');
|
||||
await dragTo(m, g, emit, 3 * 25 + 115 + 25); assert.equal(f.text, 'al', 'blind deletes past the known text');
|
||||
await dragTo(m, g, emit, 0); assert.equal(f.text, 'al', 'strict: unknown deleted, nothing retyped');
|
||||
const { m: m2, f: f2, tap: tap2, emit: emit2 } = setup();
|
||||
tap2('Grüße');
|
||||
const g2 = m2.dragStart({ px: 25, detentPx: 90 });
|
||||
await dragTo(m2, g2, emit2, 4 * 25); assert.equal(f2.text, 'G');
|
||||
await dragTo(m2, g2, emit2, 0); assert.equal(f2.text, 'Grüße');
|
||||
});
|
||||
await test('drag: something else typed in between stops restoring', async () => {
|
||||
const { m, f, tap, emit, swipe, replace } = setup();
|
||||
tap('Hallo');
|
||||
const g = m.dragStart({ px: 25, detentPx: 90 });
|
||||
await dragTo(m, g, emit, 2 * 25); assert.equal(f.text, 'Hal');
|
||||
tap('x');
|
||||
await dragTo(m, g, emit, 0); assert.equal(f.text, 'Halx');
|
||||
});
|
||||
await test('drag over a swiped word: restored or not, it stays frozen', async () => {
|
||||
const { m, f, emit, swipe, replace } = setup();
|
||||
await swipe('Hallo'); await swipe('wie');
|
||||
const g = m.dragStart({ px: 25, detentPx: 90 });
|
||||
await dragTo(m, g, emit, 50); await dragTo(m, g, emit, 0);
|
||||
assert.equal(f.text, 'Hallo wie');
|
||||
assert.equal(await replace('die'), false, 'touched: frozen');
|
||||
});
|
||||
await test('tap-typed words: whole only when the start is known', async () => {
|
||||
const { m, tap, out, swipe, replace } = setup();
|
||||
tap('hsllo ');
|
||||
assert.equal(m.endedWord(), null, 'after an idle/unknown start the word may be longer');
|
||||
out('Enter'); // boundary
|
||||
tap('hsllo ');
|
||||
assert.deepEqual(m.endedWord(), { word: 'hsllo', term: ' ', n: 6 });
|
||||
tap('Wie');
|
||||
assert.deepEqual(m.currentWord(), { text: 'Wie', n: 3 });
|
||||
tap('?');
|
||||
assert.deepEqual(m.endedWord(), { word: 'Wie', term: '?', n: 4 });
|
||||
tap(' ');
|
||||
assert.equal(m.endedWord(), null, 'two terminators: the word is not "just finished"');
|
||||
m.reset('opened', { boundary: true }); tap('ab');
|
||||
assert.deepEqual(m.currentWord(), { text: 'ab', n: 2 });
|
||||
});
|
||||
await test('correction replace keeps the terminator; edits freeze the anchor', async () => {
|
||||
const { m: m2, f: f2, emit: e2, tap: t2, out: o2, swipe, replace } = setup();
|
||||
o2('Enter'); f2.text = '';
|
||||
t2('Ich sage hsllo,');
|
||||
const w = m2.endedWord();
|
||||
assert.deepEqual([w.word, w.term, w.n], ['hsllo', ',', 6]);
|
||||
let a = m2.anchor(w.n);
|
||||
a = await m2.replaceAnchored(a, e2, 'hallo' + w.term); assert.equal(f2.text, 'Ich sage hallo,');
|
||||
a = await m2.replaceAnchored(a, e2, 'Hallen' + w.term); assert.equal(f2.text, 'Ich sage Hallen,');
|
||||
a = await m2.replaceAnchored(a, e2, 'hsllo' + w.term); assert.equal(f2.text, 'Ich sage hsllo,', 'back to the original');
|
||||
t2(' ');
|
||||
assert.equal(await m2.replaceAnchored(a, e2, 'hallo,'), null, 'typed after it: frozen');
|
||||
o2('Backspace');
|
||||
assert.equal(await m2.replaceAnchored(a, e2, 'hallo,'), null, 'a tap froze it, even once deleted again');
|
||||
assert.equal(f2.text, 'Ich sage hsllo,');
|
||||
assert.equal(await m2.replaceAnchored(m2.anchor(0), e2, 'x'), null, 'empty anchor');
|
||||
const b = m2.anchor(6); m2.observe('Backspace'); m2.observe(','); // untagged re-typing, no freeze
|
||||
assert.equal(await m2.replaceAnchored(b, e2, 'x'), null, 'retyped terminator: new characters, not provably the same');
|
||||
});
|
||||
await test('completion: prefix -> word (no space), switchable incl. back to the prefix, next swipe auto-spaced', async () => {
|
||||
const { m, f, emit, tap, out, swipe } = setup();
|
||||
out('Enter'); f.text = '';
|
||||
tap('Das ist schö');
|
||||
const cw = m.currentWord();
|
||||
assert.deepEqual(cw, { text: 'schö', n: 4 });
|
||||
let a = await m.replaceAnchored(m.anchor(cw.n), emit, 'schön');
|
||||
assert.equal(f.text, 'Das ist schön');
|
||||
a = await m.replaceAnchored(a, emit, 'schöne');
|
||||
assert.equal(f.text, 'Das ist schöne', 'another completion replaces the picked one exactly');
|
||||
a = await m.replaceAnchored(a, emit, 'schö');
|
||||
assert.equal(f.text, 'Das ist schö', 'back to the typed prefix (first strip entry)');
|
||||
a = await m.replaceAnchored(a, emit, 'schöne');
|
||||
await swipe('Tag');
|
||||
assert.equal(f.text, 'Das ist schöne Tag', 'the next swipe gets its auto-space');
|
||||
});
|
||||
await test('idle reset is a word boundary (suggestions for the next word)', async () => {
|
||||
const { m, tap } = setup();
|
||||
tap('abc');
|
||||
m.reset('idle', { boundary: true });
|
||||
tap('hsllo ');
|
||||
assert.deepEqual(m.endedWord(), { word: 'hsllo', term: ' ', n: 6 });
|
||||
});
|
||||
await test('drag: "hello world" stops at "hello ", then at the known start', async () => {
|
||||
const { m, f, tap, emit } = setup();
|
||||
m.reset('opened', { boundary: true }); f.text = 'alt '; // text before the keyboard's
|
||||
tap('hello world');
|
||||
const g = m.dragStart({ px: 25, detentPx: 90 });
|
||||
await dragTo(m, g, emit, 5 * 25 + 114); assert.equal(f.text, 'alt hello ', 'world gone, stuck before the space');
|
||||
await dragTo(m, g, emit, 5 * 25 + 115 + 5 * 25 + 114); assert.equal(f.text, 'alt ', 'space + hello gone, stuck at the known start');
|
||||
await dragTo(m, g, emit, 5 * 25 + 115 + 5 * 25 + 115); assert.equal(f.text, 'alt', 'across the start: unknown text');
|
||||
assert.equal(g.stats.unknown, 1);
|
||||
await dragTo(m, g, emit, 0); assert.equal(f.text, 'alt', 'strict: after an unknown delete nothing is retyped');
|
||||
assert.deepEqual([g.stats.restored, g.stats.stop], [0, 'unknown deleted']);
|
||||
const { m: m2, f: f2, tap: tap2, emit: emit2 } = setup();
|
||||
m2.reset('opened', { boundary: true }); f2.text = 'alt ';
|
||||
tap2('hello world');
|
||||
const g2 = m2.dragStart({ px: 25, detentPx: 90 });
|
||||
await dragTo(m2, g2, emit2, 5 * 25 + 115 + 5 * 25 + 114); assert.equal(f2.text, 'alt ', 'at the known start');
|
||||
await dragTo(m2, g2, emit2, 0); assert.equal(f2.text, 'alt hello world', 'no unknown delete: full restore');
|
||||
assert.deepEqual([g2.stats.restored, g2.stats.stop], [11, '']);
|
||||
});
|
||||
await test('drag: start of a line (after Enter) is a border too', async () => {
|
||||
const { m, f, out, emit } = setup();
|
||||
out('Enter');
|
||||
const g = m.dragStart({ px: 25, detentPx: 90 });
|
||||
await dragTo(m, g, emit, 110); assert.equal(f.text, '\n', 'not yet: 25 + 90 px');
|
||||
await dragTo(m, g, emit, 115); assert.equal(f.text, '');
|
||||
});
|
||||
await test('drag: suggestion lookups during the drag do not stop restoring', async () => {
|
||||
const { m, f, tap, emit } = setup();
|
||||
m.reset('opened', { boundary: true });
|
||||
tap('hello wor');
|
||||
const g = m.dragStart({ px: 25, detentPx: 90 });
|
||||
await dragTo(m, g, emit, 50);
|
||||
m.currentWord(); m.endedWord(); m.anchor(2); // what suggest() reads
|
||||
await dragTo(m, g, emit, 0); assert.equal(f.text, 'hello wor');
|
||||
assert.equal(g.stats.stop, '');
|
||||
});
|
||||
console.log(`${passed} text model tests passed`);
|
||||
@@ -0,0 +1,165 @@
|
||||
// textmodel.js: what the VR keyboard itself typed recently (it can't read the
|
||||
// text field). Fed with every key the keyboard emits, in order (observe):
|
||||
// single characters, "Backspace"; anything else resets it. Evaluates to
|
||||
// { VERSION, create }.
|
||||
//
|
||||
// Buffer: the last `size` code points as emitted; empty = unknown.
|
||||
// Anchors: a slice of buffer entries; intact while exactly those entries (by
|
||||
// identity) end the buffer and no freeze() / reset() happened since. Every
|
||||
// replacement (swipe candidates, corrections, completions) goes through an
|
||||
// intact anchor, so it deletes exactly what it typed or nothing.
|
||||
(() => {
|
||||
const VERSION = 5;
|
||||
const TERMINATORS = new Set([...' .,!?;:']);
|
||||
const OPENING = new Set([...'([{<"\'„“‚‘«‹¿¡/@#-_']); // no auto-space after these
|
||||
const isWordChar = (c) => /[\p{L}\p{N}'’-]/u.test(c);
|
||||
|
||||
function create({ size = 64 } = {}) {
|
||||
let buf = []; // [{ c }]
|
||||
let epoch = 0; // bumped by reset/freeze: older anchors are void
|
||||
const chars = (from = 0, to = buf.length) => buf.slice(from, to).map((e) => e.c).join('');
|
||||
const m = {
|
||||
version: 0, // bumped on every change (for caching suggestions)
|
||||
startKnown: false, // the buffer starts at a word boundary (Enter, field opened)
|
||||
get text() { return chars(); },
|
||||
reset(why, { boundary = false } = {}) {
|
||||
buf = [];
|
||||
m.startKnown = boundary;
|
||||
m.version++;
|
||||
epoch++;
|
||||
m.lastReset = why;
|
||||
},
|
||||
freeze() { epoch++; },
|
||||
// Returns false if the key reset the model.
|
||||
observe(key) {
|
||||
m.version++;
|
||||
if (key === 'Backspace') {
|
||||
if (!buf.length) m.startKnown = false; // deleting text the model never saw
|
||||
buf.pop();
|
||||
} else if (typeof key === 'string' && [...key].length === 1) {
|
||||
buf.push({ c: key });
|
||||
if (buf.length > size) { buf.splice(0, buf.length - size); m.startKnown = false; }
|
||||
} else {
|
||||
m.reset(`key ${key}`, { boundary: key === 'Enter' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
anchor(n) { return n > 0 && n <= buf.length ? { entries: buf.slice(-n), epoch } : null; },
|
||||
anchorIntact(a) {
|
||||
if (!a || a.epoch !== epoch || a.entries.length > buf.length) return false;
|
||||
const o = buf.length - a.entries.length;
|
||||
return a.entries.every((e, i) => buf[o + i] === e);
|
||||
},
|
||||
// Replace the anchored characters by text; returns the new anchor
|
||||
// (over text), or null if the anchor isn't intact. emit(key) sends one
|
||||
// key to the keyboard's output, which calls observe() synchronously; it
|
||||
// may return a promise (a pause after asynchronously typed characters).
|
||||
async replaceAnchored(a, emit, text) {
|
||||
if (!m.anchorIntact(a)) return null;
|
||||
for (let i = 0; i < a.entries.length; i++) await emit('Backspace');
|
||||
for (const c of text) await emit(c);
|
||||
return m.anchor([...text].length);
|
||||
},
|
||||
|
||||
// ---- swipe --------------------------------------------------------------
|
||||
// Auto-space only after a known character that isn't whitespace or
|
||||
// opening punctuation. Returns { anchor (the word, not its space), space }.
|
||||
async commitWord(emit, word) {
|
||||
const e = buf[buf.length - 1];
|
||||
const space = !!e && !/\s/u.test(e.c) && !OPENING.has(e.c);
|
||||
if (space) await emit(' ');
|
||||
for (const c of word) await emit(c);
|
||||
return { anchor: m.anchor([...word].length), space };
|
||||
},
|
||||
|
||||
// ---- tap-typed words ----------------------------------------------------
|
||||
// The word at the end, if known whole (a known non-word character or a
|
||||
// boundary before it): { text, n }.
|
||||
currentWord() {
|
||||
let i = buf.length;
|
||||
while (i > 0 && isWordChar(buf[i - 1].c)) i--;
|
||||
if (i === buf.length || (i === 0 && !m.startKnown)) return null;
|
||||
return { text: chars(i), n: buf.length - i };
|
||||
},
|
||||
// A word just finished by one terminator: { word, term, n (incl. term) }.
|
||||
endedWord() {
|
||||
const e = buf[buf.length - 1];
|
||||
if (!e || !TERMINATORS.has(e.c)) return null;
|
||||
let i = buf.length - 1;
|
||||
while (i > 0 && isWordChar(buf[i - 1].c)) i--;
|
||||
if (i === buf.length - 1 || (i === 0 && !m.startKnown)) return null;
|
||||
return { word: chars(i, -1), term: e.c, n: buf.length - i };
|
||||
},
|
||||
|
||||
// ---- Backspace drag -----------------------------------------------------
|
||||
// borders[k-1]: deleting the k-th character from the end crosses a word
|
||||
// border (the word at the cursor is gone, its space/punctuation would
|
||||
// be next): "Hallo wie geht" sticks at "Hallo wie ". Whitespace and
|
||||
// punctuation at the cursor go first, without a border. Known text only.
|
||||
wordBorders() {
|
||||
const out = [];
|
||||
let state = 'tail';
|
||||
for (let i = buf.length - 1; i >= 0; i--) {
|
||||
const w = isWordChar(buf[i].c);
|
||||
out.push(state === 'word' && !w);
|
||||
if (state === 'tail' ? w : state === 'word' ? !w : w) state = state === 'word' ? 'gap' : 'word';
|
||||
}
|
||||
return out;
|
||||
},
|
||||
// A drag gesture: snapshot at the press. borders also marks the start
|
||||
// of the known text (or of the line after Enter): deleting past it
|
||||
// needs the extra travel too. target(travel): characters to be deleted
|
||||
// at that leftward travel (px each, a detent of detentPx before a border).
|
||||
// Restoring retypes this gesture's deletes in reverse, only while the
|
||||
// buffer is exactly the snapshot minus what is still deleted and the
|
||||
// gesture deleted nothing beyond the known text; else it stops for good.
|
||||
dragStart({ px, detentPx }) {
|
||||
const snap = buf.map((e) => e.c), borders = m.wordBorders();
|
||||
borders[snap.length] = snap.length > 0 || m.startKnown;
|
||||
return {
|
||||
snap, borders, applied: 0, restorable: true,
|
||||
stats: { deleted: 0, restored: 0, borders: 0, unknown: 0, stop: '' },
|
||||
target(travel) {
|
||||
let k = 0;
|
||||
for (let need = px + (borders[0] ? detentPx : 0); need <= travel; need += px + (borders[k] ? detentPx : 0)) k++;
|
||||
return k;
|
||||
},
|
||||
};
|
||||
},
|
||||
async dragDelete(g, emit) {
|
||||
const border = !!g.borders[g.applied];
|
||||
await emit('Backspace');
|
||||
g.applied++;
|
||||
g.stats.deleted++;
|
||||
if (border) g.stats.borders++;
|
||||
if (g.applied > g.snap.length) g.stats.unknown++;
|
||||
return { border };
|
||||
},
|
||||
// { border } or null (restoring stopped).
|
||||
async dragRestore(g, emit) {
|
||||
const L = g.snap.length, n = g.applied;
|
||||
if (!g.restorable || n <= 0) return null;
|
||||
const border = !!g.borders[n - 1];
|
||||
if (g.stats.unknown) { // strict: never a partial, wrong-looking retype
|
||||
g.restorable = false;
|
||||
g.stats.stop = 'unknown deleted';
|
||||
return null;
|
||||
}
|
||||
if (buf.length !== L - n || buf.some((e, i) => e.c !== g.snap[i])) {
|
||||
g.restorable = false;
|
||||
g.stats.stop = 'text changed';
|
||||
return null;
|
||||
}
|
||||
await emit(g.snap[L - n]);
|
||||
g.applied--;
|
||||
g.stats.restored++;
|
||||
return { border };
|
||||
},
|
||||
};
|
||||
return m;
|
||||
}
|
||||
|
||||
return { VERSION, create };
|
||||
})()
|
||||
@@ -0,0 +1,9 @@
|
||||
// Reverts panel.js in SteamVR's systemui page: removes the strip panel, its
|
||||
// embedded-UV slot and stylesheet. Safe when nothing is patched.
|
||||
(() => {
|
||||
const S = window.__sfuiKbdStrip;
|
||||
if (!S) return 'not patched';
|
||||
try { S.dispose?.(); } catch { /* gone */ }
|
||||
delete window.__sfuiKbdStrip;
|
||||
return 'unpatched';
|
||||
})()
|
||||
@@ -0,0 +1,13 @@
|
||||
// Reverts patch.js in Steam's SharedJSContext (evaluated by the
|
||||
// steam-ui-patches service when it stops): removes the listeners, the trail
|
||||
// canvas and the candidate strip from every keyboard popup it was attached
|
||||
// to, and the globals. Safe when nothing is patched.
|
||||
(() => {
|
||||
const S = window.__sfuiSwipe;
|
||||
if (!S) return 'not patched';
|
||||
for (const detach of S.docs?.values() || []) { try { detach(); } catch { /* popup gone */ } }
|
||||
delete window.__sfuiSwipe;
|
||||
// __sfuiSwipeLog / __sfuiSwipePaths (debugging data) are kept, so a switch
|
||||
// doesn't lose the evidence of a misrecognition.
|
||||
return 'unpatched';
|
||||
})()
|
||||
@@ -0,0 +1,17 @@
|
||||
// Replays recorded swipes (window.__sfuiSwipePaths of Steam's SharedJSContext,
|
||||
// saved as JSON) through the decoder: top candidates and the cost terms of
|
||||
// the expected word. The dictionary is the built vr-keyboard-dictionary.js.
|
||||
// usage: node scripts/vr-keyboard-replay.mjs modules/vr-keyboard/decoder.js <dictionary.js> <paths.json> [word]
|
||||
import { readFileSync } from 'node:fs';
|
||||
const [, , decoderPath, dictPath, pathsPath, expected] = process.argv;
|
||||
const D = (0, eval)(readFileSync(decoderPath, 'utf8'));
|
||||
const dict = D.parseDict(JSON.parse(readFileSync(dictPath, 'utf8')));
|
||||
for (const p of JSON.parse(readFileSync(pathsPath, 'utf8'))) {
|
||||
const lay = D.layout(dict, p.keys, p.unit);
|
||||
const res = D.decode(lay, p.pts, { unit: p.unit, max: 8 });
|
||||
console.log(`${p.t}: recorded ${p.top.join(' ')}\n now ${res.map((r) => `${r.word} ${r.cost.toFixed(2)}`).join(', ')}`);
|
||||
if (expected) {
|
||||
const f = D.decode(lay, p.pts, { unit: p.unit, features: true }).find((x) => x.word === expected);
|
||||
console.log(` ${expected}: ${f ? JSON.stringify(f.f) : 'pruned (start/end key or a letter too far from the path)'}`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user