chartremotely 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chartremotely/__init__.py +0 -0
- chartremotely/assets/shortcut-template.plist +1226 -0
- chartremotely/ax.py +278 -0
- chartremotely/cli.py +136 -0
- chartremotely/config.py +68 -0
- chartremotely/doctor.py +118 -0
- chartremotely/keystore.py +101 -0
- chartremotely/layout.py +106 -0
- chartremotely/mcpclient.py +98 -0
- chartremotely/push.py +58 -0
- chartremotely/registry.py +53 -0
- chartremotely/relay.py +145 -0
- chartremotely/resolve.py +207 -0
- chartremotely/scales.py +77 -0
- chartremotely/server.py +69 -0
- chartremotely/services.py +116 -0
- chartremotely/setup.py +202 -0
- chartremotely/shortcut.py +56 -0
- chartremotely/snapshot.py +88 -0
- chartremotely/studies.py +265 -0
- chartremotely/symbol.py +160 -0
- chartremotely/tailnet.py +51 -0
- chartremotely/timeframe.py +210 -0
- chartremotely/vocab.py +140 -0
- chartremotely/window.py +134 -0
- chartremotely-0.2.0.dist-info/METADATA +381 -0
- chartremotely-0.2.0.dist-info/RECORD +31 -0
- chartremotely-0.2.0.dist-info/WHEEL +5 -0
- chartremotely-0.2.0.dist-info/entry_points.txt +2 -0
- chartremotely-0.2.0.dist-info/licenses/LICENSE +202 -0
- chartremotely-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Drive the chart's aggregation (time frame).
|
|
2
|
+
|
|
3
|
+
The presets live in a Java *lightweight* popup painted inside the window,
|
|
4
|
+
which is the most awkward control in the application:
|
|
5
|
+
|
|
6
|
+
* its rows are ``AXStaticText`` with no actions, so nothing can be pressed
|
|
7
|
+
* it ignores process-targeted mouse events entirely - no hover, no click
|
|
8
|
+
* arrow keys do not move its selection
|
|
9
|
+
* it dismisses when window activation changes
|
|
10
|
+
|
|
11
|
+
Only a real HID click works, which means the target must be uncovered
|
|
12
|
+
*before* the popup opens. Clearing occluders afterwards dismisses it.
|
|
13
|
+
|
|
14
|
+
Spoken mnemonics are chosen for phonetic distance rather than literal
|
|
15
|
+
accuracy. Digits are the worst thing to say to a recogniser - "fifteen" and
|
|
16
|
+
"fifty" collide - so the vocabulary avoids them.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from . import ax, config, layout
|
|
25
|
+
from .scales import AS_IS, MNEMONIC, canon, to_code
|
|
26
|
+
|
|
27
|
+
ITEM = re.compile(r"^\s*\d+\s*[DYW]\s*:\s*\S+", re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
class MenuError(RuntimeError):
|
|
30
|
+
"""The aggregation menu could not be opened or read."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_style_control(element) -> bool:
|
|
34
|
+
if element is None or ax.attr(element, "AXRole") != "AXCheckBox":
|
|
35
|
+
return False
|
|
36
|
+
label = ax.attr(element, "AXTitle") or ax.attr(element, "AXDescription")
|
|
37
|
+
return label == "Style"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def discover(ax_app, window_prefix: str) -> tuple[int, int] | None:
|
|
41
|
+
"""Find the aggregation toggle by its labelled neighbour.
|
|
42
|
+
|
|
43
|
+
The toggle carries no label, but "Style" sits immediately right of it
|
|
44
|
+
and does. Anchoring on a named control rather than a coordinate is what
|
|
45
|
+
survives a different layout: find Style wherever it is, then step left.
|
|
46
|
+
"""
|
|
47
|
+
style = layout.find_in_panes(ax_app, window_prefix, _is_style_control)
|
|
48
|
+
if style is None:
|
|
49
|
+
return None
|
|
50
|
+
element = ax.element_at(ax_app, *style)
|
|
51
|
+
origin = ax.position(element)
|
|
52
|
+
if origin is None:
|
|
53
|
+
return None
|
|
54
|
+
for dx in range(10, 160, 4):
|
|
55
|
+
x, y = int(origin.x) - dx, int(origin.y) + 12
|
|
56
|
+
candidate = ax.element_at(ax_app, x, y)
|
|
57
|
+
if candidate is None or ax.attr(candidate, "AXRole") != "AXCheckBox":
|
|
58
|
+
continue
|
|
59
|
+
label = ax.attr(candidate, "AXTitle") or ax.attr(candidate, "AXDescription")
|
|
60
|
+
if not label and "AXPress" in ax.actions(candidate):
|
|
61
|
+
return x, y
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def learn(app=None) -> dict:
|
|
66
|
+
app = app or ax.running_app()
|
|
67
|
+
ax.activate(app)
|
|
68
|
+
ax_app = ax.handle(app)
|
|
69
|
+
cfg = config.load()
|
|
70
|
+
found = discover(ax_app, cfg["window_prefix"])
|
|
71
|
+
if found is None:
|
|
72
|
+
raise MenuError("aggregation control not found; is a chart visible?")
|
|
73
|
+
win = ax.window(ax_app, cfg["window_prefix"])
|
|
74
|
+
origin = ax.position(win)
|
|
75
|
+
return config.update(aggregation_dx=int(found[0] - origin.x),
|
|
76
|
+
aggregation_dy=int(found[1] - origin.y))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _control(ax_app, cfg):
|
|
80
|
+
win = ax.window(ax_app, cfg["window_prefix"])
|
|
81
|
+
if win is None:
|
|
82
|
+
raise MenuError(f"no window titled {cfg['window_prefix']!r}")
|
|
83
|
+
origin = ax.position(win)
|
|
84
|
+
if cfg.get("aggregation_dx") is None:
|
|
85
|
+
raise MenuError("aggregation control not learned; run doctor")
|
|
86
|
+
x = int(origin.x) + cfg["aggregation_dx"]
|
|
87
|
+
y = int(origin.y) + cfg["aggregation_dy"]
|
|
88
|
+
return ax.element_at(ax_app, x, y), (x, y)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _open(ax_app, cfg):
|
|
92
|
+
"""Open the popup if it is closed.
|
|
93
|
+
|
|
94
|
+
The control is a TOGGLE. Pressing it while the menu is already showing
|
|
95
|
+
closes it, so a blind press is a coin flip.
|
|
96
|
+
"""
|
|
97
|
+
element, point = _control(ax_app, cfg)
|
|
98
|
+
if element is None:
|
|
99
|
+
raise MenuError(f"no aggregation control at {point}")
|
|
100
|
+
if ax.attr(element, "AXValue") in (0, "0", False, None):
|
|
101
|
+
ax.perform(element, "AXPress")
|
|
102
|
+
time.sleep(0.9)
|
|
103
|
+
return element
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _rows(ax_app) -> list[dict]:
|
|
107
|
+
"""Every preset the popup offers, with its centre and active flag.
|
|
108
|
+
|
|
109
|
+
The list is the user's favourites, so never assume a fixed set or order.
|
|
110
|
+
"""
|
|
111
|
+
found: list[dict] = []
|
|
112
|
+
seen: set[str] = set()
|
|
113
|
+
|
|
114
|
+
def visit(element):
|
|
115
|
+
for name in ("AXValue", "AXTitle", "AXDescription"):
|
|
116
|
+
value = ax.attr(element, name)
|
|
117
|
+
if isinstance(value, str) and ITEM.match(value.strip()):
|
|
118
|
+
label = value.strip()
|
|
119
|
+
spot = ax.centre(element)
|
|
120
|
+
if spot and label not in seen:
|
|
121
|
+
seen.add(label)
|
|
122
|
+
parent = ax.attr(element, "AXParent")
|
|
123
|
+
found.append({
|
|
124
|
+
"label": label, "x": spot[0], "y": spot[1],
|
|
125
|
+
"active": bool(parent is not None
|
|
126
|
+
and ax.attr(parent, "AXSelected")),
|
|
127
|
+
})
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
for window in ax.attr(ax_app, "AXWindows") or []:
|
|
131
|
+
ax.walk(window, visit)
|
|
132
|
+
return found
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def presets(app=None) -> list[str]:
|
|
136
|
+
app = app or ax.running_app()
|
|
137
|
+
ax.activate(app)
|
|
138
|
+
ax_app = ax.handle(app)
|
|
139
|
+
_open(ax_app, config.load())
|
|
140
|
+
rows = _rows(ax_app)
|
|
141
|
+
ax.escape(app.processIdentifier())
|
|
142
|
+
return [r["label"] for r in rows]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def current(app=None) -> tuple[str, str]:
|
|
146
|
+
"""The active preset as (label, mnemonic), without changing anything."""
|
|
147
|
+
app = app or ax.running_app()
|
|
148
|
+
ax.activate(app)
|
|
149
|
+
ax_app = ax.handle(app)
|
|
150
|
+
_open(ax_app, config.load())
|
|
151
|
+
rows = _rows(ax_app)
|
|
152
|
+
ax.escape(app.processIdentifier())
|
|
153
|
+
active = next((r for r in rows if r["active"]), None)
|
|
154
|
+
if active is None:
|
|
155
|
+
raise MenuError("could not read the current scale")
|
|
156
|
+
label = active["label"]
|
|
157
|
+
return label, MNEMONIC.get(canon(label), label)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def set_scale(phrase: str, app=None) -> tuple[str, str]:
|
|
161
|
+
"""Select a preset. Returns (label, mnemonic)."""
|
|
162
|
+
if phrase.lower().strip() in AS_IS:
|
|
163
|
+
return current(app)
|
|
164
|
+
|
|
165
|
+
app = app or ax.running_app()
|
|
166
|
+
pid = app.processIdentifier()
|
|
167
|
+
ax.activate(app)
|
|
168
|
+
ax_app = ax.handle(app)
|
|
169
|
+
cfg = config.load()
|
|
170
|
+
|
|
171
|
+
# Uncover the target BEFORE opening the popup: hiding an application
|
|
172
|
+
# shuffles window activation, and a lightweight popup dismisses when
|
|
173
|
+
# that happens.
|
|
174
|
+
_, point = _control(ax_app, cfg)
|
|
175
|
+
probes = [point] + [(point[0] - 120 + dx, point[1] + dy)
|
|
176
|
+
for dx in (0, 160, 320) for dy in (40, 200, 380)]
|
|
177
|
+
blockers: list[int] = []
|
|
178
|
+
for px, py in probes:
|
|
179
|
+
blockers += ax.occluders_at(px, py, pid)
|
|
180
|
+
hidden = ax.hide_apps(list(dict.fromkeys(blockers)))
|
|
181
|
+
time.sleep(0.4)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
_open(ax_app, cfg)
|
|
185
|
+
rows = _rows(ax_app)
|
|
186
|
+
if not rows:
|
|
187
|
+
raise MenuError("aggregation menu did not open")
|
|
188
|
+
|
|
189
|
+
want = to_code(phrase)
|
|
190
|
+
if not want:
|
|
191
|
+
# canon() yields "" for an unparseable phrase, and every string
|
|
192
|
+
# ends with "", so the loose match below would select whatever
|
|
193
|
+
# happened to be first. Refuse instead.
|
|
194
|
+
raise MenuError(f"could not parse timeframe {phrase!r}")
|
|
195
|
+
match = next((r for r in rows if canon(r["label"]) == want), None)
|
|
196
|
+
if match is None:
|
|
197
|
+
match = next((r for r in rows if canon(r["label"]).endswith(want)), None)
|
|
198
|
+
if match is None:
|
|
199
|
+
offered = ", ".join(r["label"] for r in rows)
|
|
200
|
+
raise MenuError(f"no preset matches {phrase!r}; this chart offers: {offered}")
|
|
201
|
+
|
|
202
|
+
ax.click(match["x"], match["y"], pid)
|
|
203
|
+
time.sleep(0.5)
|
|
204
|
+
label = match["label"]
|
|
205
|
+
return label, MNEMONIC.get(canon(label), label)
|
|
206
|
+
except Exception:
|
|
207
|
+
ax.escape(pid)
|
|
208
|
+
raise
|
|
209
|
+
finally:
|
|
210
|
+
ax.unhide_apps(hidden)
|
chartremotely/vocab.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""The complete command vocabulary. This file is the security boundary.
|
|
2
|
+
|
|
3
|
+
Everything a caller can ask the agent to do is in the dispatch table below.
|
|
4
|
+
There is no command that opens an order ticket, submits a trade, moves
|
|
5
|
+
money, or reads an account. Adding one means editing this file - in public,
|
|
6
|
+
in this repository - which is the point.
|
|
7
|
+
|
|
8
|
+
Two rules the callers depend on:
|
|
9
|
+
|
|
10
|
+
* **Never raise.** Every path returns text. The operator relays this to a
|
|
11
|
+
Shortcut, and a Shortcut treats a non-zero exit or an exception as a hard
|
|
12
|
+
failure, aborting before it can read the message.
|
|
13
|
+
* **Keep it short.** These strings get read aloud. Nobody wants a stack
|
|
14
|
+
trace spoken at them from across the room.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from . import push, registry, resolve, scales, snapshot, symbol, timeframe, window
|
|
20
|
+
|
|
21
|
+
ERR = "ERR "
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _short(text: str, limit: int = 120) -> str:
|
|
25
|
+
return str(text).splitlines()[0][:limit] if text else "something went wrong"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_resolve(spoken: str) -> str:
|
|
29
|
+
"""A spoken company name, to a ticker."""
|
|
30
|
+
if not spoken.strip():
|
|
31
|
+
return ERR + "I didn't catch a company name."
|
|
32
|
+
try:
|
|
33
|
+
ticker = resolve.resolve(spoken, registry.load())
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
return ERR + _short(exc)
|
|
36
|
+
return ticker or ERR + f"no match for {spoken!r}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_scale(spoken: str) -> str:
|
|
40
|
+
"""A spoken time frame, to a mnemonic - without touching the chart."""
|
|
41
|
+
phrase = spoken.strip()
|
|
42
|
+
if phrase.lower() in scales.AS_IS:
|
|
43
|
+
return "as is"
|
|
44
|
+
word = scales.mnemonic_for(phrase)
|
|
45
|
+
if word:
|
|
46
|
+
return word
|
|
47
|
+
return ERR + f"You say {scales.spoken_options()}."
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_set(ticker: str, scale: str = "") -> str:
|
|
51
|
+
"""Put a ticker on the chart, optionally at a given scale.
|
|
52
|
+
|
|
53
|
+
Degrades deliberately: an unparseable scale leaves the chart alone and
|
|
54
|
+
says which scale it is actually on, so the answer is never ambiguous to
|
|
55
|
+
someone who cannot see the screen.
|
|
56
|
+
"""
|
|
57
|
+
ticker = ticker.strip().replace(" ", "")
|
|
58
|
+
if not ticker:
|
|
59
|
+
return ERR + "No symbol to show."
|
|
60
|
+
try:
|
|
61
|
+
symbol.show(ticker)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
return ERR + _short(exc)
|
|
64
|
+
|
|
65
|
+
word = None
|
|
66
|
+
if scale.strip() and scale.strip().lower() not in scales.AS_IS:
|
|
67
|
+
try:
|
|
68
|
+
_, word = timeframe.set_scale(scale)
|
|
69
|
+
except Exception:
|
|
70
|
+
word = None
|
|
71
|
+
|
|
72
|
+
# Uncover the chart: a voice answer is only useful if the screen shows it.
|
|
73
|
+
try:
|
|
74
|
+
window.clear()
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
if word:
|
|
79
|
+
return f"Showing {ticker} at {word}. Good luck."
|
|
80
|
+
try:
|
|
81
|
+
_, current = timeframe.current()
|
|
82
|
+
return f"Showing {ticker} at {current}, as is. Good luck."
|
|
83
|
+
except Exception:
|
|
84
|
+
return f"Showing {ticker}. Good luck."
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_read() -> str:
|
|
88
|
+
"""Report the chart's current symbol and scale."""
|
|
89
|
+
try:
|
|
90
|
+
ticker = symbol.current()
|
|
91
|
+
_, word = timeframe.current()
|
|
92
|
+
return f"{ticker} at {word}"
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
return ERR + _short(exc)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_snapshot() -> str:
|
|
98
|
+
"""A picture of the chart pane, so a caller far away can see it."""
|
|
99
|
+
try:
|
|
100
|
+
return snapshot.as_reply(snapshot.shrink(window.capture()))
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
return ERR + _short(exc)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def dispatch(request: str) -> str:
|
|
106
|
+
"""Route one request. Anything unrecognised is treated as a company name.
|
|
107
|
+
|
|
108
|
+
The entire surface:
|
|
109
|
+
|
|
110
|
+
resolve <spoken name> -> TICKER | ERR
|
|
111
|
+
scale <spoken time frame> -> mnemonic | ERR
|
|
112
|
+
set <TICKER> | <time frame> -> spoken summary
|
|
113
|
+
read -> current symbol and scale
|
|
114
|
+
snapshot -> data:image/jpeg;base64,... | ERR
|
|
115
|
+
<spoken name> -> resolve, then set
|
|
116
|
+
"""
|
|
117
|
+
request = (request or "").strip()
|
|
118
|
+
if not request:
|
|
119
|
+
return ERR + "I didn't catch that."
|
|
120
|
+
|
|
121
|
+
verb, _, rest = request.partition(" ")
|
|
122
|
+
verb = verb.lower()
|
|
123
|
+
|
|
124
|
+
if verb == "resolve":
|
|
125
|
+
return cmd_resolve(rest)
|
|
126
|
+
if verb == "scale":
|
|
127
|
+
return cmd_scale(rest)
|
|
128
|
+
if verb == "read":
|
|
129
|
+
return cmd_read()
|
|
130
|
+
if verb == "snapshot":
|
|
131
|
+
return cmd_snapshot()
|
|
132
|
+
if verb == "set":
|
|
133
|
+
ticker, sep, scale = rest.partition("|")
|
|
134
|
+
return push.after_change(cmd_set(ticker, scale if sep else ""))
|
|
135
|
+
|
|
136
|
+
# Bare name: resolve and show it.
|
|
137
|
+
ticker = cmd_resolve(request)
|
|
138
|
+
if ticker.startswith(ERR):
|
|
139
|
+
return ticker
|
|
140
|
+
return push.after_change(cmd_set(ticker))
|
chartremotely/window.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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)
|