mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 02:00:06 +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
+16
@@ -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;
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Executable
+27
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user