mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 07:00:14 +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
+715
@@ -0,0 +1,715 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ft-layout: the Frametop screens' sizes and where they float around you.
|
||||
|
||||
Two desktop backends (BACKEND in ~/.config/frametop.conf):
|
||||
screens (default) ft-screens, our own compositor (screens): each screen
|
||||
is its own SteamVR panel with its own resolution and size in metres. ft-layout
|
||||
places them directly through ft-screens' control socket (@ft_screens).
|
||||
gamescope the old path: every screen one size (at most 1920x1080 worth of pixels),
|
||||
panels owned by the SteamVR dashboard, so ft-layout floats each one with
|
||||
`vrcmd --dock-overlay` and the pointer helper carries it into place.
|
||||
|
||||
The layout is relative to your head when it's applied: its position and the direction
|
||||
you face (yaw only), like a recenter. It lives in ~/.config/frametop-layout.json:
|
||||
{"auto": true, arrange when the desktop starts
|
||||
"mode": "preset" | "custom",
|
||||
"preset": {"kind": "arc" | "flat", "rows": 1, "distance": 2.0, "gap": 0.05, "height": 0},
|
||||
"primary": 2, the screen with the taskbar (1-based; default: the biggest)
|
||||
"visibility": {"mode": "always", ft-screens: always | dashboard (only with the SteamVR
|
||||
"wrist_angle": 60, dashboard open) | gesture (while you look at a controller)
|
||||
"gesture_hand": "left", "gesture_angle": 20}, | toggle (hidden until shown);
|
||||
wrist_angle: a pinned screen shows while you see its front
|
||||
within this many degrees
|
||||
"screens": [{"size": [w, h], "metres": 3.6, ft-screens: pixels, and width in VR
|
||||
"curve": 0, ft-screens: cylinder radius in metres, 0 = flat
|
||||
"pin": {"hand": "left", "rel": [12]}, ft-screens: riding on that controller
|
||||
"scale": 1.0, KWin output scale (1.0 = 100%)
|
||||
"pos": [x, y, z], "face": [yaw, pitch], "roll": 0, custom layout
|
||||
"rotation": "normal" | "left" | "right"}, ...], gamescope only
|
||||
"panel_size": [w, h]} gamescope: last measured panel size
|
||||
Custom positions: x right, y up, -z forward from the head, in metres; face = the
|
||||
direction you look to see the screen's front straight on, in degrees, relative to your
|
||||
heading; roll = the panel turned about its front, counterclockwise as you see it.
|
||||
Presets: "arc" hinges the screens edge to edge around you, each turned to face you
|
||||
(like monitors on a desk); "flat" puts them on one flat wall facing forward. Screen 1
|
||||
is top left, then left to right.
|
||||
|
||||
Usage (on the Frame host; Frametop Display Settings calls it too):
|
||||
ft-layout apply [--wait SECONDS] arrange every screen; --wait is for desktop start:
|
||||
wait for the screens, skip if "auto" is off
|
||||
ft-layout capture save the current arrangement as the custom layout
|
||||
ft-layout plan print the arrangement as JSON (no VR needed)
|
||||
ft-layout scale per-screen scale, positions, and primary to KWin
|
||||
ft-layout screen-args ft-screens' --screen arguments for the session script
|
||||
ft-layout toggle hide or show all screens (ft-screens)
|
||||
ft-layout pin all|N left|right pin screens to a wrist as they are; unpin all|N
|
||||
"""
|
||||
import fcntl
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
LAYOUT_PATH = os.path.expanduser("~/.config/frametop-layout.json")
|
||||
CONF_PATH = os.path.expanduser("~/.config/frametop.conf")
|
||||
VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
|
||||
HELPER = "\0ft_pointer_helper"
|
||||
SCREENS = "\0ft_screens"
|
||||
LOCK_PATH = "/tmp/ft-layout.lock"
|
||||
DEFAULT_PANEL = (1.18, 0.664) # gamescope: a floating 16:9 dashboard panel, measured on the Frame
|
||||
PIXELS_PER_METRE = 800 # ft-screens: a new screen's default size in VR (1920 px: 2.4 m)
|
||||
VISIBILITY = {"mode": "always", "wrist_angle": 60, "gesture_hand": "left", "gesture_angle": 20}
|
||||
DEFAULTS = {"auto": True, "mode": "preset",
|
||||
"preset": {"kind": "arc", "rows": 1, "distance": 2.0, "gap": 0.05, "height": 0.0},
|
||||
"screens": [], "panel_size": list(DEFAULT_PANEL)}
|
||||
|
||||
|
||||
def log(*args):
|
||||
print(*args, flush=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- config
|
||||
|
||||
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 backend():
|
||||
return "gamescope" if read_conf().get("BACKEND", "screens") == "gamescope" else "screens"
|
||||
|
||||
|
||||
def screen_count(layout=None):
|
||||
"""ft-screens: the configured screens; gamescope: SCREENS."""
|
||||
if backend() == "screens":
|
||||
return max(1, len((layout or load_layout()).get("screens", [])))
|
||||
try:
|
||||
return max(1, int(read_conf().get("SCREENS", "2")))
|
||||
except ValueError:
|
||||
return 2
|
||||
|
||||
|
||||
def load_layout():
|
||||
layout = json.loads(json.dumps(DEFAULTS))
|
||||
try:
|
||||
with open(LAYOUT_PATH) as f:
|
||||
saved = json.load(f)
|
||||
layout.update({k: v for k, v in saved.items() if k != "preset"})
|
||||
layout["preset"].update(saved.get("preset", {}))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if backend() == "screens" and not layout.get("screens"):
|
||||
layout["screens"] = [{"size": [1920, 1080], "metres": 1920 / PIXELS_PER_METRE}]
|
||||
return layout
|
||||
|
||||
|
||||
def save_layout(layout):
|
||||
os.makedirs(os.path.dirname(LAYOUT_PATH), exist_ok=True)
|
||||
tmp = LAYOUT_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(layout, f, indent=2)
|
||||
os.replace(tmp, LAYOUT_PATH)
|
||||
|
||||
|
||||
def screen_entry(layout, i):
|
||||
screens = layout.get("screens", [])
|
||||
return screens[i] if i < len(screens) else {}
|
||||
|
||||
|
||||
def screen_scale(layout, i):
|
||||
return float(screen_entry(layout, i).get("scale", 1.0))
|
||||
|
||||
|
||||
def screen_pixels(layout, i):
|
||||
w, h = screen_entry(layout, i).get("size", [1920, 1080])
|
||||
return int(w), int(h)
|
||||
|
||||
|
||||
def screen_metres(layout, i):
|
||||
w, _ = screen_pixels(layout, i)
|
||||
return float(screen_entry(layout, i).get("metres", w / PIXELS_PER_METRE))
|
||||
|
||||
|
||||
ROLL = {"normal": 0.0, "left": 90.0, "right": -90.0}
|
||||
|
||||
|
||||
def screen_rotation(layout, i):
|
||||
if backend() == "screens":
|
||||
return "normal" # portrait screens are simply tall
|
||||
r = screen_entry(layout, i).get("rotation", "normal")
|
||||
return r if r in ROLL else "normal"
|
||||
|
||||
|
||||
def screen_size(layout, i, panel_size=None):
|
||||
"""A screen's size in VR (width, height) in metres."""
|
||||
if backend() == "screens":
|
||||
w, h = screen_pixels(layout, i)
|
||||
m = screen_metres(layout, i)
|
||||
return m, m * h / w
|
||||
w, h = panel_size or layout.get("panel_size") or DEFAULT_PANEL
|
||||
return (h, w) if screen_rotation(layout, i) != "normal" else (w, h)
|
||||
|
||||
|
||||
def primary_screen(layout):
|
||||
"""0-based index of the screen with the taskbar: the chosen one, or the biggest."""
|
||||
n = screen_count(layout)
|
||||
p = layout.get("primary")
|
||||
if isinstance(p, int) and 1 <= p <= n:
|
||||
return p - 1
|
||||
return max(range(n), key=lambda i: screen_pixels(layout, i)[0] * screen_pixels(layout, i)[1])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- geometry
|
||||
# Head frame: x right, y up, -z forward, at the eye, turned to the heading (yaw).
|
||||
# yaw 0 = -Z, positive yaw turns left, positive pitch looks up (as in SteamVR's helper).
|
||||
|
||||
def direction(yaw, pitch):
|
||||
y, p = math.radians(yaw), math.radians(pitch)
|
||||
return (-math.sin(y) * math.cos(p), math.sin(p), -math.cos(y) * math.cos(p))
|
||||
|
||||
|
||||
def yaw_pitch(v):
|
||||
n = math.sqrt(sum(c * c for c in v)) or 1.0
|
||||
return math.degrees(math.atan2(-v[0], -v[2])), math.degrees(math.asin(max(-1.0, min(1.0, v[1] / n))))
|
||||
|
||||
|
||||
def dot(a, b):
|
||||
return sum(x * y for x, y in zip(a, b))
|
||||
|
||||
|
||||
def cross(a, b):
|
||||
return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
|
||||
|
||||
|
||||
def normalize(v):
|
||||
n = math.sqrt(dot(v, v)) or 1.0
|
||||
return tuple(c / n for c in v)
|
||||
|
||||
|
||||
def turn_yaw(v, yaw):
|
||||
"""Rotate v about +Y by yaw degrees (head frame -> world for the heading yaw)."""
|
||||
s, c = math.sin(math.radians(yaw)), math.cos(math.radians(yaw))
|
||||
return (v[0] * c + v[2] * s, v[1], -v[0] * s + v[2] * c)
|
||||
|
||||
|
||||
def _chain(widths, d, gap):
|
||||
"""One row of flat screens hinged edge to edge around the eye, each turned to face it,
|
||||
like monitors on a desk: the middle screen (or the seam between the middle two) is
|
||||
straight ahead at distance d; each neighbour starts at the previous screen's outer
|
||||
edge (plus the gap) and is turned until it faces the eye. Returns [(x, z, yaw)], in
|
||||
the head frame from above (x right, -z forward)."""
|
||||
n = len(widths)
|
||||
out = [None] * n
|
||||
|
||||
def right(yaw): # a screen's right vector for its yaw
|
||||
return math.cos(math.radians(yaw)), -math.sin(math.radians(yaw))
|
||||
|
||||
def yaw_of(x, z):
|
||||
return math.degrees(math.atan2(-x, -z))
|
||||
|
||||
def hang(hinge, w, side):
|
||||
# Turn the screen about its hinge until it faces the eye: its centre
|
||||
# C = hinge + side * w/2 * right(yaw) must have no sideways part, dot(C, right) = 0,
|
||||
# that is dot(hinge, right(yaw)) = -side * w/2. Take the root nearest the hinge's
|
||||
# own direction, turning outward (right: yaw falls; left: yaw rises).
|
||||
def g(yaw):
|
||||
rx, rz = right(yaw)
|
||||
return hinge[0] * rx + hinge[1] * rz + side * w / 2
|
||||
start = yaw_of(*hinge)
|
||||
a, ga = start, g(start)
|
||||
for k in range(1, 721): # 0.25-degree steps, up to half a turn
|
||||
b = start - side * k * 0.25
|
||||
gb = g(b)
|
||||
if ga == 0 or ga * gb < 0:
|
||||
for _ in range(50):
|
||||
mid = (a + b) / 2
|
||||
if g(a) * g(mid) <= 0:
|
||||
b = mid
|
||||
else:
|
||||
a = mid
|
||||
break
|
||||
a, ga = b, gb
|
||||
yaw = a
|
||||
rx, rz = right(yaw)
|
||||
return hinge[0] + side * rx * w / 2, hinge[1] + side * rz * w / 2, yaw
|
||||
|
||||
mid = n // 2
|
||||
if n % 2:
|
||||
out[mid] = (0.0, -d, 0.0)
|
||||
right_edge, left_edge, first_right, first_left = (widths[mid] / 2, -d), (-widths[mid] / 2, -d), mid + 1, mid - 1
|
||||
yaw_r = yaw_l = 0.0
|
||||
else: # a seam straight ahead
|
||||
right_edge, left_edge, first_right, first_left = (gap / 2, -d), (-gap / 2, -d), mid, mid - 1
|
||||
yaw_r = yaw_l = 0.0
|
||||
for i in range(first_right, n): # to the right: screen i hangs from the previous right edge
|
||||
rx, rz = right(yaw_r)
|
||||
g = gap if i != first_right or n % 2 else 0
|
||||
hinge = (right_edge[0] + rx * g, right_edge[1] + rz * g)
|
||||
cx, cz, yaw_r = hang(hinge, widths[i], +1)
|
||||
out[i] = (cx, cz, yaw_r)
|
||||
rx, rz = right(yaw_r)
|
||||
right_edge = (cx + rx * widths[i] / 2, cz + rz * widths[i] / 2)
|
||||
for i in range(first_left, -1, -1): # to the left, mirrored
|
||||
rx, rz = right(yaw_l)
|
||||
g = gap if i != first_left or n % 2 else 0
|
||||
hinge = (left_edge[0] - rx * g, left_edge[1] - rz * g)
|
||||
cx, cz, yaw_l = hang(hinge, widths[i], -1)
|
||||
out[i] = (cx, cz, yaw_l)
|
||||
rx, rz = right(yaw_l)
|
||||
left_edge = (cx - rx * widths[i] / 2, cz - rz * widths[i] / 2)
|
||||
return out
|
||||
|
||||
|
||||
def plan(layout, count, panel_size=None):
|
||||
"""Screen poses in the head frame: [{"pos": (x, y, z), "face": (yaw, pitch), "roll": deg}]."""
|
||||
sizes = [screen_size(layout, i, panel_size) for i in range(count)]
|
||||
rolls = [ROLL[screen_rotation(layout, i)] for i in range(count)]
|
||||
if layout.get("mode") == "custom" and len(layout.get("screens", [])) >= count and all(
|
||||
"pos" in s for s in layout["screens"][:count]):
|
||||
return [{"pos": tuple(s["pos"]), "face": tuple(s.get("face", yaw_pitch(s["pos"]))),
|
||||
"roll": float(s.get("roll", rolls[i]))} for i, s in enumerate(layout["screens"][:count])]
|
||||
p = layout["preset"]
|
||||
rows = max(1, min(int(p.get("rows", 1)), count))
|
||||
cols = math.ceil(count / rows)
|
||||
d = max(0.3, float(p.get("distance", 2.0)))
|
||||
gap = max(0.0, float(p.get("gap", 0.05)))
|
||||
height = float(p.get("height", 0.0))
|
||||
flat = p.get("kind") == "flat"
|
||||
grid = [list(range(r * cols, min(count, (r + 1) * cols))) for r in range(rows)]
|
||||
out = [None] * count
|
||||
if flat:
|
||||
# One flat wall: rows of screens side by side, centred, facing forward.
|
||||
row_h = [max(sizes[i][1] for i in row) for row in grid]
|
||||
top = height + (sum(row_h) + gap * (rows - 1)) / 2
|
||||
for r, row in enumerate(grid):
|
||||
y = top - sum(row_h[:r]) - gap * r - row_h[r] / 2
|
||||
x = -(sum(sizes[i][0] for i in row) + gap * (len(row) - 1)) / 2
|
||||
for i in row:
|
||||
out[i] = {"pos": (x + sizes[i][0] / 2, y, -d), "face": (0.0, 0.0), "roll": rolls[i]}
|
||||
x += sizes[i][0] + gap
|
||||
return out
|
||||
# Curved: each row hinged edge to edge around you (see _chain); rows stacked by angle
|
||||
# (a row of height h at distance d spans 2 atan(h/2d)), each tilted to face you.
|
||||
span = lambda m: 2 * math.degrees(math.atan(m / 2 / d))
|
||||
row_h = [max(span(sizes[i][1]) for i in row) for row in grid]
|
||||
g = span(gap)
|
||||
top = math.degrees(math.atan(height / d)) + (sum(row_h) + g * (rows - 1)) / 2
|
||||
for r, row in enumerate(grid):
|
||||
pitch = top - sum(row_h[:r]) - g * r - row_h[r] / 2
|
||||
cp, sp = math.cos(math.radians(pitch)), math.sin(math.radians(pitch))
|
||||
for i, (x, z, yaw) in zip(row, _chain([sizes[i][0] for i in row], d, gap)):
|
||||
# Tilt the row about the eye's left-right axis: forward distance shrinks by cos,
|
||||
# height grows by sin.
|
||||
r_h = math.hypot(x, z)
|
||||
out[i] = {"pos": (x * cp, r_h * sp, z * cp), "face": (yaw, pitch), "roll": rolls[i]}
|
||||
return out
|
||||
|
||||
|
||||
def relative_pose(center, x_axis, z_axis, eye, heading):
|
||||
"""A screen's pose in the world -> custom layout entry (pos, face, roll) in the head frame."""
|
||||
rel = turn_yaw(tuple(c - e for c, e in zip(center, eye)), -heading)
|
||||
fyaw, fpitch = yaw_pitch(tuple(-c for c in z_axis))
|
||||
# Roll: the panel's right vector against an upright panel's right and up.
|
||||
right = normalize(cross((0.0, 1.0, 0.0), z_axis))
|
||||
up = cross(z_axis, right)
|
||||
roll = math.degrees(math.atan2(dot(x_axis, up), dot(x_axis, right)))
|
||||
return {"pos": [round(v, 4) for v in rel], "face": [round(fyaw - heading, 2), round(fpitch, 2)],
|
||||
"roll": round(roll, 2)}
|
||||
|
||||
|
||||
class Socket:
|
||||
"""Request/reply over an abstract datagram socket (ft-screens or the pointer helper)."""
|
||||
|
||||
def __init__(self, address, what):
|
||||
self.address, self.what = address, what
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
self.sock.bind("") # autobind: an abstract address the other side can reply to
|
||||
|
||||
def ask(self, text, timeout=10.0):
|
||||
self.sock.settimeout(timeout)
|
||||
try:
|
||||
self.sock.sendto(text.encode(), self.address)
|
||||
reply = self.sock.recv(8192).decode()
|
||||
except (OSError, socket.timeout) as e:
|
||||
raise RuntimeError(f"{self.what} didn't answer ({e})")
|
||||
if not reply.startswith("ok"):
|
||||
raise RuntimeError(reply)
|
||||
return reply
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ft-screens
|
||||
|
||||
def screens_socket():
|
||||
return Socket(SCREENS, "ft-screens (the desktop's compositor) isn't running or")
|
||||
|
||||
|
||||
def screen_args(layout=None):
|
||||
layout = layout or load_layout()
|
||||
return " ".join(f"--screen {w}x{h}@{screen_metres(layout, i):.3f}"
|
||||
for i, (w, h) in ((i, screen_pixels(layout, i)) for i in range(screen_count(layout))))
|
||||
|
||||
|
||||
def visibility(layout):
|
||||
v = dict(VISIBILITY)
|
||||
v.update(layout.get("visibility", {}))
|
||||
return v
|
||||
|
||||
|
||||
def send_visibility(sock, layout):
|
||||
v = visibility(layout)
|
||||
sock.ask(f"visibility {v['mode']}")
|
||||
sock.ask(f"wrist {float(v['wrist_angle']):.1f}")
|
||||
sock.ask(f"gesture {v['gesture_hand']} {float(v['gesture_angle']):.1f}")
|
||||
|
||||
|
||||
def parse_get(reply):
|
||||
"""ft-screens' "get": pose, size, curve, and the pin (hand and controller->screen)."""
|
||||
f = reply.split()[1:]
|
||||
g = list(map(float, f[:15]))
|
||||
out = {"center": tuple(g[0:3]), "x": tuple(g[3:6]), "y": tuple(g[6:9]), "z": tuple(g[9:12]),
|
||||
"metres": g[12], "height": g[13], "curve": g[14], "hand": f[15] if len(f) > 15 else "none"}
|
||||
if out["hand"] != "none" and len(f) >= 28:
|
||||
out["rel"] = [round(float(v), 5) for v in f[16:28]]
|
||||
return out
|
||||
|
||||
|
||||
def screens_up(sock):
|
||||
"""How many screens ft-screens has shown so far."""
|
||||
f = sock.ask("screens").split()
|
||||
return sum(1 for s in f[2:] if not s.split(":")[1].startswith("0x"))
|
||||
|
||||
|
||||
def apply_screens(wait=0):
|
||||
layout = load_layout()
|
||||
count = screen_count(layout)
|
||||
deadline = time.time() + wait
|
||||
while True:
|
||||
try:
|
||||
sock = screens_socket()
|
||||
if screens_up(sock) >= count or time.time() >= deadline:
|
||||
break
|
||||
except RuntimeError:
|
||||
if time.time() >= deadline:
|
||||
raise
|
||||
time.sleep(1)
|
||||
f = sock.ask("head").split()
|
||||
eye, heading = tuple(map(float, f[1:4])), float(f[4])
|
||||
send_visibility(sock, layout)
|
||||
results = []
|
||||
for i, t in enumerate(plan(layout, count)):
|
||||
world = turn_yaw(t["pos"], heading)
|
||||
center = tuple(e + v for e, v in zip(eye, world))
|
||||
entry = screen_entry(layout, i)
|
||||
sock.ask(f"width {i + 1} {screen_metres(layout, i):.4f}")
|
||||
sock.ask(f"curve {i + 1} {float(entry.get('curve', 0)):.3f}")
|
||||
results.append(sock.ask("place %d %.4f %.4f %.4f %.3f %.3f %.3f" % (i + 1, *center, t["face"][0] + heading,
|
||||
t["face"][1], t["roll"])))
|
||||
pin = entry.get("pin") if layout.get("mode") == "custom" else None
|
||||
if pin and len(pin.get("rel", [])) == 12:
|
||||
try:
|
||||
sock.ask(f"pin {i + 1} {pin['hand']} " + " ".join(f"{v:.5f}" for v in pin["rel"]))
|
||||
except RuntimeError as e:
|
||||
log(f"screen {i + 1}: {e}") # that controller isn't on
|
||||
log(f"arranged {count} screen(s)")
|
||||
return results
|
||||
|
||||
|
||||
def capture_screens():
|
||||
layout = load_layout()
|
||||
sock = screens_socket()
|
||||
f = sock.ask("head").split()
|
||||
eye, heading = tuple(map(float, f[1:4])), float(f[4])
|
||||
screens = []
|
||||
for i in range(screen_count(layout)):
|
||||
g = parse_get(sock.ask(f"get {i + 1}"))
|
||||
entry = dict(screen_entry(layout, i))
|
||||
entry.update(relative_pose(g["center"], g["x"], g["z"], eye, heading))
|
||||
entry["metres"] = round(g["metres"], 4) # resized by hand
|
||||
entry["curve"] = round(g["curve"], 3)
|
||||
entry.pop("pin", None)
|
||||
if "rel" in g:
|
||||
entry["pin"] = {"hand": g["hand"], "rel": g["rel"]}
|
||||
screens.append(entry)
|
||||
layout["screens"] = screens + layout.get("screens", [])[len(screens):]
|
||||
layout["mode"] = "custom"
|
||||
save_layout(layout)
|
||||
return screens
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- gamescope (dashboard panels)
|
||||
|
||||
def vrcmd(*args, timeout=10):
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=os.path.dirname(VRCMD))
|
||||
try:
|
||||
return subprocess.run([VRCMD, *args], capture_output=True, text=True, timeout=timeout, env=env).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
|
||||
def screen_keys():
|
||||
"""The screens' overlay keys, in window order. gamescope (PerWindow) names each
|
||||
window's overlay frametop.app.<window seq>; .app.0 is its default connector, which
|
||||
never gets a window. It must be skipped: docking an unknown key moves whatever
|
||||
panel the dashboard shows instead."""
|
||||
keys = []
|
||||
for m in re.finditer(r"^'(frametop\.app\.(\d+))' .*VROverlayType_Dashboard_Main", vrcmd("--overlays"), re.M):
|
||||
if int(m.group(2)) > 0:
|
||||
keys.append((int(m.group(2)), m.group(1)))
|
||||
return [k for _, k in sorted(keys)]
|
||||
|
||||
|
||||
def visible_keys():
|
||||
return set(re.findall(r"^'([^']+)' .* visible VROverlayType", vrcmd("--overlays"), re.M))
|
||||
|
||||
|
||||
def helper_measure(helper, key):
|
||||
f = list(map(float, helper.ask(f"measure {key}").split()[1:]))
|
||||
return {"center": tuple(f[0:3]), "size": (f[3], f[4]), "x": tuple(f[5:8]), "y": tuple(f[8:11]),
|
||||
"z": tuple(f[11:14])}
|
||||
|
||||
|
||||
def float_screen(key):
|
||||
"""Float a docked screen: dock it into the dashboard (which opens the dashboard; a
|
||||
floated panel takes its first position from it, and none exists while it's closed),
|
||||
float it, then close the dashboard (so the carry can't snap it back in). A new
|
||||
window starts docked in the dashboard, where a dashboard request is ignored as
|
||||
redundant (and doesn't open the dashboard), so it goes to theater first."""
|
||||
vrcmd("--dock-overlay", "theater", key)
|
||||
time.sleep(0.5)
|
||||
vrcmd("--dock-overlay", "dashboard", key)
|
||||
time.sleep(0.8)
|
||||
vrcmd("--dock-overlay", "world", key)
|
||||
time.sleep(0.8)
|
||||
vrcmd("--hidedashboard")
|
||||
time.sleep(0.8)
|
||||
|
||||
|
||||
def apply_gamescope(wait=0):
|
||||
layout = load_layout()
|
||||
count = screen_count(layout)
|
||||
deadline = time.time() + wait
|
||||
keys = screen_keys()
|
||||
while len(keys) < count and time.time() < deadline:
|
||||
time.sleep(1)
|
||||
keys = screen_keys()
|
||||
if wait:
|
||||
time.sleep(3) # let Plasma draw before the screens start moving
|
||||
if not keys:
|
||||
raise RuntimeError("no Frametop screens in SteamVR; is the desktop running?")
|
||||
keys = keys[:count]
|
||||
helper = Socket(HELPER, "the pointer helper (frametop-pointer.service)")
|
||||
f = helper.ask("head").split()
|
||||
eye, heading = tuple(map(float, f[1:4])), float(f[4])
|
||||
vrcmd("--hidedashboard")
|
||||
time.sleep(0.5)
|
||||
shown = visible_keys()
|
||||
size = None
|
||||
for key in keys:
|
||||
if key not in shown:
|
||||
float_screen(key)
|
||||
try:
|
||||
m = helper_measure(helper, key)
|
||||
except RuntimeError:
|
||||
float_screen(key) # floating but without a position: float it again
|
||||
m = helper_measure(helper, key)
|
||||
# The landscape size: a screen's panel is always landscape before it's rolled.
|
||||
size = size or (tuple(sorted(m["size"], reverse=True)))
|
||||
results = []
|
||||
for key, t in zip(keys, plan(layout, len(keys), size)):
|
||||
center = tuple(e + v for e, v in zip(eye, turn_yaw(t["pos"], heading)))
|
||||
reply = helper.ask("place %s %.4f %.4f %.4f %.3f %.3f %.3f" % (key, *center, t["face"][0] + heading,
|
||||
t["face"][1], t["roll"]), timeout=30)
|
||||
log(reply)
|
||||
results.append(reply)
|
||||
if size and list(size) != layout.get("panel_size"):
|
||||
layout["panel_size"] = [round(size[0], 4), round(size[1], 4)]
|
||||
save_layout(layout)
|
||||
return results
|
||||
|
||||
|
||||
def capture_gamescope():
|
||||
layout = load_layout()
|
||||
helper = Socket(HELPER, "the pointer helper (frametop-pointer.service)")
|
||||
f = helper.ask("head").split()
|
||||
eye, heading = tuple(map(float, f[1:4])), float(f[4])
|
||||
shown = visible_keys()
|
||||
keys = [k for k in screen_keys() if k in shown]
|
||||
if not keys:
|
||||
raise RuntimeError("no floating screens to capture (screens docked in the dashboard don't count)")
|
||||
screens = []
|
||||
for i, key in enumerate(keys):
|
||||
m = helper_measure(helper, key)
|
||||
entry = dict(screen_entry(layout, i))
|
||||
entry.update(relative_pose(m["center"], m["x"], m["z"], eye, heading))
|
||||
screens.append(entry)
|
||||
layout["panel_size"] = [round(v, 4) for v in sorted(m["size"], reverse=True)]
|
||||
layout["screens"] = screens + layout.get("screens", [])[len(screens):]
|
||||
layout["mode"] = "custom"
|
||||
save_layout(layout)
|
||||
return screens
|
||||
|
||||
|
||||
def apply(wait=0):
|
||||
return apply_screens(wait) if backend() == "screens" else apply_gamescope(wait)
|
||||
|
||||
|
||||
def capture():
|
||||
return capture_screens() if backend() == "screens" else capture_gamescope()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- KWin (scale, positions, primary)
|
||||
|
||||
def nested_env():
|
||||
"""Environment of the running Frametop Plasma session (its private bus and runtime dir)."""
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open(f"/proc/{pid}/comm") as f:
|
||||
if f.read().strip() != "plasmashell":
|
||||
continue
|
||||
with open(f"/proc/{pid}/environ", "rb") as f:
|
||||
env = dict(e.split("=", 1) for e in f.read().decode(errors="replace").split("\0") if "=" in e)
|
||||
except OSError:
|
||||
continue
|
||||
if env.get("XDG_RUNTIME_DIR", "").endswith("/frametop"):
|
||||
keep = ("DBUS_SESSION_BUS_ADDRESS", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME")
|
||||
return dict(os.environ, **{k: env[k] for k in keep if k in env})
|
||||
return None
|
||||
|
||||
|
||||
def outputs(env):
|
||||
"""KWin's outputs, in screen order (WL-0, WL-1, ...)."""
|
||||
try:
|
||||
data = json.loads(subprocess.run(["kscreen-doctor", "-j"], capture_output=True, text=True, env=env,
|
||||
timeout=10).stdout)
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
outs = [o for o in data.get("outputs", []) if o.get("connected")]
|
||||
return sorted(outs, key=lambda o: [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", o.get("name", ""))])
|
||||
|
||||
|
||||
KSCREEN_ROTATION = {1: "normal", 2: "left", 4: "inverted", 8: "right"} # kscreen-doctor -j "rotation"
|
||||
|
||||
|
||||
def apply_scales():
|
||||
"""Per-screen scale and rotation, positions side by side, and the primary screen (the
|
||||
taskbar goes there) to KWin, which keeps them in the session's config."""
|
||||
env = nested_env()
|
||||
if not env:
|
||||
raise RuntimeError("the Frametop desktop isn't running")
|
||||
layout = load_layout()
|
||||
args = []
|
||||
outs = outputs(env)
|
||||
for i, o in enumerate(outs):
|
||||
s = screen_scale(layout, i)
|
||||
if abs(float(o.get("scale", 1)) - s) > 1e-3:
|
||||
args.append(f"output.{o['id']}.scale.{s:g}")
|
||||
rot = screen_rotation(layout, i)
|
||||
if KSCREEN_ROTATION.get(o.get("rotation"), "normal") != rot:
|
||||
args.append(f"output.{o['id']}.rotation.{rot}")
|
||||
if outs:
|
||||
p = outs[min(primary_screen(layout), len(outs) - 1)]
|
||||
if p.get("priority") != 1:
|
||||
args.append(f"output.{p['id']}.priority.1")
|
||||
if args:
|
||||
subprocess.run(["kscreen-doctor", *args], capture_output=True, env=env, timeout=20)
|
||||
# Side by side in screen order, centred vertically, so the pointer and dragged windows
|
||||
# cross between neighbours.
|
||||
outs = outputs(env)
|
||||
# kscreen's "size" is in pixels (already turned for a rotation); positions are in
|
||||
# logical units, the pixels divided by the scale (KWin rounds up).
|
||||
sizes = [(math.ceil(o["size"]["width"] / float(o.get("scale", 1)) - 1e-6),
|
||||
math.ceil(o["size"]["height"] / float(o.get("scale", 1)) - 1e-6))
|
||||
for o in outs if o.get("size")]
|
||||
if len(sizes) == len(outs) and outs:
|
||||
tallest, x, moves = max(h for _, h in sizes), 0, []
|
||||
for o, (w, h) in zip(outs, sizes):
|
||||
want = (x, (tallest - h) // 2)
|
||||
if (o.get("pos", {}).get("x"), o.get("pos", {}).get("y")) != want:
|
||||
moves.append(f"output.{o['id']}.position.{want[0]},{want[1]}")
|
||||
x += w
|
||||
if moves:
|
||||
subprocess.run(["kscreen-doctor", *moves], capture_output=True, env=env, timeout=20)
|
||||
args += moves
|
||||
return args
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 2 or argv[1] in ("-h", "--help"):
|
||||
print(__doc__.split("Usage")[1].split("\n", 1)[1])
|
||||
return 0 if len(argv) >= 2 else 2
|
||||
cmd = argv[1]
|
||||
try:
|
||||
if cmd == "plan":
|
||||
layout = load_layout()
|
||||
print(json.dumps(plan(layout, screen_count(layout))))
|
||||
elif cmd == "screen-args":
|
||||
print(screen_args())
|
||||
elif cmd == "toggle":
|
||||
log(screens_socket().ask("toggle"))
|
||||
elif cmd in ("pin", "unpin") and len(argv) >= 3:
|
||||
log(screens_socket().ask(" ".join(argv[1:])))
|
||||
elif cmd in ("apply", "capture", "scale"):
|
||||
with open(LOCK_PATH, "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
log("another ft-layout is already running")
|
||||
return 1
|
||||
if cmd == "apply":
|
||||
wait = float(argv[argv.index("--wait") + 1]) if "--wait" in argv else 0
|
||||
if wait and not load_layout().get("auto", True):
|
||||
log("auto-arrange is off")
|
||||
if backend() == "screens": # the visibility settings apply anyway
|
||||
try:
|
||||
sock = screens_socket()
|
||||
deadline = time.time() + wait
|
||||
while screens_up(sock) < screen_count() and time.time() < deadline:
|
||||
time.sleep(1)
|
||||
send_visibility(sock, load_layout())
|
||||
except RuntimeError as e:
|
||||
log(f"visibility: {e}")
|
||||
else:
|
||||
apply(wait)
|
||||
if wait:
|
||||
# KWin keeps these, but new screens or a changed layout need them once.
|
||||
for _ in range(30): # Plasma may still be starting
|
||||
try:
|
||||
log("kwin: " + (" ".join(apply_scales()) or "unchanged"))
|
||||
break
|
||||
except RuntimeError as e:
|
||||
last = e
|
||||
time.sleep(1)
|
||||
else:
|
||||
log(f"kwin: {last}")
|
||||
elif cmd == "capture":
|
||||
for i, s in enumerate(capture()):
|
||||
log(f"screen {i + 1}: {s}")
|
||||
else:
|
||||
changes = apply_scales()
|
||||
log("kwin: " + (" ".join(changes) if changes else "unchanged"))
|
||||
else:
|
||||
print(f"unknown command: {cmd}", file=sys.stderr)
|
||||
return 2
|
||||
except RuntimeError as e:
|
||||
log(f"error: {e}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
Reference in new issue
Block a user