Compare commits
2
Commits
538c0a887c
...
41b86c7a9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41b86c7a9e | ||
|
|
185d7edaa5 |
@@ -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
|
||||
|
||||
@@ -1334,7 +1334,7 @@ Singleton {
|
||||
"com.bitwarden.desktop", "org.gnome.Software", "helium",
|
||||
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||
"io.podman_desktop.PodmanDesktop", "claude-desktop",
|
||||
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
|
||||
"codex-desktop", "md.obsidian.Obsidian",
|
||||
"com.obsproject.Studio", "steam"
|
||||
]
|
||||
|
||||
@@ -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."
|
||||
|
||||
Executable
+422
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
Executable
+173
@@ -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'
|
||||
Reference in New Issue
Block a user