Compare commits

...
12 Commits
Author SHA1 Message Date
DeeJanuzandClaude Opus 5.5 2e09cf9efe Merge PR #24 (SuperTuxii: the mute key toggles the default output's mute) into experimental
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:36:33 -06:00
SuperTuxii f18918f781 input-relay: Add mute key as volume key
Add KEY_MUTE as volume key. It will be mapped to KEY_MACRO28 and
use wpctl to toggle the mute of the default audio sink. The toggle of
the mute state will be done once when the key is pressed instead of
continuously toggling it when it is held down.
This allows the mute button on keyboards to work properly.

Signed-off-by: SuperTuxii <123881249+SuperTuxii@users.noreply.github.com>
2026-10-04 16:06:37 +02:00
DeeJanuzandClaude Opus 5.5 e0208687b6 Merge branch tip-in-games (controller laser tip found during VR games) into experimental
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:33:49 -06:00
DeeJanuzandClaude Opus 5.5 c699e13104 Screens: find the controllers' laser tip during VR games too
GetComponentStateForDevicePath with no input source handle fails for
every render model component while a VR game runs (checked 2026-10-03
with a game up: all 21 components of frame_controller_right). TipOffset
then fell back to the controller's pose, which aims 40 degrees above the
Frame controller's laser. In games, pointing at a screen's middle missed
it and pointing below it hit, so the new aim-to-laser only worked from
the bottom; the controls' reveal and pin/roll aim were off the same way.
GetComponentState still answers then, with the same tip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:33:49 -06:00
DeeJanuzandClaude Opus 5.5 9b12b7d74e Merge branch aim-lasers (in games, pointing a controller at a Frametop panel turns its laser on) into experimental
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:27:49 -06:00
DeeJanuzandClaude Opus 5.5 c7c7c1f772 Screens: in games, pointing a controller at a panel turns its laser on
SteamVR's own floating windows take the laser while a controller points
at them in a game and give it back when it points away. Frametop's
panels didn't: with the controllers left to the game (outside_games, the
default, or dashboard), they couldn't be clicked without the dashboard.

ft-screens now sets MakeOverlaysInteractiveIfVisible on a screen or
floating window while a hand controller's laser pose meets it, its
controls, or its popups (UpdateAim; curved screens hit on their
cylinder), and clears it 0.3 s after the aim leaves a wider margin. A
drag or a held button keeps it on. The keyboard, one overlay, uses
ComputeOverlayIntersection and now follows the mode when a game starts
or ends while it's open. This replaces the reset button's own aim zone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:27:49 -06:00
DeeJanuzandClaude Opus 5.5 2486a601e3 Merge branch click-threshold (controller click zone 32 px by default) into experimental
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:17:49 -06:00
DeeJanuzandClaude Opus 5.5 de25633f87 Click stability: 32 logical pixels by default, not 8
8 is about 0.2 degrees on a 3.4 m wide 3440-pixel screen 2 m away, so a
trigger press turned into a drag unless the hand was very still. 32
(about 0.9 degrees) felt much better in the headset (2026-10-03).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:17:49 -06:00
DeeJanuzandClaude Opus 5.5 53a1836dbb Merge branch reset-button (a reset button next to each screen's grab bar, clickable in VR games) into experimental
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:15:51 -06:00
Patrick McDavidandClaude Opus 5.5 1ebf3692fb ft-floatd: a launch for a missing app no longer kills the control socket (#16)
Gio.DesktopAppInfo.new() returns NULL for a desktop file that doesn't
exist, and PyGObject raises TypeError ("constructor returned NULL")
rather than returning None, so launch()'s `if info is None` never ran.
The exception escaped the control socket's GLib callback, GLib dropped
the watch, and ft-floatd stopped answering everything: the float key,
dock, Launch as Standalone, and profiles, until the desktop restarted.

Found on the Frame (2026-10-02): a profile saved with RustDesk's
Flatpak open records its window's app id, com.carriez.flutter_hbb,
which has no desktop file (the Flatpak's is com.rustdesk.RustDesk).
`ft-layout use` on that profile asked ft-floatd to launch it, and
ft-floatd went silent. With this, that launch replies "error no app
com.carriez.flutter_hbb" and the profile's other apps open.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:12:49 -06:00
DeeJanuzandClaude Opus 5.5 072a294941 README: Frametop doesn't work on the SteamOS beta yet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:12:52 -06:00
DeeJanuzandClaude Opus 5.5 7816633353 README: link the Frametop Discord
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:18:09 -06:00
8 changed files with 138 additions and 52 deletions

No files matched your search

+4 -4
View File
@@ -1,6 +1,6 @@
# Controller desktop click stability # Controller desktop click stability
Trigger presses reach KDE immediately, but controller motion within 8 logical Trigger presses reach KDE immediately, but controller motion within 32 logical
pixels of the press stays at that position until release. Releasing without a motion outside this pixels of the press stays at that position until release. Releasing without a motion outside this
zone delivers the click at the original position, even if the hand moved during zone delivers the click at the original position, even if the hand moved during
release. Moving outside the zone begins a normal drag immediately; returning to release. Moving outside the zone begins a normal drag immediately; returning to
@@ -13,11 +13,11 @@ floating-app title-bar carrying are unaffected. Multi-button gestures keep their
existing behavior. A motion onto another desktop monitor starts a drag; existing behavior. A motion onto another desktop monitor starts a drag;
cross-monitor motion is not stabilized. cross-monitor motion is not stabilized.
CLI (runtime preferences, reset to 8 on desktop restart): CLI (runtime preferences, reset to 32 on desktop restart):
```sh ```sh
input/ft-clickctl status input/ft-clickctl status
input/ft-clickctl threshold 8 input/ft-clickctl threshold 32
input/ft-clickctl threshold 0 # disable without a restart input/ft-clickctl threshold 0 # disable without a restart
``` ```
@@ -29,6 +29,6 @@ This is a separate contribution from desktop mouse/controller ownership. Its
hardware validation must check small controls, intentional text selection, hardware validation must check small controls, intentional text selection,
long presses, cross-monitor dragging and simultaneous mouse use. The existing long presses, cross-monitor dragging and simultaneous mouse use. The existing
renderer laser remains tracked; this change stabilizes desktop input rather renderer laser remains tracked; this change stabilizes desktop input rather
than smoothing the visual laser. Default threshold is a starting point to test. than smoothing the visual laser. The default was 8 at first. That's about 0.2° on a 3.4 m wide 3440-pixel screen 2 m away, and clicking took a very still hand, so it's 32 (about 0.9°) since 2026-10-03.
Run `scripts/test-controller-click.sh` for the isolated gesture-state tests. Run `scripts/test-controller-click.sh` for the isolated gesture-state tests.
+2 -2
View File
@@ -38,7 +38,7 @@ The curved layout chains screens edge to edge, like monitors on a desk: the midd
A resize handle has to be able to shrink a screen from any direction, so the dragged corner follows the laser along the screen's diagonal rather than taking the larger of its horizontal and vertical reach. Pushing and pulling a carried screen moves it along the line from your head, because the 3D mouse's virtual controller sits just in front of the bar, below the screen's centre, so the line from the device points mostly upward. A resize handle has to be able to shrink a screen from any direction, so the dragged corner follows the laser along the screen's diagonal rather than taking the larger of its horizontal and vertical reach. Pushing and pulling a carried screen moves it along the line from your head, because the 3D mouse's virtual controller sits just in front of the bar, below the screen's centre, so the line from the device points mostly upward.
Wherever ft-screens needs to know where a laser points (showing the controls, the resize tab, the roll knob), it uses the laser's own pose, the render model's `tip` component, rather than the controller's pose. On the Frame's controllers the tip points 40° below the pose's forward axis, so rays from the pose missed what the laser was actually on. The 3D mouse's virtual controller has no tip, and its laser runs along its pose. Wherever ft-screens needs to know where a laser points (showing the controls, the resize tab, the roll knob), it uses the laser's own pose, the render model's `tip` component, rather than the controller's pose. On the Frame's controllers the tip points 40° below the pose's forward axis, so rays from the pose missed what the laser was actually on. The 3D mouse's virtual controller has no tip, and its laser runs along its pose. ft-screens reads the tip with `GetComponentState`: `GetComponentStateForDevicePath` without an input source handle fails for every component while a VR game runs, so in games the rays came from the pose, 40° too high.
`ComputeOverlayIntersection` ignores `SetOverlayIntersectionMask`, and a control can't be allowed to cover part of its screen, so the resize tab sits entirely outside the corner. `ComputeOverlayIntersection` ignores `SetOverlayIntersectionMask`, and a control can't be allowed to cover part of its screen, so the resize tab sits entirely outside the corner.
@@ -62,7 +62,7 @@ A profile's screen part is the custom arrangement under a name: each screen's po
`IVRApplications::GetCurrentSceneProcessId()` is 0 when no game is running (the Frame's home environment isn't a scene app) and the game's process ID while one is. ft-screens checks it twice a second, turns the flag off while a game runs, and by default hides the screens unless the dashboard is open. Flatscreen games run inside Steam's gamescope overlay and aren't scene apps, which is why "only with the dashboard open" is offered as a controller setting. `IVRApplications::GetCurrentSceneProcessId()` is 0 when no game is running (the Frame's home environment isn't a scene app) and the game's process ID while one is. ft-screens checks it twice a second, turns the flag off while a game runs, and by default hides the screens unless the dashboard is open. Flatscreen games run inside Steam's gamescope overlay and aren't scene apps, which is why "only with the dashboard open" is offered as a controller setting.
The reset button needs to work in a game, where the screens have the flag off. So ft-screens turns the flag on for that button's overlay alone while a hand controller aims within about one button's width of it, and off half a second after the aim leaves a zone twice as wide. ft-screens finds the aim from the controllers' laser poses, which it reads anyway to show the controls, so it doesn't need SteamVR's laser to be on first. The game loses the controllers only while you aim at the button. In a game, Frametop's panels work like SteamVR's own floating windows: point a controller at one and its laser comes on, point away and the game has the controllers again. ft-screens turns the flag on for a panel while a hand controller's laser pose meets it, its controls, or a floating window's popups. It finds that from the poses it already reads to show the controls, so SteamVR's laser doesn't have to be on first. Leaving takes a margin two control-sizes wide and 0.3 s, a drag or a held button keeps the flag on, and the keyboard, a single overlay, uses SteamVR's `ComputeOverlayIntersection`. The 3D mouse doesn't need any of this: it has its own laser mode.
## Floating windows ## Floating windows
+2 -2
View File
@@ -44,7 +44,7 @@ Every screen is an overlay named `frametop.screen.N` with five controls:
- `.curve` bends the screen into a cylinder around you, using your current distance as the radius, or makes it flat again. - `.curve` bends the screen into a cylinder around you, using your current distance as the radius, or makes it flat again.
- `.roll` rolls the screen when you drag it sideways, like a knob. It snaps level within 2.5°, and scrolling on it turns 5° per notch. - `.roll` rolls the screen when you drag it sideways, like a knob. It snaps level within 2.5°, and scrolling on it turns 5° per notch.
- `.resize`, the tab on the bottom right corner, sets the width. Screens go down to 15 cm wide. - `.resize`, the tab on the bottom right corner, sets the width. Screens go down to 15 cm wide.
- `.reset`, left of the bar, puts every screen back in its layout around where you are now, like Meta+Shift+R (`ft-layout apply`). In a VR game, where the screens leave the controllers to the game, aiming a controller at it turns SteamVR's laser on for that button alone, so the trigger clicks it; the game gets the controllers back half a second after you aim away. - `.reset`, left of the bar, puts every screen back in its layout around where you are now, like Meta+Shift+R (`ft-layout apply`).
The controls are sized from both the screen's width and its distance from you, follow the surface of a curved screen, and stay invisible until a laser or the 3D mouse's cursor lands on one or comes within about 1.5 times a button's size of it. While invisible they're still there, fully transparent, so SteamVR's laser can find them. They're translucent until a laser is on them, like SteamVR's own window controls. The controls are sized from both the screen's width and its distance from you, follow the surface of a curved screen, and stay invisible until a laser or the 3D mouse's cursor lands on one or comes within about 1.5 times a button's size of it. While invisible they're still there, fully transparent, so SteamVR's laser can find them. They're translucent until a laser is on them, like SteamVR's own window controls.
@@ -62,7 +62,7 @@ The Visibility & pins tab of Frametop Display Settings decides when the screens
In the last three modes the hotkey shows the screens anyway. A screen can also be hidden on its own (Screens shown on the same tab, or `ft-layout hide N`): it stays hidden whatever the mode or the hotkey says, until it's shown again there. Windows on it stay put, and a new window that would open on it floats instead (ft-floatd). Profiles use this to show only some screens. Two more settings on the same tab cover VR games, which ft-screens detects as SteamVR scene apps: In the last three modes the hotkey shows the screens anyway. A screen can also be hidden on its own (Screens shown on the same tab, or `ft-layout hide N`): it stays hidden whatever the mode or the hotkey says, until it's shown again there. Windows on it stay put, and a new window that would open on it floats instead (ft-floatd). Profiles use this to show only some screens. Two more settings on the same tab cover VR games, which ft-screens detects as SteamVR scene apps:
- During VR games, the Always mode hides the screens unless the dashboard is open (the default), or leaves them up. - During VR games, the Always mode hides the screens unless the dashboard is open (the default), or leaves them up.
- Controllers on the screens. Visible screens can keep SteamVR's laser mouse on, so controllers work them with the dashboard closed, but that also takes the controllers away from a game. By default this is off while a VR game runs, and the 3D mouse or the dashboard works the screens. The other choices are always on, or only with the dashboard open, which also suits flatscreen games since they aren't scene apps. - Controllers on the screens. Visible screens can keep SteamVR's laser mouse on, so controllers work them with the dashboard closed, but that also takes the controllers away from a game. By default this is off while a VR game runs, and the 3D mouse or the dashboard works the screens. Pointing a controller at a screen, a floating window, or the keyboard still turns its laser on, like SteamVR's own floating windows, and pointing away gives the game the controllers back. The other choices are always on, or only with the dashboard open, which also suits flatscreen games since they aren't scene apps.
Input from the lasers reaches KWin through ft-screens' own seat. Keys come from the input relay, from pass-through keyboards and any key a pointer device passes through. Typing follows your last click: after a click on a screen it goes to the desktop, even with the SteamVR dashboard open, and after a mouse click on any other panel (the dashboard, Steam, an app like Spotify) it goes there instead. While it goes to the desktop, the relay grabs pass-through keyboards so gamescope, which reads every keyboard itself, doesn't type them into the Steam app too. A program that watches every keyboard for a hotkey loses a grabbed one; with `SHARE_KEYS=1` in `~/.config/frametop.conf`, their keys also go to `@frametop_keys` for it. That's off by default, since any local process that binds the name first would get everything typed into the desktop. Hidden screens don't take typing. Input from the lasers reaches KWin through ft-screens' own seat. Keys come from the input relay, from pass-through keyboards and any key a pointer device passes through. Typing follows your last click: after a click on a screen it goes to the desktop, even with the SteamVR dashboard open, and after a mouse click on any other panel (the dashboard, Steam, an app like Spotify) it goes there instead. While it goes to the desktop, the relay grabs pass-through keyboards so gamescope, which reads every keyboard itself, doesn't type them into the Steam app too. A program that watches every keyboard for a hotkey loses a grabbed one; with `SHARE_KEYS=1` in `~/.config/frametop.conf`, their keys also go to `@frametop_keys` for it. That's off by default, since any local process that binds the name first would get everything typed into the desktop. Hidden screens don't take typing.
+11 -7
View File
@@ -152,10 +152,10 @@ REL_X, REL_Y, REL_WHEEL, REL_MAX = 0x00, 0x01, 0x08, 0x0F
SCROLLS = {0x06, REL_WHEEL, 0x0B, 0x0C} # REL_HWHEEL, REL_WHEEL and their _HI_RES SCROLLS = {0x06, REL_WHEEL, 0x0B, 0x0C} # REL_HWHEEL, REL_WHEEL and their _HI_RES
BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA = 0x110, 0x111, 0x112, 0x113, 0x114 BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA = 0x110, 0x111, 0x112, 0x113, 0x114
KEY_LEFTMETA, KEY_RIGHTMETA = 125, 126 KEY_LEFTMETA, KEY_RIGHTMETA = 125, 126
KEY_VOLUMEDOWN, KEY_VOLUMEUP = 114, 115 KEY_MUTE, KEY_VOLUMEDOWN, KEY_VOLUMEUP = 113, 114, 115
# Volume keys are remapped to KEY_MACRO29 and KEY_MACRO30: above 255, so X11 can't # Volume keys are remapped to KEY_MACRO28, KEY_MACRO29 and KEY_MACRO30: above 255, so X11
# carry them, and bound to nothing in the default keymap. # can't carry them, and bound to nothing in the default keymap.
VOLUME_STANDIN = {KEY_VOLUMEUP: 0x2AC, KEY_VOLUMEDOWN: 0x2AD} VOLUME_STANDIN = {KEY_MUTE: 0x2AB, KEY_VOLUMEUP: 0x2AC, KEY_VOLUMEDOWN: 0x2AD}
VOLUME_ORIGINAL = {v: k for k, v in VOLUME_STANDIN.items()} VOLUME_ORIGINAL = {v: k for k, v in VOLUME_STANDIN.items()}
VOLUME_CODES = set(VOLUME_STANDIN) | set(VOLUME_ORIGINAL) VOLUME_CODES = set(VOLUME_STANDIN) | set(VOLUME_ORIGINAL)
BUS_USB, BUS_BLUETOOTH, BUS_VIRTUAL = 0x03, 0x05, 0x06 BUS_USB, BUS_BLUETOOTH, BUS_VIRTUAL = 0x03, 0x05, 0x06
@@ -422,9 +422,13 @@ class Volume:
def key(self, fd, code, value, now): def key(self, fd, code, value, now):
if value == 1: if value == 1:
self.held = (fd, code) if VOLUME_ORIGINAL.get(code, code) == KEY_MUTE:
self.step(code) subprocess.Popen(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"],
self.next_at = now + self.DELAY stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
self.held = (fd, code)
self.step(code)
self.next_at = now + self.DELAY
elif value == 0 and self.held == (fd, code): elif value == 0 and self.held == (fd, code):
self.release() self.release()
+3 -1
View File
@@ -795,7 +795,9 @@ static bool setup_dmabuf(struct server *s) {
int main(int argc, char **argv) { int main(int argc, char **argv) {
struct server s = {0}; struct server s = {0};
s.controller_click.threshold = 8; // About 0.9 degrees on a 3.4 m wide 3440-pixel screen 2 m away; 8 (the first default,
// about 0.2 degrees) needed a very still hand to click (headset test 2026-10-03).
s.controller_click.threshold = 32;
for (int i = 0; i < MAX_SCREENS; ++i) s.scale[i] = 1; for (int i = 0; i < MAX_SCREENS; ++i) s.scale[i] = 1;
s.kb_screen = -1; s.kb_screen = -1;
s.rate[FT_FOCUSED] = 0, s.rate[FT_IN_VIEW] = 15, s.rate[FT_HIDDEN] = 1; s.rate[FT_FOCUSED] = 0, s.rate[FT_IN_VIEW] = 15, s.rate[FT_HIDDEN] = 1;
+15 -1
View File
@@ -459,7 +459,21 @@ void Hide() {
bool Shown() { return g_shown; } bool Shown() { return g_shown; }
void SetLasers(bool on) { void SetLasers(bool on) {
if (Create()) vr::VROverlay()->SetOverlayFlag(g_overlay, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, on); static vr::VROverlayHandle_t set = vr::k_ulOverlayHandleInvalid; // the overlay `was` is for
static bool was = false;
if (!Create() || (set == g_overlay && was == on)) return;
set = g_overlay, was = on;
vr::VROverlay()->SetOverlayFlag(g_overlay, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, on);
}
bool Aimed(const vr::HmdMatrix34_t &laser) {
if (!g_shown) return false;
vr::VROverlayIntersectionParams_t in{};
in.eOrigin = vr::TrackingUniverseStanding;
in.vSource = {laser.m[0][3], laser.m[1][3], laser.m[2][3]};
in.vDirection = {-laser.m[0][2], -laser.m[1][2], -laser.m[2][2]};
vr::VROverlayIntersectionResults_t out{};
return vr::VROverlay()->ComputeOverlayIntersection(g_overlay, &in, &out);
} }
void Poll(void (*handle)(const Event &, void *), void *data) { void Poll(void (*handle)(const Event &, void *), void *data) {
+2
View File
@@ -26,6 +26,8 @@ const vr::HmdMatrix34_t &Pose();
void EndDragBy(uint32_t device); void EndDragBy(uint32_t device);
// Controllers' lasers work the panel with the dashboard closed, like the screens'. // Controllers' lasers work the panel with the dashboard closed, like the screens'.
void SetLasers(bool on); void SetLasers(bool on);
// A laser (its pose, aiming along -z) points at the panel.
bool Aimed(const vr::HmdMatrix34_t &laser);
// The panel's input: key presses and releases, and Closed for its Close key. // The panel's input: key presses and releases, and Closed for its Close key.
void Poll(void (*handle)(const Event &, void *), void *data); void Poll(void (*handle)(const Event &, void *), void *data);
void Destroy(); void Destroy();
+99 -35
View File
@@ -12,9 +12,7 @@
// - a resize tab on the bottom right corner: drag it to set the width (the height // - a resize tab on the bottom right corner: drag it to set the width (the height
// follows the screen's resolution). // follows the screen's resolution).
// - a reset button left of the bar: every screen back in its layout, around where you // - a reset button left of the bar: every screen back in its layout, around where you
// are now (`ft-layout apply`, like Meta+Shift+R). Where the screens leave the // are now (`ft-layout apply`, like Meta+Shift+R).
// controllers to a VR game, aiming a controller at it turns SteamVR's laser mouse on for
// that button alone (UpdateResetLaser), so it can be clicked in a game.
// The controls are translucent, like SteamVR's own, and brighten under a laser. They // The controls are translucent, like SteamVR's own, and brighten under a laser. They
// are invisible until a laser (a controller's, or the 3D mouse's) lands on or passes very close to // are invisible until a laser (a controller's, or the 3D mouse's) lands on or passes very close to
// one of them (UpdateControls). // one of them (UpdateControls).
@@ -44,7 +42,10 @@
// default it's off while a game (a scene app) runs: the screens stay up over the game, // default it's off while a game (a scene app) runs: the screens stay up over the game,
// the controllers stay in it, and the 3D mouse (its own laser mode) or the dashboard // the controllers stay in it, and the 3D mouse (its own laser mode) or the dashboard
// works the screens. Modes: always, outside_games (default), dashboard (never on its // works the screens. Modes: always, outside_games (default), dashboard (never on its
// own; also for flatscreen games, which aren't scene apps). // own; also for flatscreen games, which aren't scene apps). Where the mode leaves the
// controllers to the game, pointing a controller at a panel (a screen, a floating window
// and its popups, their controls, the keyboard) turns the laser on for it until you
// point away, like SteamVR's own floating windows (UpdateAim).
// - during a VR game the screens hide unless the dashboard is open (g_inGames, default), // - during a VR game the screens hide unless the dashboard is open (g_inGames, default),
// or stay visible over it; the hotkey still shows them. // or stay visible over it; the hotkey still shows them.
// - paused ("pause on", from the input relay when Frametop pauses for a VR game, // - paused ("pause on", from the input relay when Frametop pauses for a VR game,
@@ -189,8 +190,12 @@ Mat TipOffset(vr::TrackedDeviceIndex_t dev) {
Tip tip{model, Identity(), false, now}; Tip tip{model, Identity(), false, now};
vr::RenderModel_ControllerMode_State_t mode{}; vr::RenderModel_ControllerMode_State_t mode{};
vr::RenderModel_ComponentState_t state{}; vr::RenderModel_ComponentState_t state{};
if (model[0] && vr::VRRenderModels()->GetComponentStateForDevicePath(model, vr::k_pch_Controller_Component_Tip, // GetComponentState, not GetComponentStateForDevicePath: without an input source handle
vr::k_ulInvalidInputValueHandle, &mode, &state)) // the latter fails for every component while a VR game runs, and the rays came from the
// pose, 40 degrees above the laser. The tip doesn't move with the buttons.
vr::VRControllerState_t buttons{};
if (model[0] && vr::VRRenderModels()->GetComponentState(model, vr::k_pch_Controller_Component_Tip, &buttons, &mode,
&state))
tip.offset = state.mTrackingToComponentLocal, tip.found = true; tip.offset = state.mTrackingToComponentLocal, tip.found = true;
cache[dev] = tip; cache[dev] = tip;
return tip.offset; return tip.offset;
@@ -237,7 +242,7 @@ constexpr double kRollSnap = 2.5; // degrees from level where rolling snaps l
constexpr double kRollStep = 5; // degrees per scroll notch on the roll button constexpr double kRollStep = 5; // degrees per scroll notch on the roll button
constexpr float kChromeIdle = 0.55f; // the controls' opacity without a laser on them constexpr float kChromeIdle = 0.55f; // the controls' opacity without a laser on them
constexpr long kControlsLinger = 35; // ticks (~0.4 s) the controls stay after a laser leaves constexpr long kControlsLinger = 35; // ticks (~0.4 s) the controls stay after a laser leaves
constexpr long kResetLinger = 45; // ticks (~0.5 s) the reset button keeps the laser mouse on constexpr long kAimLinger = 25; // ticks (~0.3 s) a panel keeps the laser on after the aim leaves it
long g_tick = 0; // ft_vr_poll calls long g_tick = 0; // ft_vr_poll calls
bool g_vr = false; // connected to SteamVR (ft-screens --no-vr runs without it) bool g_vr = false; // connected to SteamVR (ft-screens --no-vr runs without it)
constexpr vr::TrackedDeviceIndex_t kNone = vr::k_unTrackedDeviceIndexInvalid; constexpr vr::TrackedDeviceIndex_t kNone = vr::k_unTrackedDeviceIndexInvalid;
@@ -275,8 +280,7 @@ struct Screen {
float controls = 0; // the controls' fade, 0 (hidden) .. 1 float controls = 0; // the controls' fade, 0 (hidden) .. 1
bool controlsUp = false; // the controls' overlays are shown bool controlsUp = false; // the controls' overlays are shown
long nearUntil = 0; // a laser was near the controls until this tick long nearUntil = 0; // a laser was near the controls until this tick
long resetNearUntil = 0; // a hand controller aimed at the reset button until this tick long aimUntil = 0; // a hand controller pointed at it until this tick (UpdateAim)
bool resetLaser = false; // the reset button has MakeOverlaysInteractiveIfVisible
vr::TrackedDeviceIndex_t pinTarget = kNone; // moving: rides on this controller when let go vr::TrackedDeviceIndex_t pinTarget = kNone; // moving: rides on this controller when let go
vr::TrackedDeviceIndex_t onWrist = kNone; // moving: the laser is in this controller's ring vr::TrackedDeviceIndex_t onWrist = kNone; // moving: the laser is in this controller's ring
bool barLit = false; bool barLit = false;
@@ -910,26 +914,20 @@ void UpdateAttention() {
// Controllers' lasers on the screens (see the top): the flag follows the mode and whether a // Controllers' lasers on the screens (see the top): the flag follows the mode and whether a
// VR game runs. // VR game runs, and where the mode leaves the controllers to the game, whether one points at
// the panel (UpdateAim).
bool LasersByMode() { return g_lasers == Lasers::Always || (g_lasers == Lasers::OutsideGames && !g_gameRunning); }
long g_keyboardAimUntil = 0;
void UpdateLasers() { void UpdateLasers() {
const bool want = g_lasers == Lasers::Always || (g_lasers == Lasers::OutsideGames && !g_gameRunning); const bool byMode = LasersByMode();
for (auto &[i, s] : g_screens) { for (auto &[i, s] : g_screens) {
const bool want = byMode || (s.visible && g_tick < s.aimUntil);
if (s.lasers == want) continue; if (s.lasers == want) continue;
s.lasers = want; s.lasers = want;
vr::VROverlay()->SetOverlayFlag(s.overlay, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, want); vr::VROverlay()->SetOverlayFlag(s.overlay, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, want);
} }
} if (keyboard::Shown()) keyboard::SetLasers(byMode || g_tick < g_keyboardAimUntil);
// The reset button in a VR game (or the dashboard mode), where the screens don't keep
// SteamVR's laser mouse on: aiming a hand controller at it turns the laser mouse on for that
// button alone, so the trigger clicks it, and the game gets the controllers back
// kResetLinger ticks after the aim leaves it (a wider zone than the one that turns it on).
void UpdateResetLaser(Screen &s) {
const bool want = s.resetButton != vr::k_ulOverlayHandleInvalid && s.visible && !s.lasers &&
g_tick < s.resetNearUntil;
if (want == s.resetLaser) return;
s.resetLaser = want;
vr::VROverlay()->SetOverlayFlag(s.resetButton, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, want);
} }
// The distance from a laser's line to a point ahead of it, or -1 when it's behind. // The distance from a laser's line to a point ahead of it, or -1 when it's behind.
@@ -947,11 +945,10 @@ double RayDistance(const Mat &d, const Mat &c) {
// kControlsLinger ticks after it leaves, and while in use. // kControlsLinger ticks after it leaves, and while in use.
void UpdateControls() { void UpdateControls() {
std::vector<Mat> lasers; std::vector<Mat> lasers;
std::vector<bool> hands; // the laser is a hand controller's (not the 3D mouse's)
for (vr::TrackedDeviceIndex_t i = 1; i < vr::k_unMaxTrackedDeviceCount; ++i) { for (vr::TrackedDeviceIndex_t i = 1; i < vr::k_unMaxTrackedDeviceCount; ++i) {
Mat d; Mat d;
if (vr::VRSystem()->GetTrackedDeviceClass(i) == vr::TrackedDeviceClass_Controller && LaserPose(i, &d)) if (vr::VRSystem()->GetTrackedDeviceClass(i) == vr::TrackedDeviceClass_Controller && LaserPose(i, &d))
lasers.push_back(d), hands.push_back(IsHandController(i)); lasers.push_back(d);
} }
for (auto &[i, s] : g_screens) { for (auto &[i, s] : g_screens) {
Mat p; Mat p;
@@ -977,16 +974,7 @@ void UpdateControls() {
break; break;
} }
} }
if (s.resetButton != vr::k_ulOverlayHandleInvalid && !s.lasers) {
const Mat c = Mul(p, offsets[6]);
const double aim = s.grip * (s.resetLaser ? 2.0 : 0.9);
for (size_t k = 0; k < lasers.size(); ++k) {
const double r = RayDistance(lasers[k], c);
if (hands[k] && r >= 0 && r <= aim) s.resetNearUntil = g_tick + kResetLinger;
}
}
} }
UpdateResetLaser(s);
const bool inUse = s.drag != Drag::None || std::any_of(std::begin(s.hover), std::end(s.hover), [](bool h) { return h; }); const bool inUse = s.drag != Drag::None || std::any_of(std::begin(s.hover), std::end(s.hover), [](bool h) { return h; });
const bool want = s.visible && (inUse || g_tick < s.nearUntil); const bool want = s.visible && (inUse || g_tick < s.nearUntil);
// The controls stay shown while their screen is, just fully transparent when not // The controls stay shown while their screen is, just fully transparent when not
@@ -1514,6 +1502,81 @@ void ReleaseAwayBy(vr::TrackedDeviceIndex_t dev, uint32_t vrButton, void (*handl
if (g_press.buttons && dev == g_press.device) ReleaseAway(LinuxButton(vrButton), handle, data); if (g_press.buttons && dev == g_press.device) ReleaseAway(LinuxButton(vrButton), handle, data);
} }
// Where a laser meets a panel's surface, in the panel's u (metres along it from the centre,
// along the arc when curved) and v (up). OpenVR curves a screen into a cylinder toward its
// front, centred `curve` metres in front of it (see OnSurface).
bool RayOnSurface(const Screen &s, const Mat &p, const Mat &laser, double *u, double *v) {
const Mat inv = Inverse(p);
const double o[3] = {inv.m[0][0] * laser.m[0][3] + inv.m[0][1] * laser.m[1][3] + inv.m[0][2] * laser.m[2][3] + inv.m[0][3],
inv.m[1][0] * laser.m[0][3] + inv.m[1][1] * laser.m[1][3] + inv.m[1][2] * laser.m[2][3] + inv.m[1][3],
inv.m[2][0] * laser.m[0][3] + inv.m[2][1] * laser.m[1][3] + inv.m[2][2] * laser.m[2][3] + inv.m[2][3]};
double d[3];
for (int i = 0; i < 3; ++i) d[i] = -(inv.m[i][0] * laser.m[0][2] + inv.m[i][1] * laser.m[1][2] + inv.m[i][2] * laser.m[2][2]);
if (s.curve <= 0) {
if (std::fabs(d[2]) < 1e-6) return false;
const double t = -o[2] / d[2];
if (t <= 0) return false;
*u = o[0] + d[0] * t, *v = o[1] + d[1] * t;
return true;
}
// x^2 + (z - r)^2 = r^2, on the screen's side of the axis (z < r).
const double r = s.curve, oz = o[2] - r;
const double a = d[0] * d[0] + d[2] * d[2], b = 2 * (o[0] * d[0] + oz * d[2]), c = o[0] * o[0] + oz * oz - r * r;
const double disc = b * b - 4 * a * c;
if (a < 1e-9 || disc < 0) return false;
for (double t : {(-b - std::sqrt(disc)) / (2 * a), (-b + std::sqrt(disc)) / (2 * a)}) {
const double x = o[0] + d[0] * t, z = oz + d[2] * t;
if (t <= 0 || z >= 0) continue;
*u = r * std::atan2(x, -z), *v = o[1] + d[1] * t;
return true;
}
return false;
}
// Where the mode leaves the controllers to a VR game (see the top): a hand controller
// pointing at a panel, its controls, or a floating window's popups keeps that panel's laser
// on (UpdateLasers) until kAimLinger ticks after it points away, like SteamVR's own floating
// windows. Leaving takes a wider margin than arriving, and a drag or a held button keeps it
// on. The keyboard is one overlay, so SteamVR's own intersection test does there.
void UpdateAim() {
if (LasersByMode()) return;
std::vector<Mat> lasers;
for (vr::TrackedDeviceIndex_t i = 1; i < vr::k_unMaxTrackedDeviceCount; ++i) {
Mat d;
if (IsHandController(i) && LaserPose(i, &d)) lasers.push_back(d);
}
for (auto &[index, s] : g_screens) {
Mat p;
if (!s.visible || !ScreenPose(s, &p)) continue;
if (s.drag != Drag::None || (g_press.buttons && g_press.screen == index)) {
s.aimUntil = g_tick + kAimLinger;
continue;
}
const double m = s.grip * (g_tick < s.aimUntil ? 2.0 : 0.25), h = s.heightMetres();
// The panel and its controls: the bar row under it, the resize tab off its corner.
const double halfW = std::max(s.metres / 2 + s.grip, s.chrome / 2 + s.chrome * 0.12 + s.grip * 2) + m;
const double top = h / 2 + m, bottom = std::min(BarY(s) - s.grip, -(h / 2 + s.grip)) - m;
for (const Mat &l : lasers) {
double u, v;
if (!RayOnSurface(s, p, l, &u, &v)) continue;
bool on = std::fabs(u) <= halfW && v <= top && v >= bottom;
for (const auto &[k, sub] : s.subs) {
if (on || s.cropW <= 0) break;
const double su = (sub.x + sub.w / 2.0 - (s.cropX + s.cropW / 2.0)) * s.mpp;
const double sv = -(sub.y + sub.h / 2.0 - (s.cropY + s.cropH / 2.0)) * s.mpp;
on = std::fabs(u - su) <= sub.w * s.mpp / 2 + m && std::fabs(v - sv) <= sub.h * s.mpp / 2 + m;
}
if (on) {
s.aimUntil = g_tick + kAimLinger;
break;
}
}
}
if (keyboard::Shown())
for (const Mat &l : lasers)
if (keyboard::Aimed(l)) g_keyboardAimUntil = g_tick + kAimLinger;
}
const char *LasersName() { const char *LasersName() {
switch (g_lasers) { switch (g_lasers) {
case Lasers::Always: return "always"; case Lasers::Always: return "always";
@@ -2075,6 +2138,7 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) {
UpdateArrange(); UpdateArrange();
UpdateVisibility(); UpdateVisibility();
UpdateAttention(); UpdateAttention();
UpdateAim();
UpdateLasers(); UpdateLasers();
UpdateControls(); UpdateControls();
UpdateGuides(); UpdateGuides();
@@ -2101,7 +2165,7 @@ bool ft_vr_keyboard_show(int index) {
const double fx = -head.m[0][2], fz = -head.m[2][2], n = std::sqrt(fx * fx + fz * fz) + 1e-9; const double fx = -head.m[0][2], fz = -head.m[2][2], n = std::sqrt(fx * fx + fz * fz) + 1e-9;
const double at[3] = {head.m[0][3] + fx / n * kKeyboardAhead, head.m[1][3] - kKeyboardBelow, const double at[3] = {head.m[0][3] + fx / n * kKeyboardAhead, head.m[1][3] - kKeyboardBelow,
head.m[2][3] + fz / n * kKeyboardAhead}; head.m[2][3] + fz / n * kKeyboardAhead};
keyboard::SetLasers(g_lasers == Lasers::Always || (g_lasers == Lasers::OutsideGames && !g_gameRunning)); keyboard::SetLasers(LasersByMode());
g_steamInFront = SteamInFront(); g_steamInFront = SteamInFront();
if (g_steamInFront) { if (g_steamInFront) {
g_asidePose = FacingPose(at, head); g_asidePose = FacingPose(at, head);