mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 01:00:06 +02:00
Add named layouts and a head pin for screens
Named layouts: Save current arrangement in Frametop Display Settings now asks for a name, and saved layouts are listed with the presets under Arrangement, with rename and delete next to the list. A named layout is the custom arrangement under a name: each screen's place relative to your head, width, curve, and pin, but not resolution or scale. Using one copies it into the custom arrangement, so desktop start, Meta+Shift+R, and Arrange now apply it unchanged; "active" remembers the name, and a plain capture clears it. ft-layout gains save, use, layouts, rename, and delete. A layout saved with fewer screens than there are now leaves the others where they were saved last, or where the preset puts them. Head pin: ft-screens' pin command takes "head" as well as left and right, and pins the screen to the headset (device 0) where it is, like a HUD. A head-pinned screen skips the wrist facing rule and shows whenever the screens do. Carrying it re-pins it to the head on release, like a wrist pin, so it can be adjusted in VR. The Visibility tab (now Visibility & pins) sets each screen's pin: in the room, either wrist, or your head, and the pin command now rejects anything but left, right, or head (it used to take anything else as left). This removes the "no HUD" limit from the docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f01c84f9a3
commit
0be802c7e1
7 files changed
+399
-62
No files matched your search
@@ -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.
|
||||
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).
|
||||
@@ -95,6 +97,7 @@ class Backend(QObject):
|
||||
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._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()
|
||||
@@ -115,12 +118,23 @@ 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()
|
||||
|
||||
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:
|
||||
@@ -370,20 +384,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()
|
||||
|
||||
@Slot()
|
||||
def restartDesktop(self):
|
||||
@@ -401,7 +418,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
@@ -14,7 +14,7 @@ Kirigami.ApplicationWindow {
|
||||
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: "Visibility & pins", icon: "view-visible", page: visibilityPage }]
|
||||
: [{ text: "Screens", icon: "video-display", page: screensPage },
|
||||
{ text: "Layout", icon: "view-grid", page: layoutPage }]
|
||||
|
||||
@@ -66,6 +66,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
|
||||
@@ -335,6 +418,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 {
|
||||
@@ -345,11 +437,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))
|
||||
}
|
||||
]
|
||||
|
||||
@@ -366,27 +459,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
|
||||
@@ -677,10 +792,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
|
||||
@@ -695,17 +828,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -720,7 +858,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user