From 681533027d6bcff6777f047c7fd24f393ddb6b0a Mon Sep 17 00:00:00 2001 From: Pierre Kisters <1524059+lhns@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:52:15 +0200 Subject: [PATCH] launcher-menu: grid of tiles and hicolor icon fallbacks launcherMenu.grid = { enable; columns; maxRows; } restyles the "+" menu's programs section as a grid of tiles (icon, name below): Steam's own items stay in place, maxRows limits the visible rows, and Steam's scroll fade is recomputed so it only shows where there is more to scroll. The pinned Desktop row is slim and centred in tile mode. launcherMenu.iconFallbacks (default utilities-terminal, preferences-system) installs hicolor copies of Breeze app icons, since Steam resolves program icons only in hicolor; Konsole and KDE System Settings had none. The checker gains checkOnly "stylesheet" signatures (matched against the bundle's CSS files) to cover the scroll fade's gradient; launcher-menu-grid signatures cover the section keys and the ScrollFade classes. --- README.md | 64 +++++- modules/launcher-menu.nix | 85 ++++++- modules/launcher-menu/grid/patch.js | 313 ++++++++++++++++++++++++++ modules/launcher-menu/grid/unpatch.js | 8 + modules/lib/finders.js | 4 +- modules/lib/signatures.json | 33 ++- scripts/check-signatures.mjs | 26 ++- template/home.nix | 4 +- 8 files changed, 523 insertions(+), 14 deletions(-) create mode 100644 modules/launcher-menu/grid/patch.js create mode 100644 modules/launcher-menu/grid/unpatch.js 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):