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