Files
paperclip-harness/bin/lib/tuios.py

182 lines
6.6 KiB
Python

"""TUIOS helpers: claim a workspace by name, list what is open.
A workspace number is never a home. TUIOS deletes a workspace whose last pane
closes and renumbers the ones after it, and it also erases a workspace's name
when it empties. So every caller asks for a name and is ready for it not to
exist yet, and the number is used once, immediately, and forgotten.
"""
import json
import os
import subprocess
import sys
SESSION = os.environ.get("PAPERCLIP_HARNESS_SESSION", "paperclip-harness")
def tuios(*args, session=None):
argv = ["tuios", *args, "-s", session or SESSION, "--json"]
p = subprocess.run(argv, capture_output=True, text=True, timeout=60)
if p.returncode != 0:
raise RuntimeError("tuios %s failed: %s" % (" ".join(args), p.stderr.strip()))
try:
return json.loads(p.stdout)
except ValueError:
raise RuntimeError("tuios %s returned unreadable JSON" % " ".join(args))
def sessions():
"""Every session the daemon knows, as plain names."""
p = subprocess.run(["tuios", "ls", "--json"], capture_output=True, text=True, timeout=30)
if p.returncode != 0:
raise RuntimeError("tuios ls failed: %s" % p.stderr.strip())
data = json.loads(p.stdout)
rows = data if isinstance(data, list) else data.get("sessions", [])
return [r.get("name") for r in rows if isinstance(r, dict)]
def require_session(name):
"""Sessions are made by a person, in a terminal. Say so, do not invent one."""
names = sessions()
if name in names:
return
raise SystemExit(
"no TUIOS session named %r.\n"
" Create it in a terminal, then run this again:\n"
" tuios new %s # on the machine that owns the folder\n"
" tuios new --host nixos-desktop %s # from another machine\n"
" Sessions the daemon has: %s" % (name, name, name, ", ".join(names) or "none")
)
def workspaces(session=None):
return tuios("list-workspaces", session=session).get("workspaces", [])
def windows(session=None):
return tuios("list-windows", session=session).get("windows", [])
def _name_workspace(number, name, session=None):
subprocess.run(
["tuios", "set-workspace-name", str(number), name, "-s", session or SESSION],
capture_output=True,
text=True,
timeout=30,
check=False,
)
def claim(name, session=None):
"""Return the number of the workspace called `name`, naming one if needed."""
ws = workspaces(session)
exact = next((w for w in ws if (w.get("name") or "") == name), None)
if exact is None:
same = next((w for w in ws if (w.get("name") or "").lower() == name.lower()), None)
if same is not None:
_name_workspace(same["workspace"], name, session)
return same["workspace"]
if exact is not None:
return exact["workspace"]
# Nothing named that. Take a workspace that is unnamed and has no panes.
free = next(
(w for w in ws if "window_count" in w and (w.get("window_count") or 0) == 0 and not (w.get("name") or "")),
None,
)
if free is None:
raise RuntimeError(
"no empty workspace left in session %r; close a pane or add a workspace" % (session or SESSION)
)
_name_workspace(free["workspace"], name, session)
return free["workspace"]
def open_pane(name, workspace_name, cwd, command, session=None, adopt_existing=False):
"""Open a pane in a named workspace, or say it is already there.
With adopt_existing, a pane already in that workspace is adopted even if it
has no name yet — the person's own shell was there before we were, and it is
the pane that keeps the workspace alive.
"""
ws = claim(workspace_name, session)
# A pane that was already in this workspace before us is the one to keep:
# it is the person's own shell, and it is what stops the workspace being
# deleted. Only when the workspace is empty do we make a new one, and only
# then does a name match matter.
if adopt_existing:
existing = [w for w in windows(session) if w.get("workspace") == ws]
if existing:
w = existing[0]
if (w.get("custom_name") or "") != name:
subprocess.run(
["tuios", "set-window", "-w", w["window_id"], "--name", name,
"-s", session or SESSION],
capture_output=True, text=True, timeout=30, check=False,
)
return "adopted", w["window_id"], ws
for w in windows(session):
if (w.get("custom_name") or "") == name:
return "adopted", w["window_id"], ws
subprocess.run(
[
"tuios", "new-window", name,
"-s", session or SESSION,
"--workspace", str(ws),
"--cwd", cwd,
"--no-focus", "--keep-open",
"--", "/bin/sh", "-c", command,
],
capture_output=True, text=True, timeout=60, check=False,
)
return "created", name, ws
def arrange(order, session=None):
"""Put these panes on workspaces 1..N in this order, and name them.
TUIOS numbering follows the order panes land in, and closing panes renumbers
the rest, so a rebuild can scatter the pages. Moving each pane once, at the
end, is what makes the rail readable and stable.
"""
moved = []
for number, name in enumerate(order, start=1):
win = next((w for w in windows(session) if (w.get("custom_name") or "") == name), None)
if win is None:
continue
if win.get("workspace") != number:
subprocess.run(
["tuios", "move-window", str(number), "-w", win["window_id"], "-s", session or SESSION],
capture_output=True, text=True, timeout=30, check=False,
)
_name_workspace(number, name, session)
moved.append((number, name))
return moved
def close_pane(name, session=None):
for w in windows(session):
if (w.get("custom_name") or "") == name:
subprocess.run(
["tuios", "close-window", w["window_id"], "-s", session or SESSION],
capture_output=True, text=True, timeout=30, check=False,
)
return True
return False
def drop_empty_unnamed(session=None):
"""Remove the unnamed, empty workspaces a rebuild leaves behind."""
gone = 0
for w in workspaces(session):
if "window_count" in w and (w.get("window_count") or 0) == 0 and not (w.get("name") or ""):
continue
return gone
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "claim":
print(claim(sys.argv[2]))
else:
print("usage: python3 lib/tuios.py claim NAME")