mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 09:00:11 +02:00
Key combinations (and mouse and controller buttons) get two new actions: - steam_menu: Open Steam menu / close dashboard (steam/ft-steam menu). - command:CMD: run CMD with sh -c, as the relay's service, with layout/, float/ and steam/ on its PATH. Input Settings offers it for key combinations as Run a command... Both work without pointer mode. A modifier on its own is now a key combination too: a tap, pressed and released with no other key, mouse button, or scroll in between. A bound tap sends the desktop F24 before the release, so Plasma's launcher stays shut. The defaults gain a Meta tap for the Steam menu; this replaces META_DASHBOARD, which only worked in pointer mode. input/test/keys-test.py runs the relay against fake devices with every outgoing socket renamed, so it's safe next to the live relay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
7.4 KiB
Python
Executable File
220 lines
7.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Offline test of the input relay's key combinations and modifier taps.
|
|
|
|
Runs the relay's main() against a fake pass-through keyboard and a fake mouse (pipes), with
|
|
every socket it sends to renamed, no uinput devices, no grabs, and ft-steam swapped for a
|
|
logger. Nothing reaches the live desktop, SteamVR, or the running relay, so it's safe next
|
|
to them. Rules come from the test, not ~/.config/frametop-input.json.
|
|
|
|
input/test/keys-test.py [RELAY] (default: input/input-relay.py next to this folder)
|
|
"""
|
|
import fcntl
|
|
import importlib.util
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
relay_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "input-relay.py")
|
|
tag = f"ft_keys_test_{os.getpid()}"
|
|
with open(relay_path) as f:
|
|
src = f.read().replace('"\\0frametop_relay"', f'"\\0{tag}_relay"')
|
|
relay = importlib.util.module_from_spec(importlib.util.spec_from_loader("relay", loader=None))
|
|
relay.__file__ = os.path.abspath(relay_path)
|
|
exec(compile(src, relay_path, "exec"), relay.__dict__)
|
|
|
|
for name in ("SCREENS", "HELPER", "FLOAT", "GAZED", "KEYS"):
|
|
setattr(relay, name, f"\0{tag}_{name.lower()}")
|
|
OUT = tempfile.mkdtemp(prefix="ft-keys-test-")
|
|
steam_log, cmd_log = f"{OUT}/steam.log", f"{OUT}/cmd.log"
|
|
relay.FT_STEAM = f"{OUT}/ft-steam"
|
|
with open(relay.FT_STEAM, "w") as f:
|
|
f.write(f'#!/bin/sh\necho "$@" >> {steam_log}\n')
|
|
os.chmod(relay.FT_STEAM, 0o755)
|
|
|
|
|
|
class NoDevice:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def emit(self, *args):
|
|
pass
|
|
|
|
def sync(self):
|
|
pass
|
|
|
|
|
|
relay.Virtual = NoDevice
|
|
relay.log = lambda *args: None # the relay's own log
|
|
bindings = {"now": None} # the rules' key_bindings; None: the relay's defaults
|
|
|
|
|
|
def read_rules(path=None):
|
|
rules = {"devices": {}, "buttons": {}, "controller_buttons": {}}
|
|
rules["key_bindings"] = dict(relay.DEFAULT_KEY_BINDINGS if bindings["now"] is None else bindings["now"])
|
|
return rules
|
|
|
|
|
|
relay.read_rules = read_rules
|
|
relay.read_config = lambda path=None: {"POINTER": "0"}
|
|
|
|
# The fake devices: /dev/input/event900 (keyboard) and event901 (mouse), each a pipe.
|
|
kb_r, kb_w = os.pipe()
|
|
ms_r, ms_w = os.pipe()
|
|
for fd in (kb_r, ms_r):
|
|
os.set_blocking(fd, False)
|
|
FAKE = {"/dev/input/event900": kb_r, "/dev/input/event901": ms_r}
|
|
HELD = {kb_r: set(), ms_r: set()} # what EVIOCGKEY says each holds
|
|
|
|
|
|
class Inode:
|
|
st_ino = 1
|
|
|
|
|
|
fake_os = type(os)("os")
|
|
fake_os.__dict__.update(os.__dict__)
|
|
fake_os.listdir = lambda path: ["event900", "event901"] if path == "/dev/input" else os.listdir(path)
|
|
fake_os.stat = lambda path, *a, **k: Inode() if path in FAKE else os.stat(path, *a, **k)
|
|
relay.os = fake_os
|
|
|
|
|
|
def ioctl(fd, request, arg=0, *rest):
|
|
if fd in HELD and request == relay.EVIOCGKEY:
|
|
for c in HELD[fd]:
|
|
arg[c // 8] |= 1 << (c % 8)
|
|
return 0
|
|
return fcntl.ioctl(fd, request, arg, *rest)
|
|
|
|
|
|
fake_fcntl = type(fcntl)("fcntl")
|
|
fake_fcntl.__dict__.update(fcntl.__dict__)
|
|
fake_fcntl.ioctl = ioctl
|
|
relay.fcntl = fake_fcntl
|
|
|
|
|
|
def probe(path):
|
|
if FAKE[path] == kb_r:
|
|
return relay.Node(path, kb_r, "test keyboard", relay.BUS_USB, 1, 2, "", False, True)
|
|
return relay.Node(path, ms_r, "test mouse", relay.BUS_USB, 3, 4, "", True, False)
|
|
|
|
|
|
relay.probe = probe
|
|
|
|
screens = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
screens.bind(relay.SCREENS)
|
|
screens.settimeout(0.05)
|
|
EVENT = struct.Struct("llHHi")
|
|
|
|
|
|
def send(fd, etype, code, value):
|
|
os.write(fd, EVENT.pack(0, 0, etype, code, value) + EVENT.pack(0, 0, 0, 0, 0))
|
|
time.sleep(0.03)
|
|
|
|
|
|
def key(code, value, fd=kb_w):
|
|
held = HELD[ms_r if fd == ms_w else kb_r]
|
|
(held.add if value else held.discard)(code)
|
|
send(fd, relay.EV_KEY, code, value)
|
|
|
|
|
|
def typed():
|
|
"""What the desktop was sent since the last call."""
|
|
got = []
|
|
while True:
|
|
try:
|
|
got.append(screens.recv(256).decode())
|
|
except socket.timeout:
|
|
return got
|
|
|
|
|
|
def use(key_bindings):
|
|
bindings["now"] = key_bindings
|
|
c = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
c.bind("")
|
|
c.settimeout(2)
|
|
c.sendto(b"reload", f"\0{tag}_relay")
|
|
c.recv(4096)
|
|
time.sleep(0.05)
|
|
typed()
|
|
|
|
|
|
def lines(path, wait=0.3):
|
|
time.sleep(wait)
|
|
try:
|
|
with open(path) as f:
|
|
return f.read().split()
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
failures = []
|
|
|
|
|
|
def check(label, got, want):
|
|
ok = got == want
|
|
print(("ok " if ok else "FAIL ") + label + ("" if ok else f": got {got!r}, want {want!r}"), flush=True)
|
|
if not ok:
|
|
failures.append(label)
|
|
|
|
|
|
META, RMETA, SHIFT, CTRL, RCTRL, A, F, J = 125, 126, 42, 29, 97, 30, 33, 36
|
|
F24 = ["key 194 1", "key 194 0"]
|
|
|
|
|
|
def tests():
|
|
time.sleep(1.5) # the relay's first device scan
|
|
typed()
|
|
key(META, 1); key(META, 0)
|
|
check("Meta tap (default): Steam menu", lines(steam_log), ["menu"])
|
|
check("Meta tap: the desktop gets F24 before Meta's release", typed(), ["key 125 1"] + F24 + ["key 125 0"])
|
|
|
|
key(RMETA, 1); key(RMETA, 2); key(RMETA, 2); key(RMETA, 0)
|
|
check("right Meta held (autorepeat), then released: a tap", lines(steam_log), ["menu", "menu"])
|
|
typed()
|
|
|
|
key(META, 1); key(A, 1); key(A, 0); key(META, 0)
|
|
check("Meta+A: no tap", lines(steam_log), ["menu", "menu"])
|
|
check("Meta+A (unbound) is typed as is", typed(), ["key 125 1", "key 30 1", "key 30 0", "key 125 0"])
|
|
|
|
key(META, 1); key(relay.BTN_LEFT, 1, ms_w); key(relay.BTN_LEFT, 0, ms_w); key(META, 0)
|
|
check("Meta+click: no tap", lines(steam_log), ["menu", "menu"])
|
|
key(META, 1); send(ms_w, relay.EV_REL, relay.REL_WHEEL, 1); key(META, 0)
|
|
check("Meta+scroll: no tap", lines(steam_log), ["menu", "menu"])
|
|
key(META, 1); key(SHIFT, 1); key(SHIFT, 0); key(META, 0)
|
|
check("Meta+Shift: no tap", lines(steam_log), ["menu", "menu"])
|
|
typed()
|
|
|
|
key(META, 1); key(J, 1); key(J, 0); key(META, 0)
|
|
check("Meta+J (default gaze click): no tap", lines(steam_log), ["menu", "menu"])
|
|
check("Meta+J: F24, Meta up early, its real release dropped", typed(), ["key 125 1"] + F24 + ["key 125 0"])
|
|
|
|
use({"29+42+33": f"command:echo combo >> {cmd_log}", "125": "none"})
|
|
key(CTRL, 1); key(SHIFT, 1); key(F, 1); key(F, 0); key(SHIFT, 0); key(CTRL, 0)
|
|
check("Ctrl+Shift+F runs its command", lines(cmd_log), ["combo"])
|
|
check("the combination's last key isn't typed", [k for k in typed() if k.startswith("key 33 ")], [])
|
|
key(META, 1); key(META, 0)
|
|
check("Meta tap bound to none: nothing", lines(steam_log), ["menu", "menu"])
|
|
check("Meta tap bound to none: no F24", typed(), ["key 125 1", "key 125 0"])
|
|
|
|
use({"29": f"command:command -v ft-steam ft-layout ft-float | wc -l >> {cmd_log}"})
|
|
key(RCTRL, 1); key(RCTRL, 0)
|
|
check("right Ctrl tap runs its command, which finds Frametop's tools", lines(cmd_log), ["combo", "3"])
|
|
check("Ctrl tap: F24 before Ctrl's release", typed(), ["key 97 1"] + F24 + ["key 97 0"])
|
|
|
|
check("an empty command isn't an action", relay.known_action("command: "), False)
|
|
check("steam_menu and commands work without pointer mode",
|
|
(relay.needs_pointer("steam_menu"), relay.needs_pointer("command:ls")), (False, False))
|
|
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
|
|
shutil.rmtree(OUT, ignore_errors=True)
|
|
os._exit(1 if failures else 0)
|
|
|
|
|
|
threading.Thread(target=tests, daemon=True).start()
|
|
sys.argv = [relay_path, "--no-grab"]
|
|
relay.main()
|