From 9993cbd4abf65db5a264879ac3ede64192ca91c0 Mon Sep 17 00:00:00 2001 From: Pierre Kisters <1524059+lhns@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:19:17 +0200 Subject: [PATCH] window-curvature: wheel on the bar button, haptics --- README.md | 29 +- modules/lib/signatures.json | 23 +- modules/window-curvature.nix | 38 ++- modules/window-curvature/patch.js | 424 ++++++++++++++++++++-------- modules/window-curvature/unpatch.js | 13 +- template/home.nix | 3 +- 6 files changed, 392 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index 20b00b9..ccdedaa 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ 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.enable` | bool | `false` | Adjustable curvature per dashboard window: the "Toggle Curvature" row of a window's More Options menu (and its bottom-bar button, when it sits there) 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. | @@ -211,6 +211,9 @@ menu list every desktop entry, including terminals such as Konsole. | `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.dashboard.windowCurvature.barDragPixelsPerUnit` | number (px) | `30` | Drag distance per 1.0 of curvature on the bottom-bar button. | +| `steamFrame.dashboard.windowCurvature.barDragRoom` | unsigned int (px) | `160` | Transparent room added above and below a window's bottom bar while its curvature button is dragged, so the laser stays on the bar. `0`: none. | +| `steamFrame.dashboard.windowCurvature.haptics` | bool | `true` | Controller haptics while dragging: snap at snap points, edge at 0 and `max`, a tick per other step. | | `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. | @@ -748,13 +751,23 @@ flat. `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: +small arrows above/below it. When the control sits in the window's bottom +bar instead (e.g. moved there by a patch of your own), that button works +the same way, without the value: the steps are felt as haptics. - **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. + With `haptics`, the controller gives a snap at a snap point, an edge + bump at 0 and `max` and a light tick for other steps (SteamVR's own + overlay haptic effects). +- on the **bar button**, `barDragPixelsPerUnit` px per 1.0; the bar panel is + only one button high and the laser's position stops at the edge of the + pressed panel, so while dragging the panel gets `barDragRoom` px of + transparent room above and below (its origin is shifted in the scene + graph by the same amount, so the bar stays in place). ```nix steamFrame.dashboard.windowCurvature = { @@ -762,6 +775,7 @@ steamFrame.dashboard.windowCurvature = { # default = 1.0; max = 3.0; step = 0.05; # snap = 0.15; snapPoints = [ 0 1.0 ]; # dragThreshold = 8; dragPixelsPerUnit = 60; + # barDragPixelsPerUnit = 30; barDragRoom = 160; haptics = true; }; ``` @@ -790,6 +804,17 @@ kept per window (its overlay key) in the `systemui` page dashboard page). `window.__sfuiWindowCurvature.dump()` lists every window's state, `.log` recent events. +**Other patches** that handle presses on the same controls (e.g. a long +press on frame controls) coordinate through a small contract instead of +guessing each other's timing: every element the patch drives has the class +`sfui-curv-ctl`; when a press on one becomes a drag, a bubbling +`CustomEvent` `sfui-curv-dragstart` (detail `{ frameID, where: 'menu' | 'bar' }`) +is dispatched on it, and `sfui-curv-dragend` when the drag ends; +`window.__sfuiWindowCurvature.cancelPress()` ends a press without its +click. A patch with its own gesture there lets `mousemove` through while +that gesture is undecided, drops it on `sfui-curv-dragstart`, and calls +`cancelPress()` when it takes the press over. + **Limitations:** - Only the laser adjusts the value: with controller (gamepad) navigation of diff --git a/modules/lib/signatures.json b/modules/lib/signatures.json index 78eae57..273a68d 100644 --- a/modules/lib/signatures.json +++ b/modules/lib/signatures.json @@ -295,7 +295,8 @@ "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:"] + ".AdditionalOptionsRow,", ".AdditionalOptionsLabel},", "display_name", "invocation", "onClick:()=>{", "onMouseDown:", + "id:`legacy-frame-controls-${", "items_for_bottom_frame_controls", "control:e,", ".TopCenter,meters_per_pixel:"] }, "curvatureAction": { "checkOnly": true, @@ -306,7 +307,8 @@ "checkOnly": true, "module": { "includes": ["e[e.Alive=2]=\"Alive\""] }, "expects": ["this.curvature=", "this.docking=", "get isControlAdditionalOptionsOpen()", - "get controlAdditionalOptionsItems()", "get associatedSummonOverlayKeys()"] + "get controlAdditionalOptionsItems()", "get associatedSummonOverlayKeys()", + "get protoForSteam()", "isCurrentlyVisible"] }, "docking": { "checkOnly": true, @@ -325,7 +327,22 @@ }, "panel": { "checkOnly": true, - "module": { "includes": ["properties[\"curvature-origin-id\"]"] } + "module": { "includes": ["properties[\"curvature-origin-id\"]"] }, + "expects": ["properties.origin=", "properties.debug_name=this.props.debug_name", ".TopCenter:return{x:0,y:1}", + "this.m_Rect=this.getCurrentRootElement().getBoundingClientRect()", "new ResizeObserver("] + }, + "sgApp": { + "checkOnly": true, + "module": { "includes": ["forceLayoutUpdate(){", "s_bPanelsAreDirty"] }, + "expects": ["forceLayoutUpdate=this.forceLayoutUpdate", "updateLayoutValues()"] + }, + "hapticEffects": { + "checkOnly": true, + "module": { "includes": ["e[e.Snap=3]=\"Snap\",e[e.Sliding=4]=\"Sliding\",e[e.SlidingEdge=5]=\"SlidingEdge\""] } + }, + "hapticCall": { + "checkOnly": true, + "module": { "includes": ["TriggerOverlayHapticEffect(VRHTML.VROverlay.ThisOverlayHandle(),"] } }, "sceneGraphNodes": { "checkOnly": true, diff --git a/modules/window-curvature.nix b/modules/window-curvature.nix index 6e8d32b..b25ed07 100644 --- a/modules/window-curvature.nix +++ b/modules/window-curvature.nix @@ -1,7 +1,8 @@ # 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 +# (steamFrame.dashboard.windowCurvature): the "Toggle Curvature" control of a +# window (row of its More Options menu, or its bottom-bar button when moved +# there) becomes a wheel (click: toggle, drag up/down: curvature, with +# controller haptics). 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 @@ -25,8 +26,10 @@ in { 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 + window's curvature and becomes a control, and so does its button in + the window's bottom bar when it sits there (no value shown; steps and + snap points are felt as haptics). 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 @@ -63,6 +66,27 @@ in { stops at the menu's edge (about 190 px above the row), so 0 to `max` should fit into that. ''; + barDragPixelsPerUnit = value 30 '' + Drag distance (bar pixels) per 1.0 of curvature on the bottom-bar + button (when the control sits in the bar). + ''; + barDragRoom = mkOption { + type = types.ints.unsigned; + default = 160; + description = '' + Transparent room (pixels) added above and below a window's bottom bar + while its curvature button is dragged, so the laser stays on the bar + panel (its position stops at the pressed panel's edge). 0 = none. + ''; + }; + haptics = mkOption { + type = types.bool; + default = true; + description = '' + Controller haptics while dragging: a snap at snap points, a stronger + edge at 0 and `max`, a light tick for other steps. + ''; + }; }; config = lib.mkIf cfg.enable { @@ -80,8 +104,8 @@ in { 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."; + assertion = cfg.dragPixelsPerUnit > 0 && cfg.barDragPixelsPerUnit > 0; + message = "steamFrame.dashboard.windowCurvature: dragPixelsPerUnit and barDragPixelsPerUnit must be positive."; } ]; diff --git a/modules/window-curvature/patch.js b/modules/window-curvature/patch.js index eab26f6..08926fe 100644 --- a/modules/window-curvature/patch.js +++ b/modules/window-curvature/patch.js @@ -1,8 +1,11 @@ // 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. +// stock "Toggle Curvature" control becomes a wheel wherever it is shown: the +// row of a window's More Options (three-dot) menu (with the value on the +// right of the row) and, when the control sits in the window's bottom bar +// (e.g. moved there by another patch), that bar button (no value shown: +// the steps and snap points are felt as controller haptics). Click toggles +// (curved -> flat, flat -> stock curve), dragging up/down with the laser sets +// the curvature live. // // Target: SteamVR's dashboard page (vrwebhelper, DevTools 127.0.0.1:8087, // title "systemui"). This file is a function expression, called by the file @@ -40,19 +43,57 @@ // docked in the dashboard or the theater, so the docked Steam window stays // concentric with the dashboard bar. // +// Bar button: the bar panel (vsg-node#legacy-frame-controls-, +// origin TopCenter) is one button high, and the laser's position stops at +// the edge of the pressed panel. While a drag on the bar button runs, the +// panel gets opts.barDragRoom px of transparent padding above and below +// (stock window.forceLayoutUpdate() re-measures it), and the panel's origin +// is moved by the same amount in outgoing scene graphs, so the bar stays +// where it is in VR. The padding moves the bar down in page coordinates; +// laser events the compositor still maps with the old layout are recognised +// (the laser moves continuously) and shifted. opts.barDragPixelsPerUnit +// sets the drag speed there. +// +// Haptics (opts.haptics): VRHTML.VROverlay.TriggerOverlayHapticEffect with +// the stock EOverlayHapticEffect values (sigs.hapticEffects): SlidingEdge at +// 0 and max, Snap at a snap point, Sliding for other steps (at most every +// 30 ms). +// +// Contract with other patches (e.g. a long press on frame controls): this +// patch owns press-and-drag on its controls. +// - Every element it drives has the class `sfui-curv-ctl`. +// - When a press on one becomes a drag (opts.dragThreshold px vertical), +// it dispatches a bubbling CustomEvent `sfui-curv-dragstart` on the +// element (detail { frameID, where: 'menu' | 'bar' }); when that drag +// ends (release or cancelPress), `sfui-curv-dragend`. +// - window.__sfuiWindowCurvature.cancelPress() ends the current press on +// any of its controls without its click (a drag stays at its value); +// returns whether a press was active. +// A patch with its own gesture on these elements lets mousemove through +// while that gesture is undecided, drops it on `sfui-curv-dragstart`, and +// calls cancelPress() when it takes the press over. Neither side reads the +// other's thresholds or restores the other's state afterwards. +// // 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). +// stored/applied value, distances, controls), hits (scene-graph rewrite and +// haptic counters), log (last events, also in the state), setValue(frameID, +// v), controls(), cancelPress(). ((find, sigs, opts, hooks) => { const NAME = 'window-curvature'; - const VERSION = 10; + const VERSION = 11; const FLAT = 999; // origin distances >= this are "flat" const ICON_OFF = 40, ICON_ON = 39; // Toggle Curvature action icons (sigs.curvatureAction) + const HAPTIC = { Snap: 3, Sliding: 4, SlidingEdge: 5 }; // EOverlayHapticEffect (sigs.hapticEffects) - const o = { default: 1, max: 3, step: 0.05, snap: 0.15, snapPoints: [0, 1], dragThreshold: 8, dragPixelsPerUnit: 60, ...opts }; + const o = { + default: 1, max: 3, step: 0.05, snap: 0.15, snapPoints: [0, 1], dragThreshold: 8, dragPixelsPerUnit: 60, + barDragPixelsPerUnit: 30, barDragRoom: 160, haptics: true, ...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)}`; + o.dragThreshold >= 0 && o.dragPixelsPerUnit > 0 && o.barDragPixelsPerUnit > 0 && + Number.isInteger(o.barDragRoom) && o.barDragRoom >= 0 && typeof o.haptics === 'boolean')) + return `invalid options ${JSON.stringify(opts)}`; const id = JSON.stringify([VERSION, o]); const prev = window.__sfuiWindowCurvature; @@ -85,7 +126,7 @@ 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 }; + const hits = { sends: 0, origins: 0, scaled: 0, roomOrigins: 0, haptics: 0 }; // ---- values ------------------------------------------------------------------------ const round = (v) => +(Math.round(v / o.step) * o.step).toFixed(6); @@ -110,7 +151,35 @@ return v; }; + // ---- haptics ------------------------------------------------------------------------- + let lastHaptic = -Infinity; + const haptic = (effect) => { + if (!o.haptics) return; + const ov = window.VRHTML?.VROverlay; + if (typeof ov?.TriggerOverlayHapticEffect !== 'function' || typeof ov.ThisOverlayHandle !== 'function') return; + const now = performance.now(); + if (effect === HAPTIC.Sliding && now - lastHaptic < 30) return; + lastHaptic = now; + try { ov.TriggerOverlayHapticEffect(ov.ThisOverlayHandle(), effect); hits.haptics++; } catch (e) { log('haptic failed', String(e)); } + }; + const hapticFor = (v) => haptic(v <= 0 || v >= o.max ? HAPTIC.SlidingEdge + : o.snapPoints.includes(v) ? HAPTIC.Snap : HAPTIC.Sliding); + // ---- scene graph --------------------------------------------------------------------- + // Bar panels with drag room: frameID -> { node, pad, saved }. + const rooms = new Map(); + // Keeps a padded bar panel's content where it was: the stock origin (x, y + // in -1..1 of the panel, y = 1 top) is re-expressed for the taller panel. + const fixOrigin = (p) => { + const m = /^legacy-frame-controls-(\d+)$/.exec(p.debug_name ?? ''); + const r = m && rooms.get(Number(m[1])); + if (!r || !Array.isArray(p.origin) || p.origin.length < 2) return; + const h = r.node.getBoundingClientRect().height, inner = h - 2 * r.pad; + if (!(inner > 0)) return; + const fromTop = r.pad + ((1 - p.origin[1]) / 2) * inner; + p.origin = [p.origin[0], 1 - (2 * fromTop) / h, ...p.origin.slice(2)]; + hits.roomOrigins++; + }; // Origin node ids may carry a "::" (sub-scene); match the suffix. const rewrite = (sg) => { const byOrigin = new Map(); @@ -118,7 +187,7 @@ const c = curvatureOf(f); if (c?.shouldCurve) byOrigin.set(c.curvatureTransformOriginID, f); } - if (!byOrigin.size) return; + if (!byOrigin.size && !rooms.size) return; const walk = (n) => { if (!n || typeof n !== 'object') return; if (Array.isArray(n)) { n.forEach(walk); return; } @@ -133,6 +202,7 @@ hits.scaled++; } } + if (rooms.size && n.type === 'panel' && p) fixOrigin(p); if (n.children) walk(n.children); }; walk(sg); @@ -153,12 +223,18 @@ try { resendStock(); } catch (e) { log('resend failed', String(e)); } }, Math.max(0, lastResend + 40 - performance.now())); }; + // Panel bounds are re-measured on the stock forced layout update (a + // padding change doesn't trigger the panels' ResizeObserver). + const relayout = () => { + try { window.forceLayoutUpdate?.(); } catch (e) { log('forceLayoutUpdate failed', String(e)); } + resend(); + }; // 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). + // strength. On/off goes through the stock ToggleCurvature (what the + // control's stock click invokes), so Steam's state and the 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; @@ -177,13 +253,13 @@ }; // ---- 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). + // Menu row: the value is shown on its right with small arrows above/below + // (only where the value can go). Bar button: no indicator. 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-ctl { cursor: ns-resize; } +.sfui-curv-ctl.sfui-dragging { background: var(--gamepadui-grey, #67707b) !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; } @@ -208,17 +284,21 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } }; // 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. + // curvature icons. Its menu row is found by position among the menu's + // rows, checked against (else looked up by) the action's label; its bar + // button by the React `control` prop of the bar's buttons. const isCurvatureAction = (a) => { const p = a?.partialParams; return p?.invocation === 2 && p?.icon?.enum === ICON_OFF && p?.icon_active?.enum === ICON_ON; }; + const isCurvatureItem = (i) => i?.type === 2 && isCurvatureAction(actions.GetAction(i.action_id)); + const inBar = (f) => (f.protoForSteam?.controls?.items_for_bottom_frame_controls ?? []).some(isCurvatureItem); const panelOf = (fid) => document.getElementById(`legacy-frame-controls-additional-options-${fid}`); + const barOf = (fid) => document.getElementById(`legacy-frame-controls-${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))); + const idx = items.findIndex(isCurvatureItem); 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; @@ -226,6 +306,14 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } 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)` }; }; + const barButton = (node) => { + for (const b of node.querySelectorAll('.ButtonControl')) { + if (b.closest('[id^="legacy-frame-controls-additional-options-"]')) continue; + const fiber = find.findFiberUp(b, (x) => x.memoizedProps?.control != null, 12); + if (isCurvatureItem(fiber?.memoizedProps.control)) return b; + } + return null; + }; // 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 @@ -243,6 +331,14 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } const inkCentre = baseline - (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2; return lh / 2 - inkCentre; // > 0: move down }; + const buildInd = () => { + 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); + } + return ind; + }; const renderInd = (ind, v) => { const [up, num, dn] = ind.children; up.textContent = v < o.max ? '▲' : ''; @@ -252,118 +348,199 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } num.style.setProperty('--sfui-curv-dy', `${inkShift(num).toFixed(2)}px`); }; - const controls = new Map(); // frameID -> { ind, row, panel, mo, attach, detach, endDrag } + // `${frameID}:menu` / `${frameID}:bar` -> { key, fid, where, panel, el, ind, mo, down } + const controls = new Map(); function updateUI(f) { - const c = controls.get(f.frameID); - if (c?.ind.isConnected) renderInd(c.ind, shown(f)); + const c = controls.get(`${f.frameID}:menu`); + 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. + // ---- input ----------------------------------------------------------------------------- + // Every press/click on a control is stopped at the element, 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 (bar: barDragPixelsPerUnit) + // px per 1.0, see dragValue. One press at a time, window capture listeners + // while it lasts. 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 press = null; // { c, y0, v0, last, moved, shift, stale } - 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 emit = (c, type) => { + try { + c.el?.dispatchEvent(new CustomEvent(type, { bubbles: true, detail: { frameID: c.fid, where: c.where } })); + } catch (e) { log(`${type} listener failed`, String(e)); } }; - const inject = (f) => { - const fid = f.frameID; - const panel = panelOf(fid); + const addRoom = (p) => { + const node = p.c.panel; + if (!o.barDragRoom || !node?.isConnected || rooms.has(p.c.fid)) return; + const before = p.c.el.getBoundingClientRect().top; + rooms.set(p.c.fid, { node, pad: o.barDragRoom, saved: [node.style.paddingTop, node.style.paddingBottom] }); + node.style.paddingTop = node.style.paddingBottom = `${o.barDragRoom}px`; + const shift = p.c.el.getBoundingClientRect().top - before; + p.shift = shift; p.stale = shift !== 0; p.last += shift; p.y0 += shift; + relayout(); + }; + const removeRoom = (fid) => { + const r = rooms.get(fid); + if (!r) return; + rooms.delete(fid); + [r.node.style.paddingTop, r.node.style.paddingBottom] = r.saved; + relayout(); + }; + + const onMove = (e) => { + const p = press; + if (!p) return; + const f = FS.GetFrame(p.c.fid); + if (!f) { endPress('frame gone'); return; } + let y = e.clientY; + // After the bar got its room, events the compositor still maps with the + // old layout are off by `shift`: take the reading closer to the last one. + if (p.stale) { + if (Math.abs(y + p.shift - p.last) < Math.abs(y - p.last)) y += p.shift; + else p.stale = false; + } + p.last = y; + if (!p.moved) { + if (Math.abs(y - p.y0) < o.dragThreshold) return; + p.moved = true; + p.c.el?.classList.add('sfui-dragging'); + log('drag', { frame: p.c.fid, where: p.c.where }); + emit(p.c, 'sfui-curv-dragstart'); + if (press !== p) return; // a listener cancelled the press + if (p.c.where === 'bar') addRoom(p); + p.y0 = p.last; + return; + } + const ppu = p.c.where === 'bar' ? o.barDragPixelsPerUnit : o.dragPixelsPerUnit; + const v = dragValue(p.v0 + (p.y0 - y) / ppu, shown(f)); + if (v === null) return; + setValue(f, v, 'drag'); + hapticFor(v); + }; + const onUp = (e) => { + if (e.button === 0) endPress('release'); + }; + // Ends the press; `release` of a press that never moved is a click. + function endPress(why) { + const p = press; + if (!p) return false; + press = null; + window.removeEventListener('mousemove', onMove, true); + window.removeEventListener('mouseup', onUp, true); + p.c.el?.classList.remove('sfui-dragging'); + removeRoom(p.c.fid); + if (p.moved) emit(p.c, 'sfui-curv-dragend'); + const f = FS.GetFrame(p.c.fid); + if (why === 'release') { + if (!p.moved && f) setValue(f, curved(f) ? 0 : 1, `click (${p.c.where})`); // curved -> flat, flat -> stock + } else { + log('press ended', { frame: p.c.fid, where: p.c.where, why, dragged: p.moved }); + } + return true; + } + const onDown = (c) => (e) => { + e.stopPropagation(); e.preventDefault(); + const f = FS.GetFrame(c.fid); + if (e.button !== 0 || !f) return; + endPress('new press'); + press = { c, y0: e.clientY, last: e.clientY, v0: shown(f), moved: false, shift: 0, stale: false }; + window.addEventListener('mousemove', onMove, true); + window.addEventListener('mouseup', onUp, true); + }; + + // ---- controls -------------------------------------------------------------------------- + const setEl = (c, el) => { + if (c.el === el) return; + if (c.el) detach(c); + c.el = el; + el.classList.add('sfui-curv-ctl'); + if (press?.c === c && press.moved) el.classList.add('sfui-dragging'); // re-rendered mid-drag + el.addEventListener('mousedown', c.down); + for (const t of STOPPED) el.addEventListener(t, stop); + }; + function detach(c) { + c.el.classList.remove('sfui-curv-ctl', 'sfui-dragging'); + c.el.removeEventListener('mousedown', c.down); + for (const t of STOPPED) c.el.removeEventListener(t, stop); + } + const newControl = (key, fid, where, panel) => { + const c = { key, fid, where, panel, el: null, ind: where === 'menu' ? buildInd() : null }; + c.down = onDown(c); + c.mo = new MutationObserver(() => { + if (!c.el?.isConnected || (c.ind && c.ind.parentElement !== c.el)) queueSync(); + }); + c.mo.observe(panel, { childList: true, subtree: true }); + controls.set(key, c); + return c; + }; + function removeControl(key) { + const c = controls.get(key); + if (!c) return; + if (press?.c === c) endPress('control removed'); + c.mo.disconnect(); + if (c.el) detach(c); + c.ind?.remove(); + controls.delete(key); + } + + const injectMenu = (f) => { + const key = `${f.frameID}:menu`, panel = panelOf(f.frameID); 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'; } + let c = controls.get(key); + if (c && c.panel !== panel) { removeControl(key); c = null; } + if (c?.el?.isConnected && c.ind.parentElement === c.el) { 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; } + if (!r.row) 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); - } + c ??= newControl(key, f.frameID, 'menu', panel); + setEl(c, r.row); r.row.append(c.ind); renderInd(c.ind, shown(f)); return 'ok'; }; + const injectBar = (f) => { + const key = `${f.frameID}:bar`, node = barOf(f.frameID); + if (!node) return 'no bar panel'; + let c = controls.get(key); + if (c && c.panel !== node) { removeControl(key); c = null; } + if (c?.el?.isConnected && node.contains(c.el)) return 'ok'; + const btn = barButton(node); + if (!btn) return 'bar button not found'; + ensureStyle(); + c ??= newControl(key, f.frameID, 'bar', node); + setEl(c, btn); + 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); + // Wanted controls: open menus, and bars that hold the curvature action; + // injected with retries until React has rendered them (bars only while + // the frame is visible: a hidden frame has no bar panel). let syncTimer = null, retries = 0; + const failed = new Map(); // key -> last reason logged const sync = () => { syncTimer = null; - const open = openFrames(); - for (const fid of [...controls.keys()]) if (!open.some((f) => f.frameID === fid)) removeControl(fid); + const want = new Map(); + for (const f of FS.frames) { + if (f.isControlAdditionalOptionsOpen) want.set(`${f.frameID}:menu`, f); + if (inBar(f)) want.set(`${f.frameID}:bar`, f); + } + for (const key of [...controls.keys()]) if (!want.has(key)) removeControl(key); + for (const key of [...failed.keys()]) if (!want.has(key)) failed.delete(key); let pending = false; - for (const f of open) if (inject(f) !== 'ok') pending = true; + for (const [key, f] of want) { + const bar = key.endsWith(':bar'); + const r = bar ? injectBar(f) : injectMenu(f); + if (r === 'ok') { failed.delete(key); continue; } + if (bar ? !f.isCurrentlyVisible : r === 'no curvature action') continue; // hidden frame / not in this menu + pending = true; + if (failed.get(key) !== r) { failed.set(key, r); log('inject failed', { frame: f.frameID, where: bar ? 'bar' : 'menu', why: r }); } + } if (pending && retries < 12) { retries++; syncTimer = setTimeout(sync, 60 * retries); } else retries = 0; }; function queueSync() { if (!syncTimer) syncTimer = setTimeout(sync, 0); } @@ -371,16 +548,19 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } 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(','), + () => FS.frames.map((f) => [f.frameID, f.isControlAdditionalOptionsOpen ? 1 : 0, curved(f) ? 1 : 0, dockOf(f), + inBar(f) ? 1 : 0, f.isCurrentlyVisible ? 1 : 0].join(':')).join(','), () => { retries = 0; queueSync(); for (const f of FS.frames) updateUI(f); }, ), mx.reaction(() => DS.curvatureDistance, () => resend()), ]; const teardown = () => { + endPress('teardown'); for (const d of disposers.splice(0)) d(); clearTimeout(syncTimer); clearTimeout(resendTimer); - for (const fid of [...controls.keys()]) removeControl(fid); + for (const key of [...controls.keys()]) removeControl(key); + for (const fid of [...rooms.keys()]) removeRoom(fid); document.getElementById(STYLE_ID)?.remove(); hooks.remove(proto, 'SendMessage', NAME); if (window.__sfuiWindowCurvature === state) delete window.__sfuiWindowCurvature; @@ -388,7 +568,7 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } }; const check = () => { if (!hooks.has(proto, 'SendMessage', NAME)) hooks.before(proto, 'SendMessage', NAME, hook); - if (openFrames().length) queueSync(); + queueSync(); return 'unchanged'; }; const dump = () => FS.frames.map((f) => ({ @@ -396,15 +576,21 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } 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, + menuOpen: f.isControlAdditionalOptionsOpen, inBar: inBar(f), visible: f.isCurrentlyVisible, + control: !!controls.get(`${f.frameID}:menu`)?.el?.isConnected, + barControl: !!controls.get(`${f.frameID}:bar`)?.el?.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'; }, + cancelPress: () => endPress('cancelled'), + controls: () => [...controls.values()].map((c) => ({ frame: c.fid, where: c.where, connected: !!c.el?.isConnected })), + get pressing() { return press && { frame: press.c.fid, where: press.c.where, dragging: press.moved }; }, }; 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('/')})`; + return `patched (default ${o.default}, max ${o.max}, step ${o.step}, snap ±${o.snap} at ${o.snapPoints.join('/')}, ` + + `haptics ${o.haptics ? 'on' : 'off'})`; }) diff --git a/modules/window-curvature/unpatch.js b/modules/window-curvature/unpatch.js index 58170c1..914419a 100644 --- a/modules/window-curvature/unpatch.js +++ b/modules/window-curvature/unpatch.js @@ -1,9 +1,10 @@ -// 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. +// Reverts window-curvature/patch.js: ends a running press or drag, removes +// the control from open menus and bar buttons (class, listeners, value +// indicator, a bar's drag room), 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'; diff --git a/template/home.nix b/template/home.nix index 3737c43..1cddf2a 100644 --- a/template/home.nix +++ b/template/home.nix @@ -43,7 +43,8 @@ # # 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: + # # window's More Options menu (or its bar button) to toggle, drag + # # up/down to adjust (with controller haptics): # windowCurvature.enable = true; # }; # firefox.enable = true; # launcher for the Firefox Flatpak