Let the pointer pass through panels you pick, like a performance overlay

A head-locked performance overlay kept catching the 3D mouse. It has no
input method, so SteamVR's laser passes through it, but the helper hit
tests every visible overlay with ComputeOverlayIntersection, and the dot
stuck to it whenever it crossed that corner of the view.

POINTER_IGNORE in frametop.conf now lists overlay keys the helper leaves
out of the collision, comma-separated shell patterns, so "vendor.app*"
covers a whole app, including panels it opens later. The laser starts
just before the cursor point, so an ignored panel nearer to you doesn't
catch it either.

Frametop Input Settings has a new Ignored panels page. It asks the
helper for SteamVR's overlays ("overlays", answered from the list's
thread once vrcmd has run again, even while the pointer is off), groups
them by app, and has a checkbox per panel and one for the whole app.
Frametop's own screens aren't offered, since ignoring one would leave
nothing to click the app on with the mouse. Entries for apps that
aren't open are listed so they can be removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-29 14:13:34 -06:00
1 parent 89522e1894
commit 9c04207bb9
7 files changed
+340 -18

No files matched your search

+91
View File
@@ -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):