chartremotely 0.2.9__tar.gz → 0.2.11__tar.gz

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.
Files changed (54) hide show
  1. {chartremotely-0.2.9 → chartremotely-0.2.11}/PKG-INFO +1 -1
  2. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/ax.py +19 -5
  3. chartremotely-0.2.11/chartremotely/popups.py +258 -0
  4. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/requestlog.py +9 -0
  5. chartremotely-0.2.11/chartremotely/screen.py +116 -0
  6. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/symbol.py +11 -1
  7. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/timeframe.py +8 -7
  8. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/vocab.py +25 -7
  9. chartremotely-0.2.11/chartremotely/window.py +170 -0
  10. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/PKG-INFO +1 -1
  11. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/SOURCES.txt +4 -0
  12. {chartremotely-0.2.9 → chartremotely-0.2.11}/pyproject.toml +1 -1
  13. chartremotely-0.2.11/tests/test_popups.py +132 -0
  14. chartremotely-0.2.11/tests/test_screen.py +77 -0
  15. chartremotely-0.2.9/chartremotely/window.py +0 -134
  16. {chartremotely-0.2.9 → chartremotely-0.2.11}/LICENSE +0 -0
  17. {chartremotely-0.2.9 → chartremotely-0.2.11}/README.md +0 -0
  18. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/__init__.py +0 -0
  19. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/answer.py +0 -0
  20. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/assets/shortcut-template.plist +0 -0
  21. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/cli.py +0 -0
  22. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/config.py +0 -0
  23. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/doctor.py +0 -0
  24. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/guilock.py +0 -0
  25. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/keystore.py +0 -0
  26. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/layout.py +0 -0
  27. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/mcpclient.py +0 -0
  28. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/patron.py +0 -0
  29. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/push.py +0 -0
  30. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/registry.py +0 -0
  31. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/relay.py +0 -0
  32. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/resolve.py +0 -0
  33. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/scales.py +0 -0
  34. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/server.py +0 -0
  35. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/services.py +0 -0
  36. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/setup.py +0 -0
  37. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/shortcut.py +0 -0
  38. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/snapshot.py +0 -0
  39. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/studies.py +0 -0
  40. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/tailnet.py +0 -0
  41. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/dependency_links.txt +0 -0
  42. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/entry_points.txt +0 -0
  43. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/requires.txt +0 -0
  44. {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/top_level.txt +0 -0
  45. {chartremotely-0.2.9 → chartremotely-0.2.11}/setup.cfg +0 -0
  46. {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_patron.py +0 -0
  47. {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_push.py +0 -0
  48. {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_relay.py +0 -0
  49. {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_requestlog.py +0 -0
  50. {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_resolve.py +0 -0
  51. {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_setup.py +0 -0
  52. {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_snapshot.py +0 -0
  53. {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_stamp_changelog.py +0 -0
  54. {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_timeframe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.9
3
+ Version: 0.2.11
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
  import time
23
23
 
24
24
  import Quartz
25
- from AppKit import NSWorkspace
25
+ from AppKit import NSRunningApplication, NSWorkspace
26
26
  from ApplicationServices import (
27
27
  AXUIElementCopyActionNames,
28
28
  AXUIElementCopyAttributeValue,
@@ -57,11 +57,25 @@ class NotRunning(RuntimeError):
57
57
  """thinkorswim is not running."""
58
58
 
59
59
 
60
+ def app_for(pid: int):
61
+ """The running application with ``pid``, looked up now; None if gone.
62
+
63
+ Never from ``NSWorkspace.runningApplications()``: that list only
64
+ refreshes on a run loop, which the listener and relay never spin, so an
65
+ app launched (or quit) after they started is missing from it (or lingers).
66
+ """
67
+ return NSRunningApplication.runningApplicationWithProcessIdentifier_(pid)
68
+
69
+
60
70
  def running_app():
61
- for a in NSWorkspace.sharedWorkspace().runningApplications():
62
- if APP_NAME.lower() in (a.localizedName() or "").lower():
63
- return a
64
- raise NotRunning(f"{APP_NAME} is not running")
71
+ """thinkorswim, found through the window server's live list of windows."""
72
+ for w in Quartz.CGWindowListCopyWindowInfo(
73
+ Quartz.kCGWindowListOptionAll, Quartz.kCGNullWindowID) or []:
74
+ if APP_NAME.lower() in (w.get("kCGWindowOwnerName") or "").lower():
75
+ app = app_for(w["kCGWindowOwnerPID"])
76
+ if app is not None:
77
+ return app
78
+ raise NotRunning(f"{APP_NAME} is not open on this Mac")
65
79
 
66
80
 
67
81
  def handle(app=None):
@@ -0,0 +1,258 @@
1
+ """Close thinkorswim's popups before anyone looks at the chart.
2
+
3
+ Two popups get left open over the chart, and a picture taken then shows them:
4
+
5
+ * the symbol field's autocomplete list (PLTR:XTSE, PLTRX, ...), which hangs
6
+ under the field after a symbol is typed and often outlives the Return that
7
+ committed it;
8
+ * the aggregation (time frame) menu, which stays open after a preset is
9
+ picked or the current one is read.
10
+
11
+ Neither answers the obvious remedies. An Escape posted to thinkorswim lands on
12
+ whatever holds keyboard focus, which after a commit is nothing at all - so it
13
+ is silently dropped. What works, measured on thinkorswim build 1993:
14
+
15
+ * the aggregation menu closes when its toggle is pressed again (``AXPress``);
16
+ the toggle's ``AXValue`` is 1 while the menu shows;
17
+ * the autocomplete list closes on Escape once the symbol field has focus.
18
+ Escape only hides the list: text typed but not yet committed stays in the
19
+ field, so it never cancels an entry;
20
+ * focus leaves the field when the chart's own title strip (the line under
21
+ the field reading "PLTR 10 D 30m") is focused. Setting the field's
22
+ ``AXFocused`` to false is accepted and ignored.
23
+
24
+ A Swing field commits its text when it loses focus, so focus is moved off the
25
+ field only when that text is what the chart already shows. Otherwise the field
26
+ keeps focus and its pending text, and nothing changes.
27
+
28
+ The decisions are pure and live at module scope, over a :class:`Seen`
29
+ snapshot, so they are tested where PyObjC is absent. :func:`dismiss` gathers
30
+ the snapshots and acts; its macOS imports are deferred.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import re
36
+ import time
37
+ from collections.abc import Callable
38
+ from dataclasses import dataclass
39
+
40
+ Rect = tuple[float, float, float, float]
41
+
42
+ #: Longest dismiss() spends closing popups.
43
+ TIMEOUT = 1.5
44
+ #: How often dismiss() looks again after acting.
45
+ POLL = 0.1
46
+ #: Longest dismiss() waits for one action to show before trying it again.
47
+ ACT_WAIT = 0.5
48
+ #: Points of slack when deciding a list hangs from the symbol field.
49
+ SLACK = 8
50
+ #: How far under the field's bottom edge to hit-test for a list or the title.
51
+ PROBE_DY, PROBE_DX = 8, 8
52
+
53
+ #: The first word of the chart's title strip: the symbol it is drawn for.
54
+ CHART_SYMBOL = re.compile(r"^[./$^]?[A-Z0-9]{1,12}([/.][A-Z0-9]{1,3})?(:[A-Z]+)?$")
55
+
56
+ CLOSE_SCALE, CLOSE_LIST, LEAVE_FIELD = "close scale menu", "close symbol list", "leave field"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Seen:
61
+ """What the chart's popups and symbol field looked like at one moment."""
62
+
63
+ scale_open: bool = False
64
+ list_open: bool = False
65
+ field_focused: bool = False
66
+ field_value: str | None = None
67
+ #: The chart's title strip, e.g. "PLTR 10 D 30m"; None while a list covers it.
68
+ title: str | None = None
69
+
70
+ @property
71
+ def clean(self) -> bool:
72
+ """Nothing is drawn over the chart."""
73
+ return not (self.scale_open or self.list_open)
74
+
75
+
76
+ def hangs_under(field: Rect, table: Rect | None) -> bool:
77
+ """Whether ``table`` is the autocomplete list dropped from the symbol field.
78
+
79
+ thinkorswim paints that list inside the window, so the window server never
80
+ sees it; the accessibility tree reports it as a table whose top-left corner
81
+ sits on the field's bottom-left corner. ``field`` is the field's combo box.
82
+ """
83
+ if table is None:
84
+ return False
85
+ fx, fy, _, fh = field
86
+ tx, ty, tw, th = table
87
+ return (tw > 0 and th > 0 and abs(tx - fx) <= SLACK
88
+ and abs(ty - (fy + fh)) <= SLACK)
89
+
90
+
91
+ def chart_symbol(title: str | None) -> str | None:
92
+ """The symbol a chart title strip names ("PLTR 10 D 30m" -> "PLTR"), or None."""
93
+ if not isinstance(title, str) or not title.strip():
94
+ return None
95
+ word = title.split()[0]
96
+ return word if CHART_SYMBOL.match(word) else None
97
+
98
+
99
+ def shows(title: str | None, entry: str | None) -> bool:
100
+ """Whether the chart already shows what ``entry`` names.
101
+
102
+ A futures root typed as "/ES" is drawn as its front contract ("/ESZ26"),
103
+ so a root counts as shown by any of its contracts.
104
+ """
105
+ drawn = chart_symbol(title)
106
+ want = (entry or "").strip().upper()
107
+ if not drawn or not want:
108
+ return False
109
+ return drawn == want or (want.startswith("/") and drawn.startswith(want))
110
+
111
+
112
+ def next_step(seen: Seen) -> str | None:
113
+ """The one thing to do next, or None when there is nothing safe left to do.
114
+
115
+ The menu is closed before the list: its toggle is pressed through the
116
+ tree, which needs no focus, whereas closing the list moves focus into the
117
+ field. Focus leaves the field only when the chart already shows its text,
118
+ because losing focus commits whatever the field holds.
119
+ """
120
+ if seen.scale_open:
121
+ return CLOSE_SCALE
122
+ if seen.list_open:
123
+ return CLOSE_LIST
124
+ if seen.field_focused and shows(seen.title, seen.field_value):
125
+ return LEAVE_FIELD
126
+ return None
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class Dismissed:
131
+ """What dismiss() did, and whether the chart was left uncovered."""
132
+
133
+ did: tuple[str, ...] = ()
134
+ clean: bool = True
135
+
136
+ def __bool__(self) -> bool:
137
+ return bool(self.did)
138
+
139
+ def __str__(self) -> str:
140
+ done = "; ".join(self.did) or "nothing open"
141
+ return done if self.clean else done + "; a popup is still open"
142
+
143
+
144
+ def run(look: Callable[[], Seen], act: Callable[[str], None], *,
145
+ until: Callable[[Seen], bool] | None = None, timeout: float = TIMEOUT,
146
+ clock: Callable[[], float] = time.monotonic,
147
+ sleep: Callable[[float], None] = time.sleep) -> Dismissed:
148
+ """Look, act on the next step, and look again until nothing is left.
149
+
150
+ ``until``, when given, must also hold before it stops early: a symbol just
151
+ committed is waited for until the chart's title names it, so focus can
152
+ leave the field once it is safe to. One action is retried only after it
153
+ has had ACT_WAIT to show, so a slow repaint is not answered with a second
154
+ press of a toggle.
155
+ """
156
+ deadline = clock() + timeout
157
+ did: list[str] = []
158
+ last: tuple[str | None, float] = (None, -ACT_WAIT)
159
+ while True:
160
+ seen = look()
161
+ step = next_step(seen)
162
+ now = clock()
163
+ if step is None and (until is None or until(seen)) or now >= deadline:
164
+ return Dismissed(tuple(did), seen.clean)
165
+ if step is not None and (step != last[0] or now - last[1] >= ACT_WAIT):
166
+ act(step)
167
+ if step not in did:
168
+ did.append(step)
169
+ last = (step, now)
170
+ sleep(POLL)
171
+
172
+
173
+ # --------------------------------------------------------------------------
174
+ # macOS. Imported lazily so the decisions above stay importable anywhere.
175
+
176
+
177
+ def _frame(ax, element) -> Rect | None:
178
+ p, s = ax.position(element), ax.size(element)
179
+ return (p.x, p.y, s.width, s.height) if p and s else None
180
+
181
+
182
+ def _under_field(ax, ax_app, field):
183
+ """The combo's frame, the element just under it, and the list that is, if any."""
184
+ combo = ax.attr(field, "AXParent")
185
+ frame = _frame(ax, combo) if combo is not None else None
186
+ if frame is None:
187
+ return None, None, None
188
+ x, y, _, h = frame
189
+ hit = ax.element_at(ax_app, x + PROBE_DX, y + h + PROBE_DY)
190
+ table = None
191
+ for element in (hit, ax.attr(hit, "AXParent") if hit is not None else None):
192
+ if element is not None and ax.attr(element, "AXRole") == "AXTable":
193
+ table = element
194
+ break
195
+ return frame, hit, table
196
+
197
+
198
+ def dismiss(app=None, expect: str | None = None, timeout: float = TIMEOUT) -> Dismissed:
199
+ """Close the symbol list and the scale menu, and take focus off the field.
200
+
201
+ ``expect`` is a symbol just committed: dismiss() then also waits, within
202
+ ``timeout``, for the chart's title to name it before it lets focus go.
203
+
204
+ Idempotent, and cheap when nothing is open: a few accessibility reads.
205
+ Call it with thinkorswim in front (hit-testing answers from the menu bar
206
+ otherwise), inside the chart lock, after the change it tidies up after.
207
+ A field or toggle that cannot be found is left alone rather than raised
208
+ about: tidying up must never turn a change that landed into an error.
209
+ """
210
+ from . import ax, config, symbol, timeframe
211
+
212
+ app = app or ax.running_app()
213
+ pid = app.processIdentifier()
214
+ ax_app = ax.handle(app)
215
+ cfg = config.load()
216
+ try:
217
+ field, _ = symbol.locate(ax_app, cfg)
218
+ except symbol.NotFound:
219
+ field = None
220
+ try:
221
+ toggle, _ = timeframe._control(ax_app, cfg)
222
+ except timeframe.MenuError:
223
+ toggle = None
224
+ title = [None]
225
+
226
+ def look() -> Seen:
227
+ scale_open = toggle is not None and ax.attr(toggle, "AXValue") not in (0, "0", False, None)
228
+ if field is None:
229
+ return Seen(scale_open=scale_open)
230
+ frame, hit, table = _under_field(ax, ax_app, field)
231
+ list_open = frame is not None and hangs_under(
232
+ frame, _frame(ax, table) if table is not None else None)
233
+ title[0] = None if list_open else hit
234
+ return Seen(
235
+ scale_open=scale_open,
236
+ list_open=list_open,
237
+ field_focused=bool(ax.attr(field, "AXFocused")),
238
+ field_value=ax.attr(field, "AXValue"),
239
+ title=ax.attr(title[0], "AXValue") if title[0] is not None else None,
240
+ )
241
+
242
+ def act(step: str) -> None:
243
+ if step == CLOSE_SCALE:
244
+ ax.perform(toggle, "AXPress")
245
+ elif step == CLOSE_LIST:
246
+ # Escape reaches only the focused component; after a commit that
247
+ # is nothing, so the field is focused first.
248
+ if not ax.attr(field, "AXFocused"):
249
+ ax.set_attr(field, "AXFocused", True)
250
+ time.sleep(0.1)
251
+ ax.escape(pid)
252
+ elif step == LEAVE_FIELD:
253
+ # LEAVE_FIELD is only chosen when the element under the field
254
+ # read as the chart's title, so title[0] is that strip.
255
+ ax.set_attr(title[0], "AXFocused", True)
256
+
257
+ until = (lambda seen: shows(seen.title, expect)) if expect else None
258
+ return run(look, act, until=until, timeout=timeout)
@@ -51,3 +51,12 @@ def outcome(source: str, tool: str, where: str, error: str | None) -> None:
51
51
  print(" ".join(parts), file=sys.stderr, flush=True)
52
52
  except (OSError, ValueError):
53
53
  pass
54
+
55
+
56
+ def note(source: str, text: str) -> None:
57
+ """One free-form line, e.g. what it took to bring the chart in front. Never raises."""
58
+ try:
59
+ print(f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {source} {_clean(text, ERR_MAX)}",
60
+ file=sys.stderr, flush=True)
61
+ except (OSError, ValueError):
62
+ pass
@@ -0,0 +1,116 @@
1
+ """Decisions about what is on screen, made from a snapshot of the window list.
2
+
3
+ Getting the chart in front of someone takes macOS calls - unhide, activate,
4
+ un-minimise, raise, hide what covers it - but deciding WHICH window is the
5
+ chart, WHETHER it is on screen yet and WHAT covers it needs none of them. That
6
+ half lives here, over plain dicts shaped like Quartz's window list, so it runs
7
+ (and is tested) where PyObjC is absent. :mod:`window` feeds it.
8
+
9
+ A window is a dict with ``kCGWindowOwnerPID``, ``kCGWindowLayer`` and
10
+ ``kCGWindowBounds`` (``X``, ``Y``, ``Width``, ``Height``), as
11
+ ``CGWindowListCopyWindowInfo`` reports it: front to back, on-screen only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from collections.abc import Callable, Iterable
18
+ from dataclasses import dataclass
19
+
20
+ Frame = tuple[float, float, float, float]
21
+
22
+ #: Points of slack when matching the accessibility frame to the window list.
23
+ TOLERANCE = 2.0
24
+
25
+
26
+ def overlaps(a: dict, b: dict) -> bool:
27
+ """Whether two bounds dicts share any area."""
28
+ return not (a.get("X", 0) + a.get("Width", 0) <= b.get("X", 0)
29
+ or a.get("X", 0) >= b.get("X", 0) + b.get("Width", 0)
30
+ or a.get("Y", 0) + a.get("Height", 0) <= b.get("Y", 0)
31
+ or a.get("Y", 0) >= b.get("Y", 0) + b.get("Height", 0))
32
+
33
+
34
+ def largest(windows: Iterable[dict], pid: int) -> dict | None:
35
+ """The largest window ``pid`` has on screen."""
36
+ best, best_area = None, -1.0
37
+ for w in windows:
38
+ if w.get("kCGWindowOwnerPID") != pid:
39
+ continue
40
+ b = w.get("kCGWindowBounds") or {}
41
+ area = b.get("Width", 0) * b.get("Height", 0)
42
+ if area > best_area:
43
+ best, best_area = w, area
44
+ return best
45
+
46
+
47
+ def showing(windows: Iterable[dict], pid: int, frame: Frame) -> dict | None:
48
+ """The on-screen window of ``pid`` whose bounds are ``frame``, or None.
49
+
50
+ ``frame`` is the chart window's (x, y, w, h) as the accessibility tree
51
+ reports it. Matching on it, rather than taking the app's largest window,
52
+ means a detached thinkorswim window left on screen does not count as the
53
+ chart being back.
54
+ """
55
+ for w in windows:
56
+ if w.get("kCGWindowOwnerPID") != pid:
57
+ continue
58
+ b = w.get("kCGWindowBounds") or {}
59
+ got = (b.get("X", 0), b.get("Y", 0), b.get("Width", 0), b.get("Height", 0))
60
+ if all(abs(g - f) <= TOLERANCE for g, f in zip(got, frame, strict=True)):
61
+ return w
62
+ return None
63
+
64
+
65
+ def covering(windows: Iterable[dict], bounds: dict, pid: int,
66
+ regular: Iterable[int]) -> list[int]:
67
+ """Pids of ordinary apps with a window overlapping ``bounds``.
68
+
69
+ Only ``regular`` apps count: system overlays keep permanent full-screen
70
+ windows that are neither hideable nor actually in the way. Negative
71
+ layers are desktop furniture, never in front of the chart.
72
+ """
73
+ allowed = set(regular) - {pid}
74
+ found = [w["kCGWindowOwnerPID"] for w in windows
75
+ if w.get("kCGWindowOwnerPID") in allowed
76
+ and w.get("kCGWindowLayer", 0) >= 0
77
+ and overlaps(w.get("kCGWindowBounds") or {}, bounds)]
78
+ return list(dict.fromkeys(found))
79
+
80
+
81
+ def wait_for(probe: Callable[[], object], timeout: float = 2.0, interval: float = 0.1,
82
+ clock: Callable[[], float] = time.monotonic,
83
+ sleep: Callable[[float], None] = time.sleep):
84
+ """Poll ``probe`` until it returns something truthy, for at most ``timeout`` seconds.
85
+
86
+ Returns that value, or None when time ran out. Always probes at least once.
87
+ """
88
+ deadline = clock() + timeout
89
+ while True:
90
+ found = probe()
91
+ if found:
92
+ return found
93
+ if clock() >= deadline:
94
+ return None
95
+ sleep(interval)
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Presented:
100
+ """What it took to get the chart in front of the viewer."""
101
+
102
+ unhid: bool = False
103
+ unminimized: bool = False
104
+ raised: bool = False
105
+ hid: tuple[str, ...] = ()
106
+
107
+ def __bool__(self) -> bool:
108
+ return self.unhid or self.unminimized or self.raised or bool(self.hid)
109
+
110
+ def __str__(self) -> str:
111
+ done = [word for word, did in (("unhid", self.unhid),
112
+ ("unminimized", self.unminimized),
113
+ ("raised", self.raised)) if did]
114
+ if self.hid:
115
+ done.append("hid " + ", ".join(self.hid))
116
+ return "; ".join(done) or "already in front"
@@ -13,11 +13,15 @@ import time
13
13
 
14
14
  import Quartz
15
15
 
16
- from . import ax, config, layout
16
+ from . import ax, config, layout, popups
17
17
 
18
18
  # Accepts tickers, futures (/ES), indices (.SPX) and share classes (BRK/B).
19
19
  TICKER = re.compile(r"^[./$]?[A-Z]{1,6}([/.][A-Z]{1,2})?(:[A-Z]+)?$")
20
20
 
21
+ #: Longest show() waits for the chart's title to name the new symbol.
22
+ COMMIT_WAIT = 3.0
23
+
24
+
21
25
  class NotFound(RuntimeError):
22
26
  """The symbol field could not be located or did not validate."""
23
27
 
@@ -157,4 +161,10 @@ def show(ticker: str, app=None) -> str:
157
161
  ax.key(ax.KEY_RETURN, 0, pid)
158
162
  time.sleep(0.25)
159
163
  ax.key(ax.KEY_RETURN, 0, pid)
164
+
165
+ # The list can outlive both Returns and would sit over the chart - and
166
+ # over the chart's title, the one place that says the symbol took. Close
167
+ # it (Escape never discards a typed entry), wait for the title to name the
168
+ # ticker, then take focus off the field so nothing is left hanging.
169
+ popups.dismiss(app, expect=ticker, timeout=COMMIT_WAIT)
160
170
  return previous
@@ -7,6 +7,8 @@ which is the most awkward control in the application:
7
7
  * it ignores process-targeted mouse events entirely - no hover, no click
8
8
  * arrow keys do not move its selection
9
9
  * it dismisses when window activation changes
10
+ * Escape does not close it (keyboard focus is elsewhere); pressing its
11
+ toggle again does - see :mod:`popups`
10
12
 
11
13
  Only a real HID click works, which means the target must be uncovered
12
14
  *before* the popup opens. Clearing occluders afterwards dismisses it.
@@ -21,7 +23,7 @@ from __future__ import annotations
21
23
  import re
22
24
  import time
23
25
 
24
- from . import ax, config, layout
26
+ from . import ax, config, layout, popups
25
27
  from .scales import AS_IS, MNEMONIC, canon, to_code
26
28
 
27
29
  ITEM = re.compile(r"^\s*\d+\s*[DYW]\s*:\s*\S+", re.IGNORECASE)
@@ -138,7 +140,7 @@ def presets(app=None) -> list[str]:
138
140
  ax_app = ax.handle(app)
139
141
  _open(ax_app, config.load())
140
142
  rows = _rows(ax_app)
141
- ax.escape(app.processIdentifier())
143
+ popups.dismiss(app)
142
144
  return [r["label"] for r in rows]
143
145
 
144
146
 
@@ -149,7 +151,7 @@ def current(app=None) -> tuple[str, str]:
149
151
  ax_app = ax.handle(app)
150
152
  _open(ax_app, config.load())
151
153
  rows = _rows(ax_app)
152
- ax.escape(app.processIdentifier())
154
+ popups.dismiss(app)
153
155
  active = next((r for r in rows if r["active"]), None)
154
156
  if active is None:
155
157
  raise MenuError("could not read the current scale")
@@ -202,9 +204,8 @@ def set_scale(phrase: str, app=None) -> tuple[str, str]:
202
204
  ax.click(match["x"], match["y"], pid)
203
205
  time.sleep(0.5)
204
206
  label = match["label"]
205
- return label, MNEMONIC.get(canon(label), label)
206
- except Exception:
207
- ax.escape(pid)
208
- raise
209
207
  finally:
208
+ # Picked or refused, the menu must not stay over the chart.
209
+ popups.dismiss(app)
210
210
  ax.unhide_apps(hidden)
211
+ return label, MNEMONIC.get(canon(label), label)
@@ -16,7 +16,7 @@ Two rules the callers depend on:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- from . import guilock, registry, resolve, scales, snapshot, symbol, timeframe, window
19
+ from . import guilock, registry, requestlog, resolve, scales, snapshot, symbol, timeframe, window
20
20
  from .answer import Answer
21
21
 
22
22
  ERR = "ERR "
@@ -26,6 +26,21 @@ def _short(text: str, limit: int = 120) -> str:
26
26
  return str(text).splitlines()[0][:limit] if text else "something went wrong"
27
27
 
28
28
 
29
+ def _present() -> str | None:
30
+ """Get the chart in front of the viewer before driving it; see window.present.
31
+
32
+ Returns an ERR reply when it cannot be, else None. What it took (unhid,
33
+ raised, which apps it hid) goes to the log, never into the spoken reply.
34
+ """
35
+ try:
36
+ done = window.present()
37
+ except Exception as exc:
38
+ return ERR + _short(exc)
39
+ if done:
40
+ requestlog.note("present", str(done))
41
+ return None
42
+
43
+
29
44
  def cmd_resolve(spoken: str) -> str:
30
45
  """A spoken company name, to a ticker."""
31
46
  if not spoken.strip():
@@ -63,6 +78,9 @@ def set_chart(ticker: str, scale: str = "") -> Answer:
63
78
  ticker = ticker.strip().replace(" ", "")
64
79
  if not ticker:
65
80
  return Answer(ERR + "No symbol to show.")
81
+ refused = _present()
82
+ if refused:
83
+ return Answer(refused)
66
84
  try:
67
85
  symbol.show(ticker)
68
86
  except Exception as exc:
@@ -76,12 +94,6 @@ def set_chart(ticker: str, scale: str = "") -> Answer:
76
94
  except Exception:
77
95
  word = None
78
96
 
79
- # Uncover the chart: a voice answer is only useful if the screen shows it.
80
- try:
81
- window.clear()
82
- except Exception:
83
- pass
84
-
85
97
  if word:
86
98
  return Answer(f"Showing {ticker} at {word}. Good luck.", shown, word)
87
99
  try:
@@ -93,6 +105,9 @@ def set_chart(ticker: str, scale: str = "") -> Answer:
93
105
 
94
106
  def cmd_read() -> str:
95
107
  """Report the chart's current symbol and scale."""
108
+ refused = _present()
109
+ if refused:
110
+ return refused
96
111
  try:
97
112
  ticker = symbol.current()
98
113
  _, word = timeframe.current()
@@ -103,6 +118,9 @@ def cmd_read() -> str:
103
118
 
104
119
  def cmd_snapshot() -> str:
105
120
  """A picture of the chart pane, so a caller far away can see it."""
121
+ refused = _present()
122
+ if refused:
123
+ return refused
106
124
  try:
107
125
  return snapshot.as_reply(snapshot.shrink(window.capture()))
108
126
  except Exception as exc:
@@ -0,0 +1,170 @@
1
+ """Make the chart visible, and prove what it shows.
2
+
3
+ A voice command answered from across the room is only useful if the chart
4
+ is actually on screen, and only trustworthy if the caller can see that it
5
+ changed. Both matter more remotely than locally: at the desk you can see it
6
+ worked; from another building a silent failure is indistinguishable from
7
+ success.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ import tempfile
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import Quartz
18
+
19
+ from . import ax, config, layout, popups, screen, snapshot, symbol
20
+
21
+ #: NSApplicationActivateAllWindows | NSApplicationActivateIgnoringOtherApps:
22
+ #: bring every thinkorswim window forward, even when another app is in front.
23
+ ACTIVATE = 1 | 2
24
+ #: Longest wait for the chart window to reach the screen after being raised.
25
+ ON_SCREEN_WAIT = 2.0
26
+
27
+
28
+ class NotOnScreen(RuntimeError):
29
+ """The chart window could not be brought on screen."""
30
+
31
+
32
+ def _on_screen() -> list[dict]:
33
+ return list(Quartz.CGWindowListCopyWindowInfo(
34
+ Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID) or [])
35
+
36
+
37
+ def _frame(pid: int) -> dict | None:
38
+ """The largest on-screen thinkorswim window."""
39
+ return screen.largest(_on_screen(), pid)
40
+
41
+
42
+ def _ax_frame(win) -> screen.Frame | None:
43
+ p, s = ax.position(win), ax.size(win)
44
+ return (p.x, p.y, s.width, s.height) if p and s else None
45
+
46
+
47
+ def present(app=None) -> screen.Presented:
48
+ """Put the chart window in front of the viewer, before anything drives it.
49
+
50
+ Unhides thinkorswim, brings it forward, un-minimises and raises the chart
51
+ window (the one titled ``window_prefix``, which every driver works in),
52
+ waits until it is actually on screen - activation also switches to its
53
+ Space when it is full screen elsewhere - then hides the ordinary apps
54
+ overlapping it. Only regular apps are ever hidden: system overlays keep
55
+ permanent full-screen windows and are neither hideable nor in the way.
56
+
57
+ Run inside the chart lock, like every command that drives the chart.
58
+ """
59
+ app = app or ax.running_app()
60
+ pid = app.processIdentifier()
61
+ ax_app = ax.handle(app)
62
+ # Read from the process itself: NSRunningApplication's isHidden/isActive
63
+ # only refresh on a run loop, which the listener and relay never spin.
64
+ unhid = bool(ax.attr(ax_app, "AXHidden"))
65
+ raised = not ax.attr(ax_app, "AXFrontmost")
66
+ if unhid:
67
+ app.unhide()
68
+ app.activateWithOptions_(ACTIVATE)
69
+
70
+ prefix = config.load()["window_prefix"]
71
+ win = screen.wait_for(lambda: ax.window(ax_app, prefix), timeout=ON_SCREEN_WAIT)
72
+ if win is None:
73
+ raise NotOnScreen(f"no thinkorswim window titled {prefix!r}")
74
+ unminimized = bool(ax.attr(win, "AXMinimized"))
75
+ if unminimized:
76
+ ax.set_attr(win, "AXMinimized", False)
77
+ ax.perform(win, "AXRaise")
78
+ ax.set_attr(win, "AXMain", True)
79
+ ax.set_attr(win, "AXFocused", True)
80
+
81
+ def arrived():
82
+ frame = _ax_frame(win)
83
+ return frame and screen.showing(_on_screen(), pid, frame)
84
+
85
+ target = screen.wait_for(arrived, timeout=ON_SCREEN_WAIT)
86
+ if target is None:
87
+ raise NotOnScreen("the chart window did not come on screen")
88
+
89
+ hid = _uncover(pid, target["kCGWindowBounds"])
90
+ app.activateWithOptions_(ACTIVATE)
91
+ return screen.Presented(unhid=unhid, unminimized=unminimized,
92
+ raised=raised or unminimized, hid=hid)
93
+
94
+
95
+ def _uncover(pid: int, bounds: dict) -> tuple[str, ...]:
96
+ """Hide every ordinary app with a window over ``bounds``; their names."""
97
+ windows = _on_screen()
98
+ apps = {p: ax.app_for(p) for p in {w.get("kCGWindowOwnerPID") for w in windows} if p}
99
+ regular = {p: a for p, a in apps.items() if a is not None and a.activationPolicy() == 0}
100
+ hidden = []
101
+ # An app with a window on screen is not hidden, whatever a cached
102
+ # isHidden says; see present().
103
+ for p in screen.covering(windows, bounds, pid, regular):
104
+ a = regular[p]
105
+ a.hide()
106
+ hidden.append(a.localizedName() or str(p))
107
+ if hidden:
108
+ time.sleep(0.4)
109
+ return tuple(sorted(hidden))
110
+
111
+
112
+ def capture(app=None) -> bytes:
113
+ """PNG of the chart's own pane, and nothing else in the window.
114
+
115
+ Any popup left over the chart is closed first; see :func:`popups.dismiss`.
116
+
117
+ Captured by window id rather than screen region, so it works even when
118
+ something is floating on top - which is what makes it usable as proof
119
+ that a remote command landed. Then cropped to the pane that holds the
120
+ chart's symbol field; see :func:`snapshot.chart_crop` for why the rest of
121
+ the window never leaves this machine.
122
+ """
123
+ app = app or ax.running_app()
124
+ target = _frame(app.processIdentifier())
125
+ if target is None:
126
+ raise ax.NotRunning("no thinkorswim window on screen")
127
+ b = target["kCGWindowBounds"]
128
+ bounds = (int(b["X"]), int(b["Y"]), int(b["Width"]), int(b["Height"]))
129
+
130
+ # Hit-testing answers from the menu bar unless the app is in front.
131
+ ax.activate(app)
132
+ # Never photograph a symbol list or scale menu left over the chart.
133
+ popups.dismiss(app)
134
+ ax_app = ax.handle(app)
135
+ prefix = config.load()["window_prefix"]
136
+ hit = symbol.discover(ax_app, prefix)
137
+ if hit is None:
138
+ raise snapshot.ChartNotIsolated("no chart is visible")
139
+ panes = layout.panes(ax_app, prefix)
140
+
141
+ with tempfile.TemporaryDirectory() as tmp:
142
+ shot = Path(tmp) / "window.png"
143
+ subprocess.run(["screencapture", "-x", "-o", "-l", str(target["kCGWindowNumber"]), str(shot)],
144
+ check=True, capture_output=True)
145
+ crop = snapshot.chart_crop(hit, panes, bounds, _pixel_size(shot))
146
+ chart = Path(tmp) / "chart.png"
147
+ _crop(shot, chart, *crop)
148
+ return chart.read_bytes()
149
+
150
+
151
+ def _image(path: Path):
152
+ from CoreFoundation import CFURLCreateWithFileSystemPath, kCFURLPOSIXPathStyle
153
+
154
+ url = CFURLCreateWithFileSystemPath(None, str(path), kCFURLPOSIXPathStyle, False)
155
+ return Quartz.CGImageSourceCreateImageAtIndex(Quartz.CGImageSourceCreateWithURL(url, None), 0, None)
156
+
157
+
158
+ def _pixel_size(path: Path) -> tuple[int, int]:
159
+ image = _image(path)
160
+ return Quartz.CGImageGetWidth(image), Quartz.CGImageGetHeight(image)
161
+
162
+
163
+ def _crop(src: Path, dst: Path, x: int, y: int, w: int, h: int) -> None:
164
+ from CoreFoundation import CFURLCreateWithFileSystemPath, kCFURLPOSIXPathStyle
165
+
166
+ region = Quartz.CGImageCreateWithImageInRect(_image(src), Quartz.CGRectMake(x, y, w, h))
167
+ out_url = CFURLCreateWithFileSystemPath(None, str(dst), kCFURLPOSIXPathStyle, False)
168
+ dest = Quartz.CGImageDestinationCreateWithURL(out_url, "public.png", 1, None)
169
+ Quartz.CGImageDestinationAddImage(dest, region, None)
170
+ Quartz.CGImageDestinationFinalize(dest)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.9
3
+ Version: 0.2.11
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -12,12 +12,14 @@ chartremotely/keystore.py
12
12
  chartremotely/layout.py
13
13
  chartremotely/mcpclient.py
14
14
  chartremotely/patron.py
15
+ chartremotely/popups.py
15
16
  chartremotely/push.py
16
17
  chartremotely/registry.py
17
18
  chartremotely/relay.py
18
19
  chartremotely/requestlog.py
19
20
  chartremotely/resolve.py
20
21
  chartremotely/scales.py
22
+ chartremotely/screen.py
21
23
  chartremotely/server.py
22
24
  chartremotely/services.py
23
25
  chartremotely/setup.py
@@ -37,10 +39,12 @@ chartremotely.egg-info/requires.txt
37
39
  chartremotely.egg-info/top_level.txt
38
40
  chartremotely/assets/shortcut-template.plist
39
41
  tests/test_patron.py
42
+ tests/test_popups.py
40
43
  tests/test_push.py
41
44
  tests/test_relay.py
42
45
  tests/test_requestlog.py
43
46
  tests/test_resolve.py
47
+ tests/test_screen.py
44
48
  tests/test_setup.py
45
49
  tests/test_snapshot.py
46
50
  tests/test_stamp_changelog.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.2.9"
3
+ version = "0.2.11"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,132 @@
1
+ """The pure half of closing thinkorswim's popups: what is open, and what to do next."""
2
+
3
+ from chartremotely import popups
4
+ from chartremotely.popups import CLOSE_LIST, CLOSE_SCALE, LEAVE_FIELD, Seen
5
+
6
+ # The symbol combo and the list thinkorswim hung under it, as measured live.
7
+ COMBO = (373, 119, 126, 25)
8
+ LIST = (375, 142, 511, 190)
9
+
10
+
11
+ def test_a_table_on_the_fields_bottom_left_corner_is_its_list():
12
+ assert popups.hangs_under(COMBO, LIST)
13
+
14
+
15
+ def test_other_tables_are_not_the_list():
16
+ assert not popups.hangs_under(COMBO, None)
17
+ assert not popups.hangs_under(COMBO, (3, 662, 340, 1900)) # the watchlist
18
+ assert not popups.hangs_under(COMBO, (375, 300, 511, 190)) # lower in the pane
19
+ assert not popups.hangs_under(COMBO, (375, 142, 0, 0)) # collapsed, not drawn
20
+
21
+
22
+ def test_the_charts_title_names_its_symbol():
23
+ assert popups.chart_symbol("PLTR 10 D 30m") == "PLTR"
24
+ assert popups.chart_symbol("/ESZ26 5 D 5m") == "/ESZ26"
25
+ assert popups.chart_symbol("BRK/B 1 Y 1D") == "BRK/B"
26
+ assert popups.chart_symbol("Lo: 166.03") is None
27
+ assert popups.chart_symbol("") is None
28
+ assert popups.chart_symbol(None) is None
29
+
30
+
31
+ def test_the_chart_shows_an_entry_only_when_its_title_names_it():
32
+ assert popups.shows("PLTR 10 D 30m", "pltr ")
33
+ assert not popups.shows("PLTR 10 D 30m", "PLT")
34
+ assert popups.shows("/ESZ26 5 D 5m", "/ES") # a futures root, drawn as its contract
35
+ assert not popups.shows("ESTC 5 D 5m", "ES") # but not a mere prefix
36
+ assert not popups.shows(None, "PLTR")
37
+ assert not popups.shows("PLTR 10 D 30m", None)
38
+
39
+
40
+ def test_nothing_open_and_nothing_focused_needs_nothing():
41
+ assert popups.next_step(Seen(title="PLTR 10 D 30m", field_value="PLTR")) is None
42
+ assert Seen().clean
43
+
44
+
45
+ def test_the_menu_is_closed_before_the_list():
46
+ both = Seen(scale_open=True, list_open=True, field_value="PLTR")
47
+ assert not both.clean
48
+ assert popups.next_step(both) == CLOSE_SCALE
49
+ assert popups.next_step(Seen(list_open=True, field_value="PLTR")) == CLOSE_LIST
50
+
51
+
52
+ def test_focus_leaves_the_field_only_when_the_chart_shows_its_text():
53
+ committed = Seen(field_focused=True, field_value="PLTR", title="PLTR 10 D 30m")
54
+ assert popups.next_step(committed) == LEAVE_FIELD
55
+ # Losing focus commits the field: a pending "PLT" must keep focus.
56
+ pending = Seen(field_focused=True, field_value="PLT", title="PLTR 10 D 30m")
57
+ assert popups.next_step(pending) is None
58
+ # A title that has not caught up yet is not proof either.
59
+ behind = Seen(field_focused=True, field_value="NVDA", title="PLTR 10 D 30m")
60
+ assert popups.next_step(behind) is None
61
+
62
+
63
+ class Chart:
64
+ """A fake thinkorswim: popups that close when acted on, and a clock."""
65
+
66
+ def __init__(self, seen: Seen, title_after: float = 0.0, *, stuck: bool = False):
67
+ self.seen, self.title_after, self.stuck = seen, title_after, stuck
68
+ self.now, self.acts = 0.0, []
69
+
70
+ def clock(self):
71
+ return self.now
72
+
73
+ def sleep(self, seconds):
74
+ self.now += seconds
75
+
76
+ def look(self):
77
+ title = self.seen.title if self.now >= self.title_after else "OLD 1 D 1m"
78
+ return Seen(self.seen.scale_open, self.seen.list_open, self.seen.field_focused,
79
+ self.seen.field_value, None if self.seen.list_open else title)
80
+
81
+ def act(self, step):
82
+ self.acts.append(step)
83
+ if self.stuck:
84
+ return
85
+ s = self.seen
86
+ if step == CLOSE_SCALE:
87
+ self.seen = Seen(False, s.list_open, s.field_focused, s.field_value, s.title)
88
+ elif step == CLOSE_LIST:
89
+ self.seen = Seen(s.scale_open, False, True, s.field_value, s.title)
90
+ elif step == LEAVE_FIELD:
91
+ self.seen = Seen(s.scale_open, s.list_open, False, s.field_value, s.title)
92
+
93
+ def run(self, **kw):
94
+ return popups.run(self.look, self.act, clock=self.clock, sleep=self.sleep, **kw)
95
+
96
+
97
+ def test_run_closes_menu_then_list_then_lets_go_of_the_field():
98
+ chart = Chart(Seen(True, True, False, "PLTR", "PLTR 10 D 30m"))
99
+ done = chart.run()
100
+ assert chart.acts == [CLOSE_SCALE, CLOSE_LIST, LEAVE_FIELD]
101
+ assert done.clean and done.did == (CLOSE_SCALE, CLOSE_LIST, LEAVE_FIELD)
102
+
103
+
104
+ def test_run_is_cheap_and_silent_when_nothing_is_open():
105
+ chart = Chart(Seen(field_value="PLTR", title="PLTR 10 D 30m"))
106
+ done = chart.run()
107
+ assert chart.acts == [] and chart.now == 0
108
+ assert done.clean and not done and str(done) == "nothing open"
109
+
110
+
111
+ def test_run_leaves_a_pending_entry_in_the_field():
112
+ chart = Chart(Seen(list_open=True, field_value="PLT", title="PLTR 10 D 30m"))
113
+ done = chart.run()
114
+ assert chart.acts == [CLOSE_LIST]
115
+ assert done.clean and chart.seen.field_focused
116
+
117
+
118
+ def test_run_waits_for_the_title_to_name_the_new_symbol_before_letting_go():
119
+ chart = Chart(Seen(list_open=True, field_value="NVDA", title="NVDA 10 D 30m"),
120
+ title_after=0.8)
121
+ done = chart.run(until=lambda seen: popups.shows(seen.title, "NVDA"), timeout=3.0)
122
+ assert chart.acts == [CLOSE_LIST, LEAVE_FIELD]
123
+ assert 0.8 <= chart.now < 3.0 and done.clean
124
+
125
+
126
+ def test_run_gives_up_at_the_deadline_and_says_so():
127
+ chart = Chart(Seen(scale_open=True, field_value="PLTR"), stuck=True)
128
+ done = chart.run(timeout=1.5)
129
+ assert not done.clean and chart.now >= 1.5
130
+ # A toggle is pressed again only after it has had time to show.
131
+ assert 2 <= len(chart.acts) <= 1.5 / popups.ACT_WAIT + 1
132
+ assert str(done).endswith("a popup is still open")
@@ -0,0 +1,77 @@
1
+ """The pure half of bringing the chart in front: which window, whether it is back, what covers it."""
2
+
3
+ from chartremotely import screen
4
+
5
+ TOS, SAFARI, MAIL, OVERLAY = 10, 20, 30, 40
6
+
7
+
8
+ def win(pid, x, y, w, h, layer=0):
9
+ return {"kCGWindowOwnerPID": pid, "kCGWindowLayer": layer,
10
+ "kCGWindowBounds": {"X": x, "Y": y, "Width": w, "Height": h}}
11
+
12
+
13
+ CHART = win(TOS, 0, 25, 1600, 1000)
14
+
15
+
16
+ def test_overlap_needs_shared_area_not_a_shared_edge():
17
+ a = {"X": 0, "Y": 0, "Width": 100, "Height": 100}
18
+ assert screen.overlaps(a, {"X": 50, "Y": 50, "Width": 100, "Height": 100})
19
+ assert not screen.overlaps(a, {"X": 100, "Y": 0, "Width": 50, "Height": 50})
20
+ assert not screen.overlaps(a, {"X": 0, "Y": 100, "Width": 50, "Height": 50})
21
+
22
+
23
+ def test_largest_takes_only_the_apps_own_windows():
24
+ windows = [win(SAFARI, 0, 0, 3000, 3000), win(TOS, 0, 0, 200, 200), CHART]
25
+ assert screen.largest(windows, TOS) is CHART
26
+ assert screen.largest(windows, MAIL) is None
27
+
28
+
29
+ def test_the_chart_is_showing_only_when_its_own_frame_is_on_screen():
30
+ detached = win(TOS, 1700, 0, 400, 300)
31
+ assert screen.showing([detached], TOS, (0, 25, 1600, 1000)) is None
32
+ assert screen.showing([detached, CHART], TOS, (0, 25, 1600, 1000)) is CHART
33
+ # A point of rounding between the two APIs is not a different window.
34
+ assert screen.showing([CHART], TOS, (1, 24, 1600, 1001)) is CHART
35
+ assert screen.showing([CHART], SAFARI, (0, 25, 1600, 1000)) is None
36
+
37
+
38
+ def test_covering_names_each_overlapping_ordinary_app_once():
39
+ windows = [win(SAFARI, 100, 100, 500, 500), win(SAFARI, 200, 200, 50, 50),
40
+ win(MAIL, 2000, 0, 300, 300), CHART]
41
+ assert screen.covering(windows, CHART["kCGWindowBounds"], TOS,
42
+ {SAFARI, MAIL}) == [SAFARI]
43
+
44
+
45
+ def test_covering_never_touches_overlays_the_desktop_or_the_chart_itself():
46
+ windows = [win(OVERLAY, 0, 0, 3000, 2000, layer=25), # not a regular app
47
+ win(SAFARI, 0, 0, 3000, 2000, layer=-2147483603), # desktop layer
48
+ CHART]
49
+ assert screen.covering(windows, CHART["kCGWindowBounds"], TOS,
50
+ {SAFARI, TOS}) == []
51
+
52
+
53
+ def test_wait_for_returns_the_first_truthy_probe():
54
+ answers = iter([None, None, "here"])
55
+ t = [0.0]
56
+ got = screen.wait_for(lambda: next(answers), timeout=2.0, interval=0.1,
57
+ clock=lambda: t[0], sleep=lambda s: t.__setitem__(0, t[0] + s))
58
+ assert got == "here"
59
+ assert abs(t[0] - 0.2) < 1e-9
60
+
61
+
62
+ def test_wait_for_gives_up_after_the_timeout_having_probed_at_least_once():
63
+ probes = []
64
+ t = [0.0]
65
+ got = screen.wait_for(lambda: probes.append(1), timeout=0.5, interval=0.1,
66
+ clock=lambda: t[0], sleep=lambda s: t.__setitem__(0, t[0] + s))
67
+ assert got is None
68
+ assert 5 <= len(probes) <= 7
69
+
70
+
71
+ def test_presented_says_what_it_took():
72
+ assert not screen.Presented()
73
+ assert str(screen.Presented()) == "already in front"
74
+ done = screen.Presented(unhid=True, raised=True, hid=("Mail", "Safari"))
75
+ assert done
76
+ assert str(done) == "unhid; raised; hid Mail, Safari"
77
+ assert str(screen.Presented(unminimized=True, raised=True)) == "unminimized; raised"
@@ -1,134 +0,0 @@
1
- """Make the chart visible, and prove what it shows.
2
-
3
- A voice command answered from across the room is only useful if the chart
4
- is actually on screen, and only trustworthy if the caller can see that it
5
- changed. Both matter more remotely than locally: at the desk you can see it
6
- worked; from another building a silent failure is indistinguishable from
7
- success.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import subprocess
13
- import tempfile
14
- import time
15
- from pathlib import Path
16
-
17
- import Quartz
18
- from AppKit import NSWorkspace
19
-
20
- from . import ax, config, layout, snapshot, symbol
21
-
22
-
23
- def _frame(pid: int) -> dict | None:
24
- """Bounds of the largest on-screen thinkorswim window."""
25
- best, best_area = None, -1
26
- for w in Quartz.CGWindowListCopyWindowInfo(
27
- Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID):
28
- if w.get("kCGWindowOwnerPID") != pid:
29
- continue
30
- b = w.get("kCGWindowBounds") or {}
31
- area = b.get("Width", 0) * b.get("Height", 0)
32
- if area > best_area:
33
- best, best_area = w, area
34
- return best
35
-
36
-
37
- def _overlaps(a: dict, b: dict) -> bool:
38
- return not (a.get("X", 0) + a.get("Width", 0) <= b.get("X", 0)
39
- or a.get("X", 0) >= b.get("X", 0) + b.get("Width", 0)
40
- or a.get("Y", 0) + a.get("Height", 0) <= b.get("Y", 0)
41
- or a.get("Y", 0) >= b.get("Y", 0) + b.get("Height", 0))
42
-
43
-
44
- def clear(app=None) -> list[str]:
45
- """Hide every ordinary app overlapping the chart, then raise it.
46
-
47
- Only regular apps are touched - system overlays keep permanent
48
- full-screen windows and are neither hideable nor actually in the way.
49
- """
50
- app = app or ax.running_app()
51
- pid = app.processIdentifier()
52
- target = _frame(pid)
53
- if target is None:
54
- return []
55
- bounds = target["kCGWindowBounds"]
56
-
57
- regular = {a.processIdentifier(): a
58
- for a in NSWorkspace.sharedWorkspace().runningApplications()
59
- if a.activationPolicy() == 0 and a.processIdentifier() != pid}
60
-
61
- covering = {w["kCGWindowOwnerPID"]
62
- for w in Quartz.CGWindowListCopyWindowInfo(
63
- Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID)
64
- if w.get("kCGWindowOwnerPID") in regular
65
- and w.get("kCGWindowLayer", 0) >= 0
66
- and _overlaps(w.get("kCGWindowBounds") or {}, bounds)}
67
-
68
- hidden = []
69
- for p in covering:
70
- a = regular[p]
71
- if not a.isHidden():
72
- a.hide()
73
- hidden.append(a.localizedName() or str(p))
74
- if hidden:
75
- time.sleep(0.4)
76
- app.activateWithOptions_(2)
77
- return sorted(hidden)
78
-
79
-
80
- def capture(app=None) -> bytes:
81
- """PNG of the chart's own pane, and nothing else in the window.
82
-
83
- Captured by window id rather than screen region, so it works even when
84
- something is floating on top - which is what makes it usable as proof
85
- that a remote command landed. Then cropped to the pane that holds the
86
- chart's symbol field; see :func:`snapshot.chart_crop` for why the rest of
87
- the window never leaves this machine.
88
- """
89
- app = app or ax.running_app()
90
- target = _frame(app.processIdentifier())
91
- if target is None:
92
- raise ax.NotRunning("no thinkorswim window on screen")
93
- b = target["kCGWindowBounds"]
94
- bounds = (int(b["X"]), int(b["Y"]), int(b["Width"]), int(b["Height"]))
95
-
96
- # Hit-testing answers from the menu bar unless the app is in front.
97
- ax.activate(app)
98
- ax_app = ax.handle(app)
99
- prefix = config.load()["window_prefix"]
100
- hit = symbol.discover(ax_app, prefix)
101
- if hit is None:
102
- raise snapshot.ChartNotIsolated("no chart is visible")
103
- panes = layout.panes(ax_app, prefix)
104
-
105
- with tempfile.TemporaryDirectory() as tmp:
106
- shot = Path(tmp) / "window.png"
107
- subprocess.run(["screencapture", "-x", "-o", "-l", str(target["kCGWindowNumber"]), str(shot)],
108
- check=True, capture_output=True)
109
- crop = snapshot.chart_crop(hit, panes, bounds, _pixel_size(shot))
110
- chart = Path(tmp) / "chart.png"
111
- _crop(shot, chart, *crop)
112
- return chart.read_bytes()
113
-
114
-
115
- def _image(path: Path):
116
- from CoreFoundation import CFURLCreateWithFileSystemPath, kCFURLPOSIXPathStyle
117
-
118
- url = CFURLCreateWithFileSystemPath(None, str(path), kCFURLPOSIXPathStyle, False)
119
- return Quartz.CGImageSourceCreateImageAtIndex(Quartz.CGImageSourceCreateWithURL(url, None), 0, None)
120
-
121
-
122
- def _pixel_size(path: Path) -> tuple[int, int]:
123
- image = _image(path)
124
- return Quartz.CGImageGetWidth(image), Quartz.CGImageGetHeight(image)
125
-
126
-
127
- def _crop(src: Path, dst: Path, x: int, y: int, w: int, h: int) -> None:
128
- from CoreFoundation import CFURLCreateWithFileSystemPath, kCFURLPOSIXPathStyle
129
-
130
- region = Quartz.CGImageCreateWithImageInRect(_image(src), Quartz.CGRectMake(x, y, w, h))
131
- out_url = CFURLCreateWithFileSystemPath(None, str(dst), kCFURLPOSIXPathStyle, False)
132
- dest = Quartz.CGImageDestinationCreateWithURL(out_url, "public.png", 1, None)
133
- Quartz.CGImageDestinationAddImage(dest, region, None)
134
- Quartz.CGImageDestinationFinalize(dest)
File without changes
File without changes
File without changes