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.
- {chartremotely-0.2.9 → chartremotely-0.2.11}/PKG-INFO +1 -1
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/ax.py +19 -5
- chartremotely-0.2.11/chartremotely/popups.py +258 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/requestlog.py +9 -0
- chartremotely-0.2.11/chartremotely/screen.py +116 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/symbol.py +11 -1
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/timeframe.py +8 -7
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/vocab.py +25 -7
- chartremotely-0.2.11/chartremotely/window.py +170 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/PKG-INFO +1 -1
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/SOURCES.txt +4 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/pyproject.toml +1 -1
- chartremotely-0.2.11/tests/test_popups.py +132 -0
- chartremotely-0.2.11/tests/test_screen.py +77 -0
- chartremotely-0.2.9/chartremotely/window.py +0 -134
- {chartremotely-0.2.9 → chartremotely-0.2.11}/LICENSE +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/README.md +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/answer.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/assets/shortcut-template.plist +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/config.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/patron.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/push.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/relay.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/server.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/services.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/setup.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/setup.cfg +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_patron.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_push.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_relay.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_requestlog.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_setup.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_stamp_changelog.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.11}/tests/test_timeframe.py +0 -0
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|