From 2feb3d405d60af2cfb1ea84551f6fa6d3a43e2ac Mon Sep 17 00:00:00 2001 From: Pierre Kisters <1524059+lhns@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:48:15 +0200 Subject: [PATCH] window-curvature: drag detents, muted hover haptics, longer drag per step - Snap points are detents in drag distance (snapPixels, default 24): the value holds at the point, then continues, so no value is skipped. `snap` is removed in favour of `snapPixels`. - The dashboard's own hover haptics are muted during a drag, so only the value's ticks and detents are felt. - Longer drag per step by default: 120 px per 1.0 in the menu, 60 on the bar button (step stays 0.05). --- README.md | 18 ++++--- modules/window-curvature.nix | 37 +++++++++----- modules/window-curvature/patch.js | 81 +++++++++++++++++++++---------- 3 files changed, 90 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index f0f2cf7..6c53c87 100644 --- a/README.md +++ b/README.md @@ -214,13 +214,13 @@ without it. | `steamFrame.dashboard.windowCurvature.default` | number | `1.0` | Curvature of curved world/hand windows without own value (1 = stock, 0 = flat). | | `steamFrame.dashboard.windowCurvature.max` | number | `3.0` | Largest curvature. | | `steamFrame.dashboard.windowCurvature.step` | number | `0.05` | Rounding step while dragging. | -| `steamFrame.dashboard.windowCurvature.snap` | number | `0.15` | Snap distance around snap points; `0`: none. | +| `steamFrame.dashboard.windowCurvature.snapPixels` | unsigned int (px) | `24` | Detent at each snap point in drag pixels: the value holds there, then continues (nothing skipped); `0`: none. | | `steamFrame.dashboard.windowCurvature.snapPoints` | list of numbers | `[ 0 1.0 ]` | Snap points (flat, stock). | | `steamFrame.dashboard.windowCurvature.dragThreshold` | unsigned int (px) | `8` | Vertical travel before a press becomes a drag. | -| `steamFrame.dashboard.windowCurvature.dragPixelsPerUnit` | number (px) | `60` | Drag distance per 1.0 in the menu. | -| `steamFrame.dashboard.windowCurvature.barDragPixelsPerUnit` | number (px) | `30` | Drag distance per 1.0 on the bar button. | +| `steamFrame.dashboard.windowCurvature.dragPixelsPerUnit` | number (px) | `120` | Drag distance per 1.0 in the menu (6 px per 0.05 step). | +| `steamFrame.dashboard.windowCurvature.barDragPixelsPerUnit` | number (px) | `60` | Drag distance per 1.0 on the bar button. | | `steamFrame.dashboard.windowCurvature.barDragRoom` | unsigned int (px) | `160` | Room added above/below the bar while dragging; `0`: none. | -| `steamFrame.dashboard.windowCurvature.haptics` | bool | `true` | Controller haptics while dragging. | +| `steamFrame.dashboard.windowCurvature.haptics` | bool | `true` | Controller haptics while dragging (steps, detents, edges); the dashboard's hover clicks are muted during a drag. | | `steamFrame.dashboard.frameControls.enable` | bool | `false` | Move window controls between bar and three-dot menu, see [Window control bar](#window-control-bar-dashboardframecontrols). | | `steamFrame.dashboard.frameControls.longPressMs` | int, 300-10000 (ms) | `1500` | Long-press duration. | | `steamFrame.dashboard.frameControls.inBar` | list of control names | `[ ]` | Controls that start in the bar: `keyboard`, `float`, `dashboard`, `theater`, `dockLeft`, `dockRight`, `close`, `curvature`, `"icon:"`. | @@ -599,8 +599,9 @@ without the value, with haptic steps. - **click:** curved → flat, flat → stock (1); - **drag up/down** with the laser: curvature from 0 (flat) to `max`, - relative to stock (2 = half the radius), snapping near `snapPoints`, with - haptics for snaps, edges and steps. + relative to stock (2 = half the radius), with a detent of `snapPixels` of + drag at each of `snapPoints` (no values skipped), and haptics for snaps, + edges and steps. A window without its own value is shown at `default` once curved in the world or on a hand, at 1 in the dashboard or theater. Values are kept per @@ -611,8 +612,9 @@ window until SteamVR restarts. Debugging: `window.__sfuiWindowCurvature.dump()` - Laser only; with gamepad navigation the row is the stock toggle. - No thumbstick scrolling (SteamVR sends no wheel events to the menu). -- The laser stops at the menu's edge, so `dragPixelsPerUnit` × `max` should - stay below ~190 px to reach `max` in one drag. +- The laser stops at the menu's edge (~190 px above the row): with the + default 120 px per 1.0, 0 → 1 fits into one drag, 0 → 3 takes two. Lower + `dragPixelsPerUnit` (≤ 60) for the full range in one drag. **For patch authors** (other patches handling presses on these controls): every element the patch drives has class `sfui-curv-ctl`; when a press diff --git a/modules/window-curvature.nix b/modules/window-curvature.nix index 8814033..75bfc3b 100644 --- a/modules/window-curvature.nix +++ b/modules/window-curvature.nix @@ -12,7 +12,11 @@ let inherit default description; }; in { - imports = [ ./steam-ui-patches.nix ./steamvr-debugger.nix ]; + imports = [ + ./steam-ui-patches.nix ./steamvr-debugger.nix + (lib.mkRemovedOptionModule [ "steamFrame" "dashboard" "windowCurvature" "snap" ] + "Snapping is a detent in drag pixels now: use steamFrame.dashboard.windowCurvature.snapPixels.") + ]; options.steamFrame.dashboard.windowCurvature = { enable = lib.mkEnableOption '' @@ -28,13 +32,19 @@ in { ''; 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 '' - Snap distance around snap points while dragging; 0 = no snapping. - ''; + snapPixels = mkOption { + type = types.ints.unsigned; + default = 24; + description = '' + Detent at each snap point, in pixels of drag: the value holds at the + point for this much travel, then continues from it (no values are + skipped). 0 = no detent. + ''; + }; snapPoints = mkOption { type = types.listOf types.number; default = [ 0 1.0 ]; - description = "Values the drag snaps to (0 = flat, 1 = stock curve)."; + description = "Values with a detent while dragging (0 = flat, 1 = stock curve)."; }; dragThreshold = mkOption { type = types.ints.unsigned; @@ -43,11 +53,12 @@ in { Vertical laser travel (menu px) that turns a press into a drag. ''; }; - dragPixelsPerUnit = value 60 '' - Menu pixels per 1.0 of curvature. The laser stops at the menu's edge - (~190 px above the row), so 0 to `max` should fit. + dragPixelsPerUnit = value 120 '' + Menu pixels per 1.0 of curvature (6 px per 0.05 step). The laser stops + at the menu's edge (~190 px above the row): with the default, 0 to 1 + fits into one drag, the full range to `max` = 3 takes two. ''; - barDragPixelsPerUnit = value 30 '' + barDragPixelsPerUnit = value 60 '' Bar pixels per 1.0 of curvature on the bottom-bar button. ''; barDragRoom = mkOption { @@ -62,7 +73,7 @@ in { type = types.bool; default = true; description = '' - Controller haptics while dragging (snap points, 0/`max` edges, steps). + Controller haptics while dragging (steps, detents, 0/`max` edges); the dashboard's own hover clicks are muted during a drag. ''; }; }; @@ -78,8 +89,8 @@ in { 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 = lib.all (p: p >= 0 && p <= cfg.max) cfg.snapPoints; + message = "steamFrame.dashboard.windowCurvature: snapPoints must be within 0 .. max (${builtins.toJSON cfg.max})."; } { assertion = cfg.dragPixelsPerUnit > 0 && cfg.barDragPixelsPerUnit > 0; @@ -94,7 +105,7 @@ in { patch = mkPatch { name = "window-curvature"; src = ./window-curvature/patch.js; - opts = removeAttrs cfg [ "enable" ]; + opts = removeAttrs cfg [ "enable" "snap" ]; }; unpatch = ./window-curvature/unpatch.js; } ]; diff --git a/modules/window-curvature/patch.js b/modules/window-curvature/patch.js index d055c6d..8a771b1 100644 --- a/modules/window-curvature/patch.js +++ b/modules/window-curvature/patch.js @@ -1,13 +1,13 @@ // window-curvature: adjustable curvature per SteamVR dashboard window. The // stock "Toggle Curvature" control becomes a wheel: in the More Options menu // row (value shown on the right) and, if the control is in the bottom bar -// (e.g. via frame-controls), on that bar button (no value; steps and snap -// points are felt as haptics). Click toggles (curved -> flat, flat -> stock +// (e.g. via frame-controls), on that bar button (no value; steps and +// detents are felt as haptics). Click toggles (curved -> flat, flat -> stock // curve); dragging up/down with the laser sets the curvature live. // // Target: SteamVR dashboard (vrwebhelper, DevTools 127.0.0.1:8087, title // "systemui"). mkPatch patch (see lib/default.nix); opts: { default, max, -// step, snap, snapPoints, dragThreshold, dragPixelsPerUnit, +// step, snapPixels, snapPoints, dragThreshold, dragPixelsPerUnit, // barDragPixelsPerUnit, barDragRoom, haptics }. // // Stock curvature (frame.curvature, systemui's `curvature` component): @@ -36,9 +36,14 @@ // origin is compensated in outgoing scene graphs so the bar stays put in VR. // Laser events still mapped with the old layout are detected and shifted. // +// Dragging: value = start value + drag distance / px per unit, rounded to +// step. Snap points are detents in drag distance: the value holds there for +// opts.snapPixels of travel, then continues, so no value is skipped. +// // Haptics (opts.haptics): VROverlay.TriggerOverlayHapticEffect with stock // EOverlayHapticEffect values: SlidingEdge at 0/max, Snap at snap points, -// Sliding otherwise (at most every 30 ms). +// Sliding per step (at most every 30 ms). During a drag the dashboard's own +// haptics (hover clicks on buttons the laser passes) are muted. // // Contract with other patches (e.g. frame-controls' long press); this patch // owns press-and-drag on its controls: @@ -56,19 +61,20 @@ // thresholds or restores the other's state. // // Debugging: window.__sfuiWindowCurvature: dump(), hits (rewrite/haptic -// counters), log, setValue(frameID, v), controls(), cancelPress(). +// counters), log, setValue(frameID, v), controls(), cancelPress(), +// dragValue(v0, dy, ppu, cur). ((find, sigs, opts, hooks) => { const NAME = 'window-curvature'; - const VERSION = 11; + const VERSION = 16; 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, - barDragPixelsPerUnit: 30, barDragRoom: 160, haptics: true, ...opts, + default: 1, max: 3, step: 0.05, snapPixels: 24, snapPoints: [0, 1], dragThreshold: 8, dragPixelsPerUnit: 120, + barDragPixelsPerUnit: 60, 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 && + if (!(o.max > 0 && o.step > 0 && o.step <= o.max && o.default >= 0 && o.default <= o.max && o.snapPixels >= 0 && Array.isArray(o.snapPoints) && o.snapPoints.every((p) => p >= 0 && p <= o.max) && o.dragThreshold >= 0 && o.dragPixelsPerUnit > 0 && o.barDragPixelsPerUnit > 0 && Number.isInteger(o.barDragRoom) && o.barDragRoom >= 0 && typeof o.haptics === 'boolean')) @@ -119,27 +125,49 @@ 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; + // Value for a drag of dy px (up = positive) from v0 at ppu px per unit, or + // null to keep cur. Snap points are detents in drag distance: reaching one + // (or starting on it) holds the value there for snapPixels of travel, then + // it continues from the point, so no values are skipped. Off a snap point + // the value is rounded to step; a change of less than 0.6 steps away from + // cur is ignored (laser jitter at a step boundary). + const dragValue = (v0, dy, ppu, cur) => { + const dir = Math.sign(dy); + let v = v0, rest = Math.abs(dy), held = null; + const points = o.snapPoints.filter((p) => (dir > 0 ? p >= v0 : p <= v0)).sort((a, b) => dir * (a - b)); + for (const p of points) { + const dist = Math.abs(p - v) * ppu; + if (rest <= dist) break; + rest -= dist; v = p; + if (rest <= o.snapPixels) { held = p; rest = 0; break; } + rest -= o.snapPixels; + } + const target = held ?? v + dir * rest / ppu; + const nv = clamp(held ?? round(target)); + if (nv === cur || (held === null && Math.abs(target - cur) < o.step * 0.6)) return null; + return nv; }; // ---- haptics ------------------------------------------------------------------------- - let lastHaptic = -Infinity; + // While a drag runs, the dashboard's own haptics (a click whenever the laser + // enters a button, e.g. menu rows the drag passes over) are muted, so only + // the value's ticks and detents are felt. The stock function is wrapped + // once (restored by teardown); our ticks call the original. + let lastHaptic = -Infinity, muteStock = false; + const ov = window.VRHTML?.VROverlay; + const stockHaptic = typeof ov?.TriggerOverlayHapticEffect === 'function' + ? (ov.TriggerOverlayHapticEffect.__sfuiCurvOrig ?? ov.TriggerOverlayHapticEffect) : null; + if (stockHaptic) { + const wrapped = function (...a) { if (!muteStock) return stockHaptic.apply(this, a); }; + wrapped.__sfuiCurvOrig = stockHaptic; + ov.TriggerOverlayHapticEffect = wrapped; + } const haptic = (effect) => { - if (!o.haptics) return; - const ov = window.VRHTML?.VROverlay; - if (typeof ov?.TriggerOverlayHapticEffect !== 'function' || typeof ov.ThisOverlayHandle !== 'function') return; + if (!o.haptics || !stockHaptic || 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)); } + try { stockHaptic.call(ov, 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); @@ -378,6 +406,7 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } if (!p.moved) { if (Math.abs(y - p.y0) < o.dragThreshold) return; p.moved = true; + muteStock = true; p.c.el?.classList.add('sfui-dragging'); log('drag', { frame: p.c.fid, where: p.c.where }); emit(p.c, 'sfui-curv-dragstart'); @@ -387,7 +416,7 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } return; } const ppu = p.c.where === 'bar' ? o.barDragPixelsPerUnit : o.dragPixelsPerUnit; - const v = dragValue(p.v0 + (p.y0 - y) / ppu, shown(f)); + const v = dragValue(p.v0, p.y0 - y, ppu, shown(f)); if (v === null) return; setValue(f, v, 'drag'); hapticFor(v); @@ -398,6 +427,7 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } // Ends the press; `release` of a press that never moved is a click. function endPress(why) { const p = press; + muteStock = false; if (!p) return false; press = null; window.removeEventListener('mousemove', onMove, true); @@ -526,6 +556,7 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } const teardown = () => { endPress('teardown'); + if (stockHaptic && ov.TriggerOverlayHapticEffect?.__sfuiCurvOrig === stockHaptic) ov.TriggerOverlayHapticEffect = stockHaptic; for (const d of disposers.splice(0)) d(); clearTimeout(syncTimer); clearTimeout(resendTimer); for (const key of [...controls.keys()]) removeControl(key); @@ -560,6 +591,6 @@ ${ROW}:last-child > .sfui-curv-ind { margin-bottom: -14px; } 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.snapPixels} px at ${o.snapPoints.join('/')}, ` + `haptics ${o.haptics ? 'on' : 'off'})`; })