Add window-curvature: adjustable curvature per dashboard window

steamFrame.dashboard.windowCurvature: the "Toggle Curvature" row of a
window's More Options menu becomes a control (click: curved -> flat,
flat -> stock; drag up/down with the laser: curvature relative to
SteamVR's stock curve, snapping to 0 and 1). The patch moves each frame's
curvature-origin node to stock distance / value in outgoing scene graphs;
per-window values live in the systemui page. Lookups by signature
("window-curvature" in signatures.json, incl. check-only anchors).

Shared method hooks (lib/hooks.js, passed by mkPatch as 4th argument):
one SendMessage wrapper for dashboard-windows (now VERSION 3) and
window-curvature instead of a chain of per-patch wrappers; legacy
wrappers on top are unwound.
This commit is contained in:
Pierre Kisters committed 2026-09-27 21:30:25 +02:00
1 parent 8d7094916b
commit bce51eb598
12 files changed
+877 -51

No files matched your search

+111 -9
View File
@@ -142,6 +142,7 @@ the two files below).
windowMaxScale = 4.0;
windowDistance.world.max = 10.0;
steamCloseButton.enable = true;
windowCurvature.enable = true;
};
firefox.enable = true;
# Only relevant with Steam Developer Mode on (see hidden-apps below).
@@ -166,7 +167,7 @@ home-manager switch --flake .#steamos
```
Individual modules are available as
`homeManagerModules.{session,portal,keyboard-layout,steam-keyboard-patch,hidden-apps,steam-ui-patches,launcher-menu,steamvr-debugger,dashboard-windows,steam-close-button,clipboard-sync,firefox}`;
`homeManagerModules.{session,portal,keyboard-layout,steam-keyboard-patch,hidden-apps,steam-ui-patches,launcher-menu,steamvr-debugger,dashboard-windows,steam-close-button,window-curvature,clipboard-sync,firefox}`;
`default` imports all of them.
**Steam Developer Mode** (a Steam setting, not managed here) makes the "+"
@@ -200,6 +201,14 @@ menu list every desktop entry, including terminals such as Konsole.
| `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.dashboard.steamCloseButton.enable` | bool | `false` | Close (X) button on the dashboard's Steam window: switches to the previous window, or leaves just the dashboard bar. See [Steam close button](#steam-close-button-dashboardsteamclosebuttonenable). |
| `steamFrame.dashboard.windowCurvature.enable` | bool | `false` | Adjustable curvature per dashboard window: the "Toggle Curvature" row of a window's More Options menu becomes a control (click: toggle, drag up/down: curvature). See [Window curvature](#window-curvature-dashboardwindowcurvature). |
| `steamFrame.dashboard.windowCurvature.default` | number | `1.0` | Curvature of world/hand windows without a value of their own, once curved (relative to SteamVR's stock curve: 1 = stock, 2 = twice as curved, 0 = flat). Dashboard/theater windows start at 1. |
| `steamFrame.dashboard.windowCurvature.max` | number | `3.0` | Largest curvature the control goes to. |
| `steamFrame.dashboard.windowCurvature.step` | number | `0.05` | Step the value is rounded to while dragging. |
| `steamFrame.dashboard.windowCurvature.snap` | number | `0.15` | While dragging, values within ± this of a snap point snap to it. `0`: no snapping. |
| `steamFrame.dashboard.windowCurvature.snapPoints` | list of numbers | `[ 0 1.0 ]` | Snap points (flat, stock). |
| `steamFrame.dashboard.windowCurvature.dragThreshold` | unsigned int (px) | `8` | Vertical laser travel before a press becomes a drag instead of a click. |
| `steamFrame.dashboard.windowCurvature.dragPixelsPerUnit` | number (px) | `60` | Drag distance per 1.0 of curvature (the laser stops at the menu's edge, ~190 px above the row). |
| `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). |
| `steamFrame.hiddenApps` | list of str | `[ ]` | Desktop entry ids (without `.desktop`) to hide from the "+" and KDE menus. |
| `steamFrame.clipboardSync.enable` | bool | `true` | Clipboard bridge between the Steam session and the nested desktop. |
@@ -404,8 +413,8 @@ 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
options:
written as a function expression with the library, its signatures, options
and the shared method hooks (below):
```nix
steamFrame.uiPatches.patches = [ {
@@ -413,7 +422,7 @@ steamFrame.uiPatches.patches = [ {
target.title = "SharedJSContext";
patch = config.steamFrame.uiPatches.lib.mkPatch {
name = "my-patch";
src = ./my-patch/patch.js; # ((find, sigs, opts) => { … })
src = ./my-patch/patch.js; # ((find, sigs, opts, hooks) => { … })
signatures.thing = {
module.includes = [ "SomeUniqueString" ];
exports.Thing = { type = "class"; protoMethods = [ "DoIt" ]; };
@@ -425,7 +434,7 @@ steamFrame.uiPatches.patches = [ {
```
```js
((find, sigs, opts) => {
((find, sigs, opts, hooks) => {
let mods;
try { mods = find.resolveAll(find.getWebpackRequire('webpackChunksteamui'), sigs); }
catch (e) { return `not patched: ${e.message}`; }
@@ -434,6 +443,23 @@ steamFrame.uiPatches.patches = [ {
})
```
**Shared method hooks** (`modules/lib/hooks.js`, 4th argument `hooks`,
also `window.__sfuiHooks`): patches that intercept the same method, e.g.
the SteamVR dashboard mailbox's `SendMessage` ([Dashboard
windows](#dashboard-windows-dashboard) and [Window
curvature](#window-curvature-dashboardwindowcurvature) both rewrite outgoing
scene graphs), register a named hook instead of each wrapping the method:
one wrapper per method runs all hooks (in registration order), so patches
can be injected, upgraded and reverted in any order without piling up
wrappers or running one twice.
```js
hooks.before(Mailbox.prototype, 'SendMessage', 'my-patch', (args) => {
if (args[1]?.type === 'update_scene_graph') rewrite(args[1].scene_graph);
});
hooks.remove(Mailbox.prototype, 'SendMessage', 'my-patch'); // in unpatch
```
### After a Steam update
The patches keep working as long as their signatures match. To check
@@ -446,7 +472,8 @@ nix shell nixpkgs#nodejs -c node scripts/check-signatures.mjs
It reads the bundles Steam's UI page loads (`~/.local/share/Steam/steamui`:
the scripts of `index.html` and every chunk its webpack runtime can load; for
[Dashboard windows](#dashboard-windows-dashboard) also SteamVR's dashboard,
[Dashboard windows](#dashboard-windows-dashboard) and the other dashboard
patches also SteamVR's dashboard,
`/opt/steamvr/resources/webinterface/dashboard/systemui.html`),
extracts all webpack module factories and evaluates every signature of
`modules/lib/signatures.json` against them with the same finder code the
@@ -590,8 +617,9 @@ close for a big virtual screen.
describes its windows to `vrcompositor` as a scene graph
(`update_scene_graph` messages over its mailbox WebSocket), and the
compositor enforces the limits it finds there. They are constants in the
dashboard's JS, so the patch wraps the mailbox's `SendMessage` and rewrites
them in every outgoing scene graph:
dashboard's JS, so the patch hooks the mailbox's `SendMessage` (through the
[shared method hooks](#finders-and-signatures)) and rewrites them in every
outgoing scene graph:
| Option | Scene-graph property | Stock |
|---|---|---|
@@ -692,6 +720,79 @@ patch relies on (`DashboardStore._setActiveFrame`, `mainSteamFrame`,
`scripts/check-signatures.mjs`); if a lookup fails, the patch reports it and
leaves the dashboard stock. Tested with SteamVR build 11008059.
### Window curvature (`dashboard.windowCurvature.*`)
**Problem:** SteamVR's dashboard windows are either curved (fixed radius) or
flat: the "Toggle Curvature" entry of a window's More Options (three-dot)
menu only switches between the two, and windows placed in the world start
flat.
**Fix:** a UI patch (see above) of SteamVR's dashboard page (`vrwebhelper`,
`127.0.0.1:8087`, title `systemui`; it turns on the
[SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable)) makes the whole
"Toggle Curvature" row a control, with the window's value on its right and
small arrows above/below it:
- **click**: curved → flat, flat → stock curve (1);
- **press and drag up/down** with the laser (after `dragThreshold` px): the
curvature follows live, `dragPixelsPerUnit` px per 1.0, rounded to `step`,
from 0 (flat) to `max`. Near a snap point (`snapPoints`, by default 0 and
1, within ± `snap`) it snaps to it exactly; dragging on moves past.
```nix
steamFrame.dashboard.windowCurvature = {
enable = true;
# default = 1.0; max = 3.0; step = 0.05;
# snap = 0.15; snapPoints = [ 0 1.0 ];
# dragThreshold = 8; dragPixelsPerUnit = 60;
};
```
How curvature works in SteamVR: each window frame has a *curvature origin*,
a scene-graph transform node (`frame:<id>:curvature-origin`) at a distance
in front of it, which all of the window's panels reference
(`curvature-origin-id`); `vrcompositor` bends the panels onto a cylinder
around that point. The stock distance is the dashboard's curvature distance
(`DashboardStore.curvatureDistance`: dashboard distance + 1.8, e.g. 2.95, in
the dashboard's scaled units; a radius of roughly 1.1 m, the same for all
docked windows, so they are concentric with the dashboard bar), and "flat"
is just an origin 1000 units away. The curvature is 1 / radius,
so the value here is the curvature relative to stock: the patch hooks the
mailbox's `SendMessage` (like [Dashboard windows](#dashboard-windows-dashboard),
through the [shared method hooks](#finders-and-signatures)) and puts the
origin at *stock distance / value* in outgoing scene graphs (2 = half the
radius). On/off stays SteamVR's own toggle (`ToggleCurvature`), so the value
0 and the stock state always agree; SteamVR resets that toggle when a window
is docked somewhere else.
A window without a value of its own is shown at `default` once curved in the
world or on a hand, at 1 (stock) in the dashboard or theater. Values are
kept per window (its overlay key) in the `systemui` page
(`window.__sfuiWindowCurvatureState`): across restarts of the patch service
(`home-manager switch`), but not across a SteamVR restart (or a reload of the
dashboard page). `window.__sfuiWindowCurvature.dump()` lists every window's
state, `.log` recent events.
**Limitations:**
- Only the laser adjusts the value: with controller (gamepad) navigation of
the menu, the row is the stock toggle.
- The thumbstick doesn't scroll there (SteamVR delivers no wheel events to
the menu), so there are no steps by scrolling.
- The laser's position stops at the menu's edge: the whole range has to fit
into the room above/below the row (`dragPixelsPerUnit` × `max` ≲ 190 px
upwards), otherwise the top end can't be reached in one drag.
**Caveats:** depends on SteamVR UI internals: MobX, the dock-location enum,
the mailbox, the scene-graph scheduler, the `curvature` frame component,
the action store and the menu's CSS class names are found by signature, and
the names and strings the patch relies on (`curvature-origin`,
`curvature-origin-id`, `ToggleCurvature`, `m_bCurveOverride`, the
curvature action's icons, `isControlAdditionalOptionsOpen`, …) are checked
offline (`window-curvature` in `modules/lib/signatures.json`,
`scripts/check-signatures.mjs`); if a lookup fails, the patch reports it and
leaves the dashboard stock. Tested with SteamVR build 11008059.
### SteamVR debugger (`steamvrDebugger.enable`)
Patches of the SteamVR dashboard need its DevTools port, which SteamVR only
@@ -699,7 +800,8 @@ opens with the setting `VRWebHelper/DebuggerEnabled` (port
`VRWebHelper/DebuggerPort`, default 8087). It is enabled automatically
(`mkDefault`) as soon as a patch in `steamFrame.uiPatches.patches` uses port
8087 (e.g. [Dashboard windows](#dashboard-windows-dashboard),
[Steam close button](#steam-close-button-dashboardsteamclosebuttonenable)).
[Steam close button](#steam-close-button-dashboardsteamclosebuttonenable),
[Window curvature](#window-curvature-dashboardwindowcurvature)).
SteamVR rewrites `~/.config/openvr/config/steamvr.vrsettings` while it runs
and on exit, so the file can't be managed by Home Manager, and edits while it
+1
View File
@@ -22,6 +22,7 @@
steamvr-debugger = ./modules/steamvr-debugger.nix;
dashboard-windows = ./modules/dashboard-windows.nix;
steam-close-button = ./modules/steam-close-button.nix;
window-curvature = ./modules/window-curvature.nix;
# Built with the consumer's pkgs; only the source comes from our input.
# The key lets the module system deduplicate it when it is imported both
# directly and through `default`.
+30 -30
View File
@@ -4,10 +4,11 @@
// 127.0.0.1:8087, title "systemui").
//
// 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), sigs this patch's module signatures
// (lib/signatures.json, "dashboard-windows") and opts the options from
// dashboard-windows.nix (null = stock), e.g.
// (mkPatch) generates: (<this file>)(find, sigs, opts, hooks), with find the
// finder library (lib/finders.js), sigs this patch's module signatures
// (lib/signatures.json, "dashboard-windows"), opts the options from
// dashboard-windows.nix (null = stock) and hooks the shared method hooks
// (lib/hooks.js), e.g. opts
// { maxScale: 4.0,
// distance: { world: {min: null, max: 10}, theater: {...}, dashboard: {...} } }
//
@@ -25,9 +26,10 @@
// dashboard "grab-transform" 0.3 / 4 the dashboard itself
// (the keyboard's grab-transform, 0.2 / 1, is left alone).
// These are constants/literals in systemui's bundle (not patchable at the
// source), so this patch wraps the mailbox class's SendMessage (on its
// prototype, which existing instances use) and rewrites them in outgoing
// scene graphs (a fresh object per update, so editing it in place is safe).
// source), so this patch hooks the mailbox class's SendMessage (on its
// prototype, which existing instances use; through lib/hooks.js, which
// window-curvature shares) and rewrites them in outgoing scene graphs (a
// fresh object per update, so editing it in place is safe).
// Grab nodes are identified by node type plus their exact stock values: after
// a SteamVR update that changes them, the distance rewrite is a no-op.
// Then systemui is asked for one scene-graph resend (its own debounced
@@ -35,16 +37,18 @@
// limits apply immediately.
//
// State: window.__sfuiDashboardWindows (mailbox prototype, resend function,
// the active rewrite, per-kind rewrite counters in .hits). The wrapper
// (__sfuiPatch = NAME, original in __sfuiOrig) looks the rewrite up per
// call; unpatch.js restores the original and resends the stock graph.
// options id, per-kind rewrite counters in .hits). The hook is registered
// as NAME; unpatch.js removes it and resends the stock graph. (VERSION 2
// wrapped SendMessage itself and read the rewrite from the state's
// `rewrite`, which is never set any more, so a leftover wrapper is inert; the
// hooks library unwinds one still on top.)
// The mailbox class and the scheduler are found by signature, not by
// webpack module id or minified export name; if one doesn't match, the patch
// returns an error and changes nothing. Idempotent: same VERSION and options
// -> "unchanged"; otherwise the rewrite is replaced in place.
((find, sigs, opts) => {
((find, sigs, opts, hooks) => {
const NAME = 'dashboard-windows';
const VERSION = 2;
const VERSION = 3;
const GRAB = {
world: { type: 'grab-scale', min: 0.25, max: 5 },
theater: { type: 'grab-transform', min: 1, max: 6 },
@@ -63,7 +67,7 @@
const id = JSON.stringify([VERSION, maxScale, rules]);
let st = window.__sfuiDashboardWindows;
if (!st) {
if (!st?.proto) {
let mods;
try {
mods = find.resolveAll(find.getWebpackRequire('webpackChunkvrwebui'), sigs);
@@ -74,25 +78,13 @@
// resend: debounced "send the scene graph again" (0 args, NextSGID(), setTimeout).
st = window.__sfuiDashboardWindows = {
proto: mods.mailbox.exports.Mailbox.prototype, resend: mods.sceneGraph.exports.resend,
id: null, rewrite: null, hits: {},
id: null, hits: {},
};
}
delete st.rewrite; // VERSION 2's wrapper reads it: keep that inert
const cur = st.proto.SendMessage;
const installed = cur.__sfuiPatch === NAME && cur.__sfuiVersion === VERSION;
if (installed && st.id === id) return 'unchanged';
if (!installed) {
const orig = cur.__sfuiPatch === NAME ? cur.__sfuiOrig : cur;
const f = function SendMessage(target, msg, ...rest) {
const rewrite = window.__sfuiDashboardWindows?.rewrite;
if (rewrite && msg?.type === 'update_scene_graph') {
try { rewrite(msg.scene_graph); } catch { /* never break the dashboard */ }
}
return orig.call(this, target, msg, ...rest);
};
Object.assign(f, { __sfuiPatch: NAME, __sfuiVersion: VERSION, __sfuiOrig: orig });
st.proto.SendMessage = f;
}
const active = maxScale !== null || rules.length > 0;
if (st.version === VERSION && st.id === id && active === hooks.has(st.proto, 'SendMessage', NAME)) return 'unchanged';
const hits = st.hits = {};
const hit = (k) => { hits[k] = (hits[k] ?? 0) + 1; };
@@ -113,8 +105,16 @@
}
if (n.children) walk(n.children);
};
st.version = VERSION;
st.id = id;
st.rewrite = maxScale === null && rules.length === 0 ? null : walk;
if (active) {
hooks.before(st.proto, 'SendMessage', NAME, (args) => {
const msg = args[1];
if (msg?.type === 'update_scene_graph') walk(msg.scene_graph);
});
} else {
hooks.remove(st.proto, 'SendMessage', NAME);
}
try { st.resend(); } catch { /* applies with the next scene-graph update */ }
const desc = [
+5 -4
View File
@@ -1,13 +1,14 @@
// Reverts dashboard-windows/patch.js: restores the mailbox SendMessage and
// resends the scene graph, so vrcompositor gets the stock limits again right
// away. If something else has wrapped SendMessage since, our wrapper stays in
// its chain but is inert (it looks the rewrite up in the removed state).
// Reverts dashboard-windows/patch.js: removes its SendMessage hook (the
// shared wrapper goes too once no other patch uses it) and resends the scene
// graph, so vrcompositor gets the stock limits again right away. Also
// restores the mailbox SendMessage if VERSION 2's own wrapper is on top.
// Safe when not patched.
(() => {
const NAME = 'dashboard-windows';
const st = window.__sfuiDashboardWindows;
if (!st) return 'not patched';
delete window.__sfuiDashboardWindows;
window.__sfuiHooks?.remove(st.proto, 'SendMessage', NAME);
const f = st.proto.SendMessage;
if (f?.__sfuiPatch === NAME) st.proto.SendMessage = f.__sfuiOrig;
try { st.resend?.(); } catch { /* ignore */ }
+8 -3
View File
@@ -3,15 +3,18 @@
# fibers (no module ids or minified names);
# - signatures.json: the signatures the patches use, per patch, also read by
# scripts/check-signatures.mjs (run it after a Steam update);
# - hooks.js: shared method hooks (one wrapper per method for all patches,
# e.g. the SteamVR dashboard mailbox's SendMessage);
# - mkPatch: turns a patch written as a function expression
# `(find, sigs, opts) => …` into the single expression the injectors
# evaluate: (<patch>)(<finders.js>, <signatures>, <opts>).
# `(find, sigs, opts, hooks) => …` into the single expression the injectors
# evaluate: (<patch>)(<finders.js>, <signatures>, <opts>, <hooks.js>).
{ pkgs }:
let
sigFile = builtins.fromJSON (builtins.readFile ./signatures.json);
in {
signatures = sigFile;
finders = ./finders.js;
hooks = ./hooks.js;
# name: file name (and default signatures entry); src: the patch file;
# signatures: module signatures passed as `sigs` (default: the entry `name`
@@ -21,6 +24,8 @@ in {
(${builtins.readFile src}
)(${builtins.readFile ./finders.js}
, ${builtins.toJSON signatures}
, ${builtins.toJSON opts})
, ${builtins.toJSON opts}
, ${builtins.readFile ./hooks.js}
)
'';
}
+100
View File
@@ -0,0 +1,100 @@
// hooks.js: shared method hooks for runtime patches. Several patches can
// intercept the same method (e.g. SendMessage of the SteamVR dashboard's
// mailbox, which dashboard-windows and window-curvature both use to rewrite
// outgoing scene graphs) through ONE wrapper per method, instead of a chain
// of per-patch wrappers: a wrapper can't be taken out of the middle of a
// chain, so patches restarted in varying order would pile up inert wrappers
// (or run a rewrite twice).
//
// This file is a single expression. Evaluated in a page it installs the
// library as window.__sfuiHooks (unless an equal or newer VERSION is already
// there; a newer one takes over the registry) and evaluates to it;
// lib/default.nix (mkPatch) passes it to every patch as its 4th argument.
//
// before(obj, method, name, fn) registers (or replaces) hook `name` on
// obj[method] (e.g. a class prototype): fn.call(this, args) runs before
// the original with the call's arguments array (which it may modify in
// place). Installs the wrapper if needed. Idempotent. A hook that
// throws is skipped (count and last message in errors[name]).
// remove(obj, method, name) unregisters it; once no hook is left the
// wrapper is removed too (if nothing wrapped the method since).
// has(obj, method, name) registered and the wrapper installed.
//
// The wrapper (marked fn.__sfuiHooks = method, original in fn.__sfuiOrig)
// looks the hooks up per call, so re-registering, removing and upgrading the
// library take effect in place. Hooks run once per call, even if a wrapper
// ended up in the method's chain twice (then also not for a call the method
// makes to itself). Hooks are called in registration order.
// Migration: per-patch wrappers from before this library (fn.__sfuiPatch ===
// name, original in fn.__sfuiOrig) on top of obj[method] are unwound when
// `name` registers.
(() => {
const VERSION = 1;
const G = globalThis;
const have = G.__sfuiHooks;
if (have && have.version >= VERSION) return have;
// obj -> method -> Map(name -> fn). Taken over from an older version.
const table = have?.table ?? new Map();
const errors = have?.errors ?? {};
const hooksOf = (obj, method) => table.get(obj)?.get(method);
const inChain = (obj, method) => {
for (let f = obj[method], i = 0; typeof f === 'function' && i < 50; f = f.__sfuiOrig, i++)
if (f.__sfuiHooks === method) return true;
return false;
};
function install(obj, method) {
const names = hooksOf(obj, method);
// Unwind legacy per-patch wrappers of registered names from the top.
for (let f = obj[method], i = 0; typeof f === 'function' && typeof f.__sfuiPatch === 'string' &&
typeof f.__sfuiOrig === 'function' && names?.has(f.__sfuiPatch) && i < 50; f = obj[method], i++)
obj[method] = f.__sfuiOrig;
if (inChain(obj, method)) return;
const orig = obj[method];
if (typeof orig !== 'function') throw new Error(`hooks: ${method} is not a function`);
const wrapper = function (...args) {
const lib = G.__sfuiHooks;
const hs = lib?.table?.get(obj)?.get(method);
// hs.busy: an outer wrapper of this method already ran the hooks.
if (!hs?.size || hs.busy) return orig.apply(this, args);
for (const [name, fn] of hs) {
try { fn.call(this, args); } catch (e) {
const er = (lib.errors[name] ??= { count: 0, last: null });
er.count++; er.last = String(e?.stack ?? e);
}
}
hs.busy = true;
try { return orig.apply(this, args); } finally { hs.busy = false; }
};
Object.defineProperty(wrapper, 'name', { value: method });
Object.assign(wrapper, { __sfuiHooks: method, __sfuiOrig: orig });
obj[method] = wrapper;
}
function before(obj, method, name, fn) {
if (typeof fn !== 'function') throw new Error(`hooks: hook ${name} is not a function`);
let byMethod = table.get(obj);
if (!byMethod) table.set(obj, (byMethod = new Map()));
let hs = byMethod.get(method);
if (!hs) byMethod.set(method, (hs = new Map()));
hs.set(name, fn);
install(obj, method);
}
function remove(obj, method, name) {
const hs = hooksOf(obj, method);
if (!hs?.delete(name) || hs.size) return;
table.get(obj).delete(method);
if (!table.get(obj).size) table.delete(obj);
const f = obj[method];
if (f?.__sfuiHooks === method) obj[method] = f.__sfuiOrig; // else inert in its chain
}
const has = (obj, method, name) => !!hooksOf(obj, method)?.has(name) && inChain(obj, method);
const lib = { version: VERSION, table, errors, before, remove, has };
G.__sfuiHooks = lib;
return lib;
})()
+90
View File
@@ -222,6 +222,96 @@
"module": { "includes": ["\"valve.steam.gamepadui.main\"", "\"valve.steam.gamepadui.appoverlay\""] }
}
}
},
"window-curvature": {
"bundle": "vrwebui-systemui",
"modules": {
"mobx": {
"module": { "includes": ["runInAction:()=>", "makeObservable:()=>", "reaction:()=>"] }
},
"dock": {
"module": { "includes": ["e[e.Dashboard=1]=\"Dashboard\",e[e.LeftHand=2]=\"LeftHand\""] },
"exports": {
"EDockLocation": { "type": "object", "props": { "Dashboard": 1, "LeftHand": 2, "RightHand": 3, "World": 4, "Theater": 5 } }
}
},
"mailbox": {
"module": { "includes": ["\"mailbox_send \"", "WebSocketSend(", "\"vrcompositor_systemlayer\""] },
"exports": {
"Mailbox": { "type": "class", "protoMethods": ["SendMessage", "SendMessageAndWaitForResponse", "RegisterHandler"] }
}
},
"sceneGraph": {
"module": { "includes": ["\"update_scene_graph\"", "NextSGID()", "scene_graph:"] },
"exports": {
"resend": { "type": "function", "length": 0, "includes": ["NextSGID()", "setTimeout"] }
},
"expects": ["\"vrcompositor_systemlayer\"", ".SendMessage("]
},
"curvature": {
"module": { "includes": ["this.componentName=\"curvature\"", "ToggleCurvature(){"] },
"exports": {
"Curvature": { "type": "class", "includes": ["componentName=\"curvature\"", "m_bCurveOverride"] }
},
"expects": ["get curvatureTransformOriginID(){return`frame:${this.frame.frameID}:curvature-origin`}",
"ToggleCurvature(){this.m_bCurveOverride=!this.shouldCurve}", "get shouldCurve()",
"get curvatureOriginDistance(){return this.shouldCurve?", ".curvatureDistance:1e3}", "translation:{z:"]
},
"actions": {
"module": { "includes": ["window.dashboardActionStore="] },
"exports": {
"store": { "type": "object", "methods": ["GetAction", "BIsVRLinkRemoteAction"] }
},
"expects": ["get partialParams()", "get protoForSteam()", "m_PartialParams"]
},
"frameControlsClasses": {
"module": { "includes": ["FrameControlsContainer:\"", "AdditionalOptionsRow:\"", "AdditionalOptionsLabel:\""] },
"exports": {
"row": { "type": "string", "regex": ["_AdditionalOptionsRow_"] },
"label": { "type": "string", "regex": ["_AdditionalOptionsLabel_"] }
}
},
"frameControls": {
"checkOnly": true,
"module": { "includes": ["legacy-frame-controls-additional-options-", "controlAdditionalOptionsItems.filter("] },
"expects": ["id:`legacy-frame-controls-additional-options-${", "isControlAdditionalOptionsOpen",
".AdditionalOptionsRow,", ".AdditionalOptionsLabel},", "display_name", "invocation", "onClick:()=>{", "onMouseDown:"]
},
"curvatureAction": {
"checkOnly": true,
"module": { "includes": ["\"#ToggleCurvatureTooltip\""] },
"expects": ["icon:{enum:40},icon_active:{enum:39},invocation:2", ".curvature.ToggleCurvature()"]
},
"frame": {
"checkOnly": true,
"module": { "includes": ["e[e.Alive=2]=\"Alive\""] },
"expects": ["this.curvature=", "this.docking=", "get isControlAdditionalOptionsOpen()",
"get controlAdditionalOptionsItems()", "get associatedSummonOverlayKeys()"]
},
"docking": {
"checkOnly": true,
"module": { "includes": ["get dockLocation()", "SetDockLocation(e"], "excludes": ["get associatedSummonOverlayKeys()"] },
"expects": ["get visualDockLocation()"]
},
"frameStore": {
"checkOnly": true,
"module": { "includes": ["window.FrameStore="] },
"expects": ["GetFrame(e){", "get frames()"]
},
"dashboardStore": {
"checkOnly": true,
"module": { "includes": ["window.DashboardStore="] },
"expects": ["get curvatureDistance()"]
},
"panel": {
"checkOnly": true,
"module": { "includes": ["properties[\"curvature-origin-id\"]"] }
},
"sceneGraphNodes": {
"checkOnly": true,
"module": { "includes": ["case\"VSG-TRANSFORM\":", ".properties.translation="] }
}
}
}
}
}
+7 -5
View File
@@ -16,8 +16,9 @@
# Patches that need Steam's webpack modules should find them by signature
# rather than by module id or minified export name, which change with Steam
# updates: `steamFrame.uiPatches.lib.mkPatch` wraps a patch written as
# `(find, sigs, opts) => …` with the finder library (lib/finders.js) and its
# signatures; scripts/check-signatures.mjs checks signatures offline.
# `(find, sigs, opts, hooks) => …` with the finder library (lib/finders.js),
# its signatures and the shared method hooks (lib/hooks.js);
# scripts/check-signatures.mjs checks signatures offline.
{ config, pkgs, lib, ... }:
let
cfg = config.steamFrame.uiPatches;
@@ -104,12 +105,13 @@ in {
Helpers for writing patches (see modules/lib/default.nix):
`mkPatch { name, src, signatures ? …, opts ? { } }` returns a patch
file that calls `src`, a JavaScript function expression
`(find, sigs, opts) => …`, with the finder library `find`
`(find, sigs, opts, hooks) => …`, with the finder library `find`
(modules/lib/finders.js: getWebpackRequire, resolveAll, findModule,
findExport, findFiberUp, findInReactTree, …), the module signatures
`sigs` (format: modules/lib/signatures.json; default: its entry
`name`, if any) and `opts`. `finders` is
the library's path, `signatures` the parsed signatures.json.
`name`, if any), `opts` and the shared method hooks `hooks`
(modules/lib/hooks.js: before, remove, has). `finders` and `hooks`
are the libraries' paths, `signatures` the parsed signatures.json.
'';
};
+100
View File
@@ -0,0 +1,100 @@
# Adjustable curvature per SteamVR dashboard window
# (steamFrame.dashboard.windowCurvature): the "Toggle Curvature" row of a
# window's More Options menu becomes a control (click: toggle, drag up/down:
# curvature). Runtime patch of SteamVR's dashboard page (vrwebhelper
# "systemui", DevTools 127.0.0.1:8087; see window-curvature/patch.js for how
# it works), registered with steam-ui-patches.nix only when enabled; it turns
# on the SteamVR web helper debugger (steamvr-debugger.nix), which needs one
# SteamVR restart the first time. Depends on SteamVR UI internals, found by
# signature (lib/signatures.json, "window-curvature";
# scripts/check-signatures.mjs checks them offline).
{ config, lib, pkgs, ... }:
let
cfg = config.steamFrame.dashboard.windowCurvature;
inherit (lib) mkOption types;
inherit (import ./lib { inherit pkgs; }) mkPatch;
value = default: description: mkOption {
type = types.number;
inherit default description;
};
in {
imports = [ ./steam-ui-patches.nix ./steamvr-debugger.nix ];
options.steamFrame.dashboard.windowCurvature = {
enable = lib.mkEnableOption ''
adjustable curvature per SteamVR dashboard window: the "Toggle
Curvature" row of a window's More Options (three-dot) menu shows the
window's curvature and becomes a control. Click: curved → flat, flat →
stock curve. Press and drag up/down with the laser: set the curvature
live. Values are relative to SteamVR's stock curve (1 = stock, 2 = twice
as curved, i.e. half the radius, 0 = flat) and kept per window until
SteamVR restarts. Runtime patch of the SteamVR dashboard
(steamFrame.uiPatches), applied immediately; turning it off restores the
stock menu and radius (next switch)'';
default = value 1.0 ''
Curvature of windows placed in the world or on a hand that have no value
of their own yet, once curved (stock SteamVR shows them flat; the
toggle turns them on). Windows docked in the dashboard or in the theater
start at 1 (stock), so the docked Steam window stays concentric with the
dashboard bar.
'';
max = value 3.0 "Largest curvature the control goes to (relative to the stock curve).";
step = value 0.05 "Step the value is rounded to while dragging.";
snap = value 0.15 ''
While dragging, values within ± this distance of a snap point snap to
it exactly; dragging on moves past. 0 = no snapping.
'';
snapPoints = mkOption {
type = types.listOf types.number;
default = [ 0 1.0 ];
description = "Values the drag snaps to (0 = flat, 1 = stock curve).";
};
dragThreshold = mkOption {
type = types.ints.unsigned;
default = 8;
description = ''
Vertical laser travel (menu pixels) before a press on the row becomes
a drag instead of a click.
'';
};
dragPixelsPerUnit = value 60 ''
Drag distance (menu pixels) per 1.0 of curvature. The laser's position
stops at the menu's edge (about 190 px above the row), so 0 to `max`
should fit into that.
'';
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.max > 0 && cfg.step > 0 && cfg.step <= cfg.max;
message = "steamFrame.dashboard.windowCurvature: need max > 0 and 0 < step <= max, got max ${builtins.toJSON cfg.max}, step ${builtins.toJSON cfg.step}.";
}
{
assertion = cfg.default >= 0 && cfg.default <= cfg.max;
message = "steamFrame.dashboard.windowCurvature.default must be within 0 .. max (${builtins.toJSON cfg.max}), got ${builtins.toJSON cfg.default}.";
}
{
assertion = cfg.snap >= 0 && lib.all (p: p >= 0 && p <= cfg.max) cfg.snapPoints;
message = "steamFrame.dashboard.windowCurvature: snap must be >= 0 and snapPoints within 0 .. max (${builtins.toJSON cfg.max}).";
}
{
assertion = cfg.dragPixelsPerUnit > 0;
message = "steamFrame.dashboard.windowCurvature.dragPixelsPerUnit must be positive.";
}
];
steamFrame.uiPatches.patches = [ {
name = "window-curvature";
endpoint = "http://127.0.0.1:8087";
target.title = "systemui";
patch = mkPatch {
name = "window-curvature";
src = ./window-curvature/patch.js;
opts = removeAttrs cfg [ "enable" ];
};
unpatch = ./window-curvature/unpatch.js;
} ];
};
}
+410
View File
@@ -0,0 +1,410 @@
// window-curvature: adjustable curvature per SteamVR dashboard window. The
// "Toggle Curvature" row of a window's More Options (three-dot) menu becomes
// the control: click toggles (curved -> flat, flat -> stock curve), dragging
// up/down with the laser sets the curvature live; the value is shown on the
// right of the row.
//
// Target: SteamVR's dashboard page (vrwebhelper, DevTools 127.0.0.1:8087,
// title "systemui"). This file is a function expression, called by the file
// lib/default.nix (mkPatch) generates: (<this file>)(find, sigs, opts, hooks),
// with find the finder library (lib/finders.js), sigs this patch's module
// signatures (lib/signatures.json, "window-curvature"), opts the options from
// window-curvature.nix and hooks the shared method hooks (lib/hooks.js).
//
// How stock curvature works (systemui's `curvature` frame component, found
// by signature; frame.curvature):
// shouldCurve on/off: m_bCurveOverride (set by ToggleCurvature(), cleared
// when the dock location changes), else the stock default:
// curved when docked in the dashboard, theater per setting,
// flat elsewhere (world, hands).
// curvatureOriginDistance = shouldCurve ? DashboardStore.curvatureDistance
// (dashboard distance + 1.8 m, e.g. 2.95) : 1000.
// The frame renders a transform node with id curvatureTransformOriginID
// ("frame:<id>:curvature-origin") at translation z = that distance, and
// every panel of the frame references it as "curvature-origin-id";
// vrcompositor bends the panels onto a cylinder around that point. So
// curvature = 1 / radius, and "flat" is just a very distant origin (1000).
// Those computed properties can't be overridden (non-configurable MobX
// instance properties), so this patch hooks the mailbox's SendMessage
// (lib/hooks.js, shared with dashboard-windows) and rewrites the origin's
// translation in outgoing scene graphs: distance = stock distance / value,
// value 1 = SteamVR's stock curve, 2 = twice as curved (half the radius),
// 0 = flat (the stock toggle off). On/off stays the stock state
// (ToggleCurvature), so the stock toggle and this control always agree.
//
// Values are kept per window (key: the frame's first overlay key) in
// window.__sfuiWindowCurvatureState (schema 1: { values, log }), which
// unpatch and upgrades leave in place (not persisted across a dashboard
// reload / SteamVR restart). A window without its own value uses
// opts.default when placed in the world or on a hand, and 1 (stock) when
// docked in the dashboard or the theater, so the docked Steam window stays
// concentric with the dashboard bar.
//
// Debugging: window.__sfuiWindowCurvature: dump() (per frame: dock, on/off,
// stored/applied value, distances), hits (scene-graph rewrite counters),
// log (last events, also in the state), setValue(frameID, v).
((find, sigs, opts, hooks) => {
const NAME = 'window-curvature';
const VERSION = 10;
const FLAT = 999; // origin distances >= this are "flat"
const ICON_OFF = 40, ICON_ON = 39; // Toggle Curvature action icons (sigs.curvatureAction)
const o = { default: 1, max: 3, step: 0.05, snap: 0.15, snapPoints: [0, 1], dragThreshold: 8, dragPixelsPerUnit: 60, ...opts };
if (!(o.max > 0 && o.step > 0 && o.step <= o.max && o.default >= 0 && o.default <= o.max && o.snap >= 0 &&
Array.isArray(o.snapPoints) && o.snapPoints.every((p) => p >= 0 && p <= o.max) &&
o.dragThreshold >= 0 && o.dragPixelsPerUnit > 0)) return `invalid options ${JSON.stringify(opts)}`;
const id = JSON.stringify([VERSION, o]);
const prev = window.__sfuiWindowCurvature;
if (prev?.version === VERSION && prev.id === id) return prev.check();
const DS = window.DashboardStore, FS = window.FrameStore;
if (!DS || !FS) return 'not patched: window.DashboardStore/FrameStore missing';
let mods;
try {
mods = find.resolveAll(find.getWebpackRequire('webpackChunkvrwebui'), sigs);
} catch (e) {
return `signature not found, dashboard left unpatched: ${e.message}`;
}
const mx = mods.mobx.module;
if (typeof mx.reaction !== 'function') return 'signature not found, dashboard left unpatched: mobx.reaction';
const proto = mods.mailbox.exports.Mailbox.prototype;
const resendStock = mods.sceneGraph.exports.resend;
const Curvature = mods.curvature.exports.Curvature;
const actions = mods.actions.exports.store;
const DOCK = mods.dock.exports.EDockLocation;
const CLS = mods.frameControlsClasses.exports; // { row, label }: CSS module class names
prev?.teardown?.(); // older VERSION or other options
// ---- state + log ---------------------------------------------------------------
let S = window.__sfuiWindowCurvatureState;
if (S?.schema !== 1) S = window.__sfuiWindowCurvatureState = { schema: 1, values: {} };
const logBuf = S.log ??= [];
const log = (msg, data) => {
logBuf.push({ t: new Date().toISOString().slice(11, 23), msg, ...(data ? { data } : {}) });
if (logBuf.length > 200) logBuf.splice(0, logBuf.length - 200);
};
const hits = { sends: 0, origins: 0, scaled: 0 };
// ---- values ------------------------------------------------------------------------
const round = (v) => +(Math.round(v / o.step) * o.step).toFixed(6);
const clamp = (v) => Math.min(o.max, Math.max(0, v));
const fmt = (v) => (v <= 0 ? '0' : v.toFixed(o.step < 0.1 ? 2 : 1));
const curvatureOf = (f) => (f?.curvature instanceof Curvature ? f.curvature : null);
const keyOf = (f) => f.associatedSummonOverlayKeys?.[0] ?? `frame:${f.frameID}`;
const dockOf = (f) => f.docking?.visualDockLocation ?? f.docking?.dockLocation;
const strength = (f) => S.values[keyOf(f)] ??
([DOCK.LeftHand, DOCK.RightHand, DOCK.World].includes(dockOf(f)) ? o.default : 1);
const curved = (f) => !!curvatureOf(f)?.shouldCurve;
const shown = (f) => (curved(f) ? strength(f) : 0);
// Value for a drag position (target: unrounded value under the pointer),
// or null to keep the current one: within +-snap of a snap point the value
// is that point, else target rounded to step; a change of less than 0.6
// steps away from cur is ignored (laser jitter at a step boundary).
const dragValue = (target, cur) => {
const snapped = o.snapPoints.find((p) => Math.abs(target - p) <= o.snap);
const v = clamp(snapped ?? round(target));
if (v === cur || (snapped === undefined && Math.abs(target - cur) < o.step * 0.6)) return null;
return v;
};
// ---- scene graph ---------------------------------------------------------------------
// Origin node ids may carry a "<prefix>::" (sub-scene); match the suffix.
const rewrite = (sg) => {
const byOrigin = new Map();
for (const f of FS.frames) {
const c = curvatureOf(f);
if (c?.shouldCurve) byOrigin.set(c.curvatureTransformOriginID, f);
}
if (!byOrigin.size) return;
const walk = (n) => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) { n.forEach(walk); return; }
const p = n.properties, nid = p?.id;
if (typeof nid === 'string' && nid.endsWith(':curvature-origin')) {
hits.origins++;
const i = nid.lastIndexOf('::');
const f = byOrigin.get(i < 0 ? nid : nid.slice(i + 2));
const t = p.translation, v = f && strength(f);
if (v > 0 && v !== 1 && Array.isArray(t) && t.length >= 3 && t[2] < FLAT) {
p.translation = [t[0], t[1], t[2] / v];
hits.scaled++;
}
}
if (n.children) walk(n.children);
};
walk(sg);
};
const hook = (args) => {
const msg = args[1];
if (msg?.type !== 'update_scene_graph') return;
hits.sends++;
rewrite(msg.scene_graph);
};
// Scene-graph resend (systemui's own debounced scheduler), at most every 40 ms.
let lastResend = 0, resendTimer = null;
const resend = () => {
if (resendTimer) return;
resendTimer = setTimeout(() => {
resendTimer = null; lastResend = performance.now();
try { resendStock(); } catch (e) { log('resend failed', String(e)); }
}, Math.max(0, lastResend + 40 - performance.now()));
};
// Sets the shown value of a frame: 0 = curvature off, > 0 = on with that
// strength. On/off goes through the stock ToggleCurvature (what the row's
// stock click invokes), so Steam's state and the row icon match. Going to
// 0 forgets the window's value (turning it on again, e.g. by docking it in
// the dashboard, uses the default).
const setValue = (f, v, why) => {
const c = curvatureOf(f);
if (!c) return;
v = clamp(round(v));
const from = shown(f);
if (v <= 0) {
delete S.values[keyOf(f)];
if (c.shouldCurve) c.ToggleCurvature();
} else {
S.values[keyOf(f)] = v;
if (!c.shouldCurve) c.ToggleCurvature();
}
log('set', { frame: f.frameID, key: keyOf(f), why, from, to: v });
resend();
updateUI(f);
};
// ---- UI ------------------------------------------------------------------------------
// The whole Toggle Curvature row is the control; the value is shown on its
// right with small arrows above/below (only where the value can go).
const STYLE_ID = 'sfui-window-curvature-style';
const ROW = `.${CLS.row}`;
const CSS = `
${ROW}:has(> .sfui-curv-ind) { cursor: ns-resize; }
${ROW}:has(> .sfui-curv-ind.sfui-dragging) { background: var(--gamepadui-grey) !important; }
.sfui-curv-ind { flex: none; align-self: stretch; position: relative; width: 3em; min-height: 0; font-size: 0.72em;
margin: -8px 0 -8px auto; font-variant-numeric: tabular-nums; line-height: 1; pointer-events: none;
user-select: none; }
${ROW}:first-child > .sfui-curv-ind { margin-top: -14px; }
${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; }
.sfui-curv-ind > div { position: absolute; left: 0; right: 0; text-align: center; transform: translateY(-50%); }
.sfui-curv-ind > .sfui-curv-v { top: 50%; font-size: 1.2em; transform: translateY(calc(-50% + var(--sfui-curv-dy, 0px))); }
/* Arrows as CSS triangles (the arrow glyphs sit at different heights),
placed symmetrically around the number; their text only toggles visibility. */
.sfui-curv-ind > .sfui-curv-up, .sfui-curv-ind > .sfui-curv-dn {
left: 50%; right: auto; width: 0.6em; height: 0.36em; margin-left: -0.3em;
overflow: hidden; text-indent: -99em; background: currentColor; }
.sfui-curv-ind > .sfui-curv-up { top: calc(50% - 1.05em); clip-path: polygon(50% 0, 100% 100%, 0 100%); }
.sfui-curv-ind > .sfui-curv-dn { top: calc(50% + 1.05em); clip-path: polygon(0 0, 100% 0, 50% 100%); }
.sfui-curv-ind > .sfui-curv-up:empty, .sfui-curv-ind > .sfui-curv-dn:empty { visibility: hidden; }
`;
const ensureStyle = () => {
if (document.getElementById(STYLE_ID)) return;
const s = document.createElement('style');
s.id = STYLE_ID; s.textContent = CSS;
document.head.appendChild(s);
};
// The frame's Toggle Curvature action: a toggle (invocation 2) with the
// curvature icons. Its row is found by position among the menu's rows,
// checked against (else looked up by) the action's label.
const isCurvatureAction = (a) => {
const p = a?.partialParams;
return p?.invocation === 2 && p?.icon?.enum === ICON_OFF && p?.icon_active?.enum === ICON_ON;
};
const panelOf = (fid) => document.getElementById(`legacy-frame-controls-additional-options-${fid}`);
const curvatureRow = (f, panel) => {
const rows = [...panel.querySelectorAll(ROW)];
const items = (f.controlAdditionalOptionsItems ?? []).filter((i) => i.type === 2);
const idx = items.findIndex((i) => isCurvatureAction(actions.GetAction(i.action_id)));
if (idx < 0) return { why: 'no curvature action' };
const label = actions.GetAction(items[idx].action_id).protoForSteam?.display_name;
const text = (r) => r.querySelector(`.${CLS.label}`)?.textContent;
let row = rows.length === items.length ? rows[idx] : null;
if (!row || (label && text(row) !== label)) row = rows.find((r) => label && text(r) === label) ?? null;
return row ? { row } : { why: `row not found (${rows.length} rows, ${items.length} items)` };
};
// Digits have no descenders, so their ink sits above the centre of the line
// box that translateY(-50%) centres. The ink box is measured once (canvas
// measureText: font metrics vs. actual digit bounds) and the number shifted
// by the difference, so it is optically centred between the arrows.
// (CSS text-box-trim would do this; Steam's Chromium lacks it.)
let ctx2d;
const inkShift = (el) => {
const cs = getComputedStyle(el);
ctx2d ??= document.createElement('canvas').getContext('2d');
ctx2d.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
const m = ctx2d.measureText('0123456789.');
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize);
const baseline = lh / 2 + (m.fontBoundingBoxAscent - m.fontBoundingBoxDescent) / 2;
const inkCentre = baseline - (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2;
return lh / 2 - inkCentre; // > 0: move down
};
const renderInd = (ind, v) => {
const [up, num, dn] = ind.children;
up.textContent = v < o.max ? '▲' : '';
num.textContent = fmt(v);
dn.textContent = v > 0 ? '▼' : '';
if (ind.isConnected && !num.style.getPropertyValue('--sfui-curv-dy'))
num.style.setProperty('--sfui-curv-dy', `${inkShift(num).toFixed(2)}px`);
};
const controls = new Map(); // frameID -> { ind, row, panel, mo, attach, detach, endDrag }
function updateUI(f) {
const c = controls.get(f.frameID);
if (c?.ind.isConnected) renderInd(c.ind, shown(f));
}
// Row control of one frame. Every press/click on the row is stopped at the
// row, so React (listening at the root) never runs the stock onClick; the
// click is re-implemented on mouseup when the press never became a drag.
// Drag: vertical and relative (the cylinder bends horizontally, so the
// pointer's y on the panel barely moves while the curvature changes). A
// press becomes a drag after dragThreshold px (then re-based, so there is no
// jump); the value follows at dragPixelsPerUnit px per 1.0 (the laser's
// position stops at the menu panel's edge, so the whole range has to fit
// into the room above/below the row), see dragValue.
const stop = (e) => e.stopPropagation();
const STOPPED = ['click', 'dblclick', 'mouseup', 'pointerdown', 'pointerup', 'contextmenu'];
const buildControl = (fid) => {
const frame = () => FS.GetFrame(fid);
const ind = document.createElement('div');
ind.className = 'sfui-curv-ind';
for (const c of ['sfui-curv-up', 'sfui-curv-v', 'sfui-curv-dn']) {
const d = document.createElement('div'); d.className = c; ind.appendChild(d);
}
let drag = null; // { y0, v0, moved }
const onMove = (e) => {
const d = drag, f = frame();
if (!d || !f) return;
if (!d.moved) {
if (Math.abs(e.clientY - d.y0) < o.dragThreshold) return;
d.moved = true; d.y0 = e.clientY;
ind.classList.add('sfui-dragging');
return;
}
const v = dragValue(d.v0 + (d.y0 - e.clientY) / o.dragPixelsPerUnit, shown(f));
if (v !== null) setValue(f, v, 'drag');
};
const onUp = (e) => {
const d = drag;
if (!d) return;
drag = null;
window.removeEventListener('mousemove', onMove, true);
window.removeEventListener('mouseup', onUp, true);
ind.classList.remove('sfui-dragging');
const f = frame();
if (!d.moved && f && e) setValue(f, curved(f) ? 0 : 1, 'click'); // curved -> flat, flat -> stock
};
const onDown = (e) => {
e.stopPropagation(); e.preventDefault();
const f = frame();
if (e.button !== 0 || drag || !f) return;
drag = { y0: e.clientY, v0: shown(f), moved: false };
window.addEventListener('mousemove', onMove, true);
window.addEventListener('mouseup', onUp, true);
};
const attach = (row) => {
row.addEventListener('mousedown', onDown);
for (const t of STOPPED) row.addEventListener(t, stop);
};
const detach = (row) => {
row.removeEventListener('mousedown', onDown);
for (const t of STOPPED) row.removeEventListener(t, stop);
};
return { ind, attach, detach, endDrag: () => onUp(null) };
};
const inject = (f) => {
const fid = f.frameID;
const panel = panelOf(fid);
if (!panel) return 'no panel';
let c = controls.get(fid);
if (c && c.panel !== panel) { removeControl(fid); c = null; }
if (c?.ind.isConnected && c.row?.contains(c.ind)) { renderInd(c.ind, shown(f)); return 'ok'; }
const r = curvatureRow(f, panel);
if (!r.row) { log('inject failed', { frame: fid, why: r.why }); return r.why; }
ensureStyle();
if (!c) {
const b = buildControl(fid);
const mo = new MutationObserver(() => { if (!b.ind.isConnected || !b.ind.parentElement?.matches(ROW)) queueSync(); });
mo.observe(panel, { childList: true, subtree: true });
c = { ...b, panel, mo, row: null };
controls.set(fid, c);
}
if (c.row !== r.row) {
if (c.row) c.detach(c.row);
c.row = r.row;
c.attach(r.row);
}
r.row.append(c.ind);
renderInd(c.ind, shown(f));
return 'ok';
};
function removeControl(fid) {
const c = controls.get(fid);
if (!c) return;
c.mo.disconnect(); c.endDrag();
if (c.row) c.detach(c.row);
c.ind.remove();
controls.delete(fid);
}
// Open menus: inject (retrying until React has rendered the panel).
const openFrames = () => FS.frames.filter((f) => f.isControlAdditionalOptionsOpen);
let syncTimer = null, retries = 0;
const sync = () => {
syncTimer = null;
const open = openFrames();
for (const fid of [...controls.keys()]) if (!open.some((f) => f.frameID === fid)) removeControl(fid);
let pending = false;
for (const f of open) if (inject(f) !== 'ok') pending = true;
if (pending && retries < 12) { retries++; syncTimer = setTimeout(sync, 60 * retries); } else retries = 0;
};
function queueSync() { if (!syncTimer) syncTimer = setTimeout(sync, 0); }
hooks.before(proto, 'SendMessage', NAME, hook);
const disposers = [
mx.reaction(
() => FS.frames.map((f) => `${f.frameID}:${f.isControlAdditionalOptionsOpen ? 1 : 0}:${curved(f) ? 1 : 0}:${dockOf(f)}`).join(','),
() => { retries = 0; queueSync(); for (const f of FS.frames) updateUI(f); },
),
mx.reaction(() => DS.curvatureDistance, () => resend()),
];
const teardown = () => {
for (const d of disposers.splice(0)) d();
clearTimeout(syncTimer); clearTimeout(resendTimer);
for (const fid of [...controls.keys()]) removeControl(fid);
document.getElementById(STYLE_ID)?.remove();
hooks.remove(proto, 'SendMessage', NAME);
if (window.__sfuiWindowCurvature === state) delete window.__sfuiWindowCurvature;
try { resendStock(); } catch { /* applies with the next scene-graph update */ }
};
const check = () => {
if (!hooks.has(proto, 'SendMessage', NAME)) hooks.before(proto, 'SendMessage', NAME, hook);
if (openFrames().length) queueSync();
return 'unchanged';
};
const dump = () => FS.frames.map((f) => ({
frame: f.frameID, title: f.title, key: keyOf(f), dock: dockOf(f), curved: curved(f),
override: curvatureOf(f)?.m_bCurveOverride, stored: S.values[keyOf(f)], shown: shown(f),
stockDistance: curvatureOf(f)?.curvatureOriginDistance,
appliedDistance: curved(f) ? DS.curvatureDistance / strength(f) : null,
menuOpen: f.isControlAdditionalOptionsOpen, control: !!controls.get(f.frameID)?.ind.isConnected,
}));
const state = {
version: VERSION, id, opts: o, hits, log: logBuf, state: S, teardown, check, dump, dragValue,
setValue: (fid, v) => { const f = FS.GetFrame(fid); if (f) setValue(f, v, 'api'); return f ? shown(f) : 'no frame'; },
};
window.__sfuiWindowCurvature = state;
log('patched', { version: VERSION, opts: o });
resend();
queueSync();
return `patched (default ${o.default}, max ${o.max}, step ${o.step}, snap ±${o.snap} at ${o.snapPoints.join('/')})`;
})
+12
View File
@@ -0,0 +1,12 @@
// Reverts window-curvature/patch.js: removes the control from open menus,
// its style, reactions and SendMessage hook (the shared wrapper goes too once
// no other patch uses it) and resends the scene graph, so windows get the
// stock curve radius back. Curvature on/off is stock state and stays as it
// is. window.__sfuiWindowCurvatureState (per-window values) is kept for a
// re-injection. Safe when not patched.
(() => {
const s = window.__sfuiWindowCurvature;
if (!s) return 'not patched';
s.teardown();
return 'unpatched';
})()
+3
View File
@@ -39,6 +39,9 @@
# # X button on the Steam window: back to the previous window, or
# # just the dashboard bar:
# steamCloseButton.enable = true;
# # Curvature per window: click the "Toggle Curvature" row of a
# # window's More Options menu to toggle, drag up/down to adjust:
# windowCurvature.enable = true;
# };
# firefox.enable = true; # launcher for the Firefox Flatpak
# # Hidden from the "+" menu (with Steam Developer Mode on):