diff --git a/docs/controller-click-stability.md b/docs/controller-click-stability.md new file mode 100644 index 0000000..8523955 --- /dev/null +++ b/docs/controller-click-stability.md @@ -0,0 +1,32 @@ +# Controller desktop click stability + +Trigger presses reach KDE immediately, but controller motion within 8 logical +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 +release. Moving outside the zone begins a normal drag immediately; returning to +the zone does not turn it back into a click. There is no hold-duration timer. + +This filters overlay pointer content events on desktop monitors only. Native mouse +input, SteamVR UI, separate screen grab bars and floating-app title-bar carrying +are unaffected. Multi-button gestures keep their existing behavior. A motion +onto another desktop monitor starts a drag; cross-monitor motion is not stabilized. + +CLI (runtime preferences, reset to 8 on desktop restart): + +```sh +input/ft-clickctl status +input/ft-clickctl threshold 8 +input/ft-clickctl threshold 0 # disable without a restart +``` + +Thresholds are 0–64 logical pixels, normalized to each panel's KDE scale. +Status reports held state, suppressed motions, stabilized clicks and drags. +Changing the threshold while a controller button is held is refused. + +This is a separate contribution from desktop mouse/controller ownership. Its +hardware validation must check small controls, intentional text selection, +long presses, cross-monitor dragging and simultaneous mouse use. The existing +renderer laser remains tracked; this change stabilizes desktop input rather +than smoothing the visual laser. Default threshold is a starting point to test. + +Run `scripts/test-controller-click.sh` for the isolated gesture-state tests. diff --git a/input/ft-clickctl b/input/ft-clickctl new file mode 100755 index 0000000..5289149 --- /dev/null +++ b/input/ft-clickctl @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Controller desktop click stability over local IPC; no SteamVR client.""" +import argparse +import json +import math +import socket + +def request(command): + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as client: + client.bind(''); client.settimeout(2) + client.sendto(command.encode(), '\0ft_screens') + response=client.recv(8192).decode() + if response.startswith('error'): raise RuntimeError(response) + return response + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + sub=parser.add_subparsers(dest='command', required=True) + sub.add_parser('status') + sub.add_parser('threshold').add_argument('pixels', type=float) + args=parser.parse_args() + try: + if args.command == 'threshold': + if not math.isfinite(args.pixels) or not 0 <= args.pixels <= 64: + raise ValueError('Threshold must be 0–64 logical pixels; 0 disables stabilization') + if request(f'controller-click {args.pixels:g}') != 'ok': raise RuntimeError('Threshold rejected') + print(json.dumps(json.loads(request('controller-click?')), indent=2)) + except (OSError, ValueError, RuntimeError) as error: parser.exit(1, str(error)+'\n') +if __name__ == '__main__': main() diff --git a/screens/compositor.c b/screens/compositor.c index f05732c..95a44d6 100644 --- a/screens/compositor.c +++ b/screens/compositor.c @@ -58,6 +58,7 @@ #include #include "vr.h" +#include "controller-click.h" #define MAX_SCREENS 24 // screens and spare outputs @@ -104,6 +105,7 @@ struct server { struct wl_list buffers; // tracked_buffer struct wl_event_source *tick; struct screen *pointer_focus; + struct ft_controller_click controller_click; pid_t child; // Where typing goes: the screens after a click on one, Steam after a click on another // panel. The input relay grabs the keyboards while it's the screens (see keys_update). @@ -292,6 +294,11 @@ static void handle_vr_event(const struct ft_event *e, void *data) { return; } if (e->screen < 0 || e->screen >= MAX_SCREENS || !s->screens[e->screen]) return; + struct ft_event filtered = *e; + if (e->screen < s->n_config && + !ft_controller_click_filter(&s->controller_click, &filtered, s->scale[e->screen])) return; + e = &filtered; + if (e->screen < 0 || e->screen >= MAX_SCREENS || !s->screens[e->screen]) return; struct screen *sc = s->screens[e->screen]; struct wlr_surface *surface = sc->toplevel->base->surface; const uint32_t t = now_ms(); @@ -514,7 +521,18 @@ static int control_readable(int fd, uint32_t mask, void *data) { unsigned code; int value, index, w, h; double scale; - if (sscanf(buf, "size %d %d %d", &index, &w, &h) == 3) { + char tail; + if (strcmp(buf, "controller-click?") == 0) { + snprintf(reply, sizeof reply, + "{\"supported\":true,\"threshold\":%.3f,\"held\":%s,\"dragging\":%s,\"suppressedMotions\":%lu,\"clicks\":%lu,\"drags\":%lu}", + s->controller_click.threshold, s->controller_click.held ? "true" : "false", + s->controller_click.dragging ? "true" : "false", s->controller_click.suppressed, + s->controller_click.clicks, s->controller_click.drags); + } else if (sscanf(buf, "controller-click %lf %c", &scale, &tail) == 1) { + if (!isfinite(scale) || scale < 0 || scale > 64 || s->controller_click.buttons) + snprintf(reply, sizeof reply, "error threshold or held controller button"); + else { s->controller_click.threshold = scale; snprintf(reply, sizeof reply, "ok"); } + } else if (sscanf(buf, "size %d %d %d", &index, &w, &h) == 3) { // A new resolution for a screen, live: KWin resizes the screen to match. (KWin makes // it this size times its scale; ft-floatd sends spares' sizes divided by theirs.) const int min_w = index - 1 < s->n_config ? 320 : 64, min_h = index - 1 < s->n_config ? 200 : 64; @@ -641,6 +659,7 @@ static bool setup_dmabuf(struct server *s) { int main(int argc, char **argv) { struct server s = {0}; + s.controller_click.threshold = 8; for (int i = 0; i < MAX_SCREENS; ++i) s.scale[i] = 1; s.kb_screen = -1; const char *socket_name = "ft-screens-0", *control_name = "ft_screens"; diff --git a/screens/controller-click.h b/screens/controller-click.h new file mode 100644 index 0000000..2a49c8c --- /dev/null +++ b/screens/controller-click.h @@ -0,0 +1,45 @@ +#ifndef FT_CONTROLLER_CLICK_H +#define FT_CONTROLLER_CLICK_H +#include +#include +#include "vr.h" +struct ft_controller_click { + bool held, dragging; + int screen; + uint32_t buttons; + double x, y, threshold, radius; + unsigned long suppressed, clicks, drags; +}; +// Hold the desktop position at press time until movement exceeds a logical-pixel +// radius. No timer, delayed button-down, or change to native mouse input. +static inline bool ft_controller_click_filter(struct ft_controller_click *c, + struct ft_event *e, double scale) { + if (e->type == FT_BUTTON && e->button >= BTN_LEFT && e->button < BTN_LEFT+8) { + uint32_t bit = 1u << (e->button-BTN_LEFT); + if (e->pressed) c->buttons |= bit; else c->buttons &= ~bit; + } + if (e->type == FT_BUTTON && e->pressed) { + if (e->button == BTN_LEFT && !c->held && c->threshold > 0) { + c->held = true; c->dragging = false; c->screen = e->screen; + c->x = e->x; c->y = e->y; c->radius = c->threshold * scale; + } else if (e->button != BTN_LEFT) { + c->held = false; // Multi-button gestures retain their usual semantics. + } + } else if (e->type == FT_MOTION && c->held && !c->dragging) { + if (e->screen == c->screen && hypot(e->x-c->x, e->y-c->y) <= c->radius) { + ++c->suppressed; + return false; + } + c->dragging = true; ++c->drags; + } else if (e->type == FT_BUTTON && e->button == BTN_LEFT && !e->pressed && c->held) { + if (!c->dragging) { + e->screen = c->screen; e->x = c->x; e->y = c->y; + ++c->clicks; + } + c->held = false; + } else if (e->type == FT_LEAVE && c->held) { + return false; // Preserve the implicit grab through tiny edge excursions. + } + return true; +} +#endif diff --git a/screens/tests/controller-click-test.c b/screens/tests/controller-click-test.c new file mode 100644 index 0000000..c38d6fc --- /dev/null +++ b/screens/tests/controller-click-test.c @@ -0,0 +1,37 @@ +#include +#include "../controller-click.h" +static struct ft_event event(enum ft_event_type t, bool down, double x, double y) { + return (struct ft_event){.type=t,.screen=0,.button=BTN_LEFT,.pressed=down,.x=x,.y=y}; +} +int main(void) { + struct ft_controller_click c={.threshold=8}; + struct ft_event e=event(FT_BUTTON,true,100,100); + assert(ft_controller_click_filter(&c,&e,2)); + e=event(FT_MOTION,false,112,108); + assert(!ft_controller_click_filter(&c,&e,2)); + assert(c.held && !c.dragging && c.suppressed==1); + e=event(FT_BUTTON,false,116,111); + assert(ft_controller_click_filter(&c,&e,2)); + assert(e.x==100 && e.y==100 && c.clicks==1 && !c.held); + e=event(FT_BUTTON,true,100,100);ft_controller_click_filter(&c,&e,1); + e=event(FT_MOTION,false,109,100);assert(ft_controller_click_filter(&c,&e,1)); + assert(c.dragging && c.drags==1); + e=event(FT_MOTION,false,101,100);assert(ft_controller_click_filter(&c,&e,1)); + e=event(FT_BUTTON,false,103,100);assert(ft_controller_click_filter(&c,&e,1)); + assert(e.x==103 && c.clicks==1 && !c.held); + e=event(FT_BUTTON,true,100,100);ft_controller_click_filter(&c,&e,1); + e=event(FT_LEAVE,false,0,0);assert(!ft_controller_click_filter(&c,&e,1)); + e=event(FT_BUTTON,false,800,900);e.screen=1;ft_controller_click_filter(&c,&e,1); + assert(e.screen==0 && e.x==100 && c.clicks==2); + e=event(FT_BUTTON,true,100,100);ft_controller_click_filter(&c,&e,1); + e=event(FT_MOTION,false,101,100);e.screen=1; + assert(ft_controller_click_filter(&c,&e,1) && c.dragging); + e=event(FT_BUTTON,false,101,100);ft_controller_click_filter(&c,&e,1); + c.threshold=0; + e=event(FT_BUTTON,true,100,100);ft_controller_click_filter(&c,&e,1); + e=event(FT_MOTION,false,101,100);assert(ft_controller_click_filter(&c,&e,1) && !c.held); + c.threshold=8; + e=event(FT_BUTTON,true,100,100);ft_controller_click_filter(&c,&e,1); + e=event(FT_BUTTON,true,100,100);e.button=BTN_RIGHT;ft_controller_click_filter(&c,&e,1); + assert(!c.held); +} diff --git a/scripts/test-controller-click.sh b/scripts/test-controller-click.sh new file mode 100755 index 0000000..0d91bf7 --- /dev/null +++ b/scripts/test-controller-click.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +"$root/scripts/frame.sh" -C screens 'mkdir -p build; gcc -std=c11 -Wall -Wextra -Werror tests/controller-click-test.c -lm -o build/controller-click-test && build/controller-click-test'