mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 08:00:09 +02:00
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:
1 parent
c2739cb0df
commit
d666fa031f
6 files changed
+442
-63
No files matched your search
@@ -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)."""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user