diff --git a/README.md b/README.md index c501a56..9304491 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index e2524a4..e3f16b0 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -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." diff --git a/config/dot/quickshell/scripts/panama-project b/config/dot/quickshell/scripts/panama-project new file mode 100755 index 0000000..5abde87 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-project @@ -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:])) diff --git a/config/dot/quickshell/services/Projects.qml b/config/dot/quickshell/services/Projects.qml new file mode 100644 index 0000000..37e7fb6 --- /dev/null +++ b/config/dot/quickshell/services/Projects.qml @@ -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() +} diff --git a/config/local/share/vicinae/scripts/open-project b/config/local/share/vicinae/scripts/open-project new file mode 100755 index 0000000..48a15ce --- /dev/null +++ b/config/local/share/vicinae/scripts/open-project @@ -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" diff --git a/config/local/share/vicinae/scripts/save-project b/config/local/share/vicinae/scripts/save-project new file mode 100755 index 0000000..578b6b7 --- /dev/null +++ b/config/local/share/vicinae/scripts/save-project @@ -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" diff --git a/tests/quickshell/panama-commands-contract b/tests/quickshell/panama-commands-contract index c222fd0..a8682fc 100755 --- a/tests/quickshell/panama-commands-contract +++ b/tests/quickshell/panama-commands-contract @@ -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. diff --git a/tests/setup/projects-contract b/tests/setup/projects-contract new file mode 100755 index 0000000..e954fa7 --- /dev/null +++ b/tests/setup/projects-contract @@ -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'