Gaze first: precision and drag buttons, key combinations, pointer role

Two new actions for mouse buttons, controller buttons, and key
combinations: gaze_precision (hold: the pointer stops where you look and
the button's device steers it, a controller by where it points at
POINTER_PRECISION_GAIN, the mouse by its moves; release: click there) and
gaze_drag (the same with a real press at once, dragging until the
release). The relay sends "precision|gazedrag <source> 1|0" to the
helper, which runs them through the same holds as pinches and grips.

- Key combinations on any keyboard ("key_bindings" in the rules, e.g.
  Ctrl+Alt+G for gaze on/off): the last key isn't typed.
- POINTER_GAZE_MOUSE: in gaze mode the left button is a precision button
  (the default, as before) or clicks right away (direct).
- In gaze mode a moving controller no longer takes the pointer away.
- POINTER_ROLE (right, left, stylus): the driver takes a "role" command,
  so the pointer can stay off the hand holding the precision controller,
  which would otherwise take the role back. Needs the rebuilt driver.
- Input Settings: the actions on the Controllers and Buttons pages; on the
  Gaze page the mouse choice, the role, the precision sliders, and key
  combinations (captured from any keyboard).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-30 15:14:57 -06:00
1 parent c2739cb0df
commit d666fa031f
6 files changed
+442 -63

No files matched your search

+106
View File
@@ -60,6 +60,8 @@ ACTION_LABELS = {
"scroll_up": "Scroll up", "scroll_down": "Scroll down", "dashboard": "Toggle SteamVR dashboard",
"recenter": "Recenter pointer", "pointer_toggle": "Pointer on/off",
"follow_toggle": "Head follow on/off (experimental)", "gaze_toggle": "Gaze pointer on/off (experimental)",
"gaze_precision": "Gaze precision: hold to steer, release to click",
"gaze_drag": "Gaze drag: press where you look, steer, release",
"sens_up": "Faster pointer",
"sens_down": "Slower pointer", "layout_reset": "Reset desktop screen layout",
"screens_toggle": "Hide/show desktop screens", "keyboard_toggle": "Open/close keyboard",
@@ -92,7 +94,18 @@ GAZE_SETTINGS = [
("POINTER_GAZE_NUDGE_MAX", "Largest nudge to learn", 8, 1, 30, 0.5, "°"),
("POINTER_GAZE_HOLD", "Hold still to drag", 0.5, 0.1, 2.0, 0.05, "s"),
("POINTER_GAZE_SHOW", "Dot shows after moving", 1.0, 0.0, 5.0, 0.1, "s"),
("POINTER_PRECISION_GAIN", "Precision steering", 0.5, 0.1, 2.0, 0.05, "×"),
("POINTER_PRECISION_DEADZONE", "Precision dead zone", 0.3, 0.0, 3.0, 0.1, "°"),
("POINTER_GAZE_DRAG_GAIN", "Drag steering", 1.0, 0.1, 2.0, 0.05, "×"),
]
# In gaze mode, what the mouse's left button does (POINTER_GAZE_MOUSE).
GAZE_MOUSE = {"precision": "Gaze precision: hold to steer with the mouse, release to click",
"direct": "Click right away where the pointer is"}
# The hand role the pointer's virtual controller takes (POINTER_ROLE).
POINTER_ROLES = {"right": "Right hand", "left": "Left hand", "stylus": "Stylus (no hand)"}
# Key combinations ("key_bindings" in the rules): modifiers, either side folded into the left code.
MODIFIER_CODES = {29: 29, 97: 29, 42: 42, 54: 42, 56: 56, 100: 56, 125: 125, 126: 125}
MODIFIER_NAMES = {29: "Ctrl", 42: "Shift", 56: "Alt", 125: "Meta"}
# The gaze service's settings (gaze/ft-gazed): whose eye tracking, and the eye bias.
GAZE_TRACKERS = {"steam": "SteamVR's eye tracker", "own": "our own eye tracker (frame-eyes)"}
GAZE_EYES = {"auto": "auto", "left": "left eye", "right": "right eye"}
@@ -223,6 +236,7 @@ class Backend(QObject):
activity = Signal(str) # device id
captured = Signal(int, str) # code, name
capturedController = Signal(str, str) # button, label
shortcutCaptureChanged = Signal()
message = Signal(str, bool) # text, is error
def __init__(self):
@@ -234,6 +248,8 @@ class Backend(QObject):
self._bluetooth = []
self._relay_ok = False
self._capture_vr = False
self._capture_combo = "" # the action a key combination is being captured for
self._combo_mods = set()
self._vr = {} # the helper's vrstatus, {} when it doesn't answer
self._vr_at = 0.0
self._gaze = {} # ft-gazed's status, {} when it isn't running
@@ -338,6 +354,17 @@ class Backend(QObject):
self._capture_vr = False
self._send("vrcapture 0")
self.capturedController.emit(msg["code"], CONTROLLER_BUTTONS[msg["code"]])
elif t == "event" and self._capture_combo and msg.get("type") == "key":
self.activity.emit(msg["id"])
code, value = int(msg["code"]), msg["value"]
if code in MODIFIER_CODES:
(self._combo_mods.add if value else self._combo_mods.discard)(MODIFIER_CODES[code])
elif value == 1 and code < BTN_MISC:
self._save_shortcut("+".join(str(c) for c in sorted(self._combo_mods) + [code]),
self._capture_combo)
self._capture_combo = ""
self._combo_mods = set()
self.shortcutCaptureChanged.emit()
elif t == "event":
self.activity.emit(msg["id"])
if (self._capture_id and msg["id"] == self._capture_id and msg["type"] == "key"
@@ -765,6 +792,85 @@ class Backend(QObject):
self.reload_timer.start()
self.gazeChanged.emit()
@Property(str, notify=pointerChanged)
def gazeMouse(self):
v = read_conf().get("POINTER_GAZE_MOUSE", "precision")
return v if v in GAZE_MOUSE else "precision"
@Property("QVariantList", constant=True)
def gazeMouseChoices(self):
return [{"value": k, "text": v} for k, v in GAZE_MOUSE.items()]
@Slot(str)
def setGazeMouse(self, mode):
if mode in GAZE_MOUSE:
write_conf_value("POINTER_GAZE_MOUSE", mode)
self.reload_timer.start()
self.pointerChanged.emit()
self.message.emit(f"Mouse in gaze mode: {GAZE_MOUSE[mode].lower()}", False)
@Property(str, notify=pointerChanged)
def pointerRole(self):
v = read_conf().get("POINTER_ROLE", "right")
return v if v in POINTER_ROLES else "right"
@Property("QVariantList", constant=True)
def pointerRoles(self):
return [{"value": k, "text": v} for k, v in POINTER_ROLES.items()]
@Slot(str)
def setPointerRole(self, role):
if role in POINTER_ROLES:
write_conf_value("POINTER_ROLE", role)
self.reload_timer.start()
self.pointerChanged.emit()
self.message.emit(f"Pointer role: {POINTER_ROLES[role].lower()} (from its next wake)", False)
# --- key combinations ("key_bindings") ---
def comboName(self, combo):
parts = [int(c) for c in combo.split("+") if c.isdigit()]
return "+".join(MODIFIER_NAMES.get(c) or self.codeName(c).removeprefix("KEY_").title() for c in parts)
@Property("QVariantList", notify=mappingsChanged)
def keyShortcuts(self):
bound = read_json(RULES_PATH).get("key_bindings", {}) or {}
return [{"combo": c, "label": self.comboName(c), "action": a, "actionLabel": ACTION_LABELS.get(a, a)}
for c, a in sorted(bound.items())]
@Property("QVariantList", constant=True)
def shortcutActions(self):
return [{"value": a, "text": ACTION_LABELS[a]} for a in CONTROLLER_ACTIONS]
@Property(bool, notify=shortcutCaptureChanged)
def capturingShortcut(self):
return bool(self._capture_combo)
@Slot(str)
def startShortcutCapture(self, action):
if action in CONTROLLER_ACTIONS:
self._capture_combo = action
self._combo_mods = set()
self._send("watch 60")
self.shortcutCaptureChanged.emit()
@Slot()
def cancelShortcutCapture(self):
self._capture_combo = ""
self.shortcutCaptureChanged.emit()
def _save_shortcut(self, combo, action):
rules = read_json(RULES_PATH)
rules.setdefault("key_bindings", {})[combo] = action
self._save_rules(rules)
self.message.emit(f"{self.comboName(combo)} → {ACTION_LABELS[action]}", False)
@Slot(str)
def removeShortcut(self, combo):
rules = read_json(RULES_PATH)
(rules.get("key_bindings") or {}).pop(combo, None)
self._save_rules(rules)
self.message.emit(f"{self.comboName(combo)} removed", False)
@Property(str, notify=gazeChanged)
def gazeTracker(self):
"""Whose eye tracking the gaze service uses: "steam" or "own" (GAZE_TRACKER)."""
+85
View File
@@ -821,6 +821,91 @@ Kirigami.ApplicationWindow {
opacity: 0.7
font: Kirigami.Theme.smallFont
}
ColumnLayout {
Kirigami.FormData.label: "Mouse left button:"
Repeater {
model: backend.gazeMouseChoices
delegate: Controls.RadioButton {
required property var modelData
text: modelData.text
checked: backend.gazeMouse === modelData.value
onToggled: if (checked) backend.setGazeMouse(modelData.value)
}
}
}
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 30
wrapMode: Text.WordWrap
text: "Controllers: map a button to Gaze precision (hold, point the controller to steer, release to "
+ "click) or Gaze drag (the same, pressed at once) on the Controllers page. Gaze pointer on/off "
+ "can go on a controller button, a mouse button, or a key combination below."
opacity: 0.7
font: Kirigami.Theme.smallFont
}
ColumnLayout {
Kirigami.FormData.label: "Pointer takes the role:"
Repeater {
model: backend.pointerRoles
delegate: Controls.RadioButton {
required property var modelData
text: modelData.text
checked: backend.pointerRole === modelData.value
onToggled: if (checked) backend.setPointerRole(modelData.value)
}
}
}
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 30
wrapMode: Text.WordWrap
text: "A controller in your hand takes that hand's role, and then the pointer's clicks don't land. "
+ "Holding the right controller as the precision tool, give the pointer the left hand, or the "
+ "stylus role. It applies the next time the pointer wakes."
opacity: 0.7
font: Kirigami.Theme.smallFont
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Key combinations" }
Repeater {
model: backend.keyShortcuts
delegate: RowLayout {
required property var modelData
Kirigami.FormData.label: modelData.label + ":"
Controls.Label { text: modelData.actionLabel }
Controls.ToolButton {
icon.name: "edit-delete"
display: Controls.AbstractButton.IconOnly
text: "Remove"
Controls.ToolTip.text: text
Controls.ToolTip.visible: hovered
onClicked: backend.removeShortcut(modelData.combo)
}
}
}
RowLayout {
Kirigami.FormData.label: "New:"
Controls.ComboBox {
id: shortcutAction
model: backend.shortcutActions
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = indexOfValue("gaze_toggle")
Layout.preferredWidth: Kirigami.Units.gridUnit * 16
}
Controls.Button {
text: backend.capturingShortcut ? "Press the keys… (Cancel)" : "Set keys…"
onClicked: backend.capturingShortcut ? backend.cancelShortcutCapture()
: backend.startShortcutCapture(shortcutAction.currentValue)
}
}
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 30
wrapMode: Text.WordWrap
text: "Hold the modifiers (Ctrl, Alt, Shift, Meta), then press the key, on any keyboard. The "
+ "combination's last key isn't typed; the modifiers still reach the app."
opacity: 0.7
font: Kirigami.Theme.smallFont
}
RowLayout {
Kirigami.FormData.label: "Eye tracker:"
Controls.RadioButton {