Files
lhns--steam-frame-nix/modules/vr-keyboard-touch/keyboard-patch.js
T
Pierre Kisters 0699d0f532 VR keyboard touch typing: the touched key highlights (and long-presses)
Steam's UpdateTouchState counts the event's `touches` per key: that count
is the pressed highlight (rgLayoutTouchCount -> Touched) and where the long
press (Backspace repeat, accents) starts. The touch-start event left the
new touch out of `touches`, like a touchend; now it is in, as in a DOM
touchstart (tracker.js touchEvent, with a test against a model of Steam's
handlers).
2026-10-01 03:36:13 +02:00

116 lines
5.9 KiB
JavaScript

// Touch typing (vr-keyboard-touch.nix; patch name "vr-keyboard-touch"),
// injected into Steam's SharedJSContext (8080). mkPatch convention plus two
// more arguments: tracker.js and vr-keyboard-controllers' hub.js.
//
// The controller frames (hub.js; the tips relative to the keyboard) go
// through one tracker.js per hand; its events press keys with the keyboard's
// own touch handlers, like a laser press (the laser arrives as touch
// events): HandleTouchStart at "down" (key highlighted, sound, Steam's long
// press: repeating Backspace, the accent popup), HandleTouchEnd at "up"
// (Steam types the key, unless its long press already did). The key is the
// [data-key] element at the point (skipping MouseHoverBlockerHack, like
// Steam's hit test); no key there: nothing. Each hand is its own touch, so
// both hands can overlap like two fingers (Steam's multi-touch: Shift held
// by one hand). No DOM events: the swipe patch's and the extra keys'
// listeners don't see these touches; the swipe patch's text model does (it
// hooks the methods the keys go through).
// "cancel" (no pose), frames stopping for STALE_MS, the keyboard moving or
// closing end a touch without typing. Debugging: __sfuiTouchTypeLog.
((find, sigs, opts, hooks, TR, HUB) => {
const VERSION = 3;
const G = window;
const STALE_MS = 1000; // Steam's UI thread can stall frames for ~0.3 s
const SNAP = 3; // SteamVR overlay haptic effect (1 ButtonEnter, 3 Snap)
const HANDS = ['left', 'right'];
const ID = { left: 0x5f10, right: 0x5f11 }; // touch identifiers
let status;
try {
const mods = find.resolveAll(find.getWebpackRequire('webpackChunksteamui'), sigs);
status = mods.vrStatus.exports.holder;
} catch (e) {
return `Steam internals changed, no touch typing: ${e.message}`;
}
const stamp = `${VERSION}:${TR.VERSION}:${HUB.version} ${JSON.stringify(opts)}`;
if (G.__sfuiTouchType?.stamp === stamp) return 'unchanged';
try { G.__sfuiTouchType?.dispose?.(); } catch { /* gone */ }
const log = (...a) => {
const l = (G.__sfuiTouchTypeLog ??= []);
l.push([Math.round(performance.now()), ...a]);
if (l.length > 200) l.splice(0, l.length - 200);
};
const kbInst = (el) => find.findFiberUp(el, (f) => typeof f.stateNode?.HandleTouchStart === 'function' &&
typeof f.stateNode?.HandleTouchEnd === 'function' && f.stateNode.m_mapTouched instanceof Set, 200)?.stateNode;
const touches = new Map(); // hand -> { t, inst, doc }
function keyboardWindow() {
const p = [...(G.g_PopupManager?.GetPopups?.() || [])].find((x) => x.window?.document.querySelector('[data-key]'));
return p?.window ?? null;
}
// Steam's handlers read target, changedTouches, touches (tracker.js
// touchEvent) and the touches' target / clientX / clientY.
const others = (t, inst) => [...touches.values()].filter((e) => e.inst === inst && e.t !== t).map((e) => e.t);
function end(hand, { cancel = false, u, v } = {}) {
const e = touches.get(hand);
if (!e) return;
touches.delete(hand);
// Where it left, if on the page (an accent picked in Steam's popup);
// Steam types nothing for a release off the page.
const win = e.doc.defaultView, x = u * win?.innerWidth, y = v * win?.innerWidth;
if (!cancel && x >= 0 && y >= 0 && x < win.innerWidth && y < win.innerHeight) Object.assign(e.t, { clientX: x, clientY: y, pageX: x, pageY: y });
if (cancel) e.inst.m_mapTouched.delete(e.t.target); // Steam types only keys still in m_mapTouched
try { e.inst.HandleTouchEnd(TR.touchEvent(e.t, others(e.t, e.inst), false)); } catch (err) { log('end-error', String(err)); }
log(hand, cancel ? 'cancel' : 'up', e.t.target.getAttribute('data-key'));
}
function down(hand, u, v) {
end(hand, { cancel: true });
const win = keyboardWindow();
if (!win) return;
const doc = win.document, x = u * win.innerWidth, y = v * win.innerWidth;
const el = doc.elementsFromPoint(x, y).find((e) => e.id !== 'MouseHoverBlockerHack');
const keyEl = el?.closest?.('[data-key]');
const inst = keyEl && kbInst(keyEl);
if (!inst) { log(hand, 'miss', Math.round(x), Math.round(y)); return; }
const t = { identifier: ID[hand], target: keyEl, clientX: x, clientY: y, pageX: x, pageY: y };
touches.set(hand, { t, inst, doc });
try { inst.HandleTouchStart(TR.touchEvent(t, others(t, inst), true)); } catch (err) { log('start-error', String(err)); end(hand, { cancel: true }); return; }
if (opts.haptics) win.SteamClient?.OpenVR?.TriggerOverlayHapticEffect?.(SNAP, 0);
log(hand, 'down', keyEl.getAttribute('data-key'), Math.round(x), Math.round(y));
}
const trackers = Object.fromEntries(HANDS.map((h) => [h, TR.createTracker({ depth: opts.depth })]));
let lastT = 0, lastAt = -Infinity;
function step(hand, sample, t) {
const ev = trackers[hand].update(sample, t);
if (ev?.phase === 'down') down(hand, ev.u, ev.v);
else if (ev?.phase === 'up') end(hand, { u: ev.u, v: ev.v });
else if (ev?.phase === 'cancel') end(hand, { cancel: true });
}
const unsubscribe = HUB.subscribe((f) => {
const now = performance.now();
const t = f?.t ?? lastT + (now - lastAt);
lastT = t; lastAt = now;
const usable = !!(f?.keyboard && !f.moving && status.VRKeyboardStatus?.bIsOpen);
for (const h of HANDS) step(h, usable ? f.hands?.[h]?.tip ?? null : null, t);
});
// No frames (relay or SteamVR gone) or the keyboard closed: end the touches.
const watchdog = setInterval(() => {
const now = performance.now();
if (now - lastAt > STALE_MS || !status.VRKeyboardStatus?.bIsOpen) {
for (const h of HANDS) if (trackers[h].pressed || touches.has(h)) { step(h, null, lastT + (now - lastAt)); end(h, { cancel: true }); }
}
}, 250);
G.__sfuiTouchType = {
stamp,
dispose() {
unsubscribe(); clearInterval(watchdog);
for (const h of HANDS) end(h, { cancel: true });
},
};
log('attached', VERSION);
return 'patched';
})