From 023287ef602d977092b94d90ab750fd415766fc1 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 1 Oct 2026 22:01:43 -0500 Subject: [PATCH] Add opt-in desktop mouse and controller ownership handoff --- docs/desktop-mouse.md | 17 +++ input/ft-mousectl | 10 +- screens/compositor.c | 121 ++++++++++++++++----- screens/desktop-handoff.h | 24 ++++ screens/tests/desktop-handoff-test.c | 22 ++++ screens/tests/desktop-input-integration.py | 54 +++++++++ scripts/test-desktop-input.sh | 2 + 7 files changed, 220 insertions(+), 30 deletions(-) create mode 100644 screens/desktop-handoff.h create mode 100644 screens/tests/desktop-handoff-test.c diff --git a/docs/desktop-mouse.md b/docs/desktop-mouse.md index 4788429..623a999 100644 --- a/docs/desktop-mouse.md +++ b/docs/desktop-mouse.md @@ -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`. diff --git a/input/ft-mousectl b/input/ft-mousectl index de9acae..a6438c5 100755 --- a/input/ft-mousectl +++ b/input/ft-mousectl @@ -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() diff --git a/screens/compositor.c b/screens/compositor.c index 89a6ead..5e1e648 100644 --- a/screens/compositor.c +++ b/screens/compositor.c @@ -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 #include #include @@ -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 move|down|up|leave [x y [button]]"); + snprintf(reply, sizeof reply, "error input move|down|up|scroll|leave [x y [button]]"); } else { handle_vr_event(&e, s); snprintf(reply, sizeof reply, "ok"); diff --git a/screens/desktop-handoff.h b/screens/desktop-handoff.h new file mode 100644 index 0000000..a30cd14 --- /dev/null +++ b/screens/desktop-handoff.h @@ -0,0 +1,24 @@ +#ifndef FT_DESKTOP_HANDOFF_H +#define FT_DESKTOP_HANDOFF_H +#include +#include +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 diff --git a/screens/tests/desktop-handoff-test.c b/screens/tests/desktop-handoff-test.c new file mode 100644 index 0000000..8d07592 --- /dev/null +++ b/screens/tests/desktop-handoff-test.c @@ -0,0 +1,22 @@ +#include +#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)); +} diff --git a/screens/tests/desktop-input-integration.py b/screens/tests/desktop-input-integration.py index 6209348..336bcb2 100644 --- a/screens/tests/desktop-input-integration.py +++ b/screens/tests/desktop-input-integration.py @@ -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') diff --git a/scripts/test-desktop-input.sh b/scripts/test-desktop-input.sh index 68e4295..a9a789c 100755 --- a/scripts/test-desktop-input.sh +++ b/scripts/test-desktop-input.sh @@ -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