Files
Panama/config/dot/quickshell/scripts/panama-project
T
Gabriel Brown 185d7edaa5 Open the windows you open together, together
Arrange the desktop, run "Save Layout as Project" from the launcher, name it.
"Open Project" lays it out again on free workspaces, so it never lands on top of
what you are already doing. Saved layouts are listed on the Desktop settings
page, which is where they are removed.

Recorded rather than written by hand, and a terminal's directory is most of why
it is worth having: without it a project opens three terminals in your home
folder and you change directory three times. This machine had two terminals in
the same project when it was written, and reopening there is the difference
between a layout and a working desktop.

Four things had to be found by running it, none of which reading would have
shown.

A terminal's directory is not the terminal's working directory -- that is where
it was launched from. The shell inside it is what has been cd'd. Reading the
wrong one looked correct for exactly as long as the terminals under test had
been started from the right place, which they had.

gtk-launch cannot place a window. It activates over D-Bus, so the process
Hyprland started exits and a [workspace N silent] rule has nothing left to apply
to; Nautilus landed on whatever workspace was in front of you. The Exec line
from the desktop entry is run directly instead. But DBusActivatable
applications do the same thing regardless, so the window is found afterwards and
moved by address -- which also means a window that never appeared is reported
rather than assumed.

/proc/PID/task/PID/children is a file of pids, not a directory. Listing it as
one always raised, so the fast path was never once taken and everything went
through pgrep.

And kitty's --directory needs an equals sign or the short -d; the
space-separated long form is accepted and silently ignored.

The contract exercises a save and open round trip against a stubbed compositor,
and reads a terminal's directory out of a real process tree rather than grepping
the source for a shell name -- an earlier version passed against a helper that
had been changed back, because the constant was still there.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 13:45:16 -04:00

423 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
"""Project sessions: the windows you open together, opened together.
panama-project save NAME record the current layout under a name
panama-project open NAME lay it out again on free workspaces
panama-project list JSON: every project and what it contains
panama-project delete NAME forget one
Recorded rather than written by hand. A project is whatever is on screen when
you save it -- which applications, which workspace each was on, and for a
terminal, which directory it was sitting in. That last part is most of the
value: without it a project opens three terminals in your home folder and you
change directory three times.
Workspaces are recorded as positions rather than numbers -- first, second,
third -- and opening a project claims that many free workspaces. A project you
saved from 2, 3 and 4 does not land on top of whatever is on 2, 3 and 4 now.
Windows are launched through Hyprland's Lua dispatch API, because the plain
`hyprctl dispatch exec` form is parsed as Lua on this config and a rules prefix
like [workspace 4 silent] is a syntax error inside it. The same applies to
moving a window by address, which is how an already-running application is
brought into a project rather than launched a second time -- Slack, Thunderbird
and the browser all refuse to open twice and would simply steal focus.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
PROJECT_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "panama/projects"
# Panama's terminal, matching hypr/keybinds.lua. Opening one in a directory is
# the whole reason a project is worth saving, so the flag matters as much as the
# command.
TERMINAL = "kitty"
TERMINAL_DIRECTORY_FLAG = "-d"
# Applications that refuse to open twice: launching them again focuses the
# existing window and drags you off the workspace you were on. These are moved
# into place instead. Matched against the window class, lowercased.
SINGLE_INSTANCE = re.compile(
r"slack|thunderbird|helium|discord|spotify|obsidian|bitwarden|steam|firefox|chrom",
re.IGNORECASE)
NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
def emit(result: dict) -> int:
print(json.dumps(result))
return 0 if result.get("ok") else 2
def hypr(argv: list[str]) -> str:
try:
done = subprocess.run(["hyprctl", *argv], capture_output=True,
text=True, timeout=15, check=False)
return done.stdout
except (OSError, subprocess.SubprocessError):
return ""
def dispatch(lua: str) -> bool:
"""Run one Lua dispatch through hyprctl eval.
eval rather than `hyprctl dispatch`: this configuration is Lua, and the
dispatch subcommand feeds its arguments to the Lua parser, where both
`[workspace 4 silent] kitty` and `address:0x...` are syntax errors.
"""
out = hypr(["eval", lua])
# eval reports failures on stdout and still exits 0, so the exit status
# cannot be trusted and the text is what says whether it worked.
return "error" not in out.lower()
def clients() -> list[dict]:
try:
parsed = json.loads(hypr(["-j", "clients"]) or "[]")
return parsed if isinstance(parsed, list) else []
except json.JSONDecodeError:
return []
SHELLS = {"bash", "zsh", "fish", "sh", "nu"}
def working_directory(pid: int) -> str | None:
"""Where a terminal is actually sitting, read from the shell inside it.
Not the terminal's own cwd, which is where the terminal was launched from
and has nothing to do with where you have since changed to. Reading that
appeared to work here only by coincidence: these terminals were started from
a shell that was already in the project directory, so both answers matched.
One opened from the launcher and then changed into a project would have
recorded the home folder.
/proc is the only place this exists -- the compositor knows a window's class
and title and nothing whatever about what the process inside it is doing.
"""
# A file of space-separated pids, not a directory. Listing it as one always
# raised, which meant this quietly fell through to pgrep every single time
# and the faster path was never once taken.
try:
children = Path(f"/proc/{int(pid)}/task/{int(pid)}/children").read_text().split()
except (OSError, ValueError, TypeError):
children = []
if not children:
try:
children = subprocess.run(["pgrep", "-P", str(int(pid))],
capture_output=True, text=True,
timeout=5, check=False).stdout.split()
except (OSError, subprocess.SubprocessError, ValueError):
return None
for child in " ".join(children).split():
try:
name = Path(f"/proc/{child}/comm").read_text().strip()
if name not in SHELLS:
continue
target = os.readlink(f"/proc/{child}/cwd")
except (OSError, ValueError):
continue
# Home is not worth recording: it is where a terminal opens anyway, and
# storing it would make every ordinary terminal look deliberate.
return None if target == str(Path.home()) else target
return None
# Where desktop entries live, in the order XDG says to prefer them.
APPLICATION_DIRS = [
Path.home() / ".local/share/applications",
Path.home() / ".local/share/flatpak/exports/share/applications",
Path("/var/lib/flatpak/exports/share/applications"),
Path("/usr/local/share/applications"),
Path("/usr/share/applications"),
]
def desktop_id(window_class: str) -> str | None:
"""The desktop entry for a window class, if one carries that name.
Every application on this desktop names its entry after its class --
kitty.desktop, org.gnome.Nautilus.desktop, com.slack.Slack.desktop -- which
is what makes this a lookup rather than a guess. Matched case-insensitively,
because a class is not required to match the filename's case.
"""
wanted = f"{window_class}.desktop".lower()
for directory in APPLICATION_DIRS:
if not directory.is_dir():
continue
for entry in directory.glob("*.desktop"):
if entry.name.lower() == wanted:
return entry.stem
return None
# Placeholders a desktop entry uses for the files it was opened with. Nothing
# is being opened here, so they are removed rather than left to arrive as
# literal text. @@u ... @@ is flatpak's file-forwarding wrapper around the same
# idea, and goes with them.
FIELD_CODES = re.compile(r"@@[uUfF]?|\s%[fFuUdDnNickvm]\b")
def desktop_exec(window_class: str) -> str | None:
"""The command a desktop entry says starts this application.
Read out of the entry rather than handed to gtk-launch, and that is not a
style preference. gtk-launch activates an application over D-Bus, so the
process that appears is not a child of the command Hyprland ran -- and a
[workspace N silent] rule applies to the child. Launching Nautilus that way
put it on whatever workspace was in front of you instead of the project's.
Running the Exec line directly keeps it a child, so the rule lands.
Not /proc/PID/cmdline either: a flatpak reports its own container's path --
Slack is /app/extra/slack -- which does not exist outside the sandbox, so
the command that started it cannot start it again. The entry knows the
`flatpak run` form.
"""
entry = desktop_id(window_class)
if entry is None:
return None
for directory in APPLICATION_DIRS:
candidate = directory / f"{entry}.desktop"
if not candidate.is_file():
continue
try:
for line in candidate.read_text(errors="replace").splitlines():
if line.startswith("Exec="):
command = FIELD_CODES.sub("", line[5:]).strip()
return command or None
except OSError:
return None
return None
def path_for(name: str) -> Path:
return PROJECT_DIR / f"{name}.json"
def save(name: str) -> dict:
if not NAME_PATTERN.match(name):
return {"ok": False, "error": "bad-name"}
# Only real, numbered workspaces. The scratchpad is a place things are put
# aside, not part of a layout, and special workspaces cannot be recreated by
# position anyway.
windows = [w for w in clients()
if str(w.get("workspace", {}).get("name", "")).isdigit()
and not w.get("floating", False)]
if not windows:
return {"ok": False, "error": "nothing-to-save"}
# Workspaces are recorded in the order they appear, then referred to by
# position. What matters is that these three things were on one workspace
# and those two on the next -- not which numbers they happened to have.
order: list[str] = []
grouped: dict[str, list[dict]] = {}
for window in sorted(windows, key=lambda w: int(w["workspace"]["name"])):
workspace = window["workspace"]["name"]
if workspace not in grouped:
grouped[workspace] = []
order.append(workspace)
window_class = str(window.get("class") or window.get("initialClass") or "")
record: dict = {"class": window_class}
if window_class == TERMINAL:
directory = working_directory(window.get("pid", 0))
if directory:
record["directory"] = directory
command = desktop_exec(window_class)
if command:
record["exec"] = command
grouped[workspace].append(record)
project = {
"name": name,
"savedAt": int(time.time()),
"workspaces": [{"windows": grouped[w]} for w in order],
}
PROJECT_DIR.mkdir(parents=True, exist_ok=True)
path_for(name).write_text(json.dumps(project, indent=2) + "\n")
return {"ok": True, "name": name,
"workspaces": len(project["workspaces"]),
"windows": sum(len(w["windows"]) for w in project["workspaces"])}
def free_workspaces(count: int) -> list[int]:
"""Workspace numbers with nothing on them, lowest first.
Fresh ones rather than the recorded numbers, so opening a project never
lands on top of work already in progress. Ten because that is how far the
keybinds reach -- a project on a workspace ALT cannot select is a project
that is hard to get back to.
"""
occupied = {int(w["workspace"]["name"]) for w in clients()
if str(w.get("workspace", {}).get("name", "")).isdigit()}
return [n for n in range(1, 11) if n not in occupied][:count]
def running_window(window_class: str) -> str | None:
"""The address of an already-open window of this class, if there is one."""
for window in clients():
current = str(window.get("class") or window.get("initialClass") or "")
if current.lower() == window_class.lower():
address = window.get("address")
if isinstance(address, str) and address.startswith("0x"):
return address
return None
def place(window: dict, workspace: int) -> str:
"""Get one window onto one workspace. Returns what was done."""
window_class = str(window.get("class", ""))
# An application that refuses to open twice is moved rather than launched.
# Launching it would focus the window it already has and pull the desktop
# to wherever that was -- the opposite of laying out a project.
if SINGLE_INSTANCE.search(window_class):
address = running_window(window_class)
if address is not None:
moved = dispatch(
f'hl.dispatch(hl.dsp.window.move({{ workspace = {workspace}, '
f'follow = false, window = "address:{address}" }}))')
return "moved" if moved else "move-failed"
if window_class == TERMINAL:
directory = window.get("directory")
command = TERMINAL
if isinstance(directory, str) and directory:
# Quoted for the shell Hyprland runs this through, and only if the
# directory still exists -- a terminal that fails to open because a
# project was saved on a machine where that path was there is worse
# than one that opens in home.
if Path(directory).is_dir():
command = f"{TERMINAL} {TERMINAL_DIRECTORY_FLAG} '{directory}'"
else:
command = window.get("exec") or window_class
known = {w.get("address") for w in clients()}
launched = dispatch(
f'hl.dispatch(hl.dsp.exec_cmd("[workspace {workspace} silent] {command}"))')
if not launched:
return "launch-failed"
# The rule is not enough on its own. An application marked DBusActivatable
# -- Nautilus, and most of GNOME -- hands off to its own service and the
# process Hyprland launched exits immediately, so there is no child left for
# `[workspace N silent]` to apply to and the window appears on whatever
# workspace happened to be in front of you.
#
# So the window is found afterwards and moved if it needs it. This is also
# what makes the result honest: a window that never appeared is reported
# rather than assumed.
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
for window in clients():
address = window.get("address")
if address in known or not isinstance(address, str):
continue
if str(window.get("class") or "").lower() != window_class.lower():
continue
if str(window.get("workspace", {}).get("name", "")) == str(workspace):
return "launched"
moved = dispatch(
f'hl.dispatch(hl.dsp.window.move({{ workspace = {workspace}, '
f'follow = false, window = "address:{address}" }}))')
return "launched" if moved else "launched-misplaced"
time.sleep(0.2)
return "no-window"
def open_project(name: str) -> dict:
path = path_for(name)
if not path.is_file():
return {"ok": False, "error": "no-such-project"}
try:
project = json.loads(path.read_text())
except json.JSONDecodeError:
return {"ok": False, "error": "unreadable"}
workspaces = project.get("workspaces", [])
targets = free_workspaces(len(workspaces))
if len(targets) < len(workspaces):
# Better to say so than to cram two workspaces of a project onto one and
# leave somebody wondering why it looks wrong.
return {"ok": False, "error": "not-enough-free-workspaces",
"needed": len(workspaces), "available": len(targets)}
placed = []
for target, workspace in zip(targets, workspaces):
for window in workspace.get("windows", []):
placed.append({"class": window.get("class"), "workspace": target,
"result": place(window, target)})
# Hyprland assigns a launched window to the workspace named in the
# rule, and launching several at once makes that assignment race.
# A short pause between them is the difference between a laid-out
# project and a pile on one workspace.
time.sleep(0.35)
return {"ok": True, "name": name, "workspaces": targets, "placed": placed}
def list_projects() -> dict:
projects = []
if PROJECT_DIR.is_dir():
for path in sorted(PROJECT_DIR.glob("*.json")):
try:
project = json.loads(path.read_text())
except json.JSONDecodeError:
continue
workspaces = project.get("workspaces", [])
projects.append({
"name": project.get("name", path.stem),
"savedAt": project.get("savedAt", 0),
"workspaces": len(workspaces),
"windows": sum(len(w.get("windows", [])) for w in workspaces),
"applications": sorted({str(win.get("class", ""))
for w in workspaces
for win in w.get("windows", [])
if win.get("class")}),
})
return {"ok": True, "projects": projects}
def delete_project(name: str) -> dict:
path = path_for(name)
if not NAME_PATTERN.match(name) or not path.is_file():
return {"ok": False, "error": "no-such-project"}
path.unlink()
return {"ok": True, "name": name}
def main(argv: list[str]) -> int:
command = argv[0] if argv else "list"
argument = argv[1].strip() if len(argv) > 1 else ""
if command == "list":
return emit(list_projects())
if command in {"save", "open", "delete"} and not argument:
return emit({"ok": False, "error": "no-name"})
if command == "save":
return emit(save(argument))
if command == "open":
return emit(open_project(argument))
if command == "delete":
return emit(delete_project(argument))
return emit({"ok": False, "error": "unknown-command"})
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))