workmap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workmap/__init__.py +30 -0
- workmap/__main__.py +15 -0
- workmap/actions.py +350 -0
- workmap/audit.py +153 -0
- workmap/cli.py +789 -0
- workmap/config.py +589 -0
- workmap/demo.py +94 -0
- workmap/drivers/__init__.py +93 -0
- workmap/drivers/apple_terminal.py +578 -0
- workmap/layout.py +64 -0
- workmap/model.py +719 -0
- workmap/multiplexer.py +201 -0
- workmap/procs.py +504 -0
- workmap/scan.py +413 -0
- workmap/setup.py +400 -0
- workmap/shell.py +117 -0
- workmap/terminal.py +50 -0
- workmap/themes.py +45 -0
- workmap/tui/__init__.py +6 -0
- workmap/tui/app.py +1090 -0
- workmap/tui/onboarding.py +266 -0
- workmap/tui/text.py +156 -0
- workmap/tui/widgets.py +189 -0
- workmap-0.1.0.dist-info/METADATA +258 -0
- workmap-0.1.0.dist-info/RECORD +28 -0
- workmap-0.1.0.dist-info/WHEEL +4 -0
- workmap-0.1.0.dist-info/entry_points.txt +2 -0
- workmap-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Terminal drivers, and the contract every one of them meets.
|
|
2
|
+
|
|
3
|
+
One driver exists. The point of this package is that the second one is a new
|
|
4
|
+
file rather than a diff against the first, and that "the contract" is a list
|
|
5
|
+
something can be checked against instead of a paragraph at the top of a module.
|
|
6
|
+
|
|
7
|
+
CONTRACT the names a driver must define, and what each is for
|
|
8
|
+
for_host() pick the driver for the terminal we are actually running in
|
|
9
|
+
|
|
10
|
+
A driver is a plain module. There is no base class, because there is nothing
|
|
11
|
+
to share: two drivers speaking to two different applications have no
|
|
12
|
+
implementation in common, only a shape. `tests/test_driver_contract.py` checks
|
|
13
|
+
the shape.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
# Every name a driver has to define, and why it exists. The prose is here
|
|
20
|
+
# rather than in a docstring because the conformance test reads these keys, so
|
|
21
|
+
# adding a capability to the contract and forgetting to check for it is not
|
|
22
|
+
# possible.
|
|
23
|
+
CONTRACT = {
|
|
24
|
+
# -- reading ----------------------------------------------------------
|
|
25
|
+
"list_terminal_tabs":
|
|
26
|
+
"-> [{window_id:int, title:str, tty:str, profile:str}] for every tab "
|
|
27
|
+
"open. The desk is built from this and nothing else.",
|
|
28
|
+
"terminal_status":
|
|
29
|
+
'-> {"ok": bool, "reason": str, "fix": str}. Why the last query '
|
|
30
|
+
"failed, so the UI can say so instead of drawing an empty desk and "
|
|
31
|
+
"implying the machine is idle, and what the reader can do about it. "
|
|
32
|
+
"`fix` is empty when there is nothing useful to suggest: only one of "
|
|
33
|
+
"these failures is a permission problem, and the Automation "
|
|
34
|
+
"instructions were once printed for all of them.",
|
|
35
|
+
"owner_pids":
|
|
36
|
+
"(ps rows) -> pids of the emulator itself. The orphan sweep decides "
|
|
37
|
+
"a process is nobody's by walking its ancestry, and this is what "
|
|
38
|
+
"makes a live session somebody's. The one that is easy to leave out "
|
|
39
|
+
"and expensive to get wrong.",
|
|
40
|
+
"list_profiles":
|
|
41
|
+
"-> [{name, swatch}] of the colour schemes this emulator has.",
|
|
42
|
+
"available_profile_names":
|
|
43
|
+
"-> {str}. Just the names, for validating a remembered choice.",
|
|
44
|
+
"unsupported_host":
|
|
45
|
+
'-> "" if this driver can drive the terminal we are in, else why not.',
|
|
46
|
+
"desktop_bounds":
|
|
47
|
+
"-> (left, top, right, bottom) of the usable screen.",
|
|
48
|
+
# -- changing ---------------------------------------------------------
|
|
49
|
+
"set_window_titles": "({window_id: title}) -> None. Batched.",
|
|
50
|
+
"set_front_title": "(title) -> None. The front tab only, no scan.",
|
|
51
|
+
"paint_front_window": "(profile) -> None. The front tab only, no scan.",
|
|
52
|
+
"apply_profile": "([window_id], profile) -> None.",
|
|
53
|
+
"place_windows":
|
|
54
|
+
"([(window_id, (l, t, r, b))]) -> int, how many actually moved. One "
|
|
55
|
+
"call, not one per window. Not how many were asked to: macOS window "
|
|
56
|
+
"tiling accepts the write and discards it, so an emulator that cannot "
|
|
57
|
+
"move a window has to say so rather than let the caller claim it did.",
|
|
58
|
+
"close_windows":
|
|
59
|
+
"([window_id]) -> int, how many actually closed. Not how many were "
|
|
60
|
+
"asked to: an emulator may refuse, and saying so is the contract.",
|
|
61
|
+
"focus_window": "(window_id) -> None.",
|
|
62
|
+
"ensure_title_settings":
|
|
63
|
+
"() -> None. Idempotent. Turn off whatever noise the emulator puts in "
|
|
64
|
+
"a title by default, once per process.",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# What each driver answers to, by $TERM_PROGRAM. A terminal absent from here
|
|
68
|
+
# is not unsupported, it is undriven: workmap still runs, and says which desk
|
|
69
|
+
# it is actually showing you.
|
|
70
|
+
BY_TERM_PROGRAM = {
|
|
71
|
+
"Apple_Terminal": "apple_terminal",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
DEFAULT = "apple_terminal"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def for_host(term_program: str | None = None):
|
|
78
|
+
"""The driver for the terminal we are running in, or the default.
|
|
79
|
+
|
|
80
|
+
Falling back rather than failing is deliberate. Running workmap from
|
|
81
|
+
inside another emulator is not an error, it just means the desk you are
|
|
82
|
+
shown is not the desk in front of you, and `unsupported_host()` is what
|
|
83
|
+
says so. Refusing to start would be worse: the orphans are still real.
|
|
84
|
+
"""
|
|
85
|
+
if term_program is None:
|
|
86
|
+
term_program = os.environ.get("TERM_PROGRAM", "")
|
|
87
|
+
name = BY_TERM_PROGRAM.get(term_program.strip(), DEFAULT)
|
|
88
|
+
return load(name)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load(name: str):
|
|
92
|
+
from importlib import import_module
|
|
93
|
+
return import_module(f".{name}", __name__)
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""The only module that speaks AppleScript. The terminal driver.
|
|
2
|
+
|
|
3
|
+
Everything macOS-and-Terminal.app-specific is behind this seam. A driver for
|
|
4
|
+
another emulator reimplements this file and nothing else, so what counts as
|
|
5
|
+
"this file" has to be written down:
|
|
6
|
+
|
|
7
|
+
list_terminal_tabs() -> [{window_id, title, tty, profile}]
|
|
8
|
+
terminal_status() -> {"ok": bool, "reason": str}
|
|
9
|
+
owner_pids(rows) -> pids of the emulator itself
|
|
10
|
+
list_profiles() -> [{name, swatch}]
|
|
11
|
+
available_profile_names() -> {str}
|
|
12
|
+
unsupported_host() -> "" or why this driver cannot drive what you are in
|
|
13
|
+
|
|
14
|
+
set_window_titles / apply_profile / place_windows / close_windows
|
|
15
|
+
paint_front_window / set_front_title / focus_window / ensure_title_settings
|
|
16
|
+
|
|
17
|
+
`owner_pids` is the one that is easy to leave out and expensive to get wrong.
|
|
18
|
+
The orphan sweep decides a process is nobody's by walking its ancestry; the
|
|
19
|
+
emulator is what makes a process somebody's. On this driver that happened to
|
|
20
|
+
work for free, because Terminal.app is a .app bundle and the sweep already
|
|
21
|
+
skips anything descended from one, so every process in every tab was
|
|
22
|
+
protected by accident rather than by intent. A driver whose emulator is not an
|
|
23
|
+
app bundle would have quietly lost that protection and started offering live
|
|
24
|
+
sessions for killing. It is explicit now.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import contextlib
|
|
29
|
+
import fcntl
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import subprocess
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from ..model import executable_of
|
|
36
|
+
from ..procs import run_safe
|
|
37
|
+
from ..themes import ASSIGN_ORDER, PROFILE_SWATCHES
|
|
38
|
+
|
|
39
|
+
# What this driver drives.
|
|
40
|
+
HOST_APP = "Terminal.app"
|
|
41
|
+
HOST_EXECUTABLE = "Terminal.app/Contents/MacOS/Terminal"
|
|
42
|
+
HOST_TERM_PROGRAM = "Apple_Terminal" # what Terminal.app sets in the env
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Serialize all Terminal AppleScript. Concurrent osascript calls + Shell/Window
|
|
46
|
+
# menus = Terminal UI freeze (dropdown stuck open until force-quit).
|
|
47
|
+
# Runtime state, deliberately not inside the source tree: every copy of
|
|
48
|
+
# workmap (checkout, pipx, uv tool) must contend for the *same* lock, and a
|
|
49
|
+
# pip-installed package has no business writing into site-packages.
|
|
50
|
+
_TERMINAL_LOCK = Path.home() / ".local" / "state" / "workmap" / "terminal.lock"
|
|
51
|
+
|
|
52
|
+
_titles_simplified = False
|
|
53
|
+
|
|
54
|
+
# Why the last Terminal query failed, and what the reader can do about it, so
|
|
55
|
+
# the UI can say so instead of rendering an empty desk and implying nothing is
|
|
56
|
+
# open. Only one of the failures is a permission problem, and the Automation
|
|
57
|
+
# instructions used to be printed for all of them.
|
|
58
|
+
_terminal_status: dict = {"ok": True, "reason": "", "fix": ""}
|
|
59
|
+
|
|
60
|
+
# The action only. Each surface adds its own "then run this again" or "then
|
|
61
|
+
# press r", because the two do not retry the same way.
|
|
62
|
+
AUTOMATION_FIX = "Allow it under System Settings > Privacy & Security > Automation."
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def terminal_status() -> dict:
|
|
66
|
+
return dict(_terminal_status)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def owner_pids(rows: list[dict]) -> set[int]:
|
|
70
|
+
"""The emulator's own processes, from a `ps` snapshot.
|
|
71
|
+
|
|
72
|
+
Anything descended from one of these is a live session someone is sitting
|
|
73
|
+
in, not an orphan. Part of the driver contract. See the module docstring
|
|
74
|
+
for why this must not be left to inference.
|
|
75
|
+
|
|
76
|
+
Read from the executable, not from anywhere in the command line. This was
|
|
77
|
+
the fourth outing of the bug this codebase keeps finding: `HOST_EXECUTABLE
|
|
78
|
+
in r["cmd"]` matched any process that merely mentioned Terminal's path in
|
|
79
|
+
an argument, and made that process protect its whole subtree. It errs
|
|
80
|
+
toward protection, so nothing was ever killed by it and nothing on this
|
|
81
|
+
machine matches it today, which is exactly why it survived three rounds of
|
|
82
|
+
the same fix elsewhere.
|
|
83
|
+
"""
|
|
84
|
+
return {r["pid"] for r in rows
|
|
85
|
+
if executable_of(r["cmd"]).endswith(HOST_EXECUTABLE)}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def unsupported_host() -> str:
|
|
89
|
+
"""Why this driver cannot drive the terminal you are actually in.
|
|
90
|
+
|
|
91
|
+
Running workmap inside iTerm2 or Ghostty is not an error, but it is not
|
|
92
|
+
what it looks like either: the AppleScript queries answer for Terminal.app
|
|
93
|
+
windows, so you get a map of a desk that is not the one in front of you.
|
|
94
|
+
Say so rather than showing an empty or misleading one.
|
|
95
|
+
"""
|
|
96
|
+
current = os.environ.get("TERM_PROGRAM", "").strip()
|
|
97
|
+
if not current or current == HOST_TERM_PROGRAM:
|
|
98
|
+
return ""
|
|
99
|
+
return f"showing {HOST_APP} windows only: this looks like {current}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _note_terminal(ok: bool, reason: str = "", fix: str = "") -> None:
|
|
103
|
+
_terminal_status["ok"] = ok
|
|
104
|
+
_terminal_status["reason"] = reason
|
|
105
|
+
_terminal_status["fix"] = fix
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def as_string(value: str) -> str:
|
|
109
|
+
"""A Python string as the body of an AppleScript string literal.
|
|
110
|
+
|
|
111
|
+
Backslash first, then quote. The other order is not a style preference:
|
|
112
|
+
it escapes the quote, then escapes the backslash it just wrote, and the
|
|
113
|
+
literal reopens. Any function here that builds a script by concatenation
|
|
114
|
+
has to go through this.
|
|
115
|
+
|
|
116
|
+
The bug this closes was real and reachable. Escaping only quotes left a
|
|
117
|
+
name ending in a backslash able to close the literal early, after which
|
|
118
|
+
the rest of the name was parsed as AppleScript rather than data:
|
|
119
|
+
|
|
120
|
+
profile = 'Basic\\" & (do shell script "...") --'
|
|
121
|
+
|
|
122
|
+
ran the shell command. Profile names come from the config file and from
|
|
123
|
+
Terminal's own settings sets, so this is not attacker-facing on a healthy
|
|
124
|
+
machine, but it is arbitrary code execution from a string this module
|
|
125
|
+
treats as untrusted everywhere else.
|
|
126
|
+
|
|
127
|
+
Control characters are dropped rather than escaped. A tab title with a
|
|
128
|
+
newline in it is not a title anyone wanted, and leaving them in means
|
|
129
|
+
every caller has to reason about a second way to end a line.
|
|
130
|
+
"""
|
|
131
|
+
cleaned = "".join(
|
|
132
|
+
" " if ch in "\t\n\r" else ch
|
|
133
|
+
for ch in value if (ch >= " " and ch != "\x7f") or ch in "\t\n\r"
|
|
134
|
+
)
|
|
135
|
+
return cleaned.replace("\\", "\\\\").replace('"', '\\"')
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _osascript(script: str, *, timeout: float = 8.0) -> str:
|
|
139
|
+
"""Run AppleScript against Terminal under a process lock + timeout.
|
|
140
|
+
|
|
141
|
+
Without this, paint/refresh/organize pile up Apple Events on Terminal's
|
|
142
|
+
main thread and menus (Shell → New Tab, etc.) freeze mid-open.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
_TERMINAL_LOCK.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
lock = open(_TERMINAL_LOCK, "a+", encoding="utf-8")
|
|
147
|
+
except OSError:
|
|
148
|
+
# A read-only home, a full disk, or ~/.local/state existing as a
|
|
149
|
+
# file. Serialising Apple Events is an optimisation; not being able
|
|
150
|
+
# to is not a reason to end with a traceback in place of the desk.
|
|
151
|
+
lock = None
|
|
152
|
+
with contextlib.ExitStack() as stack:
|
|
153
|
+
if lock is not None:
|
|
154
|
+
stack.enter_context(lock)
|
|
155
|
+
try:
|
|
156
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
157
|
+
except OSError:
|
|
158
|
+
pass
|
|
159
|
+
try:
|
|
160
|
+
out = subprocess.check_output(
|
|
161
|
+
["osascript"],
|
|
162
|
+
input=script,
|
|
163
|
+
text=True,
|
|
164
|
+
stderr=subprocess.PIPE,
|
|
165
|
+
timeout=timeout,
|
|
166
|
+
)
|
|
167
|
+
_note_terminal(True)
|
|
168
|
+
return out
|
|
169
|
+
except subprocess.TimeoutExpired:
|
|
170
|
+
_note_terminal(False, "Terminal isn't responding",
|
|
171
|
+
"It may be busy; give it a moment.")
|
|
172
|
+
except subprocess.CalledProcessError as exc:
|
|
173
|
+
err = (exc.stderr or "").strip()
|
|
174
|
+
if "-1743" in err or "not allowed" in err.lower():
|
|
175
|
+
reason, fix = ("macOS hasn't authorised Terminal automation",
|
|
176
|
+
AUTOMATION_FIX)
|
|
177
|
+
elif "-600" in err or "not running" in err.lower():
|
|
178
|
+
reason, fix = "Terminal isn't running", "Open Terminal."
|
|
179
|
+
else:
|
|
180
|
+
reason, fix = "Terminal automation failed", ""
|
|
181
|
+
_note_terminal(False, reason, fix)
|
|
182
|
+
except OSError:
|
|
183
|
+
_note_terminal(False, "could not run osascript", "")
|
|
184
|
+
return ""
|
|
185
|
+
|
|
186
|
+
_profiles_cache: list[dict] | None = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def list_profiles(*, refresh: bool = False) -> list[dict]:
|
|
190
|
+
"""Terminal's settings sets, cached. Otherwise it is an osascript
|
|
191
|
+
round-trip per redraw for a list that only changes when you edit
|
|
192
|
+
Terminal's preferences."""
|
|
193
|
+
global _profiles_cache
|
|
194
|
+
if _profiles_cache is not None and not refresh:
|
|
195
|
+
return list(_profiles_cache)
|
|
196
|
+
script = '''
|
|
197
|
+
tell application "Terminal"
|
|
198
|
+
set out to ""
|
|
199
|
+
repeat with s in settings sets
|
|
200
|
+
set out to out & (name of s) & linefeed
|
|
201
|
+
end repeat
|
|
202
|
+
return out
|
|
203
|
+
end tell
|
|
204
|
+
'''
|
|
205
|
+
raw = _osascript(script, timeout=4.0)
|
|
206
|
+
if not raw.strip():
|
|
207
|
+
raw = "\n".join(PROFILE_SWATCHES.keys())
|
|
208
|
+
names = [ln.strip() for ln in raw.splitlines() if ln.strip()]
|
|
209
|
+
out = []
|
|
210
|
+
for name in names:
|
|
211
|
+
sw = PROFILE_SWATCHES.get(name, {"bg": "#333", "fg": "#eee", "rail": "#666"})
|
|
212
|
+
out.append({"name": name, "swatch": sw})
|
|
213
|
+
# stable order: assign order first, then the rest
|
|
214
|
+
order = {n: i for i, n in enumerate(ASSIGN_ORDER)}
|
|
215
|
+
out.sort(key=lambda p: (order.get(p["name"], 100), p["name"]))
|
|
216
|
+
_profiles_cache = out
|
|
217
|
+
return list(out)
|
|
218
|
+
|
|
219
|
+
_available_profiles: set[str] | None = None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def available_profile_names() -> set[str]:
|
|
223
|
+
global _available_profiles
|
|
224
|
+
if _available_profiles is None:
|
|
225
|
+
_available_profiles = {p["name"] for p in list_profiles()}
|
|
226
|
+
return _available_profiles
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def list_terminal_tabs() -> list[dict]:
|
|
230
|
+
"""List Terminal windows/tabs with a light, race-safe AppleScript.
|
|
231
|
+
|
|
232
|
+
Snapshot window ids first (don't iterate a live `windows` list, that
|
|
233
|
+
throws -1728 when tabs open/close mid-scan and blocks Shell menus).
|
|
234
|
+
Skip `busy` and `processes`: ps already has that, and those queries are
|
|
235
|
+
the expensive main-thread work.
|
|
236
|
+
|
|
237
|
+
Properties are read for every tab at once rather than a tab at a time.
|
|
238
|
+
Each property access is an Apple Event onto Terminal's main thread and
|
|
239
|
+
they cost about 5ms each, so asking per tab made the whole scan scale with
|
|
240
|
+
the number of tabs open: measured on a real desk, 9 tabs took 0.57s and 49
|
|
241
|
+
took 3.7s, heading for the timeout below at somewhere under a hundred.
|
|
242
|
+
`tty of every tab` is one event for the lot, which leaves the cost
|
|
243
|
+
proportional to windows instead. Same output, verified against the
|
|
244
|
+
per-tab version on a live desk.
|
|
245
|
+
"""
|
|
246
|
+
script = r'''
|
|
247
|
+
tell application "Terminal"
|
|
248
|
+
set out to ""
|
|
249
|
+
set widList to {}
|
|
250
|
+
try
|
|
251
|
+
set widList to id of every window
|
|
252
|
+
end try
|
|
253
|
+
repeat with wid in widList
|
|
254
|
+
try
|
|
255
|
+
set w to window id wid
|
|
256
|
+
set out to out & "WINDOW|" & wid & "|" & (name of w) & linefeed
|
|
257
|
+
set ttys to tty of every tab of w
|
|
258
|
+
set sets to name of current settings of every tab of w
|
|
259
|
+
repeat with i from 1 to (count of ttys)
|
|
260
|
+
set tn to item i of ttys
|
|
261
|
+
set sn to item i of sets
|
|
262
|
+
if tn is missing value then set tn to ""
|
|
263
|
+
if sn is missing value then set sn to ""
|
|
264
|
+
set out to out & "TAB|" & wid & "|" & tn & "|profile=" & sn & linefeed
|
|
265
|
+
end repeat
|
|
266
|
+
end try
|
|
267
|
+
end repeat
|
|
268
|
+
return out
|
|
269
|
+
end tell
|
|
270
|
+
'''
|
|
271
|
+
# Generous, because the honest reason this takes a while is usually a big
|
|
272
|
+
# desk rather than a wedged Terminal, and reporting "Terminal isn't
|
|
273
|
+
# responding" to someone with sixty tabs open would be a lie.
|
|
274
|
+
raw = _osascript(script, timeout=20.0)
|
|
275
|
+
if not raw.strip():
|
|
276
|
+
return []
|
|
277
|
+
windows: dict[int, str] = {}
|
|
278
|
+
tabs: list[dict] = []
|
|
279
|
+
# Both record types put the free-text field last and split up to it, so a
|
|
280
|
+
# window title or a profile name containing the separator stays whole. A
|
|
281
|
+
# line whose id is not a number is not one of ours: window titles are
|
|
282
|
+
# written by whatever is running in the tab, and a newline in one would
|
|
283
|
+
# otherwise turn the rest of that title into a record of its own.
|
|
284
|
+
for line in raw.splitlines():
|
|
285
|
+
try:
|
|
286
|
+
if line.startswith("WINDOW|"):
|
|
287
|
+
_, wid_s, name = line.split("|", 2)
|
|
288
|
+
windows[int(wid_s)] = name
|
|
289
|
+
continue
|
|
290
|
+
if not line.startswith("TAB|"):
|
|
291
|
+
continue
|
|
292
|
+
_, wid_s, tty, rest = line.split("|", 3)
|
|
293
|
+
wid = int(wid_s)
|
|
294
|
+
except ValueError:
|
|
295
|
+
continue
|
|
296
|
+
profile = rest[len("profile="):] if rest.startswith("profile=") else ""
|
|
297
|
+
tabs.append({
|
|
298
|
+
"window_id": wid,
|
|
299
|
+
"title": windows.get(wid, ""),
|
|
300
|
+
"tty": tty,
|
|
301
|
+
"busy": False,
|
|
302
|
+
"procs": "",
|
|
303
|
+
"profile": profile,
|
|
304
|
+
})
|
|
305
|
+
return tabs
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def desktop_bounds() -> tuple[int, int, int, int]:
|
|
309
|
+
"""The usable screen, as (left, top, right, bottom).
|
|
310
|
+
|
|
311
|
+
Signed. A second display arranged to the left of or above the main one
|
|
312
|
+
has negative coordinates, and reading the digits without their sign
|
|
313
|
+
turned -1512 into 1512: the rect collapsed and `organize` piled every
|
|
314
|
+
window into a sliver. Only ever seen on one display here, which is
|
|
315
|
+
exactly the kind of thing one machine cannot show you.
|
|
316
|
+
"""
|
|
317
|
+
raw = run_safe([
|
|
318
|
+
"osascript", "-e",
|
|
319
|
+
'tell application "Finder" to get bounds of window of desktop'
|
|
320
|
+
], timeout=5.0).strip()
|
|
321
|
+
nums = [int(x) for x in re.findall(r"-?\d+", raw)]
|
|
322
|
+
if len(nums) >= 4:
|
|
323
|
+
return nums[0], nums[1], nums[2], nums[3]
|
|
324
|
+
return 0, 0, 1440, 900
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def paint_front_window(profile: str) -> None:
|
|
328
|
+
safe = as_string(profile)
|
|
329
|
+
script = f'''
|
|
330
|
+
tell application "Terminal"
|
|
331
|
+
set current settings of selected tab of front window to settings set "{safe}"
|
|
332
|
+
end tell
|
|
333
|
+
'''
|
|
334
|
+
_osascript(script, timeout=3.0)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def apply_profile(window_ids: list[int], profile: str) -> None:
|
|
338
|
+
if not window_ids:
|
|
339
|
+
return
|
|
340
|
+
safe = as_string(profile)
|
|
341
|
+
# Each window in its own `try`, like set_window_titles and place_windows.
|
|
342
|
+
# Without it a single id that has closed since the scan raises and every
|
|
343
|
+
# window after it in the batch goes unpainted.
|
|
344
|
+
lines = ['tell application "Terminal"']
|
|
345
|
+
for wid in window_ids:
|
|
346
|
+
lines.append("try")
|
|
347
|
+
lines.append(f" set w to first window whose id is {wid}")
|
|
348
|
+
lines.append(" repeat with t in tabs of w")
|
|
349
|
+
lines.append(f' set current settings of t to settings set "{safe}"')
|
|
350
|
+
lines.append(" end repeat")
|
|
351
|
+
lines.append("end try")
|
|
352
|
+
lines.append("end tell")
|
|
353
|
+
_osascript("\n".join(lines), timeout=6.0)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def set_window_title(window_id: int, title: str) -> None:
|
|
357
|
+
set_window_titles({window_id: title})
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def set_window_titles(titles: dict[int, str]) -> None:
|
|
361
|
+
"""Batch custom titles into one AppleScript (avoids N main-thread hits)."""
|
|
362
|
+
if not titles:
|
|
363
|
+
return
|
|
364
|
+
lines = ['tell application "Terminal"']
|
|
365
|
+
for wid, title in titles.items():
|
|
366
|
+
safe = as_string(title)
|
|
367
|
+
lines.append("try")
|
|
368
|
+
lines.append(f" set w to first window whose id is {wid}")
|
|
369
|
+
lines.append(" repeat with t in tabs of w")
|
|
370
|
+
lines.append(f' set custom title of t to "{safe}"')
|
|
371
|
+
lines.append(" end repeat")
|
|
372
|
+
lines.append("end try")
|
|
373
|
+
lines.append("end tell")
|
|
374
|
+
_osascript("\n".join(lines), timeout=6.0)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def set_front_title(title: str) -> None:
|
|
378
|
+
safe = as_string(title)
|
|
379
|
+
script = f'''
|
|
380
|
+
tell application "Terminal"
|
|
381
|
+
set custom title of selected tab of front window to "{safe}"
|
|
382
|
+
end tell
|
|
383
|
+
'''
|
|
384
|
+
_osascript(script, timeout=3.0)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def ensure_title_settings() -> None:
|
|
388
|
+
"""Idempotent: only rewrite profile title prefs once per process."""
|
|
389
|
+
global _titles_simplified
|
|
390
|
+
if _titles_simplified:
|
|
391
|
+
return
|
|
392
|
+
simplify_title_settings()
|
|
393
|
+
_titles_simplified = True
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def simplify_title_settings() -> None:
|
|
397
|
+
"""Turn off noisy Terminal title bits (command path, 80×24) on known profiles."""
|
|
398
|
+
names = [
|
|
399
|
+
"Basic", "Ocean", "Grass", "Homebrew", "Red Sands", "Pro", "Novel",
|
|
400
|
+
"Silver Aerogel", "Man Page", "Solid Colors", "Clear Dark", "Clear Light",
|
|
401
|
+
]
|
|
402
|
+
lines = ['tell application "Terminal"']
|
|
403
|
+
for n in names:
|
|
404
|
+
safe = as_string(n)
|
|
405
|
+
lines.append('try')
|
|
406
|
+
lines.append(f' tell settings set "{safe}"')
|
|
407
|
+
lines.append(" set title displays window size to false")
|
|
408
|
+
lines.append(" set title displays shell path to false")
|
|
409
|
+
lines.append(" set title displays custom title to true")
|
|
410
|
+
lines.append(" end tell")
|
|
411
|
+
lines.append("end try")
|
|
412
|
+
lines.append("end tell")
|
|
413
|
+
_osascript("\n".join(lines), timeout=5.0)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def focus_window(window_id: int) -> None:
|
|
417
|
+
script = f'''
|
|
418
|
+
tell application "Terminal"
|
|
419
|
+
activate
|
|
420
|
+
set w to first window whose id is {window_id}
|
|
421
|
+
set index of w to 1
|
|
422
|
+
end tell
|
|
423
|
+
'''
|
|
424
|
+
_osascript(script, timeout=3.0)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _rect_text(rect: tuple[int, int, int, int]) -> str:
|
|
428
|
+
"""A rectangle as Terminal writes one, so the two can be compared."""
|
|
429
|
+
return ",".join(str(int(n)) for n in rect)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _read_bounds(blob: str) -> dict[int, str]:
|
|
433
|
+
"""`id=l,t,r,b` lines back into a mapping. A line it cannot read is
|
|
434
|
+
dropped rather than guessed at, which makes that window count as unmoved:
|
|
435
|
+
the safe direction, because the alternative is claiming a window landed
|
|
436
|
+
somewhere on the strength of a line nothing could parse."""
|
|
437
|
+
out: dict[int, str] = {}
|
|
438
|
+
for line in blob.splitlines():
|
|
439
|
+
wid, _, rect = line.strip().partition("=")
|
|
440
|
+
rect = rect.replace(" ", "")
|
|
441
|
+
if wid.isdigit() and rect:
|
|
442
|
+
out[int(wid)] = rect
|
|
443
|
+
return out
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def place_windows(placements: list[tuple[int, tuple[int, int, int, int]]],
|
|
447
|
+
*, front_first: bool = True) -> int:
|
|
448
|
+
"""Move/resize windows in one Apple Event. Returns how many actually moved.
|
|
449
|
+
|
|
450
|
+
Not how many were asked to move, for the same reason `close_windows`
|
|
451
|
+
counts survivors rather than requests. macOS window tiling takes
|
|
452
|
+
`set bounds` and throws it away. Measured on this machine: a window in a
|
|
453
|
+
tile group answered the same bounds before and after, with no error raised
|
|
454
|
+
anywhere in the script and nothing for the caller to notice. Terminal
|
|
455
|
+
reports the tiled frame while the write lands on the frame the window
|
|
456
|
+
would return to if you dragged it out, so `organize` was telling people it
|
|
457
|
+
had laid out windows that had not moved a pixel.
|
|
458
|
+
|
|
459
|
+
A window counts as placed if its bounds changed, or if it was already at
|
|
460
|
+
the rectangle it was asked for. Comparing the result against the requested
|
|
461
|
+
rectangle alone would be wrong: Terminal keeps the size it is given and
|
|
462
|
+
slides the origin to keep the window on screen, so asking for y=10 under a
|
|
463
|
+
36 pixel menu bar gives back y=33, and that is a window that moved.
|
|
464
|
+
|
|
465
|
+
The one thing this cannot tell apart is a request that clamps to exactly
|
|
466
|
+
where the window already sits, which reads as unmoved. `organize` does not
|
|
467
|
+
generate one: it works inside `_usable_desktop()`, which is already inset
|
|
468
|
+
from the menu bar and the dock.
|
|
469
|
+
"""
|
|
470
|
+
if not placements:
|
|
471
|
+
return 0
|
|
472
|
+
|
|
473
|
+
def probe(var: str) -> list[str]:
|
|
474
|
+
out = [f'set {var} to ""']
|
|
475
|
+
for wid, _rect in placements:
|
|
476
|
+
out += ["try",
|
|
477
|
+
f' set {var} to {var} & "{wid}=" & '
|
|
478
|
+
f"((bounds of window id {wid}) as text) & linefeed",
|
|
479
|
+
"end try"]
|
|
480
|
+
return out
|
|
481
|
+
|
|
482
|
+
lines = ['tell application "Terminal"', "activate"]
|
|
483
|
+
# Without this a bounds list joins with no separator at all, and
|
|
484
|
+
# `0,69,1728,1117` comes back as `06917281117`, which no reader can split.
|
|
485
|
+
lines.append("set AppleScript's text item delimiters to \",\"")
|
|
486
|
+
lines += probe("before")
|
|
487
|
+
for wid, (x1, y1, x2, y2) in placements:
|
|
488
|
+
lines += [
|
|
489
|
+
"try",
|
|
490
|
+
f" set w to first window whose id is {wid}",
|
|
491
|
+
" try",
|
|
492
|
+
" set visible of w to true",
|
|
493
|
+
" end try",
|
|
494
|
+
" try",
|
|
495
|
+
" set miniaturized of w to false",
|
|
496
|
+
" end try",
|
|
497
|
+
f" set bounds of w to {{{x1}, {y1}, {x2}, {y2}}}",
|
|
498
|
+
]
|
|
499
|
+
if front_first:
|
|
500
|
+
lines.append(" set index of w to 1")
|
|
501
|
+
lines.append("end try")
|
|
502
|
+
# Terminal reports the old frame for a moment after accepting a move, so
|
|
503
|
+
# without this the check races the write and every window reads as stuck.
|
|
504
|
+
lines.append("delay 0.4")
|
|
505
|
+
lines += probe("after")
|
|
506
|
+
lines += ['return before & "--" & after', "end tell"]
|
|
507
|
+
raw = _osascript("\n".join(lines), timeout=10.0)
|
|
508
|
+
if not terminal_status()["ok"]:
|
|
509
|
+
return 0
|
|
510
|
+
before_blob, _, after_blob = raw.partition("--")
|
|
511
|
+
was, now = _read_bounds(before_blob), _read_bounds(after_blob)
|
|
512
|
+
placed = 0
|
|
513
|
+
for wid, rect in placements:
|
|
514
|
+
after = now.get(wid)
|
|
515
|
+
if after is None:
|
|
516
|
+
# Gone, or a window Terminal will not answer for. Either way it is
|
|
517
|
+
# not on screen where this asked it to be.
|
|
518
|
+
continue
|
|
519
|
+
if after != was.get(wid) or after == _rect_text(rect):
|
|
520
|
+
placed += 1
|
|
521
|
+
return placed
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def close_windows(window_ids: list[int]) -> int:
|
|
525
|
+
"""Close these Terminal windows. Returns how many actually closed.
|
|
526
|
+
|
|
527
|
+
Not how many were asked to close, which is what this used to return.
|
|
528
|
+
Terminal puts a sheet on any window with something still running in it
|
|
529
|
+
("Closing this tab will terminate the running process X"), and `close`
|
|
530
|
+
returns straight away without waiting for the answer. So the window stays
|
|
531
|
+
on screen, wearing a dialog, while workmap reports it closed.
|
|
532
|
+
|
|
533
|
+
Which windows are left is the only honest answer, so it asks. Dismissing
|
|
534
|
+
the sheet is deliberately not attempted: agreeing to it kills whatever is
|
|
535
|
+
running in that tab, and workmap has two keys that already do that on
|
|
536
|
+
purpose. The user gets told what is waiting for them instead.
|
|
537
|
+
|
|
538
|
+
It asks twice, and the second question is why. "How many of these are
|
|
539
|
+
gone now" counts an id that was already gone as one this call closed,
|
|
540
|
+
and ids come off the last refresh: a window the user closed by hand a
|
|
541
|
+
moment ago is exactly that. Measured against a real Terminal, closing an
|
|
542
|
+
id that had already gone reported one closed. So it takes the ones that
|
|
543
|
+
were open before, and counts how many of those are not open after.
|
|
544
|
+
|
|
545
|
+
A closed window stays addressable rather than raising: measured, it
|
|
546
|
+
answers `exists` true, `visible` false, and no tabs. So having a tab is
|
|
547
|
+
the test for open, which is also what puts a window on the desk map.
|
|
548
|
+
One definition, used in both places.
|
|
549
|
+
"""
|
|
550
|
+
if not window_ids:
|
|
551
|
+
return 0
|
|
552
|
+
|
|
553
|
+
def probe(var):
|
|
554
|
+
out = [f'set {var} to ""']
|
|
555
|
+
for wid in window_ids:
|
|
556
|
+
out += ["try",
|
|
557
|
+
f" if (count of tabs of window id {wid}) > 0 then",
|
|
558
|
+
f' set {var} to {var} & "{wid}" & linefeed',
|
|
559
|
+
" end if",
|
|
560
|
+
"end try"]
|
|
561
|
+
return out
|
|
562
|
+
|
|
563
|
+
lines = ['tell application "Terminal"']
|
|
564
|
+
lines += probe("started")
|
|
565
|
+
for wid in window_ids:
|
|
566
|
+
lines += ["try", f" close (first window whose id is {wid})", "end try"]
|
|
567
|
+
# Terminal needs a moment to take a window down before it stops answering
|
|
568
|
+
# for it; without this the survivor check races the close it just asked for.
|
|
569
|
+
lines.append("delay 0.4")
|
|
570
|
+
lines += probe("ended")
|
|
571
|
+
lines += ['return started & "--" & ended', "end tell"]
|
|
572
|
+
raw = _osascript("\n".join(lines), timeout=10.0)
|
|
573
|
+
if not terminal_status()["ok"]:
|
|
574
|
+
return 0
|
|
575
|
+
started, _, ended = raw.partition("--")
|
|
576
|
+
was_open = {ln.strip() for ln in started.splitlines() if ln.strip()}
|
|
577
|
+
still_open = {ln.strip() for ln in ended.splitlines() if ln.strip()}
|
|
578
|
+
return len(was_open - still_open)
|