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
This commit is contained in:
Gabriel Brown
2026-08-21 13:45:16 -04:00
parent 538c0a887c
commit 185d7edaa5
8 changed files with 810 additions and 2 deletions
+14 -1
View File
@@ -101,7 +101,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
130 of them, under `tests/`. Run the lot, or a subset by pattern:
131 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
@@ -123,6 +123,19 @@ tests/quickshell/ The shell and its settings pages
tests/hypr/ The compositor config
```
## Projects
A project is the set of windows you open together — which applications, which
workspace each was on, and for a terminal, which directory it was sitting in.
Arrange the desktop, then run **Save Layout as Project** from the launcher and
name it; **Open Project** lays it out again.
Workspaces are recorded as positions rather than numbers, and opening a project
claims free ones, so it never lands on top of what you are already doing. An
application that refuses to open twice — Slack, Thunderbird, the browser — is
moved into place rather than launched again. Saved layouts are listed on the
Desktop settings page, which is also where they are removed.
## The `panama` command
```sh
@@ -15,6 +15,10 @@ import qs.services
SettingsPage {
id: root
// Which project is one click from being forgotten. Same in-place confirm the
// SSH Keys and Snapshots pages use.
property string confirmingProject: ""
// Turning the last screen off would leave no dock anywhere and no obvious
// way back, so the final one cannot be removed -- it collapses to "every
// screen" instead, which is the same thing on one display and recoverable
@@ -185,6 +189,80 @@ SettingsPage {
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
}
// Saved here, not created here. A layout is worth recording at the moment
// you have it right, so saving is a launcher command; this is where the
// ones you kept are reviewed and the ones you did not are removed.
SettingsCard {
title: "Projects"
subtitle: Projects.projects.length > 0
? "Saved window layouts. Opening one claims free workspaces, so it never lands on top of what you are doing."
: "No saved layouts yet. Arrange your windows, then run \"Save Layout as Project\" from the launcher."
Repeater {
model: Projects.projects
delegate: SettingRow {
id: projectRow
required property var modelData
required property int index
readonly property string projectName: String(projectRow.modelData.name ?? "")
readonly property bool confirming: root.confirmingProject === projectRow.projectName
label: projectRow.projectName
detail: projectRow.confirming
? "Forgetting this only removes the layout. Nothing that is open closes."
: projectRow.modelData.windows + " windows across "
+ projectRow.modelData.workspaces + " workspaces — "
+ (projectRow.modelData.applications ?? []).join(", ")
divider: projectRow.index < Projects.projects.length - 1
controlWidth: 210
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: !projectRow.confirming
text: "Open"
enabled: !Projects.busy
onClicked: Projects.open(projectRow.projectName)
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: projectRow.confirming
text: "Forget it"
tone: "danger"
enabled: !Projects.busy
onClicked: {
root.confirmingProject = "";
Projects.remove(projectRow.projectName);
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: projectRow.confirming ? "Keep" : "Forget"
enabled: !Projects.busy
onClicked: root.confirmingProject =
projectRow.confirming ? "" : projectRow.projectName
}
}
}
}
TextRow {
visible: Projects.lastError !== ""
label: "Problem"
detail: Projects.lastError
divider: false
}
}
SettingsCard {
title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
+422
View File
@@ -0,0 +1,422 @@
#!/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:]))
@@ -0,0 +1,95 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Saved window layouts: what is on disk, and getting rid of the ones that are
// not worth keeping.
//
// Saving deliberately does not live here. A layout is worth recording at the
// moment you have it arranged, and that moment is not one to interrupt by going
// to find a settings page -- so it is a launcher command, and this is where
// they are reviewed and pruned afterwards.
//
// Opening is offered too, but as a convenience rather than the main route: from
// the launcher it is two keystrokes and never leaves the keyboard.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helper: Quickshell.shellDir + "/scripts/panama-project"
property var projects: []
property bool busy: false
property string lastError: ""
Process {
id: listRun
command: [root.helper, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const answer = JSON.parse(this.text);
root.projects = Array.isArray(answer.projects) ? answer.projects : [];
} catch (error) {
root.lastError = "Could not read the saved projects.";
}
}
}
onExited: root.busy = false
}
Process {
id: actionRun
stdout: StdioCollector {
onStreamFinished: {
try {
const answer = JSON.parse(this.text);
if (answer.ok !== true) {
// The one worth saying out loud: a project needs as many
// free workspaces as it was saved with, and being told
// "nothing happened" would be useless.
root.lastError = answer.error === "not-enough-free-workspaces"
? `Needs ${answer.needed} free workspaces; ${answer.available} are free.`
: "That did not work.";
}
} catch (error) {
root.lastError = "That did not work.";
}
}
}
onExited: {
root.busy = false;
root.refresh();
}
}
function refresh(): void {
if (!listRun.running)
listRun.running = true;
}
function open(name: string): void {
if (actionRun.running)
return;
root.lastError = "";
root.busy = true;
actionRun.command = [root.helper, "open", name];
actionRun.running = true;
}
function remove(name: string): void {
if (actionRun.running)
return;
root.lastError = "";
root.busy = true;
actionRun.command = [root.helper, "delete", name];
actionRun.running = true;
}
Component.onCompleted: root.refresh()
}
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Open Project
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Lay a saved project out again on free workspaces.
# @vicinae.keywords ["project", "layout", "open", "session", "restore"]
# @vicinae.argument1 { "type": "text", "placeholder": "project name" }
exec "$HOME/.config/quickshell/scripts/panama-project" open "$1"
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Save Layout as Project
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Record the windows you have open, and where, under a name.
# @vicinae.keywords ["project", "layout", "save", "session", "workspace"]
# @vicinae.argument1 { "type": "text", "placeholder": "project name" }
# Arrange the desktop, then name it. Capturing from the launcher rather than a
# settings page is the point: a layout is worth saving at the moment you have it
# right, and that moment is not one you want to interrupt by going looking for a
# page.
exec "$HOME/.config/quickshell/scripts/panama-project" save "$1"
+4 -1
View File
@@ -60,9 +60,12 @@ done < <(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\([a-z-]*\)"/\1/')
# when Quickshell is down -- which is exactly when somebody is reaching for the
# launcher to look up what went wrong.
#
# The project commands are the same shape: they record or restore a window
# layout, which is panama-project's job and involves the shell not at all.
#
# They are still commands, so everything else below applies to them: a title,
# a description, search vocabulary, the Panama icon, and closing quietly.
declare -a standalone=(search-web)
declare -a standalone=(search-web save-project open-project)
# Generated commands must match their source. A stale command dispatches to a
# page that has been renamed or removed, and the launcher reports nothing wrong.
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# Project sessions: the windows you open together, opened together.
#
# Three things here are easy to get wrong and were, each caught by running it
# rather than reading it:
#
# * A terminal's directory is not the terminal's own working directory. That
# is where the terminal was launched from; the shell inside it is what has
# been cd'd. Reading the wrong one appeared correct for exactly as long as
# the test terminals happened to have been started from the right place.
# * gtk-launch cannot place a window. It activates over D-Bus, so the process
# Hyprland started exits and a [workspace N silent] rule has no child to
# apply to -- the window lands wherever you were looking.
# * DBusActivatable applications do the same thing even when launched
# directly, so the window has to be found and moved afterwards.
#
# The save/open round trip is exercised for real, against a fake HOME and a
# stubbed hyprctl, so no window is opened on the machine running the tests.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-project"
desktop_page="$repo_dir/config/dot/quickshell/modules/settings/DesktopPage.qml"
service="$repo_dir/config/dot/quickshell/services/Projects.qml"
findings=()
note() { findings+=("$1"); }
[[ -x "$helper" ]] || { printf 'projects contract: helper is missing or not executable\n' >&2; exit 1; }
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/bin" "$work/home"
# A compositor that reports two windows on two workspaces and accepts every
# dispatch, recording what it was asked to do.
cat >"$work/bin/hyprctl" <<'STUB'
#!/usr/bin/env bash
if [[ "$1" == "-j" && "$2" == "clients" ]]; then
cat "$HYPRCTL_CLIENTS"
exit 0
fi
if [[ "$1" == "eval" ]]; then
printf '%s\n' "$2" >>"$HYPRCTL_LOG"
echo ok
exit 0
fi
echo "{}"
STUB
chmod +x "$work/bin/hyprctl"
cat >"$work/clients.json" <<'JSON'
[
{"address":"0x1","class":"kitty","workspace":{"name":"2"},"floating":false,"pid":1},
{"address":"0x2","class":"org.gnome.Nautilus","workspace":{"name":"3"},"floating":false,"pid":2},
{"address":"0x3","class":"bitwarden","workspace":{"name":"special:scratch"},"floating":true,"pid":3}
]
JSON
export HYPRCTL_CLIENTS="$work/clients.json"
export HYPRCTL_LOG="$work/dispatch.log"
: >"$HYPRCTL_LOG"
run() { PATH="$work/bin:$PATH" HOME="$work/home" XDG_DATA_HOME="$work/home/.local/share" "$helper" "$@"; }
# ── Saving ───────────────────────────────────────────────────────────────────
saved="$(run save demo)"
[[ "$(jq -r '.ok' <<<"$saved")" == "true" ]] || note "saving a layout failed: $saved"
# Two workspaces, not three: the scratchpad is somewhere things are put aside,
# not part of a layout, and a floating window is not a tiled arrangement.
[[ "$(jq -r '.workspaces' <<<"$saved")" == "2" ]] \
|| note "a layout with 2 tiled workspaces and a scratchpad saved $(jq -r '.workspaces' <<<"$saved") workspaces"
[[ "$(jq -r '.windows' <<<"$saved")" == "2" ]] \
|| note 'the scratchpad or a floating window was recorded as part of the layout'
stored="$work/home/.local/share/panama/projects/demo.json"
[[ -f "$stored" ]] || note 'saving produced no project file'
if [[ -f "$stored" ]]; then
# Positions, not numbers. A project saved from 2 and 3 must not insist on 2
# and 3, or opening it lands on top of whatever is there now.
jq -e '.workspaces | type == "array"' "$stored" >/dev/null 2>&1 \
|| note 'workspaces are not stored as an ordered list of positions'
jq -e '[.workspaces[].windows[] | has("index")] | any' "$stored" >/dev/null 2>&1 \
&& note 'a workspace number was recorded, so the project would demand specific workspaces'
# gtk-launch cannot place a window; the Exec line can.
jq -r '.workspaces[].windows[].exec // ""' "$stored" | grep -q 'gtk-launch' \
&& note 'projects launch through gtk-launch, which activates over D-Bus and cannot be placed on a workspace'
fi
# ── Opening ──────────────────────────────────────────────────────────────────
: >"$HYPRCTL_LOG"
opened="$(run open demo)"
[[ "$(jq -r '.ok' <<<"$opened")" == "true" ]] || note "opening a project failed: $opened"
# Free workspaces. 2 and 3 are occupied by the stub's own windows, so a project
# saved from them must be laid out somewhere else entirely.
targets="$(jq -r '.workspaces | join(",")' <<<"$opened")"
[[ "$targets" == "1,4" ]] \
|| note "a project opened onto [$targets] when 2 and 3 were occupied; it should claim free workspaces"
grep -q 'workspace 1 silent' "$HYPRCTL_LOG" \
|| note 'windows are not launched with a workspace rule'
grep -q 'exec_cmd' "$HYPRCTL_LOG" \
|| note 'the Lua dispatch form is not used, and the plain hyprctl dispatch form is a syntax error on this config'
# ── Terminal directories ─────────────────────────────────────────────────────
# Exercised against a real process tree rather than grepped for. Checking that
# the source mentions a shell name proves only that somebody wrote the word: an
# earlier version of this contract passed against a helper that had been changed
# back to reading the wrong process, because the constant was still there.
#
# The shape below is the shape that matters -- a parent sitting in one directory
# with a shell child in another, which is exactly a terminal you have cd'd
# inside. The answer must be the child's directory.
mkdir -p "$work/parent" "$work/child"
# exec, so the subshell is replaced by python and $! is python's own pid rather
# than a shell that has already gone.
( cd "$work/parent" && exec python3 -c "
import subprocess, time
subprocess.Popen(['bash', '-c', 'sleep 30; :'], cwd='$work/child')
time.sleep(30)
" ) >/dev/null 2>&1 &
parent_pid=$!
sleep 2
if [[ -n "$parent_pid" ]]; then
answer="$(PATH="$work/bin:$PATH" HOME="$work/home" python3 - "$helper" "$parent_pid" <<'PROBE'
import importlib.machinery, importlib.util, sys
spec = importlib.util.spec_from_loader("pp",
importlib.machinery.SourceFileLoader("pp", sys.argv[1]))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
print(module.working_directory(int(sys.argv[2])) or "")
PROBE
)"
[[ "$answer" == "$work/child" ]] \
|| note "a terminal's directory is read as '$answer' rather than the shell's '$work/child' -- the terminal own working directory is where it was launched from, not where you have changed to"
kill "$parent_pid" 2>/dev/null || true
fi
# -d, not --directory with a space, which kitty accepts and silently ignores.
grep -qE 'TERMINAL_DIRECTORY_FLAG = "-d"' "$helper" \
|| note 'the terminal directory flag is not -d; the space-separated long form is ignored without an error'
# ── Placement has to be verified, not assumed ────────────────────────────────
grep -q 'hl.dsp.window.move' "$helper" \
|| note 'a window that lands on the wrong workspace is never moved, so DBusActivatable applications stay where they appeared'
# ── The page ─────────────────────────────────────────────────────────────────
grep -q 'Projects.projects' "$desktop_page" \
|| note 'the Desktop page does not list saved projects'
grep -q 'confirmingProject' "$desktop_page" \
|| note 'a project can be deleted without confirming'
grep -q '"list"' "$service" \
|| note 'the settings service never reads what is saved'
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'projects contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'projects contract: PASS\n'