diff --git a/README.md b/README.md index 4089e5f..ee8b05d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ configuration, limitations and how it works. **VR keyboard** ([docs/keyboard.md](docs/keyboard.md)): - [Extra keys](docs/keyboard.md#extra-keys) (`keyboard.vr.extraKeys`): Esc/Ctrl/Alt, arrows, Delete, Shift+Tab, real chords and AltGr/non-ASCII characters. -- [Swipe and suggestions](docs/keyboard.md#swipe-and-suggestions) (`keyboard.vr`): swipe typing, corrections, completions, Backspace drag. +- [Swipe and suggestions](docs/keyboard.md#swipe-and-suggestions) (`keyboard.vr`): swipe typing, corrections, completions, Backspace drag, F1–F12 on AltGr. - [Touch typing](docs/keyboard.md#touch-typing) (`keyboard.vr.touchTyping`): press keys by touching them with the controller's tip, both hands. **VR "+" menu** ([docs/launcher-menu.md](docs/launcher-menu.md)): @@ -260,6 +260,7 @@ which: [Repository layout](docs/development.md). | `steamFrame.keyboard.vr.backspaceDrag.enable` | bool | `true` | Backspace drag: left deletes, back right retypes. | | `steamFrame.keyboard.vr.backspaceDrag.pixelsPerChar` | int | `25` | Travel per character (keyboard px; a key is ~60). | | `steamFrame.keyboard.vr.backspaceDrag.wordDetentPixels` | int | `90` | Extra travel across a word border (`0`: none). | +| `steamFrame.keyboard.vr.functionKeys.enable` | bool | `false` | F1–F12 in the suggestion strip while AltGr (or Fn) is active, pressed with the active Ctrl/Alt/Shift; the suggestions come back unchanged. Needs `keyboard.vr.enable`, `keyboard.vr.extraKeys.enable` and `suggestions.position` `"above"`/`"below"`, see [VR keyboard](docs/keyboard.md#swipe-and-suggestions). | | `steamFrame.keyboard.vr.haptics` | bool | `true` | Haptic ticks for drag steps, word detents and picks. | | `steamFrame.keyboard.vr.checks` | package, read-only | | The tests, built with the configured dictionary. | | `steamFrame.keyboard.vr.touchTyping.enable` | bool | `false` | Touch typing: a key is pressed when a controller's tip touches it, both hands; the lasers work as before, see [Touch typing](docs/keyboard.md#touch-typing). | diff --git a/docs/development.md b/docs/development.md index 8e62218..959ebc1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -105,12 +105,14 @@ modules/ textmodel.js Steam (argument of patch.js): what the keyboard typed corrector.js Steam (argument of patch.js): corrections, completions gesture-input.js Steam (argument of patch.js): a gesture's own events, its hand's bridge path + function-keys.js Steam (argument of patch.js): F1-F12 in the strip while AltGr is active suggestions-panel/ patch.js, unpatch.js SteamVR: the strip as a panel above/below the keyboard relay.mjs service vr-keyboard-relay: strip state Steam <-> SteamVR dictionary.nix, gen-dict.py build: dictionary from wordfreq + Hunspell check.nix, tests/ test (also built before the patch): text model, - corrector, swipe-decoder accuracy, gesture input + corrector, swipe-decoder accuracy, gesture input, + the F-key strip vr-keyboard-controllers.nix controller bridge for keyboard features (internal option) vr-keyboard-controllers/ bridge-patch.js, unpatch.js SteamVR: keyboard pose, tips, laser hits, triggers diff --git a/docs/keyboard.md b/docs/keyboard.md index be4a6cf..80210ac 100644 --- a/docs/keyboard.md +++ b/docs/keyboard.md @@ -50,7 +50,8 @@ after Steam restarts. Turning it off reverts the keyboard. is harmless, and Esc/Ctrl/Alt/arrows work regardless. **Security:** the helper service that presses the keys only accepts -single-key Ctrl/Alt chords, the extra keys, Shift + Tab, Ctrl/Alt +single-key Ctrl/Alt chords, the extra keys (and F1–F12 for +[F-keys](#swipe-and-suggestions), with any of Ctrl/Alt/Shift), Shift + Tab, Ctrl/Alt hold/release and single non-ASCII/AltGr characters; it cannot type ASCII text or press Enter (`allowlist.mjs`, tested by the flake check `vr-keyboard-extra-keys`). @@ -118,6 +119,17 @@ default): buttons take the keyboard's key style. Below/above uses the [SteamVR debugger](steamvr-debugger.md), turned on automatically. - **Haptics:** light ticks for drag steps and picks, a Snap at word detents. +- **F-keys** (`functionKeys.enable`, off by default): while AltGr is + active (tapped: until the next key; tapped twice: locked; held: while + held; `Fn` on layouts without AltGr) the strip shows F1–F12 instead of + the suggestions. A tap presses the F-key for real, with the active + Ctrl/Alt/Shift (Alt + F4, Ctrl + F5, Shift + F10) and releases them like + any key. When AltGr goes off without a key typed, the strip shows the + same suggestions as before, with the same one selected. An F-key resets + the text memory, like Esc (the app may move the cursor or focus). + Needs [extra keys](#extra-keys) (they press it) and a strip position of + `above` or `below` (`inside` would cover the number row's AltGr + characters); the F-keys show even with the suggestion features off. **Dictionary** (`dictionary.*`): by default the `keyboard.layout` language (de, fr, es, it, nl, pt, sv) plus English, else English only. Add words @@ -176,10 +188,16 @@ has the letters. frames (option off, relay or SteamVR gone) only laser events count, as before. A second press during a swipe is Steam's (a tap): one text field, one word at a time. +- F-keys: `function-keys.js` decides what the strip shows; the suggestion + state is kept as it is while F1–F12 are shown. AltGr changes reach the + strip through a `componentDidUpdate` on the keyboard instance (with a + 0.5 s poll as a fallback). An F-key button types Steam key `VKX_F`, + which the extra keys' patch sends with xdotool like its other keys. - Found by signature (entries `vr-keyboard`, `vr-keyboard-panel`). **Tests:** `nix flake check` (checks `vr-keyboard`: text model, corrector, -decoder accuracy on German + English, +decoder accuracy on German + English, the F-key strip (switching and +restoring, the keys against the allowlist, the text memory), gesture input from two controllers, also with synthetic bridge frames: a pressing hand without laser events, attribution, a stale bridge) and `keyboard.vr.checks` for the diff --git a/modules/vr-keyboard-extra-keys/allowlist.mjs b/modules/vr-keyboard-extra-keys/allowlist.mjs index 3a0a87e..83c0404 100644 --- a/modules/vr-keyboard-extra-keys/allowlist.mjs +++ b/modules/vr-keyboard-extra-keys/allowlist.mjs @@ -1,13 +1,15 @@ // Allowlist of xdotool-helper.mjs: requests come from Steam's UI JS, so the // helper must not be able to type ASCII text or press Enter on its behalf. // Accepted: -// key: the extra keys (Esc, Del, Home, End, PgUp/PgDn, arrows), -// optionally with modifiers; ctrl and/or alt (+ optional +// key: the extra keys (Esc, Del, Home, End, PgUp/PgDn, arrows, +// F1-F12 from keyboard.vr.functionKeys), optionally with +// modifiers; ctrl and/or alt (+ optional // shift) with one key; or shift+Tab // type: one character Steam's key emulation can't produce: non-ASCII // (äöü߀§°´…) or | @ { [ ] } \ ~ ^ ` // down: / up: hold/release ctrl or alt (checked in the helper) -const SPECIAL = new Set(['Escape', 'Delete', 'Home', 'End', 'Prior', 'Next', 'Left', 'Right', 'Up', 'Down']); +const SPECIAL = new Set(['Escape', 'Delete', 'Home', 'End', 'Prior', 'Next', 'Left', 'Right', 'Up', 'Down', + ...Array.from({ length: 12 }, (_, i) => `F${i + 1}`)]); const KEY = /^([a-z0-9]|space|BackSpace|Tab|period|comma|minus|plus|numbersign|less|slash|ssharp|udiaeresis|odiaeresis|adiaeresis)$/; export function allowedCombo(combo) { diff --git a/modules/vr-keyboard-extra-keys/tests/allowlist.test.mjs b/modules/vr-keyboard-extra-keys/tests/allowlist.test.mjs index 891261c..47fe98a 100644 --- a/modules/vr-keyboard-extra-keys/tests/allowlist.test.mjs +++ b/modules/vr-keyboard-extra-keys/tests/allowlist.test.mjs @@ -6,8 +6,12 @@ import { pathToFileURL } from 'node:url'; const { allowedCombo, allowedChar } = await import(pathToFileURL(process.argv[2]).href); const accept = ['shift+Tab', 'ctrl+Tab', 'ctrl+shift+Tab', 'ctrl+shift+t', 'alt+f', 'shift+Left', 'Escape', 'Delete', 'shift+End', 'ctrl+BackSpace']; +// F-keys (keyboard.vr.functionKeys): any of them with any modifiers. +const mods = [[], ['ctrl'], ['alt'], ['shift'], ['ctrl', 'alt'], ['ctrl', 'shift'], ['alt', 'shift'], ['ctrl', 'alt', 'shift']]; +for (let n = 1; n <= 12; n++) for (const m of mods) accept.push([...m, `F${n}`].join('+')); const reject = ['Tab', 'shift+a', 'a', 'Return', 'shift+Return', 'ctrl+Return', 'shift+BackSpace', - 'shift+shift+Tab', 'super+Tab', 'shift+space', 'ctrl+a+b']; + 'shift+shift+Tab', 'super+Tab', 'shift+space', 'ctrl+a+b', + 'F0', 'F13', 'F24', 'f5', 'super+F4', 'ctrl+ctrl+F5', 'F5+Return', 'ctrl+alt+shift+Return']; const acceptChar = ['ä', '€', '|', '@', '\\', '`']; const rejectChar = ['a', 'A', '1', ' ', '\n', '\t', 'ab', ' ']; for (const c of accept) assert.equal(allowedCombo(c), true, `accepts key:${c}`); diff --git a/modules/vr-keyboard.nix b/modules/vr-keyboard.nix index 4e5b17f..c867738 100644 --- a/modules/vr-keyboard.nix +++ b/modules/vr-keyboard.nix @@ -12,6 +12,8 @@ # dashboard panel above/below the keyboard (suggestions-panel/patch.js in # systemui, 8087), fed by suggestions-panel/relay.mjs (user service # vr-keyboard-relay). +# - functionKeys: F1-F12 in the strip while AltGr is active +# (vr-keyboard/function-keys.js), sent by keyboard.vr.extraKeys. # - Dictionary: dictionary.nix (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 @@ -23,7 +25,9 @@ let inherit (lib) mkOption mkEnableOption types; uiLib = import ./steam-ui-patches/lib { inherit pkgs; }; - on = cfg.enable && (cfg.swipe.enable || cfg.autocorrect.enable || cfg.completions.enable || cfg.backspaceDrag.enable); + on = cfg.enable && (cfg.swipe.enable || cfg.autocorrect.enable || cfg.completions.enable || cfg.backspaceDrag.enable + || cfg.functionKeys.enable); + extraKeys = config.steamFrame.keyboard.vr.extraKeys.enable or false; panel = on && cfg.suggestions.position != "inside"; twoHanded = on && cfg.swipe.enable && cfg.swipe.twoHanded; noHub = pkgs.writeText "no-controllers.js" "null"; @@ -59,9 +63,11 @@ let pixelsPerChar = if cfg.backspaceDrag.enable then cfg.backspaceDrag.pixelsPerChar else 0; inherit (cfg.backspaceDrag) wordDetentPixels; inherit (cfg) haptics; + functionKeys = cfg.functionKeys.enable; }; extraArgs = [ ./vr-keyboard/swipe-decoder.js ./vr-keyboard/textmodel.js ./vr-keyboard/corrector.js dictionaryJs ./vr-keyboard/gesture-input.js - (if twoHanded then config.steamFrame.keyboard.vr.controllers.files.hub else noHub) ]; + (if twoHanded then config.steamFrame.keyboard.vr.controllers.files.hub else noHub) + ./vr-keyboard/function-keys.js ]; }} $out node --check $out ''; @@ -191,6 +197,14 @@ in { }; }; + functionKeys.enable = mkEnableOption '' + F1-F12 in the suggestion strip while AltGr (Fn on layouts without + AltGr) is active: tapped, locked or held. A tap on one presses it with + the active Ctrl/Alt/Shift (e.g. Alt+F4); when AltGr goes off, the + strip shows the same suggestions as before. Needs `enable`, + `extraKeys.enable` (which sends the keys) and a strip `position` of + "above" or "below"; shown even with the suggestion features off''; + haptics = mkOption { type = types.bool; default = true; @@ -208,7 +222,12 @@ in { config = lib.mkMerge [ { - assertions = map (l: { + assertions = lib.optionals cfg.functionKeys.enable [ + { assertion = cfg.enable; message = "steamFrame.keyboard.vr.functionKeys needs steamFrame.keyboard.vr.enable (the strip is part of that patch)."; } + { assertion = extraKeys; message = "steamFrame.keyboard.vr.functionKeys needs steamFrame.keyboard.vr.extraKeys.enable (its xdotool helper presses the keys)."; } + { assertion = cfg.suggestions.position != "inside"; + message = ''steamFrame.keyboard.vr.functionKeys needs suggestions.position "above" or "below": "inside" covers the number row, whose AltGr characters ({ [ ] } \ on German) it would hide.''; } + ] ++ 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; diff --git a/modules/vr-keyboard/check.nix b/modules/vr-keyboard/check.nix index 1913e67..f312442 100644 --- a/modules/vr-keyboard/check.nix +++ b/modules/vr-keyboard/check.nix @@ -1,7 +1,8 @@ # Tests of the VR keyboard (flake check `vr-keyboard`, also built before the # patch by vr-keyboard.nix with the configured dictionary): text model, -# gesture input (two controllers, the controller bridge's path), corrector -# and swipe-decoder accuracy +# gesture input (two controllers, the controller bridge's path), the F-key +# strip (function-keys.js with the text model and the extra keys' +# allowlist), corrector and swipe-decoder accuracy # (tests/*.test.mjs). The accuracy # thresholds (German keyboard geometry, German and English words) fail the # build only for German + English dictionaries, the default here. @@ -22,6 +23,7 @@ in pkgs.runCommand "vr-keyboard-checks" { nativeBuildInputs = [ pkgs.nodejs ]; } '' set -o pipefail node ${./tests/textmodel.test.mjs} ${./textmodel.js} + node ${./tests/function-keys.test.mjs} ${./function-keys.js} ${./textmodel.js} ${../vr-keyboard-extra-keys/allowlist.mjs} node ${./tests/gesture-input.test.mjs} ${./gesture-input.js} ${./tests/two-controllers.json} node ${./tests/corrector.test.mjs} ${./corrector.js} ${./swipe-decoder.js} ${words} ${lib.optionalString (!strict) "|| echo warning: corrector test failed"} node ${./tests/swipe-decoder.test.mjs} ${./swipe-decoder.js} ${words} 100 1 | tee $out ${lib.optionalString (!strict) "|| true"} diff --git a/modules/vr-keyboard/function-keys.js b/modules/vr-keyboard/function-keys.js new file mode 100644 index 0000000..122cba0 --- /dev/null +++ b/modules/vr-keyboard/function-keys.js @@ -0,0 +1,38 @@ +// function-keys.js: F1-F12 in the suggestion strip while AltGr is active +// (keyboard.vr.functionKeys; patch.js). Evaluates to +// { VERSION, KEYS, active, view, keyOf }. +// +// AltGr is Steam's toggle key (on layouts without one, keyboard.vr.extraKeys +// adds it labelled Fn). Its state has three bits: 1 one-shot (released by the +// next typed key), 2 locked (tapped twice), 4 held. Any of them shows the +// F-keys, so a tapped AltGr shows them until the next key or until it's +// tapped off; a held one while held. +// +// The F-keys only replace what the strip shows: the suggestions (patch.js +// `cur`) are neither changed nor recomputed meanwhile, so when AltGr goes off +// the strip shows the same items with the same selection again. An F-key +// is typed as Steam key VKX_F, an extra key: keyboard.vr.extraKeys presses +// it with xdotool together with the active Ctrl/Alt/Shift and releases the +// one-shot toggles, and patch.js resets its text model for it, like for Esc. +(() => { + const VERSION = 1; + const KEYS = Array.from({ length: 12 }, (_, i) => `F${i + 1}`); + const FKEYS = { kind: 'fkeys' }; // the source of an F-key view + + const active = (toggleStates) => ((toggleStates?.AltGr ?? 0) & 7) !== 0; + + // What the strip shows: { kind, items, index (the selected item, -1: none), + // source (the suggestion object itself, or the F-key marker) } or null. + // fnKeys: AltGr active and the keys can be sent; suggestions: patch.js + // `cur` if it's shown ({ items, index }), else null. + function view({ fnKeys, suggestions }) { + if (fnKeys) return { kind: 'fkeys', items: KEYS, index: -1, source: FKEYS }; + if (suggestions) return { kind: 'suggestions', items: suggestions.items, index: suggestions.index, source: suggestions }; + return null; + } + + // The Steam key of F-key button i, or null. + const keyOf = (i) => (Number.isInteger(i) && KEYS[i] ? `VKX_${KEYS[i]}` : null); + + return { VERSION, KEYS, active, view, keyOf }; +})() diff --git a/modules/vr-keyboard/patch.js b/modules/vr-keyboard/patch.js index 22afa6e..b6041a5 100644 --- a/modules/vr-keyboard/patch.js +++ b/modules/vr-keyboard/patch.js @@ -1,10 +1,10 @@ // 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, +// mkPatch convention plus seven more arguments: swipe-decoder.js, // textmodel.js, corrector.js, the dictionary text ("wordzipf*10\n..."), -// gesture-input.js and vr-keyboard-controllers' hub.js (null without -// swipe.twoHanded). +// 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 @@ -28,12 +28,14 @@ // 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) => { - const VERSION = 26; +((find, sigs, opts, hooks, D, T, C, DICT, P, HUB, F) => { + const VERSION = 27; const G = window; const O = opts; @@ -164,6 +166,11 @@ }; 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) { @@ -177,6 +184,8 @@ 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) { @@ -217,6 +226,7 @@ // (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)); @@ -274,16 +284,48 @@ 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) => { + 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 === cur.index) el.className = 'current'; + if (i === view.index) el.className = 'current'; return el; })); - strip.classList.toggle('shown', shown); - if (shown) placeStrip(); + 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): 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() { @@ -360,22 +402,22 @@ keyStyleCache = { sig, style }; return style; } - function publish(shown, force = false) { + function publish(v, 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 (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 = shown ? cur : null; + 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 === cur) pick(i); else log('stale-pick', seq, outSeq); }, + 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(false, true); + if (!inPage) publish(null, true); if (S.remote === remote) delete S.remote; }); @@ -472,7 +514,7 @@ 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); + if (strip.contains(at(x, y))) stripPick(d.index); return; } if (d?.kind === 'backspace' && d.active) { diff --git a/modules/vr-keyboard/suggestions-panel/relay.mjs b/modules/vr-keyboard/suggestions-panel/relay.mjs index aa73d2a..224d08a 100644 --- a/modules/vr-keyboard/suggestions-panel/relay.mjs +++ b/modules/vr-keyboard/suggestions-panel/relay.mjs @@ -4,7 +4,8 @@ // "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. +// __sfuiSwipe.remote.pick(seq, index). Only these two messages, validated +// (up to 12 items: the F-keys of keyboard.vr.functionKeys). // 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 = { @@ -35,7 +36,7 @@ function forward(side, payload) { 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), + items: msg.items.slice(0, 12).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})`); diff --git a/modules/vr-keyboard/tests/function-keys.test.mjs b/modules/vr-keyboard/tests/function-keys.test.mjs new file mode 100644 index 0000000..ea75f61 --- /dev/null +++ b/modules/vr-keyboard/tests/function-keys.test.mjs @@ -0,0 +1,77 @@ +// function-keys.js (keyboard.vr.functionKeys): when the strip shows F1-F12 +// and that it shows the same suggestions again afterwards; the keys it types +// pass the extra keys' xdotool allowlist with any modifiers; the text model +// stays as it was while only AltGr changes and is reset by an F-key, like by +// Esc. usage: node function-keys.test.mjs +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import assert from 'node:assert/strict'; + +const F = (0, eval)(readFileSync(process.argv[2], 'utf8')); +const T = (0, eval)(readFileSync(process.argv[3], 'utf8')); +const { allowedCombo } = await import(pathToFileURL(process.argv[4]).href); +let passed = 0; +function test(name, fn) { fn(); passed++; console.log('ok', name); } + +// extraKeys' patch: VKX_ is sent as key:[ctrl+][alt+][shift+]. +const sent = (key, { ctrl, alt, shift } = {}) => + [ctrl && 'ctrl', alt && 'alt', shift && 'shift', key.startsWith('VKX_') && key.slice(4)].filter(Boolean).join('+'); + +test('AltGr active: one-shot, locked, held (Steam toggle bits 1, 2, 4)', () => { + for (const v of [1, 2, 4, 5, 6]) assert.equal(F.active({ AltGr: v }), true, `AltGr ${v}`); + for (const ts of [{ AltGr: 0 }, {}, undefined, { Shift: 1, Control: 2, Alt: 4 }]) assert.equal(F.active(ts), false, JSON.stringify(ts)); +}); + +test('the strip switches to F1-F12 and back to the same suggestions', () => { + const cur = { kind: 'complete', items: ['hal', 'hallo', 'halt', 'halb'], index: 2, anchor: {} }; + const snapshot = structuredClone({ items: cur.items, index: cur.index }); + const before = F.view({ fnKeys: false, suggestions: cur }); + assert.deepEqual([before.kind, before.items, before.index], ['suggestions', cur.items, 2]); + assert.equal(before.source, cur); + const fk = F.view({ fnKeys: true, suggestions: cur }); + assert.deepEqual([fk.kind, fk.items, fk.index], ['fkeys', F.KEYS, -1]); + assert.deepEqual(F.KEYS, ['F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12']); + assert.notEqual(fk.source, cur); + assert.equal(F.view({ fnKeys: true, suggestions: null }).source, fk.source, 'one F-key source (stale picks are detected by identity)'); + const after = F.view({ fnKeys: false, suggestions: cur }); + assert.deepEqual(after, before, 'same items, selection and source'); + assert.deepEqual({ items: cur.items, index: cur.index }, snapshot, 'suggestions untouched'); +}); + +test('F-keys without suggestions; nothing without either', () => { + assert.equal(F.view({ fnKeys: true, suggestions: null }).kind, 'fkeys'); + assert.equal(F.view({ fnKeys: false, suggestions: null }), null); +}); + +test('buttons type VKX_F1..F12, accepted by the helper with any modifiers', () => { + for (let i = 0; i < 12; i++) { + const key = F.keyOf(i); + assert.equal(key, `VKX_F${i + 1}`); + for (const ctrl of [false, true]) for (const alt of [false, true]) for (const shift of [false, true]) { + const combo = sent(key, { ctrl, alt, shift }); + assert.equal(allowedCombo(combo), true, combo); + } + } + for (const i of [-1, 12, 1.5, '1', null, undefined]) assert.equal(F.keyOf(i), null, String(i)); + assert.equal(allowedCombo('ctrl+alt+shift+Return'), false, 'never Enter'); +}); + +test('text model: kept while AltGr toggles, reset by an F-key like by Esc', () => { + const m = T.create({ size: 64 }); + for (const c of 'Hallo') { m.freeze(); m.observe(c); } + const a = m.anchor(5); + // AltGr on and off: a toggle, nothing reaches the model (patch.js skips toggles). + assert.equal(m.anchorIntact(a), true); + // An F-key is an extra key (VKX_): patch.js resets the model for those, as for VKX_Escape. + for (const key of [F.keyOf(4), 'VKX_Escape']) { + assert.ok(key.startsWith('VKX_'), key); + const m2 = T.create({ size: 64 }); + for (const c of 'Hallo') { m2.freeze(); m2.observe(c); } + const a2 = m2.anchor(5); + m2.reset(`key ${key}`); + assert.equal(m2.anchorIntact(a2), false, key); + assert.equal(m2.text, '', key); + } +}); + +console.log(`function-keys: ${passed} tests passed`);