de-shell 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.
Files changed (57) hide show
  1. de_shell/__init__.py +25 -0
  2. de_shell/actions/__init__.py +0 -0
  3. de_shell/actions/context.py +62 -0
  4. de_shell/actions/figure_registry.py +53 -0
  5. de_shell/actions/lifecycle.py +295 -0
  6. de_shell/actions/registry.py +141 -0
  7. de_shell/actions/wizard.py +115 -0
  8. de_shell/app.py +170 -0
  9. de_shell/compute.py +103 -0
  10. de_shell/debug_flags.py +69 -0
  11. de_shell/ipc.py +236 -0
  12. de_shell/js/__init__.py +38 -0
  13. de_shell/js/__main__.py +4 -0
  14. de_shell/js/main/backendProcess.test.ts +70 -0
  15. de_shell/js/main/backendProcess.ts +330 -0
  16. de_shell/js/main/config.ts +53 -0
  17. de_shell/js/main/dialogs.ts +62 -0
  18. de_shell/js/main/envProgress.ts +126 -0
  19. de_shell/js/main/errorReport.ts +261 -0
  20. de_shell/js/main/index.ts +57 -0
  21. de_shell/js/main/problemLog.ts +53 -0
  22. de_shell/js/main/pythonEnv.test.ts +125 -0
  23. de_shell/js/main/pythonEnv.ts +442 -0
  24. de_shell/js/main/sentryEnvelope.test.ts +94 -0
  25. de_shell/js/main/sentryEnvelope.ts +100 -0
  26. de_shell/js/main/updater.ts +322 -0
  27. de_shell/js/main/updaterErrors.test.ts +111 -0
  28. de_shell/js/main/updaterErrors.ts +65 -0
  29. de_shell/js/main/window.ts +141 -0
  30. de_shell/js/package.json +5 -0
  31. de_shell/js/preload/index.ts +130 -0
  32. de_shell/js/renderer/FigureFrame.tsx +88 -0
  33. de_shell/js/renderer/figureBridge.react.ts +58 -0
  34. de_shell/js/renderer/figureBridge.test.ts +184 -0
  35. de_shell/js/renderer/figureBridge.ts +169 -0
  36. de_shell/js/renderer/index.ts +34 -0
  37. de_shell/js/renderer/protocol.ts +164 -0
  38. de_shell/js/renderer/shellState.test.ts +193 -0
  39. de_shell/js/renderer/shellState.ts +310 -0
  40. de_shell/js/testing/harness.cjs +244 -0
  41. de_shell/js/testing/harness.test.cjs +73 -0
  42. de_shell/log_stream.py +185 -0
  43. de_shell/plotting/__init__.py +0 -0
  44. de_shell/plotting/colormaps.py +27 -0
  45. de_shell/plotting/figure.py +601 -0
  46. de_shell/plotting/selectors/__init__.py +0 -0
  47. de_shell/plotting/selectors/utils.py +29 -0
  48. de_shell/plotting/stream.py +172 -0
  49. de_shell/process_guard.py +190 -0
  50. de_shell/session.py +211 -0
  51. de_shell/testing/__init__.py +0 -0
  52. de_shell/timing.py +28 -0
  53. de_shell-0.2.0.dist-info/METADATA +196 -0
  54. de_shell-0.2.0.dist-info/RECORD +57 -0
  55. de_shell-0.2.0.dist-info/WHEEL +5 -0
  56. de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
  57. de_shell-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,601 @@
1
+ """
2
+ figure.py — a single image pane, created once and repainted in place.
3
+
4
+ The smallest correct anyplotlib figure a shell app can own. Creating one emits
5
+ its HTML to the renderer, which mounts it in an iframe keyed by ``fig_id``;
6
+ every later frame is a ``set_data`` push down the same channel, so the iframe is
7
+ never rebuilt and the user's zoom survives the next frame.
8
+
9
+ **This is deliberately the small shape, not SpyDE's.** SpyDE's ``Plot`` carries
10
+ the array cache, the tiered navigator read, overlay layers and tile mode — all
11
+ of which exist because it displays multi-gigabyte lazy datasets on disk. A live
12
+ camera hands over a frame that is already in RAM, so none of it applies. The
13
+ shared wrapper is the in-memory core; an app that needs the out-of-core
14
+ machinery layers it on top rather than the shell carrying it for everyone.
15
+
16
+ It satisfies the WindowController protocol (a ``window_id`` and a ``close()``),
17
+ so a session's window registry can own its lifetime.
18
+
19
+ Two things here are easy to get wrong and cost a debugging session each; both
20
+ are enforced rather than documented-and-hoped:
21
+
22
+ * **Registration mints the fig_id.** ``_electron.register(fig)`` attaches the
23
+ trait observers that turn a later ``set_data`` into a push, and returns the id
24
+ the renderer routes those pushes by. Inventing an id instead leaves the figure
25
+ unregistered: it mounts, sizes and titles correctly, and then never updates,
26
+ with no error anywhere.
27
+ * **Levels are robust by default.** One hot pixel — which every real detector
28
+ has — sets the ceiling under a plain min/max and renders the whole image
29
+ black.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import warnings
35
+
36
+ import numpy as np
37
+
38
+ import anyplotlib as apl
39
+ import anyplotlib._electron as _electron
40
+ from anyplotlib.embed import build_standalone_html
41
+
42
+ from de_shell.ipc import emit
43
+ from de_shell.plotting.colormaps import DEFAULT_COLORMAP
44
+
45
+ log = logging.getLogger(__name__)
46
+
47
+
48
+ #: Subsample any axis longer than this before measuring levels. Percentiles over
49
+ #: 16 M pixels cost tens of ms and land on the paint path; a ≤512² sample gives
50
+ #: the same answer to well within a display level.
51
+ LEVEL_SAMPLE = 512
52
+
53
+
54
+ def robust_levels(frame: np.ndarray, *,
55
+ low: float | None = 2.0, high: float = 98.0,
56
+ sample: int = LEVEL_SAMPLE) -> tuple[float, float]:
57
+ """A display range for *frame* that a hot pixel cannot wreck.
58
+
59
+ Percentiles rather than min/max: one saturated pixel — which every real
60
+ detector has — otherwise sets the ceiling and renders everything else black.
61
+
62
+ ``low=None`` uses the true minimum instead of a low percentile. That is the
63
+ right choice for an image with no saturating spike (a navigator, a virtual
64
+ image), where clipping the floor throws away real dynamic range; an image
65
+ that DOES have one (a diffraction pattern, whose central beam is orders of
66
+ magnitude brighter than the spots) wants both ends clipped.
67
+
68
+ Lifted from SpyDE's ``Plot._robust_levels``, which had already paid for the
69
+ subsampling and the non-finite handling.
70
+ """
71
+ arr = np.asarray(frame)
72
+ try:
73
+ sy = max(1, arr.shape[0] // sample)
74
+ sx = max(1, arr.shape[1] // sample) if arr.ndim > 1 else 1
75
+ data = np.asarray(arr[::sy, ::sx] if arr.ndim > 1 else arr[::sy],
76
+ dtype=np.float64)
77
+ data = data[np.isfinite(data)]
78
+ if data.size == 0:
79
+ # Nothing measurable — an all-NaN frame, or an empty one.
80
+ return 0.0, 1.0
81
+ # No warnings.catch_warnings() here, deliberately. `data` is already
82
+ # finite-filtered above, so numpy's all-NaN RuntimeWarning cannot fire —
83
+ # and this runs on the PAINT path, where entering a context manager that
84
+ # saves and restores global warning state on every frame is pure cost.
85
+ lo = float(np.percentile(data, low)) if low is not None else float(data.min())
86
+ hi = float(np.percentile(data, high))
87
+ # Collapsed percentiles mean the BULK of the frame is one value. Fall
88
+ # back to the true maximum — load-bearing for a SPARSE image, e.g. a
89
+ # count map that is >99.5% zeros with a few bright spots: the percentile
90
+ # is zero there, and without this the spots all saturate against a
91
+ # 1-wide window instead of scaling properly.
92
+ #
93
+ # The cost is that a perfectly FLAT frame carrying one hot pixel scales
94
+ # to that pixel and renders dark. That is the right trade: a sparse real
95
+ # image is common and a flat synthetic one is not, and any frame with
96
+ # genuine variation never reaches this branch.
97
+ if hi <= lo:
98
+ hi = float(data.max())
99
+ # Still collapsed: genuinely uniform. Widen by a hair rather than return
100
+ # a zero-width window, which renders as a solid block and is
101
+ # indistinguishable from a broken decode.
102
+ if hi <= lo:
103
+ hi = lo + 1.0
104
+ return lo, hi
105
+ except Exception:
106
+ return 0.0, 1.0
107
+
108
+
109
+ #: Figure chrome background. The apps are dark; anyplotlib's template is not.
110
+ FIGURE_BACKGROUND = "#1e1e2e"
111
+
112
+
113
+ def fill_iframe_html(html: str, *, background: str = FIGURE_BACKGROUND,
114
+ extra_head: str = "") -> str:
115
+ """Make a standalone figure FILL its iframe, and match the app's theme.
116
+
117
+ Load-bearing for any app that drives figure size with
118
+ ``_electron.resize_figure``. anyplotlib's standalone template pins
119
+ ``html``/``body`` to the figure's INITIAL pixel size with
120
+ ``overflow:hidden`` — correct for a fixed docs or notebook embed. A shell app
121
+ resizes the figure live, so once the pane is larger than that initial size
122
+ the grown figure is CLIPPED to the old body box: the image spills past the
123
+ panel and the bottom is cut off, while everything around it looks fine.
124
+
125
+ ``extra_head`` is injected alongside, for an app that needs its own script in
126
+ the frame (SpyDE relays a pointerdown to bring its subwindow to the front).
127
+ """
128
+ style = (f"<style>html,body{{background:{background} !important;color-scheme:dark;"
129
+ "width:100% !important;height:100% !important;overflow:hidden}"
130
+ f"#widget-root{{background:{background} !important;"
131
+ "width:100% !important;height:100% !important;display:block !important}"
132
+ "</style>")
133
+ return html.replace("<body>", style + extra_head + "<body>", 1)
134
+
135
+
136
+ class FigureView:
137
+ """One always-on image pane.
138
+
139
+ Parameters
140
+ ----------
141
+ window_id
142
+ The window this figure belongs to, as minted by the session.
143
+ title
144
+ Shown above the image and used as the window title.
145
+ colormap
146
+ Initial colormap name.
147
+ gpu
148
+ Passed through to ``imshow``. ``"auto"`` renders large scalar images on
149
+ the GPU and falls back to Canvas2D for small ones, RGB, and machines
150
+ without it; ``"off"`` forces Canvas2D (what a CPU-reference screenshot
151
+ test wants).
152
+ """
153
+
154
+ def __init__(self, window_id: int, title: str = "", *,
155
+ colormap: str = DEFAULT_COLORMAP, gpu: str = "auto") -> None:
156
+ self.window_id = window_id
157
+ self.title = title
158
+ #: Assigned by `open()` from `_electron.register` — see the module note.
159
+ self.fig_id: str | None = None
160
+ self._fig = None
161
+ self._axes = None
162
+ self._plot2d = None
163
+ self._colormap = colormap
164
+ self._gpu = gpu
165
+ self._closed = False
166
+ self._tiled = False
167
+ #: The last frame `show` painted, for `auto_clim`. One reference, not a
168
+ #: copy; None while tiled, since `show` is not on that path.
169
+ self._last_frame = None
170
+
171
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
172
+
173
+ @property
174
+ def is_open(self) -> bool:
175
+ return self._plot2d is not None and not self._closed
176
+
177
+ def open(self, shape: tuple[int, int], *, is_navigator: bool = False) -> str | None:
178
+ """Create the figure and send it to the renderer. Idempotent.
179
+
180
+ *shape* is the frame size to lay out for. Passing the REAL size (rather
181
+ than a small stand-in) matters: a placeholder of a different shape makes
182
+ the pane resize the moment the first frame lands, which reads as the
183
+ window jumping.
184
+
185
+ Returns the fig_id.
186
+ """
187
+ if self._fig is not None:
188
+ return self.fig_id
189
+ h, w = int(shape[0]), int(shape[1])
190
+ self._fig, axes_obj = apl.subplots(1, 1)
191
+ self._axes = axes_obj[0][0] if isinstance(axes_obj, list) else axes_obj
192
+ self._plot2d = self._axes.imshow(
193
+ np.zeros((h, w), dtype=np.float32), cmap=self._colormap, gpu=self._gpu)
194
+ if self.title:
195
+ self._plot2d.set_title(self.title)
196
+
197
+ # Register BEFORE building the HTML: registration attaches the trait
198
+ # observers AND mints the id, so the HTML must be built with that id.
199
+ self.fig_id = _electron.register(self._fig)
200
+ html = fill_iframe_html(
201
+ build_standalone_html(self._fig, fig_id=self.fig_id, resizable=False))
202
+
203
+ emit({
204
+ "type": "figure",
205
+ "fig_id": self.fig_id,
206
+ "window_id": self.window_id,
207
+ "html": html,
208
+ "title": self.title,
209
+ "is_navigator": is_navigator,
210
+ "aspect": (w / h) if h else None,
211
+ })
212
+ return self.fig_id
213
+
214
+ def close(self) -> None:
215
+ """WindowController.close — idempotent, and never raises during teardown."""
216
+ if self._closed:
217
+ return
218
+ self._closed = True
219
+ self._tiled = False
220
+ self._plot2d = None
221
+ self._axes = None
222
+ self._fig = None
223
+ self._last_frame = None # a closed figure must not pin a 4096² frame
224
+
225
+ # ── Painting ──────────────────────────────────────────────────────────────
226
+
227
+ def show(self, frame: np.ndarray, *,
228
+ clim: tuple[float, float] | None = None) -> bool:
229
+ """Paint one frame. **Main thread only** — see the threading contract in
230
+ ``de_shell.actions.lifecycle``.
231
+
232
+ ``clim=None`` re-derives a robust range per frame, which is what a live
233
+ scene wants; pass an explicit range to hold contrast steady across
234
+ frames. Returns whether the paint landed, so a caller that must know
235
+ (a live preview, a test) is not left inferring it from a counter that
236
+ increments either way.
237
+ """
238
+ if not self.is_open:
239
+ return False
240
+ try:
241
+ # Pin BEFORE set_data when the plot is already tiled (a figure
242
+ # opened at a large shape tiles on its zeros placeholder), so the
243
+ # frame is encoded over a valid band in the same push. The
244
+ # after-call catches the other route: this set_data being the one
245
+ # that swaps the plot into tile mode.
246
+ pinned = self._pin_tile_band(frame)
247
+ self._plot2d.set_data(
248
+ frame, clim=clim if clim is not None else robust_levels(frame))
249
+ if not pinned and self._pin_tile_band(frame):
250
+ # Swapped mid-call: the push above quantised over the encoder's
251
+ # display-window fallback; re-encode once over the real band.
252
+ self._plot2d.update_tile_source()
253
+ # Kept so Auto can re-derive a range later. A live scene re-derives
254
+ # per frame and never needs it, but a STILL figure — a calibration
255
+ # image, a motion average — has no next frame to hand the job to,
256
+ # so without this its Auto button has nothing to compute from.
257
+ self._last_frame = frame
258
+ return True
259
+ except Exception as e:
260
+ log.debug("painting figure %s failed: %s", self.fig_id, e)
261
+ return False
262
+
263
+ def _pin_tile_band(self, frame: np.ndarray) -> bool:
264
+ """Give a tiled plot a valid quantisation band, from *frame* itself.
265
+
266
+ anyplotlib auto-tiles any frame over its size threshold and derives the
267
+ raw_min/raw_max band ONCE — from whatever the tile source holds at
268
+ enable time. A figure opened at a large shape tiles on its ZEROS
269
+ placeholder, so the band comes out (0, 0), and `_set_data_tiled` never
270
+ re-derives it. The two ends of the protocol then disagree about that
271
+ degenerate band: the Python encoder falls back to the display window,
272
+ but the frontend LUT honours (0, 0) — every byte maps below the display
273
+ floor and the pane renders black however good the data. (`set_tile_band`
274
+ documents the same failure for the explicit-tile live path, which is
275
+ why the camera view survived while every large STILL — Calibrate, the
276
+ FFT panels, Motion sums — went black.)
277
+
278
+ Returns whether the band was already valid or has been pinned; False
279
+ means the plot is not in tile mode (nothing to do). Upstream fix worth
280
+ proposing (CSSFrancis/anyplotlib): derive the band in `_set_data_tiled`,
281
+ and treat raw_max <= raw_min as unset in the frontend LUT.
282
+ """
283
+ st = getattr(self._plot2d, "_state", None)
284
+ if not isinstance(st, dict) or not st.get("tile_enabled"):
285
+ return False
286
+ lo, hi = st.get("raw_min"), st.get("raw_max")
287
+ if lo is not None and hi is not None and hi > lo:
288
+ return True
289
+ arr = np.asarray(frame)
290
+ if arr.size == 0:
291
+ return False
292
+ if arr.dtype.kind == "f":
293
+ # nanmin/nanmax rather than a finite mask: the mask materialises a
294
+ # copy of the frame (~½ GB transient on an 8k float), this doesn't.
295
+ with warnings.catch_warnings():
296
+ warnings.simplefilter("ignore", RuntimeWarning) # all-NaN frame
297
+ lo, hi = float(np.nanmin(arr)), float(np.nanmax(arr))
298
+ if not (np.isfinite(lo) and np.isfinite(hi)):
299
+ finite = arr[np.isfinite(arr)] # rare: ±inf in the frame
300
+ if finite.size == 0:
301
+ return False
302
+ lo, hi = float(finite.min()), float(finite.max())
303
+ else:
304
+ lo, hi = float(arr.min()), float(arr.max())
305
+ if not hi > lo:
306
+ hi = lo + 1.0
307
+ st["raw_min"], st["raw_max"] = lo, hi
308
+ return True
309
+
310
+ @property
311
+ def last_frame(self):
312
+ """The last frame painted by :meth:`show`, or None.
313
+
314
+ For a caller that needs to recompute something ABOUT the displayed
315
+ pixels — a histogram to re-send after the range changed, say — rather
316
+ than re-deriving it from wherever the frame originally came from.
317
+ """
318
+ return self._last_frame
319
+
320
+ def auto_clim(self) -> tuple[float, float] | None:
321
+ """Re-derive the robust display range from the last painted frame.
322
+
323
+ Returns the range applied, or None when there is nothing to derive it
324
+ from — a figure that has only ever been tiled (``show`` is never called
325
+ in tile mode) or one that has not painted yet.
326
+ """
327
+ frame = self._last_frame
328
+ if frame is None:
329
+ return None
330
+ lo, hi = robust_levels(frame)
331
+ return (float(lo), float(hi)) if self.set_clim(lo, hi) else None
332
+
333
+ # ── Tiled display ─────────────────────────────────────────────────────────
334
+
335
+ def enable_tile(self, backend, *, integration_method: str = "mean") -> bool:
336
+ """Render through a :class:`TileBackend` instead of pushed frames.
337
+
338
+ anyplotlib then owns the loop: it shows a downsampled overview as the
339
+ base and, on its own debounced ``view_changed``, asks the backend for a
340
+ hi-res tile of just the visible region at panel resolution. So the
341
+ source can be larger than anything worth sending whole — an 8192²
342
+ detector, or a camera that is only ever asked for the crop on screen.
343
+
344
+ Backends are duck-typed (``full_shape``, ``dtype``, ``origin``,
345
+ ``extent()``, ``sample()``); the shell requires no particular class.
346
+
347
+ Returns whether tiling was enabled — an anyplotlib without the tile API
348
+ is a soft failure, and the caller can fall back to :meth:`show`.
349
+ """
350
+ if not self.is_open or not hasattr(self._plot2d, "enable_tile"):
351
+ return False
352
+ try:
353
+ self._plot2d.enable_tile(backend, integration_method=integration_method)
354
+ self._tiled = True
355
+ return True
356
+ except Exception as e:
357
+ log.warning("enable_tile failed, falling back to pushed frames: %s", e)
358
+ return False
359
+
360
+ @property
361
+ def is_tiled(self) -> bool:
362
+ return self._tiled
363
+
364
+ def refresh_tile(self) -> bool:
365
+ """Re-read the CURRENT view from the backend — the live-data path.
366
+
367
+ The zoom and pan persist across the refresh, which is the contract that
368
+ matters for a live camera: new pixels arrive without the user's
369
+ viewport being reset out from under them.
370
+ """
371
+ if not self.is_open or not self._tiled:
372
+ return False
373
+ try:
374
+ self._plot2d.update_tile_source()
375
+ return True
376
+ except Exception as e:
377
+ log.debug("refresh_tile failed: %s", e)
378
+ return False
379
+
380
+ def set_tile_band(self, lo: float, hi: float) -> bool:
381
+ """Fix the tile QUANTISATION band (raw_min/raw_max) and re-encode.
382
+
383
+ anyplotlib derives this band once, at `enable_tile`, by sampling the
384
+ backend — which cannot work for a source that has no data yet (a real
385
+ DE Server serves no frame before an acquisition, and the tile source
386
+ deliberately answers zeros until then). With the band unset, tile
387
+ bytes are quantised over fallback guesses and the display window then
388
+ re-windows THOSE — on real hardware that rendered a healthy frame as
389
+ a black pane. The caller knows the true whole-frame range (the
390
+ server's histogram rides along with every read) and pins the band
391
+ here the moment data exists, or when the range outgrows it.
392
+
393
+ Reaches into `_plot2d._state` because anyplotlib has no public
394
+ band-setter yet — worth an upstream API (CSSFrancis/anyplotlib);
395
+ until then this is the one sanctioned touch point.
396
+ """
397
+ if not self.is_open or not self._tiled:
398
+ return False
399
+ try:
400
+ st = self._plot2d._state
401
+ st["raw_min"], st["raw_max"] = float(lo), float(hi)
402
+ self._plot2d.update_tile_source() # re-encode over the new band
403
+ return True
404
+ except Exception as e:
405
+ log.debug("set_tile_band(%s, %s) failed: %s", lo, hi, e)
406
+ return False
407
+
408
+ def set_clim(self, vmin: float, vmax: float) -> bool:
409
+ """Set the display range without touching the pixels.
410
+
411
+ The separate call matters in tile mode: :meth:`show` is never called
412
+ there, so there is no ``clim=`` argument to ride along with. Left
413
+ unset, a tiled plot keeps whatever range the placeholder passed to
414
+ ``imshow`` established — which renders real data as a uniform white or
415
+ black panel.
416
+ """
417
+ if not self.is_open:
418
+ return False
419
+ try:
420
+ self._plot2d.set_clim(float(vmin), float(vmax))
421
+ return True
422
+ except Exception as e:
423
+ log.debug("set_clim(%s, %s) failed: %s", vmin, vmax, e)
424
+ return False
425
+
426
+ def on_event(self, *event_types: str):
427
+ """Register a handler for anyplotlib pointer/key events on this figure.
428
+
429
+ A decorator, matching anyplotlib's own API::
430
+
431
+ @view.on_event("pointer_down")
432
+ def clicked(event): ...
433
+
434
+ The handler receives an `Event` whose ``xdata``/``ydata`` are in DATA
435
+ space — image pixels for an ``imshow`` — which is what a caller
436
+ measuring in the image wants, not canvas pixels.
437
+
438
+ Handlers fire on the asyncio main thread (the stdin reader dispatches
439
+ them), so a slow one blocks every other message: hand real work to a
440
+ worker rather than doing it inline.
441
+
442
+ Returns a no-op decorator when the figure is not open, so registration
443
+ at construction time is safe.
444
+ """
445
+ def _register(fn):
446
+ if self.is_open:
447
+ try:
448
+ self._plot2d.add_event_handler(fn, *event_types)
449
+ except Exception as e:
450
+ log.warning("could not register %s handler: %s", event_types, e)
451
+ return fn
452
+ return _register
453
+
454
+ def add_circle_widget(self, *, cx: float, cy: float, r: float,
455
+ color: str = "#00e5ff", on_change=None,
456
+ lock_center: bool = False,
457
+ linewidth: float = 2, show_handles: bool = True):
458
+ """A draggable circle overlay. Returns the widget, or None.
459
+
460
+ WIDGETS, not raw pointer handlers, are the working way to get user
461
+ geometry out of an anyplotlib figure: the plot owns the hit-testing and
462
+ pushes the new state back, whereas a bare `pointer_down` never arrives
463
+ (the plot's own pan consumes it).
464
+
465
+ `on_change(cx, cy, r)` fires on ``pointer_up`` — when the drag settles —
466
+ not on every frame, so a callback that recomputes something expensive
467
+ does not run sixty times a second.
468
+
469
+ `lock_center` pins the centre and lets only the radius change. On a
470
+ power spectrum the centre IS the DC term, so a draggable one is a
471
+ control that can only ever be wrong — and a ring nudged off-centre
472
+ silently corrupts every radius measured from it. anyplotlib enforces it
473
+ in the HIT-TEST: a grab on the ring body is refused outright, so the
474
+ centre never moves at all.
475
+
476
+ `linewidth` and `show_handles` are anyplotlib's own, passed through at
477
+ its own defaults. They matter for a circle used as an ANNOTATION rather
478
+ than a control: a set of them drawn at the default 2 px with grab
479
+ handles reads as a cluster of filled discs and a row of dots, which
480
+ obscures the very image it is marking up. A caller drawing many should
481
+ thin them and drop the handles — a handle on something nothing reads
482
+ back is an affordance that promises an effect it does not have.
483
+ """
484
+ if not self.is_open:
485
+ return None
486
+ try:
487
+ w = self._plot2d.add_circle_widget(
488
+ cx=float(cx), cy=float(cy), r=float(r), color=color,
489
+ lock_center=bool(lock_center), linewidth=float(linewidth),
490
+ show_handles=bool(show_handles))
491
+ except Exception as e:
492
+ log.warning("could not add circle widget: %s", e)
493
+ return None
494
+
495
+ def _settled(_event, _w=w):
496
+ try:
497
+ if on_change is not None:
498
+ on_change(float(_w.cx), float(_w.cy), float(_w.r))
499
+ except Exception as e:
500
+ log.debug("circle widget callback failed: %s", e)
501
+
502
+ if on_change is not None:
503
+ try:
504
+ w.add_event_handler(_settled, "pointer_up")
505
+ except Exception as e:
506
+ log.warning("could not observe circle widget: %s", e)
507
+ return w
508
+
509
+ def add_line_widget(self, *, x1: float, y1: float, x2: float, y2: float,
510
+ color: str = "#00e5ff", on_change=None):
511
+ """A draggable two-endpoint line overlay. Returns the widget, or None.
512
+
513
+ The measuring counterpart to the circle: drag either end onto a feature
514
+ and the length between them is the measurement. A LINE widget rather
515
+ than two clicks because the same argument applies — the plot owns the
516
+ hit-testing and pushes the geometry back, whereas a bare `pointer_down`
517
+ never arrives (the plot's own pan consumes it).
518
+
519
+ `on_change(x1, y1, x2, y2)` fires when a drag settles.
520
+ """
521
+ if not self.is_open:
522
+ return None
523
+ try:
524
+ w = self._plot2d.add_line_widget(
525
+ x1=float(x1), y1=float(y1), x2=float(x2), y2=float(y2),
526
+ color=color)
527
+ except Exception as e:
528
+ log.warning("could not add line widget: %s", e)
529
+ return None
530
+
531
+ def _settled(_event, _w=w):
532
+ try:
533
+ if on_change is not None:
534
+ on_change(float(_w.x1), float(_w.y1),
535
+ float(_w.x2), float(_w.y2))
536
+ except Exception as e:
537
+ log.debug("line widget callback failed: %s", e)
538
+
539
+ if on_change is not None:
540
+ try:
541
+ w.add_event_handler(_settled, "pointer_up")
542
+ except Exception as e:
543
+ log.warning("could not observe line widget: %s", e)
544
+ return w
545
+
546
+ @staticmethod
547
+ def set_widget_geometry(widget, **geometry) -> bool:
548
+ """Move an overlay from Python. Returns whether it landed.
549
+
550
+ `_notify=False` is the whole point: a `set()` is otherwise
551
+ indistinguishable from a user drag, so the widget's own `on_change`
552
+ fires on it — and a handler that recomputes a measurement from the
553
+ widget would then be recomputing from a value it was just given. The
554
+ caller already knows what it set.
555
+ """
556
+ if widget is None:
557
+ return False
558
+ try:
559
+ widget.set(_notify=False, **{k: float(v)
560
+ for k, v in geometry.items()})
561
+ return True
562
+ except Exception as e:
563
+ log.debug("set_widget_geometry(%s) failed: %s", geometry, e)
564
+ return False
565
+
566
+ def remove_widget(self, widget) -> None:
567
+ """Drop ONE overlay. Tolerates None and an already-removed widget, so a
568
+ caller swapping overlays does not have to track which it still holds."""
569
+ if widget is None or not self.is_open:
570
+ return
571
+ try:
572
+ widget.remove()
573
+ except Exception as e:
574
+ log.debug("remove_widget failed: %s", e)
575
+
576
+ def clear_widgets(self) -> None:
577
+ """Drop every overlay widget. Idempotent, never raises in teardown."""
578
+ if not self.is_open:
579
+ return
580
+ try:
581
+ self._plot2d.clear_widgets()
582
+ except Exception as e:
583
+ log.debug("clear_widgets failed: %s", e)
584
+
585
+ def set_colormap(self, name: str) -> None:
586
+ self._colormap = name
587
+ if not self.is_open:
588
+ return
589
+ try:
590
+ self._plot2d.set_colormap(name)
591
+ except Exception as e:
592
+ log.debug("set_colormap(%s) failed: %s", name, e)
593
+
594
+ def set_title(self, title: str) -> None:
595
+ self.title = title
596
+ if not self.is_open:
597
+ return
598
+ try:
599
+ self._plot2d.set_title(title)
600
+ except Exception as e:
601
+ log.debug("set_title(%s) failed: %s", title, e)
File without changes
@@ -0,0 +1,29 @@
1
+ """Selector utility functions."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+
6
+
7
+ def broadcast_rows_cartesian(*arrays: np.ndarray) -> np.ndarray:
8
+ """
9
+ Cartesian product over *rows* of multiple index arrays, keeping
10
+ the columns of each array together.
11
+
12
+ Each input is treated as shape (Ni, Ci): Ni rows, Ci columns.
13
+ The output has shape (N_total, sum(Ci)), where N_total is the
14
+ product of all Ni.
15
+ """
16
+ if len(arrays) == 0:
17
+ return np.empty((0, 0), dtype=int)
18
+
19
+ mats = [np.atleast_2d(a) for a in arrays]
20
+ n_rows = [m.shape[0] for m in mats]
21
+
22
+ grids = np.meshgrid(*[np.arange(n) for n in n_rows], indexing="ij")
23
+
24
+ parts = []
25
+ for m, g in zip(mats, grids):
26
+ chosen_rows = m[g.ravel()]
27
+ parts.append(chosen_rows)
28
+
29
+ return np.concatenate(parts, axis=1)