mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 01:00:06 +02:00
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:
1 parent
9d91ecbcaa
commit
858dbe1562
11 files changed
+619
-72
No files matched your search
@@ -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>` |
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+189
-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);
|
||||
@@ -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"))
|
||||
|
||||
@@ -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