Move the head-follow cursor only after the head passes the leash

Easing the reference toward the head all the time moved the cursor on
every small head movement, so a cursor parked in a corner of the view
drifted. Now nothing moves while the head stays within the leash. Once
the head has been past it for POINTER_LEASH_DELAY (0.2 s, so a glance
out and back doesn't count), the reference eases all the way to where
you face (POINTER_LEASH_RETURN) and the cursor lands back in its place
in the view, then the leash waits again. Leaning inside the leash no
longer moves the cursor either. Leash 0 stays head-locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-27 21:52:21 -06:00
1 parent 9f4b61729d
commit 9addd8c669
6 files changed
+57 -27

No files matched your search

+1 -1
View File
@@ -53,7 +53,7 @@ If you work in the desktop for long stretches, stop Steam from putting the heads
| Meta+Shift+H in the desktop | Hides or shows all screens (also in the menu as Hide/Show Screens, and mappable). The Visibility & wrist tab of Frametop Display Settings can instead show them only with the dashboard open, or while you look at your wrist |
| Play a VR game | The screens hide and your controllers stay in the game. Open the SteamVR dashboard, or press Meta+Shift+H, to see and use them. To keep them visible over games, change During VR games on the Visibility & wrist tab; the controllers still stay in the game, and you use the screens with the mouse or the dashboard |
You can map the mouse's extra buttons to actions such as Toggle SteamVR dashboard, Recenter pointer, or Head follow on/off on the Buttons page of Frametop Input Settings. Pointer speed, dot size, and the rest are on its Pointer page and take effect immediately. Head follow, off by default, makes the pointer come along when you turn your head: small head movements barely move it, it settles back to its place in your view when your head stops, and a leash of 0 keeps it fixed in your view.
You can map the mouse's extra buttons to actions such as Toggle SteamVR dashboard, Recenter pointer, or Head follow on/off on the Buttons page of Frametop Input Settings. Pointer speed, dot size, and the rest are on its Pointer page and take effect immediately. Head follow, off by default, makes the pointer come along when you turn your head: it stays put until your head turns past the leash angle, then glides back to its place in your view, and a leash of 0 keeps it fixed in your view.
Restarting the desktop (Restart desktop in Frametop Display Settings) closes its windows, but background work you started in it, such as servers, tmux sessions, or builds, keeps running.
+1 -1
View File
@@ -80,7 +80,7 @@ A few overlays need special handling:
- Just off a panel, the cursor stays on that panel's plane within `POINTER_EDGE_REACH`, so resize margins and window controls just outside the panel are reachable.
- While the left button is held, the cursor keeps the distance it had at the press and stops re-testing collisions, so dragging past a panel's edge doesn't make it jump.
Head follow is off by default. With it on (`POINTER_FOLLOW=1`, or a mouse button mapped to Head follow on/off), the cursor rides on a reference direction that follows where you're facing, and keeps its offset from it. The reference eases toward your facing (time constant `POINTER_LEASH_RETURN`, 0.2 s), so small head movements barely move the cursor, but it never lags more than `POINTER_LEASH_DEG` behind, and once your head settles the cursor is back where it was in your view. The first version only moved the reference when your head reached the end of the leash, which left it up to the leash off after you turned back, and getting it centred again meant overshooting with your head. A leash of 0 makes the reference your facing direction, so the cursor is locked to your view, and mouse movement shifts it within the view. Head roll is ignored, so tilting your head doesn't swing the cursor around, and the mouse can move the cursor up to `POINTER_FOLLOW_REACH` (70 degrees) from the reference. While the left button is held the cursor stays put in the room, so your head can't nudge a click or a drag. When you let go, it carries on from where it is instead of jumping.
Head follow is off by default. With it on (`POINTER_FOLLOW=1`, or a mouse button mapped to Head follow on/off), the cursor rides on a reference direction, where you were facing when your head last settled, and keeps its offset from it. The mouse can put the cursor anywhere up to `POINTER_FOLLOW_REACH` (70 degrees) from the reference, a corner of your view included. While your head stays within `POINTER_LEASH_DEG` of the reference, nothing moves on its own. Once your head has been past the leash for `POINTER_LEASH_DELAY` (0.2 s, so a glance out and back doesn't count), the reference eases to where you're facing (time constant `POINTER_LEASH_RETURN`, 0.2 s), never falling further behind than the leash, and the cursor ends up back where it was in your view. Then it waits for the leash again. Two earlier versions didn't work out. Moving the reference only while your head pulled at the end of the leash left it up to the leash off after you turned back, and getting it centred again meant overshooting with your head. Easing it toward your facing all the time moved the cursor on every small head movement. A leash of 0 makes the reference your facing direction, so the cursor is locked to your view, and mouse movement shifts it within the view. Head roll is ignored, so tilting your head doesn't swing the cursor around. While the left button is held the cursor stays put in the room, so your head can't nudge a click or a drag. When you let go, it carries on from where it is instead of jumping.
Replacing a loaded driver's files, as re-running the installer used to do, leaves SteamVR honoring the virtual controller's hand role but not its laser claim: the dashboard pointer stays unassigned until SteamVR restarts. The driver installer now leaves an unchanged driver in place.
+1 -1
View File
@@ -98,7 +98,7 @@ pointer/helper/run.sh status | log | restart
pointer/driver/install.sh probe # devices, hand roles, who owns the dashboard pointer
```
The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `POINTER_IDLE`, `POINTER_WAKE_COUNTS`, `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, `POINTER_ORIGIN_FRACTION`, `POINTER_ORIGIN_MARGIN`, `POINTER_SCENE_RADIUS`, `POINTER_EDGE_REACH`, `POINTER_LASER_WIDTH`, and the head follow settings `POINTER_FOLLOW`, `POINTER_LEASH_DEG`, `POINTER_LEASH_RETURN`, and `POINTER_FOLLOW_REACH`. The example config explains each. Frametop Input Settings changes them live; after editing the file by hand, restart the relay or the helper.
The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `POINTER_IDLE`, `POINTER_WAKE_COUNTS`, `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, `POINTER_ORIGIN_FRACTION`, `POINTER_ORIGIN_MARGIN`, `POINTER_SCENE_RADIUS`, `POINTER_EDGE_REACH`, `POINTER_LASER_WIDTH`, and the head follow settings `POINTER_FOLLOW`, `POINTER_LEASH_DEG`, `POINTER_LEASH_DELAY`, `POINTER_LEASH_RETURN`, and `POINTER_FOLLOW_REACH`. The example config explains each. Frametop Input Settings changes them live; after editing the file by hand, restart the relay or the helper.
## Frametop Input Settings
+1
View File
@@ -54,6 +54,7 @@ POINTER_SETTINGS = [
("POINTER_SCENE_RADIUS", "Dock / window-control reach", 0.5, 0.1, 1.5, 0.05, "m"),
("POINTER_EDGE_REACH", "Panel edge reach", 0.3, 0.0, 1.0, 0.05, "m"),
("POINTER_LEASH_DEG", "Head follow leash", 10, 0, 60, 1, "°"),
("POINTER_LEASH_DELAY", "Head follow delay", 0.2, 0.0, 1.0, 0.05, "s"),
("POINTER_LEASH_RETURN", "Head follow catch-up", 0.2, 0.05, 2.0, 0.05, "s"),
("POINTER_FOLLOW_REACH", "Head follow reach", 70, 20, 85, 1, "°"),
("POINTER_WAKE_COUNTS", "Movement to wake", 40, 5, 200, 5, "counts"),
+50 -22
View File
@@ -87,16 +87,21 @@
// SteamVR's resize snap back.
//
// Head follow (off by default; POINTER_FOLLOW=1, or the relay's "follow toggle"): the cursor
// is carried by a reference direction that follows where the head faces, and the cursor turns
// with it, keeping its offset (mouse movement changes the offset). The reference eases toward
// the head's facing with a time constant of POINTER_LEASH_RETURN (0.2 s), so small head
// movements barely move the cursor, and it never lags more than POINTER_LEASH_DEG (10) behind:
// a fast turn drags it along at the leash's end. Once the head settles, the reference is back
// on its facing, so the cursor is back where it was in the view (a leash that only moved at its
// end left the reference up to the leash off, and getting it back meant overshooting). At 0 the
// reference is the head's facing, so the cursor is head-locked. Head roll is ignored (the frames
// have no roll), so tilting the head doesn't swing the cursor. The cursor stays within
// POINTER_FOLLOW_REACH (70) of the reference. The ray starts at the eye. While the left
// is carried by a reference direction, where the head faced when it last settled, and turns
// with it, keeping its offset (mouse movement changes the offset, up to POINTER_FOLLOW_REACH,
// 70 deg, so the cursor can sit in a corner of the view). While the head stays within
// POINTER_LEASH_DEG (10) of the reference, nothing moves on its own: the cursor stays put in
// the room. Once the head has been past the leash for POINTER_LEASH_DELAY (0.2 s; a glance
// out and back doesn't count), the reference follows: it eases toward the head's facing with a
// time constant of POINTER_LEASH_RETURN (0.2 s), never falling further behind than the leash
// (or than it already was), until it lands on the facing, and the cursor is back where it was
// in the view. Then it waits for the leash again. Earlier tries: dragging the reference only at
// the leash's end left it up to the leash off after turning back (getting it centred took an
// overshoot), and easing it all the time moved the cursor on every small head movement. At 0
// the reference is the head's facing, so the cursor is head-locked. Head roll is ignored (the
// frames have no roll), so tilting the head doesn't swing the cursor. The ray origin (the
// anchor) moves to the eye with the reference, so leaning inside the leash doesn't move the
// cursor either. While the left
// button is held (and the drop hold after it), the leash still moves the reference but the
// cursor stays put in the room, so a click or a drag can't be nudged by the head; the offset
// is taken up from where the cursor is when the hold ends, so it doesn't jump.
@@ -134,8 +139,8 @@
// but never closer than POINTER_ORIGIN_MARGIN (0.15 m) to the cursor point: SteamVR's
// small controls (undock, frame buttons) float a few centimetres in front of their
// panel, and a laser that starts behind them can't hit them. POINTER_FOLLOW (0) and
// POINTER_LEASH_DEG (10), POINTER_LEASH_RETURN (0.2 s), POINTER_FOLLOW_REACH (70 deg): head
// follow, above.
// POINTER_LEASH_DEG (10), POINTER_LEASH_DELAY (0.2 s), POINTER_LEASH_RETURN (0.2 s),
// POINTER_FOLLOW_REACH (70 deg): head follow, above.
#include <openvr.h>
#include "vrmath.h"
@@ -330,7 +335,7 @@ constexpr double kPlaceDegPerSec = 60; // tested: 40 deg/s is applied exact
int main() {
double freeDistance = 1.5, cursorDeg = 0.4, originFraction = 0.95, originMargin = 0.15, sceneRadius = 0.5,
edgeReach = 0.3, grabOffset = 0.075,
slideSpeed = 0.5, leashDeg = 10, leashReturn = 0.2, followReach = 70;
slideSpeed = 0.5, leashDeg = 10, leashReturn = 0.2, leashDelay = 0.2, followReach = 70;
// Head follow (see the top). followConf is POINTER_FOLLOW as last read: a reload only
// overrides a "follow" command when the setting itself changed.
bool follow = false, followConf = false, followReset = true;
@@ -346,6 +351,7 @@ int main() {
slideSpeed = std::clamp(ConfDouble(conf, "LAYOUT_SLIDE_SPEED", 0.5), 0.02, 2.0);
leashDeg = std::clamp(ConfDouble(conf, "POINTER_LEASH_DEG", 10), 0.0, 90.0);
leashReturn = std::clamp(ConfDouble(conf, "POINTER_LEASH_RETURN", 0.2), 0.0, 5.0);
leashDelay = std::clamp(ConfDouble(conf, "POINTER_LEASH_DELAY", 0.2), 0.0, 5.0);
followReach = std::clamp(ConfDouble(conf, "POINTER_FOLLOW_REACH", 70), 10.0, 89.0);
const bool wantFollow = ConfDouble(conf, "POINTER_FOLLOW", 0) != 0;
if (wantFollow != followConf) follow = followConf = wantFollow, followReset = true;
@@ -434,6 +440,9 @@ int main() {
double yaw = 0, pitch = 0;
Vec3 followRef{0, 0, -1}; // head follow's reference direction (see the top)
auto followAt = std::chrono::steady_clock::now(); // its last update, for the easing
bool following = false; // past the leash: easing toward the head
double followLag = 0; // radians the reference trails the head
std::chrono::steady_clock::time_point leashOutSince{}; // head past the leash since (delay)
vr::TrackedDeviceIndex_t ours = vr::k_unTrackedDeviceIndexInvalid;
auto lastSlow = std::chrono::steady_clock::now() - std::chrono::seconds(10);
@@ -810,24 +819,43 @@ int main() {
followReset = true;
}
// Head follow (see the top): ease the reference toward the head's facing, never more than
// the leash behind, and turn the cursor with it.
// Head follow (see the top): past the leash (for the delay), ease the reference to the
// head's facing, and turn the cursor with it.
if (follow && active && anchored && hmd.bPoseIsValid) {
const Vec3 head = LimitPitch(Vec3{-hm[0][2], -hm[1][2], -hm[2][2]}, 85);
if (followReset) followRef = head, followReset = false;
if (followReset) {
followRef = head, followLag = 0, leashOutSince = {};
followReset = following = false;
}
const double dt = std::min(0.1, std::chrono::duration<double>(tnow - followAt).count());
double lag = std::acos(std::clamp(Dot(followRef, head), -1.0, 1.0)) *
(leashReturn > 0 ? std::exp(-dt / leashReturn) : 0.0);
if (lag < 0.05 * M_PI / 180) lag = 0; // settled: land exactly on the facing
const Vec3 ref =
LimitPitch(PullWithin(followRef, head, std::min(lag, leashDeg * M_PI / 180)), 85);
const double leash = leashDeg * M_PI / 180;
const double lag = std::acos(std::clamp(Dot(followRef, head), -1.0, 1.0));
double keep = lag; // how far the reference stays behind the head after this frame
if (leash <= 0) {
keep = 0; // head-locked
} else if (following) {
keep = lag * (leashReturn > 0 ? std::exp(-dt / leashReturn) : 0.0);
// A fast turn drags it at the leash's end; past it already (after the delay), it
// can't fall further behind, and it closes in from there without a jump.
keep = std::min(keep, std::max(leash, followLag));
if (keep < 0.05 * M_PI / 180) keep = 0, following = false; // landed on the facing
} else if (lag > leash) {
if (leashOutSince == decltype(leashOutSince){}) leashOutSince = tnow;
if (std::chrono::duration<double>(tnow - leashOutSince).count() >= leashDelay)
following = true, leashOutSince = {};
} else {
leashOutSince = {}; // back inside before the delay: a glance
}
followLag = keep;
const Vec3 ref = LimitPitch(PullWithin(followRef, head, keep), 85);
if (!leftHeld && tnow >= dropHoldUntil) {
// The cursor keeps its offset from the reference (frames without roll).
Vec3 d = FromBasis(AimBasis(ref), ToBasis(AimBasis(followRef), Direction(yaw, pitch)));
d = PullWithin(Normalize(d), ref, followReach * M_PI / 180);
yaw = std::atan2(-d.x, -d.z) * 180 / M_PI;
pitch = std::clamp(std::asin(std::clamp(d.y, -1.0, 1.0)) * 180 / M_PI, -85.0, 85.0);
anchor = eye;
// The ray origin closes in on the eye as the reference does on the facing.
anchor = eye + (anchor - eye) * (leash <= 0 ? 0.0 : lag > 1e-6 ? keep / lag : following ? 0.0 : 1.0);
}
followRef = ref;
}
+3 -2
View File
@@ -19,8 +19,9 @@ POINTER_ORIGIN_MARGIN=0.15 # the laser starts at least this far (m) in front o
POINTER_SCENE_RADIUS=0.5 # texture-less dashboard overlays (dock, window controls): hit radius (m) around their origin
POINTER_EDGE_REACH=0.3 # just off a panel, the cursor stays on its plane this far (m), to reach its resize edges and window controls
POINTER_FOLLOW=0 # 1 = the cursor follows your head on a leash (a mouse button mapped to Head follow on/off toggles it)
POINTER_LEASH_DEG=10 # head follow: the most (degrees) the cursor can lag behind your head; 0 locks it to your view
POINTER_LEASH_RETURN=0.2 # head follow: how quickly (s) the cursor settles back to its place in your view
POINTER_LEASH_DEG=10 # head follow: how far (degrees) your head can turn before the cursor comes along; 0 locks it to your view
POINTER_LEASH_DELAY=0.2 # head follow: how long (s) your head has to stay past the leash, so a glance doesn't move the cursor
POINTER_LEASH_RETURN=0.2 # head follow: once it comes along, how quickly (s) the cursor settles back to its place in your view
POINTER_FOLLOW_REACH=70 # head follow: how far (degrees) from the middle of your view the mouse can move the cursor
META_DASHBOARD=0 # 1 = a Meta tap on a pass-through keyboard toggles the SteamVR dashboard (pointer mode only)
SHARE_KEYS=0 # 1 = keys of keyboards grabbed for the desktop also go to @frametop_keys, for hotkey tools (any local process can listen)