Files
lhns--steam-frame-nix/modules/launcher-menu/grid/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

300 lines
14 KiB
JavaScript

// grid: shows the programs section of the VR dashboard's "+" menu
// (#VRDashboard_LaunchNonSteamApp) as a grid of tiles (icon, name below).
// mkPatch patch (see steam-ui-patches/lib/default.nix); opts: { columns,
// maxRows } (maxRows null: the stock 600 px max height).
//
// Pure restyling in SharedJSContext (the bar popups share its realm): Steam's
// items and handlers stay, so the other launcher-menu patches keep working. A
// timer attaches a MutationObserver to every bar popup document
// (g_PopupManager); the section is found via React fibers (component with a
// `header` prop, key "programs"; "windows" stays a list) and its elements get
// data-sfui-grid="<role>" markers that a stylesheet turns into:
// - list/panel: full-width section; the item container is an N-column grid
// still scrolling inside the stock scroll region (heading stays);
// - scroller: with maxRows, max-height = bottom of row maxRows, measured at
// layout time (labels have 1 or 2 lines);
// - fade/shadow: Steam computes ScrollFade and can-scroll-up/-down only on
// renders and scroll events, so they go stale after the grid relayout. The
// state is recomputed (Steam's thresholds) into data-sfui-fade/-shadow; once
// maxRows caps the scroller, the inner panel is what scrolls.
// Gamepad navigation follows the computed display: grid. The pinned-desktop
// row (.sfui-pinned-desktop) is made slim and centred.
// Anchors: "launcher-menu-grid" in steam-ui-patches/lib/signatures.json.
// unpatch.js (or a new VERSION/options) calls __sfuiLauncherGrid.stop().
((find, sigs, opts) => {
const NAME = 'launcher-menu-grid';
const VERSION = 7;
const COLS = opts.columns;
const ROWS = opts.maxRows ?? null;
if (!(Number.isInteger(COLS) && COLS >= 1) || !(ROWS === null || (Number.isInteger(ROWS) && ROWS >= 1)))
return `invalid options ${JSON.stringify(opts)}`;
const ID = `${VERSION}:${COLS}:${ROWS}`;
const SECTION_KEY = 'programs';
const ATTR = 'data-sfui-grid';
const FADE = 'data-sfui-fade';
const SHADOW = 'data-sfui-shadow';
const MAXH = '--sfui-grid-max-height';
const STYLE_ID = 'sfui-launcher-grid-style';
const PIN = 'sfui-pinned-desktop'; // pinned-desktop patch's block
const TILE = `[${ATTR}=panel] > [role=button]`;
const PINNED = `[${ATTR}=list] > .${PIN}`;
const gradient = (stops) => `linear-gradient(to bottom, ${stops}) !important`;
const CSS = `
[${ATTR}=list] { width: 100vw !important; }
[${ATTR}=panel] {
display: grid !important;
grid-template-columns: repeat(${COLS}, minmax(0, 1fr));
grid-auto-rows: max-content;
align-content: start;
gap: 4px;
width: auto !important;
padding: 8px !important;
box-sizing: border-box;
}
[${ATTR}=scroller] { max-height: var(${MAXH}, none) !important; }
[${FADE}=none] { mask-image: none !important; }
[${FADE}=top] { mask-image: ${gradient('transparent 0%, black var(--scroll-fade-size), black 100%')}; }
[${FADE}=bottom] { mask-image: ${gradient('black 0%, black calc(100% - var(--scroll-fade-size)), transparent 100%')}; }
[${FADE}=both] { mask-image: ${gradient('transparent 0%, black var(--scroll-fade-size), black calc(100% - var(--scroll-fade-size)), transparent 100%')}; }
[${SHADOW}=none]::before, [${SHADOW}=none]::after,
[${SHADOW}=top]::after, [${SHADOW}=bottom]::before { opacity: 0 !important; }
[${SHADOW}=top]::before, [${SHADOW}=bottom]::after,
[${SHADOW}=both]::before, [${SHADOW}=both]::after { opacity: 1 !important; }
${TILE} {
margin: 0 !important;
padding: 8px 4px 6px !important;
min-width: 0;
border-radius: 6px;
transform-origin: 50% 50%;
container-type: inline-size;
}
${TILE}::after { display: none !important; }
${TILE} [${ATTR}=wrap] { width: 100%; min-width: 0; align-self: flex-start !important; justify-content: center !important; }
${TILE} [${ATTR}=label] { flex-direction: column !important; align-items: center !important; gap: 6px; width: 100%; min-width: 0; }
${TILE} [${ATTR}=iconbox] { padding: 0 !important; }
${TILE} [${ATTR}=icon] { width: clamp(20px, 50cqi, 64px) !important; height: clamp(20px, 50cqi, 64px) !important; }
${TILE} [${ATTR}=icon] > img, ${TILE} [${ATTR}=icon] > svg { width: 100% !important; height: 100% !important; max-width: none !important; object-fit: contain; }
${TILE} [${ATTR}=labelbox] { width: 100%; min-width: 0; }
${TILE} [${ATTR}=marquee] { justify-content: center; mask-image: none !important; animation: none !important; }
${TILE} [${ATTR}=text] {
display: -webkit-box !important;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
flex: 0 1 auto !important;
max-width: 100%;
overflow: hidden;
white-space: normal;
word-break: break-word;
text-align: center;
font-size: 13px;
line-height: 16px;
padding: 0 !important;
animation: none !important;
transform: none !important;
}
${PINNED} { padding: 2px 20px !important; --field-negative-horizontal-margin: 20px !important; }
${PINNED} > [role=button] { padding-top: 7px !important; padding-bottom: 7px !important; }
${PINNED} > [role=button], ${PINNED} > [role=button] * { justify-content: center !important; }
${PINNED} > [role=button] * { flex-grow: 0 !important; text-align: center !important; }`;
const prev = window.__sfuiLauncherGrid;
if (prev?.id === ID) { prev.scan(); return 'unchanged'; }
prev?.stop?.();
if (!window.g_PopupManager) return 'g_PopupManager missing';
const docs = new Map(); // popup document -> { obs, ro }
const regions = new Map(); // scroller -> { d, list, panel, fade, onScroll }
const guard = (f) => (...a) => { try { f(...a); } catch (e) { console.error(`sfui ${NAME}:`, e); } };
const mark = (el, role) => { if (el && el.getAttribute(ATTR) !== role) el.setAttribute(ATTR, role); };
// Menu items are leaf buttons (the scroll region is a role=button too).
const isItem = (el) => el.getAttribute('role') === 'button' && !el.querySelector('[role=button]');
const tilesOf = (panel) => [...panel.children].filter(isItem);
const regionsOf = (d) => [...regions].filter(([, r]) => r.d === d).map(([sc]) => sc);
const px = (x) => parseFloat(x) || 0;
// Section of an item: nearest function-component fiber with a `header`
// prop; its key names the section, its first host node is the list.
const sectionOf = (item) => {
const f = find.findFiberUp(item, (x) => typeof x.type === 'function' &&
x.memoizedProps && typeof x.memoizedProps === 'object' && 'header' in x.memoizedProps, 60);
if (!f) return null;
let c = f.child;
while (c && typeof c.type !== 'string') c = c.child;
return { key: f.key, list: c?.stateNode ?? null };
};
// Stock scroll region: nearest ancestor of the panel, below the list, that
// scrolls vertically.
const scrollerOf = (panel, list) => {
const w = panel.ownerDocument.defaultView;
for (let e = panel.parentElement; e && e !== list; e = e.parentElement)
if (/^(auto|scroll)$/.test(w.getComputedStyle(e).overflowY)) return e;
return null;
};
// Element with Steam's scroll fade: the scroller or an ancestor below the
// list that is already marked or whose stock mask is a gradient.
const fadeOf = (sc, list) => {
const w = sc.ownerDocument.defaultView;
for (let e = sc; e && e !== list; e = e.parentElement) {
if (e.hasAttribute(FADE)) return e;
const cs = w.getComputedStyle(e);
if (/gradient/.test(cs.maskImage || cs.webkitMaskImage || '')) return e;
}
return null;
};
// Tile internals: item > wrappers > label [iconbox > icon, labelbox > marquee > text].
const markItem = (item) => {
const text = [...item.querySelectorAll('div')].find((e) => e.childElementCount === 0 && e.textContent.trim());
const marquee = text?.parentElement;
const labelbox = marquee?.parentElement;
const label = labelbox?.parentElement;
if (!label || label === item || !item.contains(label)) return;
for (const t of marquee.children) mark(t, 'text');
mark(marquee, 'marquee');
mark(labelbox, 'labelbox');
mark(label, 'label');
const iconbox = label.firstElementChild !== labelbox ? label.firstElementChild : null;
if (iconbox) { mark(iconbox, 'iconbox'); mark(iconbox.firstElementChild, 'icon'); }
for (let w = label.parentElement; w && w !== item; w = w.parentElement) mark(w, 'wrap');
};
// Scroll state (Steam's thresholds) of the element that actually scrolls:
// the inner panel once maxRows caps the scroller, else the scroller.
const scrolling = (sc, panel) =>
panel && (panel.scrollHeight > panel.clientHeight + 1 || panel.scrollTop > 0) ? panel : sc;
const fade = (sc) => {
const r = regions.get(sc);
if (!r) return;
const el = scrolling(sc, r.panel);
const top = el.scrollTop > 1;
const bottom = el.scrollHeight - el.scrollTop > el.clientHeight + 1;
const v = top && bottom ? 'both' : top ? 'top' : bottom ? 'bottom' : 'none';
if (sc.getAttribute(SHADOW) !== v) sc.setAttribute(SHADOW, v);
if (r.fade && r.fade.getAttribute(FADE) !== v) r.fade.setAttribute(FADE, v);
};
// maxRows: scroller max-height from its border-box top to the bottom of row
// ROWS, plus the panel's bottom padding (at least the fade size, so that
// row is not faded). Empty (no limit) while there are no more rows.
const maxHeight = (sc, r) => {
const tiles = tilesOf(r.panel);
if (tiles.length <= ROWS * COLS) return '';
const w = sc.ownerDocument.defaultView;
const bottom = Math.max(...tiles.slice((ROWS - 1) * COLS, ROWS * COLS).map((t) => t.getBoundingClientRect().bottom));
const cs = w.getComputedStyle(sc);
const fadeSize = r.fade ? px(w.getComputedStyle(r.fade).getPropertyValue('--scroll-fade-size')) : 0;
const pad = Math.max(px(w.getComputedStyle(r.panel).paddingBottom), fadeSize);
let h = bottom - sc.getBoundingClientRect().top + sc.scrollTop + pad + px(cs.paddingBottom) + px(cs.borderBottomWidth);
if (cs.boxSizing !== 'border-box') h -= px(cs.paddingTop) + px(cs.paddingBottom) + px(cs.borderTopWidth) + px(cs.borderBottomWidth);
return `${Math.ceil(h)}px`;
};
const layout = (sc) => {
const r = regions.get(sc);
if (!r) return;
r.fade ??= fadeOf(sc, r.list);
if (ROWS !== null) {
const v = maxHeight(sc, r);
if (sc.style.getPropertyValue(MAXH) !== v) {
if (v) sc.style.setProperty(MAXH, v); else sc.style.removeProperty(MAXH);
}
}
fade(sc);
};
const track = (d, sc, panel, list) => {
const ro = docs.get(d)?.ro;
const r = regions.get(sc);
if (r) {
if (r.panel !== panel) {
ro?.unobserve(r.panel); r.panel.removeEventListener('scroll', r.onScroll);
r.panel = panel; ro?.observe(panel); panel.addEventListener('scroll', r.onScroll, { passive: true });
}
return;
}
const onScroll = guard(() => fade(sc));
regions.set(sc, { d, list, panel, fade: fadeOf(sc, list), onScroll });
sc.addEventListener('scroll', onScroll, { passive: true });
panel.addEventListener('scroll', onScroll, { passive: true });
ro?.observe(sc);
ro?.observe(panel);
};
const untrack = (sc) => {
const r = regions.get(sc);
if (!r) return;
regions.delete(sc);
sc.removeEventListener('scroll', r.onScroll);
r.panel.removeEventListener('scroll', r.onScroll);
const ro = docs.get(r.d)?.ro;
ro?.unobserve(sc);
ro?.unobserve(r.panel);
sc.style.removeProperty(MAXH);
sc.removeAttribute(SHADOW);
r.fade?.removeAttribute(FADE);
};
const update = (d) => {
const keep = new Set();
for (const item of d.querySelectorAll('[role=button]')) {
const panel = item.parentElement;
if (!panel || keep.has(panel) || !isItem(item) || item.closest('.' + PIN)) continue;
const sec = sectionOf(item);
if (sec?.key !== SECTION_KEY || !sec.list?.contains(panel) || sec.list === panel) continue;
keep.add(panel).add(sec.list);
mark(sec.list, 'list');
mark(panel, 'panel');
tilesOf(panel).forEach(markItem);
const sc = scrollerOf(panel, sec.list);
if (sc) { keep.add(sc); mark(sc, 'scroller'); track(d, sc, panel, sec.list); }
}
for (const el of d.querySelectorAll(`[${ATTR}=list], [${ATTR}=panel], [${ATTR}=scroller]`))
if (!keep.has(el)) el.removeAttribute(ATTR);
for (const sc of regionsOf(d)) if (!keep.has(sc) || !sc.isConnected) untrack(sc);
regionsOf(d).forEach(layout);
};
const attach = (d) => {
if (docs.has(d) || !d?.body) return;
find.ensureStyle(d, STYLE_ID, CSS);
const obs = new MutationObserver(guard(() => update(d)));
obs.observe(d.body, { childList: true, subtree: true, characterData: true });
const ro = new d.defaultView.ResizeObserver(guard(() => regionsOf(d).forEach(layout)));
docs.set(d, { obs, ro });
update(d);
};
const detach = (d, { obs, ro }) => {
obs.disconnect();
ro.disconnect();
for (const sc of regionsOf(d)) regions.delete(sc);
docs.delete(d);
};
const scan = () => {
for (const [d, o] of docs) if (!d.defaultView || d.defaultView.closed) detach(d, o);
for (const p of g_PopupManager.GetPopups())
if (/barpopup/.test(p.m_strName ?? '')) guard(attach)(p.window?.document);
};
const timer = setInterval(scan, 1000);
const stop = () => {
clearInterval(timer);
for (const sc of [...regions.keys()]) { try { untrack(sc); } catch {} }
for (const [d, o] of docs) {
detach(d, o);
try {
for (const el of d.querySelectorAll(`[${ATTR}], [${FADE}], [${SHADOW}]`)) { el.removeAttribute(ATTR); el.removeAttribute(FADE); el.removeAttribute(SHADOW); }
d.getElementById(STYLE_ID)?.remove();
} catch {}
}
if (window.__sfuiLauncherGrid === state) delete window.__sfuiLauncherGrid;
};
const state = { id: ID, version: VERSION, columns: COLS, maxRows: ROWS, scan, stop, docs, regions };
window.__sfuiLauncherGrid = state;
scan();
return `patched (${COLS} columns${ROWS === null ? '' : `, max ${ROWS} rows`})`;
})