diff --git a/README.md b/README.md index 2df4a3b..2067813 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ If you work in the desktop for long stretches, or leave the headset on a stand, | Leave the headset on a stand | Its displays turn off once it has gone unused for the time set in Frametop Display Settings → Power, even if the stand covers its proximity sensor. Pick it up, or use any mouse, keyboard, or button, and they come back on | | Play a VR game | The screens hide and your controllers stay in the game. Open the SteamVR dashboard, or press Meta+Shift+H, to see and use them. To keep them visible over games, change During VR games on the Visibility & pins tab; the controllers still stay in the game, and you use the screens with the mouse or the dashboard | -You can map the mouse's extra buttons to actions such as Toggle SteamVR dashboard, Recenter pointer, or Head follow on/off on the Buttons page of Frametop Input Settings, and the Frame controllers' buttons on its Controllers page. Pointer speed, dot size, and the rest are on its Pointer page and take effect immediately. Head follow, which is experimental and off by default, makes the pointer come along when you turn your head: it stays put until your head turns past the leash angle, then glides back to its place in your view, and a leash of 0 keeps it fixed in your view. It's only lightly tested and not polished; tuning its settings, or improving how it feels, is open to anyone who wants to take it further. +You can map the mouse's extra buttons to actions such as Toggle SteamVR dashboard, Recenter pointer, or Head follow on/off on the Buttons page of Frametop Input Settings, and the Frame controllers' buttons on its Controllers page. Pointer speed, dot size, and the rest are on its Pointer page and take effect immediately. If a panel you only look at, such as a performance overlay that follows your view, keeps catching the dot, tick it (or its whole app) on the Ignored panels page, and the pointer passes through it. Head follow, which is experimental and off by default, makes the pointer come along when you turn your head: it stays put until your head turns past the leash angle, then glides back to its place in your view, and a leash of 0 keeps it fixed in your view. It's only lightly tested and not polished; tuning its settings, or improving how it feels, is open to anyone who wants to take it further. Restarting the desktop (Restart desktop in Frametop Display Settings) closes its windows, but background work you started in it, such as servers, tmux sessions, or builds, keeps running. diff --git a/docs/design.md b/docs/design.md index de4c8af..f99046a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -74,7 +74,7 @@ The driver starts disconnected, because holding the right-hand role while SteamV ### The cursor -Mouse motion turns into yaw and pitch around an anchor, the head position at the last recenter. A ray from the anchor is tested against every visible overlay with `ComputeOverlayIntersection`. On a hit, the cursor sits on that surface; otherwise it floats at `POINTER_DISTANCE`. Since the anchor isn't your current eye position, a second test runs along your line of sight to the cursor point, and anything nearer wins, so the cursor always lands on what you see under it. +Mouse motion turns into yaw and pitch around an anchor, the head position at the last recenter. A ray from the anchor is tested against every visible overlay with `ComputeOverlayIntersection`. On a hit, the cursor sits on that surface; otherwise it floats at `POINTER_DISTANCE`. Since the anchor isn't your current eye position, a second test runs along your line of sight to the cursor point, and anything nearer wins, so the cursor always lands on what you see under it. Overlays in `POINTER_IGNORE` are left out of both tests. A display-only panel, like a performance overlay locked to your view, has no input method, so SteamVR's laser passes through it, but `ComputeOverlayIntersection` still hits it, and the cursor stuck to it. The laser starts just before the cursor point, so an ignored panel nearer to you doesn't catch it either. OpenVR has no call to list other programs' overlays, so the helper runs `vrcmd --overlays` in the background. It includes hidden overlays, because a floating window's controls only appear while something hovers the window, and the cursor has to find them immediately. diff --git a/docs/reference.md b/docs/reference.md index 970d29f..4caabc5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,16 +102,17 @@ pointer/helper/run.sh status | log | restart pointer/driver/install.sh probe # devices, hand roles, who owns the dashboard pointer ``` -The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `POINTER_IDLE`, `POINTER_WAKE_COUNTS`, `POINTER_CONTROLLER_PICKUP`, `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, `POINTER_ORIGIN_FRACTION`, `POINTER_ORIGIN_MARGIN`, `POINTER_SCENE_RADIUS`, `POINTER_EDGE_REACH`, `POINTER_LASER_WIDTH`, the head follow settings `POINTER_FOLLOW`, `POINTER_LEASH_DEG`, `POINTER_LEASH_DELAY`, `POINTER_LEASH_RETURN`, and `POINTER_FOLLOW_REACH`, and the gaze mode settings `POINTER_GAZE`, `POINTER_GAZE_RETAKE`, `POINTER_GAZE_NUDGE_MAX`, `POINTER_GAZE_HOLD`, and `POINTER_GAZE_SHOW`. The example config explains each. Frametop Input Settings changes them live; after editing the file by hand, restart the relay or the helper. +The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `POINTER_IDLE`, `POINTER_WAKE_COUNTS`, `POINTER_CONTROLLER_PICKUP`, `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, `POINTER_ORIGIN_FRACTION`, `POINTER_ORIGIN_MARGIN`, `POINTER_SCENE_RADIUS`, `POINTER_EDGE_REACH`, `POINTER_LASER_WIDTH`, `POINTER_IGNORE`, the head follow settings `POINTER_FOLLOW`, `POINTER_LEASH_DEG`, `POINTER_LEASH_DELAY`, `POINTER_LEASH_RETURN`, and `POINTER_FOLLOW_REACH`, and the gaze mode settings `POINTER_GAZE`, `POINTER_GAZE_RETAKE`, `POINTER_GAZE_NUDGE_MAX`, `POINTER_GAZE_HOLD`, and `POINTER_GAZE_SHOW`. The example config explains each. Frametop Input Settings changes them live; after editing the file by hand, restart the relay or the helper. ## Frametop Input Settings -A Kirigami app with a Python backend, in the Plasma menu under Settings. It runs in the `dev` container and talks to the relay over its control socket, `@frametop_relay`. It has six pages: +A Kirigami app with a Python backend, in the Plasma menu under Settings. It runs in the `dev` container and talks to the relay over its control socket, `@frametop_relay`. It has seven pages: - Devices lists every USB and Bluetooth mouse and keyboard, with a light that flashes when the device is used. Each device gets a role: 3D pointer (grabbed, drives the pointer; the default for anything with a mouse), Pass through (grabbed only while typing goes to the desktop; the default for keyboards, where a Meta tap toggles the dashboard if `META_DASHBOARD=1` is in `~/.config/frametop.conf`), or Ignore. A device is identified by its Bluetooth address, or its USB ids and name, so all of its input nodes share one role. Forget drops everything saved for a device. - Buttons maps a pointer device's buttons. Choose Capture a button, press the button or key, then pick an action: a click, back, scroll, toggle dashboard, recenter, pointer on or off, head follow on or off, gaze pointer on or off, faster or slower, pass the key through, or nothing. Devices with saved mappings are listed even while they're asleep. - Controllers maps the Frame controllers' buttons (every button but the system button) to the same actions, except passing a key through. Capture a button and press it on a controller, or pick it from the list. The controllers aren't input devices on the host; only SteamVR sees them. So the pointer helper reads them with SteamVR input (`pointer/helper/vrbuttons.h`, `pointer/helper/actions/`) and sends presses to the relay (`vrbtn right/a 1`), which does the mapped action. The helper only takes the buttons that are mapped (the relay tells it with `vrbind`), at an overlay-global priority, and only while no game (scene application) runs, so games keep every button; with In games on (`controller_in_games`), a mapped button is taken from games too. That needs SteamVR's "Enable global input from overlays (Experimental)" setting (`steamvr/globalActionSetPriority`), which the page's Global input switch turns on and off. Mappings are saved as `controller_buttons` in `~/.config/frametop-input.json`. - Pointer has a Head follow switch and sliders for the pointer settings, which apply immediately, and a Recenter button. +- Ignored panels lists the SteamVR overlays that are showing, grouped by app (the first two parts of the overlay key, such as `sasaken.frame-perf-overlay`), from the pointer helper (`overlays`). Tick a panel, or Ignore the whole app, and the pointer passes through it to what's behind. It's for panels you only look at, like a performance overlay that follows your view. The list is saved as `POINTER_IGNORE` in `~/.config/frametop.conf`: comma-separated overlay keys, where a shell pattern like `vendor.app*` covers a whole app, including panels it opens later. The helper reloads at once. Frametop's own screens aren't listed, and entries for apps that aren't open are listed below, to remove. - Gaze has the gaze pointer switch (on now and from now on; a mapped button toggles it until the helper restarts), the gaze mode sliders, the gaze service's state (headset, samples per second, how often the tracker is losing each eye, the calibration, the nudges learned), and Calibrate (opens the gaze probe), Check headset fit (opens the probe's Headset fit mode), Reload calibration, and Forget nudges. - Bluetooth lists paired devices and has Apply Bluetooth fixes, which runs `/etc/steamframe/bt-fixups.sh` through `pkexec`. Pair new devices in Steam. diff --git a/input-settings/ft_input_settings.py b/input-settings/ft_input_settings.py index 3e7f7f5..e8040b7 100644 --- a/input-settings/ft_input_settings.py +++ b/input-settings/ft_input_settings.py @@ -10,6 +10,8 @@ to the input relay over its control socket (@frametop_relay): helper through SteamVR input (@ft_pointer_helper: vrstatus, vrglobal), and a mapped button is taken from games. - Pointer: speed, dot size, distance and the rest, applied live. + - Ignored panels: SteamVR overlays the pointer passes through (POINTER_IGNORE), by app or + one by one. The helper lists them (@ft_pointer_helper "overlays"). - Gaze: the pointer's gaze mode (@ft_pointer_helper "gaze") and the gaze service (gaze/ft-gazed, @ft_gazed: status, forget, reload). - Bluetooth: paired devices, and re-applying the Bluetooth LE fixes after pairing. @@ -21,6 +23,7 @@ Rules go to ~/.config/frametop-input.json and pointer settings to ~/.config/frametop.conf; then the relay (and through it the helper) reloads. Launch with input-settings/ft-input-settings (host wrapper). """ +import fnmatch import json import os import re @@ -97,6 +100,13 @@ POINTER_SETTINGS = [ ("POINTER_IDLE", "Release after idle", 30, 5, 120, 5, "s"), ("POINTER_CONTROLLER_PICKUP", "Controller movement to take over", 1.0, 0.5, 5.0, 0.1, "×"), ] +# Overlay keys (shell patterns) the pointer passes through, comma-separated. +IGNORE_KEY = "POINTER_IGNORE" + + +def overlay_app(key): + """The app an overlay key belongs to, by the vendor.app.overlay convention.""" + return ".".join(key.split(".")[:2]) def code_names(): @@ -196,6 +206,7 @@ class Backend(QObject): controllersChanged = Signal() gazeChanged = Signal() driverChanged = Signal() + panelsChanged = Signal() activity = Signal(str) # device id captured = Signal(int, str) # code, name capturedController = Signal(str, str) # button, label @@ -217,6 +228,7 @@ class Backend(QObject): self._gaze_at = 0.0 self._gaze_mode = None # the helper's gaze mode: True, False, None (no answer) self._driver_block = "" # set by _check_driver + self._panels = None # SteamVR's overlays, from the helper; None until it answers self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) self.sock.bind("") # autobind an abstract address the relay can reply to self.sock.setblocking(False) @@ -298,6 +310,11 @@ class Backend(QObject): self._pointer_mode = bool(msg.get("pointer_mode")) self._relay_ok = True self.devicesChanged.emit() + elif t == "overlays": + panels = [o for o in msg.get("list", []) if isinstance(o, dict) and isinstance(o.get("key"), str)] + if panels != self._panels: + self._panels = panels + self.panelsChanged.emit() elif t == "vrstatus": self._vr = msg self._vr_at = time.monotonic() @@ -506,6 +523,80 @@ class Backend(QObject): def recenter(self): return self._send("recenter", HELPER) + # --- ignored panels --- + @staticmethod + def _ignore_list(): + return [p.strip() for p in read_conf().get(IGNORE_KEY, "").split(",") if p.strip()] + + def _save_ignore(self, entries, text): + write_conf_value(IGNORE_KEY, ", ".join(entries)) + # Only the helper reads it; the relay passes "reload" on only in pointer mode. + self._send("reload", HELPER) + self.panelsChanged.emit() + self.message.emit(text, False) + + def _open_panels(self): + """The helper's overlays, minus Frametop's own: ignoring a screen would leave nothing + to click this app on with the mouse.""" + return [o for o in self._panels or [] if not o["key"].startswith("frametop.")] + + @Slot() + def refreshPanels(self): + """Ask the helper for the overlay list; it answers once it has listed them again.""" + self._send("overlays", HELPER) + + @Property(bool, notify=panelsChanged) + def panelsLoaded(self): + return self._panels is not None + + @Property("QVariantList", notify=panelsChanged) + def panelGroups(self): + """Open overlays by app: {app, title, appIgnored, anyVisible, anyIgnored, panels: [{key, + name, visible, ignoredBy}]}. ignoredBy is the entry that ignores it ("" if none).""" + entries = self._ignore_list() + groups = {} + for o in self._open_panels(): + key = o["key"] + app = overlay_app(key) + g = groups.setdefault(app, {"app": app, "title": app, "panels": []}) + name = o.get("name") or key + if key == app: + g["title"] = name + g["panels"].append({"key": key, "name": name, "visible": bool(o.get("visible")), + "ignoredBy": next((p for p in entries if fnmatch.fnmatchcase(key, p)), "")}) + for g in groups.values(): + g["appIgnored"] = g["app"] + "*" in entries + g["anyVisible"] = any(p["visible"] for p in g["panels"]) + g["anyIgnored"] = any(p["ignoredBy"] for p in g["panels"]) + g["panels"].sort(key=lambda p: (not p["visible"], p["key"])) + return sorted(groups.values(), key=lambda g: (not g["anyVisible"], g["title"].lower())) + + @Property("QVariantList", notify=panelsChanged) + def ignoreOrphans(self): + """Entries that match no open overlay (the app isn't running), so they can be removed.""" + keys = [o["key"] for o in self._open_panels()] + return [p for p in self._ignore_list() if not any(fnmatch.fnmatchcase(k, p) for k in keys)] + + @Slot(str, bool) + def setPanelIgnored(self, key, on): + entries = [p for p in self._ignore_list() if p != key] + if on: + entries.append(key) + self._save_ignore(entries, f"{key}: " + ("the pointer passes through it" if on else "the pointer lands on it again")) + + @Slot(str, bool) + def setAppIgnored(self, app, on): + pattern = app + "*" + entries = [p for p in self._ignore_list() if p != pattern] + if on: + entries.append(pattern) + self._save_ignore(entries, f"{app}: " + ("the pointer passes through all its panels" if on + else "no longer ignored as a whole")) + + @Slot(str) + def removeIgnore(self, pattern): + self._save_ignore([p for p in self._ignore_list() if p != pattern], f"{pattern}: no longer ignored") + # --- controllers --- @Property("QVariantList", constant=True) def controllerActions(self): diff --git a/input-settings/main.qml b/input-settings/main.qml index 0e46e8b..0bc5e6e 100644 --- a/input-settings/main.qml +++ b/input-settings/main.qml @@ -20,6 +20,7 @@ Kirigami.ApplicationWindow { Kirigami.Action { text: "Buttons"; icon.name: "input-keyboard"; onTriggered: root.show(buttonsPage) }, Kirigami.Action { text: "Controllers"; icon.name: "input-gamepad"; onTriggered: root.show(controllersPage) }, Kirigami.Action { text: "Pointer"; icon.name: "transform-move"; onTriggered: root.show(pointerPage) }, + Kirigami.Action { text: "Ignored panels"; icon.name: "view-hidden"; onTriggered: root.show(ignorePage) }, Kirigami.Action { text: "Gaze"; icon.name: "view-visible"; onTriggered: root.show(gazePage) }, Kirigami.Action { text: "Bluetooth"; icon.name: "preferences-system-bluetooth"; onTriggered: root.show(bluetoothPage) } ] @@ -54,9 +55,9 @@ Kirigami.ApplicationWindow { pageStack.push(page) } - // FT_INPUT_PAGE=buttons|controllers|pointer|gaze|bluetooth opens the app on that page. - pageStack.initialPage: ({ buttons: buttonsPage, controllers: controllersPage, pointer: pointerPage, gaze: gazePage, - bluetooth: bluetoothPage })[startPage] || devicesPage + // FT_INPUT_PAGE=buttons|controllers|pointer|ignore|gaze|bluetooth opens the app on that page. + pageStack.initialPage: ({ buttons: buttonsPage, controllers: controllersPage, pointer: pointerPage, + ignore: ignorePage, gaze: gazePage, bluetooth: bluetoothPage })[startPage] || devicesPage Connections { target: backend @@ -575,6 +576,142 @@ Kirigami.ApplicationWindow { } } + // ---------------------------------------------------------------- Ignored panels + Component { + id: ignorePage + Kirigami.ScrollablePage { + id: ipage + title: "Ignored panels" + header: DriverWarning {} + + Timer { + // The helper lists SteamVR's panels again for each request. + running: true + repeat: true + triggeredOnStart: true + interval: 3000 + onTriggered: backend.refreshPanels() + } + + ColumnLayout { + spacing: Kirigami.Units.largeSpacing + + Kirigami.InlineMessage { + Layout.fillWidth: true + visible: !backend.controllerStatus.helper + type: Kirigami.MessageType.Error + text: "The pointer helper isn't answering (frametop-pointer.service, needs SteamVR). " + + "It lists SteamVR's panels and does the ignoring." + } + Controls.Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: "The mouse pointer passes through the panels ticked here to whatever is behind them, " + + "as if they weren't there. Use it for panels you only look at, like a performance " + + "overlay that follows your view. Ignore a whole app, or only some of its panels. " + + "Controllers aren't affected." + } + Controls.Switch { + id: showHidden + text: "Also list panels that aren't showing now" + } + Controls.Label { + visible: backend.controllerStatus.helper && !backend.panelsLoaded + text: "Asking the pointer helper for SteamVR's panels…" + opacity: 0.7 + } + + Repeater { + model: backend.panelGroups + delegate: ColumnLayout { + id: grp + required property var modelData + Layout.fillWidth: true + visible: showHidden.checked || modelData.anyVisible || modelData.anyIgnored + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Kirigami.Heading { + level: 4 + text: grp.modelData.title + } + Controls.Label { + text: grp.modelData.app + opacity: 0.6 + } + Item { Layout.fillWidth: true } + Controls.CheckBox { + text: "Ignore the whole app" + checked: grp.modelData.appIgnored + onToggled: backend.setAppIgnored(grp.modelData.app, checked) + } + } + Repeater { + model: grp.modelData.panels + delegate: RowLayout { + id: prow + required property var modelData + // Ignored by the whole app, or by a pattern written in frametop.conf. + readonly property bool byOther: modelData.ignoredBy !== "" && modelData.ignoredBy !== modelData.key + visible: showHidden.checked || modelData.visible || modelData.ignoredBy !== "" + Layout.leftMargin: Kirigami.Units.gridUnit + Controls.CheckBox { + text: prow.modelData.name + checked: prow.modelData.ignoredBy !== "" + enabled: !prow.byOther + onToggled: backend.setPanelIgnored(prow.modelData.key, checked) + } + Controls.Label { + text: prow.modelData.key + opacity: 0.6 + } + Controls.Label { + text: (prow.modelData.visible ? "showing" : "hidden") + + (!prow.byOther ? "" + : prow.modelData.ignoredBy === grp.modelData.app + "*" ? ", whole app ignored" + : ", ignored by " + prow.modelData.ignoredBy) + opacity: 0.6 + } + } + } + } + } + + Kirigami.Heading { + visible: backend.ignoreOrphans.length > 0 + level: 3 + text: "Ignored, not open now" + } + Repeater { + model: backend.ignoreOrphans + delegate: RowLayout { + id: orow + required property string modelData + Controls.Label { + text: orow.modelData + Layout.preferredWidth: Kirigami.Units.gridUnit * 16 + } + Controls.Button { + text: "Remove" + icon.name: "edit-delete-remove" + onClicked: backend.removeIgnore(orow.modelData) + } + } + } + } + + footer: Controls.Label { + padding: Kirigami.Units.largeSpacing + wrapMode: Text.Wrap + opacity: 0.7 + text: "Saved as POINTER_IGNORE in ~/.config/frametop.conf, and applied at once. A whole app is " + + "its key followed by *, which also covers panels it opens later. Frametop's own screens " + + "aren't listed." + } + } + } + // ---------------------------------------------------------------- Gaze Component { id: gazePage diff --git a/pointer/helper/ft-pointer.cpp b/pointer/helper/ft-pointer.cpp index 765eb68..dbd6cb8 100644 --- a/pointer/helper/ft-pointer.cpp +++ b/pointer/helper/ft-pointer.cpp @@ -169,10 +169,17 @@ // about 7 cm, so it isn't used. The result is measured again, and the move repeated up to // twice while it's more than 1.5 cm or 1 deg off. // +// Ignored panels: overlays matching POINTER_IGNORE are left out of the collision, so the cursor +// passes through them to what's behind. For display-only panels in the way, such as a +// head-locked performance overlay, which ComputeOverlayIntersection hits like any other. The +// laser starts just before the cursor point (see Looks), so a panel nearer to you doesn't +// catch it either. +// // Commands (datagrams on @ft_pointer_helper): show, hide, recenter, move , // follow on|off|toggle (head follow, until the next restart or a change to POINTER_FOLLOW), // gaze on|off|toggle|? (gaze mode, likewise with POINTER_GAZE; ? only asks), gz ... (the gaze, from ft-gazed), // reload (re-read the settings below), debug (toggle a twice-a-second state log), +// overlays (replies with the overlay list as JSON, see OverlayList), // vrbind/vrglobal/vrstatus (Frame controller buttons, see vrbuttons.h), // and btn/scroll lines, which are forwarded to the driver unchanged. For layouts, with a // reply datagram to the sender's (abstract) address: @@ -195,7 +202,7 @@ // POINTER_FOLLOW_REACH (70 deg): head follow, above. POINTER_GAZE (0), POINTER_GAZE_RETAKE // (5 deg), POINTER_GAZE_NUDGE_MAX (8 deg), POINTER_GAZE_HOLD (0.5 s), POINTER_GAZE_SHOW (1 s): // gaze mode, above. POINTER_CONTROLLER_PICKUP (1, 0.5 to 5): how hard a controller must -// move to take the laser back, above. +// move to take the laser back, above. POINTER_IGNORE (empty): ignored panels, above. #include #include "vrbuttons.h" @@ -218,6 +225,8 @@ #include +#include + #include #include #include @@ -282,23 +291,40 @@ void SendTo(int fd, const char *name, const std::string &msg) { sendto(fd, msg.data(), msg.size(), 0, reinterpret_cast(&addr), len); } +// JSON string literal (names come from other apps). +std::string JsonQuote(const std::string &s) { + std::string out = "\""; + for (const unsigned char c : s) { + if (c == '"' || c == '\\') out += '\\', out += char(c); + else if (c < 0x20) { + char esc[8]; + std::snprintf(esc, sizeof esc, "\\u%04x", c); + out += esc; + } else out += char(c); + } + return out + "\""; +} + // Overlay keys, refreshed in the background from `vrcmd --overlays` (OpenVR has no // public call to enumerate other apps' overlays). Hidden ones are listed too: the // window controls under a floating panel only appear while something hovers the // panel, and the cursor has to find them the moment they do, not a second later. // Paused while the pointer is off: each vrcmd run connects to SteamVR as a new app, and a new // app every second kept SteamVR (and the headset's displays) from going to standby. +// "overlays" requests (Frametop Input Settings' Ignored panels page) refresh the list even +// while paused, and are answered from this thread once it's fresh. class OverlayList { public: void Start() { + out_ = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC, 0); thread_ = std::thread([this] { while (running_) { - if (!paused_) Refresh(); + if (!paused_ || requested_) Refresh(); // Wait a second, or less when the pointer wakes (refresh right away then). for (int i = 0; i < 10 && running_; ++i) { const bool wasPaused = paused_; std::this_thread::sleep_for(std::chrono::milliseconds(100)); - if (wasPaused && !paused_) break; + if ((wasPaused && !paused_) || requested_) break; } } }); @@ -310,14 +336,28 @@ public: } std::vector Keys() { std::lock_guard guard(lock_); - return keys_; + std::vector keys; + for (const auto &e : entries_) keys.push_back(e.key); + return keys; + } + // Answer `to` with {"t":"overlays","list":[{"key","name","visible"}...]} after the next refresh. + void Request(const sockaddr_un &to, socklen_t len) { + if (len <= offsetof(sockaddr_un, sun_path)) return; + std::lock_guard guard(lock_); + if (waiting_.size() < 8) waiting_.push_back({to, len}); + requested_ = true; } private: + struct Entry { + std::string key, name; + bool visible; + }; void Refresh() { + requested_ = false; FILE *p = popen("LD_LIBRARY_PATH=/opt/steamvr/bin/linuxarm64 /opt/steamvr/bin/linuxarm64/vrcmd --overlays 2>/dev/null", "r"); if (!p) return; - std::vector keys; + std::vector entries; char line[1024]; while (std::fgets(line, sizeof line, p)) { // 'key' -- 'name', WxH visible VROverlayType_... @@ -331,20 +371,62 @@ private: key.rfind("frametop.pointer", 0) == 0 || key.rfind("frametop.guide", 0) == 0 || key == "system.HeadsetView" || key == "system.toast") continue; - keys.push_back(key); + // The name can hold quotes; it ends at the last "', " (the size and state follow). + const auto nameAt = rest.find("-- '"), nameEnd = rest.rfind("', "); + const std::string name = + nameAt != std::string::npos && nameEnd > nameAt + 3 ? rest.substr(nameAt + 4, nameEnd - nameAt - 4) : key; + entries.push_back({key, name, rest.find(" not_visible ") == std::string::npos}); } pclose(p); - std::lock_guard guard(lock_); - keys_ = std::move(keys); + std::vector> waiting; + { + std::lock_guard guard(lock_); + entries_ = std::move(entries); + waiting.swap(waiting_); + } + if (waiting.empty()) return; + std::string msg = "{\"t\":\"overlays\",\"list\":["; + for (size_t i = 0; i < entries_.size(); ++i) + msg += std::string(i ? "," : "") + "{\"key\":" + JsonQuote(entries_[i].key) + ",\"name\":" + + JsonQuote(entries_[i].name) + ",\"visible\":" + (entries_[i].visible ? "true" : "false") + "}"; + msg += "]}"; + for (const auto &[to, len] : waiting) + sendto(out_, msg.data(), msg.size(), MSG_DONTWAIT, reinterpret_cast(&to), len); } std::thread thread_; + int out_ = -1; std::atomic paused_{false}; std::atomic running_{true}; + std::atomic requested_{false}; std::mutex lock_; - std::vector keys_; + std::vector entries_; // written only by the thread; the lock guards readers + std::vector> waiting_; }; +// POINTER_IGNORE: overlay keys the pointer passes through, as if they weren't there +// (display-only panels such as a performance overlay). Comma-separated shell patterns +// (fnmatch, no escapes), so "vendor.app*" covers an app's overlays. +std::vector ParseIgnore(const std::string &list) { + std::vector out; + size_t at = 0; + while (at <= list.size()) { + const size_t comma = std::min(list.find(',', at), list.size()); + std::string item = list.substr(at, comma - at); + item.erase(0, item.find_first_not_of(" \t")); + item.erase(item.find_last_not_of(" \t") + 1); + if (!item.empty()) out.push_back(item); + at = comma + 1; + } + return out; +} + +bool Ignored(const std::vector &patterns, const std::string &key) { + for (const auto &p : patterns) + if (fnmatch(p.c_str(), key.c_str(), FNM_NOESCAPE) == 0) return true; + return false; +} + vr::HmdMatrix34_t Billboard(Vec3 at, Vec3 eye) { // Overlay faces +Z; point +Z at the eye, keep +Y roughly up. const Vec3 z = Normalize(eye - at); @@ -432,6 +514,7 @@ int main() { bool gazeOn = false, gazeConf = false; double gazeRetake = 5, gazeNudgeMax = 8, gazeHold = 0.5, gazeShow = 1; double pickupScale = 1; // POINTER_CONTROLLER_PICKUP: scales the controller-moved limits + std::vector ignore; // POINTER_IGNORE (see ParseIgnore) auto loadConfig = [&] { const auto conf = ReadConfig(); freeDistance = std::clamp(ConfDouble(conf, "POINTER_DISTANCE", 1.5), 0.3, 10.0); @@ -455,6 +538,8 @@ int main() { const bool wantGaze = ConfDouble(conf, "POINTER_GAZE", 0) != 0; if (wantGaze != gazeConf) gazeOn = gazeConf = wantGaze; pickupScale = std::clamp(ConfDouble(conf, "POINTER_CONTROLLER_PICKUP", 1), 0.5, 5.0); + const auto ig = conf.find("POINTER_IGNORE"); + ignore = ParseIgnore(ig == conf.end() ? "" : ig->second); }; loadConfig(); const float laserWidth = float(ConfDouble(ReadConfig(), "POINTER_LASER_WIDTH", 0.8)); @@ -863,6 +948,10 @@ int main() { reply(controllerButtons.Status()); continue; } + if (std::strncmp(buf, "overlays", 8) == 0) { + overlays.Request(sender, senderLen); // answered from the list's thread + continue; + } if (std::strncmp(buf, "gaze", 4) == 0) { const char *arg = buf + 4; while (*arg == ' ') ++arg; @@ -961,9 +1050,11 @@ int main() { recenter = true; } else if (std::strncmp(buf, "reload", 6) == 0) { loadConfig(); + lastSlow = Clock::now() - std::chrono::seconds(10); // apply POINTER_IGNORE now std::printf("reloaded: free distance %.2f m, dot %.2f deg, origin %.2f, head follow %s, leash %.0f deg, " - "controller pickup %.1fx\n", - freeDistance, cursorDeg, originFraction, follow ? "on" : "off", leashDeg, pickupScale); + "controller pickup %.1fx, %zu ignored\n", + freeDistance, cursorDeg, originFraction, follow ? "on" : "off", leashDeg, pickupScale, + ignore.size()); std::fflush(stdout); } else if (std::strncmp(buf, "follow", 6) == 0) { const char *arg = buf + 6; @@ -1148,6 +1239,7 @@ int main() { lastSlow = now; handles.clear(); for (const auto &key : overlays.Keys()) { + if (Ignored(ignore, key)) continue; vr::VROverlayHandle_t h; if (overlay->FindOverlay(key.c_str(), &h) != vr::VROverlayError_None) continue; handles[key] = h; diff --git a/session/frametop.conf.example b/session/frametop.conf.example index ce1739e..3ea46cb 100644 --- a/session/frametop.conf.example +++ b/session/frametop.conf.example @@ -19,6 +19,7 @@ POINTER_LASER_WIDTH=0.8 # controller beam width (dashboard.laserRayWidthScale POINTER_ORIGIN_MARGIN=0.15 # the laser starts at least this far (m) in front of its target, so small floating controls stay hittable POINTER_SCENE_RADIUS=0.5 # texture-less dashboard overlays (dock, window controls): hit radius (m) around their origin POINTER_EDGE_REACH=0.3 # just off a panel, the cursor stays on its plane this far (m), to reach its resize edges and window controls +POINTER_IGNORE= # overlay keys the cursor passes through, comma-separated; vendor.app* covers an app (Frametop Input Settings > Ignored panels) POINTER_FOLLOW=0 # 1 = the cursor follows your head on a leash (a mouse button mapped to Head follow on/off toggles it) POINTER_LEASH_DEG=10 # head follow: how far (degrees) your head can turn before the cursor comes along; 0 locks it to your view POINTER_LEASH_DELAY=0.2 # head follow: how long (s) your head has to stay past the leash, so a glance doesn't move the cursor