From dc3a1b8208ec7656dbee0eda2778c4a354b9be48 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:04:14 -0600 Subject: [PATCH 01/13] Keyboard clicks at the gaze: Meta+J and Meta+K gaze_left and gaze_right (Meta+J and Meta+K by default) click where you look. A tap clicks where the dot was at the press and tells the gaze tracker it was right there. Held, the pointer stays put in your view, so turning your head carries it onto the target; the release clicks there and the correction is a lesson (judged by the net correction, not the head's path). Held still for POINTER_GAZE_HOLD it's a real press that the head drags, and Meta+K while Meta+J aims presses where the dot is now, to correct and then drag. A Meta combination now also sends the desktop an F24 press and Meta's release at once: Meta's release no longer opens Plasma's launcher, and the click isn't Meta+click (KWin's window move and resize, which swallowed right clicks). The desktop gets Meta back for the next key if it's still held. Also fixes e16b758, which had taken the gaze actions off the key combination list along with the controllers'; and gaze_quickcal (the gaze service's one-dot check) joins the actions. Co-Authored-By: Claude Opus 5.5 --- input-settings/ft_input_settings.py | 33 +++++-- input-settings/main.qml | 8 +- input/input-relay.py | 60 +++++++++++-- pointer/helper/ft-pointer.cpp | 129 ++++++++++++++++++++++++++-- 4 files changed, 204 insertions(+), 26 deletions(-) diff --git a/input-settings/ft_input_settings.py b/input-settings/ft_input_settings.py index d44e12d..1933a42 100644 --- a/input-settings/ft_input_settings.py +++ b/input-settings/ft_input_settings.py @@ -62,6 +62,9 @@ ACTION_LABELS = { "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", + "gaze_left": "Gaze left click: tap, or hold and turn your head to aim", + "gaze_right": "Gaze right click: tap, or hold and turn your head to aim", + "gaze_quickcal": "Gaze quick check (one dot)", "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", @@ -87,8 +90,14 @@ CONTROLLER_BUTTONS = { "right/bumper": "Right bumper", "right/trigger": "Right trigger", "right/grip": "Right grip", "right/thumbstick": "Right stick click", } -# Not the gaze actions: gaze mode is a mouse feature (docs/gaze-controllers.md; the relay's GAZE_ACTIONS). -CONTROLLER_ACTIONS = [a for a in ACTION_LABELS if a not in ("key", "none", "gaze_toggle", "gaze_precision", "gaze_drag")] +# Gaze mode is a mouse and keyboard feature (docs/gaze-controllers.md; the relay's GAZE_ACTIONS). +GAZE_ACTIONS = ("gaze_toggle", "gaze_precision", "gaze_drag", "gaze_left", "gaze_right", "gaze_quickcal") +CONTROLLER_ACTIONS = [a for a in ACTION_LABELS if a not in ("key", "none") + GAZE_ACTIONS] +# Key combinations take any action but key and none; the keyboard clicks only work there. +SHORTCUT_ACTIONS = [a for a in ACTION_LABELS if a not in ("key", "none")] +KEYBOARD_ONLY = ("gaze_left", "gaze_right") +# The relay's DEFAULT_KEY_BINDINGS: used while the rules file has no "key_bindings" (Meta+J, Meta+K). +DEFAULT_KEY_BINDINGS = {"125+36": "gaze_left", "125+37": "gaze_right"} # Gaze mode settings (pointer helper), like POINTER_SETTINGS. GAZE_SETTINGS = [ ("POINTER_GAZE_RETAKE", "Look away to hand back", 5, 1, 45, 0.5, "°"), @@ -469,7 +478,7 @@ class Backend(QObject): @Property("QVariantList", constant=True) def actions(self): - return [{"value": k, "text": v} for k, v in ACTION_LABELS.items()] + return [{"value": k, "text": v} for k, v in ACTION_LABELS.items() if k not in KEYBOARD_ONLY] @Slot(str, result="QVariantList") def mappings(self, device_id): @@ -842,13 +851,19 @@ class Backend(QObject): @Property("QVariantList", notify=mappingsChanged) def keyShortcuts(self): - bound = read_json(RULES_PATH).get("key_bindings", {}) or {} + bound = self._key_bindings(read_json(RULES_PATH)) 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] + return [{"value": a, "text": ACTION_LABELS[a]} for a in SHORTCUT_ACTIONS] + + @staticmethod + def _key_bindings(rules): + """The key combinations in effect: the defaults until the rules file has its own.""" + bound = rules.get("key_bindings") + return dict(DEFAULT_KEY_BINDINGS) if bound is None else bound @Property(bool, notify=shortcutCaptureChanged) def capturingShortcut(self): @@ -856,7 +871,7 @@ class Backend(QObject): @Slot(str) def startShortcutCapture(self, action): - if action in CONTROLLER_ACTIONS: + if action in SHORTCUT_ACTIONS: self._capture_combo = action self._combo_mods = set() self._send("watch 60") @@ -869,14 +884,16 @@ class Backend(QObject): def _save_shortcut(self, combo, action): rules = read_json(RULES_PATH) - rules.setdefault("key_bindings", {})[combo] = action + rules["key_bindings"] = self._key_bindings(rules) # the defaults stay when the first one is added + rules["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) + rules["key_bindings"] = self._key_bindings(rules) + rules["key_bindings"].pop(combo, None) self._save_rules(rules) self.message.emit(f"{self.comboName(combo)} removed", False) diff --git a/input-settings/main.qml b/input-settings/main.qml index 7aa41d4..9fd4d3f 100644 --- a/input-settings/main.qml +++ b/input-settings/main.qml @@ -842,9 +842,11 @@ Kirigami.ApplicationWindow { Controls.Label { Layout.maximumWidth: Kirigami.Units.gridUnit * 30 wrapMode: Text.WordWrap - text: "Gaze works with the mouse. The Frame controllers don't take part, and moving one hands the " - + "pointer back to the controllers. Gaze pointer on/off, Gaze precision, and Gaze drag can go on " - + "a mouse button (Buttons page) or a key combination below." + text: "Gaze works with the mouse and the keyboard. Keyboard clicks (Meta+J left, Meta+K right by " + + "default): tap to click where you look, or hold, turn your head until the dot sits on the " + + "target, and let go; hold still to drag. The Frame controllers don't take part, and moving " + + "one hands the pointer back to them. Gaze pointer on/off, Gaze precision, and Gaze drag can " + + "also go on a mouse button (Buttons page)." opacity: 0.7 font: Kirigami.Theme.smallFont } diff --git a/input/input-relay.py b/input/input-relay.py index 05ce294..0ffa5be 100755 --- a/input/input-relay.py +++ b/input/input-relay.py @@ -23,7 +23,11 @@ pointer_toggle, follow_toggle = head follow on or off, gaze_toggle = gaze mode o (the pointer goes where you look; see pointer/helper/ft-pointer.cpp), gaze_precision = while held, the pointer stops where you look and the mouse steers it, and the release clicks there, gaze_drag = the same, but pressed at once, so it drags ("precision|gazedrag mouse|keyboard 1|0" -to the helper), sens_up, sens_down, +to the helper), gaze_left and gaze_right = keyboard clicks at the gaze: a tap clicks where you +look; held, the pointer stops there and your head steers it (it stays put in your view), and +the release clicks; held still for half a second, it's a real press that your head drags +("gazekey left|right 1|0" to the helper; by default Meta+J and Meta+K, DEFAULT_KEY_BINDINGS), +gaze_quickcal = the gaze service's one-dot check ("quickcal" to @ft_gazed), sens_up, sens_down, layout_reset = put the desktop screens back in their saved layout, screens_toggle = hide or show the desktop screens, keyboard_toggle = open or close Frametop's keyboard, key = pass through as a key, none). @@ -31,7 +35,10 @@ Frame controller buttons can be mapped too ("controller_buttons": {"right/a": ac rules file; any action but key and the gaze ones, GAZE_ACTIONS: gaze mode is a mouse feature, docs/gaze-controllers.md). So can key combinations on any keyboard ("key_bindings": {"29+56+34": action}, evdev codes joined by "+", modifiers first and left-hand codes for -either side, here Ctrl+Alt+G): the combination does the action, and its last key isn't typed. The controllers aren't input devices here, only SteamVR sees +either side, here Ctrl+Alt+G): the combination does the action, and its last key isn't typed. +A combination with Meta also sends the desktop an F24 press and Meta's release right away: so +letting go of Meta doesn't open Plasma's launcher, and a gaze click isn't Meta+click (KWin's +window move and resize). Another key while Meta is still held gives the desktop Meta back. The controllers aren't input devices here, only SteamVR sees them, so the pointer helper reads them with SteamVR input and sends "vrbtn