mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 04:04:09 +02:00
Frametop: a multi-screen desktop and universal 3D mouse for the Steam Frame
Several KDE Plasma screens floating in SteamVR, each a real monitor of any resolution and shape, shown by our own compositor (ft-screens), with a layout, wrist pinning, and visibility modes; a Bluetooth mouse that drives all of SteamVR as a room-anchored 3D pointer (input relay, ft-pointer helper, ft_pointer SteamVR driver); two settings apps; and Bluetooth LE fixes. Installs on the headset with ./install.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
d439bc3f25
61 files changed
+8785
No files matched your search
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Launch Frametop Input Settings from a Plasma session on the Frame host.
|
||||
# The app runs in the dev container (PySide6 and Kirigami come from Fedora there).
|
||||
# podman needs the real XDG_RUNTIME_DIR and the real user bus (to reach systemd for
|
||||
# the container's cgroup; the Frametop session runs on a private bus from
|
||||
# dbus-run-session). The session's Wayland socket and bus go to the app itself.
|
||||
here=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
|
||||
wl=${WAYLAND_DISPLAY:-wayland-0}
|
||||
case $wl in /*) ;; *) wl="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/$wl" ;; esac
|
||||
session_bus=${DBUS_SESSION_BUS_ADDRESS:-}
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus
|
||||
exec "$HOME/.local/bin/distrobox" enter dev -- env WAYLAND_DISPLAY="$wl" DISPLAY="${DISPLAY:-}" \
|
||||
XAUTHORITY="${XAUTHORITY:-}" DBUS_SESSION_BUS_ADDRESS="$session_bus" \
|
||||
QT_QPA_PLATFORM="wayland;xcb" \
|
||||
python3 "$here/ft_input_settings.py" "$@"
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Frametop Input Settings
|
||||
GenericName=Mouse and keyboard settings for the 3D pointer
|
||||
Comment=Choose and map Bluetooth mice and keyboards for the SteamVR 3D pointer
|
||||
Exec=@REPO@/frametop/input-settings/ft-input-settings
|
||||
Icon=input-mouse
|
||||
Categories=Settings;HardwareSettings;
|
||||
Keywords=mouse;pointer;bluetooth;keyboard;steamvr;frametop;
|
||||
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frametop Input Settings: choose and map input devices for the universal 3D mouse.
|
||||
|
||||
A Kirigami (QML) app with a Python backend. It runs in the dev container and talks
|
||||
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.
|
||||
- Pointer: speed, dot size, distance and the rest, applied live.
|
||||
- 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.
|
||||
Launch with input-settings/ft-input-settings (host wrapper).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from PySide6.QtCore import Property, QObject, QSocketNotifier, QTimer, QUrl, Signal, Slot
|
||||
from PySide6.QtGui import QGuiApplication, QIcon
|
||||
from PySide6.QtQml import QQmlApplicationEngine
|
||||
from PySide6.QtQuickControls2 import QQuickStyle
|
||||
|
||||
RULES_PATH = os.path.expanduser("~/.config/frametop-input.json")
|
||||
CONF_PATH = os.path.expanduser("~/.config/frametop.conf")
|
||||
RELAY = "\0frametop_relay"
|
||||
HELPER = "\0ft_pointer_helper"
|
||||
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",
|
||||
"BTN_TRIGGER_HAPPY", "KEY_MIN_INTERESTING", "KEY_MAX", "KEY_CNT", "BTN_A", "BTN_B", "BTN_X", "BTN_Y"}
|
||||
DEFAULT_BUTTONS = {0x110: "left", 0x111: "right", 0x112: "middle", 0x113: "back", 0x114: "back"}
|
||||
ACTION_LABELS = {
|
||||
"left": "Left click", "right": "Right click", "middle": "Middle click", "back": "Back",
|
||||
"scroll_up": "Scroll up", "scroll_down": "Scroll down", "dashboard": "Toggle SteamVR dashboard",
|
||||
"recenter": "Recenter pointer", "pointer_toggle": "Pointer on/off", "sens_up": "Faster pointer",
|
||||
"sens_down": "Slower pointer", "layout_reset": "Reset desktop screen layout",
|
||||
"screens_toggle": "Hide/show desktop screens", "key": "Pass through as key",
|
||||
"none": "Do nothing",
|
||||
}
|
||||
ROLE_LABELS = {"pointer": "3D pointer", "passthrough": "Pass through", "ignore": "Ignore"}
|
||||
# Pointer settings: key, label, default, min, max, step, unit.
|
||||
POINTER_SETTINGS = [
|
||||
("POINTER_SENSITIVITY", "Speed", 0.03, 0.005, 0.12, 0.001, "°/count"),
|
||||
("POINTER_CURSOR_DEG", "Dot size", 0.4, 0.1, 2.0, 0.05, "°"),
|
||||
("POINTER_DISTANCE", "Distance in open space", 1.5, 0.5, 4.0, 0.1, "m"),
|
||||
("POINTER_ORIGIN_FRACTION", "SteamVR dot shrink", 0.95, 0.5, 0.98, 0.01, ""),
|
||||
("POINTER_ORIGIN_MARGIN", "Room for small controls", 0.15, 0.03, 0.5, 0.01, "m"),
|
||||
("POINTER_SCENE_RADIUS", "Dock / window-control reach", 0.5, 0.1, 1.5, 0.05, "m"),
|
||||
("POINTER_EDGE_REACH", "Panel edge reach", 0.3, 0.0, 1.0, 0.05, "m"),
|
||||
("POINTER_WAKE_COUNTS", "Movement to wake", 40, 5, 200, 5, "counts"),
|
||||
("POINTER_IDLE", "Release after idle", 30, 5, 120, 5, "s"),
|
||||
]
|
||||
|
||||
|
||||
def code_names():
|
||||
"""evdev key/button code -> name, from the kernel header (first name wins, BTN_ for buttons)."""
|
||||
names = {}
|
||||
try:
|
||||
with open("/usr/include/linux/input-event-codes.h") as f:
|
||||
for m in re.finditer(r"#define\s+((?:KEY|BTN)_\w+)\s+(0x[0-9a-fA-F]+|\d+)", f.read()):
|
||||
code = int(m.group(2), 0)
|
||||
name = m.group(1)
|
||||
if name in RANGE_ALIASES:
|
||||
continue
|
||||
if code not in names or (code >= BTN_MISC and name.startswith("BTN_") and not names[code].startswith("BTN_")):
|
||||
names[code] = name
|
||||
except OSError:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def read_json(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def read_conf():
|
||||
conf = {}
|
||||
try:
|
||||
with open(CONF_PATH) as f:
|
||||
for line in f:
|
||||
line = line.split("#", 1)[0].strip()
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
conf[k.strip()] = v.strip()
|
||||
except OSError:
|
||||
pass
|
||||
return conf
|
||||
|
||||
|
||||
def write_conf_value(key, value):
|
||||
"""Set KEY=value in frametop.conf, keeping comments and the rest of the file."""
|
||||
try:
|
||||
with open(CONF_PATH) as f:
|
||||
lines = f.read().splitlines()
|
||||
except OSError:
|
||||
lines = []
|
||||
for i, line in enumerate(lines):
|
||||
if line.split("#", 1)[0].strip().startswith(f"{key}="):
|
||||
comment = line[line.index("#"):] if "#" in line else ""
|
||||
lines[i] = f"{key}={value}" + (f" {comment}" if comment else "")
|
||||
break
|
||||
else:
|
||||
lines.append(f"{key}={value}")
|
||||
with open(CONF_PATH, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def host(*cmd):
|
||||
"""Run a command on the SteamOS host (we live in the dev container)."""
|
||||
runner = ["distrobox-host-exec"] if shutil.which("distrobox-host-exec") else []
|
||||
try:
|
||||
return subprocess.run(runner + list(cmd), capture_output=True, text=True, timeout=20)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return subprocess.CompletedProcess(cmd, 1, "", str(e))
|
||||
|
||||
|
||||
class Backend(QObject):
|
||||
devicesChanged = Signal()
|
||||
mappingsChanged = Signal()
|
||||
pointerChanged = Signal()
|
||||
bluetoothChanged = Signal()
|
||||
activity = Signal(str) # device id
|
||||
captured = Signal(int, str) # code, name
|
||||
message = Signal(str, bool) # text, is error
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._names = code_names()
|
||||
self._nodes = []
|
||||
self._pointer_mode = False
|
||||
self._capture_id = ""
|
||||
self._bluetooth = []
|
||||
self._relay_ok = False
|
||||
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)
|
||||
self.notifier = QSocketNotifier(self.sock.fileno(), QSocketNotifier.Read)
|
||||
self.notifier.activated.connect(self._read)
|
||||
self.poll = QTimer(interval=2000, timeout=self._refresh)
|
||||
self.poll.start()
|
||||
self.rewatch = QTimer(interval=50000, timeout=lambda: self._send("watch 60"))
|
||||
self.rewatch.start()
|
||||
self.reload_timer = QTimer(singleShot=True, interval=400, timeout=lambda: self._send("reload"))
|
||||
self._refresh()
|
||||
self._send("watch 60")
|
||||
self.refreshBluetooth()
|
||||
|
||||
# --- relay socket ---
|
||||
def _send(self, text, to=RELAY):
|
||||
try:
|
||||
self.sock.sendto(text.encode(), to)
|
||||
return True
|
||||
except OSError:
|
||||
if to == RELAY and self._relay_ok:
|
||||
self._relay_ok = False
|
||||
self.devicesChanged.emit()
|
||||
return False
|
||||
|
||||
def _refresh(self):
|
||||
self._send("devices")
|
||||
|
||||
def _read(self):
|
||||
while True:
|
||||
try:
|
||||
data = self.sock.recv(65536)
|
||||
except BlockingIOError:
|
||||
return
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
except ValueError:
|
||||
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 == "event":
|
||||
self.activity.emit(msg["id"])
|
||||
if (self._capture_id and msg["id"] == self._capture_id and msg["type"] == "key"
|
||||
and msg["value"] == 1):
|
||||
code = int(msg["code"])
|
||||
self._capture_id = ""
|
||||
self.captured.emit(code, self.codeName(code))
|
||||
|
||||
# --- devices ---
|
||||
@Property(bool, notify=devicesChanged)
|
||||
def relayRunning(self):
|
||||
return self._relay_ok
|
||||
|
||||
@Property(bool, notify=devicesChanged)
|
||||
def pointerMode(self):
|
||||
return self._pointer_mode
|
||||
|
||||
@Property("QVariantList", notify=devicesChanged)
|
||||
def devices(self):
|
||||
"""Connected devices, plus devices with saved rules or bindings that aren't connected
|
||||
(a sleeping Bluetooth mouse), so their bindings can still be edited and removed."""
|
||||
all_rules = read_json(RULES_PATH)
|
||||
rules = all_rules.get("devices", {})
|
||||
bindings = all_rules.get("buttons", {})
|
||||
grouped = {}
|
||||
for n in self._nodes:
|
||||
d = grouped.setdefault(n["id"], {"id": n["id"], "name": n["name"], "bus": n["bus"], "kinds": [],
|
||||
"nodes": [], "role": n["role"], "grabbed": False})
|
||||
d["nodes"].append(n["path"])
|
||||
d["kinds"] = sorted(set(d["kinds"]) | set(n["kinds"]))
|
||||
d["grabbed"] = d["grabbed"] or n["grabbed"]
|
||||
if "mouse" in n["kinds"]:
|
||||
d["name"] = n["name"].replace(" Mouse", "") # the Z3's nodes are "Z3 Mouse"/"Z3 Keyboard"
|
||||
for d in grouped.values():
|
||||
d["connected"] = True
|
||||
for device_id in set(rules) | {i for i, b in bindings.items() if b}:
|
||||
if device_id in grouped:
|
||||
continue
|
||||
rule = rules.get(device_id, {})
|
||||
# Not connected: the relay's default for a device we have bindings for is "pointer".
|
||||
grouped[device_id] = {"id": device_id, "name": rule.get("name", device_id), "bus": "",
|
||||
"kinds": [], "nodes": [], "role": rule.get("role", "pointer"),
|
||||
"grabbed": False, "connected": False}
|
||||
for d in grouped.values():
|
||||
d["explicit"] = d["id"] in rules and "role" in rules[d["id"]]
|
||||
d["roleLabel"] = ROLE_LABELS.get(d["role"], d["role"])
|
||||
return sorted(grouped.values(), key=lambda d: (not d["connected"], d["role"] != "pointer", d["name"].lower()))
|
||||
|
||||
@Property("QVariantList", constant=True)
|
||||
def roles(self):
|
||||
return [{"value": k, "text": v} for k, v in ROLE_LABELS.items()]
|
||||
|
||||
@Slot(str, str, str)
|
||||
def setRole(self, device_id, role, name):
|
||||
rules = read_json(RULES_PATH)
|
||||
rules.setdefault("devices", {})[device_id] = {"role": role, "name": name}
|
||||
self._save_rules(rules)
|
||||
self.message.emit(f"{name}: {ROLE_LABELS.get(role, role)}", False)
|
||||
|
||||
@Slot(str)
|
||||
def resetRole(self, device_id):
|
||||
rules = read_json(RULES_PATH)
|
||||
rules.get("devices", {}).get(device_id, {}).pop("role", None)
|
||||
self._save_rules(rules)
|
||||
|
||||
@Slot(str)
|
||||
def forgetDevice(self, device_id):
|
||||
"""Drop everything saved for a device: role, name, and bindings."""
|
||||
rules = read_json(RULES_PATH)
|
||||
name = rules.get("devices", {}).pop(device_id, {}).get("name", device_id)
|
||||
rules.get("buttons", {}).pop(device_id, None)
|
||||
self._save_rules(rules)
|
||||
self.message.emit(f"Forgot {name}", False)
|
||||
|
||||
def _remember_name(self, rules, device_id):
|
||||
"""Keep the device's name with its rules, so it can be listed while disconnected."""
|
||||
entry = rules.setdefault("devices", {}).setdefault(device_id, {})
|
||||
if "name" not in entry:
|
||||
for d in self.devices:
|
||||
if d["id"] == device_id:
|
||||
entry["name"] = d["name"]
|
||||
|
||||
def _save_rules(self, rules):
|
||||
os.makedirs(os.path.dirname(RULES_PATH), exist_ok=True)
|
||||
with open(RULES_PATH, "w") as f:
|
||||
json.dump(rules, f, indent=2)
|
||||
self._send("reload")
|
||||
QTimer.singleShot(300, self._refresh)
|
||||
self.mappingsChanged.emit()
|
||||
self.devicesChanged.emit()
|
||||
|
||||
# --- buttons ---
|
||||
@Slot(int, result=str)
|
||||
def codeName(self, code):
|
||||
return self._names.get(code, f"code {code}")
|
||||
|
||||
@Property("QVariantList", constant=True)
|
||||
def actions(self):
|
||||
return [{"value": k, "text": v} for k, v in ACTION_LABELS.items()]
|
||||
|
||||
@Slot(str, result="QVariantList")
|
||||
def mappings(self, device_id):
|
||||
custom = read_json(RULES_PATH).get("buttons", {}).get(device_id, {})
|
||||
rows = {}
|
||||
for code, action in DEFAULT_BUTTONS.items():
|
||||
rows[code] = {"code": code, "action": action, "custom": False}
|
||||
for code, action in custom.items():
|
||||
rows[int(code)] = {"code": int(code), "action": action, "custom": True}
|
||||
out = []
|
||||
for code in sorted(rows):
|
||||
r = rows[code]
|
||||
r["isDefault"] = code in DEFAULT_BUTTONS # a built-in binding (left/right/middle/side/extra)
|
||||
r["name"] = self.codeName(code)
|
||||
r["actionLabel"] = ACTION_LABELS.get(r["action"], r["action"])
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
@Slot(str)
|
||||
def startCapture(self, device_id):
|
||||
self._capture_id = device_id
|
||||
self._send("watch 60")
|
||||
|
||||
@Slot()
|
||||
def cancelCapture(self):
|
||||
self._capture_id = ""
|
||||
|
||||
@Slot(str, int, str)
|
||||
def setMapping(self, device_id, code, action):
|
||||
rules = read_json(RULES_PATH)
|
||||
rules.setdefault("buttons", {}).setdefault(device_id, {})[str(code)] = action
|
||||
self._remember_name(rules, device_id)
|
||||
self._save_rules(rules)
|
||||
self.message.emit(f"{self.codeName(code)} → {ACTION_LABELS.get(action, action)}", False)
|
||||
|
||||
@Slot(str, int)
|
||||
def removeMapping(self, device_id, code):
|
||||
"""Drop a custom binding: built-in buttons go back to their default, others pass through."""
|
||||
rules = read_json(RULES_PATH)
|
||||
rules.get("buttons", {}).get(device_id, {}).pop(str(code), None)
|
||||
self._save_rules(rules)
|
||||
default = DEFAULT_BUTTONS.get(code)
|
||||
self.message.emit(f"{self.codeName(code)}: " + (f"back to {ACTION_LABELS[default]}" if default
|
||||
else "binding removed"), False)
|
||||
|
||||
@Slot(str, int)
|
||||
def unbind(self, device_id, code):
|
||||
"""Make a button do nothing (also works for the built-in bindings)."""
|
||||
self.setMapping(device_id, code, "none")
|
||||
|
||||
@Slot(str)
|
||||
def clearMappings(self, device_id):
|
||||
rules = read_json(RULES_PATH)
|
||||
removed = len(rules.get("buttons", {}).pop(device_id, {}) or {})
|
||||
self._save_rules(rules)
|
||||
self.message.emit(f"Removed {removed} binding{'s' if removed != 1 else ''}; defaults restored", False)
|
||||
|
||||
# --- pointer settings ---
|
||||
@Property("QVariantList", notify=pointerChanged)
|
||||
def pointerSettings(self):
|
||||
conf = read_conf()
|
||||
out = []
|
||||
for key, label, default, lo, hi, step, unit in POINTER_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(str, float)
|
||||
def setPointerSetting(self, key, value):
|
||||
integer = key in ("POINTER_WAKE_COUNTS", "POINTER_IDLE")
|
||||
write_conf_value(key, str(int(round(value))) if integer else f"{value:.3f}".rstrip("0").rstrip("."))
|
||||
self.reload_timer.start() # debounce slider drags
|
||||
self.pointerChanged.emit()
|
||||
|
||||
@Slot(result=bool)
|
||||
def recenter(self):
|
||||
return self._send("recenter", HELPER)
|
||||
|
||||
# --- bluetooth ---
|
||||
@Property("QVariantList", notify=bluetoothChanged)
|
||||
def bluetooth(self):
|
||||
return self._bluetooth
|
||||
|
||||
@Slot()
|
||||
def refreshBluetooth(self):
|
||||
devices = []
|
||||
listing = host("bluetoothctl", "devices", "Paired")
|
||||
for line in listing.stdout.splitlines():
|
||||
parts = line.split(" ", 2)
|
||||
if len(parts) < 3 or parts[0] != "Device":
|
||||
continue
|
||||
info = host("bluetoothctl", "info", parts[1]).stdout
|
||||
battery = re.search(r"Battery Percentage: \S+ \((\d+)\)", info)
|
||||
devices.append({"address": parts[1], "name": parts[2],
|
||||
"connected": "Connected: yes" in info,
|
||||
"battery": int(battery.group(1)) if battery else -1})
|
||||
self._bluetooth = devices
|
||||
self.bluetoothChanged.emit()
|
||||
|
||||
@Slot()
|
||||
def applyBluetoothFixes(self):
|
||||
if not os.path.exists("/run/host/etc/steamframe/bt-fixups.sh") and not os.path.exists("/etc/steamframe/bt-fixups.sh"):
|
||||
self.message.emit("Bluetooth fixes aren't installed (setup/bluetooth/install.sh)", True)
|
||||
return
|
||||
result = host("pkexec", "/etc/steamframe/bt-fixups.sh")
|
||||
if result.returncode == 0:
|
||||
self.message.emit("Bluetooth fixes applied: " + " ".join(result.stdout.split())[:120], False)
|
||||
else:
|
||||
self.message.emit("Couldn't apply the Bluetooth fixes: " + (result.stderr.strip() or "cancelled")[:160], True)
|
||||
self.refreshBluetooth()
|
||||
|
||||
|
||||
def main():
|
||||
app = QGuiApplication(sys.argv)
|
||||
app.setApplicationName("ft-input-settings")
|
||||
app.setApplicationDisplayName("Frametop Input Settings")
|
||||
app.setDesktopFileName("ft-input-settings")
|
||||
if not QIcon.themeName():
|
||||
QIcon.setThemeName("breeze")
|
||||
QQuickStyle.setStyle("org.kde.desktop")
|
||||
engine = QQmlApplicationEngine()
|
||||
backend = Backend()
|
||||
engine.rootContext().setContextProperty("backend", backend)
|
||||
engine.rootContext().setContextProperty("startPage", os.environ.get("FT_INPUT_PAGE", "devices"))
|
||||
engine.load(QUrl.fromLocalFile(os.path.join(os.path.dirname(os.path.abspath(__file__)), "main.qml")))
|
||||
if not engine.rootObjects():
|
||||
sys.exit(1)
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install (or remove) the Frametop Input Settings menu entry on the Frame.
|
||||
# Usage: input-settings/install.sh [install|uninstall]
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
. "$root/scripts/_env.sh"
|
||||
"$root/scripts/sync.sh" >/dev/null
|
||||
case ${1:-install} in
|
||||
install)
|
||||
fill_template "$root/input-settings/ft-input-settings.desktop" |
|
||||
on_frame "chmod +x input-settings/ft-input-settings && mkdir -p ~/.local/share/applications && cat > ~/.local/share/applications/ft-input-settings.desktop"
|
||||
echo "installed: Frametop Input Settings (Plasma menu, Settings)" ;;
|
||||
uninstall) on_frame 'rm -f ~/.local/share/applications/ft-input-settings.desktop; echo removed' ;;
|
||||
*) echo "usage: $0 [install|uninstall]" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,416 @@
|
||||
// Frametop Input Settings (Kirigami). Backend: ft_input_settings.py ("backend").
|
||||
import QtQuick
|
||||
import QtQuick.Controls as Controls
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
|
||||
Kirigami.ApplicationWindow {
|
||||
id: root
|
||||
title: "Frametop Input Settings"
|
||||
width: Kirigami.Units.gridUnit * 44
|
||||
height: Kirigami.Units.gridUnit * 34
|
||||
|
||||
globalDrawer: Kirigami.GlobalDrawer {
|
||||
isMenu: false
|
||||
modal: false
|
||||
collapsible: true
|
||||
collapsed: root.width < Kirigami.Units.gridUnit * 30
|
||||
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: "Pointer"; icon.name: "transform-move"; onTriggered: root.show(pointerPage) },
|
||||
Kirigami.Action { text: "Bluetooth"; icon.name: "preferences-system-bluetooth"; onTriggered: root.show(bluetoothPage) }
|
||||
]
|
||||
}
|
||||
|
||||
function show(page) {
|
||||
pageStack.clear()
|
||||
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
|
||||
|
||||
Connections {
|
||||
target: backend
|
||||
function onMessage(text, isError) {
|
||||
root.showPassiveNotification(text, isError ? "long" : "short")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Devices
|
||||
Component {
|
||||
id: devicesPage
|
||||
Kirigami.ScrollablePage {
|
||||
title: "Devices"
|
||||
|
||||
header: Kirigami.InlineMessage {
|
||||
visible: !backend.relayRunning || !backend.pointerMode
|
||||
position: Kirigami.InlineMessage.Position.Header
|
||||
type: backend.relayRunning ? Kirigami.MessageType.Information : Kirigami.MessageType.Error
|
||||
text: !backend.relayRunning
|
||||
? "The input relay isn't running (frametop-input-relay.service)."
|
||||
: "Pointer mode is off (POINTER=0): pointer devices act as a plain mouse."
|
||||
}
|
||||
|
||||
ListView {
|
||||
model: backend.devices
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Kirigami.PlaceholderMessage {
|
||||
anchors.centerIn: parent
|
||||
visible: parent.count === 0
|
||||
text: "No USB or Bluetooth mice or keyboards connected"
|
||||
explanation: "Connect or wake one; it appears here within a second."
|
||||
}
|
||||
|
||||
delegate: Controls.ItemDelegate {
|
||||
id: row
|
||||
required property var modelData
|
||||
width: ListView.view.width
|
||||
hoverEnabled: false
|
||||
down: false
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
// Activity light: flashes when the device sends input.
|
||||
Rectangle {
|
||||
id: light
|
||||
implicitWidth: Kirigami.Units.gridUnit * 0.8
|
||||
implicitHeight: implicitWidth
|
||||
radius: width / 2
|
||||
color: Kirigami.Theme.disabledTextColor
|
||||
opacity: 0.35
|
||||
Connections {
|
||||
target: backend
|
||||
function onActivity(id) {
|
||||
if (id === row.modelData.id) {
|
||||
light.color = Kirigami.Theme.positiveTextColor
|
||||
light.opacity = 1
|
||||
fade.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
Timer {
|
||||
id: fade
|
||||
interval: 350
|
||||
onTriggered: { light.color = Kirigami.Theme.disabledTextColor; light.opacity = 0.35 }
|
||||
}
|
||||
}
|
||||
|
||||
Kirigami.Icon {
|
||||
source: row.modelData.kinds.indexOf("mouse") >= 0 ? "input-mouse" : "input-keyboard"
|
||||
implicitWidth: Kirigami.Units.iconSizes.medium
|
||||
implicitHeight: implicitWidth
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
Controls.Label {
|
||||
text: row.modelData.name
|
||||
font.bold: true
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Controls.Label {
|
||||
text: row.modelData.connected
|
||||
? row.modelData.bus + " · " + row.modelData.kinds.join(" + ") + " · " + row.modelData.id
|
||||
+ (row.modelData.grabbed ? " · grabbed" : "")
|
||||
: "not connected · saved settings · " + row.modelData.id
|
||||
opacity: 0.7
|
||||
font: Kirigami.Theme.smallFont
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Controls.ComboBox {
|
||||
model: backend.roles
|
||||
textRole: "text"
|
||||
valueRole: "value"
|
||||
Component.onCompleted: currentIndex = indexOfValue(row.modelData.role)
|
||||
onActivated: backend.setRole(row.modelData.id, currentValue, row.modelData.name)
|
||||
}
|
||||
Controls.Button {
|
||||
visible: !row.modelData.connected
|
||||
text: "Forget"
|
||||
icon.name: "edit-delete-remove"
|
||||
onClicked: backend.forgetDevice(row.modelData.id)
|
||||
}
|
||||
Controls.ToolButton {
|
||||
icon.name: "edit-undo"
|
||||
visible: row.modelData.explicit
|
||||
display: Controls.AbstractButton.IconOnly
|
||||
text: "Use the default role"
|
||||
Controls.ToolTip.text: text
|
||||
Controls.ToolTip.visible: hovered
|
||||
onClicked: backend.resetRole(row.modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: Controls.Label {
|
||||
padding: Kirigami.Units.largeSpacing
|
||||
wrapMode: Text.Wrap
|
||||
opacity: 0.7
|
||||
text: "Move or press a device to see which row it is. 3D pointer: grabbed, drives the SteamVR pointer. "
|
||||
+ "Pass through: left alone (a Meta tap still toggles the dashboard). Ignore: left alone."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Buttons
|
||||
Component {
|
||||
id: buttonsPage
|
||||
Kirigami.ScrollablePage {
|
||||
id: bpage
|
||||
title: "Buttons"
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: "Remove all"
|
||||
icon.name: "edit-clear-all"
|
||||
tooltip: "Remove every binding you added for this device; built-in buttons go back to their defaults"
|
||||
enabled: bpage.rows.some(r => r.custom)
|
||||
onTriggered: backend.clearMappings(bpage.deviceId)
|
||||
}
|
||||
]
|
||||
property string deviceId: pointerDevices.length > 0 ? pointerDevices[Math.max(0, deviceBox.currentIndex)].id : ""
|
||||
property var pointerDevices: backend.devices.filter(d => d.role === "pointer")
|
||||
property int capturedCode: -1
|
||||
property string capturedName: ""
|
||||
property bool capturing: false
|
||||
property var rows: deviceId ? backend.mappings(deviceId) : []
|
||||
|
||||
Connections {
|
||||
target: backend
|
||||
function onCaptured(code, name) { bpage.capturedCode = code; bpage.capturedName = name; bpage.capturing = false }
|
||||
function onMappingsChanged() { bpage.rows = bpage.deviceId ? backend.mappings(bpage.deviceId) : [] }
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
Kirigami.PlaceholderMessage {
|
||||
Layout.fillWidth: true
|
||||
visible: bpage.pointerDevices.length === 0
|
||||
text: "No 3D pointer devices"
|
||||
explanation: "Set a device's role to 3D pointer on the Devices page."
|
||||
}
|
||||
|
||||
Kirigami.FormLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: bpage.pointerDevices.length > 0
|
||||
|
||||
Controls.ComboBox {
|
||||
id: deviceBox
|
||||
Kirigami.FormData.label: "Device:"
|
||||
model: bpage.pointerDevices.map(d => ({ name: d.name + (d.connected ? "" : " (not connected)"), id: d.id }))
|
||||
textRole: "name"
|
||||
onActivated: { bpage.capturedCode = -1; bpage.rows = backend.mappings(bpage.deviceId) }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: "New mapping:"
|
||||
enabled: bpage.pointerDevices.length > 0 && bpage.pointerDevices[Math.max(0, deviceBox.currentIndex)].connected
|
||||
Controls.Button {
|
||||
text: bpage.capturing ? "Press a button or key on the device…" : "Capture a button"
|
||||
icon.name: "input-mouse-click-left"
|
||||
highlighted: bpage.capturing
|
||||
onClicked: {
|
||||
if (bpage.capturing) { backend.cancelCapture(); bpage.capturing = false }
|
||||
else { bpage.capturedCode = -1; bpage.capturing = true; backend.startCapture(bpage.deviceId) }
|
||||
}
|
||||
}
|
||||
Controls.Label {
|
||||
visible: bpage.capturedCode >= 0
|
||||
text: bpage.capturedName
|
||||
font.bold: true
|
||||
}
|
||||
Controls.ComboBox {
|
||||
id: newAction
|
||||
visible: bpage.capturedCode >= 0
|
||||
model: backend.actions
|
||||
textRole: "text"
|
||||
valueRole: "value"
|
||||
}
|
||||
Controls.Button {
|
||||
visible: bpage.capturedCode >= 0
|
||||
text: "Map"
|
||||
icon.name: "dialog-ok-apply"
|
||||
onClicked: { backend.setMapping(bpage.deviceId, bpage.capturedCode, newAction.currentValue); bpage.capturedCode = -1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Kirigami.Heading {
|
||||
visible: bpage.rows.length > 0
|
||||
level: 3
|
||||
text: "Current mappings"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: bpage.rows
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
Controls.Label {
|
||||
text: modelData.name
|
||||
font.family: "monospace"
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
|
||||
}
|
||||
Controls.ComboBox {
|
||||
model: backend.actions
|
||||
textRole: "text"
|
||||
valueRole: "value"
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 13
|
||||
Component.onCompleted: currentIndex = indexOfValue(modelData.action)
|
||||
onActivated: backend.setMapping(bpage.deviceId, modelData.code, currentValue)
|
||||
}
|
||||
Controls.Label {
|
||||
text: modelData.custom ? (modelData.isDefault ? "changed" : "custom") : "default"
|
||||
opacity: 0.6
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 4
|
||||
}
|
||||
// Remove: your own binding goes away (the button passes through again).
|
||||
// Reset: a changed built-in binding goes back to its default.
|
||||
// Unbind: an untouched built-in binding is set to "Do nothing".
|
||||
Controls.Button {
|
||||
text: modelData.custom ? (modelData.isDefault ? "Reset" : "Remove") : "Unbind"
|
||||
icon.name: modelData.custom ? (modelData.isDefault ? "edit-undo" : "edit-delete-remove")
|
||||
: "list-remove"
|
||||
onClicked: modelData.custom ? backend.removeMapping(bpage.deviceId, modelData.code)
|
||||
: backend.unbind(bpage.deviceId, modelData.code)
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
Controls.Label {
|
||||
Layout.fillWidth: true
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Pointer
|
||||
Component {
|
||||
id: pointerPage
|
||||
Kirigami.ScrollablePage {
|
||||
title: "Pointer"
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: "Recenter"
|
||||
icon.name: "zoom-fit-best"
|
||||
onTriggered: backend.recenter()
|
||||
}
|
||||
]
|
||||
|
||||
Kirigami.FormLayout {
|
||||
Repeater {
|
||||
model: backend.pointerSettings
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Kirigami.FormData.label: modelData.label + ":"
|
||||
Controls.Slider {
|
||||
id: slider
|
||||
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: (modelData.step < 1 ? slider.value.toFixed(modelData.step < 0.01 ? 3 : 2) : Math.round(slider.value))
|
||||
+ (modelData.unit ? " " + 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: { slider.value = modelData.default; backend.setPointerSetting(modelData.key, modelData.default) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: Controls.Label {
|
||||
padding: Kirigami.Units.largeSpacing
|
||||
wrapMode: Text.Wrap
|
||||
opacity: 0.7
|
||||
text: "Changes apply live. SteamVR dot shrink: how close to the target the pointer's laser starts; "
|
||||
+ "higher makes SteamVR's own blue dot smaller (max 0.98)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Bluetooth
|
||||
Component {
|
||||
id: bluetoothPage
|
||||
Kirigami.ScrollablePage {
|
||||
title: "Bluetooth"
|
||||
actions: [
|
||||
Kirigami.Action { text: "Refresh"; icon.name: "view-refresh"; onTriggered: backend.refreshBluetooth() },
|
||||
Kirigami.Action {
|
||||
text: "Apply Bluetooth fixes"
|
||||
icon.name: "tools-wizard"
|
||||
tooltip: "Privacy off, and address resolution on bonded LE devices (asks for your password)"
|
||||
onTriggered: backend.applyBluetoothFixes()
|
||||
}
|
||||
]
|
||||
|
||||
ListView {
|
||||
model: backend.bluetooth
|
||||
Kirigami.PlaceholderMessage {
|
||||
anchors.centerIn: parent
|
||||
visible: parent.count === 0
|
||||
text: "No paired Bluetooth devices"
|
||||
explanation: "Pair in Steam: Settings → Bluetooth."
|
||||
}
|
||||
delegate: Controls.ItemDelegate {
|
||||
required property var modelData
|
||||
width: ListView.view.width
|
||||
contentItem: RowLayout {
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
Kirigami.Icon {
|
||||
source: "preferences-system-bluetooth"
|
||||
implicitWidth: Kirigami.Units.iconSizes.medium
|
||||
implicitHeight: implicitWidth
|
||||
opacity: modelData.connected ? 1 : 0.4
|
||||
}
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
Controls.Label { text: modelData.name; font.bold: true }
|
||||
Controls.Label {
|
||||
text: modelData.address + " · " + (modelData.connected ? "connected" : "not connected")
|
||||
+ (modelData.battery >= 0 ? " · battery " + modelData.battery + "%" : "")
|
||||
opacity: 0.7
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: Controls.Label {
|
||||
padding: Kirigami.Units.largeSpacing
|
||||
wrapMode: Text.Wrap
|
||||
opacity: 0.7
|
||||
text: "Pair new devices in Steam (Settings → Bluetooth). After pairing an LE mouse or keyboard, "
|
||||
+ "use Apply Bluetooth fixes once so it reconnects on its own."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user