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:
DeeJanuzandClaude Opus 5.5 committed 2026-09-26 16:14:13 -06:00
commit d439bc3f25
61 files changed
+8785

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Launch Frametop Display 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_display_settings.py" "$@"
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Frametop Display Settings
GenericName=Screens and layout for the VR desktop
Comment=Number of screens, resolution, scale, and where they float around you
Exec=@REPO@/frametop/display-settings/ft-display-settings
Icon=video-display
Categories=Settings;HardwareSettings;
Keywords=display;screen;monitor;resolution;layout;arrange;steamvr;frametop;
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=Reset Screen Layout
GenericName=Put the VR desktop's screens back in their layout
Comment=Float the screens and arrange them in the layout from Frametop Display Settings
Exec=@REPO@/frametop/layout/ft-layout apply
Icon=view-restore
Categories=Settings;
Keywords=display;screen;layout;arrange;reset;steamvr;frametop;
X-KDE-Shortcuts=Meta+Shift+R
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=Hide/Show Screens
GenericName=Hide or show the VR desktop's screens
Comment=Hide the screens (and SteamVR's laser) for a VR game; press again to bring them back
Exec=@REPO@/frametop/layout/ft-layout toggle
Icon=view-visible
Categories=Settings;
Keywords=display;screen;hide;show;steamvr;frametop;
X-KDE-Shortcuts=Meta+Shift+H
+467
View File
@@ -0,0 +1,467 @@
#!/usr/bin/env python3
"""Frametop Display Settings: the Frametop screens' count, resolution, size, scale, and layout.
A Kirigami (QML) app with a Python backend, like Frametop Input Settings. It runs in
the dev container:
- Screens (ft-screens backend): each screen's resolution (any, portrait too), its width
in VR in metres, its scale, and which one has the taskbar. Resolution and width
apply at once (ft-screens' control socket, @ft_screens); adding or removing a screen
when the desktop starts again. (gamescope backend: one shared resolution, at most
1920x1080 worth of pixels, rotation for portrait.)
- Visibility (ft-screens): when the screens show (always, only with the SteamVR
dashboard open, while you look at a controller, or only when toggled), the wrist
angle within which a pinned screen shows, and pin or unpin all screens.
- Layout: a preset (curved or flat, rows, distance, gap, height) or the arrangement
captured from where the screens are now, with a preview; arrange now; save the
current arrangement; arrange automatically when the desktop starts.
Settings go to ~/.config/frametop.conf and ~/.config/frametop-layout.json. Anything
that touches SteamVR runs layout/ft-layout on the host.
Launch with display-settings/ft-display-settings (host wrapper).
"""
import os
import shutil
import socket
import sys
from PySide6.QtCore import Property, QObject, QProcess, QTimer, QUrl, Signal, Slot
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle
HERE = os.path.dirname(os.path.abspath(__file__))
LAYOUT_DIR = os.path.join(HERE, "..", "layout")
sys.path.insert(0, LAYOUT_DIR)
import ft_layout # noqa: E402 (pure Python: the same geometry ft-layout uses)
FT_LAYOUT = os.path.join(LAYOUT_DIR, "ft-layout")
DESKTOPS = os.path.join(HERE, "..", "desktops.sh")
CONF_PATH = ft_layout.CONF_PATH
# gamescope's VR backend uploads a texture the size of a screen at start, through a
# 1920x1080x4-byte buffer: more pixels than 1920x1080 abort it (see the design notes).
MAX_PIXELS = 1920 * 1080
RESOLUTIONS = [(1280, 720), (1600, 900), (1920, 1080), (1728, 1080), (1920, 800), (2224, 928), (2560, 800)]
# ft-screens has no pixel limit; portrait screens are just tall.
SCREEN_RESOLUTIONS = [(1920, 1080, ""), (2560, 1440, ""), (3840, 2160, "4K"), (2560, 1080, "ultrawide"),
(3440, 1440, "ultrawide"), (5120, 1440, "super ultrawide"), (1920, 1200, "16:10"),
(2560, 1600, "16:10"), (1080, 1920, "portrait"), (1440, 2560, "portrait"),
(2160, 3840, "portrait 4K")]
FT_SCREENS = "\0ft_screens"
SCALES = [0.75, 1.0, 1.25, 4 / 3, 1.5, 1.75, 2.0]
ROTATIONS = [("normal", "Landscape"), ("left", "Portrait"), ("right", "Portrait (flipped)")]
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_command(*cmd):
"""argv to run a command on the SteamOS host (we live in the dev container).
distrobox-host-exec reaches the host through the user's real session bus; inside the
desktop our DBUS_SESSION_BUS_ADDRESS is the nested session's private one, where it
fails (exit 127, silently)."""
if not shutil.which("distrobox-host-exec"):
return list(cmd)
bus = f"unix:path=/run/user/{os.getuid()}/bus"
return ["env", f"DBUS_SESSION_BUS_ADDRESS={bus}", "distrobox-host-exec"] + list(cmd)
class Backend(QObject):
changed = Signal()
busyChanged = Signal()
message = Signal(str, bool) # text, is error
def __init__(self):
super().__init__()
self._busy = ""
self._proc = None
self._running = False
self._running_count = 0
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
self._sock.bind("") # an abstract address ft-screens can reply to
self._sock.settimeout(1.0)
self._started = {} # conf values the running desktop started with
self.poll = QTimer(interval=3000, timeout=self._check_running)
self.poll.start()
self._check_running()
# --- state ---
def _conf(self):
conf = ft_layout.read_conf()
def num(key, default, cast=float):
try:
return cast(conf.get(key, default))
except ValueError:
return default
return {"screens": num("SCREENS", 2, int), "width": num("WIDTH", 1920, int),
"height": num("HEIGHT", 1080, int), "physWidth": num("PHYS_WIDTH", 1.6)}
def _check_running(self):
# The session's own Wayland socket (host processes' environments aren't readable
# from the container, so ft_layout.nested_env() doesn't work here).
running = os.path.exists(f"/run/user/{os.getuid()}/frametop/wayland-0")
count = self._screens_running() if running and ft_layout.backend() == "screens" else 0
if running != self._running or count != self._running_count:
self._running = running
self._running_count = count
self._started = self._conf() if running else {}
self.changed.emit()
def _ask_screens(self, text):
"""Request/reply to ft-screens; None if it isn't running."""
try:
self._sock.sendto(text.encode(), FT_SCREENS)
return self._sock.recv(4096).decode()
except OSError:
return None
def _screens_running(self):
reply = self._ask_screens("screens")
return int(reply.split()[1]) if reply and reply.startswith("ok") else 0
@Property(bool, notify=changed)
def desktopRunning(self):
return self._running
@Property(str, notify=changed)
def backend(self):
return ft_layout.backend()
@Property(bool, notify=changed)
def restartNeeded(self):
"""ft-screens: screens added or removed since the desktop started; gamescope: count,
resolution, or width changed."""
if not self._running:
return False
if ft_layout.backend() == "screens":
return self._running_count != ft_layout.screen_count(ft_layout.load_layout())
return bool(self._started) and self._started != self._conf()
@Property("QVariantList", constant=True)
def screenResolutions(self):
return [{"text": f"{w} × {h}" + (f" ({t})" if t else ""), "width": w, "height": h}
for w, h, t in SCREEN_RESOLUTIONS]
@Property(int, notify=changed)
def screens(self):
return self._conf()["screens"]
@Property(int, notify=changed)
def width(self):
return self._conf()["width"]
@Property(int, notify=changed)
def height(self):
return self._conf()["height"]
@Property(float, notify=changed)
def physWidth(self):
return self._conf()["physWidth"]
@Property("QVariantList", constant=True)
def resolutions(self):
return [{"text": f"{w} × {h}", "width": w, "height": h} for w, h in RESOLUTIONS]
@Property("QVariantList", constant=True)
def scales(self):
return [{"text": f"{round(s * 100)}%", "value": round(s, 4)} for s in SCALES]
@Property("QVariantList", constant=True)
def rotations(self):
return [{"text": t, "value": v} for v, t in ROTATIONS]
@Property("QVariantList", notify=changed)
def screenList(self):
c, layout = self._conf(), ft_layout.load_layout()
out = []
if ft_layout.backend() == "screens":
primary = ft_layout.primary_screen(layout)
for i in range(ft_layout.screen_count(layout)):
w, h = ft_layout.screen_pixels(layout, i)
s = ft_layout.screen_scale(layout, i)
out.append({"index": i, "width": w, "height": h, "metres": ft_layout.screen_metres(layout, i),
"scale": round(s, 4), "primary": i == primary,
"curved": float(ft_layout.screen_entry(layout, i).get("curve", 0)) > 0,
"effective": f"{round(w / s)} × {round(h / s)}"})
return out
for i in range(c["screens"]):
s = ft_layout.screen_scale(layout, i)
rot = ft_layout.screen_rotation(layout, i)
w, h = (c["height"], c["width"]) if rot != "normal" else (c["width"], c["height"])
out.append({"index": i, "scale": round(s, 4), "rotation": rot,
"effective": f"{round(w / s)} × {round(h / s)}"})
return out
@Property("QVariantMap", notify=changed)
def layout(self):
return ft_layout.load_layout()
@Property("QVariantList", notify=changed)
def plan(self):
"""The arrangement in the head frame, for the preview."""
layout = ft_layout.load_layout()
c = self._conf()
# Before any panel was measured: the width SteamVR floats a panel at, and the aspect.
if "panel_size" not in layout or layout["panel_size"] == list(ft_layout.DEFAULT_PANEL):
layout["panel_size"] = [ft_layout.DEFAULT_PANEL[0], ft_layout.DEFAULT_PANEL[0] * c["height"] / c["width"]]
out = []
for i, t in enumerate(ft_layout.plan(layout, c["screens"])):
w, h = ft_layout.screen_size(layout, i)
out.append({"index": i, "x": t["pos"][0], "y": t["pos"][1], "z": t["pos"][2],
"faceYaw": t["face"][0], "facePitch": t["face"][1], "width": w, "height": h})
return out
@Property(str, notify=busyChanged)
def busy(self):
return self._busy
# --- screens ---
@Slot(int)
def setScreens(self, n):
write_conf_value("SCREENS", str(max(1, min(6, n))))
self.changed.emit()
@Slot(int, int)
def setResolution(self, w, h):
if w * h > MAX_PIXELS:
self.message.emit(f"{w} × {h} is more than gamescope's VR mode can draw (1920 × 1080 worth of pixels, "
f"about {MAX_PIXELS // 1000} thousand); try {round((MAX_PIXELS * w / h) ** 0.5) // 8 * 8} × "
f"{round((MAX_PIXELS * h / w) ** 0.5) // 8 * 8}", True)
return
if w >= 640 and h >= 360:
write_conf_value("WIDTH", str(w))
write_conf_value("HEIGHT", str(h))
self.changed.emit()
@Slot(float)
def setPhysWidth(self, w):
write_conf_value("PHYS_WIDTH", f"{w:.2f}")
self.changed.emit()
@Slot(int, float)
def setScale(self, i, s):
layout = ft_layout.load_layout()
screens = layout.setdefault("screens", [])
while len(screens) <= i:
screens.append({})
screens[i]["scale"] = s
ft_layout.save_layout(layout)
self.changed.emit()
if self._running:
self._run("Applying scale", "scale")
@Slot(int, str)
def setRotation(self, i, rotation):
layout = ft_layout.load_layout()
screens = layout.setdefault("screens", [])
while len(screens) <= i:
screens.append({})
screens[i]["rotation"] = rotation
screens[i].pop("roll", None) # a saved arrangement follows the new rotation
ft_layout.save_layout(layout)
self.changed.emit()
if self._running:
self._run("Rotating", "scale")
# --- ft-screens: per-screen resolution and size ---
def _edit_screen(self, i, fn):
layout = ft_layout.load_layout()
screens = layout.setdefault("screens", [])
while len(screens) <= i:
screens.append({"size": [1920, 1080], "metres": 1920 / ft_layout.PIXELS_PER_METRE})
fn(screens[i])
ft_layout.save_layout(layout)
self.changed.emit()
@Slot(int, int, int)
def setScreenSize(self, i, w, h):
if not (320 <= w <= 16384 and 200 <= h <= 16384):
self.message.emit("Width and height: 320 to 16384 pixels", True)
return
self._edit_screen(i, lambda s: s.__setitem__("size", [w, h]))
if self._running and i < self._running_count:
self._ask_screens(f"size {i + 1} {w} {h}")
@Slot(int, float)
def setScreenMetres(self, i, m):
self._edit_screen(i, lambda s: s.__setitem__("metres", round(m, 3)))
if self._running and i < self._running_count:
self._ask_screens(f"width {i + 1} {m:.3f}")
@Slot(int, bool)
def setCurved(self, i, on):
"""Curve a screen into a cylinder around you (radius: your distance to it now, from
ft-screens; the layout's distance if the desktop isn't running)."""
radius = float(ft_layout.load_layout()["preset"].get("distance", 2.0)) if on else 0.0
if self._running and i < self._running_count:
reply = self._ask_screens(f"curve {i + 1} {'on' if on else 'off'}")
if reply and reply.startswith("ok"):
radius = float(reply.split()[1])
self._edit_screen(i, lambda s: s.__setitem__("curve", round(radius, 3)))
@Slot(int)
def setPrimary(self, i):
layout = ft_layout.load_layout()
layout["primary"] = i + 1
ft_layout.save_layout(layout)
self.changed.emit()
if self._running:
self._run("Moving the taskbar", "scale")
@Slot()
def addScreen(self):
layout = ft_layout.load_layout()
layout.setdefault("screens", []).append({"size": [1920, 1080], "metres": 1920 / ft_layout.PIXELS_PER_METRE})
ft_layout.save_layout(layout)
self.changed.emit()
@Slot(int)
def removeScreen(self, i):
layout = ft_layout.load_layout()
screens = layout.get("screens", [])
if len(screens) <= 1 or i >= len(screens):
return
screens.pop(i)
if layout.get("primary") == i + 1:
layout.pop("primary")
ft_layout.save_layout(layout)
self.changed.emit()
@Slot()
def toggleScreens(self):
self._ask_screens("toggle")
# --- ft-screens: visibility and pinning ---
@Property("QVariantMap", notify=changed)
def visibility(self):
return ft_layout.visibility(ft_layout.load_layout())
@Slot(str, "QVariant")
def setVisibility(self, key, value):
layout = ft_layout.load_layout()
v = ft_layout.visibility(layout)
v[key] = value
layout["visibility"] = v
ft_layout.save_layout(layout)
self.changed.emit()
if self._running:
if key == "mode":
self._ask_screens(f"visibility {value}")
elif key == "wrist_angle":
self._ask_screens(f"wrist {float(value):.1f}")
else:
self._ask_screens(f"gesture {v['gesture_hand']} {float(v['gesture_angle']):.1f}")
@Slot(str)
def pinAll(self, hand):
reply = self._ask_screens(f"pin all {hand}") if self._running else None
if reply and reply.startswith("ok"):
self.message.emit(f"All screens ride on your {hand} wrist now; grab a screen's bar to take it off. "
"Save current arrangement keeps it.", False)
else:
self.message.emit(f"Couldn't pin: {reply or 'the desktop is not running'}", True)
@Slot()
def unpinAll(self):
reply = self._ask_screens("unpin all") if self._running else None
if not (reply and reply.startswith("ok")):
self.message.emit(f"Couldn't unpin: {reply or 'the desktop is not running'}", True)
@Slot()
def restartDesktop(self):
"""Restart the Frametop desktop (this app closes with it) to apply count and resolution."""
argv = host_command("systemd-run", "--user", "--collect", "--quiet", os.path.abspath(DESKTOPS), "restart")
QProcess.startDetached(argv[0], argv[1:])
self.message.emit("Restarting the desktop…", False)
# --- layout ---
def _edit_layout(self, fn):
layout = ft_layout.load_layout()
fn(layout)
ft_layout.save_layout(layout)
self.changed.emit()
@Slot(str)
def setMode(self, mode):
self._edit_layout(lambda l: l.__setitem__("mode", mode))
@Slot(str, "QVariant")
def setPreset(self, key, value):
def edit(layout):
layout["mode"] = "preset"
layout["preset"][key] = value
self._edit_layout(edit)
@Slot(bool)
def setAuto(self, on):
self._edit_layout(lambda l: l.__setitem__("auto", bool(on)))
@Slot()
def arrange(self):
self._run("Arranging the screens", "apply")
@Slot()
def capture(self):
self._run("Saving the current arrangement", "capture")
# --- ft-layout on the host ---
def _run(self, label, *args):
if self._proc is not None:
self.message.emit(f"Still busy: {self._busy}", True)
return
self._busy = label
self.busyChanged.emit()
proc = QProcess(self)
proc.setProcessChannelMode(QProcess.MergedChannels)
argv = host_command(os.path.abspath(FT_LAYOUT), *args)
proc.finished.connect(lambda code, _status: self._done(proc, label, code))
self._proc = proc
proc.start(argv[0], argv[1:])
def _done(self, proc, label, code):
out = bytes(proc.readAllStandardOutput()).decode(errors="replace").strip()
self._proc = None
self._busy = ""
self.busyChanged.emit()
self.changed.emit()
last = out.splitlines()[-1] if out else ""
if code == 0:
self.message.emit(f"{label}: done" + (f" ({last})" if last and not last.startswith("ok") else ""), False)
else:
self.message.emit(f"{label} failed: {last or 'exit code ' + str(code)}", True)
def main():
app = QGuiApplication(sys.argv)
app.setApplicationName("ft-display-settings")
app.setApplicationDisplayName("Frametop Display Settings")
app.setDesktopFileName("ft-display-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_DISPLAY_PAGE", "screens"))
engine.load(QUrl.fromLocalFile(os.path.join(HERE, "main.qml")))
if not engine.rootObjects():
sys.exit(1)
sys.exit(app.exec())
if __name__ == "__main__":
main()
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Install (or remove) Frametop Display Settings and the Reset Screen Layout and Hide/Show Screens
# entries on the Frame, with global shortcuts in the Frametop desktop: Meta+Shift+R and
# Meta+Shift+H (they take effect the next time the desktop starts).
# Usage: display-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
apps=.local/share/applications
shortcuts=.config/frametop/kglobalshortcutsrc
case ${1:-install} in
install)
for f in ft-display-settings ft-layout-reset ft-screens-toggle; do
fill_template "$root/display-settings/$f.desktop" | on_frame "mkdir -p ~/$apps && cat > ~/$apps/$f.desktop"
done
on_frame "chmod +x display-settings/ft-display-settings layout/ft-layout layout/ft_layout.py
mkdir -p ~/.config/frametop
kwriteconfig6 --file ~/$shortcuts --group services --group ft-layout-reset.desktop --key _launch 'Meta+Shift+R'
kwriteconfig6 --file ~/$shortcuts --group services --group ft-screens-toggle.desktop --key _launch 'Meta+Shift+H'"
echo "installed: Frametop Display Settings, Reset Screen Layout (Meta+Shift+R), Hide/Show Screens (Meta+Shift+H)" ;;
uninstall)
on_frame "rm -f ~/$apps/ft-display-settings.desktop ~/$apps/ft-layout-reset.desktop ~/$apps/ft-screens-toggle.desktop
[ -f ~/$shortcuts ] && for f in ft-layout-reset ft-screens-toggle; do kwriteconfig6 --file ~/$shortcuts --group services --group \$f.desktop --key _launch --delete; done
echo removed" ;;
*) echo "usage: $0 [install|uninstall]" >&2; exit 2 ;;
esac
+666
View File
@@ -0,0 +1,666 @@
// Frametop Display Settings (Kirigami). Backend: ft_display_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 Display Settings"
width: Kirigami.Units.gridUnit * 46
height: Kirigami.Units.gridUnit * 36
// Pages as tabs across the top (a side drawer was easy to miss).
readonly property var pages: backend.backend === "screens"
? [{ text: "Screens", icon: "video-display", page: screensPage },
{ text: "Layout", icon: "view-grid", page: layoutPage },
{ text: "Visibility & wrist", icon: "view-visible", page: visibilityPage }]
: [{ text: "Screens", icon: "video-display", page: screensPage },
{ text: "Layout", icon: "view-grid", page: layoutPage }]
header: Controls.TabBar {
id: tabs
Repeater {
model: root.pages
Controls.TabButton {
required property var modelData
text: modelData.text
icon.name: modelData.icon
onClicked: root.show(modelData.page)
}
}
Component.onCompleted: currentIndex = ({ layout: 1, visibility: 2 })[startPage] || 0
}
function show(page) {
pageStack.clear()
pageStack.push(page)
}
// FT_DISPLAY_PAGE=layout|visibility opens the app on that page.
pageStack.initialPage: ({ layout: layoutPage, visibility: visibilityPage })[startPage] || screensPage
Connections {
target: backend
function onMessage(text, isError) {
root.showPassiveNotification(text, isError ? "long" : "short")
}
}
Kirigami.PromptDialog {
id: restartDialog
title: "Restart the desktop?"
subtitle: "Every window on the desktop closes, this app too. It starts again with the current settings."
standardButtons: Kirigami.Dialog.NoButton
customFooterActions: [
Kirigami.Action {
text: "Restart"
icon.name: "view-refresh"
onTriggered: { restartDialog.close(); backend.restartDesktop() }
},
Kirigami.Action {
text: "Cancel"
icon.name: "dialog-cancel"
onTriggered: restartDialog.close()
}
]
}
// ---------------------------------------------------------------- Screens
Component {
id: screensPage
Kirigami.ScrollablePage {
id: spage
title: "Screens"
property bool md: backend.backend === "screens"
actions: [
Kirigami.Action {
visible: spage.md
text: "Add screen"
icon.name: "list-add"
onTriggered: backend.addScreen()
},
Kirigami.Action {
visible: spage.md && backend.desktopRunning
text: "Hide/show screens"
icon.name: "view-visible"
onTriggered: backend.toggleScreens()
},
Kirigami.Action {
visible: backend.desktopRunning
text: "Restart desktop"
icon.name: "view-refresh"
tooltip: "Closes the desktop's windows (and this app) and starts it again"
onTriggered: restartDialog.open()
}
]
header: Kirigami.InlineMessage {
position: Kirigami.InlineMessage.Position.Header
visible: backend.restartNeeded || !backend.desktopRunning
type: Kirigami.MessageType.Information
text: backend.restartNeeded
? (spage.md ? "Screens were added or removed. That applies when the desktop starts again; restarting closes its windows (and this app)."
: "The number of screens, resolution, or panel width changed. They apply when the desktop starts again; restarting closes its windows (and this app).")
: "The desktop isn't running. These settings apply the next time it starts."
actions: [
Kirigami.Action {
visible: backend.restartNeeded
text: "Restart desktop"
icon.name: "system-reboot"
onTriggered: backend.restartDesktop()
}
]
}
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
// ft-screens: every screen its own resolution and size.
Repeater {
model: spage.md ? backend.screenList : []
delegate: Kirigami.AbstractCard {
id: card
required property var modelData
Layout.fillWidth: true
property int customIndex: backend.screenResolutions.length
contentItem: ColumnLayout {
RowLayout {
Kirigami.Heading { level: 3; text: "Screen " + (card.modelData.index + 1) }
Controls.Label {
text: card.modelData.width + " × " + card.modelData.height
+ (card.modelData.scale !== 1 ? ", works like " + card.modelData.effective : "")
opacity: 0.7
}
Item { Layout.fillWidth: true }
Controls.RadioButton {
text: "Taskbar here"
checked: card.modelData.primary
onToggled: if (checked) backend.setPrimary(card.modelData.index)
}
Controls.ToolButton {
icon.name: "edit-delete-remove"
enabled: backend.screenList.length > 1
display: Controls.AbstractButton.IconOnly
text: "Remove this screen"
Controls.ToolTip.text: text
Controls.ToolTip.visible: hovered
onClicked: backend.removeScreen(card.modelData.index)
}
}
Kirigami.FormLayout {
Layout.fillWidth: true
RowLayout {
Kirigami.FormData.label: "Resolution:"
Controls.ComboBox {
id: res
model: backend.screenResolutions.concat([{ text: "Custom…", width: 0, height: 0 }])
textRole: "text"
Component.onCompleted: {
const i = backend.screenResolutions.findIndex(r => r.width === card.modelData.width && r.height === card.modelData.height)
currentIndex = i >= 0 ? i : card.customIndex
}
onActivated: {
const r = model[currentIndex]
if (r.width > 0) backend.setScreenSize(card.modelData.index, r.width, r.height)
}
}
Controls.SpinBox {
id: cw
visible: res.currentIndex === card.customIndex
from: 320; to: 16384; stepSize: 8; editable: true
value: card.modelData.width
}
Controls.Label { visible: cw.visible; text: "×" }
Controls.SpinBox {
id: ch
visible: cw.visible
from: 200; to: 16384; stepSize: 8; editable: true
value: card.modelData.height
}
Controls.Button {
visible: cw.visible
text: "Set"
onClicked: backend.setScreenSize(card.modelData.index, cw.value, ch.value)
}
}
RowLayout {
Kirigami.FormData.label: "Width in VR:"
Controls.Slider {
id: metres
from: 0.5; to: 6.0; stepSize: 0.05
value: card.modelData.metres
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onMoved: backend.setScreenMetres(card.modelData.index, value)
}
Controls.Label {
text: metres.value.toFixed(2) + " m wide, "
+ (metres.value * card.modelData.height / card.modelData.width).toFixed(2) + " m tall"
}
}
Controls.ComboBox {
Kirigami.FormData.label: "Scale:"
model: backend.scales
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(card.modelData.scale))
onActivated: backend.setScale(card.modelData.index, currentValue)
}
Controls.Switch {
Kirigami.FormData.label: "Curved:"
text: "Bend around you"
checked: card.modelData.curved
onToggled: backend.setCurved(card.modelData.index, checked)
}
}
}
}
}
// gamescope: one shared resolution.
Kirigami.FormLayout {
visible: !spage.md
Layout.fillWidth: true
Controls.SpinBox {
Kirigami.FormData.label: "Screens:"
from: 1
to: 6
value: backend.screens
onValueModified: backend.setScreens(value)
}
RowLayout {
Kirigami.FormData.label: "Resolution:"
Controls.ComboBox {
id: resBox
property bool custom: currentIndex === count - 1
model: backend.resolutions.concat([{ text: "Custom…", width: 0, height: 0 }])
textRole: "text"
Component.onCompleted: {
const i = backend.resolutions.findIndex(r => r.width === backend.width && r.height === backend.height)
currentIndex = i >= 0 ? i : count - 1
}
onActivated: {
const r = model[currentIndex]
if (r.width > 0) backend.setResolution(r.width, r.height)
}
}
Controls.SpinBox {
id: customW
visible: resBox.custom
from: 640; to: 7680; stepSize: 8
editable: true
value: backend.width
}
Controls.Label { visible: resBox.custom; text: "×" }
Controls.SpinBox {
id: customH
visible: resBox.custom
from: 360; to: 4320; stepSize: 8
editable: true
value: backend.height
}
Controls.Button {
visible: resBox.custom
text: "Set"
onClicked: backend.setResolution(customW.value, customH.value)
}
}
RowLayout {
Kirigami.FormData.label: "Panel width:"
Controls.Slider {
id: physSlider
from: 0.8; to: 3.0; stepSize: 0.05
value: backend.physWidth
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onMoved: backend.setPhysWidth(value)
}
Controls.Label { text: physSlider.value.toFixed(2) + " m" }
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Each screen" }
Repeater {
model: spage.md ? [] : backend.screenList
delegate: RowLayout {
required property var modelData
Kirigami.FormData.label: "Screen " + (modelData.index + 1) + ":"
Controls.ComboBox {
model: backend.rotations
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(modelData.rotation))
onActivated: backend.setRotation(modelData.index, currentValue)
}
Controls.ComboBox {
model: backend.scales
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(modelData.scale))
onActivated: backend.setScale(modelData.index, currentValue)
}
Controls.Label {
text: "looks like " + modelData.effective
opacity: 0.7
}
}
}
}
}
footer: Controls.Label {
padding: Kirigami.Units.largeSpacing
wrapMode: Text.Wrap
opacity: 0.7
text: spage.md
? "Each screen is a real monitor of its own: any resolution, portrait by choosing a tall one. Resolution, "
+ "width, and curve apply at once. In VR: move a screen by the bar underneath, curve it with the round "
+ "button next to the bar, resize it by the tab on its bottom right corner; Save current arrangement on the "
+ "Layout page keeps all of it."
: "gamescope draws every screen at the same resolution, at most 1920 × 1080 worth of pixels. Portrait turns "
+ "a screen on its side. Rotation and scale apply at once; the rest when the desktop starts."
}
}
}
// ---------------------------------------------------------------- Layout
Component {
id: layoutPage
Kirigami.ScrollablePage {
id: lpage
title: "Layout"
property var layout: backend.layout
property var preset: layout.preset || {}
property bool hasCustom: (layout.screens || []).some(s => s.pos !== undefined)
actions: [
Kirigami.Action {
text: "Arrange now"
icon.name: "view-restore"
tooltip: "Float the screens out of the dashboard and put them in this layout, around where you're facing"
enabled: backend.desktopRunning && backend.busy === ""
onTriggered: backend.arrange()
},
Kirigami.Action {
text: "Save current arrangement"
icon.name: "document-save"
tooltip: "Use where the screens are now (placed by hand) as the layout"
enabled: backend.desktopRunning && backend.busy === ""
onTriggered: backend.capture()
}
]
header: Kirigami.InlineMessage {
position: Kirigami.InlineMessage.Position.Header
visible: backend.busy !== ""
type: Kirigami.MessageType.Information
text: backend.busy + "… (the pointer is borrowed for a few seconds)"
}
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
Kirigami.FormLayout {
Layout.fillWidth: true
Controls.ComboBox {
Kirigami.FormData.label: "Arrangement:"
model: [
{ text: "Curved around you", value: "arc" },
{ text: "Flat wall", value: "flat" },
{ text: "Saved arrangement", value: "custom" }
]
textRole: "text"
valueRole: "value"
currentIndex: lpage.layout.mode === "custom" ? 2 : (lpage.preset.kind === "flat" ? 1 : 0)
onActivated: {
if (currentValue === "custom") backend.setMode("custom")
else backend.setPreset("kind", currentValue)
}
}
Controls.Label {
visible: lpage.layout.mode === "custom"
Kirigami.FormData.label: ""
text: lpage.hasCustom ? "Where the screens were when you saved. Pick a preset to edit."
: "Nothing saved yet: place the screens by hand, then Save current arrangement."
opacity: 0.7
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 20
}
Controls.SpinBox {
Kirigami.FormData.label: "Rows:"
visible: lpage.layout.mode !== "custom"
from: 1
to: Math.max(1, backend.screens)
value: lpage.preset.rows || 1
onValueModified: backend.setPreset("rows", value)
}
Repeater {
model: [
{ key: "distance", label: "Distance", from: 0.6, to: 3.0, step: 0.05, unit: "m", def: 1.2 },
{ key: "gap", label: "Gap", from: 0.0, to: 0.3, step: 0.01, unit: "m", def: 0.04 },
{ key: "height", label: "Height", from: -0.8, to: 0.8, step: 0.05, unit: "m", def: 0.0 }
]
delegate: RowLayout {
required property var modelData
visible: lpage.layout.mode !== "custom"
Kirigami.FormData.label: modelData.label + ":"
Controls.Slider {
id: s
from: modelData.from; to: modelData.to; stepSize: modelData.step
value: lpage.preset[modelData.key] !== undefined ? lpage.preset[modelData.key] : modelData.def
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onMoved: backend.setPreset(modelData.key, value)
}
Controls.Label {
text: (modelData.key === "height" && s.value > 0 ? "+" : "") + s.value.toFixed(2) + " " + modelData.unit
+ (modelData.key === "height" ? " (from eye level)" : "")
}
}
}
Controls.Switch {
Kirigami.FormData.label: "When the desktop starts:"
text: "Float the screens and arrange them"
checked: lpage.layout.auto !== false
onToggled: backend.setAuto(checked)
}
}
// Preview: from above (you at the bottom) and from the front.
Kirigami.Heading { level: 3; text: "Preview" }
Canvas {
id: preview
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 13
property var plan: backend.plan
onPlanChanged: requestPaint()
onWidthChanged: requestPaint()
onPaint: {
const ctx = getContext("2d")
ctx.reset()
const text = Kirigami.Theme.textColor
const accent = Kirigami.Theme.highlightColor
const half = width / 2
ctx.font = Kirigami.Theme.smallFont.pixelSize + "px sans-serif"
ctx.fillStyle = text
ctx.fillText("From above", 4, 12)
ctx.fillText("From the front", half + 8, 12)
if (!plan || plan.length === 0) return
// From above: x right, forward (-z) up; you are the dot at the bottom.
let ends = [[0, 0]]
for (const p of plan) {
const f = p.faceYaw * Math.PI / 180
const rx = Math.cos(f) * p.width / 2, rz = -Math.sin(f) * p.width / 2
ends.push([p.x - rx, p.z - rz], [p.x + rx, p.z + rz])
}
const xs = ends.map(e => e[0]), zs = ends.map(e => e[1])
const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs), 0.5)
const k = Math.min(half - 20, height - 44) / span
const cx = half / 2 - (Math.max(...xs) + Math.min(...xs)) / 2 * k
const cz = height - 12 - Math.max(...zs) * k // your dot 12 px above the bottom
const P = (x, z) => [cx + x * k, cz + z * k]
ctx.fillStyle = text
ctx.beginPath(); const me = P(0, 0); ctx.arc(me[0], me[1], 4, 0, 2 * Math.PI); ctx.fill()
ctx.lineWidth = 4
ctx.lineCap = "round"
// Screens stacked in rows share a spot from above, so they share a label ("1·3"):
// the same direction on a curve, the same x on a flat wall.
const flat = plan.every(p => Math.abs(p.faceYaw - plan[0].faceYaw) < 0.1
&& Math.abs(p.facePitch - plan[0].facePitch) < 0.1)
const labels = {}
for (const p of plan) {
const f = p.faceYaw * Math.PI / 180
const rx = Math.cos(f) * p.width / 2, rz = -Math.sin(f) * p.width / 2
const a = P(p.x - rx, p.z - rz), b = P(p.x + rx, p.z + rz)
ctx.strokeStyle = accent
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke()
const c = P(p.x, p.z), spot = flat ? Math.round(p.x * 50) : Math.round(p.faceYaw * 2)
labels[spot] = labels[spot] || { at: c, names: [] }
labels[spot].names.push(p.index + 1)
}
ctx.fillStyle = text
for (const spot in labels) {
const l = labels[spot], t = l.names.join("·")
ctx.fillText(t, l.at[0] - ctx.measureText(t).width / 2, l.at[1] - 7)
}
// From the front: x right, y up. A flat wall as it is; a curved layout
// unrolled (arc length by yaw and pitch), so the gaps show true.
const front = plan.map(p => {
if (flat) return { x: p.x, y: p.y, w: p.width, h: p.height }
const r = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z)
const arc = m => 2 * r * Math.atan(m / 2 / r) // what the screen spans on the curve
return { x: -p.faceYaw * Math.PI / 180 * r, y: p.facePitch * Math.PI / 180 * r,
w: arc(p.width), h: arc(p.height) }
})
const fx = [], fy = []
front.forEach(f => { fx.push(f.x - f.w / 2, f.x + f.w / 2); fy.push(f.y - f.h / 2, f.y + f.h / 2) })
fy.push(0)
const fspan = Math.max(Math.max(...fx) - Math.min(...fx), Math.max(...fy) - Math.min(...fy), 0.5)
const fk = Math.min(half - 20, height - 44) / fspan
const ox = half + half / 2 - (Math.max(...fx) + Math.min(...fx)) / 2 * fk
const oy = 30 + (height - 42) / 2 + (Math.max(...fy) + Math.min(...fy)) / 2 * fk
ctx.strokeStyle = Kirigami.Theme.disabledTextColor
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
ctx.beginPath(); ctx.moveTo(half + 8, oy); ctx.lineTo(width - 4, oy); ctx.stroke() // eye level
ctx.setLineDash([])
plan.forEach((p, i) => {
const f = front[i], x = ox + (f.x - f.w / 2) * fk, y = oy - (f.y + f.h / 2) * fk
ctx.fillStyle = Qt.rgba(accent.r, accent.g, accent.b, 0.35)
ctx.fillRect(x, y, f.w * fk, f.h * fk)
ctx.strokeStyle = accent
ctx.lineWidth = 2
ctx.strokeRect(x, y, f.w * fk, f.h * fk)
ctx.fillStyle = text
ctx.fillText(String(p.index + 1), x + f.w * fk / 2 - 3, y + f.h * fk / 2 + 4)
})
}
}
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
opacity: 0.7
text: "The layout goes around where you're facing when it's applied. Move screens by hand any time "
+ "(grab bar under each screen); to put them back: Meta+Shift+R in the desktop, the Reset "
+ "Screen Layout menu entry, Arrange now here, or a mouse button mapped to \"Reset desktop "
+ "screen layout\" in Frametop Input Settings → Buttons."
}
}
}
}
// ---------------------------------------------------------------- Visibility
Component {
id: visibilityPage
Kirigami.ScrollablePage {
id: vpage
title: "Visibility"
property var v: backend.visibility
actions: [
Kirigami.Action {
text: "Hide/show now"
icon.name: "view-visible"
enabled: backend.desktopRunning
onTriggered: backend.toggleScreens()
}
]
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
Kirigami.FormLayout {
Layout.fillWidth: true
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "When the screens show" }
Repeater {
model: [
{ value: "always", text: "Always", help: "Meta+Shift+H (or a mapped button) hides them, for a VR game." },
{ value: "dashboard", text: "Only with the SteamVR dashboard open", help: "They come and go with the dashboard. Meta+Shift+H shows them anyway." },
{ value: "gesture", text: "While I look at my wrist", help: "They show while you look toward the controller below. Meta+Shift+H shows them anyway." },
{ value: "toggle", text: "Only when I show them", help: "Hidden until Meta+Shift+H (or a mapped button) shows them." }
]
delegate: ColumnLayout {
required property var modelData
spacing: 0
Controls.RadioButton {
text: modelData.text
checked: vpage.v.mode === modelData.value
onToggled: if (checked) backend.setVisibility("mode", modelData.value)
}
Controls.Label {
text: modelData.help
opacity: 0.7
font: Kirigami.Theme.smallFont
leftPadding: Kirigami.Units.gridUnit * 1.6
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
}
}
}
RowLayout {
Kirigami.FormData.label: "Wrist:"
visible: vpage.v.mode === "gesture"
Controls.ComboBox {
model: [{ text: "Left controller", value: "left" }, { text: "Right controller", value: "right" }]
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = indexOfValue(vpage.v.gesture_hand)
onActivated: backend.setVisibility("gesture_hand", currentValue)
}
}
RowLayout {
Kirigami.FormData.label: "Look within:"
visible: vpage.v.mode === "gesture"
Controls.Slider {
id: gesture
from: 5; to: 60; stepSize: 1
value: vpage.v.gesture_angle
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onMoved: backend.setVisibility("gesture_angle", value)
}
Controls.Label { text: Math.round(gesture.value) + "° of it" }
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Screens on a wrist" }
RowLayout {
Kirigami.FormData.label: "Show while facing you within:"
Controls.Slider {
id: wrist
from: 20; to: 120; stepSize: 1
value: vpage.v.wrist_angle
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onMoved: backend.setVisibility("wrist_angle", value)
}
Controls.Label { text: Math.round(wrist.value) + "°" }
}
RowLayout {
Kirigami.FormData.label: "All screens:"
Controls.Button {
text: "Pin to left wrist"
enabled: backend.desktopRunning
onClicked: backend.pinAll("left")
}
Controls.Button {
text: "Pin to right wrist"
enabled: backend.desktopRunning
onClicked: backend.pinAll("right")
}
Controls.Button {
text: "Unpin"
enabled: backend.desktopRunning
onClicked: backend.unpinAll()
}
}
}
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
opacity: 0.7
text: "Pin one screen: carry it by its bar and sweep its laser across your other controller. A "
+ "ring shows the target and a dot shows where the laser is; crossing the ring arms the pin (ring "
+ "and bar turn blue), crossing it again disarms it. Turn and place the screen the way you want, "
+ "then let go: it rides on that wrist at that size and distance, however far away. To adjust a "
+ "pinned screen, grab its bar, move it, and let go (it stays pinned); sweep across the ring to "
+ "take it off. It shows while you see its front within the angle above, and fades out beyond "
+ "it. Save current arrangement (Layout) keeps pins."
}
}
}
}
}