diff --git a/README.md b/README.md index c99d62d..8765b61 100644 --- a/README.md +++ b/README.md @@ -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::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 diff --git a/flake.nix b/flake.nix index 8e1d365..6b8886c 100644 --- a/flake.nix +++ b/flake.nix @@ -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`. diff --git a/modules/dashboard-windows/patch.js b/modules/dashboard-windows/patch.js index 2b7c1f5..3113ea1 100644 --- a/modules/dashboard-windows/patch.js +++ b/modules/dashboard-windows/patch.js @@ -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: ()(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: ()(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 = [ diff --git a/modules/dashboard-windows/unpatch.js b/modules/dashboard-windows/unpatch.js index 219cfb0..11d566b 100644 --- a/modules/dashboard-windows/unpatch.js +++ b/modules/dashboard-windows/unpatch.js @@ -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 */ } diff --git a/modules/lib/default.nix b/modules/lib/default.nix index 32b4b57..a5d4381 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -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: ()(, , ). +# `(find, sigs, opts, hooks) => …` into the single expression the injectors +# evaluate: ()(, , , ). { 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} + ) ''; } diff --git a/modules/lib/hooks.js b/modules/lib/hooks.js new file mode 100644 index 0000000..eea6bb2 --- /dev/null +++ b/modules/lib/hooks.js @@ -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; +})() diff --git a/modules/lib/signatures.json b/modules/lib/signatures.json index 0f47aa9..c6385bb 100644 --- a/modules/lib/signatures.json +++ b/modules/lib/signatures.json @@ -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="] } + } + } } } } diff --git a/modules/steam-ui-patches.nix b/modules/steam-ui-patches.nix index 1b021d7..943faf5 100644 --- a/modules/steam-ui-patches.nix +++ b/modules/steam-ui-patches.nix @@ -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. ''; }; diff --git a/modules/window-curvature.nix b/modules/window-curvature.nix new file mode 100644 index 0000000..6e8d32b --- /dev/null +++ b/modules/window-curvature.nix @@ -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; + } ]; + }; +} diff --git a/modules/window-curvature/patch.js b/modules/window-curvature/patch.js new file mode 100644 index 0000000..eab26f6 --- /dev/null +++ b/modules/window-curvature/patch.js @@ -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: ()(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::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 "::" (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('/')})`; +}) diff --git a/modules/window-curvature/unpatch.js b/modules/window-curvature/unpatch.js new file mode 100644 index 0000000..58170c1 --- /dev/null +++ b/modules/window-curvature/unpatch.js @@ -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'; +})() diff --git a/template/home.nix b/template/home.nix index e807491..659aaa0 100644 --- a/template/home.nix +++ b/template/home.nix @@ -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):