Merge branch 'layouts-headpin' into experimental

# Conflicts:
#	README.md
#	display-settings/ft_display_settings.py
#	display-settings/main.qml
#	docs/reference.md
This commit is contained in:
DeeJanuz committed 2026-09-29 10:24:30 -06:00
commit ed9542d3b8
7 files changed
+400 -63

No files matched your search

+73 -19
View File
@@ -10,10 +10,12 @@ the dev container:
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.
angle within which a pinned screen shows, and pinning each screen to a wrist or
your head.
- Layout: a preset (curved or flat, rows, distance, gap, height) or a named layout
saved from where the screens are, with a preview; arrange now; save the current
arrangement under a name; rename and delete; arrange automatically when the
desktop starts.
- Power: how long the headset can go unused before ft-powerd turns its displays off
(DISPLAY_OFF_MIN; the service's state comes from its control socket, @ft_powerd),
and whether the Frame stays awake while plugged in, which is Steam's own setting
@@ -115,6 +117,7 @@ class Backend(QObject):
self._steam_error = ""
self._steam_busy = False
self._steamDone.connect(self._steam_done, Qt.QueuedConnection)
self._pins = [] # each running screen's pin: none | left | right | head
self.poll = QTimer(interval=3000, timeout=self._check_running)
self.poll.start()
self._check_running()
@@ -135,13 +138,24 @@ class Backend(QObject):
# 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:
pins = self._read_pins(count)
if running != self._running or count != self._running_count or pins != self._pins:
if running != self._running or count != self._running_count:
self._started = self._conf() if running else {}
self._running = running
self._running_count = count
self._started = self._conf() if running else {}
self._pins = pins
self.changed.emit()
self._check_powerd()
def _read_pins(self, count):
pins = []
for i in range(count):
reply = self._ask_screens(f"get {i + 1}")
f = reply.split() if reply and reply.startswith("ok") else []
pins.append(f[16] if len(f) > 16 else "none")
return pins
def _ask_screens(self, text):
"""Request/reply to ft-screens; None if it isn't running."""
try:
@@ -391,20 +405,23 @@ class Backend(QObject):
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)
@Property("QVariantList", notify=changed)
def pins(self):
return self._pins
@Slot()
def unpinAll(self):
reply = self._ask_screens("unpin all") if self._running else None
@Slot(str, str)
def pin(self, which, where):
"""Pin screen `which` (1-based, or "all") to "left", "right", or "head" as it is
now, or take it off ("none")."""
cmd = f"unpin {which}" if where == "none" else f"pin {which} {where}"
reply = self._ask_screens(cmd) 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)
self.message.emit(f"Couldn't {'unpin' if where == 'none' else 'pin'}: "
f"{reply or 'the desktop is not running'}", True)
elif which == "all" and where != "none":
place = "on your head" if where == "head" else f"on your {where} wrist"
self.message.emit(f"All screens ride {place} now. Save current arrangement (Layout) keeps it.", False)
self._check_running()
# --- power: ft-powerd and Steam's sleep setting ---
def _check_powerd(self):
@@ -513,7 +530,44 @@ class Backend(QObject):
@Slot(str)
def setMode(self, mode):
self._edit_layout(lambda l: l.__setitem__("mode", mode))
def edit(layout):
layout["mode"] = mode
layout.pop("active", None)
self._edit_layout(edit)
@Property("QVariantList", notify=changed)
def layoutNames(self):
return ft_layout.layout_names(ft_layout.load_layout())
@Slot(str)
def useLayout(self, name):
"""A named layout as the arrangement (Arrange now puts the screens there)."""
try:
self._edit_layout(lambda l: ft_layout.use_named(l, name))
except RuntimeError as e:
self.message.emit(str(e), True)
@Slot(str)
def saveLayout(self, name):
try:
ft_layout.check_name(name)
except RuntimeError as e:
return self.message.emit(str(e), True)
self._run(f"Saving the arrangement as {' '.join(name.split())}", "save", name)
@Slot(str, str)
def renameLayout(self, old, new):
try:
self._edit_layout(lambda l: ft_layout.rename_named(l, old, new))
except RuntimeError as e:
self.message.emit(str(e), True)
@Slot(str)
def deleteLayout(self, name):
try:
self._edit_layout(lambda l: ft_layout.delete_named(l, name))
except RuntimeError as e:
self.message.emit(str(e), True)
@Slot(str, "QVariant")
def setPreset(self, key, value):
+165 -24
View File
@@ -14,7 +14,7 @@ Kirigami.ApplicationWindow {
readonly property var pages: backend.backend === "screens"
? [{ name: "screens", text: "Screens", icon: "video-display", page: screensPage },
{ name: "layout", text: "Layout", icon: "view-grid", page: layoutPage },
{ name: "visibility", text: "Visibility & wrist", icon: "view-visible", page: visibilityPage },
{ name: "visibility", text: "Visibility & pins", icon: "view-visible", page: visibilityPage },
{ name: "power", text: "Power", icon: "preferences-system-power-management", page: powerPage }]
: [{ name: "screens", text: "Screens", icon: "video-display", page: screensPage },
{ name: "layout", text: "Layout", icon: "view-grid", page: layoutPage },
@@ -76,6 +76,89 @@ Kirigami.ApplicationWindow {
]
}
// Save the arrangement under a name, or rename a saved layout.
Kirigami.PromptDialog {
id: nameDialog
property string mode: "save" // save | rename
property string oldName: ""
readonly property var names: backend.layoutNames
readonly property string name: nameField.text.trim().split(/\s+/).join(" ")
readonly property bool taken: name !== oldName && names.indexOf(name) >= 0
readonly property bool ok: name !== "" && !(mode === "rename" && taken)
title: mode === "save" ? "Save the arrangement" : "Rename " + oldName
standardButtons: Kirigami.Dialog.NoButton
function openFor(m, text) {
mode = m
oldName = m === "rename" ? text : ""
nameField.text = text
open()
nameField.forceActiveFocus()
nameField.selectAll()
}
function accept() {
if (!ok) return
close()
if (mode === "save") backend.saveLayout(name)
else if (name !== oldName) backend.renameLayout(oldName, name)
}
ColumnLayout {
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
text: nameDialog.mode === "save"
? "Where the screens are now, with their sizes, curves, and pins, under this name:"
: "New name:"
}
Controls.TextField {
id: nameField
Layout.fillWidth: true
maximumLength: 40
onAccepted: nameDialog.accept()
}
Controls.Label {
visible: nameDialog.taken
opacity: 0.7
text: nameDialog.mode === "save" ? "Replaces the saved layout with that name."
: "There's already a layout with that name."
}
}
customFooterActions: [
Kirigami.Action {
text: nameDialog.mode === "save" ? "Save" : "Rename"
icon.name: nameDialog.mode === "save" ? "document-save" : "edit-rename"
enabled: nameDialog.ok
onTriggered: nameDialog.accept()
},
Kirigami.Action {
text: "Cancel"
icon.name: "dialog-cancel"
onTriggered: nameDialog.close()
}
]
}
Kirigami.PromptDialog {
id: deleteDialog
property string name: ""
title: "Delete " + name + "?"
subtitle: "The screens stay where they are; only the saved layout goes."
standardButtons: Kirigami.Dialog.NoButton
customFooterActions: [
Kirigami.Action {
text: "Delete"
icon.name: "edit-delete"
onTriggered: { deleteDialog.close(); backend.deleteLayout(deleteDialog.name) }
},
Kirigami.Action {
text: "Cancel"
icon.name: "dialog-cancel"
onTriggered: deleteDialog.close()
}
]
}
// ---------------------------------------------------------------- Screens
Component {
id: screensPage
@@ -345,6 +428,15 @@ Kirigami.ApplicationWindow {
property var layout: backend.layout
property var preset: layout.preset || {}
property bool hasCustom: (layout.screens || []).some(s => s.pos !== undefined)
// Named layouts: the arrangement is one of them (named) when it came from it, and
// hasn't been placed by hand and saved without a name since.
property var names: backend.layoutNames
property bool fromNamed: names.indexOf(layout.active) >= 0
property bool named: layout.mode === "custom" && fromNamed
property bool unnamed: names.length === 0 || ((hasCustom || layout.mode === "custom") && !fromNamed)
property var choices: [{ text: "Curved around you", value: "arc" }, { text: "Flat wall", value: "flat" }]
.concat(names.map(n => ({ text: n, value: "layout:" + n })))
.concat(unnamed ? [{ text: names.length ? "Unnamed arrangement" : "Saved arrangement", value: "custom" }] : [])
actions: [
Kirigami.Action {
@@ -355,11 +447,12 @@ Kirigami.ApplicationWindow {
onTriggered: backend.arrange()
},
Kirigami.Action {
text: "Save current arrangement"
text: "Save current arrangement…"
icon.name: "document-save"
tooltip: "Use where the screens are now (placed by hand) as the layout"
tooltip: "Save where the screens are now (placed by hand) as a named layout, and use it"
enabled: backend.desktopRunning && backend.busy === ""
onTriggered: backend.capture()
onTriggered: nameDialog.openFor("save", lpage.named ? lpage.layout.active
: "Layout " + (lpage.names.length + 1))
}
]
@@ -376,27 +469,49 @@ Kirigami.ApplicationWindow {
Kirigami.FormLayout {
Layout.fillWidth: true
Controls.ComboBox {
RowLayout {
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.ComboBox {
model: lpage.choices
textRole: "text"
valueRole: "value"
currentIndex: lpage.layout.mode !== "custom" ? (lpage.preset.kind === "flat" ? 1 : 0)
: lpage.named ? 2 + lpage.names.indexOf(lpage.layout.active)
: lpage.choices.length - 1
onActivated: {
if (currentValue === "custom") backend.setMode("custom")
else if (currentValue.startsWith("layout:")) backend.useLayout(currentValue.slice(7))
else backend.setPreset("kind", currentValue)
}
}
Controls.ToolButton {
visible: lpage.named
icon.name: "edit-rename"
text: "Rename…"
display: Controls.AbstractButton.IconOnly
Controls.ToolTip.text: text
Controls.ToolTip.visible: hovered
onClicked: nameDialog.openFor("rename", lpage.layout.active)
}
Controls.ToolButton {
visible: lpage.named
icon.name: "edit-delete"
text: "Delete…"
display: Controls.AbstractButton.IconOnly
Controls.ToolTip.text: text
Controls.ToolTip.visible: hovered
onClicked: { deleteDialog.name = lpage.layout.active; deleteDialog.open() }
}
}
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."
text: lpage.named ? "Where the screens were when you saved it. Arrange now puts them there. "
+ "Save current arrangement updates it or saves a new one."
: lpage.hasCustom ? "Where the screens were when you saved. Save current arrangement "
+ "names it. 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
@@ -687,10 +802,28 @@ Kirigami.ApplicationWindow {
}
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Screens on a wrist" }
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Pinned screens" }
Repeater {
model: backend.pins
delegate: Controls.ComboBox {
required property var modelData
required property int index
Kirigami.FormData.label: "Screen " + (index + 1) + ":"
model: [
{ text: "In the room", value: "none" },
{ text: "On the left wrist", value: "left" },
{ text: "On the right wrist", value: "right" },
{ text: "On your head", value: "head" }
]
textRole: "text"
valueRole: "value"
currentIndex: Math.max(0, ["none", "left", "right", "head"].indexOf(modelData))
onActivated: backend.pin(String(index + 1), currentValue)
}
}
RowLayout {
Kirigami.FormData.label: "Show while facing you within:"
Kirigami.FormData.label: "Wrist screens show within:"
Controls.Slider {
id: wrist
from: 20; to: 120; stepSize: 1
@@ -705,17 +838,22 @@ Kirigami.ApplicationWindow {
Controls.Button {
text: "Pin to left wrist"
enabled: backend.desktopRunning
onClicked: backend.pinAll("left")
onClicked: backend.pin("all", "left")
}
Controls.Button {
text: "Pin to right wrist"
enabled: backend.desktopRunning
onClicked: backend.pinAll("right")
onClicked: backend.pin("all", "right")
}
Controls.Button {
text: "Pin to head"
enabled: backend.desktopRunning
onClicked: backend.pin("all", "head")
}
Controls.Button {
text: "Unpin"
enabled: backend.desktopRunning
onClicked: backend.unpinAll()
onClicked: backend.pin("all", "none")
}
}
}
@@ -730,7 +868,10 @@ Kirigami.ApplicationWindow {
+ "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."
+ "it.\n\nPin a screen to your head: choose On your head above. It rides on the headset where it "
+ "is now, like a HUD, and shows whenever the screens do. Grab its bar to move it; it stays on "
+ "your head where you let go. Choosing a pin above keeps the screen where it is now, so place "
+ "it first. Save current arrangement (Layout) keeps pins."
}
}
}