chartremotely 0.2.9__tar.gz → 0.2.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {chartremotely-0.2.9 → chartremotely-0.2.10}/PKG-INFO +1 -1
  2. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/ax.py +19 -5
  3. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/requestlog.py +9 -0
  4. chartremotely-0.2.10/chartremotely/screen.py +116 -0
  5. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/vocab.py +25 -7
  6. chartremotely-0.2.10/chartremotely/window.py +166 -0
  7. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/PKG-INFO +1 -1
  8. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/SOURCES.txt +2 -0
  9. {chartremotely-0.2.9 → chartremotely-0.2.10}/pyproject.toml +1 -1
  10. chartremotely-0.2.10/tests/test_screen.py +77 -0
  11. chartremotely-0.2.9/chartremotely/window.py +0 -134
  12. {chartremotely-0.2.9 → chartremotely-0.2.10}/LICENSE +0 -0
  13. {chartremotely-0.2.9 → chartremotely-0.2.10}/README.md +0 -0
  14. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/__init__.py +0 -0
  15. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/answer.py +0 -0
  16. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/assets/shortcut-template.plist +0 -0
  17. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/cli.py +0 -0
  18. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/config.py +0 -0
  19. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/doctor.py +0 -0
  20. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/guilock.py +0 -0
  21. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/keystore.py +0 -0
  22. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/layout.py +0 -0
  23. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/mcpclient.py +0 -0
  24. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/patron.py +0 -0
  25. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/push.py +0 -0
  26. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/registry.py +0 -0
  27. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/relay.py +0 -0
  28. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/resolve.py +0 -0
  29. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/scales.py +0 -0
  30. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/server.py +0 -0
  31. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/services.py +0 -0
  32. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/setup.py +0 -0
  33. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/shortcut.py +0 -0
  34. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/snapshot.py +0 -0
  35. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/studies.py +0 -0
  36. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/symbol.py +0 -0
  37. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/tailnet.py +0 -0
  38. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/timeframe.py +0 -0
  39. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/dependency_links.txt +0 -0
  40. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/entry_points.txt +0 -0
  41. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/requires.txt +0 -0
  42. {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/top_level.txt +0 -0
  43. {chartremotely-0.2.9 → chartremotely-0.2.10}/setup.cfg +0 -0
  44. {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_patron.py +0 -0
  45. {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_push.py +0 -0
  46. {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_relay.py +0 -0
  47. {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_requestlog.py +0 -0
  48. {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_resolve.py +0 -0
  49. {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_setup.py +0 -0
  50. {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_snapshot.py +0 -0
  51. {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_stamp_changelog.py +0 -0
  52. {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_timeframe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.9
3
+ Version: 0.2.10
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
  import time
23
23
 
24
24
  import Quartz
25
- from AppKit import NSWorkspace
25
+ from AppKit import NSRunningApplication, NSWorkspace
26
26
  from ApplicationServices import (
27
27
  AXUIElementCopyActionNames,
28
28
  AXUIElementCopyAttributeValue,
@@ -57,11 +57,25 @@ class NotRunning(RuntimeError):
57
57
  """thinkorswim is not running."""
58
58
 
59
59
 
60
+ def app_for(pid: int):
61
+ """The running application with ``pid``, looked up now; None if gone.
62
+
63
+ Never from ``NSWorkspace.runningApplications()``: that list only
64
+ refreshes on a run loop, which the listener and relay never spin, so an
65
+ app launched (or quit) after they started is missing from it (or lingers).
66
+ """
67
+ return NSRunningApplication.runningApplicationWithProcessIdentifier_(pid)
68
+
69
+
60
70
  def running_app():
61
- for a in NSWorkspace.sharedWorkspace().runningApplications():
62
- if APP_NAME.lower() in (a.localizedName() or "").lower():
63
- return a
64
- raise NotRunning(f"{APP_NAME} is not running")
71
+ """thinkorswim, found through the window server's live list of windows."""
72
+ for w in Quartz.CGWindowListCopyWindowInfo(
73
+ Quartz.kCGWindowListOptionAll, Quartz.kCGNullWindowID) or []:
74
+ if APP_NAME.lower() in (w.get("kCGWindowOwnerName") or "").lower():
75
+ app = app_for(w["kCGWindowOwnerPID"])
76
+ if app is not None:
77
+ return app
78
+ raise NotRunning(f"{APP_NAME} is not open on this Mac")
65
79
 
66
80
 
67
81
  def handle(app=None):
@@ -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"
@@ -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,166 @@
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, 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
+ Captured by window id rather than screen region, so it works even when
116
+ something is floating on top - which is what makes it usable as proof
117
+ that a remote command landed. Then cropped to the pane that holds the
118
+ chart's symbol field; see :func:`snapshot.chart_crop` for why the rest of
119
+ the window never leaves this machine.
120
+ """
121
+ app = app or ax.running_app()
122
+ target = _frame(app.processIdentifier())
123
+ if target is None:
124
+ raise ax.NotRunning("no thinkorswim window on screen")
125
+ b = target["kCGWindowBounds"]
126
+ bounds = (int(b["X"]), int(b["Y"]), int(b["Width"]), int(b["Height"]))
127
+
128
+ # Hit-testing answers from the menu bar unless the app is in front.
129
+ ax.activate(app)
130
+ ax_app = ax.handle(app)
131
+ prefix = config.load()["window_prefix"]
132
+ hit = symbol.discover(ax_app, prefix)
133
+ if hit is None:
134
+ raise snapshot.ChartNotIsolated("no chart is visible")
135
+ panes = layout.panes(ax_app, prefix)
136
+
137
+ with tempfile.TemporaryDirectory() as tmp:
138
+ shot = Path(tmp) / "window.png"
139
+ subprocess.run(["screencapture", "-x", "-o", "-l", str(target["kCGWindowNumber"]), str(shot)],
140
+ check=True, capture_output=True)
141
+ crop = snapshot.chart_crop(hit, panes, bounds, _pixel_size(shot))
142
+ chart = Path(tmp) / "chart.png"
143
+ _crop(shot, chart, *crop)
144
+ return chart.read_bytes()
145
+
146
+
147
+ def _image(path: Path):
148
+ from CoreFoundation import CFURLCreateWithFileSystemPath, kCFURLPOSIXPathStyle
149
+
150
+ url = CFURLCreateWithFileSystemPath(None, str(path), kCFURLPOSIXPathStyle, False)
151
+ return Quartz.CGImageSourceCreateImageAtIndex(Quartz.CGImageSourceCreateWithURL(url, None), 0, None)
152
+
153
+
154
+ def _pixel_size(path: Path) -> tuple[int, int]:
155
+ image = _image(path)
156
+ return Quartz.CGImageGetWidth(image), Quartz.CGImageGetHeight(image)
157
+
158
+
159
+ def _crop(src: Path, dst: Path, x: int, y: int, w: int, h: int) -> None:
160
+ from CoreFoundation import CFURLCreateWithFileSystemPath, kCFURLPOSIXPathStyle
161
+
162
+ region = Quartz.CGImageCreateWithImageInRect(_image(src), Quartz.CGRectMake(x, y, w, h))
163
+ out_url = CFURLCreateWithFileSystemPath(None, str(dst), kCFURLPOSIXPathStyle, False)
164
+ dest = Quartz.CGImageDestinationCreateWithURL(out_url, "public.png", 1, None)
165
+ Quartz.CGImageDestinationAddImage(dest, region, None)
166
+ Quartz.CGImageDestinationFinalize(dest)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.9
3
+ Version: 0.2.10
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -18,6 +18,7 @@ chartremotely/relay.py
18
18
  chartremotely/requestlog.py
19
19
  chartremotely/resolve.py
20
20
  chartremotely/scales.py
21
+ chartremotely/screen.py
21
22
  chartremotely/server.py
22
23
  chartremotely/services.py
23
24
  chartremotely/setup.py
@@ -41,6 +42,7 @@ tests/test_push.py
41
42
  tests/test_relay.py
42
43
  tests/test_requestlog.py
43
44
  tests/test_resolve.py
45
+ tests/test_screen.py
44
46
  tests/test_setup.py
45
47
  tests/test_snapshot.py
46
48
  tests/test_stamp_changelog.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.2.9"
3
+ version = "0.2.10"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,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