Files
lhns--steam-frame-nix/modules/vr-keyboard/patch.js
T
Pierre Kisters af32cf72ce VR keyboard swipe with two controllers: the path from the controller bridge
With both lasers on the keyboard SteamVR forwards per poll only one laser's
movement, so the pressing one may get no touchmoves and its swipe became a
tap. keyboard.vr.swipe.twoHanded (default on; turns on the controller
bridge): a press is attributed to the hand whose laser hit is nearest to it
(within 30 px, the trigger as a tiebreak; without recent frames the first
frame after the press decides, within 60 px). The path comes from runs of
one source: the touch's own moves while they flow, that hand's laser hit
per bridge frame once they pause for 60 ms, touchmoves again if frames
pause. A touchend disagreeing with the laser during a bridge run ends at
the laser. Touchmoves on the other hand's laser move the attribution.
Without fresh frames: touch events only, as before. A second press during
a swipe stays Steam's tap (one text field, one word at a time).

The bridge sends full rate only on demand now: hub.js demand(ms) goes
back through the relay (CDP binding __sfuiCtlIn) to systemui's
__sfuiCtl.demand; the swipe patch asks from the press to the release.

Tests: gesture-input with the two-controller recording and synthetic bridge
frames (a pressing hand without touchmoves, attribution with both lasers on
the keyboard, a stale bridge, frames stopping mid-swipe, re-attribution,
interleaved hover and presses); hub demand.
2026-10-01 03:45:47 +02:00

548 lines
28 KiB
JavaScript

// 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 six more arguments: swipe-decoder.js,
// textmodel.js, corrector.js, the dictionary text ("word<TAB>zipf*10\n..."),
// gesture-input.js and vr-keyboard-controllers' hub.js (null without
// swipe.twoHanded).
//
// - Swipe: the laser arrives as touch events; Steam types the key a touch
// started on at release. With both lasers on the keyboard the pressing one
// may get no touchmoves; its path then comes from the controller bridge
// (gesture-input.js: the hand whose laser hit the press point; full rate
// asked for from the press to the 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", suggestions-panel/patch.js):
// state out via the CDP binding __sfuiStripOut, picks back via
// __sfuiSwipe.remote.pick(seq, index) (suggestions-panel/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, P, HUB) => {
const VERSION = 26;
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}:${P.VERSION}:${HUB?.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 (suggestions-panel/)
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, Shift+Tab, 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') || key === 'Tab'))) {
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', ... }.
// input (gesture-input.js): the events of the contact that started it
// (a touch per laser press, they go on after Chromium's pointercancel; a
// mouse from pointer events, or mouse events if there are none). The
// other controller's hover, a second press: ignored (Steam's, as stock).
let down = null;
const input = P.create({ now: () => G.performance.now() });
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);
// 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 c = input.read(e);
if (!c) return;
if (down?.active) clearTrail(); // its release was missed
down = start(c);
if (!down) return;
const hand = input.claim(c);
if (bridge) { demand(); log('press', c.id, hand ?? '-'); }
}
function start({ x, y, target }) {
const si = stripIndexOf(target);
if (si !== -1) return { kind: 'strip', index: si };
const keyEl = keyOf(target), key = keyEl?.getAttribute('data-key'), inst = keyEl && kbInst(keyEl);
if (!inst) return null;
hookInst(inst);
if (key === 'Backspace' && O.pixelsPerChar > 0) {
return { kind: 'backspace', keyEl, inst, x0: x, active: false, planned: 0,
g: model.dragStart({ px: O.pixelsPerChar, detentPx: O.wordDetentPixels }) };
}
const lay = O.swipe && isLetter(key) && currentLayout();
return lay ? { kind: 'key', key, keyEl, inst, lay, pts: [[x, y]], active: false } : null;
}
// 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 c = input.read(e);
if (c) move(c.x, c.y);
}
function move(x, y) {
if (!down || down.kind === 'strip') return;
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 st = { ...input.stats, hand: input.hand };
const c = input.read(e);
if (!c) return;
const { x, y } = c, d = down;
down = null;
if (bridge) { endDemand(); if (d) d.src = st; }
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), src: d.src });
if (d.src) log('path', d.src.hand ?? '-', `touch ${d.src.touch}`, `bridge ${d.src.bridge}`, d.src.late ? 'late' : '', d.src.switches ? `switches ${d.src.switches}` : '');
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 (!input.read(e)) return; // another contact; Chromium's touch pointercancel
if (down?.active) clearTrail();
down = null;
if (bridge) endDemand();
}
// Controller bridge (HUB, swipe.twoHanded): the pressing hand's laser per
// frame while its touchmoves pause (gesture-input.js). The bridge sends
// full rate only while asked to (a lease, renewed during the gesture).
const bridge = HUB && typeof HUB.subscribe === 'function' ? HUB : null;
let demandAt = -Infinity;
const DEMAND_MS = 800;
function demand() { demandAt = G.performance.now(); try { bridge.demand?.(DEMAND_MS); } catch { /* old hub */ } }
function endDemand() { if (demandAt > -Infinity) { demandAt = -Infinity; try { bridge.demand?.(0); } catch { /* old hub */ } } }
if (bridge) {
cleanup.push(bridge.subscribe((f) => {
const p = input.frame(f);
if (down && input.owner && G.performance.now() - demandAt > DEMAND_MS / 3) demand();
if (p) move(p[0], p[1]);
}));
cleanup.push(endDemand);
}
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;
};
}
})