Files
lhns--steam-frame-nix/modules/vr-keyboard/suggestions-panel/patch.js
T
Pierre Kisters 5d15902ba1 Clearer file names and layout; docs/development.md
- steam-keyboard-patch.nix -> vr-keyboard-extra-keys.nix (option
  keyboard.vr.extraKeys), helper.mjs -> xdotool-helper.mjs;
  homeManagerModules.steam-keyboard-patch stays as an alias.
- vr-keyboard: panel.js/unpatch-panel.js/relay.mjs ->
  suggestions-panel/{patch,unpatch}.js + relay.mjs; decoder.js ->
  swipe-decoder.js; build.nix split into dictionary.nix and check.nix.
- modules/lib -> modules/steam-ui-patches/lib (the UI patch library).
- docs/development.md: repository layout (what runs where), runtime names.

Patch names, user services and state files are unchanged.
2026-09-29 01:42:34 +02:00

202 lines
12 KiB
JavaScript

// The suggestion strip as a SteamVR dashboard panel above or below Steam's
// VR keyboard (suggestions.position "above" / "below"; patch name
// "vr-keyboard-panel"), injected into SteamVR's systemui page (8087). State
// comes from the keyboard page (../patch.js) through relay.mjs:
// __sfuiKbdStrip.show({ seq, items, current, visible, style }); a click calls
// the binding __sfuiStripPick('{"seq":n,"index":i}').
//
// systemui mounts the keyboard overlay (mountedId "...gamepadui.keyboard")
// under an anchor transform, scaled by its sibling transform; the overlay
// hangs from its top edge (SteamVR's grab handle sits in the page's
// transparent bottom band). Our panel is a child of that anchor, as wide as
// the keyboard, so it follows it: "above" at y = +GAP (origin bottom
// centre); "below" at the keyboard's lower edge (origin top centre), in
// front of the grab handle. Built like
// frame-controls' own popups: a vsg-node panel whose buildNode publishes a
// region of this page (SGApp embedded-UV slot) at a free spot of the page.
(() => {
const VERSION = 12;
const G = window;
const GAP = 0.012, DEPTH = 0.005; // m above the keyboard's top edge / in front of it
// "below": the strip's top edge sits this much above the lower of the
// keyboard's and the grab handle's lower edges (user-tuned), overlapping
// the handle's hit area a little -- so the strip lies in front of the
// handle (+z toward the viewer; the handle is at z 0.01) and gets the laser.
const BELOW_OFFSET = 0.013, BELOW_DEPTH = 0.015;
const O = { heightPx: 110, widthPx: 1700 }; // max page region for the strip texture (px)
const old = G.__sfuiKbdStrip;
if (old?.version === VERSION) return 'unchanged';
const missing = [
typeof G.SGApp?.addEmbeddedPanelUVs !== 'function' && 'SGApp.addEmbeddedPanelUVs',
typeof G.SGApp?.removeEmbeddedPanelUVs !== 'function' && 'SGApp.removeEmbeddedPanelUVs',
typeof G.VRHTML?.NextSGID !== 'function' && 'VRHTML.NextSGID',
typeof G.VRHTML?.VROverlay?.TriggerOverlayHapticEffect !== 'function' && 'VRHTML.VROverlay.TriggerOverlayHapticEffect',
typeof G.forceLayoutUpdate !== 'function' && 'forceLayoutUpdate',
].filter(Boolean);
if (missing.length) return `SteamVR internals changed, no strip panel: missing ${missing.join(', ')}`;
const saved = old?.state ?? null;
try { old?.dispose?.(); } catch { /* gone */ }
const S = G.__sfuiKbdStrip = { version: VERSION, state: saved, own: null, log: [] };
const log = (...a) => { S.log.push([Math.round(performance.now()), ...a]); if (S.log.length > 100) S.log.shift(); };
const style = document.createElement('style');
style.id = 'sfui-kbd-strip-style';
style.textContent = `
.sfui-kbd-strip { position: fixed; display: flex; box-sizing: border-box; width: ${O.widthPx}px;
background: rgb(35, 38, 46); z-index: 1; pointer-events: auto; }
.sfui-kbd-strip > div { flex: 1 1 0; min-width: 0; display: flex; align-items: center; justify-content: center;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-sizing: border-box; }
.sfui-kbd-strip > div:hover { filter: brightness(1.6); }`;
document.head.appendChild(style);
const fiberProps = (e) => {
const k = Object.keys(e).find((x) => x.startsWith('__reactFiber$'));
let f = k && e[k];
for (let i = 0; f && i < 6; f = f.return, i++) if (f.memoizedProps?.mountedId) return f.memoizedProps;
return null;
};
// The keyboard's anchor transform (see header) and its overlay scale, or null.
function keyboardAnchor() {
for (const e of document.querySelectorAll('[vsg-type="mountedscenegraph"]')) {
if (!String(fiberProps(e)?.mountedId ?? '').endsWith('gamepadui.keyboard')) continue;
const scaled = e.parentElement, anchor = scaled?.parentElement;
if (!anchor || anchor.tagName !== 'VSG-TRANSFORM') return null;
const scale = parseFloat((scaled.getAttribute('scale') || '1').split(' ')[0]) || 1;
let width = 1;
try { width = VRHTML.VROverlay.GetWidthInMeters(VRHTML.VROverlay.FindOverlay('valve.steam.gamepadui.keyboard')) || 1; } catch { /* 1 m */ }
// SteamVR's grab handle: a sibling transform (origin bottom centre) whose
// y is where its panel ends; 0.66 m if not found.
const handle = [...anchor.children].find((c) => c !== scaled && !c.querySelector?.('.sfui-kbd-own') && c.querySelector?.('[vsg-type="panel"]'));
const handleBottom = -parseFloat((handle?.getAttribute('translation') || '0 -0.66').split(' ')[1]) || 0.66;
return { anchor, width: width * scale, handleBottom };
}
return null;
}
// A free spot of the page for the texture region (below every panel).
function freeSpot() {
const rs = [...document.querySelectorAll('[vsg-type="panel"]')]
.filter((p) => !p.classList.contains('sfui-kbd-own')).map((p) => p.getBoundingClientRect()).filter((r) => r.width && r.height);
const W = Math.min(O.widthPx, innerWidth - 4), H = O.heightPx;
for (let y = innerHeight - H - 4; y >= 0; y -= 16) {
if (!rs.some((r) => r.top < y + H + 2 && r.bottom > y - 2 && r.left < W + 4)) return { x: 2, y, W };
}
return null;
}
function mount(kb) {
const app = G.SGApp, spot = freeSpot();
if (!spot) { log('no free spot'); return null; }
const outer = document.createElement('vsg-transform');
for (const [k, v] of Object.entries({ translation: '0 0 0', rotation: '1 0 0 0', scale: '1 1 1', sgid: VRHTML.NextSGID() }))
outer.setAttribute(k, String(v));
const node = document.createElement('vsg-node');
node.id = 'sfui-kbd-strip-panel';
node.className = 'sfui-kbd-own';
node.setAttribute('vsg-type', 'panel');
const sgid = VRHTML.NextSGID();
node.setAttribute('sgid', String(sgid));
const content = document.createElement('div');
content.className = 'sfui-kbd-strip';
content.style.left = `${spot.x}px`; content.style.top = `${spot.y}px`; content.style.width = `${spot.W}px`;
content.style.height = `${O.heightPx}px`;
for (const t of ['mousedown', 'mouseup', 'click', 'dblclick', 'pointerdown', 'pointerup', 'contextmenu'])
content.addEventListener(t, (e) => e.stopPropagation()); // nothing reaches the dashboard's handlers
content.addEventListener('click', (e) => {
const el = e.target.closest?.('[data-index]');
if (!el || !S.state) return;
log('pick', S.state.seq, +el.dataset.index);
if (+el.dataset.index !== S.state.current && S.state.haptic) { // tick on this (the laser's) overlay
try { VRHTML.VROverlay.TriggerOverlayHapticEffect(VRHTML.VROverlay.ThisOverlayHandle(), S.state.haptic); } catch (err) { log('haptic', String(err)); }
}
G.__sfuiStripPick?.(JSON.stringify({ seq: S.state.seq, index: +el.dataset.index }));
});
node.appendChild(content);
outer.appendChild(node);
const fp = { // what SGApp's embedded-UV table reads of a panel
props: { debug_name: node.id }, isExternal: false, m_Rect: { x: 0, y: 0, width: 0, height: 0 }, idx: undefined,
getSGID: () => sgid, getEmbeddedIndex: () => fp.idx, getCurrentRootElement: () => node,
updateLayoutValues() { const r = content.getBoundingClientRect(); fp.m_Rect = { x: r.x, y: r.y, width: r.width, height: r.height }; },
};
fp.idx = app.addEmbeddedPanelUVs(fp);
if (fp.idx == null) { log('no embedded UV slot'); return null; }
node.buildNode = (ctx) => {
if (!kb.anchor.isConnected) { queueMicrotask(() => unmount('keyboard gone')); return [ctx, null]; }
const r = content.getBoundingClientRect(), W = innerWidth, H = innerHeight;
return [{ ...ctx, currentPanel: fp, bInsideReparentedPanel: false }, { type: 'panel', properties: {
id: `system.systemui::${node.id}`, sgid, key: 'system.systemui', debug_name: node.id,
width: kb.width, origin: S.state?.position === 'below' ? [0, 1] : [0, -1], interactive: true, scrollable: false, visibility: 0,
'only-visible-with-laser': false, 'lasermouse-filtering': 0, 'can-take-keyboard-focus': false,
'sort-depth-bias': -1, 'scale-index': 0,
'embedded-uv-index': fp.idx, uv_min: [r.x / W, r.y / H], uv_max: [(r.x + r.width) / W, (r.y + r.height) / H],
} }];
};
kb.anchor.appendChild(outer);
G.forceLayoutUpdate();
log('mounted', spot.y, kb.width);
return { outer, node, content, fp, anchor: kb.anchor };
}
function unmount(why) {
const o = S.own;
if (!o) return;
S.own = null;
o.outer.remove();
try { G.SGApp.removeEmbeddedPanelUVs(o.fp); } catch (e) { log('remove UVs', String(e)); }
G.forceLayoutUpdate();
log('unmounted', why);
}
// The keyboard's key look (patch.js keyStyle), scaled from its page
// (pageWidth px across the keyboard) to ours (content width across it).
const FALLBACK = { pageWidth: 854, pageHeight: 280, keyHeight: 44, pad: [1, 1, 2, 2], background: 'rgb(14, 20, 27)', color: 'rgb(255, 255, 255)',
fontFamily: '"Motiva Sans", Arial, Helvetica, sans-serif', fontSize: 16, fontWeight: '400', radius: 0,
border: '0px none rgb(255, 255, 255)', boxShadow: 'none', board: 'rgb(35, 38, 46)', pressed: 'rgb(26, 159, 255)' };
function applyStyle(content, st) {
const k = { ...FALLBACK, ...(st || {}) };
const f = content.getBoundingClientRect().width / (k.pageWidth || 854) || 2;
const [pt, pr, pb, pl] = (Array.isArray(k.pad) && k.pad.length === 4 ? k.pad : FALLBACK.pad).map((v) => v * f);
const h = Math.min(O.heightPx, Math.round((k.keyHeight + (k.pad?.[0] ?? 1) + (k.pad?.[2] ?? 2)) * f));
Object.assign(content.style, { height: `${h}px`, background: k.board, padding: `${pt}px ${pr}px ${pb}px ${pl}px`, gap: `${pl + pr}px` });
S.applied = { f, h, key: k };
return { k, f };
}
function render() {
const st = S.state;
const kb = st?.visible && st.items?.length ? keyboardAnchor() : null;
if (!kb) { unmount(st?.visible ? 'no keyboard' : 'hidden'); return; }
if (S.own && (S.own.anchor !== kb.anchor || !S.own.outer.isConnected)) unmount('anchor changed');
if (!S.own) S.own = mount(kb);
if (!S.own) return;
const { k, f } = applyStyle(S.own.content, st.style);
// Placement (m, in the keyboard anchor's frame; the keyboard hangs from y = 0).
const kbHeight = kb.width * (k.pageHeight || 280) / (k.pageWidth || 854);
const below = st.position === 'below';
const y = below ? -(Math.max(kbHeight, kb.handleBottom) - BELOW_OFFSET) : GAP;
S.own.outer.setAttribute('translation', `0 ${y.toFixed(4)} ${below ? BELOW_DEPTH : DEPTH}`);
S.placement = { position: st.position, y, kbHeight, handleBottom: kb.handleBottom };
S.own.content.replaceChildren(...st.items.map((text, i) => {
const el = document.createElement('div');
el.textContent = text;
el.dataset.index = i;
Object.assign(el.style, {
background: k.background, color: k.color, fontFamily: k.fontFamily, fontSize: `${k.fontSize * f}px`,
fontWeight: k.fontWeight, borderRadius: `${k.radius * f}px`, border: k.border, boxShadow: k.boxShadow,
});
// The word as it stands in the text: a thin bar in the keyboard's pressed colour.
if (i === st.current) el.style.boxShadow = `inset 0 ${-Math.max(2, Math.round(3 * f))}px 0 ${k.pressed}`;
el.addEventListener('mousedown', () => { el.style.background = k.pressed; });
el.addEventListener('mouseleave', () => { el.style.background = k.background; });
return el;
}));
G.forceLayoutUpdate();
}
S.show = (state) => { S.state = state; render(); return 'ok'; };
// The keyboard can appear/disappear without a new state (dashboard toggled).
S.timer = setInterval(() => {
const want = !!(S.state?.visible && S.state.items?.length);
if (want !== !!S.own || (S.own && !S.own.anchor.isConnected)) render();
}, 500);
S.dispose = () => { clearInterval(S.timer); unmount('dispose'); style.remove(); };
render();
return 'patched';
})