···11+#!/usr/bin/env python3
22+"""Place new sway windows on the workspace of the process that spawned them.
33+44+Sway maps a new view onto the *currently focused* workspace. That is wrong
55+whenever the spawning process lives elsewhere: a build finishing in a terminal
66+on workspace "strom" pops its window onto whatever workspace happens to be
77+focused at that moment.
88+99+This daemon subscribes to sway's window::new events and resolves the workspace
1010+of the spawning process by walking /proc PPid links until it hits a pid that
1111+already owns a window. Terminals here exec tmux, which breaks the ppid chain at
1212+the (session-global) tmux server, so the walk also hops tmux pane -> session ->
1313+attached client pid and continues from there.
1414+1515+Windows whose ancestry cannot be resolved (systemd user services, dbus
1616+activation, single-process multi-window apps like Firefox) keep sway's default
1717+behaviour.
1818+"""
1919+2020+from __future__ import annotations
2121+2222+import json
2323+import subprocess
2424+import sys
2525+from collections.abc import Iterator
2626+from typing import Any
2727+2828+# Sway's hidden scratchpad workspace; views parked there must never be moved,
2929+# and a view living there is not a placement hint for anything else.
3030+SCRATCHPAD = "__i3_scratch"
3131+3232+3333+def run(args: list[str]) -> str | None:
3434+ """Run a command, returning its stdout, or None when it failed."""
3535+ try:
3636+ proc = subprocess.run(args, capture_output=True, text=True, check=False)
3737+ except OSError as e:
3838+ print(f"failed to run {args[0]}: {e}", file=sys.stderr)
3939+ return None
4040+ if proc.returncode != 0:
4141+ return None
4242+ return proc.stdout
4343+4444+4545+def swaymsg_json(*args: str) -> Any:
4646+ """Query sway IPC, returning the parsed reply or None."""
4747+ out = run(["swaymsg", "-r", "-t", *args])
4848+ if out is None:
4949+ return None
5050+ try:
5151+ return json.loads(out)
5252+ except json.JSONDecodeError as e:
5353+ print(f"failed to parse sway reply: {e}", file=sys.stderr)
5454+ return None
5555+5656+5757+def iter_views(
5858+ node: dict[str, Any], workspace: str | None
5959+) -> Iterator[tuple[str, int, int]]:
6060+ """Yield (workspace name, container id, pid) for every view in the tree."""
6161+ if node.get("type") == "workspace":
6262+ workspace = node.get("name")
6363+ pid = node.get("pid")
6464+ if pid is not None and workspace is not None:
6565+ yield workspace, int(node["id"]), int(pid)
6666+ for key in ("nodes", "floating_nodes"):
6767+ for child in node.get(key, []):
6868+ yield from iter_views(child, workspace)
6969+7070+7171+def parent_pid(pid: int) -> int | None:
7272+ """Read PPid from /proc, or None if the process is gone."""
7373+ try:
7474+ with open(f"/proc/{pid}/status", encoding="utf-8") as f:
7575+ for line in f:
7676+ if line.startswith("PPid:"):
7777+ return int(line.split()[1])
7878+ except (OSError, ValueError):
7979+ return None
8080+ return None
8181+8282+8383+def tmux_hops() -> dict[int, list[int]]:
8484+ """Map each tmux pane's root pid to the pids of its session's clients.
8585+8686+ A process started inside a pane has that pane's root pid as an ancestor,
8787+ but its ppid chain leads to the tmux server, not to the terminal window.
8888+ The attached client processes are children of that terminal, so they are
8989+ where the walk has to continue.
9090+ """
9191+ panes = run(["tmux", "list-panes", "-a", "-F", "#{pane_pid} #{session_id}"])
9292+ clients = run(["tmux", "list-clients", "-F", "#{client_pid} #{session_id}"])
9393+ if not panes or not clients:
9494+ return {}
9595+ session_clients: dict[str, list[int]] = {}
9696+ for line in clients.splitlines():
9797+ pid, _, session = line.partition(" ")
9898+ try:
9999+ session_clients.setdefault(session, []).append(int(pid))
100100+ except ValueError:
101101+ continue
102102+ hops: dict[int, list[int]] = {}
103103+ for line in panes.splitlines():
104104+ pid, _, session = line.partition(" ")
105105+ try:
106106+ hops[int(pid)] = session_clients.get(session, [])
107107+ except ValueError:
108108+ continue
109109+ return hops
110110+111111+112112+def resolve_workspace(
113113+ pid: int,
114114+ pid_workspaces: dict[int, str],
115115+ hops: dict[int, list[int]],
116116+ visited: set[int],
117117+) -> str | None:
118118+ """Find the workspace of the nearest ancestor process that owns a window."""
119119+ current: int | None = pid
120120+ while current is not None and current > 1:
121121+ if current in visited:
122122+ return None
123123+ visited.add(current)
124124+ workspace = pid_workspaces.get(current)
125125+ if workspace is not None:
126126+ return workspace
127127+ for client in hops.get(current, []):
128128+ workspace = resolve_workspace(client, pid_workspaces, hops, visited)
129129+ if workspace is not None:
130130+ return workspace
131131+ current = parent_pid(current)
132132+ return None
133133+134134+135135+def quote(name: str) -> str:
136136+ """Quote a workspace name for a sway command."""
137137+ escaped = name.replace("\\", "\\\\").replace('"', '\\"')
138138+ return f'"{escaped}"'
139139+140140+141141+def handle_new_window(container_id: int, pid: int) -> None:
142142+ """Move a freshly mapped view to the workspace of its spawning process."""
143143+ tree = swaymsg_json("get_tree")
144144+ if tree is None:
145145+ return
146146+147147+ origin: str | None = None
148148+ pid_workspaces: dict[int, str] = {}
149149+ for workspace, view_id, view_pid in iter_views(tree, None):
150150+ if view_id == container_id:
151151+ origin = workspace
152152+ continue
153153+ # Views of the same process are no hint: a single-process multi-window
154154+ # app (Firefox) would drag every new window to an unrelated workspace.
155155+ if view_pid == pid or workspace.startswith(SCRATCHPAD):
156156+ continue
157157+ pid_workspaces.setdefault(view_pid, workspace)
158158+159159+ if origin is None or origin.startswith(SCRATCHPAD):
160160+ # Window already closed again, or parked in the scratchpad by a
161161+ # for_window rule (the workspace-manager Firefox anchor).
162162+ return
163163+164164+ target = resolve_workspace(pid, pid_workspaces, tmux_hops(), set())
165165+ if target is None or target == origin:
166166+ return
167167+168168+ print(
169169+ f"moving container {container_id} (pid {pid}): {origin} -> {target}",
170170+ file=sys.stderr,
171171+ )
172172+ run(
173173+ [
174174+ "swaymsg",
175175+ "--",
176176+ f"[con_id={container_id}] move container to workspace {quote(target)}",
177177+ ]
178178+ )
179179+180180+181181+def main() -> int:
182182+ """Subscribe to sway window events and place new windows."""
183183+ proc = subprocess.Popen(
184184+ ["swaymsg", "-t", "subscribe", "-m", '["window"]'],
185185+ stdout=subprocess.PIPE,
186186+ text=True,
187187+ )
188188+ if proc.stdout is None:
189189+ print("no stdout from swaymsg subscribe", file=sys.stderr)
190190+ return 1
191191+192192+ for line in proc.stdout:
193193+ try:
194194+ event: dict[str, Any] = json.loads(line)
195195+ except json.JSONDecodeError as e:
196196+ print(f"failed to parse sway event: {e}", file=sys.stderr)
197197+ continue
198198+ if event.get("change") != "new":
199199+ continue
200200+ container: dict[str, Any] = event.get("container") or {}
201201+ pid = container.get("pid")
202202+ container_id = container.get("id")
203203+ if pid is None or container_id is None:
204204+ continue
205205+ handle_new_window(int(container_id), int(pid))
206206+207207+ return proc.wait()
208208+209209+210210+if __name__ == "__main__":
211211+ sys.exit(main())
···9393 };
9494 };
95959696+ # Keep new windows on the workspace of the process that spawned them instead
9797+ # of the focused one (sway only tracks this for its own `exec`, and only for
9898+ # 60s).
9999+ systemd.user.services.sway-spawn-workspace = {
100100+ description = "Place new windows on their spawning process's workspace";
101101+ partOf = [ "sway-session.target" ];
102102+ wantedBy = [ "sway-session.target" ];
103103+ serviceConfig = {
104104+ Type = "simple";
105105+ ExecStart = lib.getExe self.packages.${pkgs.system}.sway-spawn-workspace;
106106+ Restart = "on-failure";
107107+ RestartSec = 2;
108108+ };
109109+ };
110110+96111 # Inhibit idle when audio is playing (prevents suspend during calls/music)
97112 systemd.user.services.sway-audio-idle-inhibit = {
98113 description = "Inhibit idle when audio is playing";