mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 04:04:16 +02:00
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.
This commit is contained in:
1 parent
b05f03c6d4
commit
681533027d
8 files changed
+523
-14
No files matched your search
@@ -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/<name>.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
|
||||
|
||||
@@ -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/<name>.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}/<size>/$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;
|
||||
};
|
||||
}
|
||||
@@ -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: (<this file>)(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="<role>" 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`})`;
|
||||
})
|
||||
@@ -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';
|
||||
})()
|
||||
@@ -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": <text signature> (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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"`);
|
||||
|
||||
+3
-1
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user