VR keyboard F-keys: F1-F12 in the suggestion strip while AltGr is active

keyboard.vr.functionKeys.enable (default off): while AltGr (Fn on layouts
without AltGr) is one-shot, locked or held, the strip shows F1-F12 instead
of the suggestions. A tap types the extra key VKX_F<n>, which the extra
keys' patch presses with xdotool together with the active Ctrl/Alt/Shift
(Alt+F4, Ctrl+F5) and releases the toggles; the text model resets, like
for Esc. The suggestions are kept as they are meanwhile, so when AltGr goes
off the strip shows the same items and selection again.

- vr-keyboard/function-keys.js: what the strip shows, the F-key keys.
- patch.js (VERSION 27): the strip's view, F-key picks, AltGr changes via
  a componentDidUpdate on the keyboard instance (and the poll).
- Allowlist: F1-F12 with any modifiers; Enter still refused.
- relay.mjs: up to 12 strip items.
- Assertions: needs keyboard.vr.enable, extraKeys.enable and a strip
  position "above"/"below" ("inside" would hide the AltGr number row).
- Tests: function-keys.test.mjs (switching and restoring, keys against
  the allowlist, text model), allowlist F-key cases.
This commit is contained in:
Pierre Kisters committed 2026-10-01 03:53:01 +02:00
1 parent af32cf72ce
commit aa8ccc67c9
11 files changed
+238 -32

No files matched your search

+2 -1
View File
@@ -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). |
+3 -1
View File
@@ -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
+20 -2
View File
@@ -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<n>`,
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
+5 -3
View File
@@ -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:<combo> the extra keys (Esc, Del, Home, End, PgUp/PgDn, arrows),
// optionally with modifiers; ctrl and/or alt (+ optional
// key:<combo> 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:<char> one character Steam's key emulation can't produce: non-ASCII
// (äöü߀§°´…) or | @ { [ ] } \ ~ ^ `
// down:<mod> / up:<mod> 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) {
@@ -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}`);
+22 -3
View File
@@ -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;
+4 -2
View File
@@ -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"}
+38
View File
@@ -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<n>, 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 };
})()
+59 -17
View File
@@ -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 ("word<TAB>zipf*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<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() {
@@ -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) {
@@ -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})`);
@@ -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 <function-keys.js> <textmodel.js> <allowlist.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_<keysym> is sent as key:[ctrl+][alt+][shift+]<keysym>.
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`);