mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 04:04:09 +02:00
Map the Frame controllers' buttons; make gaze clicks correctable
The Frame controllers aren't input devices on the host, so the pointer helper reads them with SteamVR input (vrbuttons.h, actions/), one action set per button, and sends presses to the relay, which does the mapped action like for a mouse button. Only mapped buttons are taken, at an overlay-global priority (SteamVR's experimental "Enable global input from overlays"), and only outside games unless In games is on. Frametop Input Settings gets a Controllers page to map them. Gaze mode: a left press while the gaze has the pointer isn't sent at once. The pointer stops, you drag it onto what you meant with the button held, and the release clicks there; a press held still for POINTER_GAZE_HOLD (0.5 s) becomes a real press, for drags. The dot shows only while the mouse moves it (POINTER_GAZE_SHOW), while a press is held, and as a pulse per click. Outside games the pointer stays on while gaze mode is on. ft-gazed takes a third of each lesson's offset instead of all of it: in the first live test one 6 degree lesson moved everything and put the next target 7 degrees off. Frametop Input Settings gets a Gaze page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3070fd5776
commit
0d50efec45
13 files changed
+1426
-54
No files matched your search
@@ -6,7 +6,12 @@ to the input relay over its control socket (@frametop_relay):
|
||||
- Devices: every USB/Bluetooth mouse and keyboard, a live activity light to
|
||||
identify them, and a role for each (3D pointer, pass through, ignore).
|
||||
- Buttons: press a button or key on a pointer device, then pick an action.
|
||||
- Controllers: the same for the Frame controllers' buttons. They're read by the pointer
|
||||
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.
|
||||
- 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.
|
||||
Rules go to ~/.config/frametop-input.json and pointer settings to
|
||||
~/.config/frametop.conf; then the relay (and through it the helper) reloads.
|
||||
@@ -19,6 +24,7 @@ import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from PySide6.QtCore import Property, QObject, QSocketNotifier, QTimer, QUrl, Signal, Slot
|
||||
from PySide6.QtGui import QGuiApplication, QIcon
|
||||
@@ -29,6 +35,9 @@ RULES_PATH = os.path.expanduser("~/.config/frametop-input.json")
|
||||
CONF_PATH = os.path.expanduser("~/.config/frametop.conf")
|
||||
RELAY = "\0frametop_relay"
|
||||
HELPER = "\0ft_pointer_helper"
|
||||
GAZED = "\0ft_gazed"
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
GAZE_PROBE = os.path.join(REPO, "gaze", "probe", "ft-gazeprobe")
|
||||
BTN_MISC = 0x100
|
||||
# Header names that mark the start of a range, not a real key (BTN_MOUSE == BTN_LEFT).
|
||||
RANGE_ALIASES = {"BTN_MISC", "BTN_MOUSE", "BTN_JOYSTICK", "BTN_GAMEPAD", "BTN_DIGI", "BTN_WHEEL",
|
||||
@@ -45,6 +54,24 @@ ACTION_LABELS = {
|
||||
"none": "Do nothing",
|
||||
}
|
||||
ROLE_LABELS = {"pointer": "3D pointer", "passthrough": "Pass through", "ignore": "Ignore"}
|
||||
# Frame controller buttons the pointer helper can read (pointer/helper/vrbuttons.h). The
|
||||
# system button stays SteamVR's.
|
||||
CONTROLLER_BUTTONS = {
|
||||
"left/view": "Left View", "left/dpad_up": "Left D-pad up", "left/dpad_down": "Left D-pad down",
|
||||
"left/dpad_left": "Left D-pad left", "left/dpad_right": "Left D-pad right", "left/bumper": "Left bumper",
|
||||
"left/trigger": "Left trigger", "left/grip": "Left grip", "left/thumbstick": "Left stick click",
|
||||
"right/menu": "Right Menu", "right/a": "Right A", "right/b": "Right B", "right/x": "Right X", "right/y": "Right Y",
|
||||
"right/bumper": "Right bumper", "right/trigger": "Right trigger", "right/grip": "Right grip",
|
||||
"right/thumbstick": "Right stick click",
|
||||
}
|
||||
CONTROLLER_ACTIONS = [a for a in ACTION_LABELS if a not in ("key", "none")]
|
||||
# Gaze mode settings (pointer helper), like POINTER_SETTINGS.
|
||||
GAZE_SETTINGS = [
|
||||
("POINTER_GAZE_RETAKE", "Look away to hand back", 5, 1, 45, 0.5, "°"),
|
||||
("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 settings: key, label, default, min, max, step, unit.
|
||||
POINTER_SETTINGS = [
|
||||
("POINTER_SENSITIVITY", "Speed", 0.03, 0.005, 0.12, 0.001, "°/count"),
|
||||
@@ -134,8 +161,11 @@ class Backend(QObject):
|
||||
mappingsChanged = Signal()
|
||||
pointerChanged = Signal()
|
||||
bluetoothChanged = Signal()
|
||||
controllersChanged = Signal()
|
||||
gazeChanged = Signal()
|
||||
activity = Signal(str) # device id
|
||||
captured = Signal(int, str) # code, name
|
||||
capturedController = Signal(str, str) # button, label
|
||||
message = Signal(str, bool) # text, is error
|
||||
|
||||
def __init__(self):
|
||||
@@ -146,6 +176,13 @@ class Backend(QObject):
|
||||
self._capture_id = ""
|
||||
self._bluetooth = []
|
||||
self._relay_ok = False
|
||||
self._capture_vr = False
|
||||
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
|
||||
self._gaze_prev = None # the status before, for rates
|
||||
self._gaze_at = 0.0
|
||||
self._gaze_mode = None # the helper's gaze mode: True, False, None (no answer)
|
||||
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)
|
||||
@@ -173,6 +210,19 @@ class Backend(QObject):
|
||||
|
||||
def _refresh(self):
|
||||
self._send("devices")
|
||||
self._send("vrstatus", HELPER)
|
||||
self._send("gaze ?", HELPER)
|
||||
if not self._send("status", GAZED) and self._gaze:
|
||||
self._gaze, self._gaze_prev = {}, None
|
||||
self.gazeChanged.emit()
|
||||
now = time.monotonic()
|
||||
# No answer for a while: that side isn't running (any more).
|
||||
if self._vr and now - self._vr_at > 5:
|
||||
self._vr = {}
|
||||
self.controllersChanged.emit()
|
||||
if self._gaze_mode is not None and now - self._gaze_at > 5:
|
||||
self._gaze_mode = None
|
||||
self.gazeChanged.emit()
|
||||
|
||||
def _read(self):
|
||||
while True:
|
||||
@@ -180,16 +230,37 @@ class Backend(QObject):
|
||||
data = self.sock.recv(65536)
|
||||
except BlockingIOError:
|
||||
return
|
||||
text = data.decode(errors="replace")
|
||||
if text in ("ok on", "ok off"): # the helper's answer to "gaze ?" (from an unbound socket)
|
||||
self._gaze_mode = text == "ok on"
|
||||
self._gaze_at = time.monotonic()
|
||||
self.gazeChanged.emit()
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(msg, dict) and "samples" in msg and "t" not in msg: # ft-gazed's status
|
||||
self._gaze_status(msg)
|
||||
continue
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
t = msg.get("t")
|
||||
if t == "devices":
|
||||
self._nodes = msg.get("nodes", [])
|
||||
self._pointer_mode = bool(msg.get("pointer_mode"))
|
||||
self._relay_ok = True
|
||||
self.devicesChanged.emit()
|
||||
elif t == "vrstatus":
|
||||
self._vr = msg
|
||||
self._vr_at = time.monotonic()
|
||||
self.controllersChanged.emit()
|
||||
elif t == "event" and msg.get("type") == "vr":
|
||||
self.activity.emit(msg["id"])
|
||||
if self._capture_vr and msg["value"] == 1 and msg["code"] in CONTROLLER_BUTTONS:
|
||||
self._capture_vr = False
|
||||
self._send("vrcapture 0")
|
||||
self.capturedController.emit(msg["code"], CONTROLLER_BUTTONS[msg["code"]])
|
||||
elif t == "event":
|
||||
self.activity.emit(msg["id"])
|
||||
if (self._capture_id and msg["id"] == self._capture_id and msg["type"] == "key"
|
||||
@@ -381,6 +452,167 @@ class Backend(QObject):
|
||||
def recenter(self):
|
||||
return self._send("recenter", HELPER)
|
||||
|
||||
# --- controllers ---
|
||||
@Property("QVariantList", constant=True)
|
||||
def controllerActions(self):
|
||||
return [{"value": a, "text": ACTION_LABELS[a]} for a in CONTROLLER_ACTIONS]
|
||||
|
||||
@Property("QVariantList", constant=True)
|
||||
def controllerButtons(self):
|
||||
return [{"value": b, "text": label} for b, label in CONTROLLER_BUTTONS.items()]
|
||||
|
||||
@Property("QVariantList", notify=mappingsChanged)
|
||||
def controllerMappings(self):
|
||||
mapped = read_json(RULES_PATH).get("controller_buttons", {})
|
||||
return [{"button": b, "label": label, "action": mapped[b],
|
||||
"actionLabel": ACTION_LABELS.get(mapped[b], mapped[b])}
|
||||
for b, label in CONTROLLER_BUTTONS.items() if b in mapped]
|
||||
|
||||
@Property("QVariantMap", notify=controllersChanged)
|
||||
def controllerStatus(self):
|
||||
"""helper: answering; manifest: its SteamVR input is set up; global: SteamVR's
|
||||
"Enable global input from overlays"; active: mapped buttons SteamVR delivers now."""
|
||||
vr = self._vr
|
||||
return {"helper": bool(vr), "manifest": bool(vr.get("manifest")), "global": bool(vr.get("global")),
|
||||
"inGame": bool(vr.get("in_game")), "bound": vr.get("bound", []), "active": vr.get("active", [])}
|
||||
|
||||
@Property(bool, notify=mappingsChanged)
|
||||
def controllerInGames(self):
|
||||
return bool(read_json(RULES_PATH).get("controller_in_games"))
|
||||
|
||||
@Slot(bool)
|
||||
def setControllerInGames(self, on):
|
||||
rules = read_json(RULES_PATH)
|
||||
rules["controller_in_games"] = bool(on)
|
||||
self._save_rules(rules)
|
||||
self.message.emit("Mapped controller buttons: " + ("taken in games too" if on else "left to games"), False)
|
||||
|
||||
@Slot(str, str)
|
||||
def setControllerMapping(self, button, action):
|
||||
if button not in CONTROLLER_BUTTONS or action not in CONTROLLER_ACTIONS:
|
||||
return
|
||||
rules = read_json(RULES_PATH)
|
||||
rules.setdefault("controller_buttons", {})[button] = action
|
||||
self._save_rules(rules)
|
||||
self.message.emit(f"{CONTROLLER_BUTTONS[button]} → {ACTION_LABELS[action]}", False)
|
||||
|
||||
@Slot(str)
|
||||
def removeControllerMapping(self, button):
|
||||
rules = read_json(RULES_PATH)
|
||||
rules.get("controller_buttons", {}).pop(button, None)
|
||||
self._save_rules(rules)
|
||||
self.message.emit(f"{CONTROLLER_BUTTONS.get(button, button)}: back to games", False)
|
||||
|
||||
@Slot()
|
||||
def clearControllerMappings(self):
|
||||
rules = read_json(RULES_PATH)
|
||||
removed = len(rules.pop("controller_buttons", {}) or {})
|
||||
self._save_rules(rules)
|
||||
self.message.emit(f"Removed {removed} controller binding{'s' if removed != 1 else ''}", False)
|
||||
|
||||
@Slot()
|
||||
def startControllerCapture(self):
|
||||
self._capture_vr = True
|
||||
self._send("watch 60")
|
||||
self._send("vrcapture 30")
|
||||
|
||||
@Slot()
|
||||
def cancelControllerCapture(self):
|
||||
if self._capture_vr:
|
||||
self._capture_vr = False
|
||||
self._send("vrcapture 0")
|
||||
|
||||
@Slot(bool)
|
||||
def setGlobalInput(self, on):
|
||||
"""SteamVR's "Enable global input from overlays (Experimental)", which the helper needs
|
||||
to get controller buttons while a game or the dashboard has focus."""
|
||||
if self._send(f"vrglobal {'on' if on else 'off'}", HELPER):
|
||||
self.message.emit(f"SteamVR global input from overlays {'on' if on else 'off'}", False)
|
||||
else:
|
||||
self.message.emit("The pointer helper isn't running (frametop-pointer.service)", True)
|
||||
|
||||
# --- gaze ---
|
||||
def _gaze_status(self, status):
|
||||
now = time.monotonic()
|
||||
prev = self._gaze_prev
|
||||
# Rates over the last poll: samples per second, and the share with only one eye.
|
||||
if prev and now - prev[0] > 0.5 and status["samples"] >= prev[1]["samples"]:
|
||||
n = status["samples"] - prev[1]["samples"]
|
||||
status["rate"] = n / (now - prev[0])
|
||||
status["one_eye_share"] = (status["one_eye"] - prev[1]["one_eye"]) / n if n else 0.0
|
||||
elif self._gaze:
|
||||
status.setdefault("rate", self._gaze.get("rate"))
|
||||
status.setdefault("one_eye_share", self._gaze.get("one_eye_share"))
|
||||
if not prev or now - prev[0] > 0.5:
|
||||
self._gaze_prev = (now, status)
|
||||
self._gaze = status
|
||||
self.gazeChanged.emit()
|
||||
|
||||
@Property("QVariantMap", notify=gazeChanged)
|
||||
def gazeStatus(self):
|
||||
return self._gaze
|
||||
|
||||
@Property(bool, notify=gazeChanged)
|
||||
def gazeServiceRunning(self):
|
||||
return bool(self._gaze)
|
||||
|
||||
@Property(int, notify=gazeChanged)
|
||||
def gazeMode(self):
|
||||
"""The helper's gaze mode now: 1 on, 0 off, -1 no answer (helper not running)."""
|
||||
return -1 if self._gaze_mode is None else int(self._gaze_mode)
|
||||
|
||||
@Property(bool, notify=gazeChanged)
|
||||
def gazeDefault(self):
|
||||
return read_conf().get("POINTER_GAZE", "0") not in ("", "0")
|
||||
|
||||
@Slot(bool)
|
||||
def setGazeMode(self, on):
|
||||
"""On or off now and from now on (POINTER_GAZE); a mapped button toggles it until restart."""
|
||||
write_conf_value("POINTER_GAZE", "1" if on else "0")
|
||||
self._send(f"gaze {'on' if on else 'off'}", HELPER)
|
||||
self.reload_timer.start()
|
||||
self.gazeChanged.emit()
|
||||
|
||||
@Property("QVariantList", notify=pointerChanged)
|
||||
def gazeSettings(self):
|
||||
conf = read_conf()
|
||||
out = []
|
||||
for key, label, default, lo, hi, step, unit in GAZE_SETTINGS:
|
||||
try:
|
||||
value = float(conf.get(key, default))
|
||||
except ValueError:
|
||||
value = default
|
||||
out.append({"key": key, "label": label, "value": value, "min": lo, "max": hi, "step": step,
|
||||
"unit": unit, "default": default})
|
||||
return out
|
||||
|
||||
@Slot()
|
||||
def forgetGazeLessons(self):
|
||||
if self._send("forget", GAZED):
|
||||
self.message.emit("Forgot what the pointer's nudges taught; the calibration stays", False)
|
||||
else:
|
||||
self.message.emit("The gaze service isn't running (frametop-gaze.service)", True)
|
||||
|
||||
@Slot()
|
||||
def reloadGazeCalibration(self):
|
||||
if self._send("reload", GAZED):
|
||||
self.message.emit("The gaze service read the calibration again", False)
|
||||
else:
|
||||
self.message.emit("The gaze service isn't running (frametop-gaze.service)", True)
|
||||
|
||||
@Slot()
|
||||
def openGazeProbe(self):
|
||||
"""Calibrate in ft-gazeprobe (a GTK app on the host, fullscreen on a Frametop screen)."""
|
||||
runner = ["distrobox-host-exec"] if shutil.which("distrobox-host-exec") else []
|
||||
env = [f"{k}={os.environ[k]}" for k in ("WAYLAND_DISPLAY", "DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS")
|
||||
if os.environ.get(k)]
|
||||
try:
|
||||
subprocess.Popen(runner + ["env"] + env + [GAZE_PROBE], stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
self.message.emit("Opening the gaze probe: calibrate there, then close it", False)
|
||||
except OSError as e:
|
||||
self.message.emit(f"Couldn't open the gaze probe: {e}", True)
|
||||
|
||||
# --- bluetooth ---
|
||||
@Property("QVariantList", notify=bluetoothChanged)
|
||||
def bluetooth(self):
|
||||
|
||||
+312
-3
@@ -18,7 +18,9 @@ Kirigami.ApplicationWindow {
|
||||
actions: [
|
||||
Kirigami.Action { text: "Devices"; icon.name: "input-mouse"; onTriggered: root.show(devicesPage) },
|
||||
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: "Gaze"; icon.name: "view-visible"; onTriggered: root.show(gazePage) },
|
||||
Kirigami.Action { text: "Bluetooth"; icon.name: "preferences-system-bluetooth"; onTriggered: root.show(bluetoothPage) }
|
||||
]
|
||||
}
|
||||
@@ -28,8 +30,9 @@ Kirigami.ApplicationWindow {
|
||||
pageStack.push(page)
|
||||
}
|
||||
|
||||
// FT_INPUT_PAGE=buttons|pointer|bluetooth opens the app on that page.
|
||||
pageStack.initialPage: ({ buttons: buttonsPage, pointer: pointerPage, bluetooth: bluetoothPage })[startPage] || devicesPage
|
||||
// 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
|
||||
|
||||
Connections {
|
||||
target: backend
|
||||
@@ -294,7 +297,177 @@ Kirigami.ApplicationWindow {
|
||||
wrapMode: Text.Wrap
|
||||
opacity: 0.7
|
||||
text: "Buttons without a mapping pass through (mouse buttons as clicks, keys as keys). "
|
||||
+ "The Z3's extra buttons show up as keys from its keyboard node."
|
||||
+ "The Z3's extra buttons show up as keys from its keyboard node. "
|
||||
+ "The Frame controllers' buttons are on the Controllers page."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Controllers
|
||||
Component {
|
||||
id: controllersPage
|
||||
Kirigami.ScrollablePage {
|
||||
id: cpage
|
||||
title: "Controllers"
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: "Remove all"
|
||||
icon.name: "edit-clear-all"
|
||||
tooltip: "Give every controller button back to games"
|
||||
enabled: backend.controllerMappings.length > 0
|
||||
onTriggered: backend.clearControllerMappings()
|
||||
}
|
||||
]
|
||||
property string capturedButton: ""
|
||||
property string capturedLabel: ""
|
||||
property bool capturing: false
|
||||
property var status: backend.controllerStatus
|
||||
Component.onDestruction: backend.cancelControllerCapture()
|
||||
|
||||
Connections {
|
||||
target: backend
|
||||
function onCapturedController(button, label) {
|
||||
cpage.capturedButton = button; cpage.capturedLabel = label; cpage.capturing = false
|
||||
buttonBox.currentIndex = buttonBox.indexOfValue(button)
|
||||
}
|
||||
}
|
||||
Timer {
|
||||
// The relay takes every button for 30 s while capturing.
|
||||
running: cpage.capturing
|
||||
interval: 30000
|
||||
onTriggered: { backend.cancelControllerCapture(); cpage.capturing = false }
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
Kirigami.InlineMessage {
|
||||
Layout.fillWidth: true
|
||||
visible: !cpage.status.helper
|
||||
type: Kirigami.MessageType.Error
|
||||
text: "The pointer helper isn't answering (frametop-pointer.service, needs SteamVR). "
|
||||
+ "It reads the controller buttons."
|
||||
}
|
||||
Kirigami.InlineMessage {
|
||||
Layout.fillWidth: true
|
||||
visible: cpage.status.helper && !cpage.status.manifest
|
||||
type: Kirigami.MessageType.Error
|
||||
text: "The pointer helper couldn't set up SteamVR input (pointer/helper/actions). See its log."
|
||||
}
|
||||
Kirigami.InlineMessage {
|
||||
Layout.fillWidth: true
|
||||
visible: cpage.status.helper && !cpage.status.global
|
||||
type: Kirigami.MessageType.Warning
|
||||
text: "SteamVR's \"Enable global input from overlays (Experimental)\" is off. Without it the "
|
||||
+ "buttons only reach Frametop when nothing else has focus, if at all."
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: "Turn it on"
|
||||
icon.name: "dialog-ok-apply"
|
||||
onTriggered: backend.setGlobalInput(true)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Kirigami.FormLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Controls.Switch {
|
||||
Kirigami.FormData.label: "In games:"
|
||||
text: "Mapped buttons work while a game is running too (the game loses them)"
|
||||
checked: backend.controllerInGames
|
||||
onToggled: backend.setControllerInGames(checked)
|
||||
}
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: "New mapping:"
|
||||
Controls.Button {
|
||||
text: cpage.capturing ? "Press a button on a controller…" : "Capture a button"
|
||||
icon.name: "input-gamepad"
|
||||
highlighted: cpage.capturing
|
||||
enabled: cpage.status.helper
|
||||
onClicked: {
|
||||
if (cpage.capturing) { backend.cancelControllerCapture(); cpage.capturing = false }
|
||||
else { cpage.capturedButton = ""; cpage.capturing = true; backend.startControllerCapture() }
|
||||
}
|
||||
}
|
||||
Controls.Label { text: "or" ; opacity: 0.7 }
|
||||
Controls.ComboBox {
|
||||
id: buttonBox
|
||||
model: backend.controllerButtons
|
||||
textRole: "text"
|
||||
valueRole: "value"
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
|
||||
}
|
||||
Controls.ComboBox {
|
||||
id: newControllerAction
|
||||
model: backend.controllerActions
|
||||
textRole: "text"
|
||||
valueRole: "value"
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 13
|
||||
}
|
||||
Controls.Button {
|
||||
text: "Map"
|
||||
icon.name: "dialog-ok-apply"
|
||||
onClicked: { backend.setControllerMapping(buttonBox.currentValue, newControllerAction.currentValue); cpage.capturedButton = "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Kirigami.Heading {
|
||||
visible: backend.controllerMappings.length > 0
|
||||
level: 3
|
||||
text: "Current mappings"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: backend.controllerMappings
|
||||
delegate: RowLayout {
|
||||
id: crow
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
Controls.Label {
|
||||
text: crow.modelData.label
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
|
||||
}
|
||||
Controls.ComboBox {
|
||||
model: backend.controllerActions
|
||||
textRole: "text"
|
||||
valueRole: "value"
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 13
|
||||
Component.onCompleted: currentIndex = indexOfValue(crow.modelData.action)
|
||||
onActivated: backend.setControllerMapping(crow.modelData.button, currentValue)
|
||||
}
|
||||
Controls.Label {
|
||||
// Active: SteamVR delivers it to Frametop now (a controller is on).
|
||||
text: cpage.status.active.indexOf(crow.modelData.button) >= 0 ? "active"
|
||||
: cpage.status.inGame && !backend.controllerInGames ? "game's" : "waiting"
|
||||
opacity: 0.6
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 4
|
||||
Controls.ToolTip.text: text === "active" ? "SteamVR gives this button to Frametop"
|
||||
: text === "game's" ? "A game is running: the button is the game's until it quits"
|
||||
: "No controller with this button is on, or SteamVR doesn't give it to Frametop"
|
||||
Controls.ToolTip.visible: hover.hovered
|
||||
HoverHandler { id: hover }
|
||||
}
|
||||
Controls.Button {
|
||||
text: "Remove"
|
||||
icon.name: "edit-delete-remove"
|
||||
onClicked: backend.removeControllerMapping(crow.modelData.button)
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
Controls.Label {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.Wrap
|
||||
opacity: 0.7
|
||||
text: "Outside games, a mapped button is Frametop's; while a game runs it's the game's, unless "
|
||||
+ "\"In games\" is on. Unmapped buttons are left alone, and the system button stays SteamVR's. "
|
||||
+ "Clicks and scrolling go to the 3D pointer. The trigger and grip also move SteamVR's laser "
|
||||
+ "to that controller, so they're better left unmapped."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,6 +543,142 @@ Kirigami.ApplicationWindow {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Gaze
|
||||
Component {
|
||||
id: gazePage
|
||||
Kirigami.ScrollablePage {
|
||||
id: gpage
|
||||
title: "Gaze"
|
||||
property var status: backend.gazeStatus
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: "Calibrate…"
|
||||
icon.name: "crosshairs"
|
||||
tooltip: "Open the gaze probe to calibrate (fullscreen on a Frametop screen)"
|
||||
onTriggered: backend.openGazeProbe()
|
||||
},
|
||||
Kirigami.Action {
|
||||
text: "Reload calibration"
|
||||
icon.name: "view-refresh"
|
||||
enabled: backend.gazeServiceRunning
|
||||
onTriggered: backend.reloadGazeCalibration()
|
||||
},
|
||||
Kirigami.Action {
|
||||
text: "Forget nudges"
|
||||
icon.name: "edit-clear-history"
|
||||
enabled: backend.gazeServiceRunning
|
||||
tooltip: "Drop what your mouse nudges taught; the calibration stays"
|
||||
onTriggered: backend.forgetGazeLessons()
|
||||
}
|
||||
]
|
||||
|
||||
Kirigami.FormLayout {
|
||||
Kirigami.InlineMessage {
|
||||
Layout.fillWidth: true
|
||||
Layout.maximumWidth: Kirigami.Units.gridUnit * 30
|
||||
visible: true
|
||||
type: Kirigami.MessageType.Warning
|
||||
text: "Gaze mode is experimental. The pointer goes where you look and the mouse does the last bit; "
|
||||
+ "moving the mouse takes over, looking well away hands it back. A small nudge and a click "
|
||||
+ "teach the gaze service where it was off."
|
||||
}
|
||||
Controls.Switch {
|
||||
Kirigami.FormData.label: "Gaze pointer:"
|
||||
text: backend.gazeMode < 0 ? "Pointer helper not running" : "Pointer goes where you look"
|
||||
enabled: backend.gazeMode >= 0
|
||||
checked: backend.gazeMode > 0
|
||||
onToggled: backend.setGazeMode(checked)
|
||||
}
|
||||
Controls.Label {
|
||||
visible: backend.gazeMode >= 0 && (backend.gazeMode > 0) !== backend.gazeDefault
|
||||
text: "Toggled by a button; it starts " + (backend.gazeDefault ? "on" : "off") + " after a restart."
|
||||
opacity: 0.7
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
Repeater {
|
||||
model: backend.gazeSettings
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Kirigami.FormData.label: modelData.label + ":"
|
||||
Controls.Slider {
|
||||
id: gslider
|
||||
from: modelData.min
|
||||
to: modelData.max
|
||||
stepSize: modelData.step
|
||||
value: modelData.value
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 14
|
||||
onMoved: backend.setPointerSetting(modelData.key, value)
|
||||
}
|
||||
Controls.Label {
|
||||
text: gslider.value.toFixed(modelData.step < 0.1 ? 2 : 1) + " " + modelData.unit
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 5
|
||||
}
|
||||
Controls.ToolButton {
|
||||
icon.name: "edit-undo"
|
||||
display: Controls.AbstractButton.IconOnly
|
||||
text: "Default (" + modelData.default + ")"
|
||||
Controls.ToolTip.text: text
|
||||
Controls.ToolTip.visible: hovered
|
||||
onClicked: { gslider.value = modelData.default; backend.setPointerSetting(modelData.key, modelData.default) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Gaze service" }
|
||||
|
||||
Controls.Label {
|
||||
Kirigami.FormData.label: "Service:"
|
||||
text: backend.gazeServiceRunning
|
||||
? (gpage.status.ft_gaze ? "running" : "running, eye tracker reader restarting")
|
||||
: "not running (frametop-gaze.service, starts with SteamVR)"
|
||||
color: backend.gazeServiceRunning ? Kirigami.Theme.textColor : Kirigami.Theme.negativeTextColor
|
||||
}
|
||||
Controls.Label {
|
||||
visible: backend.gazeServiceRunning
|
||||
Kirigami.FormData.label: "Headset:"
|
||||
text: gpage.status.headset_on ? "on" : "off"
|
||||
}
|
||||
Controls.Label {
|
||||
visible: backend.gazeServiceRunning
|
||||
Kirigami.FormData.label: "Tracking:"
|
||||
text: gpage.status.rate === undefined || gpage.status.rate === null ? "…"
|
||||
: Math.round(gpage.status.rate) + " samples/s"
|
||||
+ (gpage.status.one_eye_share > 0.5 ? " · only one eye tracked" : "")
|
||||
color: gpage.status.one_eye_share > 0.5 ? Kirigami.Theme.neutralTextColor : Kirigami.Theme.textColor
|
||||
Controls.ToolTip.text: "Only one eye tracked: reseat the headset or check the lenses. It still works, "
|
||||
+ "probably less precisely."
|
||||
Controls.ToolTip.visible: gpage.status.one_eye_share > 0.5 && ghover.hovered
|
||||
HoverHandler { id: ghover }
|
||||
}
|
||||
Controls.Label {
|
||||
visible: backend.gazeServiceRunning
|
||||
Kirigami.FormData.label: "Calibration:"
|
||||
text: gpage.status.calibration_samples > 0
|
||||
? gpage.status.calibration_samples + " points (" + gpage.status.model + ")"
|
||||
: "none yet: use Calibrate…"
|
||||
}
|
||||
Controls.Label {
|
||||
visible: backend.gazeServiceRunning
|
||||
Kirigami.FormData.label: "Learned from nudges:"
|
||||
text: gpage.status.lessons + (gpage.status.lessons === 1 ? " nudge" : " nudges")
|
||||
+ (gpage.status.refused > 0 ? " (" + gpage.status.refused + " too far off, ignored)" : "")
|
||||
}
|
||||
}
|
||||
|
||||
footer: Controls.Label {
|
||||
padding: Kirigami.Units.largeSpacing
|
||||
wrapMode: Text.Wrap
|
||||
opacity: 0.7
|
||||
text: "Map a mouse button (Buttons) or a controller button (Controllers) to \"Gaze pointer on/off\" "
|
||||
+ "to switch it on the fly. A click waits for the release: if the gaze is off, drag onto the target "
|
||||
+ "with the button held and let go there. Hold still to drag: hold a press this long without moving "
|
||||
+ "to drag something instead. Look away to hand back: how far from the pointer you look before the "
|
||||
+ "gaze takes it back from the mouse. Largest nudge to learn: bigger mouse moves before a click "
|
||||
+ "are treated as using the mouse, not correcting the gaze."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Bluetooth
|
||||
Component {
|
||||
id: bluetoothPage
|
||||
|
||||
Reference in new issue
Block a user