From 8afd42798ff7fc3faf45f598f5326aee5cecd18a Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 1 Oct 2026 21:13:38 -0500 Subject: [PATCH] Add independent KDE mouse input and controller fallback --- docs/desktop-mouse.md | 104 ++++++++ docs/reference.md | 2 + input/ft-mousectl | 160 ++++++++++++ input/input-relay.py | 171 ++++++++++++- input/test_desktop_mouse.py | 147 +++++++++++ layout/ft_layout.py | 24 ++ pointer/helper/ft-pointer.cpp | 36 ++- screens/compositor.c | 275 ++++++++++++++++++++- screens/controller-fallback.h | 19 ++ screens/cursor-cache.h | 67 +++++ screens/desktop-cursor.h | 26 ++ screens/desktop-mouse.h | 61 +++++ screens/tests/controller-fallback-test.c | 29 +++ screens/tests/cursor-cache-test.c | 106 ++++++++ screens/tests/desktop-cursor-test.c | 23 ++ screens/tests/desktop-input-integration.py | 35 +++ screens/tests/desktop-mouse-test.c | 34 +++ screens/vr.cpp | 99 ++++++++ screens/vr.h | 5 + scripts/test-desktop-input.sh | 27 ++ session/frametop-session.sh | 5 + session/frametop.conf.example | 3 + 22 files changed, 1440 insertions(+), 18 deletions(-) create mode 100644 docs/desktop-mouse.md create mode 100755 input/ft-mousectl create mode 100644 input/test_desktop_mouse.py create mode 100644 screens/controller-fallback.h create mode 100644 screens/cursor-cache.h create mode 100644 screens/desktop-cursor.h create mode 100644 screens/desktop-mouse.h create mode 100644 screens/tests/controller-fallback-test.c create mode 100644 screens/tests/cursor-cache-test.c create mode 100644 screens/tests/desktop-cursor-test.c create mode 100644 screens/tests/desktop-input-integration.py create mode 100644 screens/tests/desktop-mouse-test.c create mode 100755 scripts/test-desktop-input.sh diff --git a/docs/desktop-mouse.md b/docs/desktop-mouse.md new file mode 100644 index 0000000..4788429 --- /dev/null +++ b/docs/desktop-mouse.md @@ -0,0 +1,104 @@ +# Independent desktop mouse + +`input/ft-mousectl desktop --save` selects normal desktop input: the physical +mouse moves across the nested KDE monitors, clicks and scrolls through KDE's +Wayland seat, and displays KDE's own cursor. No mouse event becomes a virtual +controller event. Physical controllers remain available to SteamVR and games. +While a routed mouse is connected, controller events on desktop app panels are ignored; +floating app panels, the separate grab bars and placement controls still work under the existing +controller policy. SteamVR dashboard controls are unaffected. + +Native CLI commands (no additional project tooling required): + +```sh +input/ft-mousectl status +input/ft-mousectl desktop --save +input/ft-mousectl center 2 +input/ft-mousectl position 2 960 540 +input/ft-mousectl layout +input/ft-mousectl spatial --save +``` + +Numbers follow KDE's WL-0, WL-1 output order, starting at 1. Positions are local +logical pixels; `layout` reads actual output geometry and scale from the running +private KDE session. Geometry follows FrameTop layout changes too. A drag keeps +the Wayland implicit grab on its initial output until all buttons are released, +while the cursor can cross onto another monitor. + +The relay gathers motion and wheels until the mouse's SYN_REPORT. High resolution +wheel reports use 1/120-notch units and replace accompanying ordinary reports; +they are delivered immediately without the spatial pointer's pulse timer. The +main and thumb wheels are independent. Standard mouse button codes are passed +through, including back/forward; spatial mouse button mappings do not apply. +`DESKTOP_MOUSE_SPEED` in `~/.config/frametop.conf` sets logical pixels per motion +count, default 1, range .05–10. This initial implementation uses constant speed, +not KDE's libinput acceleration settings, because FrameTop's relay owns the +physical device outside the nested session. + +The cursor overlay only displays KDE's cursor; it has no input method, laser +claim, or interactive overlay flag. Cursor shape, hotspot and buffer scale come +from KDE. GPU cursor buffers use the same zero-copy DMA-BUF import as the monitor +textures. Common 32-bit shared-memory cursor formats are converted to RGBA. + +Controller fallback: `DESKTOP_CONTROLLER_FALLBACK=1` (default) allows controller +laser input into desktop apps when no grabbed physical mouse is routed by the +relay. Idle mice do not trigger fallback. The relay reports presence every +second; absence must persist for one second, so disconnect detection normally +takes about one to two seconds. Keyboard routing still follows desktop clicks. +The virtual spatial mouse/gaze pointer stays off; controller policy stays +`dashboard`, preserving SteamVR's existing pointer-mode behavior. + +Reconnect or native mouse motion returns ownership to the native seat and +releases controller-held buttons before clearing its focus. A held native mouse +button prevents fallback until released. An unknown presence or a heartbeat +older than three seconds blocks automatic controller desktop input. Set +`DESKTOP_CONTROLLER_FALLBACK=0` to retain strict separation even without a mouse; +reload the relay at an appropriate pause after changing this preference. +`input/ft-mousectl status` includes `controllerFallback` with presence, freshness, +configured enablement and actual desktop controller ownership. Older running +compositors report this field unavailable until explicitly reloaded. +Both paths support images up to 512×512 and buffer scale 8. FrameTop retains the +image from the first surface commit, because KDE can commit it before selecting +the active cursor and wlroots releases current.buffer after commit callbacks. +An unsupported buffer is reported by cursorReady=false with cursorError +and hides the old cursor image. Rendering in VR still needs a wearer check for +visibility, stereo depth, monitor edges and cursor shape changes. + +The private desktop and apps launched from it use KDE's `kcminputrc` cursor +preferences (24px Breeze by default). Steam's VR environment exports +`XCURSOR_SIZE=256` and `XCURSOR_THEME=steam`; inheriting these makes app cursors +huge and can make their appearance change across monitors. A live KDE +CursorChanged notification updates KDE, but already running apps may retain +the old environment until reopened. Do not resize the VR overlay to mask an +app's incorrectly configured cursor size. + +The updated compositor, pointer helper and relay must all be loaded before +activation. `status` is read-only and shows readiness, actual/desired mode, +held buttons and delivery counters. Saving happens only after an acknowledged +mode change. Switching refuses while buttons/keys on a pointer device are held. +An absent compositor does not prevent explicit spatial recovery. Restarting the +desktop requires the user's approval; use `desktops.sh restart` so background +work is preserved, and restore the current app windows afterwards. + +If the compositor is lost, native mouse messages are not redirected into VR. +The input helpers need reloading when upgrading this feature. A standalone +helper/compositor restart may require `input/ft-mousectl desktop` again; inspect +all three components in `status` rather than inferring success from motion alone. + +Offline verification (or run `scripts/test-desktop-input.sh`): + +```sh +python3 -m unittest discover -s input -p 'test_*.py' +scripts/frame.sh -C screens 'gcc -std=c11 -Wall -Wextra -Werror tests/desktop-mouse-test.c -lm -o build/desktop-mouse-test && build/desktop-mouse-test' +scripts/frame.sh -C screens 'gcc -std=c11 -Wall -Wextra -Werror $(pkg-config --cflags libdrm) tests/desktop-cursor-test.c -o build/desktop-cursor-test && build/desktop-cursor-test' +scripts/frame.sh -C screens '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 && build/cursor-cache-test' +``` + +The direct seat and cursor path follow the [Wayland pointer protocol](https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_pointer). + + +The default remains `MOUSE_MODE=spatial`. Native mode is explicitly selected +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. diff --git a/docs/reference.md b/docs/reference.md index 8b3fac2..fe074db 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -2,6 +2,8 @@ How each part of Frametop works, where its settings live, and the commands for running parts of it by hand. For why it's built this way, see [design.md](design.md). +For independent mouse input, KDE cursor rendering and controller fallback, see [desktop-mouse.md](desktop-mouse.md). + ## The desktop From the headset, open Launch a program → Desktop. The installer replaces that launcher entry with Frametop's (`~/.local/share/applications/deckard-nested-desktop.desktop`), and `desktops.sh uninstall` gives the stock single-screen desktop back. diff --git a/input/ft-mousectl b/input/ft-mousectl new file mode 100755 index 0000000..de9acae --- /dev/null +++ b/input/ft-mousectl @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Native KDE mouse control, entirely over local IPC; no SteamVR client needed.""" +import argparse +import importlib.util +import json +import math +import os +from pathlib import Path +import re +import socket +import tempfile + +SCREENS = '\0ft_screens' +RELAY = '\0frametop_relay' +HELPER = '\0ft_pointer_helper' +CONF = Path.home() / '.config/frametop.conf' + + +def request(address, command): + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock: + sock.bind('') + sock.settimeout(1) + sock.sendto(command.encode(), address) + result = sock.recv(8192).decode() + if result.startswith('error'): + raise RuntimeError(result) + return result + + +def query(address, command): + try: + result = json.loads(request(address, command)) + if not isinstance(result, dict): raise ValueError('Expected an object') + return result + except (OSError, ValueError, RuntimeError) as error: + return {'available': False, 'reason': str(error)} + + +def status(): + result = {'relay': query(RELAY, 'mouse-mode?'), 'desktop': query(SCREENS, 'mouse?'), + 'pointer': query(HELPER, 'pointerstatus'), + 'controllerFallback': query(SCREENS, 'mouse-presence?')} + 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' + and result['desktop'].get('mode') == 'desktop' + and result['pointer'].get('desktopInput')) + return result + + +def require_ready(): + result = status() + if not result['ready']: + raise RuntimeError('Native mouse support is not loaded in all three components. ' + 'Inspect status; the updated desktop and input helpers must be running.') + return result + + +def layout_outputs(): + spec = importlib.util.spec_from_file_location('mouse_layout', Path(__file__).resolve().parents[1] / 'layout/ft_layout.py') + ft = importlib.util.module_from_spec(spec) + spec.loader.exec_module(ft) + env = ft.nested_env() + if env is None: raise RuntimeError('No running FrameTop KDE desktop') + outs = ft.outputs(env) + if not outs or not any(o.get('enabled') for o in outs): + raise RuntimeError('KDE output geometry is unavailable') + return outs + + +def configure(outs): + commands = [] + for index, output in enumerate(outs, 1): + if not output.get('enabled'): + commands.append(f'mouse-output-off {index}') + continue + scale = float(output['scale']) + if not math.isfinite(scale) or not .25 <= scale <= 8: + raise ValueError('Invalid KDE output scale') + w, h = (math.ceil(output['size'][k]/scale) for k in ('width', 'height')) + x, y = (float(output['pos'][k]) for k in ('x', 'y')) + if not all(math.isfinite(v) for v in (x, y)) or not (1 <= w <= 16384 and 1 <= h <= 16384): + raise ValueError('Invalid KDE output geometry') + commands.append(f'mouse-layout {index} {x:g} {y:g} {w} {h} {scale:g}') + # Validate the entire geometry before sending any updates. + for command in commands: + if request(SCREENS, command) != 'ok': raise RuntimeError('Mouse layout rejected') + + +def save_mode(mode, path=None): + path = Path(path or CONF) + text = path.read_text() if path.exists() else '' + pattern = re.compile(r'^\s*MOUSE_MODE\s*=.*$', re.M) + text = pattern.sub('MOUSE_MODE='+mode, text) if pattern.search(text) else text.rstrip('\n')+'\nMOUSE_MODE='+mode+'\n' + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=path.name+'.', dir=path.parent) + try: + os.fchmod(fd, (path.stat().st_mode & 0o777) if path.exists() else 0o600) + with os.fdopen(fd, 'w') as file: + file.write(text) + file.flush() + os.fsync(file.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): os.unlink(temporary) + + +def switch(mode, save=False): + if mode == 'desktop': + require_ready() + configure(layout_outputs()) + # Spatial recovery intentionally works with an absent desktop. + result = query(RELAY, 'mouse-mode '+mode) + if result.get('ok') is not True: raise RuntimeError(result.get('error', result.get('reason', 'Relay rejected mode'))) + if save: + try: save_mode(mode) + except OSError as error: + raise RuntimeError(f'Live mode is {mode}, but saving the preference failed: {error}') from error + return dict(mode=mode, saved=save) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + sub.add_parser('status') + sub.add_parser('layout') + for name in ('desktop', 'spatial'): + sub.add_parser(name).add_argument('--save', action='store_true') + pos = sub.add_parser('position') + pos.add_argument('screen', type=int) + pos.add_argument('x', type=float) + pos.add_argument('y', type=float) + center = sub.add_parser('center') + center.add_argument('screen', type=int) + args = parser.parse_args() + try: + if args.command == 'status': result = status() + elif args.command in ('desktop', 'spatial'): result = switch(args.command, args.save) + else: + require_ready() + outs = layout_outputs() + if args.command == 'layout': + configure(outs) + result = {'outputs': outs} + else: + if not 1 <= args.screen <= len(outs): raise ValueError('Invalid screen number') + if args.command == 'center': + output = outs[args.screen-1] + x, y = (math.ceil(output['size'][k]/output['scale'])/2 for k in ('width', 'height')) + else: x, y = args.x, args.y + if not all(math.isfinite(v) for v in (x, y)): raise ValueError('Position must be finite') + if request(SCREENS, f'mouse-position {args.screen} {x:g} {y:g}') != 'ok': + raise RuntimeError('Mouse position rejected') + result = dict(screen=args.screen, x=x, y=y) + print(json.dumps(result, indent=2)) + except (OSError, RuntimeError, ValueError, KeyError) as error: + parser.exit(1, str(error)+'\n') + + +if __name__ == '__main__': main() diff --git a/input/input-relay.py b/input/input-relay.py index e4fb462..429300b 100755 --- a/input/input-relay.py +++ b/input/input-relay.py @@ -115,6 +115,7 @@ import atexit import errno import fcntl import json +import math import os import select import signal @@ -129,7 +130,7 @@ EV_SYN, EV_KEY, EV_REL, EV_MSC = 0x00, 0x01, 0x02, 0x04 SYN_REPORT = 0 BTN_MISC, KEY_MAX = 0x100, 0x2FF KEY_A = 30 -REL_X, REL_Y, REL_WHEEL, REL_MAX = 0x00, 0x01, 0x08, 0x0F +REL_X, REL_Y, REL_HWHEEL, REL_WHEEL, REL_MAX = 0x00, 0x01, 0x06, 0x08, 0x0F BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA = 0x110, 0x111, 0x112, 0x113, 0x114 KEY_LEFTMETA, KEY_RIGHTMETA = 125, 126 KEY_VOLUMEDOWN, KEY_VOLUMEUP = 114, 115 @@ -590,6 +591,75 @@ class Pointer: return 0.02 if any(t is not None for t in pending) else 0.5 +def desktop_mouse_present(nodes): + """Only a physical mouse actually routed by this relay owns the desktop.""" + return any(n.is_mouse and n.role == "pointer" and n.grabbed for n in nodes) + + +def ipc_request(address, command, timeout=.2): + """Bounded local request/reply, with no OpenVR client or desktop wake.""" + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock: + sock.bind("") + sock.settimeout(timeout) + sock.sendto(command.encode(), address) + reply = sock.recv(8192).decode() + if reply.startswith("error"): + raise RuntimeError(reply) + return reply + + +class DesktopMouse: + """Physical mouse -> nested KDE seat. Never sends to the VR pointer helper.""" + def __init__(self, speed=1): + if not math.isfinite(speed) or not .05 <= speed <= 10: + raise ValueError("Desktop mouse speed must be between .05 and 10") + self.speed = speed + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + self.dx = self.dy = 0 + self.wheel = [0, 0] + self.hires = [0, 0] + self.has_hires = [False, False] + self.held = set() + self.sent = self.failed = 0 + + def send(self, command): + try: + self.sock.sendto(command.encode(), SCREENS) + self.sent += 1 + except OSError: + self.failed += 1 + + def motion(self, code, value): + if code == REL_X: self.dx += value + elif code == REL_Y: self.dy += value + elif code in (REL_HWHEEL, REL_WHEEL): self.wheel[code == REL_WHEEL] += value + elif code in (12, 11): + axis = int(code == 11) + self.hires[axis] += value + self.has_hires[axis] = True + + def flush(self): + if self.dx or self.dy: + self.send(f"mouse-move {self.dx*self.speed:.6f} {self.dy*self.speed:.6f}") + self.dx = self.dy = 0 + wheel = [self.hires[i]/120 if self.has_hires[i] else self.wheel[i] for i in (0, 1)] + if any(wheel): self.send(f"mouse-wheel {wheel[0]:.6f} {wheel[1]:.6f}") + self.wheel = [0, 0]; self.hires = [0, 0]; self.has_hires = [False, False] + + def button(self, code, value): + if not BTN_LEFT <= code <= 279 or value not in (0, 1): return + self.flush() + self.send(f"mouse-button {code} {value}") + if value: self.held.add(code) + else: self.held.discard(code) + + def release_all(self): + self.dx = self.dy = 0 + self.wheel = [0, 0]; self.hires = [0, 0]; self.has_hires = [False, False] + for code in sorted(self.held): self.send(f"mouse-button {code} 0") + self.held.clear() + + class Node: """One input event node: a candidate device (mouse or keyboard, USB or Bluetooth), or any other device with volume keys (candidate False, role "volume").""" @@ -673,13 +743,22 @@ def main(): # every second); typing_applied: the grabs match that as of the last apply_roles(). # vr_capture_until: every controller button is taken until then (the settings app capturing one). state = {"pointer": None, "rules": {}, "meta_dashboard": False, "share_keys": False, - "desktop_until": 0.0, "typing_applied": None, "vr_capture_until": 0.0} + "desktop_until": 0.0, "typing_applied": None, "vr_capture_until": 0.0, + "desktop_mouse": None, "mouse_mode": "spatial", "desktop_speed": 1.0, + "controller_fallback": True} def load_config(): conf = read_config() state["rules"] = read_rules() state["meta_dashboard"] = conf.get("META_DASHBOARD", "0") == "1" state["share_keys"] = conf.get("SHARE_KEYS", "0") == "1" + state["mouse_mode"] = "desktop" if conf.get("MOUSE_MODE") == "desktop" else "spatial" + state["controller_fallback"] = conf.get("DESKTOP_CONTROLLER_FALLBACK", "1") == "1" + try: + speed = float(conf.get("DESKTOP_MOUSE_SPEED", "1")) + state["desktop_speed"] = speed if math.isfinite(speed) and .05 <= speed <= 10 else 1 + except ValueError: + state["desktop_speed"] = 1 if conf.get("POINTER", "0") == "1": p = state["pointer"] or Pointer(0.03, 30) p.sensitivity = float(conf.get("POINTER_SENSITIVITY", "0.03")) @@ -698,7 +777,9 @@ def main(): def vr_bind(now): """Tell the pointer helper which controller buttons to take from games.""" - if now < state["vr_capture_until"]: + if state["desktop_mouse"]: + buttons = "-" # leave every physical controller button to SteamVR/games + elif now < state["vr_capture_until"]: buttons = "*" else: state["vr_capture_until"] = 0.0 @@ -721,7 +802,7 @@ def main(): reply(addr, {"t": "event", "id": VR_DEVICE, "path": "", "name": "Steam Frame controllers", "type": "vr", "code": button, "value": value}) action = state["rules"]["controller_buttons"].get(button) - if (state["pointer"] or (action and not needs_pointer(action))) and known_action(action) \ + if not state["desktop_mouse"] and (state["pointer"] or (action and not needs_pointer(action))) and known_action(action) \ and action not in ("key", "none") and action not in GAZE_ACTIONS: do_action(action, value, now, button.split("/")[0]) @@ -902,6 +983,12 @@ def main(): return "pointer" if has_mouse else "passthrough" def release_held(node): + if node.role == "pointer" and state["desktop_mouse"]: + state["desktop_mouse"].release_all() + for code in node.held: + if code < BTN_MISC: to_screens(code, 0) + node.held.clear() + return for code in node.held: if node.role == "passthrough": share_key(node, code, 0) @@ -995,6 +1082,40 @@ def main(): except OSError: watchers.pop(addr, None) + def set_mouse_mode(mode, now): + if any(keys_down(n) for n in nodes.values() if n.role == "pointer"): + raise RuntimeError("Release mouse buttons before switching modes") + native = state["desktop_mouse"] + if native and native.held: + raise RuntimeError("Release mouse buttons before switching modes") + if mode == "desktop": + ipc_request(SCREENS, "mouse-mode desktop") + try: + ipc_request(HELPER, "desktopmouse on") + except (OSError, RuntimeError): + ipc_request(SCREENS, "mouse-mode spatial") + raise + if native: + state["mouse_mode"] = mode + return + state["desktop_mouse"] = DesktopMouse(state["desktop_speed"]) + if state["pointer"]: + state["pointer"].active = False + state["pointer"].dx = state["pointer"].dy = 0 + state["pointer"].claim_at = state["pointer"].claim_release = None + state["pointer"].scroll_until = state["pointer"].system_at = state["pointer"].system_release = None + else: + # An absent desktop must not prevent explicit spatial recovery. + try: ipc_request(SCREENS, "mouse-mode spatial") + except OSError: pass + ipc_request(HELPER, "desktopmouse off") + if native: + native.release_all(); native.sock.close() + state["desktop_mouse"] = None + state["mouse_mode"] = mode + vr_bind(now) + log(f"mouse mode: {mode}") + def handle_control(now): while True: try: @@ -1031,6 +1152,18 @@ def main(): reply(addr, {"t": "devices", "pointer_mode": state["pointer"] is not None, "actions": ACTIONS, "nodes": [n.describe() for n in nodes.values() if n.candidate]}) + elif cmd == "mouse-mode?": + native = state["desktop_mouse"] + reply(addr, {"supported": True, "mode": "desktop" if native else "spatial", + "requestedMode": state["mouse_mode"], "speed": state["desktop_speed"], + "heldButtons": sorted(native.held) if native else [], + "sent": native.sent if native else 0, "sendFailures": native.failed if native else 0}) + elif cmd == "mouse-mode" and len(words) == 2 and words[1] in ("desktop", "spatial"): + try: + set_mouse_mode(words[1], now) + reply(addr, {"mode": words[1], "ok": True}) + except (OSError, RuntimeError, ValueError) as error: + reply(addr, {"error": str(error), "ok": False}) elif cmd == "watch": seconds = float(words[1]) if len(words) > 1 else 30 watchers[addr] = now + min(seconds, 600) @@ -1078,6 +1211,9 @@ def main(): if now >= next_scan: next_scan = now + 1.0 reconcile_desktop_keys() + if (state["mouse_mode"] == "desktop") != bool(state["desktop_mouse"]): + try: set_mouse_mode(state["mouse_mode"], now) + except (OSError, RuntimeError, ValueError): pass # desktop may be starting; retry on the next scan current = {} for name in os.listdir("/dev/input"): if name.startswith("event"): @@ -1104,11 +1240,18 @@ def main(): added = True if added: apply_roles() + # Presence, not activity: an idle connected mouse keeps its own seat. + present = desktop_mouse_present(nodes.values()) + try: + ipc_request(SCREENS, "mouse-presence %d %d" % + (present, state["controller_fallback"])) + except (OSError, RuntimeError, ValueError): + pass # Older/missing compositor: preserve existing native mode. ready, _, _ = select.select(list(nodes) + [control], [], [], volume.timeout(now, pointer.timeout() if pointer else 0.5)) now = time.monotonic() - if pointer: + if pointer and not state["desktop_mouse"]: pointer.tick(now) volume.tick(now) if state["vr_capture_until"] and now >= state["vr_capture_until"]: @@ -1160,7 +1303,7 @@ def main(): node.held.add(code) else: node.held.discard(code) - if (pointer and state["meta_dashboard"] and node.role == "passthrough" + if (pointer and not state["desktop_mouse"] and state["meta_dashboard"] and node.role == "passthrough" and etype == EV_KEY): if code in (KEY_LEFTMETA, KEY_RIGHTMETA): if value == 1: @@ -1172,6 +1315,14 @@ def main(): meta_down = False # Meta used as a modifier, not a tap continue if etype == EV_KEY: + if state["desktop_mouse"]: + if code >= BTN_LEFT: + state["desktop_mouse"].button(code, value) + else: + to_screens(code, value) + if value: node.held.add(code) + else: node.held.discard(code) + continue action = buttons.get(str(code), DEFAULT_BUTTONS.get(code, "key")) if pointer and action not in ("key", "none"): do_action(action, value, now) @@ -1186,12 +1337,16 @@ def main(): else: node.held.discard(code) elif etype == EV_REL: - if pointer: + if state["desktop_mouse"]: + state["desktop_mouse"].motion(code, value) + elif pointer: pointer.motion(code, value, now) else: mouse.emit(etype, code, value) elif etype == EV_SYN and code == SYN_REPORT: - if pointer: + if state["desktop_mouse"]: + state["desktop_mouse"].flush() + elif pointer: pointer.flush() mouse.sync() keyboard.sync() diff --git a/input/test_desktop_mouse.py b/input/test_desktop_mouse.py new file mode 100644 index 0000000..06fb0fd --- /dev/null +++ b/input/test_desktop_mouse.py @@ -0,0 +1,147 @@ +"""Offline physical mouse tests: no input devices, desktop or SteamVR accessed.""" +import importlib.machinery +import importlib.util +import math +from pathlib import Path +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import patch + + +def module(name, file): + loader = importlib.machinery.SourceFileLoader(name, str(Path(__file__).with_name(file))) + spec = importlib.util.spec_from_loader(name, loader) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +relay = module('desktop_relay', 'input-relay.py') +ctl = module('mouse_control', 'ft-mousectl') + + +class DesktopMouseTests(unittest.TestCase): + def test_presence_requires_a_routed_grabbed_physical_mouse(self): + def node(mouse=True, role='pointer', grabbed=True): + return SimpleNamespace(is_mouse=mouse, role=role, grabbed=grabbed) + self.assertFalse(relay.desktop_mouse_present([])) + for n in (node(mouse=False), node(role='ignore'), node(role='passthrough'), node(grabbed=False)): + self.assertFalse(relay.desktop_mouse_present([n])) + self.assertTrue(relay.desktop_mouse_present([node(mouse=False), node()])) + def mouse(self, speed=1): + with patch.object(relay.socket, 'socket'): + m = relay.DesktopMouse(speed) + m.messages = [] + m.send = m.messages.append + return m + + def test_motion_precedes_click_at_new_location(self): + m = self.mouse(.5) + m.motion(relay.REL_X, 12) + m.motion(relay.REL_Y, -8) + m.button(272, 1) + m.button(272, 0) + m.flush() + self.assertEqual(m.messages, ['mouse-move 6.000000 -4.000000', 'mouse-button 272 1', 'mouse-button 272 0']) + + def test_hires_replaces_companion_legacy_in_both_orders(self): + for events in ([(8, 1), (11, 120)], [(11, 120), (8, 1)]): + m = self.mouse() + for code, value in events: m.motion(code, value) + m.flush() + self.assertEqual(m.messages, ['mouse-wheel 0.000000 1.000000']) + + def test_fractional_scroll_has_no_pulse_timer(self): + m = self.mouse() + for value in (15, 15, -30): + m.motion(11, value); m.flush() + self.assertEqual(m.messages, ['mouse-wheel 0.000000 0.125000', 'mouse-wheel 0.000000 0.125000', 'mouse-wheel 0.000000 -0.250000']) + + def test_thumb_and_main_wheels_are_independent(self): + m = self.mouse() + m.motion(6, -2); m.motion(8, 3); m.motion(11, 60); m.flush() + self.assertEqual(m.messages, ['mouse-wheel -2.000000 0.500000']) + m.motion(8, -1); m.flush() + self.assertEqual(m.messages[-1], 'mouse-wheel 0.000000 -1.000000') + + def test_hotplug_releases_all_buttons_without_stale_motion(self): + m = self.mouse() + m.button(272, 1); m.button(275, 1) + m.motion(0, 20); m.motion(8, 1) + m.release_all(); m.flush() + self.assertEqual(m.messages, ['mouse-button 272 1', 'mouse-button 275 1', 'mouse-button 272 0', 'mouse-button 275 0']) + self.assertFalse(m.held) + + def test_button_repeat_is_ignored(self): + m = self.mouse() + m.button(272, 2); m.button(280, 1) + self.assertEqual(m.messages, []) + + def test_native_messages_never_target_virtual_controller(self): + with patch.object(relay.socket, 'socket') as sock: + m = relay.DesktopMouse() + m.motion(0, 3); m.motion(8, 1); m.button(272, 1); m.release_all() + self.assertTrue(sock.return_value.sendto.call_args_list) + for call in sock.return_value.sendto.call_args_list: + self.assertEqual(call.args[1], relay.SCREENS) + + def test_bad_speed_rejected_before_socket_created(self): + with patch.object(relay.socket, 'socket') as sock: + for speed in (math.nan, math.inf, 0, 11): + with self.assertRaises(ValueError): relay.DesktopMouse(speed) + sock.assert_not_called() + + +class ControlTests(unittest.TestCase): + def test_old_components_cannot_trigger_partial_activation(self): + with patch.object(ctl, 'status', return_value={'ready': False}), patch.object(ctl, 'configure') as configure, patch.object(ctl, 'save_mode') as save: + with self.assertRaisesRegex(RuntimeError, 'not loaded'): ctl.switch('desktop', True) + configure.assert_not_called(); save.assert_not_called() + + def test_rejected_switch_does_not_persist(self): + with patch.object(ctl, 'require_ready'), patch.object(ctl, 'layout_outputs'), patch.object(ctl, 'configure'), patch.object(ctl, 'query', return_value={'ok': False, 'error': 'held buttons'}), patch.object(ctl, 'save_mode') as save: + with self.assertRaisesRegex(RuntimeError, 'held buttons'): ctl.switch('desktop', True) + save.assert_not_called() + + def test_spatial_recovery_does_not_need_kde(self): + with patch.object(ctl, 'layout_outputs') as layout, patch.object(ctl, 'query', return_value={'ok': True}) as query: + self.assertEqual(ctl.switch('spatial'), {'mode': 'spatial', 'saved': False}) + layout.assert_not_called(); query.assert_called_once_with(ctl.RELAY, 'mouse-mode spatial') + + def test_failed_persistence_reports_actual_live_mode(self): + with patch.object(ctl, 'query', return_value={'ok': True}), patch.object(ctl, 'save_mode', side_effect=OSError('read only')): + with self.assertRaisesRegex(RuntimeError, 'Live mode is spatial'): ctl.switch('spatial', True) + + def test_scaled_outputs_use_actual_logical_geometry(self): + outs = [{'enabled': True, 'pos': {'x': -1280, 'y': 20}, 'size': {'width': 1920, 'height': 1080}, 'scale': 1.5}, {'enabled': False}] + with patch.object(ctl, 'request', return_value='ok') as request: + ctl.configure(outs) + self.assertEqual([c.args for c in request.call_args_list], [(ctl.SCREENS, 'mouse-layout 1 -1280 20 1280 720 1.5'), (ctl.SCREENS, 'mouse-output-off 2')]) + + def test_invalid_later_output_does_not_partially_change_geometry(self): + outs = [{'enabled': True, 'pos': {'x': 0, 'y': 0}, 'size': {'width': 1920, 'height': 1080}, 'scale': 1}, {'enabled': True, 'scale': 0}] + with patch.object(ctl, 'request') as request: + with self.assertRaises(ValueError): ctl.configure(outs) + request.assert_not_called() + + def test_save_preserves_other_preferences_and_permissions(self): + with tempfile.TemporaryDirectory() as root: + path = Path(root) / 'frametop.conf' + path.write_text('# user settings\nPOINTER=1\nMOUSE_MODE=spatial\nMETA_DASHBOARD=0\n') + path.chmod(0o600) + ctl.save_mode('desktop', path) + self.assertEqual(path.read_text(), '# user settings\nPOINTER=1\nMOUSE_MODE=desktop\nMETA_DASHBOARD=0\n') + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + def test_failed_atomic_save_preserves_old_file(self): + with tempfile.TemporaryDirectory() as root: + path = Path(root) / 'frametop.conf' + path.write_text('MOUSE_MODE=spatial\n') + with patch.object(ctl.os, 'replace', side_effect=OSError('failed')): + with self.assertRaises(OSError): ctl.save_mode('desktop', path) + self.assertEqual(path.read_text(), 'MOUSE_MODE=spatial\n') + self.assertEqual(list(Path(root).iterdir()), [path]) + + +if __name__ == '__main__': unittest.main() diff --git a/layout/ft_layout.py b/layout/ft_layout.py index 774817e..8a37a1b 100755 --- a/layout/ft_layout.py +++ b/layout/ft_layout.py @@ -941,6 +941,29 @@ def send_scales(outs): log(f"screen {i + 1}: scale: {reply}") except RuntimeError as e: log(f"scale: {e}") + finally: + if 'sock' in locals(): sock.sock.close() + # Native mouse coordinates follow the actual logical KWin output layout. + # Capability probing keeps this compatible with older ft-screens builds. + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as probe: + probe.bind(''); probe.settimeout(1) + probe.sendto(b'mouse?', SCREENS) + if not json.loads(probe.recv(8192)).get('supported'): return + sock = screens_socket() + try: + for i, o in enumerate(outs): + if not o.get('enabled', True): + sock.ask(f'mouse-output-off {i+1}') + continue + scale = float(o.get('scale', 1)) + size, pos = o.get('size', {}), o.get('pos', {}) + w = math.ceil(size['width']/scale - 1e-6) + h = math.ceil(size['height']/scale - 1e-6) + sock.ask(f"mouse-layout {i+1} {pos.get('x',0)} {pos.get('y',0)} {w} {h} {scale:g}") + finally: sock.sock.close() + except (OSError, ValueError, RuntimeError, KeyError): + pass # old compositor, closing desktop, or a drag in progress; next layout refresh retries def logical_size(o): @@ -998,6 +1021,7 @@ def apply_scales(): if moves: subprocess.run(["kscreen-doctor", *moves], capture_output=True, env=env, timeout=20) args += moves + send_scales(outputs(env)) return args diff --git a/pointer/helper/ft-pointer.cpp b/pointer/helper/ft-pointer.cpp index 98d5e82..283f152 100644 --- a/pointer/helper/ft-pointer.cpp +++ b/pointer/helper/ft-pointer.cpp @@ -857,9 +857,10 @@ int main() { overlay->FindOverlay("system.pointer", &systemPointer); Vec3 pivot, tiltOrigin, lastPoint, lastOrigin, lastAim{0, 0, -1}; Basis tiltBasis{}; + bool desktopInput = false; // native desktop input never claims a SteamVR hand bool headsetOff = false; // nobody is wearing the headset (see the main loop) auto wake = [&](Clock::time_point t) { - if (t < noWakeUntil || headsetOff) return; + if (t < noWakeUntil || headsetOff || desktopInput) return; wokeAt = t; active = true; recenter = true; @@ -1273,7 +1274,7 @@ int main() { } // Hand gestures in the last two minutes keep it the same way. const bool handsRecent = handsOn && handUsed != Clock::time_point{} && t - handUsed < std::chrono::minutes(2); - const bool awake = (gazeOn || handsRecent) && !inGame && !headsetOff; + const bool awake = (gazeOn || handsRecent) && !inGame && !headsetOff && !desktopInput; if (awake != gazeAwake || t - gazeAwakeAt > std::chrono::seconds(5)) { if (awake != gazeAwake) std::printf("%s keeps the pointer: %s\n", gazeOn ? "gaze" : "hand use", @@ -1365,6 +1366,34 @@ int main() { reply(controllerButtons.Status()); continue; } + if (std::strcmp(buf, "pointerstatus") == 0) { + char msg[256]; + const int role = ours == vr::k_unTrackedDeviceIndexInvalid ? 0 : + int(vr::VRSystem()->GetControllerRoleForTrackedDeviceIndex(ours)); + std::snprintf(msg, sizeof msg, + "{\"active\":%s,\"headsetOff\":%s,\"gazeMode\":%s,\"handRole\":%d,\"desktopInput\":%s}", + active ? "true" : "false", headsetOff ? "true" : "false", + gazeOn ? "true" : "false", role, desktopInput ? "true" : "false"); + reply(msg); + continue; + } + if (std::strncmp(buf, "desktopmouse ", 13) == 0) { + if (std::strcmp(buf+13, "on") && std::strcmp(buf+13, "off")) { + reply("error desktopmouse on|off"); + continue; + } + desktopInput = std::strcmp(buf+13, "on") == 0; + if (desktopInput) { + if (leftHeld) releaseLeft(); + active = claimPending = claimHeld = false; + overlay->HideOverlay(cursor); overlay->HideOverlay(marker); + SendTo(out, "ft_pointer", "scroll 0 0"); + SendTo(out, "ft_pointer", "btn a 0"); + SendTo(out, "ft_pointer", "hide"); + } + reply("ok"); + continue; + } if (std::strncmp(buf, "overlays", 8) == 0) { overlays.Request(sender, senderLen); // answered from the list's thread continue; @@ -1752,6 +1781,7 @@ int main() { hp = std::asin(std::clamp(f.y, -1.0, 1.0)) * 180 / M_PI; return true; }; + if (desktopInput) keyPresses.clear(); for (const KeyPress &k : keyPresses) { (k.right ? keyRightDown : keyLeftDown) = k.down; if (!k.down) { @@ -1841,7 +1871,7 @@ int main() { handBaseline = true, handOpens = handFile.opens; } // Not over a VR game (unless the dashboard is up), and not with the headset off. - const bool allowed = !headsetOff && (!inGame || overlay->IsDashboardVisible()); + const bool allowed = !desktopInput && !headsetOff && (!inGame || overlay->IsDashboardVisible()); for (int k = 1; k >= 0; --k) // grips first: one takes over a pinch for (int s = 0; s < 2; ++s) { const fh_pinch_t &g = slots[k][s]; diff --git a/screens/compositor.c b/screens/compositor.c index f05732c..89a6ead 100644 --- a/screens/compositor.c +++ b/screens/compositor.c @@ -25,8 +25,10 @@ // COMMAND run with WAYLAND_DISPLAY set to our socket (e.g. the Frametop session) // Runs in the dev container (wlroots 0.20); KWin connects from the host. #define _GNU_SOURCE +#include "controller-fallback.h" #include #include +#include #include #include #include @@ -58,8 +60,11 @@ #include #include "vr.h" +#include "desktop-mouse.h" +#include "desktop-cursor.h" +#include "cursor-cache.h" -#define MAX_SCREENS 24 // screens and spare outputs +#define MAX_SCREENS FT_MOUSE_OUTPUTS // screens and spare outputs struct config { int width, height; @@ -104,6 +109,19 @@ struct server { struct wl_list buffers; // tracked_buffer struct wl_event_source *tick; struct screen *pointer_focus; + bool desktop_mouse, cursor_ready, cursor_frame_pending; + struct ft_controller_fallback controller_fallback; + bool controller_desktop; + uint32_t controller_buttons; + struct ft_cursor_cache cursor_cache; + const char *cursor_error; + const char *cursor_buffer; + struct wlr_buffer *cursor_held; + struct ft_mouse mouse; + struct wlr_surface *cursor_surface; + int cursor_hx, cursor_hy; + struct wl_listener request_cursor, cursor_commit, cursor_destroy; + unsigned long mouse_motions, mouse_clicks, mouse_wheels, vr_mouse_ignored; 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). @@ -201,6 +219,8 @@ static void screen_destroy(struct wl_listener *l, void *data) { if (sc->held) wlr_buffer_unlock(sc->held); ft_vr_screen_destroy(sc->index); if (sc->server->pointer_focus == sc) sc->server->pointer_focus = NULL; + sc->server->mouse.outputs[sc->index].enabled = false; + if (sc->server->mouse.grab == sc->index) sc->server->mouse.buttons = 0, sc->server->mouse.grab = -1; if (sc->decoration) wl_list_remove(&sc->decoration_destroy.link); sc->server->screens[sc->index] = NULL; wl_list_remove(&sc->commit.link); @@ -275,7 +295,7 @@ static void new_decoration(struct wl_listener *l, void *data) { static void panel_key(struct server *s, uint32_t code, bool pressed); -static void handle_vr_event(const struct ft_event *e, void *data) { +static void deliver_pointer_event(const struct ft_event *e, void *data) { struct server *s = data; if (e->type == FT_QUIT) { wl_display_terminate(s->display); @@ -335,11 +355,11 @@ static void handle_vr_event(const struct ft_event *e, void *data) { } if (e->dy != 0) wlr_seat_pointer_notify_axis(s->seat, t, WL_POINTER_AXIS_VERTICAL_SCROLL, e->dy * 15, - (int32_t)(e->dy * 120), WL_POINTER_AXIS_SOURCE_WHEEL, + (int32_t)llround(e->dy * 120), WL_POINTER_AXIS_SOURCE_WHEEL, WL_POINTER_AXIS_RELATIVE_DIRECTION_IDENTICAL); if (e->dx != 0) wlr_seat_pointer_notify_axis(s->seat, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL, e->dx * 15, - (int32_t)(e->dx * 120), WL_POINTER_AXIS_SOURCE_WHEEL, + (int32_t)llround(e->dx * 120), WL_POINTER_AXIS_SOURCE_WHEEL, WL_POINTER_AXIS_RELATIVE_DIRECTION_IDENTICAL); break; case FT_LEAVE: @@ -354,11 +374,150 @@ static void handle_vr_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. + 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, + WL_POINTER_BUTTON_STATE_RELEASED); + s->controller_buttons = 0; + 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"); + ft_vr_cursor_move(-1, 0, 0, 1, false); +} + +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. + 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; + } + 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]; + struct ft_event event = {.type = FT_MOTION, .screen = index, + .x = (s->mouse.x-o->x)*o->scale, .y = (s->mouse.y-o->y)*o->scale}; + deliver_pointer_event(&event, s); + const struct ft_mouse_output *display = &s->mouse.outputs[s->mouse.screen]; + ft_vr_cursor_move(s->mouse.screen, (s->mouse.x-display->x)*display->scale, + (s->mouse.y-display->y)*display->scale, display->scale, s->desktop_mouse); +} + +static void cursor_hide(struct server *s) { + ft_vr_cursor_image(NULL, 0, 0, 0, 0, 1); + if (s->cursor_held) wlr_buffer_unlock(s->cursor_held); + s->cursor_held = NULL; +} +static void cursor_update(struct server *s) { + struct wlr_surface *surface = s->cursor_surface; + struct wlr_buffer *buffer = surface ? ft_cursor_cached(&s->cursor_cache, surface) : NULL; + s->cursor_ready = false; + s->cursor_error = "no cached KDE cursor buffer"; + if (surface) s->cursor_frame_pending = true; + s->cursor_buffer = "none"; + if (!buffer) { cursor_hide(s); return; } + if (buffer->width < 1 || buffer->height < 1 || buffer->width > 512 || buffer->height > 512) { + cursor_hide(s); return; + } + int scale = surface->current.scale > 0 ? surface->current.scale : 1; + if (scale > 8 || s->cursor_hx < -512 || s->cursor_hy < -512 || s->cursor_hx > 512 || s->cursor_hy > 512) { + s->cursor_error = "invalid cursor scale or hotspot"; + cursor_hide(s); return; + } + struct wlr_dmabuf_attributes dma; + if (wlr_buffer_get_dmabuf(buffer, &dma)) { + s->cursor_buffer = "dmabuf"; + struct ft_dmabuf b = {.width = dma.width, .height = dma.height, .format = dma.format, + .modifier = dma.modifier, .n_planes = dma.n_planes}; + for (int i = 0; i < dma.n_planes && i < 4; ++i) { + b.offset[i] = dma.offset[i]; b.stride[i] = dma.stride[i]; b.fd[i] = dma.fd[i]; + } + track_buffer(s, buffer); + s->cursor_ready = ft_vr_cursor_dmabuf(buffer, &b, s->cursor_hx*scale, s->cursor_hy*scale, scale); + s->cursor_error = s->cursor_ready ? "" : "SteamVR rejected GPU cursor"; + if (s->cursor_ready) { + wlr_buffer_lock(buffer); + if (s->cursor_held) wlr_buffer_unlock(s->cursor_held); + s->cursor_held = buffer; + } else cursor_hide(s); + return; + } + s->cursor_buffer = "shm"; + void *data; uint32_t format; size_t stride; + if (!wlr_buffer_begin_data_ptr_access(buffer, WLR_BUFFER_DATA_PTR_ACCESS_READ, &data, &format, &stride)) { + s->cursor_error = "KDE cursor buffer cannot be read"; + cursor_hide(s); return; + } + uint8_t *rgba = malloc((size_t)buffer->width*buffer->height*4); + s->cursor_error = "unsupported cursor format or allocation failed"; + if (rgba && ft_cursor_rgba(rgba, data, buffer->width, buffer->height, stride, format)) { + s->cursor_error = "SteamVR rejected cursor texture"; + s->cursor_ready = ft_vr_cursor_image(rgba, buffer->width, buffer->height, + s->cursor_hx*scale, s->cursor_hy*scale, scale); + if (s->cursor_ready) s->cursor_error = ""; + } + free(rgba); + wlr_buffer_end_data_ptr_access(buffer); + if (!s->cursor_ready) cursor_hide(s); + else if (s->cursor_held) { wlr_buffer_unlock(s->cursor_held); s->cursor_held = NULL; } +} + +static void cursor_committed(struct wl_listener *l, void *data) { + struct server *s = wl_container_of(l, s, cursor_commit); + cursor_update(s); +} +static void cursor_destroyed(struct wl_listener *l, void *data) { + struct server *s = wl_container_of(l, s, cursor_destroy); + wl_list_remove(&s->cursor_commit.link); wl_list_remove(&s->cursor_destroy.link); + s->cursor_surface = NULL; s->cursor_ready = false; + cursor_hide(s); +} +static void cursor_requested(struct wl_listener *l, void *data) { + struct server *s = wl_container_of(l, s, request_cursor); + struct wlr_seat_pointer_request_set_cursor_event *e = data; + if (e->seat_client != s->seat->pointer_state.focused_client) return; + if (s->cursor_surface) { + wl_list_remove(&s->cursor_commit.link); wl_list_remove(&s->cursor_destroy.link); + } + s->cursor_surface = e->surface; s->cursor_hx = e->hotspot_x; s->cursor_hy = e->hotspot_y; + if (e->surface) { + s->cursor_commit.notify = cursor_committed; + s->cursor_destroy.notify = cursor_destroyed; + wl_signal_add(&e->surface->events.commit, &s->cursor_commit); + wl_signal_add(&e->surface->events.destroy, &s->cursor_destroy); + } + cursor_update(s); +} + // Tell the input relay where typing goes, on a change and every second: while it's the // desktop, the relay grabs pass-through keyboards so SteamVR (and the Steam app with // gamescope's focus) doesn't get the keys too. Without word from us for a few seconds, // the relay gives the keyboards back, so a closed desktop doesn't keep them. static void keys_update(struct server *s) { + if (!s->vr) return; // the isolated test desktop must never route live keyboards if (!s->vr) return; // a test instance leaves the running desktop's keyboards alone const bool desktop = s->keys_clicked && ft_vr_screens_shown(); const uint32_t t = now_ms(); @@ -377,6 +536,7 @@ static void keys_update(struct server *s) { // Every ~11 ms (90 Hz): SteamVR events, and frame callbacks for screens that committed. static int tick(void *data) { struct server *s = data; + update_controller_fallback(s); ft_vr_poll(handle_vr_event, s); if (++s->ticks % 9 == 0) keys_update(s); if (s->kb_close_at && s->ticks >= s->kb_close_at) { @@ -388,6 +548,10 @@ static int tick(void *data) { } struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now); + if (s->cursor_surface && s->cursor_frame_pending) { + s->cursor_frame_pending = false; + wlr_surface_send_frame_done(s->cursor_surface, &now); + } for (int i = 0; i < MAX_SCREENS; ++i) { struct screen *sc = s->screens[i]; if (sc && sc->frame_pending) { @@ -513,8 +677,100 @@ static int control_readable(int fd, uint32_t mask, void *data) { buf[n] = 0; unsigned code; int value, index, w, h; - double scale; - if (sscanf(buf, "size %d %d %d", &index, &w, &h) == 3) { + double scale, wheel_x, wheel_y; + double mx, my, mw, mh; + char mode[16], tail; + 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", + s->controller_fallback.present ? "true" : "false", + s->controller_fallback.enabled ? "true" : "false", + s->controller_desktop ? "true" : "false", + s->controller_fallback.known && (uint32_t)(now_ms()-s->controller_fallback.received)<3000 ? "true" : "false"); + } else if (sscanf(buf, "mouse-presence %d %d %c", &index, &value, &tail) == 2) { + if ((index != 0 && index != 1) || (value != 0 && value != 1)) + snprintf(reply, sizeof reply, "error invalid presence"); + else { + ft_fallback_presence(&s->controller_fallback, index, value, now_ms()); + update_controller_fallback(s); + snprintf(reply, sizeof reply, "ok"); + } + } else if (strcmp(buf, "mouse?") == 0) { + snprintf(reply, sizeof reply, + "{\"supported\":true,\"mode\":\"%s\",\"screen\":%d,\"x\":%.3f,\"y\":%.3f," + "\"buttons\":%u,\"cursorReady\":%s,\"cursorSource\":\"kde\",\"cursorCache\":true,\"cursorBuffer\":\"%s\",\"cursorError\":\"%s\"," + "\"motionEvents\":%lu,\"buttonEvents\":%lu,\"wheelEvents\":%lu,\"controllerEventsIgnored\":%lu}", + s->desktop_mouse ? "desktop" : "spatial", s->mouse.screen+1, s->mouse.x, s->mouse.y, + s->mouse.buttons, s->cursor_ready ? "true" : "false", s->cursor_buffer ? s->cursor_buffer : "none", s->cursor_error ? s->cursor_error : "no cursor request", s->mouse_motions, + s->mouse_clicks, s->mouse_wheels, s->vr_mouse_ignored); + } else if (sscanf(buf, "mouse-output-off %d %c", &index, &tail) == 1) { + if (index < 1 || index > MAX_SCREENS || s->mouse.buttons) + snprintf(reply, sizeof reply, "error invalid mouse output or held button"); + else { s->mouse.outputs[index-1].enabled = false; snprintf(reply, sizeof reply, "ok"); } + } else if (sscanf(buf, "mouse-layout %d %lf %lf %lf %lf %lf %c", &index, &mx, &my, &mw, &mh, &scale, &tail) == 6) { + if (index < 1 || index > MAX_SCREENS || !s->screens[index-1] || + !ft_mouse_output_set(&s->mouse, index-1, mx, my, mw, mh, scale)) + 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"))) + 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"); + else { + s->desktop_mouse = !strcmp(mode, "desktop"); + if (s->desktop_mouse) desktop_motion(s); + else { + wlr_seat_pointer_notify_clear_focus(s->seat); s->pointer_focus = NULL; + wlr_seat_pointer_notify_frame(s->seat); + ft_vr_cursor_move(-1, 0, 0, 1, false); + } + 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)) + 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 (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 || + !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"); + } + } 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 { + desktop_motion(s); + int target = ft_mouse_target(&s->mouse); + const struct ft_mouse_output *o = target >= 0 ? &s->mouse.outputs[target] : NULL; + if (!o || !s->screens[target]) snprintf(reply, sizeof reply, "error no mouse surface"); + else { + struct ft_event event = {.type = FT_BUTTON, .screen = target, .button = code, .pressed = value, + .x = (s->mouse.x-o->x)*o->scale, .y = (s->mouse.y-o->y)*o->scale}; + deliver_pointer_event(&event, s); + ft_mouse_button(&s->mouse, code, value); ++s->mouse_clicks; + if (!s->mouse.buttons) desktop_motion(s); + snprintf(reply, sizeof reply, "ok"); + } + } + } else if (sscanf(buf, "mouse-wheel %lf %lf %c", &wheel_x, &wheel_y, &tail) == 2) { + int target = ft_mouse_target(&s->mouse); + if (!s->desktop_mouse || (s->vr && !ft_vr_screens_shown()) || target < 0 || !s->screens[target] || + !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 { + desktop_motion(s); + struct ft_event event = {.type = FT_SCROLL, .screen = target, .dx = wheel_x, .dy = -wheel_y}; + deliver_pointer_event(&event, s); ++s->mouse_wheels; 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 +897,7 @@ static bool setup_dmabuf(struct server *s) { int main(int argc, char **argv) { struct server s = {0}; + ft_mouse_init(&s.mouse); 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"; @@ -685,7 +942,8 @@ int main(int argc, char **argv) { s.display = wl_display_create(); s.loop = wl_display_get_event_loop(s.display); wl_list_init(&s.buffers); - wlr_compositor_create(s.display, 6, NULL); + struct wlr_compositor *compositor = wlr_compositor_create(s.display, 6, NULL); + ft_cursor_cache_init(&s.cursor_cache, compositor); wlr_subcompositor_create(s.display); const uint32_t shm_formats[] = {DRM_FORMAT_ARGB8888, DRM_FORMAT_XRGB8888}; // wlroots wants DRM codes wlr_shm_create(s.display, 1, shm_formats, 2); @@ -700,6 +958,8 @@ int main(int argc, char **argv) { wl_signal_add(&s.decoration->events.new_toplevel_decoration, &s.new_decoration); s.seat = wlr_seat_create(s.display, "seat0"); + s.request_cursor.notify = cursor_requested; + wl_signal_add(&s.seat->events.request_set_cursor, &s.request_cursor); wlr_keyboard_init(&s.keyboard, &keyboard_impl, "ft-screens-keyboard"); struct xkb_context *xkb = xkb_context_new(XKB_CONTEXT_NO_FLAGS); struct xkb_keymap *keymap = xkb_keymap_new_from_names(xkb, NULL, XKB_KEYMAP_COMPILE_NO_FLAGS); @@ -746,6 +1006,7 @@ int main(int argc, char **argv) { // wlroots asserts that nothing still listens to its globals when they go. wl_list_remove(&s.new_toplevel.link); wl_list_remove(&s.new_decoration.link); + ft_cursor_cache_finish(&s.cursor_cache); ft_vr_shutdown(); wl_display_destroy(s.display); return 0; diff --git a/screens/controller-fallback.h b/screens/controller-fallback.h new file mode 100644 index 0000000..0c4fe76 --- /dev/null +++ b/screens/controller-fallback.h @@ -0,0 +1,19 @@ +#ifndef FT_CONTROLLER_FALLBACK_H +#define FT_CONTROLLER_FALLBACK_H +#include +#include +struct ft_controller_fallback { + bool known, present, enabled; + uint32_t received, absent_since; +}; +static inline void ft_fallback_presence(struct ft_controller_fallback *f, + bool present, bool enabled, uint32_t now) { + if (!f->known || (uint32_t)(now-f->received) >= 3000 || f->present || present) + f->absent_since = now; + f->known = true; f->present = present; f->enabled = enabled; f->received = now; +} +static inline bool ft_fallback_active(const struct ft_controller_fallback *f, uint32_t now) { + return f->known && f->enabled && !f->present && + (uint32_t)(now-f->received) < 3000 && (uint32_t)(now-f->absent_since) >= 1000; +} +#endif diff --git a/screens/cursor-cache.h b/screens/cursor-cache.h new file mode 100644 index 0000000..4059368 --- /dev/null +++ b/screens/cursor-cache.h @@ -0,0 +1,67 @@ +#pragma once +#include +#include +#include +#include + +// A rendererless compositor has no persistent surface texture. wlroots releases +// current.buffer after each commit callback. KDE commits a cursor before issuing +// set_cursor, so retain small buffers from the first commit, not from set_cursor. +// This also keeps animation/callback-only commits from erasing the last image. +struct ft_cursor_cache; +struct ft_cursor_frame { + struct ft_cursor_cache *cache; + struct wlr_surface *surface; + struct wlr_buffer *buffer; + struct wl_listener commit, destroy; + struct wl_list link; +}; +struct ft_cursor_cache { + struct wl_listener new_surface; + struct wl_list frames; +}; +static inline struct wlr_buffer *ft_cursor_cached(struct ft_cursor_cache *cache, struct wlr_surface *surface) { + struct ft_cursor_frame *f; + wl_list_for_each(f, &cache->frames, link) if (f->surface == surface) return f->buffer; + return NULL; +} +static inline void ft_cursor_frame_committed(struct wl_listener *listener, void *data) { + (void)data; + struct ft_cursor_frame *f = wl_container_of(listener, f, commit); + if (!(f->surface->current.committed & WLR_SURFACE_STATE_BUFFER)) return; + struct wlr_buffer *buffer = f->surface->current.buffer; + // Desktop DMA-BUFs go straight to SteamVR; do not retain them here. + struct wlr_buffer *next = buffer && buffer->width > 0 && buffer->height > 0 && + buffer->width <= 512 && buffer->height <= 512 ? wlr_buffer_lock(buffer) : NULL; + if (f->buffer) wlr_buffer_unlock(f->buffer); + f->buffer = next; +} +static inline void ft_cursor_frame_destroyed(struct wl_listener *listener, void *data) { + (void)data; + struct ft_cursor_frame *f = wl_container_of(listener, f, destroy); + wl_list_remove(&f->commit.link); wl_list_remove(&f->destroy.link); wl_list_remove(&f->link); + if (f->buffer) wlr_buffer_unlock(f->buffer); + free(f); +} +static inline void ft_cursor_surface_created(struct wl_listener *listener, void *data) { + struct ft_cursor_cache *cache = wl_container_of(listener, cache, new_surface); + struct wlr_surface *surface = data; + struct ft_cursor_frame *f = calloc(1, sizeof *f); + if (!f) { wl_client_post_no_memory(wl_resource_get_client(surface->resource)); return; } + f->cache = cache; f->surface = surface; + f->commit.notify = ft_cursor_frame_committed; + f->destroy.notify = ft_cursor_frame_destroyed; + wl_signal_add(&surface->events.commit, &f->commit); + wl_signal_add(&surface->events.destroy, &f->destroy); + wl_list_insert(&cache->frames, &f->link); +} +static inline void ft_cursor_cache_init(struct ft_cursor_cache *cache, struct wlr_compositor *compositor) { + wl_list_init(&cache->frames); + cache->new_surface.notify = ft_cursor_surface_created; + wl_signal_add(&compositor->events.new_surface, &cache->new_surface); +} +static inline void ft_cursor_cache_finish(struct ft_cursor_cache *cache) { + wl_list_remove(&cache->new_surface.link); + struct ft_cursor_frame *f, *tmp; + wl_list_for_each_safe(f, tmp, &cache->frames, link) ft_cursor_frame_destroyed(&f->destroy, NULL); +} diff --git a/screens/desktop-cursor.h b/screens/desktop-cursor.h new file mode 100644 index 0000000..17b3ee7 --- /dev/null +++ b/screens/desktop-cursor.h @@ -0,0 +1,26 @@ +#pragma once +#include +#include +#include +#include +#include +// Shared-memory Wayland cursor images use ARGB with premultiplied alpha. +// SteamVR's raw overlay texture is RGBA. Respect padding and recover RGB at edges. +static inline bool ft_cursor_rgba(uint8_t *out, const void *data, int w, int h, + size_t stride, uint32_t format) { + if (!data || w < 1 || h < 1 || w > 512 || h > 512 || stride < (size_t)w*4 || + (format != DRM_FORMAT_ARGB8888 && format != DRM_FORMAT_ABGR8888 && + format != DRM_FORMAT_XRGB8888 && format != DRM_FORMAT_XBGR8888)) return false; + const bool swap = format == DRM_FORMAT_ABGR8888 || format == DRM_FORMAT_XBGR8888; + const bool opaque = format == DRM_FORMAT_XRGB8888 || format == DRM_FORMAT_XBGR8888; + for (int y = 0; y < h; ++y) for (int x = 0; x < w; ++x) { + uint32_t v; memcpy(&v, (const uint8_t *)data + (size_t)y*stride + x*4, 4); + unsigned a = opaque ? 255 : v >> 24; + unsigned rgb[3] = {swap ? v & 255 : (v >> 16) & 255, (v >> 8) & 255, + swap ? (v >> 16) & 255 : v & 255}; + uint8_t *p = out + ((size_t)y*w+x)*4; + for (int c = 0; c < 3; ++c) { unsigned n = a ? (rgb[c]*255 + a/2)/a : 0; p[c] = n > 255 ? 255 : n; } + p[3] = a; + } + return true; +} diff --git a/screens/desktop-mouse.h b/screens/desktop-mouse.h new file mode 100644 index 0000000..7ec789b --- /dev/null +++ b/screens/desktop-mouse.h @@ -0,0 +1,61 @@ +#pragma once +#include +#include +#include + +#define FT_MOUSE_OUTPUTS 24 +struct ft_mouse_output { bool enabled; double x, y, width, height, scale; }; +struct ft_mouse { + struct ft_mouse_output outputs[FT_MOUSE_OUTPUTS]; + double x, y; + uint32_t buttons; + int screen, grab; + bool positioned; +}; +static inline void ft_mouse_init(struct ft_mouse *m) { *m = (struct ft_mouse){.screen = -1, .grab = -1}; } +static inline bool ft_mouse_output_set(struct ft_mouse *m, int i, double x, double y, + double w, double h, double scale) { + if (i < 0 || i >= FT_MOUSE_OUTPUTS || m->buttons || !isfinite(x) || !isfinite(y) || + !isfinite(w) || !isfinite(h) || !isfinite(scale) || fabs(x) > 65536 || fabs(y) > 65536 || + w < 1 || h < 1 || w > 16384 || h > 16384 || scale < .25 || scale > 8) return false; + m->outputs[i] = (struct ft_mouse_output){true, x, y, w, h, scale}; + return true; +} +static inline bool ft_mouse_position(struct ft_mouse *m, double x, double y) { + if (!isfinite(x) || !isfinite(y)) return false; + double best = INFINITY, bx = 0, by = 0; + int screen = -1; + for (int i = 0; i < FT_MOUSE_OUTPUTS; ++i) { + const struct ft_mouse_output *o = &m->outputs[i]; + if (!o->enabled) continue; + double px = fmin(fmax(x, o->x), o->x + o->width - 1); + double py = fmin(fmax(y, o->y), o->y + o->height - 1); + double d = (px-x)*(px-x) + (py-y)*(py-y); + if (d < best) best = d, bx = px, by = py, screen = i; + } + if (screen < 0) return false; + m->x = bx; m->y = by; m->screen = screen; m->positioned = true; + return true; +} +static inline bool ft_mouse_move(struct ft_mouse *m, double dx, double dy) { + if (!isfinite(dx) || !isfinite(dy) || fabs(dx) > 16384 || fabs(dy) > 16384) return false; + if (!m->positioned) { + for (int i = 0; i < FT_MOUSE_OUTPUTS; ++i) if (m->outputs[i].enabled) { + const struct ft_mouse_output *o = &m->outputs[i]; + if (!ft_mouse_position(m, o->x + o->width/2, o->y + o->height/2)) return false; + break; + } + } + return ft_mouse_position(m, m->x + dx, m->y + dy); +} +// The host seat's implicit grab stays on one KWin output during a drag. Motion +// remains in that output's coordinates, including positions beyond its edges; +// nested KWin adds the output origin and can drag windows across its monitors. +static inline int ft_mouse_target(const struct ft_mouse *m) { return m->buttons ? m->grab : m->screen; } +static inline bool ft_mouse_button(struct ft_mouse *m, unsigned button, bool down) { + if (!m->positioned || button < 272 || button > 279) return false; + uint32_t bit = 1u << (button - 272); + if (down) { if (!m->buttons) m->grab = m->screen; m->buttons |= bit; } + else { m->buttons &= ~bit; if (!m->buttons) m->grab = -1; } + return true; +} diff --git a/screens/tests/controller-fallback-test.c b/screens/tests/controller-fallback-test.c new file mode 100644 index 0000000..d72c22b --- /dev/null +++ b/screens/tests/controller-fallback-test.c @@ -0,0 +1,29 @@ +#include +#include +#include "../controller-fallback.h" +int main(void) { + struct ft_controller_fallback f = {0}; + assert(!ft_fallback_active(&f, 100)); + ft_fallback_presence(&f, false, true, 100); + assert(!ft_fallback_active(&f, 1099)); + assert(ft_fallback_active(&f, 1100)); + ft_fallback_presence(&f, false, true, 1100); + assert(ft_fallback_active(&f, 1100)); // heartbeat doesn't reset debounce + assert(!ft_fallback_active(&f, 4100)); // lost relay fails closed + ft_fallback_presence(&f, false, true, 4100); + assert(!ft_fallback_active(&f, 4100)); // old absence cannot bypass new debounce + assert(ft_fallback_active(&f, 5100)); + ft_fallback_presence(&f, true, true, 4200); + assert(!ft_fallback_active(&f, 9000)); // connected but idle is still a mouse + ft_fallback_presence(&f, false, false, 9100); + assert(!ft_fallback_active(&f, 10100)); // disabled preference + ft_fallback_presence(&f, true, true, 10200); + ft_fallback_presence(&f, false, true, 10300); + assert(!ft_fallback_active(&f, 11299)); + assert(ft_fallback_active(&f, 11300)); + ft_fallback_presence(&f, true, true, 11301); + assert(!ft_fallback_active(&f, 11301)); // reconnect wins immediately + ft_fallback_presence(&f, false, true, UINT32_MAX-500); + assert(ft_fallback_active(&f, 500)); // monotonic counter wrap + puts("disconnect debounce, heartbeat expiry, reconnect and disabled fallback passed"); +} diff --git a/screens/tests/cursor-cache-test.c b/screens/tests/cursor-cache-test.c new file mode 100644 index 0000000..b5a60ab --- /dev/null +++ b/screens/tests/cursor-cache-test.c @@ -0,0 +1,106 @@ +// Actual rendererless Wayland/wlroots lifecycle. No OpenVR or real desktop. +#define _GNU_SOURCE +#define WLR_USE_UNSTABLE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../cursor-cache.h" + +static struct wl_compositor *client_compositor; +static struct wl_shm *client_shm; +static void global(void *data, struct wl_registry *registry, uint32_t name, const char *interface, uint32_t version) { + (void)data; (void)version; + if (!strcmp(interface, "wl_compositor")) client_compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4); + if (!strcmp(interface, "wl_shm")) client_shm = wl_registry_bind(registry, name, &wl_shm_interface, 1); +} +static void removed(void *data, struct wl_registry *registry, uint32_t name) { (void)data; (void)registry; (void)name; } +static const struct wl_registry_listener registry_listener = {global, removed}; +static void stage(struct wl_display *display, int report, int ack, char value) { + assert(wl_display_roundtrip(display) >= 0); + assert(write(report, &value, 1) == 1); + char answer; + assert(read(ack, &answer, 1) == 1 && answer == value); +} +static void client(int fd, int report, int ack) { + alarm(10); + struct wl_display *d = wl_display_connect_to_fd(fd); + assert(d); + struct wl_registry *registry = wl_display_get_registry(d); + wl_registry_add_listener(registry, ®istry_listener, NULL); + assert(wl_display_roundtrip(d) >= 0 && client_compositor && client_shm); + int memory = memfd_create("offline-cursor", MFD_CLOEXEC); + assert(memory >= 0 && ftruncate(memory, 600*4) == 0); + uint32_t *pixels = mmap(NULL, 600*4, PROT_READ|PROT_WRITE, MAP_SHARED, memory, 0); + assert(pixels != MAP_FAILED); pixels[0] = 0xff123456; + struct wl_shm_pool *pool = wl_shm_create_pool(client_shm, memory, 600*4); + struct wl_buffer *small = wl_shm_pool_create_buffer(pool, 0, 8, 1, 32, WL_SHM_FORMAT_ARGB8888); + struct wl_buffer *large = wl_shm_pool_create_buffer(pool, 0, 600, 1, 2400, WL_SHM_FORMAT_ARGB8888); + struct wl_surface *surface = wl_compositor_create_surface(client_compositor); + wl_surface_attach(surface, small, 0, 0); wl_surface_commit(surface); + stage(d, report, ack, 1); // image committed before any set_cursor/consumer + wl_surface_commit(surface); stage(d, report, ack, 2); // callback-only commit preserves image + wl_surface_attach(surface, NULL, 0, 0); wl_surface_commit(surface); stage(d, report, ack, 3); + wl_surface_attach(surface, large, 0, 0); wl_surface_commit(surface); stage(d, report, ack, 4); + wl_surface_attach(surface, small, 0, 0); wl_surface_commit(surface); stage(d, report, ack, 5); + wl_surface_destroy(surface); stage(d, report, ack, 6); + wl_buffer_destroy(small); wl_buffer_destroy(large); wl_shm_pool_destroy(pool); + wl_registry_destroy(registry); wl_display_disconnect(d); + munmap(pixels, 600*4); close(memory); + _exit(0); +} +int main(void) { + alarm(10); + struct wl_display *display = wl_display_create(); + struct wlr_compositor *compositor = wlr_compositor_create(display, 6, NULL); + uint32_t formats[] = {DRM_FORMAT_ARGB8888, DRM_FORMAT_XRGB8888}; + assert(wlr_shm_create(display, 1, formats, 2)); + struct ft_cursor_cache cache; + ft_cursor_cache_init(&cache, compositor); + int connection[2], report[2], ack[2]; + assert(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, connection) == 0); + assert(pipe(report) == 0 && pipe(ack) == 0); + pid_t pid = fork(); assert(pid >= 0); + if (!pid) { close(connection[0]); close(report[0]); close(ack[1]); client(connection[1], report[1], ack[0]); } + close(connection[1]); close(report[1]); close(ack[0]); + fcntl(report[0], F_SETFL, O_NONBLOCK); + assert(wl_client_create(display, connection[0])); + struct wl_event_loop *loop = wl_display_get_event_loop(display); + struct wlr_buffer *first = NULL; + for (char expected = 1; expected <= 6;) { + assert(wl_event_loop_dispatch(loop, 10) >= 0); wl_display_flush_clients(display); + char observed; + if (read(report[0], &observed, 1) != 1) continue; + assert(observed == expected); + if (expected == 6) assert(wl_list_empty(&cache.frames)); + else { + assert(!wl_list_empty(&cache.frames)); + struct ft_cursor_frame *f = wl_container_of(cache.frames.next, f, link); + assert(f->surface->current.buffer == NULL); // wlroots already released it + struct wlr_buffer *buffer = ft_cursor_cached(&cache, f->surface); + if (expected == 3 || expected == 4) assert(buffer == NULL); + else { + assert(buffer && buffer->width == 8 && buffer->height == 1); + if (expected == 1) first = buffer; + if (expected == 2) assert(buffer == first); + void *data; uint32_t format; size_t stride; + assert(wlr_buffer_begin_data_ptr_access(buffer, WLR_BUFFER_DATA_PTR_ACCESS_READ, &data, &format, &stride)); + assert(*(uint32_t *)data == 0xff123456 && format == DRM_FORMAT_ARGB8888 && stride == 32); + wlr_buffer_end_data_ptr_access(buffer); + } + } + assert(write(ack[1], &observed, 1) == 1); ++expected; + } + int status; assert(waitpid(pid, &status, 0) == pid && WIFEXITED(status) && WEXITSTATUS(status) == 0); + close(report[0]); close(ack[1]); wl_display_destroy_clients(display); + ft_cursor_cache_finish(&cache); wl_display_destroy(display); + puts("cursor before selection, buffer-only lifetime, null/oversize commits and destruction passed"); +} diff --git a/screens/tests/desktop-cursor-test.c b/screens/tests/desktop-cursor-test.c new file mode 100644 index 0000000..bfb569e --- /dev/null +++ b/screens/tests/desktop-cursor-test.c @@ -0,0 +1,23 @@ +#include "../desktop-cursor.h" +#include +#include +int main(void) { + uint32_t input[] = {0x80402010, 0, 0xdeadbeef, 0xff102030, 0x00ffffff, 0}; + uint8_t out[16]; + assert(ft_cursor_rgba(out, input, 2, 2, 12, DRM_FORMAT_ARGB8888)); + assert(out[0] == 128 && out[1] == 64 && out[2] == 32 && out[3] == 128); + assert(out[4] == 0 && out[7] == 0); + assert(out[8] == 16 && out[9] == 32 && out[10] == 48 && out[11] == 255); + assert(out[12] == 0 && out[13] == 0 && out[14] == 0 && out[15] == 0); + assert(ft_cursor_rgba(out, input, 1, 1, 4, DRM_FORMAT_ABGR8888)); + assert(out[0] == 32 && out[1] == 64 && out[2] == 128); + assert(ft_cursor_rgba(out, input, 1, 1, 4, DRM_FORMAT_XRGB8888)); + assert(out[0] == 64 && out[1] == 32 && out[2] == 16 && out[3] == 255); + assert(ft_cursor_rgba(out, input, 1, 1, 4, DRM_FORMAT_XBGR8888)); + assert(out[0] == 16 && out[2] == 64 && out[3] == 255); + assert(!ft_cursor_rgba(out, input, 1, 1, 3, DRM_FORMAT_ARGB8888)); + assert(!ft_cursor_rgba(out, input, 513, 1, 2052, DRM_FORMAT_ARGB8888)); + assert(!ft_cursor_rgba(out, NULL, 1, 1, 4, DRM_FORMAT_ARGB8888)); + assert(!ft_cursor_rgba(out, input, 1, 1, 4, DRM_FORMAT_RGB565)); + puts("KDE cursor channels, alpha, padding and bounds passed"); +} diff --git a/screens/tests/desktop-input-integration.py b/screens/tests/desktop-input-integration.py new file mode 100644 index 0000000..6209348 --- /dev/null +++ b/screens/tests/desktop-input-integration.py @@ -0,0 +1,35 @@ +"""Only addresses upstream's isolated test compositor, never live input sockets.""" +import json +import socket +import time + +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() + for command in ('mouse-layout 1 0 0 960 540 2', + 'mouse-layout 2 960 0 540 960 2', 'mouse-output-off 24', + 'mouse-mode desktop', 'mouse-position 1 100 100', 'mouse-move 10 5', + 'mouse-button 272 1', 'mouse-move 900 0', 'mouse-button 272 0', + 'mouse-wheel 0 0.125'): + assert ask(command) == 'ok', command + assert ask('mouse-output-off 25').startswith('error') + assert ask('mouse-layout 25 0 0 960 540 1').startswith('error') + ask('mouse-presence 1 1') + before = json.loads(ask('mouse?')) + ask('input 1 down 200 100 left'); ask('input 1 up 200 100 left') + after = json.loads(ask('mouse?')) + assert after['controllerEventsIgnored'] >= before['controllerEventsIgnored'] + 2 + ask('mouse-presence 0 1') + time.sleep(1.1) + assert json.loads(ask('mouse-presence?'))['controllerDesktop'] + before = json.loads(ask('mouse?')) + ask('input 1 down 200 100 left') + after = json.loads(ask('mouse?')) + assert after['controllerEventsIgnored'] == before['controllerEventsIgnored'] + ask('mouse-presence 1 1') + 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') diff --git a/screens/tests/desktop-mouse-test.c b/screens/tests/desktop-mouse-test.c new file mode 100644 index 0000000..677b573 --- /dev/null +++ b/screens/tests/desktop-mouse-test.c @@ -0,0 +1,34 @@ +#include "../desktop-mouse.h" +#include +#include +int main(void) { + struct ft_mouse m; + ft_mouse_init(&m); + assert(!ft_mouse_move(&m, 0, 0)); + assert(ft_mouse_output_set(&m, 0, -1280, 0, 1280, 720, 1.5)); + assert(ft_mouse_output_set(&m, 1, 0, 0, 1920, 1080, 1)); + assert(ft_mouse_move(&m, 0, 0)); + assert(m.screen == 0 && m.x == -640 && m.y == 360); + assert(ft_mouse_position(&m, -1, 100)); + assert(ft_mouse_button(&m, 272, true)); + assert(!ft_mouse_output_set(&m, 0, 0, 0, 100, 100, 1)); + assert(ft_mouse_move(&m, 20, 0)); + assert(m.screen == 1 && m.x == 19 && ft_mouse_target(&m) == 0); + // During the grab, host coordinates exceed the old output's width. + assert((m.x-m.outputs[0].x)*m.outputs[0].scale == 1948.5); + assert(ft_mouse_button(&m, 275, true)); + assert(ft_mouse_button(&m, 272, false)); + assert(ft_mouse_target(&m) == 0); + assert(ft_mouse_button(&m, 275, false)); + assert(ft_mouse_target(&m) == 1 && m.grab == -1); + assert(ft_mouse_move(&m, 10000, 10000)); + assert(m.x == 1919 && m.y == 1079); + assert(!ft_mouse_move(&m, NAN, 0)); + assert(!ft_mouse_position(&m, INFINITY, 1)); + assert(!ft_mouse_move(&m, 16385, 0)); + assert(!ft_mouse_output_set(&m, FT_MOUSE_OUTPUTS, 0, 0, 100, 100, 1)); + assert(!ft_mouse_output_set(&m, 0, 0, 0, 0, 100, 1)); + assert(!ft_mouse_output_set(&m, 0, 0, 0, 100, 100, NAN)); + assert(!ft_mouse_button(&m, 280, true)); + puts("desktop mouse geometry, drag and validation passed"); +} diff --git a/screens/vr.cpp b/screens/vr.cpp index 53ddc5f..51e47b6 100644 --- a/screens/vr.cpp +++ b/screens/vr.cpp @@ -302,6 +302,12 @@ struct Screen { } }; std::map g_screens; +struct DesktopCursor { + vr::VROverlayHandle_t overlay = vr::k_ulOverlayHandleInvalid; + int width = 0, height = 0, hx = 0, hy = 0, scale = 1, screen = -1; + double x = 0, y = 0, outputScale = 1; + bool enabled = false, image = false; +} g_cursor; std::map g_imports; // Hand cutouts (see the top and handcut.h). @@ -584,6 +590,32 @@ Mat OnSurface(const Screen &s, double u, double v, double dz) { return m; } +void UpdateDesktopCursor() { + if (g_cursor.overlay == vr::k_ulOverlayHandleInvalid) return; + const auto it = g_screens.find(g_cursor.screen); + const auto activity = vr::VRSystem()->GetTrackedDeviceActivityLevel(vr::k_unTrackedDeviceIndex_Hmd); + const bool off = activity == vr::k_EDeviceActivityLevel_Idle || activity == vr::k_EDeviceActivityLevel_Standby || + activity == vr::k_EDeviceActivityLevel_Idle_Timeout; + Mat pose; + if (!g_cursor.enabled || !g_cursor.image || off || it == g_screens.end() || + !it->second.visible || it->second.width < 1 || !ScreenPose(it->second, &pose)) { + vr::VROverlay()->HideOverlay(g_cursor.overlay); + return; + } + const Screen &s = it->second; + const double perPixel = s.metres / s.width; + const double cursorPixel = perPixel * g_cursor.outputScale / g_cursor.scale; + const double u = (g_cursor.x - s.width/2.0)*perPixel + (g_cursor.width/2.0-g_cursor.hx)*cursorPixel; + const double v = (s.height/2.0-g_cursor.y)*perPixel + (g_cursor.hy-g_cursor.height/2.0)*cursorPixel; + const double width = g_cursor.width*cursorPixel; + const Mat m = Mul(pose, OnSurface(s, u, v, 0.004)); + vr::VROverlay()->SetOverlayTransformAbsolute(g_cursor.overlay, vr::TrackingUniverseStanding, &m); + vr::VROverlay()->SetOverlayWidthInMeters(g_cursor.overlay, float(width)); + vr::VROverlay()->SetOverlayCurvature(g_cursor.overlay, s.curve > 0 ? float(std::min(1.0, width/(2*M_PI*s.curve))) : 0); + vr::VROverlay()->SetOverlayAlpha(g_cursor.overlay, s.alpha); + vr::VROverlay()->ShowOverlay(g_cursor.overlay); +} + double BarY(const Screen &s) { return -(s.heightMetres() / 2 + s.chrome * 0.06 + s.chrome * 12 / 256); } Mat BarOffset(const Screen &s) { return OnSurface(s, 0, BarY(s), 0.003); } @@ -1540,6 +1572,7 @@ bool ft_vr_init(void) { void ft_vr_shutdown(void) { if (!g_vr) return; + if (g_cursor.overlay != vr::k_ulOverlayHandleInvalid) vr::VROverlay()->DestroyOverlay(g_cursor.overlay); if (g_cutterState == 1) for (auto &[i, s] : g_screens) g_cutter.DropPanel(i); // drops their imports while SteamVR is up if (g_catcher != vr::k_ulOverlayHandleInvalid) vr::VROverlay()->DestroyOverlay(g_catcher); @@ -1569,6 +1602,71 @@ int ft_vr_modifiers(uint32_t format, uint64_t *out, int max) { bool ft_vr_screens_shown(void) { return g_vr && ModeVisible(); } +static bool EnsureDesktopCursor() { + if (!g_vr) return false; + if (g_cursor.overlay == vr::k_ulOverlayHandleInvalid) { + if (vr::VROverlay()->CreateOverlay("frametop.desktop.cursor", "KDE desktop cursor", &g_cursor.overlay) != vr::VROverlayError_None) return false; + vr::VROverlay()->SetOverlayInputMethod(g_cursor.overlay, vr::VROverlayInputMethod_None); + vr::VROverlay()->SetOverlaySortOrder(g_cursor.overlay, 200); + } + return true; +} + +bool ft_vr_cursor_image(const uint8_t *rgba, int width, int height, int hx, int hy, int scale) { + if (!rgba) { g_cursor.image = false; UpdateDesktopCursor(); return true; } + if (width < 1 || height < 1 || width > 512 || height > 512 || scale < 1 || scale > 8 || !EnsureDesktopCursor()) return false; + if (vr::VROverlay()->SetOverlayRaw(g_cursor.overlay, const_cast(rgba), width, height, 4) != vr::VROverlayError_None) return false; + g_cursor.width = width; g_cursor.height = height; g_cursor.hx = hx; g_cursor.hy = hy; g_cursor.scale = scale; + g_cursor.image = true; + UpdateDesktopCursor(); + return true; +} + +void ft_vr_cursor_move(int screen, double x, double y, double scale, bool enabled) { + g_cursor.screen = screen; g_cursor.x = x; g_cursor.y = y; g_cursor.outputScale = scale; g_cursor.enabled = enabled; + UpdateDesktopCursor(); +} + +static vr::SharedTextureHandle_t ImportBuffer(const void *key, const struct ft_dmabuf *b) { + auto it = g_imports.find(key); + if (it == g_imports.end()) { + vr::DmabufAttributes_t a{}; + a.unWidth = uint32_t(b->width); + a.unHeight = uint32_t(b->height); + a.unDepth = a.unMipLevels = a.unArrayLayers = a.unSampleCount = 1; + a.unFormat = b->format; + a.ulModifier = b->modifier; + a.unPlaneCount = uint32_t(b->n_planes); + for (int i = 0; i < b->n_planes && i < int(vr::MaxDmabufPlaneCount); ++i) { + a.plane[i].unOffset = b->offset[i]; + a.plane[i].unStride = b->stride[i]; + a.plane[i].nFd = b->fd[i]; + } + vr::SharedTextureHandle_t h = 0; + if (!vr::VRIPCResourceManager()->ImportDmabuf(vr::VRApplication_Overlay, &a, &h)) { + std::fprintf(stderr, "openvr: ImportDmabuf failed: %dx%d format 0x%x modifier 0x%llx\n", b->width, + b->height, b->format, (unsigned long long)b->modifier); + return 0; + } + it = g_imports.emplace(key, h).first; + } + return it->second; +} + +bool ft_vr_cursor_dmabuf(const void *key, const struct ft_dmabuf *b, int hx, int hy, int scale) { + if (!b || b->width < 1 || b->height < 1 || b->width > 512 || b->height > 512 || + scale < 1 || scale > 8 || !EnsureDesktopCursor()) return false; + vr::SharedTextureHandle_t handle = ImportBuffer(key, b); + if (!handle) return false; + vr::Texture_t tex = {&handle, vr::TextureType_SharedTextureHandle, vr::ColorSpace_Gamma}; + if (vr::VROverlay()->SetOverlayTexture(g_cursor.overlay, &tex) != vr::VROverlayError_None) return false; + g_cursor.width = b->width; g_cursor.height = b->height; g_cursor.hx = hx; g_cursor.hy = hy; g_cursor.scale = scale; + g_cursor.image = true; + UpdateDesktopCursor(); + return true; +} + + } // extern "C" namespace { @@ -1893,6 +1991,7 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) { UpdateLasers(); UpdateControls(); UpdateGuides(); + UpdateDesktopCursor(); UpdateCutouts(); UpdateCatcher(); } diff --git a/screens/vr.h b/screens/vr.h index eb7abf8..15e93e7 100644 --- a/screens/vr.h +++ b/screens/vr.h @@ -35,6 +35,11 @@ void ft_vr_shutdown(void); int ft_vr_modifiers(uint32_t format, uint64_t *out, int max); // The screens are showing (by the visibility mode; not counting a wrist-pinned screen). bool ft_vr_screens_shown(void); +// Desktop cursor: KDE supplies the image; this is a non-interactive display +// overlay only. Mouse input goes straight to the Wayland seat, never SteamVR. +bool ft_vr_cursor_image(const uint8_t *rgba, int width, int height, int hotspot_x, int hotspot_y, int buffer_scale); +bool ft_vr_cursor_dmabuf(const void *key, const struct ft_dmabuf *buf, int hotspot_x, int hotspot_y, int buffer_scale); +void ft_vr_cursor_move(int screen, double pixel_x, double pixel_y, double output_scale, bool enabled); // A panel for screen `index`, width in metres, placed in a row in front of the head. void ft_vr_screen_create(int index, double metres, int count); void ft_vr_screen_destroy(int index); diff --git a/scripts/test-desktop-input.sh b/scripts/test-desktop-input.sh new file mode 100755 index 0000000..68e4295 --- /dev/null +++ b/scripts/test-desktop-input.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Offline native input tests; --headless also starts an isolated test desktop. +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$root" +[[ $# == 0 || ($# == 1 && $1 == --headless) ]] || { echo 'Usage: scripts/test-desktop-input.sh [--headless]' >&2; exit 2; } +python3 -m unittest discover -s input -p 'test_desktop_mouse.py' +"$root/scripts/frame.sh" -C screens 'set -e; mkdir -p build +gcc -std=c11 -Wall -Wextra -Werror tests/desktop-mouse-test.c -lm -o build/desktop-mouse-test +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 $(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 +build/cursor-cache-test' +if [[ ${1:-} == --headless ]]; then + # Reuses upstream's throwaway @ft_screens_test fixture, never the live desktop. + if pgrep -f '[f]t-screens.*--control ft_screens_test' >/dev/null; then + echo 'Test desktop already running; stop it yourself before this test.' >&2 + exit 1 + fi + "$root/screens/build.sh" + trap '"$root/screens/test/headless.sh" stop' EXIT + "$root/screens/test/headless.sh" start 2 3 + python3 "$root/screens/tests/desktop-input-integration.py" +fi diff --git a/session/frametop-session.sh b/session/frametop-session.sh index b212cee..79f91ad 100755 --- a/session/frametop-session.sh +++ b/session/frametop-session.sh @@ -177,6 +177,11 @@ export XDG_CONFIG_HOME=$HOME/.config/frametop export XDG_STATE_HOME=$HOME/.local/state/frametop mkdir -p "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" +# Steam's VR session exports a 256px cursor. Use this desktop's own KDE +# preferences instead of inheriting the dashboard cursor size and theme. +export XCURSOR_SIZE=$(kreadconfig6 --file kcminputrc --group Mouse --key cursorSize --default 24) +export XCURSOR_THEME=$(kreadconfig6 --file kcminputrc --group Mouse --key cursorTheme --default breeze_cursors) + # Remote desktop over VNC: session/remote-desktop.sh captures the desktop with # krdp on 127.0.0.1, and session/vnc-bridge.sh re-serves its primary screen over VNC. krdpserver runs from the container, so KWin can't # match it to an installed app. KWin's permission check for screencast and fake diff --git a/session/frametop.conf.example b/session/frametop.conf.example index 0d1b17b..67a86ac 100644 --- a/session/frametop.conf.example +++ b/session/frametop.conf.example @@ -10,6 +10,9 @@ REMOTE=0 # 1 = serve the desktop's primary screen over VNC on the tail FLOAT_SLOTS=8 # screens backend: how many windows can float in VR at once (spare outputs; 0 turns it off; restart the desktop after a change) FLOAT_MARGIN=300 # floating windows: pixels around each window on its output, so menus have room past its edges POINTER=0 # 1 = the mouse drives the universal 3D pointer (ft_pointer driver) instead of a plain mouse +MOUSE_MODE=spatial # desktop: physical mouse controls KDE directly; controllers remain separate. CLI: input/ft-mousectl desktop --save +DESKTOP_MOUSE_SPEED=1 # logical desktop pixels per physical mouse count (0.05 to 10) +DESKTOP_CONTROLLER_FALLBACK=1 # desktop controller pointers when no routed physical mouse is connected POINTER_SENSITIVITY=0.03 # degrees per mouse count POINTER_IDLE=30 # seconds without mouse use before the controllers get their laser back POINTER_WAKE_COUNTS=40 # mouse counts within 1 s needed to wake or re-claim the laser (ignores desk jitter)