diff --git a/README.md b/README.md index b4957ca..2df4a3b 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,12 @@ If you work in the desktop for long stretches, or leave the headset on a stand, | Click the curve button (next to the bar) | Curves the screen around you, or flattens it | | Drag the roll button sideways, or scroll on it | Rolls the screen; it snaps level near straight | | While carrying a screen, sweep its laser across your other controller's ring, then let go | Pins it to that wrist, at its size and distance, as you hold it when you let go; it shows while you see its front. Grab its bar to adjust it (it stays pinned); sweep across the ring again to take it off | +| Set a screen to On your head (Frametop Display Settings, Visibility & pins) | Pins it to your head where it is, like a HUD. Grab its bar to move it; it stays on your head | +| Save current arrangement… (Frametop Display Settings, Layout) | Saves where the screens are, with their sizes and pins, under a name. Pick a saved layout under Arrangement and press Arrange now to switch to it | | Meta+Shift+R in the desktop | Puts the screens back in their layout (also in the menu as Reset Screen Layout, and mappable to a mouse button) | -| Meta+Shift+H in the desktop | Hides or shows all screens (also in the menu as Hide/Show Screens, and mappable). The Visibility & wrist tab of Frametop Display Settings can instead show them only with the dashboard open, or while you look at your wrist | +| Meta+Shift+H in the desktop | Hides or shows all screens (also in the menu as Hide/Show Screens, and mappable). The Visibility & pins tab of Frametop Display Settings can instead show them only with the dashboard open, or while you look at your wrist | | Leave the headset on a stand | Its displays turn off once it has gone unused for the time set in Frametop Display Settings → Power, even if the stand covers its proximity sensor. Pick it up, or use any mouse, keyboard, or button, and they come back on | -| Play a VR game | The screens hide and your controllers stay in the game. Open the SteamVR dashboard, or press Meta+Shift+H, to see and use them. To keep them visible over games, change During VR games on the Visibility & wrist tab; the controllers still stay in the game, and you use the screens with the mouse or the dashboard | +| Play a VR game | The screens hide and your controllers stay in the game. Open the SteamVR dashboard, or press Meta+Shift+H, to see and use them. To keep them visible over games, change During VR games on the Visibility & pins tab; the controllers still stay in the game, and you use the screens with the mouse or the dashboard | You can map the mouse's extra buttons to actions such as Toggle SteamVR dashboard, Recenter pointer, or Head follow on/off on the Buttons page of Frametop Input Settings, and the Frame controllers' buttons on its Controllers page. Pointer speed, dot size, and the rest are on its Pointer page and take effect immediately. Head follow, which is experimental and off by default, makes the pointer come along when you turn your head: it stays put until your head turns past the leash angle, then glides back to its place in your view, and a leash of 0 keeps it fixed in your view. It's only lightly tested and not polished; tuning its settings, or improving how it feels, is open to anyone who wants to take it further. @@ -76,7 +78,7 @@ This is an early release, tested on one Steam Frame (SteamOS 0.3.0 build 2026092 - A SteamOS or SteamVR update can break parts of it until Frametop catches up. If something stops working after an update, please report it. - The first install downloads 1–2 GB for the build container and compiles everything on the headset, which takes several minutes. - During a VR game you can't show the screens with a controller button, because the game owns the buttons. Open the SteamVR dashboard, press Meta+Shift+H, or use a mapped mouse button instead. -- Flatscreen games aren't detected as games. If your controllers end up working the screens instead of the game, set Controllers on the screens to "Only with the SteamVR dashboard open" (Frametop Display Settings, Visibility & wrist tab). +- Flatscreen games aren't detected as games. If your controllers end up working the screens instead of the game, set Controllers on the screens to "Only with the SteamVR dashboard open" (Frametop Display Settings, Visibility & pins tab). - Typing follows your last click. A controller click on a panel other than the screens (the dashboard, a Steam app) doesn't move typing there; click it with the mouse, or click a screen to bring typing back. - The screens don't draw a mouse cursor of their own. The 3D mouse's dot or SteamVR's laser shows where you're pointing. - On SteamVR's Settings page, the 3D mouse shows a laser beam and a larger hit dot, like a controller. SteamVR doesn't tell other programs where that page is (unlike Steam's pages, such as Library), so the mouse used to miss most of it: clicks went through to a desktop screen behind, and the dot disappeared. As a workaround, on that page only, the laser starts near your eye and SteamVR finds the page itself. See docs/design.md. diff --git a/display-settings/ft_display_settings.py b/display-settings/ft_display_settings.py index 9db521d..25b5ad1 100644 --- a/display-settings/ft_display_settings.py +++ b/display-settings/ft_display_settings.py @@ -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): diff --git a/display-settings/main.qml b/display-settings/main.qml index f64a969..8ba0f96 100644 --- a/display-settings/main.qml +++ b/display-settings/main.qml @@ -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." } } } diff --git a/docs/design.md b/docs/design.md index 5b7d478..de4c8af 100644 --- a/docs/design.md +++ b/docs/design.md @@ -42,12 +42,18 @@ Wherever ft-screens needs to know where a laser points (showing the controls, th `ComputeOverlayIntersection` ignores `SetOverlayIntersectionMask`, and a control can't be allowed to cover part of its screen, so the resize tab sits entirely outside the corner. -### Wrist pinning +### Pinning Pinning started as "bring the screen to your wrist", which doesn't work for big screens, because their centre is far from the edge you bring close. It became aiming: while a screen is carried, the line from the carrying device to its bar is tested against the other hand controllers. Crossing a controller's 6 cm ring arms the pin (leaving past 9 cm, so it doesn't flicker), and crossing it again disarms it. The pin happens on release, with the screen's pose at that moment, so you can arm it and then turn the screen. An earlier version pinned the moment the laser touched the wrist, which left the screen at whatever angle the carrying hand had while pointing there. A pinned screen's alpha follows the angle between its front and the direction to your head, fully visible inside the wrist angle and fading over the last 10°. +A head pin is the same pin on the headset (device index 0): the screen's transform is relative to the headset, so SteamVR keeps it rigidly in your view with no lag from us. It skips the facing rule, since a screen on your head always faces you the way it did when pinned. There's no aiming gesture for it: the line from the carrying device can't sensibly pass through your own head, and a ring in front of your face would be in the way. So it's set from Frametop Display Settings or `ft-layout`, and it pins the screen where it is. Carrying a head-pinned screen re-pins it on release, like a wrist pin, so it can be adjusted in VR. + +### Named layouts + +A named layout is the custom arrangement under a name: each screen's pose relative to your head, width, curve, and pin, but not its resolution or scale, which need a desktop restart or belong to KWin. Using one copies it into the custom arrangement, so everything that applies the layout (desktop start, Meta+Shift+R, Arrange now) works unchanged, and `active` remembers which name it came from. Saving without a name (`ft-layout capture`) clears `active`, because the screens have been placed by hand since. Layouts are kept per screen number, so one saved with a different screen count still applies: missing screens keep their last saved place or the preset's. + ### Visibility and VR games `VROverlayFlags_MakeOverlaysInteractiveIfVisible` keeps SteamVR's laser mouse on while an overlay with that flag is visible. Without it, the laser is off whenever the dashboard is closed: the first click on a panel only turns it on, and the laser turns off again as soon as it leaves every panel. With it, controllers work the screens normally, but the laser also takes the controllers away from a VR game. diff --git a/docs/reference.md b/docs/reference.md index a2b0fe2..970d29f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -39,7 +39,9 @@ The controls are sized from both the screen's width and its distance from you, f To pin a screen to a wrist, carry it by its bar and sweep the laser across your other controller. A ring around that controller marks the target, and a dot shows where the laser passes. Crossing the ring arms the pin, and the ring and bar turn blue; crossing it again disarms it. When you let go while armed, the screen rides on that controller at the size, distance, and angle it had, so you can arm the pin first and then turn the screen the way you want. Grab a pinned screen's bar to adjust it; it goes back to the same wrist when you let go unless you disarm it. A pinned screen shows only while you're looking at its front, within the wrist angle, and fades out over the last 10°. -The Visibility & wrist tab of Frametop Display Settings decides when the screens show: +To pin a screen to your head, like a HUD, set it to On your head on the Visibility & pins tab of Frametop Display Settings (or `ft-layout pin N head`). It rides on the headset where it is at that moment, so place it first, and it shows whenever the screens do. Grab its bar to move it; it goes back on your head where you let go. Sweeping across a wrist ring while you carry it moves it to that wrist, and sweeping across again leaves it in the room. The 3D mouse's dot stays in the room, so a head-pinned screen moves away from it when you turn your head, unless head follow is on. + +The Visibility & pins tab of Frametop Display Settings decides when the screens show: - Always. Meta+Shift+H, the Hide/Show Screens menu entry, or a mapped mouse button hides them. - Only while the SteamVR dashboard is open. @@ -59,7 +61,7 @@ ft-screens listens for datagrams on the abstract socket `@ft_screens` and replie ``` place N x y z yaw pitch roll width N metres curve N radius|on|off -pin N|all left|right [matrix] unpin N|all size N w h +pin N|all left|right|head [matrix] unpin N|all size N w h get N screens head state key code value scale N s visibility always|dashboard|gesture|toggle wrist degrees gesture left|right degrees hide | show | toggle controllers always|outside_games|dashboard ingames hide|visible @@ -119,13 +121,13 @@ Device rules are saved in `~/.config/frametop-input.json`. `input-settings/insta When the desktop starts, its screens arrange themselves around where you're facing. You can move them by hand at any time and put them back with Meta+Shift+R, the Reset Screen Layout menu entry, Arrange now in the app, or a mouse button mapped to Reset desktop screen layout. -The desktop's own screen arrangement follows where the screens are around you, whatever their numbers: a screen you see to the left of another is to its left in Plasma too, so the pointer and dragged windows cross straight to it. Screens one above the other stack, and screens pinned to a wrist come last. It's updated at startup, after arranging or saving the layout, and half a second after you let go of a screen you moved. With the headset off there's no head pose to go by, and the arrangement stays as it was. +The desktop's own screen arrangement follows where the screens are around you, whatever their numbers: a screen you see to the left of another is to its left in Plasma too, so the pointer and dragged windows cross straight to it. Screens one above the other stack, and screens pinned to a wrist or your head come last. It's updated at startup, after arranging or saving the layout, and half a second after you let go of a screen you moved. With the headset off there's no head pose to go by, and the arrangement stays as it was. -Frametop Display Settings has four tabs (three with the gamescope backend, which has no Visibility & wrist): +Frametop Display Settings has four tabs (three with the gamescope backend, which has no Visibility & pins): - Screens: add and remove screens, and set each one's resolution (presets from 1080p to 4K, ultrawide, super ultrawide, portrait, or custom), its width in VR (0.5 to 6 m), its scale, whether it's curved, and whether it has the taskbar. Resolution, width, and curve apply at once. Adding or removing a screen takes a desktop restart, which the app offers. -- Layout: a curve around you, with the screens hinged edge to edge like monitors on a desk and each turned to face you, or a flat wall. Both take rows, distance, gap, and height. Save current arrangement keeps the positions and sizes you set by hand instead. A preview shows the layout from above and from the front, and a switch turns auto-arrange at startup on or off. -- Visibility & wrist: the visibility, game, and controller settings described above, the wrist angle, and buttons to pin all screens to a wrist or unpin them. +- Layout: a curve around you, with the screens hinged edge to edge like monitors on a desk and each turned to face you, or a flat wall. Both take rows, distance, gap, and height. Save current arrangement saves the positions, sizes, curves, and pins you set by hand under a name instead. Named layouts are listed with the presets: pick one and Arrange now to switch to it, and rename or delete it with the buttons next to the list. A layout saved with fewer screens than you have now leaves the others where they were saved last, or where the preset would put them. A preview shows the layout from above and from the front, and a switch turns auto-arrange at startup on or off. +- Visibility & pins: the visibility, game, and controller settings described above, the wrist angle, where each screen is pinned (in the room, a wrist, or your head), and buttons to pin all screens or unpin them. - Power: when the displays turn off while the headset isn't used, their state now, Turn displays off now (to try it), and Stay awake while plugged in. See [Displays off and sleep](#displays-off-and-sleep). `layout/ft-layout` does the arranging. It's a Python script that uses only the standard library and runs on the host: @@ -133,6 +135,10 @@ Frametop Display Settings has four tabs (three with the gamescope backend, which ``` layout/ft-layout apply # arrange every screen layout/ft-layout capture # save the current arrangement and sizes as the layout +layout/ft-layout save NAME # ...under a name too, and use it +layout/ft-layout use NAME # switch to a named layout and arrange the screens in it +layout/ft-layout layouts # list the named layouts (* = in use); rename OLD NEW, delete NAME +layout/ft-layout pin N|all left|right|head # pin as they are now; unpin N|all layout/ft-layout plan # print the arrangement as JSON (no VR needed) layout/ft-layout scale # per-screen scale, positions (as the screens are around you), and taskbar screen, to KWin layout/ft-layout toggle # hide or show all screens @@ -174,7 +180,6 @@ With remote access on, the nested KWin runs with `KWIN_WAYLAND_NO_PERMISSION_CHE ## Limits -- There's no way yet to pin a screen to your head like a HUD. - A controller button can't show hidden screens; a mapped mouse or keyboard button can. - KWin's cursor isn't drawn on the screens, because KWin draws it as a host cursor, which ft-screens doesn't render. The 3D mouse's dot and SteamVR's laser dot show where you're pointing. - The old gamescope backend (`BACKEND=gamescope`) still works, but it gives every screen the same resolution, at most 1920×1080 pixels' worth, and arranging screens borrows the pointer for a few seconds. diff --git a/layout/ft_layout.py b/layout/ft_layout.py index d37aaa9..601a817 100755 --- a/layout/ft_layout.py +++ b/layout/ft_layout.py @@ -15,6 +15,9 @@ you face (yaw only), like a recenter. It lives in ~/.config/frametop-layout.json "mode": "preset" | "custom", "preset": {"kind": "arc" | "flat", "rows": 1, "distance": 2.0, "gap": 0.05, "height": 0}, "primary": 2, the screen with the taskbar (1-based; default: the biggest) + "layouts": {"Work": [{"pos": ..., "face": ..., "roll": ..., "metres": ..., "curve": ..., + "pin": ...}, ...]}, named layouts: each screen's place (SPATIAL) + "active": "Work", the named layout the custom arrangement came from "visibility": {"mode": "always", ft-screens: always | dashboard (only with the SteamVR "wrist_angle": 60, dashboard open) | gesture (while you look at a controller) "gesture_hand": "left", "gesture_angle": 20}, | toggle (hidden until shown); @@ -23,6 +26,7 @@ you face (yaw only), like a recenter. It lives in ~/.config/frametop-layout.json "screens": [{"size": [w, h], "metres": 3.6, ft-screens: pixels, and width in VR "curve": 0, ft-screens: cylinder radius in metres, 0 = flat "pin": {"hand": "left", "rel": [12]}, ft-screens: riding on that controller + (left | right), or on the headset (head) "scale": 1.0, KWin output scale (1.0 = 100%) "pos": [x, y, z], "face": [yaw, pitch], "roll": 0, custom layout "rotation": "normal" | "left" | "right"}, ...], gamescope only @@ -38,12 +42,17 @@ Usage (on the Frame host; Frametop Display Settings calls it too): ft-layout apply [--wait SECONDS] arrange every screen; --wait is for desktop start: wait for the screens, skip if "auto" is off ft-layout capture save the current arrangement as the custom layout + ft-layout save NAME save it as a named layout too, and use that + ft-layout use NAME switch to a named layout and arrange the screens in it + ft-layout layouts list the named layouts (* = the one in use) + ft-layout rename OLD NEW | delete NAME ft-layout plan print the arrangement as JSON (no VR needed) ft-layout scale per-screen scale, positions (as the screens are around you), and primary to KWin ft-layout screen-args ft-screens' --screen arguments for the session script ft-layout toggle hide or show all screens (ft-screens) - ft-layout pin all|N left|right pin screens to a wrist as they are; unpin all|N + ft-layout pin all|N left|right|head pin screens to a wrist or your head as they are; + unpin all|N """ import fcntl import json @@ -68,6 +77,7 @@ PIXELS_PER_METRE = 800 # ft-screens: a new screen's default size in VR ( # or leaves them up (visible). VISIBILITY = {"mode": "always", "wrist_angle": 60, "gesture_hand": "left", "gesture_angle": 20, "controllers": "outside_games", "in_games": "hide"} +SPATIAL = ("pos", "face", "roll", "metres", "curve", "pin") # what a named layout keeps of a screen DEFAULTS = {"auto": True, "mode": "preset", "preset": {"kind": "arc", "rows": 1, "distance": 2.0, "gap": 0.05, "height": 0.0}, "screens": [], "panel_size": list(DEFAULT_PANEL)} @@ -574,7 +584,82 @@ def apply(wait=0): def capture(): - return capture_screens() if backend() == "screens" else capture_gamescope() + """Where the screens are now as the custom layout. It's no longer a named one's until + `save NAME` (placed by hand since).""" + screens = capture_screens() if backend() == "screens" else capture_gamescope() + layout = load_layout() + if layout.pop("active", None) is not None: + save_layout(layout) + return screens + + +# ---------------------------------------------------------------- named layouts + +def layout_names(layout): + return sorted(layout.get("layouts", {}), key=str.casefold) + + +def check_name(name): + name = " ".join(name.split()) + if not name or len(name) > 40: + raise RuntimeError("a layout's name needs 1 to 40 characters") + return name + + +def save_named(layout, name): + """The custom arrangement (as captured) under `name`, replacing one of that name, and + in use.""" + name = check_name(name) + screens = [s for s in layout.get("screens", [])[:screen_count(layout)] if "pos" in s] + if not screens: + raise RuntimeError("nothing to save: no arrangement captured") + layout.setdefault("layouts", {})[name] = [{k: s[k] for k in SPATIAL if k in s} for s in screens] + layout["mode"], layout["active"] = "custom", name + return name + + +def use_named(layout, name): + """Make a named layout the custom arrangement (not arranged yet). A layout saved with + fewer screens leaves the others where the preset would put them, or where they were + saved last; one saved with more keeps its extra screens for later.""" + saved = layout.get("layouts", {}).get(name) + if saved is None: + raise RuntimeError(f"no layout called {name!r}") + count = screen_count(layout) + preset = plan(dict(layout, mode="preset"), count) + screens = layout.setdefault("screens", []) + while len(screens) < count: + screens.append({}) + for i in range(count): + if i < len(saved): + for k in SPATIAL: + screens[i].pop(k, None) + screens[i].update(json.loads(json.dumps(saved[i]))) + elif "pos" not in screens[i]: + screens[i].update({"pos": list(preset[i]["pos"]), "face": list(preset[i]["face"]), + "roll": preset[i]["roll"]}) + layout["mode"], layout["active"] = "custom", name + + +def rename_named(layout, old, new): + new = check_name(new) + named = layout.get("layouts", {}) + if old not in named: + raise RuntimeError(f"no layout called {old!r}") + if new != old and new in named: + raise RuntimeError(f"there's already a layout called {new!r}") + named[new] = named.pop(old) + if layout.get("active") == old: + layout["active"] = new + return new + + +def delete_named(layout, name): + """The screens stay where the layout put them, as an unnamed custom arrangement.""" + if layout.get("layouts", {}).pop(name, None) is None: + raise RuntimeError(f"no layout called {name!r}") + if layout.get("active") == name: + layout.pop("active") # ---------------------------------------------------------------- KWin (scale, positions, primary) @@ -737,10 +822,21 @@ def main(argv): print(screen_args()) elif cmd == "toggle": log(screens_socket().ask("toggle")) + elif cmd == "layouts": + layout = load_layout() + for name in layout_names(layout): + print(("* " if name == layout.get("active") and layout.get("mode") == "custom" else " ") + name) + elif cmd in ("rename", "delete") and len(argv) == (4 if cmd == "rename" else 3): + layout = load_layout() + if cmd == "rename": + rename_named(layout, argv[2], argv[3]) + else: + delete_named(layout, argv[2]) + save_layout(layout) elif cmd in ("pin", "unpin") and len(argv) >= 3: log(screens_socket().ask(" ".join(argv[1:]))) kwin_follow() # pinned screens go last - elif cmd in ("apply", "capture", "scale"): + elif cmd in ("apply", "capture", "scale") or (cmd in ("save", "use") and len(argv) == 3): with open(LOCK_PATH, "w") as lock: try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) @@ -779,6 +875,24 @@ def main(argv): for i, s in enumerate(capture()): log(f"screen {i + 1}: {s}") kwin_follow() + elif cmd == "save": + check_name(argv[2]) + capture() + layout = load_layout() + log(f"saved layout {save_named(layout, argv[2])!r}") + save_layout(layout) + kwin_follow() + elif cmd == "use": + layout = load_layout() + use_named(layout, argv[2]) + save_layout(layout) + log(f"using layout {argv[2]!r}") + try: + apply() + except RuntimeError as e: + log(f"not arranged now: {e}") + else: + kwin_follow() else: changes = apply_scales() log("kwin: " + (" ".join(changes) if changes else "unchanged")) diff --git a/screens/vr.cpp b/screens/vr.cpp index 9fbb63c..264bea0 100644 --- a/screens/vr.cpp +++ b/screens/vr.cpp @@ -23,6 +23,11 @@ // screen keeps it armed for its wrist: move it, let go, and it's re-pinned there // (sweep across the ring to take it off). A pinned screen shows only while you see // its front, within the wrist angle (and fades out over the last kFade degrees). +// - pin to your head (the pin command, from ft-layout and Frametop Display Settings): the +// screen rides on the headset as it is then, like a HUD, and shows whenever the +// screens do. Carrying it works like a wrist pin: let go and it's re-pinned to your +// head where you put it; sweep across a wrist ring to move it to that wrist, or twice +// to leave it in the room. // - visibility modes: always (the hide hotkey toggles), only with the SteamVR dashboard // open, while you look at a chosen controller (the wrist gesture), or toggle only // (hidden until the hotkey shows them). @@ -169,11 +174,14 @@ bool IsHandController(vr::TrackedDeviceIndex_t i) { vr::VRSystem()->GetStringTrackedDeviceProperty(i, vr::Prop_ControllerType_String, type, sizeof type); return std::strcmp(type, "ft_pointer") != 0; // not the 3D mouse's virtual controller } +// "left", "right", or "head" (the headset) -> the device to pin to. vr::TrackedDeviceIndex_t HandDevice(const char *hand) { + if (std::strcmp(hand, "head") == 0) return vr::k_unTrackedDeviceIndex_Hmd; return vr::VRSystem()->GetTrackedDeviceIndexForControllerRole( std::strcmp(hand, "right") == 0 ? vr::TrackedControllerRole_RightHand : vr::TrackedControllerRole_LeftHand); } const char *HandName(vr::TrackedDeviceIndex_t i) { + if (i == vr::k_unTrackedDeviceIndex_Hmd) return "head"; switch (vr::VRSystem()->GetControllerRoleForTrackedDeviceIndex(i)) { case vr::TrackedControllerRole_LeftHand: return "left"; case vr::TrackedControllerRole_RightHand: return "right"; @@ -658,7 +666,8 @@ void UpdateVisibility() { bool visible = s.shown && (shared || s.drag != Drag::None); float alpha = 1; Mat p; - if (visible && s.pinned != kNone && s.drag == Drag::None && haveHead && ScreenPose(s, &p)) { + if (visible && s.pinned != kNone && s.pinned != vr::k_unTrackedDeviceIndex_Hmd && s.drag == Drag::None && + haveHead && ScreenPose(s, &p)) { // A pinned screen shows while you see its front: fully inside the wrist angle, // fading out over the last kFade degrees, gone beyond it (and from behind). const double a = FacingAngle(p, head); @@ -896,7 +905,8 @@ void FinishDrag(Screen &s, int index) { ArrangeDesktopSoon(); if (target != kNone && DevicePose(target, &c) && ScreenPose(s, &p)) { Pin(s, target, Mul(Inverse(c), p)); - std::printf("screen %d: pinned to the %s controller\n", index + 1, HandName(target)); + if (target == vr::k_unTrackedDeviceIndex_Hmd) std::printf("screen %d: pinned to the head\n", index + 1); + else std::printf("screen %d: pinned to the %s controller\n", index + 1, HandName(target)); } } @@ -1273,11 +1283,12 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) { // width // curve cylinder radius in metres; 0 = flat // curve on|off on: the radius is the head's distance to it now -> "ok " -// pin [12 numbers] pin to that hand's controller: as it is now, -// or at the given controller->screen transform (rows of a 3x4) +// pin [12 numbers] pin to that hand's controller or the +// headset: as it is now, or at the given device->screen transform +// (rows of a 3x4) // unpin -// get -> "ok x y z xx xy xz yx yy yz zx zy zz width height curve hand -// [12 numbers: controller->screen, when pinned]" +// get -> "ok x y z xx xy xz yx yy yz zx zy zz width height curve pin +// [12 numbers: device->screen, when pinned]" (pin: none|left|right|head) // screens -> "ok :x: ..." // head -> "ok x y z yaw" // visibility always|dashboard|gesture|toggle @@ -1333,8 +1344,12 @@ void ft_vr_command(const char *cmd, char *reply, int size) { &r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9], &r[10], &r[11]); got >= 2) { + if (std::strcmp(hand, "left") && std::strcmp(hand, "right") && std::strcmp(hand, "head")) + return (void)std::snprintf(reply, size, "error pin to left, right, or head"); const vr::TrackedDeviceIndex_t dev = HandDevice(hand); Mat c; + if (dev == vr::k_unTrackedDeviceIndex_Hmd && !DevicePose(dev, &c)) + return (void)std::snprintf(reply, size, "error no head pose (headset off?)"); if (dev == kNone || !DevicePose(dev, &c)) return (void)std::snprintf(reply, size, "error no %s controller tracked", hand); Mat rel = Identity();