mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 01:00:13 +02:00
find.keyboardPopup() (finders.js VERSION 3) replaces the same g_PopupManager lookup in the swipe, extra keys and touch typing patches (VERSIONs 29, 24, 4). hub.js keeps its own: it gets no finder library.
593 lines
31 KiB
JavaScript
593 lines
31 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 seven more arguments: swipe-decoder.js,
|
|
// textmodel.js, corrector.js, the dictionary text ("word<TAB>zipf*10\n..."),
|
|
// gesture-input.js, vr-keyboard-controllers' hub.js (null without
|
|
// swipe.twoHanded) and function-keys.js.
|
|
//
|
|
// - 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).
|
|
// With keyboard.vr.functionKeys it shows F1-F12 instead while AltGr is
|
|
// active (function-keys.js), typed via keyboard.vr.extraKeys.
|
|
// 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, F) => {
|
|
const VERSION = 29;
|
|
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 = find.keyboardPopup();
|
|
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 });
|
|
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(); };
|
|
|
|
// view: what the strip shows (function-keys.js): the suggestions (cur),
|
|
// or F1-F12 while AltGr is active, leaving cur as it is. kbNow: the
|
|
// keyboard instance last hooked.
|
|
let view = null, kbNow = inst0;
|
|
|
|
// 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;
|
|
kbNow = inst;
|
|
hookFnKeys(inst);
|
|
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(() => {
|
|
// Popup closed: detach now (else on the next injection), so this
|
|
// instance stops publishing over the next popup's strip.
|
|
if (!doc.defaultView) { S.docs.get(doc)?.(); S.docs.delete(doc); return; }
|
|
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;
|
|
if (O.functionKeys && fnKeysOn() !== (view?.kind === 'fkeys')) refreshLater(); // AltGr change missed
|
|
}, 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));
|
|
view = F.view({ fnKeys: fnKeysOn(), suggestions: shown ? cur : null });
|
|
if (!inPage) { publish(view); return; }
|
|
strip.replaceChildren(...(view ? view.items : []).map((text, i) => {
|
|
const el = doc.createElement('div');
|
|
el.textContent = text;
|
|
el.dataset.index = i;
|
|
if (i === view.index) el.className = 'current';
|
|
return el;
|
|
}));
|
|
strip.classList.toggle('shown', !!view);
|
|
if (view) placeStrip();
|
|
}
|
|
const stripPick = (i) => (view?.kind === 'fkeys' ? typeFnKey(i) : pick(i));
|
|
|
|
// ---- F-keys (keyboard.vr.functionKeys) ------------------------------------------
|
|
// Sent by keyboard.vr.extraKeys (its TypeKeyInternal wrapper and xdotool
|
|
// helper), so only while that patch is live. AltGr changes reach the strip
|
|
// through a componentDidUpdate on the keyboard instance (Steam's has none;
|
|
// React looks it up on every update), and the poll above if missed.
|
|
function fnKeysReady() { return !!O.functionKeys && typeof G.__vrkbdKey === 'function' && !!G.__vrkbdProto?.TypeKeyInternal?.__vrkbd; }
|
|
function fnKeysOn() { return fnKeysReady() && F.active(kbNow?.state?.toggleStates); }
|
|
function hookFnKeys(inst) {
|
|
if (!O.functionKeys || (own(inst, 'componentDidUpdate') && inst.componentDidUpdate.__sfuiSwipe === hook)) return;
|
|
const f = function (...a) {
|
|
const r = Object.getPrototypeOf(inst).componentDidUpdate?.apply(this, a);
|
|
if (F.active(a[1]?.toggleStates) !== F.active(this.state?.toggleStates)) refreshLater();
|
|
return r;
|
|
};
|
|
f.__sfuiSwipe = hook;
|
|
inst.componentDidUpdate = f;
|
|
cleanup.push(() => { if (own(inst, 'componentDidUpdate') && inst.componentDidUpdate === f) delete inst.componentDidUpdate; });
|
|
}
|
|
// An extra key (VKX_F<n>): our TypeKeyInternal hook resets the text model,
|
|
// like for Esc; extraKeys presses it with the active Ctrl/Alt/Shift and
|
|
// releases the one-shot toggles (AltGr too: the strip goes back).
|
|
function typeFnKey(i) {
|
|
const key = F.keyOf(i);
|
|
if (!key || !fnKeysReady()) return;
|
|
hookInst(kbNow);
|
|
log('fkey', key);
|
|
kbNow.TypeKeyInternal({ strKey: key });
|
|
if (inPage) haptic(HAPTIC.pick); // a panel ticks on its own overlay
|
|
}
|
|
// 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(v, force = false) {
|
|
let st = null;
|
|
if (v) try { st = keyStyle(); } catch (e) { log('key-style-error', String(e)); }
|
|
const body = JSON.stringify(v ? { items: v.items, current: v.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 = v?.source ?? 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 === view?.source) stripPick(i); else log('stale-pick', seq, outSeq); },
|
|
sync() { outLast = ''; showStrip(); return 'ok'; },
|
|
};
|
|
S.remote = remote;
|
|
cleanup.push(() => {
|
|
if (!inPage) publish(null, 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))) stripPick(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;
|
|
};
|
|
}
|
|
})
|