diff --git a/README.md b/README.md index 5b146dd..1083572 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ the two files below). pinDesktop = "bottom"; closeOnLaunch = true; launchDebounce = 10; + grid = { enable = true; columns = 4; maxRows = 4; }; }; dashboard = { windowMaxScale = 4.0; @@ -190,6 +191,10 @@ menu list every desktop entry, including terminals such as Konsole. | `steamFrame.launcherMenu.pinDesktop` | null or `"top"` / `"bottom"` | `null` | Pin "Desktop" above or below the "+" menu's scrolling list (always visible). `null`: a normal list entry. | | `steamFrame.launcherMenu.closeOnLaunch` | bool | `false` | Close the "+" menu as soon as a program in it is clicked. | | `steamFrame.launcherMenu.launchDebounce` | unsigned int (seconds) | `0` | Ignore repeated launches of the same program from the "+" menu within this time. `0`: off. | +| `steamFrame.launcherMenu.grid.enable` | bool | `false` | Show the "+" menu's programs as a grid of tiles (large icon, name below) instead of a list. | +| `steamFrame.launcherMenu.grid.columns` | int, 1-8 | `4` | Tiles per row (popup is 300 px wide: 3 ≈ 92 px, 4 ≈ 68 px, 5 ≈ 53 px tiles). | +| `steamFrame.launcherMenu.grid.maxRows` | null or positive int | `null` | Rows visible at once, the rest scrolls. `null`: fill up to the menu's max height (600 px). | +| `steamFrame.launcherMenu.iconFallbacks` | list of str | `[ "utilities-terminal" "preferences-system" ]` | Icon names installed as hicolor copies of Breeze app icons, so the "+" menu shows them (Konsole, KDE System Settings). `[ ]`: none. Takes effect after a Steam restart. | | `steamFrame.dashboard.windowMaxScale` | null or number | `null` | Largest resize-handle scale of SteamVR dashboard windows, relative to their default size. `null`: stock (2). See [Dashboard windows](#dashboard-windows-dashboard). | | `steamFrame.dashboard.windowDistance.{world,theater,dashboard}.{min,max}` | null or number (m) | `null` | How close / far grabbed windows can be pulled in / pushed back. `null`: stock (world 0.25-5, theater 1-6, dashboard 0.3-4 m). | | `steamFrame.steamvrDebugger.enable` | bool | `true` if a UI patch uses port 8087, else `false` | SteamVR dashboard DevTools on `127.0.0.1:8087` (`VRWebHelper/DebuggerEnabled`), needed by dashboard patches. See [SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable). | @@ -390,7 +395,10 @@ The signatures are data, in `modules/lib/signatures.json`, shared by the patches and the offline checker. Besides `module`/`exports`, an entry can list `expects` (strings the patch relies on, e.g. internal property names, only checked offline) or be `checkOnly` (anchors a patch uses without the -finder, e.g. the React prop names of the "+" menu, checked offline only). +finder, e.g. the React prop names of the "+" menu, checked offline only). A +`checkOnly` entry with `stylesheet` instead of `module` is matched against +the bundle's CSS files (`styles` directory of the bundle), e.g. the scroll +fade's gradient the grid relies on. For your own patches, `steamFrame.uiPatches.lib.mkPatch` wraps a patch written as a function expression with the library, its signatures and @@ -499,12 +507,62 @@ the program twice. ignored and logged (`journalctl --user -u steam-ui-patches`, on the next re-injection, i.e. within 15 s). Consequence: a program that exits right away can only be started again once the time is up. +- `grid.enable = true`: the programs section ("Launch Program") becomes a + grid of tiles: large icon, name centred below (up to two lines). The + "Add desktop window" section stays a list. This only restyles Steam's own + items (found through React: the section keyed `programs`), so launching, + sounds and the other options above work unchanged; Desktop is a normal + tile, or with `pinDesktop` a slim, centred full-width row. + + The popup window is a fixed 300 px wide, so `columns` sets the tile size: + + | `columns` | tile width | + |---|---| + | 3 | ≈ 92 px | + | 4 (default) | ≈ 68 px | + | 5 | ≈ 53 px | + + Icons scale with the tile (up to 64 px). `maxRows = n` limits the menu to + n rows of tiles (measured, since names take one or two lines); the rest + scrolls inside the menu. `null` (default) lets the grid grow up to the + menu's stock maximum height (600 px). Steam's scroll fade (the gradient at + the top/bottom edge) is shown only where there is more to scroll: Steam + computes it only when React re-renders or the list scrolls, so the patch + recomputes it after the grid changes the layout. + + Controller navigation keeps working: Steam derives thumbstick / D-pad + directions from the panel's CSS grid, so up/down/left/right move between + tiles. All are reverted when the options are turned off (next switch). These -patches use Steam APIs (`SteamClient.Apps`) and React props rather than -webpack modules; the offline checker verifies those anchors too. +patches use Steam APIs (`SteamClient.Apps`), React props and CSS rather +than webpack modules; the offline checker verifies those anchors too +(including the scroll fade's class names and stylesheet, checked as a +`stylesheet` signature against Steam's CSS files). Tested with Steam client 1790377368. +#### Icon fallbacks (`launcherMenu.iconFallbacks`) + +**Problem:** Steam's scan of host programs resolves a desktop entry's +`Icon=` name only in the hicolor icon theme (and `pixmaps`), not in the +desktop's icon theme. Konsole (`Icon=utilities-terminal`) and KDE System +Settings (`Icon=preferences-system`), which SteamOS ships, have their icons +only in Breeze, so they show up without an icon in the "+" menu. + +**Fix:** for each name in `iconFallbacks` (default: those two), the largest +Breeze app icon from nixpkgs' `kdePackages.breeze-icons` is installed as +`~/.nix-profile/share/icons/hicolor/scalable/apps/.svg` +(`~/.nix-profile/share` is on Steam's `XDG_DATA_DIRS`); desktop entries are +left alone. Add names for other programs that lack an icon (the `Icon=` +value of their desktop entry), e.g. +`iconFallbacks = [ "utilities-terminal" "preferences-system" "system-file-manager" ];`; +`[ ]` installs nothing. A name Breeze has no app icon for fails the build +with an error naming it. + +**Caveat:** Steam caches the icon lookup (GTK icon cache) for its lifetime, +and files in the Nix store all have the same 1970 mtime, so new icons show +up only after Steam is restarted (e.g. a reboot), not on the next switch. + ### Dashboard windows (`dashboard.*`) **Problem:** SteamVR dashboard windows (Steam, app windows, overlays, the diff --git a/modules/launcher-menu.nix b/modules/launcher-menu.nix index e40939f..e04685d 100644 --- a/modules/launcher-menu.nix +++ b/modules/launcher-menu.nix @@ -12,15 +12,43 @@ # ignore repeated launches of the same program within a few seconds (stock, # the menu stays open until the program's window appears, inviting double # launches). Options are passed by calling the patch's function. +# - grid/: restyles the programs section as a grid of tiles (icon, name +# below), optionally limited to maxRows visible rows. # All are reverted when turned off (next switch). -# Depends on Steam UI internals (React props, popup names), not on webpack -# module ids; scripts/check-signatures.mjs checks them after a Steam update -# (lib/signatures.json). Tested with Steam client 1790377368. +# Depends on Steam UI internals (React props, popup names, the scroll fade's +# classes and CSS), not on webpack module ids; scripts/check-signatures.mjs +# checks them after a Steam update (lib/signatures.json). Tested with Steam +# client 1790377368. +# +# iconFallbacks is not a patch: Steam's scan of host programs resolves a +# desktop entry's Icon= name only in the hicolor icon theme (and pixmaps), +# so programs whose icon exists only in the desktop's Breeze theme (SteamOS' +# Konsole and KDE System Settings) have no icon in the menu. Hicolor copies +# from nixpkgs' Breeze are installed into the profile (~/.nix-profile/share +# is on Steam's XDG_DATA_DIRS). { config, lib, pkgs, ... }: let cfg = config.steamFrame.launcherMenu; inherit (import ./lib { inherit pkgs; }) mkPatch; sharedJSContext = { title = "SharedJSContext"; }; + + # Largest Breeze app icon (all are SVG) as hicolor/scalable/apps/.svg. + breezeApps = "${pkgs.kdePackages.breeze-icons}/share/icons/breeze/apps"; + iconFallbacks = pkgs.runCommand "launcher-menu-icon-fallbacks" { } '' + dir=$out/share/icons/hicolor/scalable/apps + mkdir -p $dir + for name in ${lib.escapeShellArgs cfg.iconFallbacks}; do + src= + for size in 64 48 32 24 22 16; do + if [ -e "${breezeApps}/$size/$name.svg" ]; then src="${breezeApps}/$size/$name.svg"; break; fi + done + if [ -z "$src" ]; then + echo "steamFrame.launcherMenu.iconFallbacks: no Breeze app icon \"$name\" (${breezeApps}//$name.svg)" >&2 + exit 1 + fi + cp -L "$src" "$dir/$name.svg" + done + ''; in { imports = [ ./steam-ui-patches.nix ]; @@ -69,8 +97,49 @@ in { 0 disables it. Runtime patch of Steam's UI (steamFrame.uiPatches). ''; }; + grid = { + enable = lib.mkEnableOption '' + the "+" menu's programs as a grid of tiles (large icon, name below) + instead of a list. Runtime patch of Steam's UI (steamFrame.uiPatches) + ''; + columns = lib.mkOption { + type = lib.types.ints.between 1 8; + default = 4; + description = '' + Tiles per row. The menu popup is a fixed 300 px wide: 3 columns give + tiles of about 92 px, 4 about 68 px, 5 about 53 px. + ''; + }; + maxRows = lib.mkOption { + type = lib.types.nullOr lib.types.ints.positive; + default = null; + example = 4; + description = '' + Rows of tiles visible at once: the menu shrinks to that many rows and + the rest scrolls. null: the grid fills up to the menu's stock maximum + height (600 px). + ''; + }; + }; + iconFallbacks = lib.mkOption { + type = lib.types.listOf (lib.types.strMatching "[A-Za-z0-9._+-]+"); + default = [ "utilities-terminal" "preferences-system" ]; + example = [ "utilities-terminal" "preferences-system" "system-file-manager" ]; + description = '' + Icon names (Icon= of desktop entries) installed as hicolor icons, + copied from nixpkgs' Breeze app icons, so the "+" menu shows them: + Steam resolves program icons only in the hicolor theme, not in the + desktop's Breeze theme. The default covers the entries SteamOS ships + whose icons exist only in Breeze: Konsole (utilities-terminal) and + KDE System Settings (preferences-system). A name Breeze has no app + icon for fails the build, naming it. [] installs nothing. Steam + picks up new icons only after a restart. + ''; + }; }; + config.home.packages = lib.optional (cfg.iconFallbacks != [ ]) iconFallbacks; + config.steamFrame.uiPatches.patches = lib.optional cfg.sort { name = "launcher-menu-order"; @@ -100,5 +169,15 @@ in { }; }; unpatch = ./launcher-menu/launch/unpatch.js; + } + ++ lib.optional cfg.grid.enable { + name = "launcher-menu-grid"; + target = sharedJSContext; + patch = mkPatch { + name = "launcher-menu-grid"; + src = ./launcher-menu/grid/patch.js; + opts = { inherit (cfg.grid) columns maxRows; }; + }; + unpatch = ./launcher-menu/grid/unpatch.js; }; } diff --git a/modules/launcher-menu/grid/patch.js b/modules/launcher-menu/grid/patch.js new file mode 100644 index 0000000..4e63bf4 --- /dev/null +++ b/modules/launcher-menu/grid/patch.js @@ -0,0 +1,313 @@ +// grid: shows the programs of the VR dashboard's "+" menu (section +// #VRDashboard_LaunchNonSteamApp) as a grid of tiles: large icon, name +// centred below (up to 2 lines). +// +// This file is a function expression, called by the file lib/default.nix +// (mkPatch) generates: ()(find, sigs, opts), with find the finder +// library (lib/finders.js) and opts { columns: 4, maxRows: 4 } from +// launcher-menu.nix (maxRows null: the list fills up to the menu's stock +// 600 px max height). +// +// Pure restyling, evaluated in SharedJSContext (the bar popups share its +// realm): Steam's own items (with their onActivate, focus handling and +// sounds) stay where they are, so launching and the other launcher-menu +// patches keep working unchanged. A timer attaches a MutationObserver to +// every dashboard bar popup document (g_PopupManager); whenever the menu +// renders, the programs section is found through React fibers (the section +// component, which has a `header` prop, keyed "programs"; the "windows" +// section, "add desktop window", stays a list) and its elements get +// data-sfui-grid="" markers, which a stylesheet in that document turns +// into: +// - list: the section (flex column: heading + scroll region) is as wide +// as the popup window (100vw, a fixed 300 px); +// - panel: the item container becomes a CSS grid of N columns; it still +// scrolls inside the stock scroll region, so the heading stays; +// - tile internals (wrap, label, iconbox, icon, labelbox, marquee, text) +// are laid out as a column; +// - scroller: the stock scroll region. With maxRows, its max-height (CSS +// variable) is set to the bottom of row maxRows, measured at +// layout time (labels have 1 or 2 lines), so the menu shrinks to +// that many rows and the rest scrolls; +// - fade: the element with Steam's ScrollFade class. Steam computes its +// ScrolledToTop/ScrolledToBottom state only on React renders and +// scroll events, so it is stale once the grid changes the layout +// (e.g. a bottom fade although nothing overflows). The state is +// recomputed (same thresholds) on scroll, resize and mutation and +// put in data-sfui-fade (none/top/bottom/both), which selects the +// same gradients as Steam's stylesheet (var(--scroll-fade-size)). +// Steam's gamepad navigation derives a panel's layout from its computed +// style (display: grid), so up/down/left/right move between tiles. +// With the pinned-desktop patch, its pinned row (.sfui-pinned-desktop, +// outside the grid) is made slim and its content centred. +// Anchors (section keys, `header` prop, ScrollFade classes and gradients) +// are checked by scripts/check-signatures.mjs ("launcher-menu-grid" in +// lib/signatures.json). +// unpatch.js (or a new VERSION or options) calls __sfuiLauncherGrid.stop(), +// which removes markers, stylesheets, observers and the timer. +((find, sigs, opts) => { + const NAME = 'launcher-menu-grid'; + const VERSION = 4; + 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 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%')}; } +${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: the nearest fiber ancestor that is a function + // component with a `header` prop; its key names the section, its first + // host descendant is the list container. + 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'); + }; + + // Fade state from the actual scroll position (Steam's thresholds). + const fade = (sc) => { + const r = regions.get(sc); + if (!r?.fade) return; + const top = sc.scrollTop > 1; + const bottom = sc.scrollHeight - sc.scrollTop > sc.clientHeight + 1; + const v = top && bottom ? 'both' : top ? 'top' : bottom ? 'bottom' : 'none'; + if (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 = panel; ro?.observe(panel); } + return; + } + const onScroll = guard(() => fade(sc)); + regions.set(sc, { d, list, panel, fade: fadeOf(sc, list), onScroll }); + sc.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); + const ro = docs.get(r.d)?.ro; + ro?.unobserve(sc); + ro?.unobserve(r.panel); + sc.style.removeProperty(MAXH); + 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; + let s = d.getElementById(STYLE_ID); + if (!s) { + s = d.createElement('style'); + s.id = STYLE_ID; + (d.head ?? d.documentElement).appendChild(s); + } + if (s.textContent !== CSS) s.textContent = 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}]`)) { el.removeAttribute(ATTR); el.removeAttribute(FADE); } + 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`})`; +}) diff --git a/modules/launcher-menu/grid/unpatch.js b/modules/launcher-menu/grid/unpatch.js new file mode 100644 index 0000000..7aeeba0 --- /dev/null +++ b/modules/launcher-menu/grid/unpatch.js @@ -0,0 +1,8 @@ +// Reverts grid/patch.js (markers, stylesheets, observers, timer): the "+" +// menu is a stock list again. Safe when not patched. +(() => { + const st = window.__sfuiLauncherGrid; + if (!st) return 'not patched'; + st.stop(); + return 'unpatched'; +})() diff --git a/modules/lib/finders.js b/modules/lib/finders.js index f36713a..5221652 100644 --- a/modules/lib/finders.js +++ b/modules/lib/finders.js @@ -35,7 +35,9 @@ // "getters": ["g", ...] getters on its prototype chain } // Fields that only scripts/check-signatures.mjs uses: "expects" (strings the // module source should contain, i.e. property names the patch relies on; -// reported as warnings) and "checkOnly": true (not resolved by the patch). +// reported as warnings), "checkOnly": true (not resolved by the patch) and, +// in place of "module", "stylesheet": (a checkOnly entry +// matched against the bundle's CSS files instead of its modules). // // Every lookup must match exactly once; otherwise a FinderError names the // signature, the part that failed and the candidates. diff --git a/modules/lib/signatures.json b/modules/lib/signatures.json index 063da41..0c7d3f8 100644 --- a/modules/lib/signatures.json +++ b/modules/lib/signatures.json @@ -5,7 +5,8 @@ "chunkGlobal": "webpackChunksteamui", "dir": "~/.local/share/Steam/steamui", "html": "index.html", - "exclude": "^localization/" + "exclude": "^localization/", + "styles": "css" }, "vrwebui-systemui": { "description": "SteamVR dashboard (vrwebhelper page systemui)", @@ -100,6 +101,36 @@ } } }, + "launcher-menu-grid": { + "bundle": "steamui", + "modules": { + "dashboardBar": { + "checkOnly": true, + "module": { "includes": ["allowLaunchProgram", "refBarPopopHandle", "#VRDashboard_LaunchNonSteamApp"] }, + "expects": ["key:\"programs\"", "key:\"windows\"", "header:"] + }, + "popupNames": { + "checkOnly": true, + "module": { "includes": ["\"valve.steam.gamepadui.barpopup\""] } + }, + "scrollFade": { + "checkOnly": true, + "module": { "includes": ["ScrollFade", "ScrolledToTop", "ScrolledToBottom", "strClassNamesForScrollable"] }, + "expects": ["scrollTop<=1", "clientHeight+1"] + }, + "scrollFadeClasses": { + "checkOnly": true, + "module": { "includes": ["ScrollFade:\"", "ScrolledToTop:\"", "ScrolledToBottom:\""] } + }, + "scrollFadeStyle": { + "checkOnly": true, + "stylesheet": { "includes": [ + "mask-image:linear-gradient(to bottom, transparent 0%, black var(--scroll-fade-size), black calc(100% - var(--scroll-fade-size)), transparent 100%)", + "--scroll-fade-size:" + ] } + } + } + }, "dashboard-windows": { "bundle": "vrwebui-systemui", "modules": { diff --git a/scripts/check-signatures.mjs b/scripts/check-signatures.mjs index fb9e8e5..6f9510e 100644 --- a/scripts/check-signatures.mjs +++ b/scripts/check-signatures.mjs @@ -4,7 +4,9 @@ // signatures.json) still matches the installed Steam / SteamVR web UI // bundles: each module signature must match exactly one webpack module, each // export signature exactly one export of it, and the strings a patch relies -// on ("expects") should still be there. Run it after a Steam update: +// on ("expects") should still be there; a "stylesheet" signature (CSS a patch +// relies on, e.g. a variable) must match exactly one stylesheet of the +// bundle's "styles" directory. Run it after a Steam update: // // nix shell nixpkgs#nodejs -c node scripts/check-signatures.mjs // @@ -24,7 +26,7 @@ // in which every import and unknown global is an inert stub, so top-level // definitions (objects, functions, classes, singletons) exist and are // matched with the same code the patches use (modules/lib/finders.js). -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -142,6 +144,12 @@ function bundle(name) { const files = b.html ? pageFiles(dir, b.html) : undefined; const mods = loadBundles(dir, { files, exclude: b.exclude && new RegExp(b.exclude) }); const req = { m: Object.fromEntries([...mods].map(([id, m]) => [id, m.factory])) }; + // Stylesheets: [{ file (relative to dir), source }]. + const stylesDir = b.styles && join(dir, b.styles); + const styles = stylesDir && existsSync(stylesDir) + ? readdirSync(stylesDir, { recursive: true }).filter((f) => f.endsWith('.css')) + .map((f) => ({ file: join(b.styles, f), source: readFileSync(join(stylesDir, f), 'utf8') })) + : []; let version = null; for (const f of ['changelist.txt']) if (existsSync(join(dir, f))) version = readFileSync(join(dir, f), 'utf8').trim(); for (const f of files ?? []) { @@ -154,7 +162,7 @@ function bundle(name) { if (v) { version = v[1]; break; } } } - return (loaded[name] = { dir, mods, req, version }); + return (loaded[name] = { dir, mods, req, styles, version }); } const report = { ok: true, warnings: 0, patches: {} }; @@ -164,6 +172,13 @@ for (const [pname, p] of Object.entries(patches)) { const pr = report.patches[pname] = { bundle: p.bundle, modules: {} }; if (b.error) { pr.skipped = b.error; continue; } for (const [mname, sig] of Object.entries(p.modules ?? {})) { + if (sig.stylesheet) { + const files = b.styles.filter((f) => find.matchText(f.source, sig.stylesheet)).map((f) => f.file); + const status = files.length === 1 ? 'found' : files.length ? 'ambiguous' : 'missing'; + pr.modules[mname] = { checkOnly: true, stylesheet: true, signature: sig.stylesheet, ids: [], files, status }; + if (status !== 'found') report.ok = false; + continue; + } const r = pr.modules[mname] = { checkOnly: !!sig.checkOnly, signature: sig.module }; const ids = find.findAllModules(b.req, sig.module); r.ids = ids; @@ -199,8 +214,9 @@ if (asJson) { for (const [pname, pr] of Object.entries(report.patches)) { console.log(`\n${pname} (${pr.bundle})${pr.skipped ? `: skipped, ${pr.skipped}` : ''}`); for (const [mname, r] of Object.entries(pr.modules)) { - const where = r.ids.map((id, i) => `${id} (${r.files[i]})`).join(', '); - console.log(` ${mname.padEnd(22)} ${r.status.padEnd(10)} ${r.status === 'missing' ? 'no module matches ' + JSON.stringify(r.signature) : 'module ' + where}${r.checkOnly ? ' [check only]' : ''}`); + const kind = r.stylesheet ? 'stylesheet' : 'module'; + const where = r.stylesheet ? r.files.join(', ') : r.ids.map((id, i) => `${id} (${r.files[i]})`).join(', '); + console.log(` ${mname.padEnd(22)} ${r.status.padEnd(10)} ${r.status === 'missing' ? `no ${kind} matches ` + JSON.stringify(r.signature) : `${kind} ` + where}${r.checkOnly ? ' [check only]' : ''}`); for (const [ename, e] of Object.entries(r.exports ?? {})) console.log(` .${ename.padEnd(20)} ${e.status.padEnd(10)} ${e.keys.length ? 'export ' + e.keys.map((k) => k || '(module.exports)').join(', ') : 'no export matches ' + JSON.stringify(e.signature)}`); if (r.runError) console.log(` note: module factory threw offline (${r.runError}); unresolved exports are "unverified"`); diff --git a/template/home.nix b/template/home.nix index d13e42d..661d62a 100644 --- a/template/home.nix +++ b/template/home.nix @@ -18,12 +18,14 @@ # keyboardLayout = "de"; # XKB layout for the Steam session # steamKeyboardPatch.enable = true; # Esc/Ctrl/Alt/arrows on the VR keyboard # # VR "+" menu: sorted by name, Desktop pinned below the list, closed - # # on click, no second launch of the same program within 10 s: + # # on click, no second launch of the same program within 10 s, programs + # # as a grid of 4 columns, at most 4 rows visible: # launcherMenu = { # sort = true; # pinDesktop = "bottom"; # closeOnLaunch = true; # launchDebounce = 10; + # grid = { enable = true; columns = 4; maxRows = 4; }; # }; # # SteamVR dashboard windows: resizable up to 4x (stock 2x), pushed # # back up to 10 m in the world / 12 m in theater mode (stock 5 / 6 m):