Nix-Vibe public snapshot (squashed history)
Current state of main at 0240060 feat(hp-laptop): install TeleportFling from its flake. History intentionally collapsed to a single commit; this repo mirrors only the latest state.
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
#!/usr/bin/env python3
|
||||
# hyprland-tablet-daemon.py — Hyprland tablet-mode rotation + on-screen keyboard.
|
||||
#
|
||||
# Wired to the ThinkPad X1 Yoga Gen 6 (Intel HID switches, /dev/input/event17):
|
||||
# * SW_TABLET_MODE tells us the LCD is folded into tablet mode.
|
||||
# * "Accelerometer orientation changed: <orient>" lines are parsed from
|
||||
# `monitor-sensor` (a thin client of iio-sensor-proxy, which runs as a
|
||||
# system service on this host). iio-sensor-proxy merges the accel axes +
|
||||
# mount matrix into a single compass-style orientation string.
|
||||
#
|
||||
# While in tablet mode the eDP-1 monitor transform (and matching per-device
|
||||
# transforms for touch inputs) follow the accelerometer. The on-screen
|
||||
# keyboard (wvkbd) follows text focus instead of Hyprland's input-method v2
|
||||
# protocol (which Hyprland advertises but never fires):
|
||||
# * We watch `.socket2.sock` "activewindow>>class,title" events. If the
|
||||
# focused window class is in the text-capable allowlist the keyboard is
|
||||
# shown (SIGUSR2), otherwise hidden (SIGUSR1). This gives reliable
|
||||
# auto-hide when there is nothing to type into.
|
||||
# * Manual control (super+crtl+k / the bar chip) flips wvkbd directly via
|
||||
# SIGRTMIN. The daemon re-applies focus state on the next focus change,
|
||||
# so a manual dismissal lasts until the user switches windows.
|
||||
# Leaving tablet mode snaps transform back to 0 and kills wvkbd.
|
||||
#
|
||||
# State is mirrored to a small JSON file ($XDG_RUNTIME_DIR/hyprland-tablet)
|
||||
# that the QuickShell bar chip reads via `hyprland-tablet status`:
|
||||
# {"tablet": bool, "osk": visible-or-not}
|
||||
#
|
||||
# Control commands arrive over a Unix datagram socket
|
||||
# ($XDG_RUNTIME_DIR/hyprland-tablet.ctl): "toggle" | "show" | "hide".
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Orientation string (from iio-sensor-proxy) -> Hyprland monitor transform.
|
||||
# Hyprland transform: 0 normal, 1 90° CCW, 2 180°, 3 270° CCW.
|
||||
# If the screen rotates the wrong way on hardware, swap the 1 and 3 below.
|
||||
ORIENTATION_TRANSFORM = {
|
||||
"normal": 0,
|
||||
"left-up": 1,
|
||||
"bottom-up": 2,
|
||||
"right-up": 3,
|
||||
}
|
||||
|
||||
SW_TABLET_MODE = 0x01 # input event code for SW_TABLET_MODE
|
||||
|
||||
# Paths resolved at build time by the Nix module (wrapped with pkgs.python3).
|
||||
# The placeholder strings below are in at-sign-delimited form because
|
||||
# pkgs.replaceVarsWith substitutes exactly that syntax.
|
||||
EVDEV_DEVICE = "@EVDEV_DEVICE@"
|
||||
WVKBD_PATH = "@WVKBD_PATH@"
|
||||
WVKBD_ARGS = @WVKBD_ARGS@ # replaced with a JSON array (valid Python list)
|
||||
MONITOR_SENSOR = "@MONITOR_SENSOR@"
|
||||
HYPRCTL = "@HYPRCTL@"
|
||||
TEXT_APPS = @TEXT_APPS_JSON@ # replaced with a JSON array (valid Python list)
|
||||
# State file lives in $XDG_RUNTIME_DIR (set by the user systemd service); the
|
||||
# QuickShell bar chip + CLI read the same path.
|
||||
STATE_FILE = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "hyprland-tablet")
|
||||
CONTROL_SOCKET = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "hyprland-tablet.ctl")
|
||||
|
||||
DEBUG = os.environ.get("HYPRLAND_TABLET_DEBUG") == "1"
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
if DEBUG:
|
||||
print(f"[hyprland-tablet] {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
_state_lock = threading.Lock()
|
||||
|
||||
|
||||
def write_state(tablet: bool, osk: bool) -> None:
|
||||
try:
|
||||
with open(STATE_FILE, "w") as f:
|
||||
json.dump({"tablet": tablet, "osk": osk}, f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def hyprctl_transform(monitor: str, transform: int) -> None:
|
||||
"""Apply a transform to the monitor and every touch/tablet device."""
|
||||
# hyprland.nix uses the Lua config parser (0.55+), so the legacy
|
||||
# `monitor NAME,transform,N` / input keyword lines don't apply at runtime
|
||||
# ("keyword can't work with non-legacy parsers"). The equivalent Lua API:
|
||||
# hl.monitor({ output = "NAME", mode = ..., scale = ..., transform = N })
|
||||
# hl.config({ input = { touchdevice/tablet = { transform = N } } })
|
||||
# scale MUST be passed explicitly: omitting it makes Hyprland re-derive
|
||||
# HiDPI zoom (1.5 here), blowing up the bar on rotation.
|
||||
tx = [HYPRCTL, "eval", f'hl.monitor({{ output = "{monitor}", mode = "preferred", position = "auto", scale = 1, transform = {transform} }})']
|
||||
subprocess.run(tx, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
||||
# touchdevice transform follows the display so touch coordinates track the
|
||||
# rotated framebuffer. input:touchdevice:transform is a global input option
|
||||
# (not per-device device:touchdevice:transform which doesn't exist).
|
||||
subprocess.run(
|
||||
[HYPRCTL, "eval", f'hl.config({{ input = {{ touchdevice = {{ transform = {transform} }} }} }})'],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
# same for the tablet (pen) — this Yoga presents the Wacom digitizer as a
|
||||
# tablet device, and without this the pen coordinates stay in screen space.
|
||||
subprocess.run(
|
||||
[HYPRCTL, "eval", f'hl.config({{ input = {{ tablet = {{ transform = {transform} }} }} }})'],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def transform_for(orientation: str) -> int:
|
||||
return ORIENTATION_TRANSFORM.get(orientation, 0)
|
||||
|
||||
|
||||
def _hypr_socket(name: str) -> str:
|
||||
"""Locate a Hyprland IPC socket (".socket.sock" / ".socket2.sock")."""
|
||||
runtime = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
|
||||
inst = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
if inst:
|
||||
candidate = os.path.join(runtime, "hypr", inst, name)
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
base = os.path.join(runtime, "hypr")
|
||||
try:
|
||||
for entry in os.listdir(base):
|
||||
candidate = os.path.join(base, entry, name)
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
class Daemon:
|
||||
def __init__(self, monitor: str) -> None:
|
||||
self.monitor = monitor
|
||||
self.tablet = False
|
||||
self.orientation = "normal"
|
||||
self.current_transform = 0
|
||||
self.osk_proc = None
|
||||
self.osk_visible = False
|
||||
self.dev = None
|
||||
self.sensor = None
|
||||
self.last_focus_class = ""
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# evdev switch watcher (thread)
|
||||
# -------------------------------------------------------------
|
||||
def open_evdev(self) -> bool:
|
||||
try:
|
||||
import evdev
|
||||
import evdev.ecodes as ecodes
|
||||
|
||||
self.dev = evdev.InputDevice(EVDEV_DEVICE)
|
||||
except Exception as e:
|
||||
log(f"cannot open {EVDEV_DEVICE}: {e}")
|
||||
return False
|
||||
# Seed the current switch state via EVIOCGSW so the daemon is correct
|
||||
# if it starts mid-tablet.
|
||||
EVIOCGSW = (2 << 30) | (ord("E") << 8) | 0x1B | (64 << 16)
|
||||
buf = bytearray(64)
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.ioctl(self.dev.fd, EVIOCGSW, buf)
|
||||
vals = struct.unpack("16i", buf)
|
||||
self.tablet = bool(vals[0] >> SW_TABLET_MODE & 1)
|
||||
except OSError:
|
||||
self.tablet = False
|
||||
log(f"opened {self.dev.name}, starting tablet={self.tablet}")
|
||||
return True
|
||||
|
||||
def evdev_thread(self) -> None:
|
||||
import evdev.ecodes as ecodes
|
||||
|
||||
for event in self.dev.read_loop():
|
||||
if event.type != ecodes.EV_SW or event.code != SW_TABLET_MODE:
|
||||
continue
|
||||
new = event.value == 1
|
||||
if new != self.tablet:
|
||||
self.tablet = new
|
||||
log(f"SW_TABLET_MODE -> {new}")
|
||||
self.on_tablet_changed(new)
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# orientation watcher (thread)
|
||||
# -------------------------------------------------------------
|
||||
def _start_sensor(self) -> bool:
|
||||
try:
|
||||
self.sensor = subprocess.Popen(
|
||||
[MONITOR_SENSOR],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
log("monitor-sensor not found")
|
||||
return False
|
||||
return self.sensor.stdout is not None
|
||||
|
||||
def sensor_thread(self) -> None:
|
||||
# monitor-sensor (a thin client of iio-sensor-proxy) only emits
|
||||
# "Accelerometer orientation changed:" on *changes*; the initial
|
||||
# orientation arrives as "Has accelerometer (orientation: X, ...)".
|
||||
# Match both, and restart the client if it ever exits so orientation
|
||||
# tracking survives.
|
||||
first = True
|
||||
while True:
|
||||
if not self._start_sensor():
|
||||
return
|
||||
if self.sensor is None or self.sensor.stdout is None:
|
||||
return
|
||||
if first:
|
||||
log("monitor-sensor started")
|
||||
first = False
|
||||
for line in self.sensor.stdout:
|
||||
m = re.search(r"Accelerometer orientation changed:\s*(\S+)", line.strip())
|
||||
if m:
|
||||
self.on_orientation(m.group(1))
|
||||
continue
|
||||
m = re.search(r"Has accelerometer \(orientation:\s*(\S+)", line.strip())
|
||||
if m:
|
||||
# value is "normal," / "left-up," — strip the separator
|
||||
self.on_orientation(m.group(1).rstrip(","))
|
||||
# monitor-sensor exited: reopen it (iio-sensor-proxy may have
|
||||
# dropped our client if a second one connected).
|
||||
log("monitor-sensor exited, restarting")
|
||||
time.sleep(1)
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Hyprland focus watcher (thread) — drives auto show/hide
|
||||
# -------------------------------------------------------------
|
||||
def focus_thread(self) -> None:
|
||||
path = _hypr_socket(".socket2.sock")
|
||||
if not path:
|
||||
log("hyprland .socket2.sock not found; focus auto-hide disabled")
|
||||
return
|
||||
log(f"watching focus events on {path}")
|
||||
while True:
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.connect(path)
|
||||
f = s.makefile("r")
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line.startswith("activewindow>>"):
|
||||
continue
|
||||
payload = line.split(">>", 1)[1].split(",", 1)
|
||||
self.on_focus_changed(payload[0].strip())
|
||||
except OSError as e:
|
||||
log(f"focus socket error: {e}")
|
||||
time.sleep(2)
|
||||
|
||||
def _is_text_app(self, window_class: str) -> bool:
|
||||
if not window_class:
|
||||
return False
|
||||
lowered = window_class.lower()
|
||||
for spec in TEXT_APPS:
|
||||
if lowered == spec or lowered.startswith(spec):
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_focus_changed(self, window_class: str) -> None:
|
||||
self.last_focus_class = window_class
|
||||
if not self.tablet:
|
||||
return # in laptop mode the OSK duty is entirely manual
|
||||
want = self._is_text_app(window_class)
|
||||
log(f"focus={window_class!r} text_app={want}")
|
||||
self.set_osk_visible(want)
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# control socket (thread) — manual show/hide/toggle
|
||||
# -------------------------------------------------------------
|
||||
def control_thread(self) -> None:
|
||||
try:
|
||||
os.unlink(CONTROL_SOCKET)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
s.bind(CONTROL_SOCKET)
|
||||
s.settimeout(1.0)
|
||||
except OSError as e:
|
||||
log(f"cannot bind control socket: {e}")
|
||||
return
|
||||
log(f"control socket ready at {CONTROL_SOCKET}")
|
||||
while True:
|
||||
try:
|
||||
data, _ = s.recvfrom(128)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
cmd = data.decode("ascii", "replace").strip()
|
||||
log(f"control command: {cmd}")
|
||||
if cmd == "toggle":
|
||||
self.toggle_osk()
|
||||
elif cmd == "show":
|
||||
self.set_osk_visible(True)
|
||||
elif cmd == "hide":
|
||||
self.set_osk_visible(False)
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# actions
|
||||
# -------------------------------------------------------------
|
||||
def ensure_osk(self) -> None:
|
||||
with _state_lock:
|
||||
if self.osk_proc is not None and self.osk_proc.poll() is None:
|
||||
return
|
||||
try:
|
||||
# Start hidden: focus state decides whether to SIGUSR2 it.
|
||||
self.osk_proc = subprocess.Popen([WVKBD_PATH, *WVKBD_ARGS, "--hidden"])
|
||||
self.osk_visible = False
|
||||
log(f"wvkbd started (hidden, args={WVKBD_ARGS!r})")
|
||||
except FileNotFoundError:
|
||||
log("wvkbd not found")
|
||||
write_state(self.tablet, self.osk_visible)
|
||||
|
||||
def kill_osk(self) -> None:
|
||||
with _state_lock:
|
||||
if self.osk_proc is not None and self.osk_proc.poll() is None:
|
||||
self.osk_proc.terminate()
|
||||
try:
|
||||
self.osk_proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.osk_proc.kill()
|
||||
self.osk_proc.wait()
|
||||
log("wvkbd stopped")
|
||||
self.osk_proc = None
|
||||
self.osk_visible = False
|
||||
write_state(self.tablet, False)
|
||||
|
||||
def _signal_osk(self, sig: int) -> None:
|
||||
if self.osk_proc is not None and self.osk_proc.poll() is None:
|
||||
try:
|
||||
os.kill(self.osk_proc.pid, sig)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def set_osk_visible(self, visible: bool) -> None:
|
||||
if visible == self.osk_visible:
|
||||
return
|
||||
if visible:
|
||||
if self.osk_proc is None or self.osk_proc.poll() is not None:
|
||||
self.ensure_osk()
|
||||
if self.osk_proc is None:
|
||||
return
|
||||
self._signal_osk(signal.SIGUSR2)
|
||||
self.osk_visible = True
|
||||
log("wvkbd show")
|
||||
else:
|
||||
self._signal_osk(signal.SIGUSR1)
|
||||
self.osk_visible = False
|
||||
log("wvkbd hide")
|
||||
write_state(self.tablet, self.osk_visible)
|
||||
|
||||
def toggle_osk(self) -> None:
|
||||
with _state_lock:
|
||||
if self.osk_proc is None or self.osk_proc.poll() is not None:
|
||||
self.osk_proc = subprocess.Popen([WVKBD_PATH, *WVKBD_ARGS, "--hidden"])
|
||||
self.osk_visible = False
|
||||
log("wvkbd started (hidden, manual toggle)")
|
||||
write_state(self.tablet, self.osk_visible)
|
||||
return
|
||||
# wvkbd SIGRTMIN toggles visibility; we shadow its state here.
|
||||
self.osk_visible = not self.osk_visible
|
||||
self._signal_osk(signal.SIGRTMIN)
|
||||
log(f"wvkbd toggled -> visible={self.osk_visible}")
|
||||
write_state(self.tablet, self.osk_visible)
|
||||
|
||||
def apply_transform(self, t: int) -> None:
|
||||
if t != self.current_transform:
|
||||
hyprctl_transform(self.monitor, t)
|
||||
self.current_transform = t
|
||||
log(f"transform -> {t}")
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# event handlers
|
||||
# -------------------------------------------------------------
|
||||
def on_tablet_changed(self, new: bool) -> None:
|
||||
if new:
|
||||
self.apply_transform(transform_for(self.orientation))
|
||||
self.ensure_osk()
|
||||
else:
|
||||
self.apply_transform(0)
|
||||
self.kill_osk()
|
||||
write_state(self.tablet, self.osk_visible)
|
||||
|
||||
def on_orientation(self, orient: str) -> None:
|
||||
self.orientation = orient
|
||||
if self.tablet:
|
||||
self.apply_transform(transform_for(orient))
|
||||
# laptop mode: never rotate (gated by the switch above)
|
||||
|
||||
# -------------------------------------------------------------
|
||||
def run(self) -> int:
|
||||
have_evdev = self.open_evdev()
|
||||
|
||||
# If we started mid-tablet, bring Hyprland to the current state right
|
||||
# away instead of waiting for the next SW_TABLET_MODE edge. The OSK
|
||||
# duty for the currently-focused window is applied below.
|
||||
if self.tablet:
|
||||
self.apply_transform(transform_for(self.orientation))
|
||||
self.ensure_osk()
|
||||
cls = self.cur_focus_class()
|
||||
if cls:
|
||||
self.on_focus_changed(cls)
|
||||
write_state(self.tablet, self.osk_visible)
|
||||
|
||||
threads = []
|
||||
if have_evdev:
|
||||
t = threading.Thread(target=self.evdev_thread, daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
t = threading.Thread(target=self.sensor_thread, daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
t = threading.Thread(target=self.focus_thread, daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
t = threading.Thread(target=self.control_thread, daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
|
||||
def cur_focus_class(self) -> str:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[HYPRCTL, "activewindow", "-j"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3,
|
||||
)
|
||||
info = json.loads(out.stdout)
|
||||
return info.get("class") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
monitor = os.environ.get("HYPRLAND_TABLET_MONITOR", "eDP-1")
|
||||
d = Daemon(monitor)
|
||||
return d.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user