mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 01:00:06 +02:00
Merge screen-hide and profiles into experimental
This commit is contained in:
commit
079390cde4
13 files changed
+782
-79
No files matched your search
@@ -52,11 +52,13 @@ If you work in the desktop for long stretches, or leave the headset on a stand,
|
||||
| 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 |
|
||||
| Save as profile… (Frametop Display Settings, Layout & profiles) | Saves where the screens are, with their sizes and pins, which ones are hidden, and the open apps and where their windows are (on a screen or floating), under a name. Pick it under Arrangement and press Open profile to switch to it: the screens move, open windows of its apps go to their places, and the apps that aren't open start. Nothing closes |
|
||||
| Pick a profile under Start in profile, or run its entry (Frametop: NAME) from SteamVR's Launch a program list | The desktop starts in that profile, or switches to it if it's running. A profile can also go on a key combination, mouse button, or controller button in Frametop Input Settings |
|
||||
| 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+F over a desktop window | Floats that window in VR as a panel of its own, or puts it back on its screen if it floats. It acts on the window under the pointer, or the active one if the pointer is over the wallpaper. Rebind it, or map it to a mouse or controller button, in Frametop Input Settings (Keyboard page, or Buttons and Controllers as Float window in VR). Float in VR is also in every window's menu (Alt+F3), and the button left of Close in a window's title bar does the same (apps that draw their own title bar, like Chromium and Electron apps, don't have it) |
|
||||
| Right-click an app in the Application Launcher (or the taskbar) and pick Launch as Standalone | Starts the app with its window floating in VR, where that app last floated, or in front of you the first time. From a terminal: `float/ft-float launch org.kde.dolphin`, or `float/ft-float run <command>` |
|
||||
| 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 |
|
||||
| Switch a screen to Hidden (Frametop Display Settings, Visibility & pins → Screens shown) | Hides just that screen until you switch it back, whatever the other visibility settings say; new windows that would open on it float instead |
|
||||
| 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 & pins tab; the controllers still stay in the game, and you use the screens with the mouse or the dashboard |
|
||||
|
||||
|
||||
@@ -409,6 +409,31 @@ class Backend(QObject):
|
||||
def pins(self):
|
||||
return self._pins
|
||||
|
||||
@Property("QVariantList", notify=changed)
|
||||
def screensShown(self):
|
||||
"""For each screen, whether it shows (False: hidden on its own, ft-layout hide N)."""
|
||||
layout = ft_layout.load_layout()
|
||||
return [not ft_layout.screen_entry(layout, i).get("hidden") for i in range(ft_layout.screen_count(layout))]
|
||||
|
||||
@Slot(int, bool)
|
||||
def setScreenShown(self, index, shown):
|
||||
"""Hide screen `index` (0-based) on its own, whatever the visibility mode, or show it."""
|
||||
layout = ft_layout.load_layout()
|
||||
screens = layout.setdefault("screens", [])
|
||||
while len(screens) <= index:
|
||||
screens.append({})
|
||||
if shown:
|
||||
screens[index].pop("hidden", None)
|
||||
else:
|
||||
screens[index]["hidden"] = True
|
||||
ft_layout.save_layout(layout)
|
||||
self.changed.emit()
|
||||
if self._running:
|
||||
reply = self._ask_screens(f"{'reveal' if shown else 'conceal'} {index + 1}")
|
||||
if not (reply and reply.startswith("ok")):
|
||||
self.message.emit("Saved; the desktop applies it when it next starts "
|
||||
"(its compositor is older than hiding screens one at a time)", False)
|
||||
|
||||
@Slot(str, str)
|
||||
def pin(self, which, where):
|
||||
"""Pin screen `which` (1-based, or "all") to "left", "right", or "head" as it is
|
||||
@@ -559,16 +584,55 @@ class Backend(QObject):
|
||||
def renameLayout(self, old, new):
|
||||
try:
|
||||
self._edit_layout(lambda l: ft_layout.rename_named(l, old, new))
|
||||
except RuntimeError as e:
|
||||
ft_layout.write_launchers(ft_layout.load_layout())
|
||||
except (RuntimeError, OSError) 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:
|
||||
ft_layout.write_launchers(ft_layout.load_layout())
|
||||
except (RuntimeError, OSError) as e:
|
||||
self.message.emit(str(e), True)
|
||||
|
||||
# --- profiles (docs/profiles.md): a named layout's apps and hidden screens ---
|
||||
@Slot(str, result="QVariantList")
|
||||
def profileWindows(self, name):
|
||||
"""A profile's windows, as "app" and "where" for the list."""
|
||||
out = []
|
||||
for e in ft_layout.load_layout().get("profiles", {}).get(name, {}).get("windows", []):
|
||||
app = e.get("app") or os.path.basename((e.get("cmd") or ["?"])[0])
|
||||
app = app.rsplit(".", 1)[-1] if "." in app and not e.get("cmd") else app
|
||||
where = "floating" if "float" in e else f"screen {e.get('screen', 1)}" + (", maximized" if e.get("maximized") else "")
|
||||
out.append({"app": app, "where": where})
|
||||
return out
|
||||
|
||||
@Slot(str, result="QVariantList")
|
||||
def profileHidden(self, name):
|
||||
return ft_layout.load_layout().get("profiles", {}).get(name, {}).get("hidden", [])
|
||||
|
||||
@Slot(str, int)
|
||||
def removeProfileWindow(self, name, index):
|
||||
def edit(layout):
|
||||
windows = layout.get("profiles", {}).get(name, {}).get("windows", [])
|
||||
if 0 <= index < len(windows):
|
||||
windows.pop(index)
|
||||
self._edit_layout(edit)
|
||||
|
||||
@Property(str, notify=changed)
|
||||
def defaultProfile(self):
|
||||
return ft_layout.load_layout().get("default_profile", "")
|
||||
|
||||
@Slot(str)
|
||||
def setDefaultProfile(self, name):
|
||||
def edit(layout):
|
||||
if name:
|
||||
layout["default_profile"] = name
|
||||
else:
|
||||
layout.pop("default_profile", None)
|
||||
self._edit_layout(edit)
|
||||
|
||||
@Slot(str, "QVariant")
|
||||
def setPreset(self, key, value):
|
||||
def edit(layout):
|
||||
@@ -582,7 +646,13 @@ class Backend(QObject):
|
||||
|
||||
@Slot()
|
||||
def arrange(self):
|
||||
self._run("Arranging the screens", "apply")
|
||||
"""Arrange the screens; in a profile, also open its apps (ft-layout use)."""
|
||||
layout = ft_layout.load_layout()
|
||||
name = layout.get("active")
|
||||
if layout.get("mode") == "custom" and name in layout.get("layouts", {}):
|
||||
self._run(f"Opening {name}", "use", name)
|
||||
else:
|
||||
self._run("Arranging the screens", "apply")
|
||||
|
||||
@Slot()
|
||||
def capture(self):
|
||||
|
||||
@@ -424,7 +424,7 @@ Kirigami.ApplicationWindow {
|
||||
id: layoutPage
|
||||
Kirigami.ScrollablePage {
|
||||
id: lpage
|
||||
title: "Layout"
|
||||
title: "Layout & profiles"
|
||||
property var layout: backend.layout
|
||||
property var preset: layout.preset || {}
|
||||
property bool hasCustom: (layout.screens || []).some(s => s.pos !== undefined)
|
||||
@@ -440,16 +440,17 @@ Kirigami.ApplicationWindow {
|
||||
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: "Arrange now"
|
||||
text: lpage.named ? "Open profile" : "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"
|
||||
tooltip: lpage.named ? "Put the screens in this profile's places, around where you're facing, and open its apps (windows already open move; nothing closes)"
|
||||
: "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…"
|
||||
text: "Save as profile…"
|
||||
icon.name: "document-save"
|
||||
tooltip: "Save where the screens are now (placed by hand) as a named layout, and use it"
|
||||
tooltip: "Save where the screens are now, which ones are hidden, and the open apps and where their windows are, under a name"
|
||||
enabled: backend.desktopRunning && backend.busy === ""
|
||||
onTriggered: nameDialog.openFor("save", lpage.named ? lpage.layout.active
|
||||
: "Layout " + (lpage.names.length + 1))
|
||||
@@ -507,16 +508,61 @@ Kirigami.ApplicationWindow {
|
||||
Controls.Label {
|
||||
visible: lpage.layout.mode === "custom"
|
||||
Kirigami.FormData.label: ""
|
||||
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 "
|
||||
text: lpage.named ? "Where the screens were when you saved it, and the apps that were open. Open "
|
||||
+ "profile puts the screens there and opens the apps. Save as profile updates "
|
||||
+ "it or saves a new one."
|
||||
: lpage.hasCustom ? "Where the screens were when you saved. Save as profile "
|
||||
+ "names it. Pick a preset to edit."
|
||||
: "Nothing saved yet: place the screens by hand, then Save current arrangement."
|
||||
: "Nothing saved yet: place the screens by hand, open your apps, then Save as profile."
|
||||
opacity: 0.7
|
||||
wrapMode: Text.Wrap
|
||||
Layout.maximumWidth: Kirigami.Units.gridUnit * 20
|
||||
}
|
||||
|
||||
// The profile's apps (docs/profiles.md): each window and where it goes.
|
||||
ColumnLayout {
|
||||
id: profileApps
|
||||
visible: lpage.named
|
||||
Kirigami.FormData.label: "Apps:"
|
||||
property var windows: lpage.named ? backend.profileWindows(lpage.layout.active) : []
|
||||
property var hidden: lpage.named ? backend.profileHidden(lpage.layout.active) : []
|
||||
Connections {
|
||||
target: backend
|
||||
function onChanged() {
|
||||
profileApps.windows = lpage.named ? backend.profileWindows(lpage.layout.active) : []
|
||||
profileApps.hidden = lpage.named ? backend.profileHidden(lpage.layout.active) : []
|
||||
}
|
||||
}
|
||||
Controls.Label {
|
||||
visible: profileApps.windows.length === 0
|
||||
text: "None saved. Open the apps you want, place their windows, then Save as profile."
|
||||
opacity: 0.7
|
||||
wrapMode: Text.Wrap
|
||||
Layout.maximumWidth: Kirigami.Units.gridUnit * 20
|
||||
}
|
||||
Repeater {
|
||||
model: profileApps.windows
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
required property int index
|
||||
Controls.Label { text: modelData.app + " (" + modelData.where + ")" }
|
||||
Controls.ToolButton {
|
||||
icon.name: "list-remove"
|
||||
text: "Leave out"
|
||||
display: Controls.AbstractButton.IconOnly
|
||||
Controls.ToolTip.text: "Leave this window out of the profile"
|
||||
Controls.ToolTip.visible: hovered
|
||||
onClicked: backend.removeProfileWindow(lpage.layout.active, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
Controls.Label {
|
||||
visible: profileApps.hidden.length > 0
|
||||
text: "Hides screen" + (profileApps.hidden.length > 1 ? "s " : " ") + profileApps.hidden.join(", ")
|
||||
opacity: 0.7
|
||||
}
|
||||
}
|
||||
|
||||
Controls.SpinBox {
|
||||
Kirigami.FormData.label: "Rows:"
|
||||
visible: lpage.layout.mode !== "custom"
|
||||
@@ -554,8 +600,19 @@ Kirigami.ApplicationWindow {
|
||||
Kirigami.FormData.label: "When the desktop starts:"
|
||||
text: "Float the screens and arrange them"
|
||||
checked: lpage.layout.auto !== false
|
||||
enabled: backend.defaultProfile === ""
|
||||
onToggled: backend.setAuto(checked)
|
||||
}
|
||||
Controls.ComboBox {
|
||||
Kirigami.FormData.label: "Start in profile:"
|
||||
model: [{ text: "None", value: "" }].concat(lpage.names.map(n => ({ text: n, value: n })))
|
||||
textRole: "text"
|
||||
valueRole: "value"
|
||||
currentIndex: Math.max(0, indexOfValue(backend.defaultProfile))
|
||||
onActivated: backend.setDefaultProfile(currentValue)
|
||||
Controls.ToolTip.text: "The desktop starts in this profile: its screens, and its apps open. Each profile also has its own entry in SteamVR's Launch a program list"
|
||||
Controls.ToolTip.visible: hovered
|
||||
}
|
||||
}
|
||||
|
||||
// Preview: from above (you at the bottom) and from the front.
|
||||
@@ -802,6 +859,27 @@ Kirigami.ApplicationWindow {
|
||||
}
|
||||
}
|
||||
|
||||
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Screens shown" }
|
||||
|
||||
Repeater {
|
||||
model: backend.screensShown
|
||||
delegate: Controls.Switch {
|
||||
required property var modelData
|
||||
required property int index
|
||||
Kirigami.FormData.label: "Screen " + (index + 1) + ":"
|
||||
text: modelData ? "Shown" : "Hidden"
|
||||
checked: modelData
|
||||
onToggled: backend.setScreenShown(index, checked)
|
||||
}
|
||||
}
|
||||
Controls.Label {
|
||||
text: "A hidden screen stays hidden whatever the choices above say, and Meta+Shift+H doesn't bring it back. Windows on it stay there; new ones that would open on it float instead."
|
||||
opacity: 0.7
|
||||
font: Kirigami.Theme.smallFont
|
||||
wrapMode: Text.Wrap
|
||||
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
||||
}
|
||||
|
||||
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Pinned screens" }
|
||||
|
||||
Repeater {
|
||||
|
||||
@@ -12,6 +12,7 @@ Built so far (2026-09-29; the KWin side tested on the headless test desktop, `sc
|
||||
- The float key (2026-09-30): the input relay's `float_toggle` (Meta+Shift+F by default) and `dock_all`, through `float pointer` and `dock all` on ft-floatd. Tested on the headless desktop.
|
||||
- The title bar button (2026-09-30, branch `float-titlebar`): `decoration/` (Frametop's QML window decoration, installed by the session script; `decoration/apply.sh` switches the running desktop to it or back to Breeze), and keep-below as the floating flag in the script. Tried on the live desktop: the button floats Dolphin and docks it again, the float key and `dock all` keep the flag in step, and maximized windows look right.
|
||||
- Launching floating (2026-09-30, branch `float-launch`): `ft-float launch APP` and `ft-float run COMMAND`, matching the new window by process (or a child) or by desktop file name for 30 seconds, and each app's remembered place (pose relative to the primary screen, size in pixels, scale) in `~/.config/frametop-float.json`, kept whenever one of its windows stops floating. With nothing remembered, the window opens in front of you, at the primary screen's density, 0.8 to 2 m away. Launch as Standalone (`float/ft_apps.py`): the session writes the desktop file copies and puts them first in `XDG_DATA_DIRS`; ft-floatd rewrites them when apps change. Tried on the live desktop: Dolphin launched floating, came back at its remembered pose after closing, and Konsole floated from `ft-float run`; the right-click entry needs a desktop start to show. The remembered scale is kept but not applied yet (see Known problems).
|
||||
- Screens hidden one at a time (2026-09-30, branch `screen-hide`): ft-screens' `conceal`/`reveal`/`concealed`, `ft-layout hide|show N`, Screens shown in Display Settings, and ft-floatd floats a new window that opens on a hidden screen (and puts strays on a screen that shows).
|
||||
|
||||
Not built yet: phase 2 (the ghost, tear-off by dragging, push-flush docking), the rest of phase 3 (new windows of a floating app placed where that app's last went), and phase 4.
|
||||
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
# Profiles (plan)
|
||||
|
||||
Status: design settled with the user on 2026-09-30; not built yet. Profiles come last in the build order in `docs/floating-windows.md`, after launching apps floating (phase 3 there) and hiding screens one at a time.
|
||||
Status: design settled with the user on 2026-09-30, and built the same day on the branch `profiles`, which builds on `screen-hide` (screens hidden one at a time). Tested offline: saving, renaming, and deleting profiles with their launcher entries, against a stand-in ft-floatd. Still to try on the desktop: capturing and opening real apps, and starting the desktop in a profile.
|
||||
|
||||
A profile is a named layout that also opens apps. It holds:
|
||||
|
||||
@@ -35,23 +35,23 @@ So a "Work" profile can put three screens around you with a browser, two termina
|
||||
{"app": "org.kde.konsole", "screen": 2, "rect": [40, 60, 1200, 800], "maximized": false},
|
||||
{"app": "com.brave.Browser", "screen": 1, "maximized": true},
|
||||
{"cmd": ["/opt/tool/run"], "class": "tool", "screen": 1, "rect": [...]},
|
||||
{"app": "org.kde.dolphin", "float": {"pos": [...], "face": [...], "roll": 0,
|
||||
"pixels": [1400, 900], "scale": 1.2, "pin": ...}}]}},
|
||||
{"app": "org.kde.dolphin", "float": {"rel": [12 numbers], "pixels": [1400, 900],
|
||||
"scale": 1.2, "mpp": 0.00097}}]}},
|
||||
"default_profile": "Work"
|
||||
```
|
||||
|
||||
- `screen` is 1-based, as everywhere in Frametop. `rect` is the window's frame in KWin's logical units, relative to its screen's output, so it survives the screens being arranged differently.
|
||||
- A floating window's place is relative to your head when the profile is applied, the same way the screens' places are (position, and the direction you face, yaw only).
|
||||
- A floating window's place (`rel`) is its panel's centre and axes in the frame of the primary screen's panel, the same way ft-floatd remembers each app's place. The screens go relative to your head when the profile is applied, and the floating windows follow them. `mpp` is its density in metres per pixel. Its scale is kept but not yet applied (see Known problems in `docs/floating-windows.md`).
|
||||
- Renaming or deleting a layout renames or deletes its profile entry with it.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Capture** (`ft-layout save NAME`, and Save current arrangement… in Display Settings). ft-layout captures the screens as today, then asks ft-floatd for the windows (`windows` on @frametop_float). ft-floatd answers from the KWin script's table: every normal window with its desktop file name, output, frame, and maximized state, and for floating windows their panel's pose from ft-screens, their size in pixels, and their scale. Windows with no desktop file name are kept by their process's command line (`/proc/<pid>/cmdline`) and window class. Windows of Plasma itself, the Frametop settings apps, and dialogs aren't recorded.
|
||||
- **Apply** (`ft-layout use NAME`). ft-layout arranges the screens as today, hides and shows them (the per-screen hide in ft-screens), then hands the profile's windows to ft-floatd (`profile NAME`). ft-floatd goes through the entries app by app. It claims windows of that app already open (oldest first, each claimed once), and moves each to its entry's place: onto its screen at its rect (or maximized), or floating at its pose. For the entries left over, it launches the app (`ft-float launch`, the same path as Launch as Standalone), places windows as they show up, and launches again for each one still missing once the first has appeared. It gives up on an app after 30 seconds and notifies.
|
||||
- **Default at start.** `default_profile` replaces today's "arrange when the desktop starts": the session's autostart runs `ft-layout start`, which applies the default profile (screens and apps), or just arranges the screens if there's none. Plasma's session restore is turned off in the session (`ksmserverrc`: `loginMode=emptySession`).
|
||||
- **Launcher entries.** Each profile gets `~/.local/share/applications/frametop-profile-<name>.desktop` ("Frametop: Work"), written when it's saved and removed when it's deleted. They show in SteamVR's Launch a program list, the Application Launcher, and KRunner. Running one (`ft-layout open NAME`) switches to that profile if the desktop runs, or starts the desktop with `FT_PROFILE` set, which overrides `default_profile` for that start.
|
||||
- **The action.** `profile:NAME` in the input relay, for key combinations, mouse buttons, and controller buttons. Input Settings lists one entry per profile.
|
||||
- **Display Settings.** The Layout page becomes Profiles: the list (switch, rename, delete, save the current state over one), each profile's apps with a remove button, which screens it hides, and which profile the desktop starts with.
|
||||
- **Capture** (`ft-layout save NAME`, and Save as profile… in Display Settings). ft-layout captures the screens as before, then asks ft-floatd for the windows (`windows` on @frametop_float). ft-floatd has the KWin script report every window as it is now (`report-all`), then answers with every normal window: its desktop file name, the screen it's on, its rectangle there, and whether it's maximized. For floating windows it gives their panel's place, their size in pixels, and their scale. Windows with no desktop file name are kept by their process's command line (`/proc/<pid>/cmdline`) and window class. Windows of Plasma itself, the Frametop settings apps, and dialogs aren't recorded. If ft-floatd doesn't answer, the profile keeps the apps it had.
|
||||
- **Apply** (`ft-layout use NAME`, Open profile in Display Settings). ft-layout makes the profile's hidden screens the screens' own setting, arranges the screens (which hides and shows them: ft-screens' `conceal` and `reveal`), then has ft-floatd open the apps (`profile NAME`; ft-floatd reads the windows from the layout file). ft-floatd goes through the entries app by app. It claims windows of that app already open (oldest first, each claimed once), and moves each to its entry's place: onto its screen at its rect (or maximized), or floating at its pose. For the entries left over, it launches the app (`ft-float launch`, the same path as Launch as Standalone), places windows as they show up, and launches again for each one still missing once the first has appeared. It stops waiting for an app's windows after 30 seconds.
|
||||
- **Default at start.** The session script runs `ft-layout start --wait 90` (it used to run `apply --wait 90`). That opens the profile in `FT_PROFILE` or `default_profile` (screens, then the apps once ft-floatd is up), or does what `apply --wait` did if there's none. Start in profile on the Layout & profiles page sets `default_profile` (`ft-layout default NAME|none`). Plasma's session restore is turned off in the session (`ksmserverrc`: `loginMode=emptySession`).
|
||||
- **Launcher entries.** Each profile gets `~/.local/share/applications/frametop-profile-<name>.desktop` ("Frametop: Work"), written when it's saved and removed when it's deleted. They show in SteamVR's Launch a program list, the Application Launcher, and KRunner. Running one (`ft-layout open NAME`) switches to that profile if the desktop runs. Otherwise it starts the desktop with `FT_PROFILE` set (`systemd-run`, as `desktops.sh start` does), which overrides `default_profile` for that start. That needs SteamVR to be running.
|
||||
- **The action.** `profile:NAME` in the input relay (it runs `ft-layout use NAME`) for key combinations, mouse buttons, and controller buttons, with or without pointer mode. Input Settings lists one "Open profile NAME" action per profile.
|
||||
- **Display Settings.** The Layout page becomes Layout & profiles. The arrangement list has the profiles (rename and delete as before). Open profile and Save as profile… are the page's actions. A profile's apps are listed with where each goes and a button to leave one out, plus which screens it hides. Start in profile picks the one the desktop starts with. The Visibility tab's Screens shown switches hide screens one at a time.
|
||||
|
||||
## Open for when it's built
|
||||
|
||||
|
||||
+6
-3
@@ -48,7 +48,7 @@ The Visibility & pins tab of Frametop Display Settings decides when the screens
|
||||
- While you look at a chosen controller (the wrist gesture).
|
||||
- Only after you show them with the hotkey.
|
||||
|
||||
In the last three modes the hotkey shows the screens anyway. Two more settings on the same tab cover VR games, which ft-screens detects as SteamVR scene apps:
|
||||
In the last three modes the hotkey shows the screens anyway. A screen can also be hidden on its own (Screens shown on the same tab, or `ft-layout hide N`): it stays hidden whatever the mode or the hotkey says, until it's shown again there. Windows on it stay put, and a new window that would open on it floats instead (ft-floatd). Profiles use this to show only some screens. Two more settings on the same tab cover VR games, which ft-screens detects as SteamVR scene apps:
|
||||
|
||||
- During VR games, the Always mode hides the screens unless the dashboard is open (the default), or leaves them up.
|
||||
- Controllers on the screens. Visible screens can keep SteamVR's laser mouse on, so controllers work them with the dashboard closed, but that also takes the controllers away from a game. By default this is off while a VR game runs, and the 3D mouse or the dashboard works the screens. The other choices are always on, or only with the dashboard open, which also suits flatscreen games since they aren't scene apps.
|
||||
@@ -139,13 +139,16 @@ 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 save NAME # ...under a name too, with the open apps and hidden screens (a profile, docs/profiles.md), and use it
|
||||
layout/ft-layout use NAME # switch to a profile: arrange the screens in it and open its apps
|
||||
layout/ft-layout open NAME # a profile's launcher entry: use it, or start the desktop in it
|
||||
layout/ft-layout default NAME|none # the profile the desktop starts with (start --wait runs it at desktop start)
|
||||
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
|
||||
layout/ft-layout hide N|all # hide a screen on its own, whatever the visibility mode; show N|all brings it back, hidden lists them
|
||||
display-settings/install.sh # menu entries and the Meta+Shift+R and Meta+Shift+H shortcuts
|
||||
```
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ function info(w) {
|
||||
frame: rect(w.frameGeometry), client: rect(w.clientGeometry), popup: w.popupWindow,
|
||||
transient: w.transient, parent: w.transientFor ? String(w.transientFor.internalId) : "",
|
||||
normal: w.normalWindow, dialog: w.dialog, fullScreen: w.fullScreen, minimized: w.minimized,
|
||||
onAllDesktops: w.onAllDesktops
|
||||
onAllDesktops: w.onAllDesktops, maximized: w.maximizeMode === 3
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ function run(c) {
|
||||
workspace.sendClientToScreen(w, o);
|
||||
w.frameGeometry = {x: c.x, y: c.y, width: c.w, height: c.h};
|
||||
if (c.onAllDesktops !== undefined) w.onAllDesktops = c.onAllDesktops;
|
||||
if (c.maximized) w.setMaximize(true, true);
|
||||
break;
|
||||
}
|
||||
case "geometry":
|
||||
@@ -189,6 +190,10 @@ function run(c) {
|
||||
case "info":
|
||||
if (w) report("window", w);
|
||||
break;
|
||||
case "report-all": // a profile's capture: every window as it is now, then a marker
|
||||
workspace.windowList().forEach(w => report("window", w));
|
||||
send({ev: "reported", token: c.token});
|
||||
break;
|
||||
case "request-active": // ft-float float|dock active
|
||||
requestFloat(workspace.activeWindow);
|
||||
break;
|
||||
|
||||
+276
-27
@@ -13,6 +13,8 @@ Commands and ft-screens' events arrive as datagrams on @frametop_float (ft-float
|
||||
command-line side). Replies go to the sender:
|
||||
float [ID|active|pointer] dock [ID|active|all] close ID list quit (ft-float)
|
||||
launch APP.desktop run ["argv", ...] (start an app and float its first window)
|
||||
windows (-> JSON: the open apps' windows and where they are, for a profile; ft-layout save)
|
||||
profile NAME (open a profile's apps: move the matching windows, launch the missing ones)
|
||||
("float pointer" is the float key: the window under the pointer, else the active one,
|
||||
floated or docked; the input relay sends it for float_toggle, and "dock all" for dock_all)
|
||||
dock N | close N | resize N W H | scale N STEPS (ft-screens, N = its screen)
|
||||
@@ -34,6 +36,14 @@ name. Each app's last floating place (its pose relative to the primary screen, s
|
||||
with the screens' layout), size in pixels, and scale are kept in PLACES_PATH, by desktop
|
||||
file name, whenever one of its windows stops floating.
|
||||
|
||||
Profiles (docs/profiles.md): "windows" lists every app window with where it is: on a screen
|
||||
(its rectangle on that output, maximized or not) or floating (its pose relative to the primary
|
||||
screen, size, scale). "profile NAME" takes a profile's windows from the layout file: the
|
||||
windows of each app already open (oldest first) move to its entries, and for the entries
|
||||
left the app is launched once, and again for each window still missing 3 seconds after its
|
||||
first one shows up (a browser restores its own windows; a terminal opens one each time).
|
||||
Nothing is ever closed.
|
||||
|
||||
Launch as Standalone (float/ft_apps.py): copies of the apps' desktop files with that action,
|
||||
which only the Frametop desktop reads. ft-floatd rewrites them when apps change.
|
||||
"""
|
||||
@@ -126,6 +136,20 @@ def kscreen(*args):
|
||||
return ""
|
||||
|
||||
|
||||
def output_rects():
|
||||
"""Each output's place and size in KWin's layout (logical)."""
|
||||
try:
|
||||
data = json.loads(kscreen("-j") or "{}")
|
||||
except ValueError:
|
||||
return {}
|
||||
out = {}
|
||||
for o in data.get("outputs", []):
|
||||
pos, size, scale = o.get("pos") or {}, o.get("size") or {}, float(o.get("scale", 1)) or 1.0
|
||||
if o.get("name") and size:
|
||||
out[o["name"]] = (pos.get("x", 0), pos.get("y", 0), size.get("width", 0) / scale, size.get("height", 0) / scale)
|
||||
return out
|
||||
|
||||
|
||||
def output_scales():
|
||||
try:
|
||||
data = json.loads(kscreen("-j") or "{}")
|
||||
@@ -185,12 +209,36 @@ def pid_chain(pid):
|
||||
return out
|
||||
|
||||
|
||||
class Launch:
|
||||
"""An app we started, whose first window floats."""
|
||||
# Windows a profile doesn't keep: Plasma's own, the Frametop settings apps, the login splash.
|
||||
SKIP_APPS = ("org.kde.plasmashell", "org.kde.krunner", "org.kde.ksplashqml", "org.kde.polkit-kde-authentication-agent-1")
|
||||
|
||||
def __init__(self, app, pid):
|
||||
|
||||
def recordable(ev):
|
||||
"""An app's top-level window, the kind a profile keeps."""
|
||||
if not ev.get("normal") or ev.get("popup") or ev.get("transient"):
|
||||
return False
|
||||
app, cls = ev.get("app", ""), ev.get("cls", "")
|
||||
return app not in SKIP_APPS and not any(n.startswith(("ft-", "frametop", "ksplash")) for n in (app, cls))
|
||||
|
||||
|
||||
def cmdline(pid):
|
||||
try:
|
||||
with open(f"/proc/{int(pid)}/cmdline", "rb") as f:
|
||||
return [a.decode(errors="replace") for a in f.read().split(b"\0") if a]
|
||||
except (OSError, ValueError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
class Launch:
|
||||
"""An app we started: its first window floats, or (for a profile) its windows go to the
|
||||
profile's entries for it, in order."""
|
||||
|
||||
def __init__(self, app, pid, entries=None, argv=None):
|
||||
self.app = app # its desktop file name, without .desktop ("" for a command)
|
||||
self.pid = pid # the process we started (None when D-Bus started it)
|
||||
self.argv = argv # the command, when it isn't a desktop file's app
|
||||
self.pids = [pid] if pid else [] # the processes we started (none when D-Bus started it)
|
||||
self.entries = entries # a profile's entries still waiting for a window, or None
|
||||
self.relaunched = False
|
||||
self.until = time.monotonic() + LAUNCH_SECONDS
|
||||
|
||||
|
||||
@@ -238,6 +286,9 @@ class Daemon:
|
||||
self.sub_numbers = {} # popup/dialog id -> (window id, number)
|
||||
self.next_sub = 1
|
||||
self.launches = [] # Launch: apps we started, waiting for their window
|
||||
self.captures = {} # token -> sender: "windows" requests waiting for the script's report
|
||||
self.next_token = 1
|
||||
self.sock = None # our socket (set by main), for deferred replies
|
||||
self.places = load_places() # app -> {"rel": pose by the primary screen, "pixels", "scale"}
|
||||
|
||||
# ------------------------------------------------------------ the script
|
||||
@@ -313,11 +364,24 @@ class Daemon:
|
||||
if wid:
|
||||
self.windows[wid] = ev
|
||||
f = self.floats.get(wid)
|
||||
if kind == "reported":
|
||||
self.captured(ev.get("token"))
|
||||
return
|
||||
if kind == "added" and self.launches and not f:
|
||||
launch = self.launched(ev)
|
||||
if launch:
|
||||
if launch and launch.entries is None:
|
||||
self.float_launched(ev, launch)
|
||||
return
|
||||
if launch:
|
||||
self.place_entry(ev, launch.entries.pop(0))
|
||||
if launch.entries and not launch.relaunched:
|
||||
GLib.timeout_add_seconds(3, lambda: self.relaunch(launch) and False)
|
||||
return
|
||||
if kind == "added" and not f and self.on_hidden_screen(ev):
|
||||
# Nobody would see it there (a profile can hide every screen): it floats instead.
|
||||
log(f"{wid[:9]} ({ev.get('cls')}) opened on a hidden screen")
|
||||
self.float_launched(ev, None)
|
||||
return
|
||||
if kind == "float-request":
|
||||
self.float_window(ev)
|
||||
elif kind == "dock-request":
|
||||
@@ -387,8 +451,16 @@ class Daemon:
|
||||
for o in self.floats.values()):
|
||||
self.float_window(ev)
|
||||
else:
|
||||
self.command(cmd="place", id=wid, output="WL-0", x=ev["frame"]["x"] % 400 + 100,
|
||||
y=ev["frame"]["y"] % 300 + 100, w=ev["frame"]["w"], h=ev["frame"]["h"])
|
||||
# Onto the first screen that shows (or floating, if none does).
|
||||
hidden = self.concealed()
|
||||
shown = [i for i in range(self.screens_n) if i + 1 not in hidden]
|
||||
if not shown:
|
||||
self.float_launched(ev, None)
|
||||
return
|
||||
name = f"WL-{shown[0]}"
|
||||
x0, y0, _, _ = output_rects().get(name, (0, 0, 0, 0))
|
||||
self.command(cmd="place", id=wid, output=name, x=x0 + ev["frame"]["x"] % 400 + 100,
|
||||
y=y0 + ev["frame"]["y"] % 300 + 100, w=ev["frame"]["w"], h=ev["frame"]["h"])
|
||||
|
||||
# ------------------------------------------------------------ floating and docking
|
||||
|
||||
@@ -562,14 +634,15 @@ class Daemon:
|
||||
want, slot.want = slot.size, None
|
||||
self.set_size(slot, want)
|
||||
|
||||
def dock(self, f, frame=None):
|
||||
"""Back where it came from (or onto screen 1 if we don't know)."""
|
||||
def dock(self, f, frame=None, output=None, maximized=False):
|
||||
"""Back where it came from (or onto screen 1 if we don't know), or onto output at frame."""
|
||||
saved = f.saved or {"output": "WL-0", "frame": dict(f.frame or {"x": 100, "y": 100, "w": 800, "h": 600}),
|
||||
"onAllDesktops": False}
|
||||
fr = frame or saved["frame"]
|
||||
log(f"{f.id[:9]} back to {saved['output']}")
|
||||
self.command(cmd="place", id=f.id, output=saved["output"], x=fr["x"], y=fr["y"], w=fr["w"], h=fr["h"],
|
||||
onAllDesktops=bool(saved.get("onAllDesktops")))
|
||||
output = output or saved["output"]
|
||||
log(f"{f.id[:9]} back to {output}")
|
||||
self.command(cmd="place", id=f.id, output=output, x=fr["x"], y=fr["y"], w=fr["w"], h=fr["h"],
|
||||
onAllDesktops=bool(saved.get("onAllDesktops")), maximized=maximized)
|
||||
|
||||
def remember(self, f):
|
||||
"""Keep where an app's window floated (before its panel goes)."""
|
||||
@@ -633,30 +706,32 @@ class Daemon:
|
||||
return c, ft_layout.turn_yaw((1.0, 0.0, 0.0), heading), (0.0, 1.0, 0.0), ft_layout.turn_yaw((0.0, 0.0, 1.0), heading)
|
||||
|
||||
def launch(self, app=None, argv=None):
|
||||
"""Start an app (a desktop file name) or a command; its first window will float."""
|
||||
"""Start an app (a desktop file name) or a command; its first window will float.
|
||||
Returns (reply, pid or None)."""
|
||||
if app:
|
||||
app = app.removesuffix(".desktop")
|
||||
info = Gio.DesktopAppInfo.new(app + ".desktop")
|
||||
if info is None:
|
||||
return f"error no app {app}"
|
||||
return f"error no app {app}", None
|
||||
pids = []
|
||||
try:
|
||||
info.launch_uris_as_manager([], Gio.AppLaunchContext(), GLib.SpawnFlags.SEARCH_PATH,
|
||||
None, None, lambda _info, pid, *_: pids.append(pid), None)
|
||||
except GLib.Error as e:
|
||||
return f"error {app}: {e.message}"
|
||||
return f"error {app}: {e.message}", None
|
||||
self.launches.append(Launch(app, pids[0] if pids else None))
|
||||
log(f"launched {app} (pid {pids[0] if pids else 'by D-Bus'})")
|
||||
return "ok", (pids[0] if pids else None)
|
||||
else:
|
||||
try:
|
||||
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
except OSError as e:
|
||||
return f"error {argv[0]}: {e}"
|
||||
return f"error {argv[0]}: {e}", None
|
||||
GLib.child_watch_add(GLib.PRIORITY_DEFAULT, proc.pid, lambda *_: None) # reap it
|
||||
self.launches.append(Launch("", proc.pid))
|
||||
log(f"started {argv[0]} (pid {proc.pid})")
|
||||
return "ok"
|
||||
return "ok", proc.pid
|
||||
|
||||
def launched(self, ev):
|
||||
"""Is this new window the one an app we started was to open? Takes it off the list."""
|
||||
@@ -667,15 +742,30 @@ class Daemon:
|
||||
chain = pid_chain(int(ev.get("pid") or 0))
|
||||
app = ev.get("app", "")
|
||||
for la in self.launches:
|
||||
if (la.pid and la.pid in chain) or (la.app and app and la.app == app):
|
||||
self.launches.remove(la)
|
||||
if any(p in chain for p in la.pids) or (la.app and app and la.app == app):
|
||||
if not la.entries or len(la.entries) <= 1:
|
||||
self.launches.remove(la)
|
||||
return la
|
||||
return None
|
||||
|
||||
def concealed(self):
|
||||
"""The screens (1-based) hidden on their own (ft-layout hide N)."""
|
||||
reply = self.screens.ask("concealed", quiet=True)
|
||||
return {int(w) for w in reply.split()[1:] if w.isdigit()} if reply.startswith("ok") else set()
|
||||
|
||||
def on_hidden_screen(self, ev):
|
||||
"""A new top-level window on a screen that's hidden on its own."""
|
||||
m = re.match(r"WL-(\d+)$", ev.get("output", ""))
|
||||
if not m or int(m.group(1)) >= self.screens_n:
|
||||
return False
|
||||
if not ev.get("normal") or ev.get("popup") or ev.get("transient") or ev.get("cls") == "ksplashqml":
|
||||
return False
|
||||
return int(m.group(1)) + 1 in self.concealed()
|
||||
|
||||
def float_launched(self, ev, launch):
|
||||
"""Float a launched app's window where that app last floated (its size and scale too),
|
||||
or in front of you, at the primary screen's density."""
|
||||
app = ev.get("app") or launch.app
|
||||
"""Float a launched app's window (or one that opened on a hidden screen) where that app
|
||||
last floated (its size too), or in front of you, at the primary screen's density."""
|
||||
app = ev.get("app") or (launch.app if launch else "")
|
||||
known = self.places.get(app) if app else None
|
||||
ref = self.reference()
|
||||
place = to_world(ref, known["rel"]) if known and ref and len(known.get("rel", [])) == 12 else self.in_front()
|
||||
@@ -690,6 +780,160 @@ class Daemon:
|
||||
# fixing first (docs/floating-windows.md, Known problems).
|
||||
self.float_window(dict(ev, app=app), place=place, mpp=mpp)
|
||||
|
||||
# ------------------------------------------------------------ profiles (docs/profiles.md)
|
||||
|
||||
def capture(self, sender):
|
||||
""""windows": have the script report every window as it is now, and answer when the
|
||||
report is in (or after 3 seconds with what we know)."""
|
||||
if not sender:
|
||||
return "error windows needs a reply address"
|
||||
token = self.next_token
|
||||
self.next_token += 1
|
||||
self.captures[token] = sender
|
||||
self.command(cmd="report-all", token=token)
|
||||
GLib.timeout_add(3000, lambda: self.captured(token) and False)
|
||||
return None # answered by captured
|
||||
|
||||
def captured(self, token):
|
||||
sender = self.captures.pop(token, None)
|
||||
if sender and self.sock:
|
||||
try:
|
||||
self.sock.sendto(("ok " + json.dumps(self.window_entries())).encode(), sender)
|
||||
except OSError as e:
|
||||
log(f"windows: {e}")
|
||||
|
||||
def window_entries(self):
|
||||
"""Every app window and where it is, as a profile keeps it."""
|
||||
ref = self.reference()
|
||||
rects = None
|
||||
out = []
|
||||
for wid, ev in self.windows.items():
|
||||
if not recordable(ev):
|
||||
continue
|
||||
entry = {"app": ev["app"]} if ev.get("app") else {"cmd": cmdline(ev.get("pid")), "class": ev.get("cls", "")}
|
||||
if not entry.get("app") and not entry.get("cmd"):
|
||||
continue
|
||||
f = self.floats.get(wid)
|
||||
if f:
|
||||
g = self.panel_get(f.slot.index)
|
||||
if not g or not ref or not f.frame:
|
||||
continue
|
||||
pixels = list(f.normal or (round(f.frame["w"] * f.scale), round(f.frame["h"] * f.scale)))
|
||||
entry["float"] = {"rel": to_local(ref, g), "pixels": pixels, "scale": f.scale, "mpp": round(f.mpp, 8)}
|
||||
else:
|
||||
m = re.match(r"WL-(\d+)$", ev.get("output", ""))
|
||||
if not m or int(m.group(1)) >= self.screens_n:
|
||||
continue
|
||||
fr, o = ev["frame"], ev.get("outputRect")
|
||||
if not o:
|
||||
rects = rects if rects is not None else output_rects()
|
||||
x0, y0, _, _ = rects.get(ev["output"], (0, 0, 0, 0))
|
||||
o = {"x": x0, "y": y0}
|
||||
entry["screen"] = int(m.group(1)) + 1
|
||||
entry["rect"] = [round(fr["x"] - o["x"]), round(fr["y"] - o["y"]), round(fr["w"]), round(fr["h"])]
|
||||
if ev.get("maximized"):
|
||||
entry["maximized"] = True
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
def window_key(self, ev):
|
||||
return ev.get("app") or json.dumps(cmdline(ev.get("pid")))
|
||||
|
||||
def open_profile(self, name):
|
||||
"""Open a profile's apps (additive: nothing closes)."""
|
||||
try:
|
||||
profile = ft_layout.load_layout().get("profiles", {}).get(name)
|
||||
except (OSError, ValueError):
|
||||
profile = None
|
||||
if profile is None:
|
||||
return f"error no profile {name!r}"
|
||||
groups = {}
|
||||
for e in profile.get("windows", []):
|
||||
key = e.get("app") or json.dumps(e.get("cmd") or [])
|
||||
if key != "[]":
|
||||
groups.setdefault(key, []).append(e)
|
||||
claimed = set()
|
||||
for key, entries in groups.items():
|
||||
have = [ev for wid, ev in self.windows.items()
|
||||
if wid not in claimed and recordable(ev) and self.window_key(ev) == key]
|
||||
for e, ev in zip(entries, have):
|
||||
claimed.add(ev["id"])
|
||||
self.place_entry(ev, e)
|
||||
rest = entries[len(have):]
|
||||
if rest:
|
||||
self.launch_entries(rest)
|
||||
log(f"profile {name!r}: {sum(len(v) for v in groups.values())} windows, {len(claimed)} already open")
|
||||
return "ok"
|
||||
|
||||
def launch_entries(self, entries):
|
||||
"""Launch an app for a profile's entries that have no window yet."""
|
||||
e = entries[0]
|
||||
if e.get("app"):
|
||||
reply, pid = self.launch(app=e["app"])
|
||||
app = e["app"]
|
||||
else:
|
||||
reply, pid = self.launch(argv=e["cmd"])
|
||||
app = ""
|
||||
if not reply.startswith("ok"):
|
||||
log(f"profile: {reply}")
|
||||
return
|
||||
la = self.launches[-1] # the one launch() just added
|
||||
la.entries, la.argv = list(entries), (None if app else e["cmd"])
|
||||
|
||||
def relaunch(self, la):
|
||||
"""3 seconds after a profile's app showed its first window: start it again for each
|
||||
entry still waiting (an app that restores its own windows has shown them by now)."""
|
||||
if la.relaunched or not la.entries or la not in self.launches:
|
||||
return
|
||||
la.relaunched = True
|
||||
la.until = time.monotonic() + LAUNCH_SECONDS
|
||||
for _ in la.entries:
|
||||
if la.app:
|
||||
reply, pid = self.launch(app=la.app)
|
||||
else:
|
||||
reply, pid = self.launch(argv=la.argv)
|
||||
if reply.startswith("ok"):
|
||||
self.launches.pop() # launch() added one of its own; this one waits for them all
|
||||
if pid:
|
||||
la.pids.append(pid)
|
||||
|
||||
def place_entry(self, ev, e):
|
||||
"""Move a window to a profile entry's place: on a screen, or floating."""
|
||||
f = self.floats.get(ev["id"])
|
||||
if "float" in e:
|
||||
fl, ref = e["float"], self.reference()
|
||||
if not ref or len(fl.get("rel", [])) != 12:
|
||||
return
|
||||
place = to_world(ref, fl["rel"])
|
||||
pixels = fl.get("pixels")
|
||||
if f:
|
||||
self.pose(f, *place)
|
||||
if pixels and f.frame:
|
||||
m = self.margin
|
||||
self.command(cmd="geometry", id=f.id, x=f.slot.pos[0] + m / f.scale,
|
||||
y=f.slot.pos[1] + m / f.scale, w=pixels[0] / f.scale, h=pixels[1] / f.scale)
|
||||
return
|
||||
if pixels:
|
||||
s = output_scales().get(ev["output"], 1.0)
|
||||
ev = dict(ev, frame=dict(ev["frame"], w=pixels[0] / s, h=pixels[1] / s))
|
||||
self.float_window(ev, place=place, mpp=fl.get("mpp"))
|
||||
return
|
||||
n = int(e.get("screen", 1)) - 1
|
||||
if not 0 <= n < self.screens_n:
|
||||
n = 0
|
||||
name = f"WL-{n}"
|
||||
x0, y0, ow, oh = output_rects().get(name, (0, 0, 0, 0))
|
||||
rx, ry, rw, rh = (e.get("rect") or [100, 100, ev["frame"]["w"], ev["frame"]["h"]])[:4]
|
||||
if ow and oh: # keep it on the screen if the screen got smaller
|
||||
rw, rh = min(rw, ow), min(rh, oh)
|
||||
rx, ry = max(0, min(rx, ow - rw)), max(0, min(ry, oh - rh))
|
||||
frame = {"x": x0 + rx, "y": y0 + ry, "w": rw, "h": rh}
|
||||
if f:
|
||||
self.dock(f, frame=frame, output=name, maximized=bool(e.get("maximized")))
|
||||
else:
|
||||
self.command(cmd="place", id=ev["id"], output=name, x=frame["x"], y=frame["y"], w=rw, h=rh,
|
||||
maximized=bool(e.get("maximized")))
|
||||
|
||||
# ------------------------------------------------------------ popups and dialogs
|
||||
|
||||
def sub(self, ev):
|
||||
@@ -727,13 +971,13 @@ class Daemon:
|
||||
return s.window
|
||||
return None
|
||||
|
||||
def request(self, text):
|
||||
def request(self, text, sender=None):
|
||||
words = text.split()
|
||||
if not words:
|
||||
return "error empty"
|
||||
cmd, rest = words[0], words[1:]
|
||||
if cmd == "launch" and len(rest) == 1:
|
||||
return self.launch(app=rest[0])
|
||||
return self.launch(app=rest[0])[0]
|
||||
if cmd == "run" and rest:
|
||||
try:
|
||||
argv = json.loads(text.split(None, 1)[1])
|
||||
@@ -741,7 +985,11 @@ class Daemon:
|
||||
return "error run takes a JSON list"
|
||||
if not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv):
|
||||
return "error run takes a JSON list"
|
||||
return self.launch(argv=argv)
|
||||
return self.launch(argv=argv)[0]
|
||||
if cmd == "windows":
|
||||
return self.capture(sender)
|
||||
if cmd == "profile" and rest:
|
||||
return self.open_profile(text.split(None, 1)[1])
|
||||
if cmd == "list":
|
||||
return "ok " + " ".join(f"{s.output}:{s.window.id if s.window else '-'}" for s in self.slots)
|
||||
if cmd == "quit":
|
||||
@@ -863,6 +1111,7 @@ def main():
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
sock.bind("\0" + args.socket)
|
||||
sock.setblocking(False)
|
||||
daemon.sock = sock
|
||||
|
||||
def readable(*_):
|
||||
while True:
|
||||
@@ -870,8 +1119,8 @@ def main():
|
||||
data, sender = sock.recvfrom(4096)
|
||||
except BlockingIOError:
|
||||
return True
|
||||
reply = daemon.request(data.decode(errors="replace").strip())
|
||||
if sender:
|
||||
reply = daemon.request(data.decode(errors="replace").strip(), sender)
|
||||
if sender and reply is not None:
|
||||
try:
|
||||
sock.sendto(reply.encode(), sender)
|
||||
except OSError:
|
||||
|
||||
@@ -92,6 +92,26 @@ CONTROLLER_BUTTONS = {
|
||||
}
|
||||
# Not the gaze actions: gaze mode is a mouse feature (docs/gaze-controllers.md; the relay's GAZE_ACTIONS).
|
||||
CONTROLLER_ACTIONS = [a for a in ACTION_LABELS if a not in ("key", "none", "gaze_toggle", "gaze_precision", "gaze_drag")]
|
||||
# Profiles (docs/profiles.md): "profile:NAME" opens one (the relay runs ft-layout use NAME).
|
||||
LAYOUT_PATH = os.path.expanduser("~/.config/frametop-layout.json")
|
||||
PROFILE = "profile:"
|
||||
|
||||
|
||||
def profile_actions():
|
||||
"""One action per profile (named layout), for buttons, controllers, and key combinations."""
|
||||
names = sorted(read_json(LAYOUT_PATH).get("layouts", {}), key=str.casefold)
|
||||
return [PROFILE + n for n in names]
|
||||
|
||||
|
||||
def action_label(a):
|
||||
if a.startswith(PROFILE):
|
||||
return f"Open profile {a[len(PROFILE):]}"
|
||||
return ACTION_LABELS.get(a, a)
|
||||
|
||||
|
||||
def mappable(a):
|
||||
"""An action a controller button or key combination can have."""
|
||||
return a in CONTROLLER_ACTIONS or (a.startswith(PROFILE) and len(a) > len(PROFILE))
|
||||
# Gaze mode settings (pointer helper), like POINTER_SETTINGS.
|
||||
GAZE_SETTINGS = [
|
||||
("POINTER_GAZE_RETAKE", "Look away to hand back", 5, 1, 45, 0.5, "°"),
|
||||
@@ -481,7 +501,8 @@ class Backend(QObject):
|
||||
|
||||
@Property("QVariantList", constant=True)
|
||||
def actions(self):
|
||||
return [{"value": k, "text": v} for k, v in ACTION_LABELS.items()]
|
||||
return [{"value": k, "text": v} for k, v in ACTION_LABELS.items()] + \
|
||||
[{"value": a, "text": action_label(a)} for a in profile_actions()]
|
||||
|
||||
@Slot(str, result="QVariantList")
|
||||
def mappings(self, device_id):
|
||||
@@ -496,7 +517,7 @@ class Backend(QObject):
|
||||
r = rows[code]
|
||||
r["isDefault"] = code in DEFAULT_BUTTONS # a built-in binding (left/right/middle/side/extra)
|
||||
r["name"] = self.codeName(code)
|
||||
r["actionLabel"] = ACTION_LABELS.get(r["action"], r["action"])
|
||||
r["actionLabel"] = action_label(r["action"])
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
@@ -515,7 +536,7 @@ class Backend(QObject):
|
||||
rules.setdefault("buttons", {}).setdefault(device_id, {})[str(code)] = action
|
||||
self._remember_name(rules, device_id)
|
||||
self._save_rules(rules)
|
||||
self.message.emit(f"{self.codeName(code)} → {ACTION_LABELS.get(action, action)}", False)
|
||||
self.message.emit(f"{self.codeName(code)} → {action_label(action)}", False)
|
||||
|
||||
@Slot(str, int)
|
||||
def removeMapping(self, device_id, code):
|
||||
@@ -681,7 +702,7 @@ class Backend(QObject):
|
||||
# --- controllers ---
|
||||
@Property("QVariantList", constant=True)
|
||||
def controllerActions(self):
|
||||
return [{"value": a, "text": ACTION_LABELS[a]} for a in CONTROLLER_ACTIONS]
|
||||
return [{"value": a, "text": action_label(a)} for a in CONTROLLER_ACTIONS + profile_actions()]
|
||||
|
||||
@Property("QVariantList", constant=True)
|
||||
def controllerButtons(self):
|
||||
@@ -691,7 +712,7 @@ class Backend(QObject):
|
||||
def controllerMappings(self):
|
||||
mapped = read_json(RULES_PATH).get("controller_buttons", {})
|
||||
return [{"button": b, "label": label, "action": mapped[b],
|
||||
"actionLabel": ACTION_LABELS.get(mapped[b], mapped[b])}
|
||||
"actionLabel": action_label(mapped[b])}
|
||||
for b, label in CONTROLLER_BUTTONS.items() if b in mapped]
|
||||
|
||||
@Property("QVariantMap", notify=controllersChanged)
|
||||
@@ -715,12 +736,12 @@ class Backend(QObject):
|
||||
|
||||
@Slot(str, str)
|
||||
def setControllerMapping(self, button, action):
|
||||
if button not in CONTROLLER_BUTTONS or action not in CONTROLLER_ACTIONS:
|
||||
if button not in CONTROLLER_BUTTONS or not mappable(action):
|
||||
return
|
||||
rules = read_json(RULES_PATH)
|
||||
rules.setdefault("controller_buttons", {})[button] = action
|
||||
self._save_rules(rules)
|
||||
self.message.emit(f"{CONTROLLER_BUTTONS[button]} → {ACTION_LABELS[action]}", False)
|
||||
self.message.emit(f"{CONTROLLER_BUTTONS[button]} → {action_label(action)}", False)
|
||||
|
||||
@Slot(str)
|
||||
def removeControllerMapping(self, button):
|
||||
@@ -855,12 +876,12 @@ class Backend(QObject):
|
||||
@Property("QVariantList", notify=mappingsChanged)
|
||||
def keyShortcuts(self):
|
||||
bound = key_bindings(read_json(RULES_PATH))
|
||||
return [{"combo": c, "label": self.comboName(c), "action": a, "actionLabel": ACTION_LABELS.get(a, a)}
|
||||
return [{"combo": c, "label": self.comboName(c), "action": a, "actionLabel": action_label(a)}
|
||||
for c, a in sorted(bound.items())]
|
||||
|
||||
@Property("QVariantList", constant=True)
|
||||
def shortcutActions(self):
|
||||
return [{"value": a, "text": ACTION_LABELS[a]} for a in CONTROLLER_ACTIONS]
|
||||
return [{"value": a, "text": action_label(a)} for a in CONTROLLER_ACTIONS + profile_actions()]
|
||||
|
||||
@Property(bool, notify=shortcutCaptureChanged)
|
||||
def capturingShortcut(self):
|
||||
@@ -868,7 +889,7 @@ class Backend(QObject):
|
||||
|
||||
@Slot(str)
|
||||
def startShortcutCapture(self, action):
|
||||
if action in CONTROLLER_ACTIONS:
|
||||
if mappable(action):
|
||||
self._capture_combo = action
|
||||
self._combo_mods = set()
|
||||
self._send("watch 60")
|
||||
@@ -883,7 +904,7 @@ class Backend(QObject):
|
||||
rules = read_json(RULES_PATH)
|
||||
rules["key_bindings"] = dict(key_bindings(rules), **{combo: action})
|
||||
self._save_rules(rules)
|
||||
self.message.emit(f"{self.comboName(combo)} → {ACTION_LABELS[action]}", False)
|
||||
self.message.emit(f"{self.comboName(combo)} → {action_label(action)}", False)
|
||||
|
||||
@Slot(str)
|
||||
def removeShortcut(self, combo):
|
||||
|
||||
+24
-7
@@ -27,7 +27,8 @@ to the helper), sens_up, sens_down,
|
||||
layout_reset = put the desktop screens back in their saved layout, screens_toggle = hide or show the desktop screens,
|
||||
keyboard_toggle = open or close Frametop's keyboard, float_toggle = float the desktop window under the
|
||||
pointer (else the active one) in VR, or put it back if it floats, dock_all = put every floating
|
||||
window back (both to ft-floatd, @frametop_float), key = pass through as a key, none).
|
||||
window back (both to ft-floatd, @frametop_float), profile:NAME = switch to that profile (ft-layout
|
||||
use NAME: its screens and apps; docs/profiles.md), key = pass through as a key, none).
|
||||
|
||||
Frame controller buttons can be mapped too ("controller_buttons": {"right/a": action} in the
|
||||
rules file; any action but key and the gaze ones, GAZE_ACTIONS: gaze mode is a mouse feature,
|
||||
@@ -195,6 +196,15 @@ SCREENS = "\0ft_screens"
|
||||
FLOAT = "\0frametop_float" # ft-floatd, floating windows in the Frametop desktop
|
||||
# Actions for ft-floatd ("float_toggle", "dock_all"): they don't need pointer mode.
|
||||
FLOAT_ACTIONS = {"float_toggle": b"float pointer", "dock_all": b"dock all"}
|
||||
PROFILE = "profile:" # "profile:NAME": switch to that profile (doesn't need pointer mode either)
|
||||
|
||||
|
||||
def known_action(a):
|
||||
return a in ACTIONS or (isinstance(a, str) and a.startswith(PROFILE) and len(a) > len(PROFILE))
|
||||
|
||||
|
||||
def needs_pointer(a):
|
||||
return a not in FLOAT_ACTIONS and not a.startswith(PROFILE)
|
||||
# Key combinations a rules file without "key_bindings" gets: Meta+Shift+F floats a window.
|
||||
DEFAULT_KEY_BINDINGS = {"42+125+33": "float_toggle"}
|
||||
KEYS = "\0frametop_keys" # keys of keyboards grabbed for the desktop, for other readers
|
||||
@@ -657,7 +667,7 @@ def main():
|
||||
else:
|
||||
state["vr_capture_until"] = 0.0
|
||||
buttons = " ".join(b for b, a in state["rules"]["controller_buttons"].items()
|
||||
if b in VR_BUTTONS and a in ACTIONS and a not in ("key", "none")
|
||||
if b in VR_BUTTONS and known_action(a) and a not in ("key", "none")
|
||||
and a not in GAZE_ACTIONS) or "-"
|
||||
if state["rules"].get("controller_in_games"):
|
||||
buttons = "+games " + buttons
|
||||
@@ -675,7 +685,8 @@ def main():
|
||||
reply(addr, {"t": "event", "id": VR_DEVICE, "path": "", "name": "Steam Frame controllers",
|
||||
"type": "vr", "code": button, "value": value})
|
||||
action = state["rules"]["controller_buttons"].get(button)
|
||||
if state["pointer"] and action in ACTIONS and action not in ("key", "none") and action not in GAZE_ACTIONS:
|
||||
if (state["pointer"] or (action and not needs_pointer(action))) and known_action(action) \
|
||||
and action not in ("key", "none") and action not in GAZE_ACTIONS:
|
||||
do_action(action, value, now, button.split("/")[0])
|
||||
|
||||
def vr_keyboard_mode():
|
||||
@@ -701,10 +712,16 @@ def main():
|
||||
|
||||
def do_action(action, value, now, source="mouse"):
|
||||
"""A mapped mouse or controller button, or key combination (pointer mode only, but
|
||||
for FLOAT_ACTIONS)."""
|
||||
for FLOAT_ACTIONS and profiles)."""
|
||||
if action == "keyboard_toggle":
|
||||
if value == 1 and vr_keyboard_mode() != "never":
|
||||
vr_keyboard("toggle")
|
||||
elif action.startswith(PROFILE):
|
||||
if value == 1:
|
||||
# Runs a few seconds and borrows the pointer, like layout_reset.
|
||||
subprocess.Popen([FT_LAYOUT, "use", action[len(PROFILE):]], stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
log(action)
|
||||
elif action in FLOAT_ACTIONS:
|
||||
if value == 1:
|
||||
try:
|
||||
@@ -726,17 +743,17 @@ def main():
|
||||
return False
|
||||
if value == 0 and code in combos_down:
|
||||
action = combos_down.pop(code)
|
||||
if state["pointer"] or action in FLOAT_ACTIONS:
|
||||
if state["pointer"] or not needs_pointer(action):
|
||||
do_action(action, 0, now, "keyboard")
|
||||
return True
|
||||
if value != 1 or not state["rules"]["key_bindings"]:
|
||||
return value == 2 and code in combos_down
|
||||
combo = "+".join(str(c) for c in sorted(held_modifiers) + [code])
|
||||
action = state["rules"]["key_bindings"].get(combo)
|
||||
if action not in ACTIONS or action in ("key", "none"):
|
||||
if not known_action(action) or action in ("key", "none"):
|
||||
return False
|
||||
combos_down[code] = action
|
||||
if state["pointer"] or action in FLOAT_ACTIONS:
|
||||
if state["pointer"] or not needs_pointer(action):
|
||||
do_action(action, 1, now, "keyboard")
|
||||
log(f"key combination {combo}: {action}")
|
||||
return True
|
||||
|
||||
+237
-3
@@ -18,6 +18,10 @@ you face (yaw only), like a recenter. It lives in ~/.config/frametop-layout.json
|
||||
"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
|
||||
"profiles": {"Work": {"hidden": [3], "windows": [...]}}, what a named layout opens too
|
||||
(docs/profiles.md): screens hidden on their own, and the
|
||||
apps' windows (ft-floatd's "windows")
|
||||
"default_profile": "Work", the profile the desktop starts with (FT_PROFILE overrides)
|
||||
"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);
|
||||
@@ -28,6 +32,8 @@ you face (yaw only), like a recenter. It lives in ~/.config/frametop-layout.json
|
||||
"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%)
|
||||
"hidden": true, ft-screens: hidden on its own, whatever the
|
||||
visibility mode (ft-layout hide N)
|
||||
"pos": [x, y, z], "face": [yaw, pitch], "roll": 0, custom layout
|
||||
"rotation": "normal" | "left" | "right"}, ...], gamescope only
|
||||
"panel_size": [w, h]} gamescope: last measured panel size
|
||||
@@ -42,8 +48,14 @@ 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 save NAME save it as a named layout too, and use that; with the
|
||||
desktop's apps and hidden screens, as a profile
|
||||
ft-layout use NAME switch to a named layout, arrange the screens in it, and
|
||||
open its apps (moving open windows, nothing closed)
|
||||
ft-layout start [--wait SECONDS] desktop start: the profile in FT_PROFILE or default_profile,
|
||||
or else as apply --wait
|
||||
ft-layout open NAME a profile's launcher entry: use it, or start the desktop in it
|
||||
ft-layout default NAME|none the profile the desktop starts with
|
||||
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)
|
||||
@@ -52,6 +64,9 @@ Usage (on the Frame host; Frametop Display Settings calls it too):
|
||||
ft-layout screen-args ft-screens' --screen arguments for the session script
|
||||
ft-layout remote-view the primary screen's place in the workspace, for the VNC bridge
|
||||
ft-layout toggle hide or show all screens (ft-screens)
|
||||
ft-layout hide N|all hide a screen on its own (it stays hidden whatever the
|
||||
ft-layout show N|all visibility mode or the hotkey say), or show it again
|
||||
ft-layout hidden the screens hidden on their own
|
||||
ft-layout pin all|N left|right|head pin screens to a wrist or your head as they are;
|
||||
unpin all|N
|
||||
"""
|
||||
@@ -71,6 +86,9 @@ VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
|
||||
HELPER = "\0ft_pointer_helper"
|
||||
SCREENS = "\0ft_screens"
|
||||
LOCK_PATH = "/tmp/ft-layout.lock"
|
||||
FLOAT = "\0frametop_float" # ft-floatd: the apps' windows (profiles)
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
||||
LAUNCHERS = os.path.expanduser("~/.local/share/applications") # a profile's launcher entry each
|
||||
DEFAULT_PANEL = (1.18, 0.664) # gamescope: a floating 16:9 dashboard panel, measured on the Frame
|
||||
PIXELS_PER_METRE = 800 # ft-screens: a new screen's default size in VR (1920 px: 2.4 m)
|
||||
# controllers: when controllers' lasers work the screens (always | outside_games | dashboard).
|
||||
@@ -393,6 +411,42 @@ def send_visibility(sock, layout):
|
||||
sock.ask(f"ingames {v['in_games']}")
|
||||
|
||||
|
||||
def send_hidden(sock, layout):
|
||||
"""Screens hidden on their own (ft-screens' conceal/reveal)."""
|
||||
for i in range(screen_count(layout)):
|
||||
word = "conceal" if screen_entry(layout, i).get("hidden") else "reveal"
|
||||
try:
|
||||
sock.ask(f"{word} {i + 1}")
|
||||
except RuntimeError as e:
|
||||
log(f"screens hidden on their own: {e}") # an ft-screens from before conceal
|
||||
return
|
||||
|
||||
|
||||
def set_hidden(which, hidden):
|
||||
"""Hide (or show) screen N (1-based) or "all" on its own: saved, and applied if the
|
||||
desktop runs."""
|
||||
layout = load_layout()
|
||||
n = screen_count(layout)
|
||||
picked = range(n) if which == "all" else [int(which) - 1] if which.isdigit() else []
|
||||
if not picked or not all(0 <= i < n for i in picked):
|
||||
raise RuntimeError(f"no screen {which} (1 to {n})")
|
||||
screens = layout.setdefault("screens", [])
|
||||
while len(screens) < n:
|
||||
screens.append({})
|
||||
for i in picked:
|
||||
if hidden:
|
||||
screens[i]["hidden"] = True
|
||||
else:
|
||||
screens[i].pop("hidden", None)
|
||||
save_layout(layout)
|
||||
try:
|
||||
sock = screens_socket()
|
||||
for i in picked:
|
||||
sock.ask(f"{'conceal' if hidden else 'reveal'} {i + 1}")
|
||||
except RuntimeError as e:
|
||||
log(f"saved; not applied now: {e}")
|
||||
|
||||
|
||||
def parse_get(reply):
|
||||
"""ft-screens' "get": pose, size, curve, and the pin (hand and controller->screen)."""
|
||||
f = reply.split()[1:]
|
||||
@@ -441,6 +495,7 @@ def apply_screens(wait=0):
|
||||
sock.ask(f"pin {i + 1} {pin['hand']} " + " ".join(f"{v:.5f}" for v in pin["rel"]))
|
||||
except RuntimeError as e:
|
||||
log(f"screen {i + 1}: {e}") # that controller isn't on
|
||||
send_hidden(sock, layout)
|
||||
try:
|
||||
sock.ask("vrkeyboard close") # the keyboard, if open, goes too: a reset starts over
|
||||
except RuntimeError:
|
||||
@@ -654,6 +709,11 @@ def rename_named(layout, old, new):
|
||||
if new != old and new in named:
|
||||
raise RuntimeError(f"there's already a layout called {new!r}")
|
||||
named[new] = named.pop(old)
|
||||
profiles = layout.get("profiles", {})
|
||||
if old in profiles:
|
||||
profiles[new] = profiles.pop(old)
|
||||
if layout.get("default_profile") == old:
|
||||
layout["default_profile"] = new
|
||||
if layout.get("active") == old:
|
||||
layout["active"] = new
|
||||
return new
|
||||
@@ -663,10 +723,133 @@ 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}")
|
||||
layout.get("profiles", {}).pop(name, None)
|
||||
if layout.get("default_profile") == name:
|
||||
layout.pop("default_profile")
|
||||
if layout.get("active") == name:
|
||||
layout.pop("active")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- profiles (docs/profiles.md)
|
||||
|
||||
def ask_float(text, timeout=6.0):
|
||||
"""ft-floatd (floating windows, in the desktop's session); None if it isn't running."""
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
sock.bind("")
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.sendto(text.encode(), FLOAT)
|
||||
return sock.recv(1 << 20).decode()
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def capture_profile(layout, name):
|
||||
"""The desktop's apps and hidden screens into profile `name` (kept as they were if
|
||||
ft-floatd doesn't answer)."""
|
||||
hidden = [i + 1 for i in range(screen_count(layout)) if screen_entry(layout, i).get("hidden")]
|
||||
profile = layout.setdefault("profiles", {}).setdefault(name, {})
|
||||
profile["hidden"] = hidden
|
||||
reply = ask_float("windows")
|
||||
if reply and reply.startswith("ok "):
|
||||
profile["windows"] = json.loads(reply[3:])
|
||||
log(f"profile {name!r}: {len(profile['windows'])} windows, hidden screens {hidden or 'none'}")
|
||||
else:
|
||||
log(f"profile {name!r}: the apps weren't saved ({reply or 'ft-floatd is not running'})")
|
||||
|
||||
|
||||
def use_hidden(layout, name):
|
||||
"""A profile's hidden screens as the screens' own setting (applied with the arrangement)."""
|
||||
profile = layout.get("profiles", {}).get(name)
|
||||
if profile is None:
|
||||
return
|
||||
hidden = set(profile.get("hidden", []))
|
||||
screens = layout.setdefault("screens", [])
|
||||
for i in range(screen_count(layout)):
|
||||
while len(screens) <= i:
|
||||
screens.append({})
|
||||
if i + 1 in hidden:
|
||||
screens[i]["hidden"] = True
|
||||
else:
|
||||
screens[i].pop("hidden", None)
|
||||
|
||||
|
||||
def open_apps(name, wait=0):
|
||||
"""Have ft-floatd open a profile's apps (waiting up to `wait` seconds for it to start)."""
|
||||
if not load_layout().get("profiles", {}).get(name, {}).get("windows"):
|
||||
return
|
||||
deadline = time.time() + wait
|
||||
while True:
|
||||
reply = ask_float(f"profile {name}")
|
||||
if reply is not None or time.time() >= deadline:
|
||||
break
|
||||
time.sleep(1)
|
||||
log(f"apps: {reply or 'ft-floatd is not running'}")
|
||||
|
||||
|
||||
def launcher_name(name):
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "profile"
|
||||
return f"frametop-profile-{slug}.desktop"
|
||||
|
||||
|
||||
def write_launchers(layout):
|
||||
"""A launcher entry for each profile (SteamVR's Launch a program list, the Application
|
||||
Launcher, KRunner), and none for ones that are gone."""
|
||||
os.makedirs(LAUNCHERS, exist_ok=True)
|
||||
want = {}
|
||||
for name in layout_names(layout):
|
||||
# Quoted for Exec (\" \` \$ \\), then each backslash doubled for the key file.
|
||||
quoted = ('"' + re.sub(r'(["`$\\])', r"\\\1", name) + '"').replace("\\", "\\\\")
|
||||
want[launcher_name(name)] = "\n".join([
|
||||
"[Desktop Entry]", "Type=Application", f"Name=Frametop: {name}",
|
||||
"Comment=Open the Frametop desktop in this profile: its screens and apps",
|
||||
f"Exec={os.path.join(REPO, 'layout', 'ft-layout')} open {quoted}",
|
||||
"Icon=preferences-desktop-display", "Categories=Utility;", "X-Frametop-Profile=true", ""])
|
||||
for f in os.listdir(LAUNCHERS):
|
||||
if f.startswith("frametop-profile-") and f.endswith(".desktop") and f not in want:
|
||||
os.remove(os.path.join(LAUNCHERS, f))
|
||||
for f, text in want.items():
|
||||
path = os.path.join(LAUNCHERS, f)
|
||||
try:
|
||||
with open(path) as old:
|
||||
if old.read() == text:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
with open(path, "w") as out:
|
||||
out.write(text)
|
||||
|
||||
|
||||
def start_profile(layout):
|
||||
"""The profile the desktop starts with: FT_PROFILE, else default_profile."""
|
||||
name = os.environ.get("FT_PROFILE") or layout.get("default_profile")
|
||||
return name if name and name in layout.get("layouts", {}) else None
|
||||
|
||||
|
||||
def desktop_running():
|
||||
try:
|
||||
screens_socket().ask("screens", timeout=2)
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
|
||||
def start_desktop(profile):
|
||||
"""Start the Frametop desktop in a profile (as desktops.sh start does)."""
|
||||
if subprocess.run(["pgrep", "-x", "vrcompositor"], capture_output=True).returncode != 0:
|
||||
raise RuntimeError("SteamVR isn't running")
|
||||
subprocess.run(["systemctl", "--user", "reset-failed", "frametop-desktop"], capture_output=True)
|
||||
session = os.path.join(REPO, "session", "frametop-session.sh")
|
||||
r = subprocess.run(["systemd-run", "--user", "--collect", "--quiet", "--unit", "frametop-desktop",
|
||||
f"--setenv=FT_PROFILE={profile}", "bash", "-c",
|
||||
f'exec "{session}" > /tmp/frametop-session.log 2>&1'], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"couldn't start the desktop: {r.stderr.strip()}")
|
||||
log(f"starting the desktop in {profile!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- KWin (scale, positions, primary)
|
||||
|
||||
def nested_env():
|
||||
@@ -855,6 +1038,11 @@ def main(argv):
|
||||
print(remote_view())
|
||||
elif cmd == "toggle":
|
||||
log(screens_socket().ask("toggle"))
|
||||
elif cmd in ("hide", "show") and len(argv) == 3:
|
||||
set_hidden(argv[2], cmd == "hide")
|
||||
elif cmd == "hidden":
|
||||
layout = load_layout()
|
||||
print(" ".join(str(i + 1) for i in range(screen_count(layout)) if screen_entry(layout, i).get("hidden")))
|
||||
elif cmd == "layouts":
|
||||
layout = load_layout()
|
||||
for name in layout_names(layout):
|
||||
@@ -866,6 +1054,46 @@ def main(argv):
|
||||
else:
|
||||
delete_named(layout, argv[2])
|
||||
save_layout(layout)
|
||||
write_launchers(layout)
|
||||
elif cmd == "default" and len(argv) == 3:
|
||||
layout = load_layout()
|
||||
if argv[2] == "none":
|
||||
layout.pop("default_profile", None)
|
||||
elif argv[2] in layout.get("layouts", {}):
|
||||
layout["default_profile"] = argv[2]
|
||||
else:
|
||||
raise RuntimeError(f"no profile called {argv[2]!r}")
|
||||
save_layout(layout)
|
||||
elif cmd == "open" and len(argv) == 3:
|
||||
if argv[2] not in load_layout().get("layouts", {}):
|
||||
raise RuntimeError(f"no profile called {argv[2]!r}")
|
||||
if desktop_running():
|
||||
return main([argv[0], "use", argv[2]])
|
||||
start_desktop(argv[2])
|
||||
elif cmd == "start":
|
||||
# Desktop start (the session script): the profile it starts with, or the arrangement.
|
||||
layout = load_layout()
|
||||
name = start_profile(layout)
|
||||
if not name:
|
||||
return main([argv[0], "apply"] + argv[2:])
|
||||
use_named(layout, name)
|
||||
use_hidden(layout, name)
|
||||
save_layout(layout)
|
||||
log(f"starting in profile {name!r}")
|
||||
wait = float(argv[argv.index("--wait") + 1]) if "--wait" in argv else 60
|
||||
with open(LOCK_PATH, "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
apply(wait)
|
||||
for _ in range(30): # Plasma may still be starting
|
||||
try:
|
||||
log("kwin: " + (" ".join(apply_scales()) or "unchanged"))
|
||||
break
|
||||
except RuntimeError as e:
|
||||
last = e
|
||||
time.sleep(1)
|
||||
else:
|
||||
log(f"kwin: {last}")
|
||||
open_apps(name, wait=90) # ft-floatd starts with Plasma
|
||||
elif cmd in ("pin", "unpin") and len(argv) >= 3:
|
||||
log(screens_socket().ask(" ".join(argv[1:])))
|
||||
kwin_follow() # pinned screens go last
|
||||
@@ -887,6 +1115,7 @@ def main(argv):
|
||||
while screens_up(sock) < screen_count() and time.time() < deadline:
|
||||
time.sleep(1)
|
||||
send_visibility(sock, load_layout())
|
||||
send_hidden(sock, load_layout())
|
||||
except RuntimeError as e:
|
||||
log(f"visibility: {e}")
|
||||
else:
|
||||
@@ -912,12 +1141,16 @@ def main(argv):
|
||||
check_name(argv[2])
|
||||
capture()
|
||||
layout = load_layout()
|
||||
log(f"saved layout {save_named(layout, argv[2])!r}")
|
||||
name = save_named(layout, argv[2])
|
||||
capture_profile(layout, name)
|
||||
save_layout(layout)
|
||||
write_launchers(layout)
|
||||
log(f"saved layout {name!r}")
|
||||
kwin_follow()
|
||||
elif cmd == "use":
|
||||
layout = load_layout()
|
||||
use_named(layout, argv[2])
|
||||
use_hidden(layout, argv[2])
|
||||
save_layout(layout)
|
||||
log(f"using layout {argv[2]!r}")
|
||||
try:
|
||||
@@ -926,6 +1159,7 @@ def main(argv):
|
||||
log(f"not arranged now: {e}")
|
||||
else:
|
||||
kwin_follow()
|
||||
open_apps(argv[2])
|
||||
else:
|
||||
changes = apply_scales()
|
||||
log("kwin: " + (" ".join(changes) if changes else "unchanged"))
|
||||
|
||||
+19
-1
@@ -31,6 +31,9 @@
|
||||
// - 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).
|
||||
// - a screen hidden on its own ("conceal <screen>", from ft-layout and profiles) stays
|
||||
// hidden whatever the mode or the hotkey says, until "reveal <screen>". (Not "hide
|
||||
// <screen>": an older build reads anything starting with "hide" as the hotkey's hide.)
|
||||
// - controllers on the screens: while visible, the screens can keep SteamVR's laser mouse
|
||||
// on (VROverlayFlags_MakeOverlaysInteractiveIfVisible), so controllers use them with
|
||||
// the dashboard closed. That also takes the controllers away from a VR game, so by
|
||||
@@ -245,6 +248,7 @@ struct Screen {
|
||||
double curve = 0; // cylinder radius in metres; 0 = flat
|
||||
const void *shown = nullptr; // a frame arrived
|
||||
bool visible = false; // shown in VR right now
|
||||
bool alone = false; // hidden on its own (conceal <screen>), whatever the mode
|
||||
float alpha = 1;
|
||||
vr::TrackedDeviceIndex_t pinned = kNone; // riding on this controller
|
||||
Mat pinRel = Identity(); // controller -> screen
|
||||
@@ -788,7 +792,7 @@ void UpdateVisibility() {
|
||||
Mat head;
|
||||
const bool haveHead = DevicePose(vr::k_unTrackedDeviceIndex_Hmd, &head);
|
||||
for (auto &[i, s] : g_screens) {
|
||||
bool visible = s.shown && (shared || s.drag != Drag::None);
|
||||
bool visible = s.shown && (shared || s.drag != Drag::None) && !s.alone;
|
||||
// A floating window's panel: while a window floats on it, its output is on, and the
|
||||
// window isn't minimized (and once it has a crop).
|
||||
if (s.floating) visible = visible && s.floatOn && s.outputOn && !s.minimized && s.cropW > 0;
|
||||
@@ -1944,6 +1948,8 @@ void ft_vr_keyboard_hide(void) {
|
||||
// wrist <degrees> a pinned screen shows while you see its front within this
|
||||
// gesture <left|right> <degrees> the gesture mode: look within this of that controller
|
||||
// hide | show | toggle the manual switch (see g_manual)
|
||||
// conceal <screen|all> | reveal <screen|all> a screen hidden on its own, whatever the mode
|
||||
// concealed -> "ok [<screen> ...]" the screens hidden on their own
|
||||
// controllers always|outside_games|dashboard when controllers' lasers work the screens
|
||||
// ingames hide|visible during a VR game, "always" acts like "only with the dashboard"
|
||||
// (hide), or stays as it is (visible)
|
||||
@@ -2076,6 +2082,18 @@ void ft_vr_command(const char *cmd, char *reply, int size) {
|
||||
g_gestureHand = std::strcmp(hand, "right") == 0 ? "right" : "left";
|
||||
g_gestureAngle = std::clamp(w, 5.0, 90.0);
|
||||
std::snprintf(reply, size, "ok");
|
||||
} else if (std::strncmp(cmd, "concealed", 9) == 0) {
|
||||
int len = std::snprintf(reply, size, "ok");
|
||||
for (auto &[i, s] : g_screens)
|
||||
if (len < size && !s.floating && s.alone) len += std::snprintf(reply + len, size - len, " %d", i + 1);
|
||||
} else if (std::sscanf(cmd, "conceal %15s", word) == 1 || std::sscanf(cmd, "reveal %15s", word) == 1) {
|
||||
const bool hide = cmd[0] == 'c';
|
||||
const Screen *one = std::strcmp(word, "all") ? Find(std::atoi(word)) : nullptr;
|
||||
if (std::strcmp(word, "all") && (!one || one->floating))
|
||||
return (void)std::snprintf(reply, size, "error no screen %s", word);
|
||||
each(word, [&](Screen &s) { s.alone = hide; });
|
||||
UpdateVisibility();
|
||||
std::snprintf(reply, size, "ok");
|
||||
} else if (!std::strncmp(cmd, "hide", 4) || !std::strncmp(cmd, "show", 4) || !std::strncmp(cmd, "toggle", 6)) {
|
||||
const bool always = EffectiveMode() == Mode::Always;
|
||||
const bool shownNow = always ? !g_manual : g_manual;
|
||||
|
||||
@@ -70,9 +70,10 @@ if [ "${1:-}" != --inner ]; then
|
||||
# Plasma can't find them and opens Discover instead.
|
||||
export XDG_DATA_DIRS=${XDG_DATA_DIRS:-/usr/local/share:/usr/share}
|
||||
set +u; . /etc/profile.d/flatpak.sh; set -u
|
||||
# Arrange the screens in the saved layout once they're up (layout; skipped
|
||||
# when auto-arrange is off).
|
||||
setsid "$here/../layout/ft-layout" apply --wait 90 > /tmp/frametop-layout.log 2>&1 < /dev/null &
|
||||
# Arrange the screens once they're up: in the profile this desktop starts with (FT_PROFILE,
|
||||
# from a profile's launcher entry, or the default profile), which also opens its apps, or
|
||||
# else in the saved layout (skipped when auto-arrange is off). docs/profiles.md.
|
||||
setsid "$here/../layout/ft-layout" start --wait 90 > /tmp/frametop-layout.log 2>&1 < /dev/null &
|
||||
|
||||
if [ "$backend" = gamescope ]; then
|
||||
export ENABLE_GAMESCOPE_WSI=1 GAMESCOPE_MANGOAPP_SOCKET_DISABLE=1
|
||||
@@ -223,4 +224,8 @@ elif [ "$(kreadconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key theme
|
||||
kwriteconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key theme --delete
|
||||
fi
|
||||
|
||||
# Profiles reopen apps (docs/profiles.md), so Plasma's own session restore stays off here;
|
||||
# with both, apps would open twice.
|
||||
kwriteconfig6 --file "$XDG_CONFIG_HOME/ksmserverrc" --group General --key loginMode emptySession
|
||||
|
||||
dbus-run-session startplasma-wayland
|
||||
Reference in new issue
Block a user