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.
@@ -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)