mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 02: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
+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"))
|
||||
|
||||
Reference in new issue
Block a user