chartremotely 0.2.10__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.10 → chartremotely-0.2.11}/PKG-INFO +1 -1
- chartremotely-0.2.11/chartremotely/popups.py +258 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/symbol.py +11 -1
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/timeframe.py +8 -7
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/window.py +5 -1
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/PKG-INFO +1 -1
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/SOURCES.txt +2 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/pyproject.toml +1 -1
- chartremotely-0.2.11/tests/test_popups.py +132 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/LICENSE +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/README.md +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/answer.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/assets/shortcut-template.plist +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/config.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/patron.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/push.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/relay.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/requestlog.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/screen.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/server.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/services.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/setup.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely/vocab.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/setup.cfg +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_patron.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_push.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_relay.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_requestlog.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_screen.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_setup.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_stamp_changelog.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.11}/tests/test_timeframe.py +0 -0
|
@@ -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)
|
|
@@ -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 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
import Quartz
|
|
18
18
|
|
|
19
|
-
from . import ax, config, layout, screen, snapshot, symbol
|
|
19
|
+
from . import ax, config, layout, popups, screen, snapshot, symbol
|
|
20
20
|
|
|
21
21
|
#: NSApplicationActivateAllWindows | NSApplicationActivateIgnoringOtherApps:
|
|
22
22
|
#: bring every thinkorswim window forward, even when another app is in front.
|
|
@@ -112,6 +112,8 @@ def _uncover(pid: int, bounds: dict) -> tuple[str, ...]:
|
|
|
112
112
|
def capture(app=None) -> bytes:
|
|
113
113
|
"""PNG of the chart's own pane, and nothing else in the window.
|
|
114
114
|
|
|
115
|
+
Any popup left over the chart is closed first; see :func:`popups.dismiss`.
|
|
116
|
+
|
|
115
117
|
Captured by window id rather than screen region, so it works even when
|
|
116
118
|
something is floating on top - which is what makes it usable as proof
|
|
117
119
|
that a remote command landed. Then cropped to the pane that holds the
|
|
@@ -127,6 +129,8 @@ def capture(app=None) -> bytes:
|
|
|
127
129
|
|
|
128
130
|
# Hit-testing answers from the menu bar unless the app is in front.
|
|
129
131
|
ax.activate(app)
|
|
132
|
+
# Never photograph a symbol list or scale menu left over the chart.
|
|
133
|
+
popups.dismiss(app)
|
|
130
134
|
ax_app = ax.handle(app)
|
|
131
135
|
prefix = config.load()["window_prefix"]
|
|
132
136
|
hit = symbol.discover(ax_app, prefix)
|
|
@@ -12,6 +12,7 @@ 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
|
|
@@ -38,6 +39,7 @@ chartremotely.egg-info/requires.txt
|
|
|
38
39
|
chartremotely.egg-info/top_level.txt
|
|
39
40
|
chartremotely/assets/shortcut-template.plist
|
|
40
41
|
tests/test_patron.py
|
|
42
|
+
tests/test_popups.py
|
|
41
43
|
tests/test_push.py
|
|
42
44
|
tests/test_relay.py
|
|
43
45
|
tests/test_requestlog.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")
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|