diff --git a/README.md b/README.md index d445743..782e824 100644 --- a/README.md +++ b/README.md @@ -459,6 +459,10 @@ reboot or Steam restart needed. or AltGr. - AltGr + arrows: Home, End, Page Up, Page Down (hinted on the keys); Shift + arrows select text. +- AltGr + the key left of Backspace (`´` on German, `=` on US): Delete, + labelled like Steam's Delete key in its language (`Entf`; `Del` if that is + longer); repeats while held. Layouts with an AltGr character on that key + get none. - Ctrl/Alt chords and Esc are sent with `xdotool key` on `:0` (focus follows the VR-selected window); a toggled Ctrl/Alt is held down while the keyboard is open (e.g. Ctrl+scroll). diff --git a/modules/lib/signatures.json b/modules/lib/signatures.json index 8c80863..2d5af58 100644 --- a/modules/lib/signatures.json +++ b/modules/lib/signatures.json @@ -60,7 +60,9 @@ "keyboardComponent": { "checkOnly": true, "module": { "includes": ["TypeKeyInternal(", "toggleStates", "standardLayout"] }, - "expects": ["\"data-key\"", "\"data-key-row\"", "HandleVirtualKeyDown"] + "expects": ["\"data-key\"", "\"data-key-row\"", "HandleVirtualKeyDown", "RenderStandardKeyboard(", + "rgLayout:", "s_longPressThreshold", "s_longPressRepeatThreshold", "m_mapTouched", + "CancelLongPressTimer(){", "DismissLongPress(){", "ClearHoldTarget(){", "\"Backspace\""] }, "popupManager": { "checkOnly": true, diff --git a/modules/steam-keyboard-patch.nix b/modules/steam-keyboard-patch.nix index 8a2003b..e59d01d 100644 --- a/modules/steam-keyboard-patch.nix +++ b/modules/steam-keyboard-patch.nix @@ -3,7 +3,8 @@ # - patch.js (injected over CEF DevTools, 127.0.0.1:8080) adds a bottom row # Esc Ctrl Alt [space] AltGr ← ↑ ↓ → (AltGr+arrows = Pos1/PgUp/PgDn/End) and # hands chords, Shift+arrows and characters Steam would turn into "1" -# (non-ASCII, AltGr/dead keys) to the helper. +# (non-ASCII, AltGr/dead keys) to the helper. AltGr + the key left of +# Backspace = Delete. # - helper.mjs keeps it injected, sends those keys with xdotool on :0 (an # allowlist: no ASCII text, no Enter) and unpatches on stop. # Tested with Steam client 1790377368 (UI build 11041156). diff --git a/modules/steam-keyboard-patch/patch.js b/modules/steam-keyboard-patch/patch.js index ad830cc..cfa7bff 100644 --- a/modules/steam-keyboard-patch/patch.js +++ b/modules/steam-keyboard-patch/patch.js @@ -2,6 +2,8 @@ // Extends Steam's VR keyboard for gamescope app windows: // - bottom row: Esc Ctrl Alt [space] AltGr ← ↑ ↓ → Close, stable with Shift // and AltGr; AltGr + arrows = Pos1/PgUp/PgDn/End +// - AltGr + the key left of Backspace (German ´/`, empty on AltGr) = Delete +// (Steam's "#Key_Delete" label, e.g. Entf), repeating while held // - Ctrl/Alt chords, Esc, the AltGr arrow keys and Shift+arrows are pressed // for real (Steam can't: in VR SteamClient.Input.ControllerKeyboardSetKeyState // throws "Unknown method") @@ -19,7 +21,7 @@ // mkPatch patch (see lib/default.nix); no options. On a signature mismatch // it returns an error and changes nothing. Idempotent. ((find, sigs) => { - const VERSION = 10; + const VERSION = 11; const send = (msg) => window.__vrkbdKey && window.__vrkbdKey(msg); let mods; try { @@ -33,6 +35,7 @@ // For unpatch.js, which runs without the finder library. window.__vrkbdRefs = { Manager, currentLayout: Layouts.currentLayout }; let changed = false; + const active = (v) => (v & 7) !== 0; // toggle state bits: 1 one-shot, 2 locked, 4 held // Replace obj[name] with make(original), once per VERSION. const wrap = (obj, name, make) => { @@ -70,12 +73,39 @@ out.splice(out.length - 1, 0, ...ARROWS); // before Close/Done (last entry) return out; }; + // ---- AltGr: Delete on the key left of Backspace -------------------------------- + // rgLayout is called while the keyboard component renders + // (RenderStandardKeyboard, wrapped below to publish the instance as + // window.__vrkbdRendering), so the rows follow its AltGr state. While AltGr + // is active, the key left of Backspace becomes Delete if it has no AltGr + // character (German ´/`, US =/+; Steam would draw it empty). Also while a + // Delete hold repeats: its first Delete releases a one-shot AltGr, like any + // key, but the held key stays until release. + const DEL = 'VKX_Delete'; + const delLabel = () => { // Steam's own: Entf, Suppr, Canc, Del; English "Delete" -> Del + let l = null; + try { l = window.LocalizationManager?.LocalizeString?.('#Key_Delete'); } catch { /* not loaded */ } + return typeof l === 'string' && l && l.length <= 5 ? l : 'Del'; + }; + const freeOnAltGr = (x) => (Array.isArray(x) ? !x[2] : (typeof x === 'string' ? x : x?.key)?.length === 1); + const withDelete = (rows) => { + const kb = window.__vrkbdRendering; + if (!kb || !(active(kb.state?.toggleStates?.AltGr) || kb.__vrkbdDelHold)) return rows; + let done = false; + return rows.map((row) => { + const i = done ? -1 : row.findIndex((x) => !Array.isArray(x) && x?.key === 'Backspace'); + if (i < 0) return row; + done = true; + if (i < 1 || !freeOnAltGr(row[i - 1])) return row; + return [...row.slice(0, i - 1), { key: DEL, label: delLabel(), type: first(row[i - 1])?.type }, ...row.slice(i)]; + }); + }; window.__vrkbdLayouts ??= new Set(); // for unpatch.js, incl. layouts disabled since for (const l of [...(Layouts.enabledLayouts() || []), Layouts.currentLayout()]) { if (typeof l?.rgLayout !== 'function') continue; window.__vrkbdLayouts.add(l); wrap(l, 'rgLayout', (orig) => (opts) => { - const rows = orig(opts); + const rows = withDelete(orig(opts)); return [...rows.slice(0, -1), bottomRow(rows[rows.length - 1])]; }); } @@ -137,6 +167,14 @@ while (!Object.prototype.hasOwnProperty.call(proto, 'TypeKeyInternal')) proto = Object.getPrototypeOf(proto); window.__vrkbdInst = inst; // for the hold timer and unpatch.js window.__vrkbdProto = proto; + const canDelete = typeof proto.RenderStandardKeyboard === 'function'; + if (canDelete) { + wrap(proto, 'RenderStandardKeyboard', (orig) => function (...a) { + const prev = window.__vrkbdRendering; + window.__vrkbdRendering = this; + try { return orig.apply(this, a); } finally { window.__vrkbdRendering = prev; } + }); + } // Stylesheet, re-applied on every inject (the popup can be recreated). The // space bar is the only flexible key, so it absorbs the width changes. @@ -154,11 +192,10 @@ // Arrow keys: smaller (still centered) icon and a small hint at the bottom edge. '[data-key^="Arrow"] span ~ span { font-size: 7px !important; top: auto !important; bottom: 3px !important; line-height: 1 !important; }', '[data-key^="Arrow"] span:first-child svg { height: 18px !important; }', - `${sel(ALTGR_ARROWS.map((x) => x.key), ' span')} { font-size: 13px !important; }`, + `${sel([...ALTGR_ARROWS.map((x) => x.key), DEL], ' span')} { font-size: 13px !important; }`, ].join('\n'); // ---- key handling ---------------------------------------------------------- - const active = (v) => (v & 7) !== 0; // toggle state bits: 1 one-shot, 2 locked const release = (v) => { const z = v & 3; return (z === 1 ? 0 : z) | (v & 4); }; const TOGGLES = ['Shift', 'CapsLock', 'Control', 'Alt', 'AltGr']; const SYMS = { @@ -187,6 +224,70 @@ Alt: release(s.toggleStates.Alt), AltGr: release(s.toggleStates.AltGr) } })); }); + // ---- Delete repeat ----------------------------------------------------------- + // Steam repeats only data-key "Backspace" on a long press (and types other + // keys once at release). Like it: after s_longPressThreshold of holding + // Delete, take the press over (Steam's own touch-end cleanup: no key at + // release, no long press) and type Delete every s_longPressRepeatThreshold + // until release. Passive capture listeners, one set per keyboard window. + const win = kbPopup.window; + if (canDelete && win.__vrkbdDelRepeat !== VERSION) { + win.__vrkbdDelDetach?.(); + let hold = null; // { inst, el, target, timer, taken } + const stop = () => { + if (!hold) return; + const h = hold; + hold = null; + clearTimeout(h.timer); + if (h.inst.__vrkbdDelHold) { h.inst.__vrkbdDelHold = false; h.inst.forceUpdate(); } + }; + const start = (target, el) => { + const kb = find.findFiberUp(el, (f) => typeof f.stateNode?.TypeKeyInternal === 'function', 200)?.stateNode; + if (!kb) return; + const C = kb.constructor; + const delay = C.s_longPressThreshold ?? 450, repeat = C.s_longPressRepeatThreshold ?? 200; + const h = hold = { inst: kb, el, target, timer: 0, taken: false }; + const tick = (ms) => { + h.timer = setTimeout(() => { + if (hold !== h) return; + if (!el.isConnected || el.getAttribute('data-key') !== DEL || !Status.VRKeyboardStatus?.bIsOpen) { stop(); return; } + if (!h.taken) { + h.taken = true; + kb.__vrkbdDelHold = true; // keeps the key after a one-shot AltGr is released + try { + for (const t of [...kb.m_mapTouched]) if (el.contains(t)) kb.m_mapTouched.delete(t); + kb.CancelLongPressTimer(); kb.DismissLongPress(); kb.ClearHoldTarget(); + } catch { /* Steam changed: at worst one more Delete at release */ } + } + kb.TypeKeyInternal({ strKey: DEL }); + tick(repeat); + }, ms); + }; + tick(delay - 10); // just before Steam's own long-press timer + }; + const keyEl = (t) => t?.closest?.('[data-key]'); + const onDown = (e) => { + stop(); + const t = e.type === 'touchstart' ? (e.touches.length === 1 ? e.changedTouches[0]?.target : null) : (e.button === 0 ? e.target : null); + const el = keyEl(t); + if (el?.getAttribute('data-key') === DEL) start(t, el); + }; + const onUp = (e) => { + if (!hold) return; + if (e.type.startsWith('touch') && ![...e.changedTouches].some((t) => t.target === hold.target)) return; + stop(); + }; + const L = { capture: true, passive: true }; + const types = [['touchstart', onDown], ['mousedown', onDown], ['touchend', onUp], ['touchcancel', onUp], ['mouseup', onUp]]; + for (const [type, f] of types) win.addEventListener(type, f, L); + win.__vrkbdDelDetach = () => { + stop(); + for (const [type, f] of types) win.removeEventListener(type, f, L); + delete win.__vrkbdDelDetach; delete win.__vrkbdDelRepeat; + }; + win.__vrkbdDelRepeat = VERSION; // __vrkbdDelDetach: for unpatch.js + } + // ---- held modifiers --------------------------------------------------------- // Keep the real Ctrl/Alt pressed while the toggle is active and the keyboard is // open (for Ctrl+scroll etc.). One timer per Steam UI instance. diff --git a/modules/steam-keyboard-patch/unpatch.js b/modules/steam-keyboard-patch/unpatch.js index 98d5ca5..ed96e7b 100644 --- a/modules/steam-keyboard-patch/unpatch.js +++ b/modules/steam-keyboard-patch/unpatch.js @@ -1,6 +1,7 @@ // Reverts patch.js in Steam's SharedJSContext (run by helper.mjs on stop). // Patched functions keep their original as __vrkbdOrig; objects Steam doesn't -// expose are in window.__vrkbdRefs/__vrkbdLayouts/__vrkbdProto/__vrkbdInst. +// expose are in window.__vrkbdRefs/__vrkbdLayouts/__vrkbdProto/__vrkbdInst; +// the Delete-repeat listeners of a keyboard window in its __vrkbdDelDetach. // Safe when not patched. (() => { const refs = window.__vrkbdRefs; @@ -12,17 +13,20 @@ for (const l of window.__vrkbdLayouts || []) unwrap(l, 'rgLayout'); unwrap(SteamClient.Input, 'ControllerKeyboardSendText'); unwrap(window.__vrkbdProto, 'TypeKeyInternal'); + unwrap(window.__vrkbdProto, 'RenderStandardKeyboard'); unwrap(refs?.Manager, 'GetEnterKeyLabel'); unwrap(refs?.Manager, 'HandleVirtualKeyDown'); clearInterval(window.__vrkbdHoldTimer); + for (const p of g_PopupManager.GetPopups?.() || []) p.window?.__vrkbdDelDetach?.(); const inst = window.__vrkbdInst; if (inst) { delete inst.__vrkbd; + delete inst.__vrkbdDelHold; if (refs?.currentLayout) inst.setState({ standardLayout: refs.currentLayout() }); inst.forceUpdate(); } for (const p of g_PopupManager.GetPopups?.() || []) p.window?.document.getElementById('vrkbd-style')?.remove(); - for (const k of ['__vrkbdHoldTimer', '__vrkbdHeld', '__vrkbdInst', '__vrkbdProto', '__vrkbdLayouts', '__vrkbdRefs']) delete window[k]; + for (const k of ['__vrkbdHoldTimer', '__vrkbdHeld', '__vrkbdInst', '__vrkbdProto', '__vrkbdLayouts', '__vrkbdRefs', '__vrkbdRendering']) delete window[k]; return 'unpatched'; })()