Arrange the desktop's outputs the way the screens are around you

KWin's output positions now follow where the Frametop screens are in the room
instead of their numbers: a screen you see to the left of another is to its
left in Plasma, so the pointer and dragged windows cross straight to it.
Screens one above the other stack, and wrist-pinned screens go last.
ft-layout scale works this out from ft-screens' head pose and screen poses,
and runs after arranging, capturing, or pinning; ft-screens runs it half a
second after a screen is let go. With no head pose (headset off) KWin's
order is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-27 22:51:07 -06:00
1 parent c43418085d
commit e84ac22313
3 files changed
+104 -10

No files matched your search

+3 -1
View File
@@ -115,6 +115,8 @@ Device rules are saved in `~/.config/frametop-input.json`. `input-settings/insta
When the desktop starts, its screens arrange themselves around where you're facing. You can move them by hand at any time and put them back with Meta+Shift+R, the Reset Screen Layout menu entry, Arrange now in the app, or a mouse button mapped to Reset desktop screen layout.
The desktop's own screen arrangement follows where the screens are around you, whatever their numbers: a screen you see to the left of another is to its left in Plasma too, so the pointer and dragged windows cross straight to it. Screens one above the other stack, and screens pinned to a wrist come last. It's updated at startup, after arranging or saving the layout, and half a second after you let go of a screen you moved. With the headset off there's no head pose to go by, and the arrangement stays as it was.
Frametop Display Settings has three tabs:
- Screens: add and remove screens, and set each one's resolution (presets from 1080p to 4K, ultrawide, super ultrawide, portrait, or custom), its width in VR (0.5 to 6 m), its scale, whether it's curved, and whether it has the taskbar. Resolution, width, and curve apply at once. Adding or removing a screen takes a desktop restart, which the app offers.
@@ -127,7 +129,7 @@ Frametop Display Settings has three tabs:
layout/ft-layout apply # arrange every screen
layout/ft-layout capture # save the current arrangement and sizes as the layout
layout/ft-layout plan # print the arrangement as JSON (no VR needed)
layout/ft-layout scale # per-screen scale, positions, and taskbar screen, to KWin
layout/ft-layout scale # per-screen scale, positions (as the screens are around you), and taskbar screen, to KWin
layout/ft-layout toggle # hide or show all screens
display-settings/install.sh # menu entries and the Meta+Shift+R and Meta+Shift+H shortcuts
```
+67 -9
View File
@@ -39,7 +39,8 @@ Usage (on the Frame host; Frametop Display Settings calls it too):
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 scale per-screen scale, positions (as the screens are around
you), 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
@@ -611,6 +612,39 @@ def outputs(env):
KSCREEN_ROTATION = {1: "normal", 2: "left", 4: "inverted", 8: "right"} # kscreen-doctor -j "rotation"
def arrangement(count):
"""The screens as you see them from where you are: columns left to right, each top to
bottom (0-based screen indices), for KWin's output positions. Screens pinned to a
wrist come last. None when there's nothing to go by (gamescope, no ft-screens)."""
if backend() != "screens":
return None
try:
sock = screens_socket()
f = sock.ask("head").split()
eye, heading = tuple(map(float, f[1:4])), float(f[4])
gets = [parse_get(sock.ask(f"get {i + 1}")) for i in range(count)]
except (RuntimeError, ValueError, IndexError):
return None
seen, pinned = [], []
for i, g in enumerate(gets):
if g["hand"] != "none":
pinned.append(i)
continue
rel = turn_yaw(tuple(c - e for c, e in zip(g["center"], eye)), -heading)
yaw, pitch = yaw_pitch(rel)
half = math.degrees(math.atan2(g["metres"] / 2, max(0.1, math.sqrt(dot(rel, rel)))))
seen.append({"i": i, "x": -yaw, "pitch": pitch, "half": half}) # x grows to the right
seen.sort(key=lambda b: b["x"])
columns = []
for b in seen:
# One above the other: centres closer sideways than half the narrower screen.
if columns and abs(b["x"] - columns[-1][-1]["x"]) < min(b["half"], columns[-1][-1]["half"]):
columns[-1].append(b)
else:
columns.append([b])
return [[b["i"] for b in sorted(c, key=lambda b: -b["pitch"])] for c in columns] + [[i] for i in pinned]
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."""
@@ -633,8 +667,8 @@ def apply_scales():
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.
# Laid out as you see the screens around you (arrangement), centred on one line, so
# the pointer and dragged windows cross to the screen you see next to this one.
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).
@@ -642,18 +676,38 @@ def apply_scales():
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
columns = arrangement(len(outs))
if not columns or sorted(i for c in columns for i in c) != list(range(len(outs))):
# Nothing to go by (no head pose with the headset off, say): keep KWin's order.
columns = [[i] for i in sorted(range(len(outs)), key=lambda i: (outs[i].get("pos", {}).get("x", 0),
outs[i].get("pos", {}).get("y", 0)))]
widths = [max(sizes[i][0] for i in c) for c in columns]
heights = [sum(sizes[i][1] for i in c) for c in columns]
tallest, x, moves = max(heights), 0, []
for c, cw, ch in zip(columns, widths, heights):
y = (tallest - ch) // 2
for i in c:
want = (x + (cw - sizes[i][0]) // 2, y)
y += sizes[i][1]
o = outs[i]
if (o.get("pos", {}).get("x"), o.get("pos", {}).get("y")) != want:
moves.append(f"output.{o['id']}.position.{want[0]},{want[1]}")
x += cw
if moves:
subprocess.run(["kscreen-doctor", *moves], capture_output=True, env=env, timeout=20)
args += moves
return args
def kwin_follow():
"""KWin's outputs after the screens moved, if the desktop is up."""
try:
changes = apply_scales()
log("kwin: " + (" ".join(changes) if changes else "unchanged"))
except RuntimeError as e:
log(f"kwin: {e}")
def main(argv):
if len(argv) < 2 or argv[1] in ("-h", "--help"):
print(__doc__.split("Usage")[1].split("\n", 1)[1])
@@ -669,6 +723,7 @@ def main(argv):
log(screens_socket().ask("toggle"))
elif cmd in ("pin", "unpin") and len(argv) >= 3:
log(screens_socket().ask(" ".join(argv[1:])))
kwin_follow() # pinned screens go last
elif cmd in ("apply", "capture", "scale"):
with open(LOCK_PATH, "w") as lock:
try:
@@ -691,6 +746,8 @@ def main(argv):
log(f"visibility: {e}")
else:
apply(wait)
if not wait:
kwin_follow()
if wait:
# KWin keeps these, but new screens or a changed layout need them once.
for _ in range(30): # Plasma may still be starting
@@ -705,6 +762,7 @@ def main(argv):
elif cmd == "capture":
for i, s in enumerate(capture()):
log(f"screen {i + 1}: {s}")
kwin_follow()
else:
changes = apply_scales()
log("kwin: " + (" ".join(changes) if changes else "unchanged"))
+34
View File
@@ -41,7 +41,12 @@
#include <openvr.h>
#include <fcntl.h>
#include <linux/input-event-codes.h>
#include <limits.h>
#include <spawn.h>
extern char **environ; // for posix_spawn
#include <algorithm>
#include <chrono>
@@ -854,6 +859,33 @@ void EndDrag(Screen &s) {
ApplyAlpha(s);
}
// KWin's outputs follow where the screens are, so the pointer and dragged windows cross
// to the screen you see next to this one: `ft-layout scale` runs once a move has settled.
long g_arrangeAt = -1; // g_tick to run it at, -1 = not pending
void ArrangeDesktopSoon() { g_arrangeAt = g_tick + 45; } // about half a second
void UpdateArrange() {
if (g_arrangeAt < 0 || g_tick < g_arrangeAt) return;
g_arrangeAt = -1;
char exe[PATH_MAX];
if (!realpath("/proc/self/exe", exe)) return;
std::string layout(exe); // <repo>/screens/build/ft-screens -> <repo>/layout/ft-layout
for (int up = 0; up < 3 && layout.rfind('/') != std::string::npos; ++up) layout.resize(layout.rfind('/'));
layout += "/layout/ft-layout";
posix_spawn_file_actions_t io;
posix_spawn_file_actions_init(&io);
posix_spawn_file_actions_addopen(&io, 0, "/dev/null", O_RDONLY, 0);
posix_spawn_file_actions_addopen(&io, 1, "/tmp/frametop-layout.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
posix_spawn_file_actions_adddup2(&io, 1, 2);
char scale[] = "scale";
char *argv[] = {layout.data(), scale, nullptr};
pid_t pid; // reaped by the compositor's SIGCHLD handler
if (posix_spawn(&pid, layout.c_str(), &io, nullptr, argv, environ) != 0)
std::printf("can't run %s\n", layout.c_str());
posix_spawn_file_actions_destroy(&io);
}
// Let go: pin to the armed wrist, as the screen is now.
void FinishDrag(Screen &s, int index) {
const bool moved = s.drag == Drag::Move;
@@ -861,6 +893,7 @@ void FinishDrag(Screen &s, int index) {
EndDrag(s);
Mat c, p;
if (!moved) return;
ArrangeDesktopSoon();
if (target != kNone && DevicePose(target, &c) && ScreenPose(s, &p)) {
Pin(s, target, Mul(Inverse(c), p));
std::printf("screen %d: pinned to the %s controller\n", index + 1, HandName(target));
@@ -1228,6 +1261,7 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) {
}
++g_tick;
UpdateGame();
UpdateArrange();
UpdateVisibility();
UpdateLasers();
UpdateControls();