Add opt-in desktop mouse and controller ownership handoff

This commit is contained in:
Codex committed 2026-10-01 23:09:50 -05:00
1 parent 8afd42798f
commit 023287ef60
7 files changed
+220 -30

No files matched your search

+17
View File
@@ -102,3 +102,20 @@ with `input/ft-mousectl desktop --save`; no driver bindings or SteamVR defaults
are changed by installation. The new native mode suppresses virtual gaze, hand
gesture and gaze-click input while selected. Switch back to spatial mode to
use those features. Ordinary VR keyboard events remain available.
## Experimental desktop handoff
`input/ft-mousectl policy last-active` enables shared desktop input. Controller
clicks and nonzero scrolls inside desktop monitors take ownership; aiming alone
does not. Native mouse motion, clicks and scrolling reclaim ownership. Same
millisecond claims favor the mouse. Held buttons keep ownership until released;
other-device events are discarded rather than replayed after the drag. SteamVR
controls and separate monitor grab/placement bars do not participate.
`input/ft-mousectl policy mouse` restores the original mouse-first behavior
(including configured mouse-absent fallback). `policy pointer` reserves desktop
input for the controller. Changes refuse while either device holds a desktop
button. `status` reports policy, owner, held buttons and ignored mouse events.
These choices are runtime-only and reset to `mouse` when the desktop restarts.
The original PR checkpoint is retained on `checkpoint/desktop-input-before-handoff`;
this experiment is developed separately on `feat/desktop-input-handoff`.
+9 -1
View File
@@ -39,7 +39,8 @@ def query(address, command):
def status():
result = {'relay': query(RELAY, 'mouse-mode?'), 'desktop': query(SCREENS, 'mouse?'),
'pointer': query(HELPER, 'pointerstatus'),
'controllerFallback': query(SCREENS, 'mouse-presence?')}
'controllerFallback': query(SCREENS, 'mouse-presence?'),
'desktopPolicy': query(SCREENS, 'desktop-policy?')}
result['ready'] = bool(result['relay'].get('supported') and result['desktop'].get('supported')
and 'desktopInput' in result['pointer'])
result['active'] = bool(result['ready'] and result['relay'].get('mode') == 'desktop'
@@ -124,6 +125,8 @@ def main():
sub = parser.add_subparsers(dest='command', required=True)
sub.add_parser('status')
sub.add_parser('layout')
policy = sub.add_parser('policy', help='Choose desktop mouse/pointer ownership (runtime only)')
policy.add_argument('mode', choices=('mouse', 'pointer', 'last-active'))
for name in ('desktop', 'spatial'):
sub.add_parser(name).add_argument('--save', action='store_true')
pos = sub.add_parser('position')
@@ -135,6 +138,11 @@ def main():
args = parser.parse_args()
try:
if args.command == 'status': result = status()
elif args.command == 'policy':
require_ready()
if request(SCREENS, 'desktop-policy '+args.mode) != 'ok':
raise RuntimeError('Desktop policy rejected')
result = query(SCREENS, 'desktop-policy?')
elif args.command in ('desktop', 'spatial'): result = switch(args.command, args.save)
else:
require_ready()
+92 -29
View File
@@ -26,6 +26,7 @@
// Runs in the dev container (wlroots 0.20); KWin connects from the host.
#define _GNU_SOURCE
#include "controller-fallback.h"
#include "desktop-handoff.h"
#include <drm_fourcc.h>
#include <linux/input-event-codes.h>
#include <math.h>
@@ -112,6 +113,10 @@ struct server {
bool desktop_mouse, cursor_ready, cursor_frame_pending;
struct ft_controller_fallback controller_fallback;
bool controller_desktop;
struct ft_handoff handoff;
struct ft_event controller_position[MAX_SCREENS];
bool controller_position_known[MAX_SCREENS];
unsigned long mouse_events_ignored;
uint32_t controller_buttons;
struct ft_cursor_cache cursor_cache;
const char *cursor_error;
@@ -374,11 +379,10 @@ static void deliver_pointer_event(const struct ft_event *e, void *data) {
wlr_seat_pointer_notify_frame(s->seat);
}
static void update_controller_fallback(struct server *s) {
bool active = s->desktop_mouse && !s->mouse.buttons &&
ft_fallback_active(&s->controller_fallback, now_ms());
if (active == s->controller_desktop) return;
// Release controller-owned presses before the native seat takes over.
static void set_desktop_owner(struct server *s, bool pointer) {
if (pointer == s->controller_desktop) return;
// Claims are refused during a held drag. Legacy disconnect recovery may
// still revoke a stale controller, releasing its buttons safely.
for (unsigned bit = 0; bit < 8; ++bit)
if (s->controller_buttons & (1u << bit))
wlr_seat_pointer_notify_button(s->seat, now_ms(), BTN_LEFT+bit,
@@ -387,34 +391,70 @@ static void update_controller_fallback(struct server *s) {
wlr_seat_pointer_notify_clear_focus(s->seat);
wlr_seat_pointer_notify_frame(s->seat);
s->pointer_focus = NULL;
s->controller_desktop = active;
wlr_log(WLR_INFO, "desktop controller fallback %s (mouse %s, telemetry %s)",
active ? "active" : "inactive", s->controller_fallback.present ? "present" : "absent",
s->controller_fallback.known && (uint32_t)(now_ms()-s->controller_fallback.received)<3000 ? "fresh" : "unknown/stale");
s->controller_desktop = pointer;
wlr_log(WLR_INFO, "desktop owner %s (policy %s)", pointer ? "pointer" : "mouse",
ft_handoff_name(s->handoff.policy));
ft_vr_cursor_move(-1, 0, 0, 1, false);
}
static void update_controller_fallback(struct server *s) {
bool active = s->desktop_mouse && !s->mouse.buttons &&
ft_fallback_active(&s->controller_fallback, now_ms());
if (s->desktop_mouse && s->handoff.policy != FT_PREFER_MOUSE) {
active = s->controller_desktop;
if (!s->mouse.buttons && !s->controller_buttons &&
(s->handoff.policy == FT_PREFER_POINTER ||
ft_fallback_active(&s->controller_fallback, now_ms()))) active = true;
}
set_desktop_owner(s, active);
}
static bool claim_desktop_mouse(struct server *s) {
if (!ft_handoff_claim(&s->handoff, false, now_ms(), s->mouse.buttons, s->controller_buttons)) {
++s->mouse_events_ignored;
return false;
}
ft_fallback_presence(&s->controller_fallback, true, s->controller_fallback.enabled, now_ms());
set_desktop_owner(s, false);
return true;
}
static void handle_vr_event(const struct ft_event *e, void *data) {
struct server *s = data;
// Native mouse mode owns the desktop seat. Controller lasers still operate
// SteamVR, games and FrameTop's separate screen placement controls.
// Events here are panel content, not SteamVR or separate screen grab bars.
bool desktop = e->screen >= 0 && e->screen < s->n_config;
if (desktop && e->type == FT_MOTION) {
s->controller_position[e->screen] = *e;
s->controller_position_known[e->screen] = true;
}
update_controller_fallback(s);
if (s->desktop_mouse && !s->controller_desktop && e->screen < s->n_config &&
(e->type == FT_MOTION || e->type == FT_BUTTON || e->type == FT_SCROLL || e->type == FT_LEAVE)) { ++s->vr_mouse_ignored; return; }
if (s->controller_desktop && e->screen < s->n_config && e->type == FT_BUTTON && e->button >= BTN_LEFT && e->button < BTN_LEFT+8) {
uint32_t bit = 1u << (e->button-BTN_LEFT);
if (e->pressed) s->controller_buttons |= bit;
else s->controller_buttons &= ~bit;
if (s->desktop_mouse && desktop) {
bool pointer_event = e->type == FT_MOTION || e->type == FT_BUTTON ||
e->type == FT_SCROLL || e->type == FT_LEAVE;
uint32_t bit = e->type == FT_BUTTON && e->button >= BTN_LEFT && e->button < BTN_LEFT+8
? 1u << (e->button-BTN_LEFT) : 0;
bool action = (e->type == FT_BUTTON && e->pressed && bit) ||
(e->type == FT_SCROLL && (e->dx != 0 || e->dy != 0));
if (action && s->handoff.policy != FT_PREFER_MOUSE &&
ft_handoff_claim(&s->handoff, true, now_ms(), s->mouse.buttons, s->controller_buttons)) {
set_desktop_owner(s, true);
if (e->type == FT_SCROLL && s->controller_position_known[e->screen])
deliver_pointer_event(&s->controller_position[e->screen], s);
}
if (pointer_event && (!s->controller_desktop || s->mouse.buttons ||
(e->type == FT_BUTTON && !e->pressed && !(s->controller_buttons & bit)) ||
(e->type == FT_LEAVE && s->controller_buttons))) {
++s->vr_mouse_ignored; return;
}
if (bit) {
if (e->pressed) s->controller_buttons |= bit;
else s->controller_buttons &= ~bit;
}
}
deliver_pointer_event(e, data);
}
static void desktop_motion(struct server *s) {
// A real mouse packet wins immediately, before the next hotplug heartbeat.
if (s->controller_desktop) {
ft_fallback_presence(&s->controller_fallback, true, s->controller_fallback.enabled, now_ms());
update_controller_fallback(s);
}
int index = ft_mouse_target(&s->mouse);
if (index < 0 || !s->screens[index]) return;
const struct ft_mouse_output *o = &s->mouse.outputs[index];
@@ -680,7 +720,23 @@ static int control_readable(int fd, uint32_t mask, void *data) {
double scale, wheel_x, wheel_y;
double mx, my, mw, mh;
char mode[16], tail;
if (strcmp(buf, "mouse-presence?") == 0) {
if (strcmp(buf, "desktop-policy?") == 0) {
snprintf(reply, sizeof reply,
"{\"supported\":true,\"policy\":\"%s\",\"owner\":\"%s\",\"mouseButtons\":%u,\"pointerButtons\":%u,\"mouseEventsIgnored\":%lu}",
ft_handoff_name(s->handoff.policy), s->controller_desktop ? "pointer" : "mouse",
s->mouse.buttons, s->controller_buttons, s->mouse_events_ignored);
} else if (sscanf(buf, "desktop-policy %15s %c", mode, &tail) == 1) {
if (s->mouse.buttons || s->controller_buttons ||
(strcmp(mode, "mouse") && strcmp(mode, "pointer") && strcmp(mode, "last-active")))
snprintf(reply, sizeof reply, "error policy or held button");
else {
s->handoff = (struct ft_handoff){.policy = !strcmp(mode, "last-active") ? FT_LAST_ACTIVE :
!strcmp(mode, "pointer") ? FT_PREFER_POINTER : FT_PREFER_MOUSE};
set_desktop_owner(s, s->handoff.policy == FT_PREFER_POINTER);
update_controller_fallback(s);
snprintf(reply, sizeof reply, "ok");
}
} else if (strcmp(buf, "mouse-presence?") == 0) {
snprintf(reply, sizeof reply,
"{\"supported\":true,\"known\":%s,\"present\":%s,\"enabled\":%s,\"controllerDesktop\":%s,\"fresh\":%s}",
s->controller_fallback.known ? "true" : "false",
@@ -714,7 +770,7 @@ static int control_readable(int fd, uint32_t mask, void *data) {
snprintf(reply, sizeof reply, "error invalid mouse output or held button");
else { s->scale[index-1] = scale; snprintf(reply, sizeof reply, "ok"); }
} else if (sscanf(buf, "mouse-mode %15s %c", mode, &tail) == 1) {
if (s->mouse.buttons || (strcmp(mode, "desktop") && strcmp(mode, "spatial")))
if (s->mouse.buttons || s->controller_buttons || (strcmp(mode, "desktop") && strcmp(mode, "spatial")))
snprintf(reply, sizeof reply, "error mouse mode or held button");
else if (!strcmp(mode, "desktop") && !ft_mouse_move(&s->mouse, 0, 0))
snprintf(reply, sizeof reply, "error configure mouse layout first");
@@ -729,22 +785,25 @@ static int control_readable(int fd, uint32_t mask, void *data) {
snprintf(reply, sizeof reply, "ok");
}
} else if (sscanf(buf, "mouse-move %lf %lf %c", &mx, &my, &tail) == 2) {
if (!s->desktop_mouse || (s->vr && !ft_vr_screens_shown()) || !ft_mouse_move(&s->mouse, mx, my))
if (!s->desktop_mouse || (s->vr && !ft_vr_screens_shown()) || !isfinite(mx) || !isfinite(my) || fabs(mx)>16384 || fabs(my)>16384)
snprintf(reply, sizeof reply, "error desktop mouse unavailable or invalid motion");
else { desktop_motion(s); ++s->mouse_motions; snprintf(reply, sizeof reply, "ok"); }
else { if ((mx != 0 || my != 0) && claim_desktop_mouse(s) && ft_mouse_move(&s->mouse, mx, my)) { desktop_motion(s); ++s->mouse_motions; } snprintf(reply, sizeof reply, "ok"); }
} else if (sscanf(buf, "mouse-position %d %lf %lf %c", &index, &mx, &my, &tail) == 3) {
if (!s->desktop_mouse || s->mouse.buttons || index < 1 || index > MAX_SCREENS ||
if (!s->desktop_mouse || s->mouse.buttons || s->controller_buttons || index < 1 || index > MAX_SCREENS ||
!s->mouse.outputs[index-1].enabled || !isfinite(mx) || !isfinite(my) ||
mx < 0 || my < 0 || mx >= s->mouse.outputs[index-1].width || my >= s->mouse.outputs[index-1].height)
snprintf(reply, sizeof reply, "error mouse position or held button");
else {
ft_mouse_position(&s->mouse, s->mouse.outputs[index-1].x+mx, s->mouse.outputs[index-1].y+my);
desktop_motion(s); snprintf(reply, sizeof reply, "ok");
if (claim_desktop_mouse(s)) desktop_motion(s);
snprintf(reply, sizeof reply, "ok");
}
} else if (sscanf(buf, "mouse-button %u %d %c", &code, &value, &tail) == 2) {
if (!s->desktop_mouse || (value != 0 && value != 1) ||
(s->vr && !ft_vr_screens_shown() && value) || code < BTN_LEFT || code > BTN_TASK)
snprintf(reply, sizeof reply, "error mouse button or hidden desktop");
else if ((!value && !(s->mouse.buttons & (1u << (code-BTN_LEFT)))) || !claim_desktop_mouse(s))
snprintf(reply, sizeof reply, "ok");
else {
desktop_motion(s);
int target = ft_mouse_target(&s->mouse);
@@ -765,6 +824,8 @@ static int control_readable(int fd, uint32_t mask, void *data) {
!s->mouse.outputs[target].enabled ||
!isfinite(wheel_x) || !isfinite(wheel_y) || fabs(wheel_x)>120 || fabs(wheel_y)>120)
snprintf(reply, sizeof reply, "error mouse wheel or hidden desktop");
else if ((wheel_x == 0 && wheel_y == 0) || !claim_desktop_mouse(s))
snprintf(reply, sizeof reply, "ok");
else {
desktop_motion(s);
struct ft_event event = {.type = FT_SCROLL, .screen = target, .dx = wheel_x, .dy = -wheel_y};
@@ -809,11 +870,13 @@ static int control_readable(int fd, uint32_t mask, void *data) {
else if (strcmp(button, "middle") == 0) e.button = BTN_MIDDLE;
if (got >= 2 && strcmp(what, "leave") == 0) e.type = FT_LEAVE;
else if (got >= 4 && strcmp(what, "move") == 0) e.type = FT_MOTION;
else if (got >= 4 && strcmp(what, "scroll") == 0 && isfinite(x) && isfinite(y) && fabs(x)<=120 && fabs(y)<=120)
e.type = FT_SCROLL, e.dx = x, e.dy = y;
else if (got >= 4 && (strcmp(what, "down") == 0 || strcmp(what, "up") == 0))
e.type = FT_BUTTON, e.pressed = what[0] == 'd';
else index = 0;
if (index < 1 || index > MAX_SCREENS || !s->screens[index - 1]) {
snprintf(reply, sizeof reply, "error input <screen> move|down|up|leave [x y [button]]");
snprintf(reply, sizeof reply, "error input <screen> move|down|up|scroll|leave [x y [button]]");
} else {
handle_vr_event(&e, s);
snprintf(reply, sizeof reply, "ok");
+24
View File
@@ -0,0 +1,24 @@
#ifndef FT_DESKTOP_HANDOFF_H
#define FT_DESKTOP_HANDOFF_H
#include <stdbool.h>
#include <stdint.h>
enum ft_handoff_policy { FT_PREFER_MOUSE, FT_LAST_ACTIVE, FT_PREFER_POINTER };
struct ft_handoff { enum ft_handoff_policy policy; bool mouse_seen; uint32_t mouse_at; };
// Only deliberate desktop actions call this. Tracking motion never claims a seat.
static inline bool ft_handoff_claim(struct ft_handoff *h, bool pointer,
uint32_t now, uint32_t mouse_buttons,
uint32_t pointer_buttons) {
if (pointer) {
if (mouse_buttons || h->policy == FT_PREFER_MOUSE) return false;
if (h->policy == FT_LAST_ACTIVE && !pointer_buttons && h->mouse_seen && now == h->mouse_at)
return false; // Same millisecond: native mouse wins.
} else {
if (pointer_buttons || h->policy == FT_PREFER_POINTER) return false;
h->mouse_seen = true; h->mouse_at = now;
}
return true;
}
static inline const char *ft_handoff_name(enum ft_handoff_policy p) {
return p == FT_LAST_ACTIVE ? "last-active" : p == FT_PREFER_POINTER ? "pointer" : "mouse";
}
#endif
+22
View File
@@ -0,0 +1,22 @@
#include <assert.h>
#include "../desktop-handoff.h"
int main(void) {
struct ft_handoff h = {.policy=FT_LAST_ACTIVE};
assert(ft_handoff_claim(&h, false, 100, 0, 0));
assert(!ft_handoff_claim(&h, true, 100, 0, 0));
assert(ft_handoff_claim(&h, true, 101, 0, 0));
assert(!ft_handoff_claim(&h, true, 102, 1, 0));
assert(!ft_handoff_claim(&h, false, 102, 0, 1));
assert(ft_handoff_claim(&h, true, 102, 0, 1));
assert(ft_handoff_claim(&h, false, 102, 1, 0));
assert(ft_handoff_claim(&h, false, 103, 0, 0));
h.policy=FT_PREFER_MOUSE;
assert(!ft_handoff_claim(&h, true, 104, 0, 0));
assert(ft_handoff_claim(&h, false, 104, 0, 0));
h.policy=FT_PREFER_POINTER;
assert(!ft_handoff_claim(&h, false, 105, 0, 0));
assert(ft_handoff_claim(&h, true, 105, 0, 0));
h=(struct ft_handoff){.policy=FT_LAST_ACTIVE};
assert(ft_handoff_claim(&h, false, UINT32_MAX, 0, 0));
assert(ft_handoff_claim(&h, true, 0, 0, 0));
}
@@ -33,3 +33,57 @@ with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as client:
assert not json.loads(ask('mouse-presence?'))['controllerDesktop']
ask('mouse-button 272 1'); ask('mouse-button 272 0')
print('Native motion/click/wheel, cross-output drag, output bounds and controller fallback passed')
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as client:
client.bind(''); client.settimeout(2)
def ask(command):
client.sendto(command.encode(), '\0ft_screens_test')
return client.recv(8192).decode()
def policy(): return json.loads(ask('desktop-policy?'))
assert ask('desktop-policy last-active') == 'ok'
assert ask('mouse-presence 1 1') == 'ok'
assert ask('mouse-move 3 0') == 'ok'
assert policy()['owner'] == 'mouse'
for _ in range(3): ask('input 1 move 200 100')
assert policy()['owner'] == 'mouse', 'aiming must not claim ownership'
time.sleep(.01)
ask('input 1 down 200 100 left')
assert policy()['owner'] == 'pointer'
assert policy()['pointerButtons'] == 1
before = json.loads(ask('mouse?'))
ask('mouse-move 40 0'); ask('mouse-button 272 1'); ask('mouse-button 272 0'); ask('mouse-wheel 0 1')
after = json.loads(ask('mouse?'))
assert before['x'] == after['x'], 'mouse motion must not accumulate during pointer drag'
assert before['buttonEvents'] == after['buttonEvents']
assert before['wheelEvents'] == after['wheelEvents']
assert policy()['owner'] == 'pointer'
assert ask('desktop-policy mouse').startswith('error'), 'cannot change policy during drag'
ask('mouse-presence 1 1')
assert policy()['owner'] == 'pointer', 'presence heartbeat must not steal ownership'
ask('input 1 up 200 100 left')
assert policy()['pointerButtons'] == 0
ask('mouse-wheel 0 1')
assert policy()['owner'] == 'mouse'
ask('mouse-button 272 1'); time.sleep(.01)
ask('input 1 down 200 100 left'); ask('input 1 up 200 100 left')
assert policy()['owner'] == 'mouse'
assert policy()['mouseButtons'] == 1
ask('mouse-button 272 0')
time.sleep(.01); ask('input 1 down 200 100 left'); ask('input 1 up 200 100 left')
assert policy()['owner'] == 'pointer'
ask('mouse-move 0 0')
assert policy()['owner'] == 'pointer', 'zero movement must not claim'
ask('mouse-move 1 0')
assert policy()['owner'] == 'mouse'
time.sleep(.01)
ask('input 1 move 200 100'); ask('input 1 scroll 0 1')
assert policy()['owner'] == 'pointer', 'controller scroll must claim desktop'
ask('mouse-wheel 0 1')
assert policy()['owner'] == 'mouse'
assert ask('desktop-policy pointer') == 'ok'
ask('mouse-move 1 0')
assert policy()['owner'] == 'pointer'
assert ask('desktop-policy mouse') == 'ok'
ask('input 1 down 200 100 left'); ask('input 1 up 200 100 left')
assert policy()['owner'] == 'mouse'
print('Desktop handoff, aiming exclusion, drag locks, ignored releases, presence stability and policy controls passed')
+2
View File
@@ -10,6 +10,8 @@ gcc -std=c11 -Wall -Wextra -Werror tests/desktop-mouse-test.c -lm -o build/deskt
build/desktop-mouse-test
gcc -std=c11 -Wall -Wextra -Werror tests/controller-fallback-test.c -o build/controller-fallback-test
build/controller-fallback-test
gcc -std=c11 -Wall -Wextra -Werror tests/desktop-handoff-test.c -o build/desktop-handoff-test
build/desktop-handoff-test
gcc -std=c11 -Wall -Wextra -Werror $(pkg-config --cflags libdrm) tests/desktop-cursor-test.c -o build/desktop-cursor-test
build/desktop-cursor-test
gcc -std=c11 -Wall -Wextra -Werror tests/cursor-cache-test.c $(pkg-config --cflags --libs wlroots-0.20 wayland-server wayland-client libdrm pixman-1) -o build/cursor-cache-test