Profiles: named layouts that open apps

A profile is a named layout plus the screens it hides and its apps' windows
(docs/profiles.md). ft-layout save captures them (ft-floatd's "windows",
after the KWin script reports every window as it is now); use opens them:
the screens move, open windows of each app go to their places (on a screen,
maximized or not, or floating), and missing apps start, once and then again
for each window still missing 3 s after the first. Nothing closes.

The desktop starts in FT_PROFILE or default_profile (ft-layout start, from
the session script). Each profile gets a launcher entry (Frametop: NAME, in
SteamVR's Launch a program list) that switches to it or starts the desktop
in it. The relay's profile:NAME action and Input Settings' "Open profile"
entries put one on a key, mouse button, or controller button. Display
Settings' Layout page becomes Layout & profiles: Save as profile, Open
profile, the profile's apps, and Start in profile. Plasma's own session
restore is off in the Frametop session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-30 22:32:10 -06:00
1 parent 9d91ecbcaa
commit 858dbe1562
11 files changed
+619 -72

No files matched your search

+2 -1
View File
@@ -52,7 +52,8 @@ 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>` |
+48 -3
View File
@@ -584,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):
@@ -607,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):
+66 -9
View File
@@ -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.
+10 -10
View File
@@ -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
+4 -2
View File
@@ -139,8 +139,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 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)
+6 -1
View File
@@ -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;
+230 -22
View File
@@ -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.
"""
@@ -199,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
@@ -252,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
@@ -327,11 +364,19 @@ 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")
@@ -589,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)."""
@@ -660,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."""
@@ -694,8 +742,9 @@ 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
@@ -731,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):
@@ -768,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])
@@ -782,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":
@@ -904,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:
@@ -911,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:
+32 -11
View File
@@ -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
View File
@@ -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
+189 -3
View File
@@ -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);
@@ -44,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)
@@ -76,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).
@@ -696,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
@@ -705,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():
@@ -913,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
@@ -960,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:
@@ -974,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"))
+8 -3
View File
@@ -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