de-shell 0.2.1__tar.gz → 0.3.0__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 (71) hide show
  1. {de_shell-0.2.1 → de_shell-0.3.0}/PKG-INFO +24 -8
  2. {de_shell-0.2.1 → de_shell-0.3.0}/README.md +21 -5
  3. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/__init__.py +1 -1
  4. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/pythonEnv.test.ts +16 -4
  5. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/renderer/FigureFrame.tsx +15 -2
  6. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/renderer/figureBridge.test.ts +29 -1
  7. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/renderer/figureBridge.ts +30 -0
  8. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/renderer/index.ts +1 -1
  9. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/testing/harness.cjs +35 -20
  10. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/testing/harness.test.cjs +30 -0
  11. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/plotting/figure.py +80 -1
  12. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell.egg-info/PKG-INFO +24 -8
  13. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell.egg-info/requires.txt +1 -1
  14. {de_shell-0.2.1 → de_shell-0.3.0}/pyproject.toml +70 -2
  15. {de_shell-0.2.1 → de_shell-0.3.0}/tests/test_figure.py +82 -1
  16. {de_shell-0.2.1 → de_shell-0.3.0}/LICENSE +0 -0
  17. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/actions/__init__.py +0 -0
  18. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/actions/context.py +0 -0
  19. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/actions/figure_registry.py +0 -0
  20. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/actions/lifecycle.py +0 -0
  21. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/actions/registry.py +0 -0
  22. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/actions/wizard.py +0 -0
  23. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/app.py +0 -0
  24. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/compute.py +0 -0
  25. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/debug_flags.py +0 -0
  26. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/ipc.py +0 -0
  27. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/__init__.py +0 -0
  28. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/__main__.py +0 -0
  29. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/backendProcess.test.ts +0 -0
  30. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/backendProcess.ts +0 -0
  31. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/config.ts +0 -0
  32. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/dialogs.ts +0 -0
  33. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/envProgress.ts +0 -0
  34. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/errorReport.ts +0 -0
  35. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/index.ts +0 -0
  36. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/problemLog.ts +0 -0
  37. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/pythonEnv.ts +0 -0
  38. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/sentryEnvelope.test.ts +0 -0
  39. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/sentryEnvelope.ts +0 -0
  40. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/stdoutDemux.test.ts +0 -0
  41. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/stdoutDemux.ts +0 -0
  42. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/updater.ts +0 -0
  43. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/updaterErrors.test.ts +0 -0
  44. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/updaterErrors.ts +0 -0
  45. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/main/window.ts +0 -0
  46. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/package.json +0 -0
  47. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/preload/index.ts +0 -0
  48. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/renderer/figureBridge.react.ts +0 -0
  49. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/renderer/protocol.ts +0 -0
  50. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/renderer/shellState.test.ts +0 -0
  51. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/renderer/shellState.ts +0 -0
  52. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/renderer/sizeReporter.test.ts +0 -0
  53. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/js/renderer/sizeReporter.ts +0 -0
  54. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/log_stream.py +0 -0
  55. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/plotting/__init__.py +0 -0
  56. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/plotting/colormaps.py +0 -0
  57. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/plotting/selectors/__init__.py +0 -0
  58. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/plotting/selectors/utils.py +0 -0
  59. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/plotting/stream.py +0 -0
  60. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/process_guard.py +0 -0
  61. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/session.py +0 -0
  62. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/testing/__init__.py +0 -0
  63. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell/timing.py +0 -0
  64. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell.egg-info/SOURCES.txt +0 -0
  65. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell.egg-info/dependency_links.txt +0 -0
  66. {de_shell-0.2.1 → de_shell-0.3.0}/de_shell.egg-info/top_level.txt +0 -0
  67. {de_shell-0.2.1 → de_shell-0.3.0}/setup.cfg +0 -0
  68. {de_shell-0.2.1 → de_shell-0.3.0}/tests/test_actions.py +0 -0
  69. {de_shell-0.2.1 → de_shell-0.3.0}/tests/test_boundary.py +0 -0
  70. {de_shell-0.2.1 → de_shell-0.3.0}/tests/test_session_base.py +0 -0
  71. {de_shell-0.2.1 → de_shell-0.3.0}/tests/test_stream.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: de-shell
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: The Electron + Python desktop-app shell Direct Electron's apps (SpyDE, Ground Crew, Autopilot) are assembled from
5
5
  Author-email: Carter Francis <cartsfrancis@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/directelectron/de-shell
8
8
  Project-URL: Repository, https://github.com/directelectron/de-shell
9
9
  Project-URL: Issues, https://github.com/directelectron/de-shell/issues
10
- Project-URL: Changelog, https://github.com/directelectron/de-shell/blob/main/CHANGELOG.md
10
+ Project-URL: Changelog, https://github.com/directelectron/de-shell/blob/main/CHANGELOG.rst
11
11
  Keywords: electron,desktop,anyplotlib,electron-microscopy,direct-electron
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: Developers
@@ -26,7 +26,7 @@ Requires-Python: >=3.10
26
26
  Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: numpy>=1.20.0
29
- Requires-Dist: anyplotlib>=0.8.0
29
+ Requires-Dist: anyplotlib>=0.10.0
30
30
  Requires-Dist: pyyaml
31
31
  Provides-Extra: tests
32
32
  Requires-Dist: pytest>=3.6; extra == "tests"
@@ -122,9 +122,10 @@ peer dependencies — react, electron, electron-updater, @playwright/test —
122
122
  are the app's to declare; every app already does. The e2e specs take the
123
123
  harness from `shell/testing/harness.cjs`.
124
124
 
125
- Autopilot and SpyDE ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
126
- are wired this way, pinned `de-shell>=0.2,<0.3`; Ground Crew still carries a
127
- copy under its `packages/` and is next.
125
+ All three apps are wired this way: Autopilot, SpyDE
126
+ ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
127
+ and Ground Crew ([directelectron/de_ground_crew#183](https://github.com/directelectron/de_ground_crew/pull/183)),
128
+ pinned to the `0.2` line.
128
129
 
129
130
  ## Developing it
130
131
 
@@ -149,8 +150,20 @@ tests, and builds the wheel and checks what it carries.
149
150
 
150
151
  The version is written once, in `de_shell/__init__.py`. To release:
151
152
 
152
- 1. Bump `__version__`, move the `CHANGELOG.md` entries under the new version.
153
- 2. Commit, tag `vX.Y.Z`, push the tag.
153
+ 1. Run **Prepare Release** from the Actions tab and pick the bump (`minor`,
154
+ `bugfix`, `major`, `pre-release`, or `finalize` to drop a `bN` suffix). It
155
+ bumps `__version__`, assembles `CHANGELOG.rst` from the news fragments in
156
+ `upcoming_changes/`, runs the pre-flight checks, and opens a release PR that
157
+ names the one tag that will pass.
158
+ 2. Review and merge that PR, then tag the merge commit and push the tag — the
159
+ PR body has the exact commands.
160
+
161
+ The tag has to match `__version__` exactly; going through the workflow makes
162
+ them agree by construction, because the PR *is* the bump. To assemble the
163
+ changelog by hand instead, `uv tool run towncrier build --version X.Y.Z` does
164
+ the same thing — but stage `upcoming_changes/` with `git add -A`, because
165
+ towncrier deletes the fragments it consumed and a plain `git add CHANGELOG.rst`
166
+ leaves the deletions behind for the next release to re-publish.
154
167
 
155
168
  `.github/workflows/publish.yml` builds the distributions, refuses a tag that
156
169
  does not match `__version__`, and uploads to PyPI through trusted publishing
@@ -190,6 +203,9 @@ SpyDE commit the app copies were taken from:
190
203
  * **The protocol is the contract.** `PLOTAPP:` JSON lines and `PLOTBIN:`
191
204
  binary frames over the sidecar's stdio. Both halves of it live in this one
192
205
  package on purpose; keep it that way.
206
+ * **Every pull request carries its own changelog entry**, as a news fragment
207
+ under [`upcoming_changes/`](upcoming_changes/README.rst) — one file per PR,
208
+ so two of them never conflict over the same lines of `CHANGELOG.rst`.
193
209
  * **LF line endings**, enforced by `.gitattributes`.
194
210
 
195
211
  ## License
@@ -87,9 +87,10 @@ peer dependencies — react, electron, electron-updater, @playwright/test —
87
87
  are the app's to declare; every app already does. The e2e specs take the
88
88
  harness from `shell/testing/harness.cjs`.
89
89
 
90
- Autopilot and SpyDE ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
91
- are wired this way, pinned `de-shell>=0.2,<0.3`; Ground Crew still carries a
92
- copy under its `packages/` and is next.
90
+ All three apps are wired this way: Autopilot, SpyDE
91
+ ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
92
+ and Ground Crew ([directelectron/de_ground_crew#183](https://github.com/directelectron/de_ground_crew/pull/183)),
93
+ pinned to the `0.2` line.
93
94
 
94
95
  ## Developing it
95
96
 
@@ -114,8 +115,20 @@ tests, and builds the wheel and checks what it carries.
114
115
 
115
116
  The version is written once, in `de_shell/__init__.py`. To release:
116
117
 
117
- 1. Bump `__version__`, move the `CHANGELOG.md` entries under the new version.
118
- 2. Commit, tag `vX.Y.Z`, push the tag.
118
+ 1. Run **Prepare Release** from the Actions tab and pick the bump (`minor`,
119
+ `bugfix`, `major`, `pre-release`, or `finalize` to drop a `bN` suffix). It
120
+ bumps `__version__`, assembles `CHANGELOG.rst` from the news fragments in
121
+ `upcoming_changes/`, runs the pre-flight checks, and opens a release PR that
122
+ names the one tag that will pass.
123
+ 2. Review and merge that PR, then tag the merge commit and push the tag — the
124
+ PR body has the exact commands.
125
+
126
+ The tag has to match `__version__` exactly; going through the workflow makes
127
+ them agree by construction, because the PR *is* the bump. To assemble the
128
+ changelog by hand instead, `uv tool run towncrier build --version X.Y.Z` does
129
+ the same thing — but stage `upcoming_changes/` with `git add -A`, because
130
+ towncrier deletes the fragments it consumed and a plain `git add CHANGELOG.rst`
131
+ leaves the deletions behind for the next release to re-publish.
119
132
 
120
133
  `.github/workflows/publish.yml` builds the distributions, refuses a tag that
121
134
  does not match `__version__`, and uploads to PyPI through trusted publishing
@@ -155,6 +168,9 @@ SpyDE commit the app copies were taken from:
155
168
  * **The protocol is the contract.** `PLOTAPP:` JSON lines and `PLOTBIN:`
156
169
  binary frames over the sidecar's stdio. Both halves of it live in this one
157
170
  package on purpose; keep it that way.
171
+ * **Every pull request carries its own changelog entry**, as a news fragment
172
+ under [`upcoming_changes/`](upcoming_changes/README.rst) — one file per PR,
173
+ so two of them never conflict over the same lines of `CHANGELOG.rst`.
158
174
  * **LF line endings**, enforced by `.gitattributes`.
159
175
 
160
176
  ## License
@@ -20,6 +20,6 @@ from __future__ import annotations
20
20
 
21
21
  #: The package version — the one place it is written. pyproject.toml reads it
22
22
  #: (dynamic version) and the release workflow refuses a tag that disagrees.
23
- __version__ = "0.2.1"
23
+ __version__ = "0.3.0"
24
24
 
25
25
  __all__ = ["ipc", "log_stream", "process_guard", "debug_flags", "compute"]
@@ -10,9 +10,9 @@
10
10
  * Run: `node --test src/pythonEnv.test.ts` (from packages/shell-main/), or via
11
11
  * the `test:unit` npm script.
12
12
  */
13
- import { test } from 'node:test'
13
+ import { test, after } from 'node:test'
14
14
  import assert from 'node:assert/strict'
15
- import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'
15
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
16
16
  import { join } from 'path'
17
17
  import { tmpdir } from 'os'
18
18
  import { configureShell } from './config.ts'
@@ -24,17 +24,29 @@ configureShell({
24
24
  pythonModule: 'testapp',
25
25
  })
26
26
 
27
+ /** Every temp directory these tests make, removed when the file is done. */
28
+ const tempDirs: string[] = []
29
+ after(() => {
30
+ for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true })
31
+ })
32
+
33
+ function tempDir(prefix: string): string {
34
+ const dir = mkdtempSync(join(tmpdir(), prefix))
35
+ tempDirs.push(dir)
36
+ return dir
37
+ }
38
+
27
39
  /** A directory containing a uv stub under both spellings, so these tests do not
28
40
  * fork on the host platform. */
29
41
  function dirWithUv(): string {
30
- const dir = mkdtempSync(join(tmpdir(), 'uv-stub-'))
42
+ const dir = tempDir('uv-stub-')
31
43
  writeFileSync(join(dir, 'uv'), '')
32
44
  writeFileSync(join(dir, 'uv.exe'), '')
33
45
  return dir
34
46
  }
35
47
 
36
48
  function emptyDir(): string {
37
- return mkdtempSync(join(tmpdir(), 'uv-none-'))
49
+ return tempDir('uv-none-')
38
50
  }
39
51
 
40
52
  /** An env whose PATH and every fallback root point somewhere we control. */
@@ -24,7 +24,7 @@
24
24
  * the host page needs `script-src … blob:` for anyplotlib's ESM boot.
25
25
  */
26
26
  import React, { useEffect, useRef } from 'react'
27
- import { attachFigure, type FigureBridge } from './figureBridge'
27
+ import { attachFigure, readoutListener, type FigureBridge, type ReadoutInfo } from './figureBridge'
28
28
  import { createSizeReporter } from './sizeReporter'
29
29
 
30
30
  export interface FigureFrameProps {
@@ -37,13 +37,16 @@ export interface FigureFrameProps {
37
37
  title?: string
38
38
  /** Called with the frame's pixel size whenever it changes. */
39
39
  onResize?: (width: number, height: number) => void
40
+ /** The hover readout, on the shown grid; null when the cursor leaves. Pair it
41
+ * with `FigureView.set_readout_visible(False)` to replace the on-image pill. */
42
+ onReadout?: (info: ReadoutInfo | null) => void
40
43
  className?: string
41
44
  style?: React.CSSProperties
42
45
  'data-testid'?: string
43
46
  }
44
47
 
45
48
  export function FigureFrame({
46
- bridge, figId, html, fileUrl, title, onResize, className, style,
49
+ bridge, figId, html, fileUrl, title, onResize, onReadout, className, style,
47
50
  'data-testid': testId,
48
51
  }: FigureFrameProps) {
49
52
  const ref = useRef<HTMLIFrameElement | null>(null)
@@ -71,6 +74,16 @@ export function FigureFrame({
71
74
  return () => ro.disconnect()
72
75
  }, [figId])
73
76
 
77
+ // In a ref for the same reason as onResize: an inline prop must not re-run this.
78
+ const onReadoutRef = useRef(onReadout)
79
+ onReadoutRef.current = onReadout
80
+ useEffect(() => {
81
+ const listen = readoutListener(() => ref.current?.contentWindow,
82
+ (info) => onReadoutRef.current?.(info))
83
+ window.addEventListener('message', listen)
84
+ return () => window.removeEventListener('message', listen)
85
+ }, [figId])
86
+
74
87
  // REGISTRATION IS OWNED BY AN EFFECT, not by the ref callback alone, and it
75
88
  // re-registers on every run — see `attachFigure`, which holds the reason and
76
89
  // is where the StrictMode double-invoke is pinned by a test.
@@ -10,7 +10,7 @@
10
10
  import { test, describe } from 'node:test'
11
11
  import assert from 'node:assert/strict'
12
12
 
13
- import { attachFigure, createFigureBridge } from './figureBridge.ts'
13
+ import { attachFigure, createFigureBridge, readoutListener } from './figureBridge.ts'
14
14
 
15
15
  /** A stand-in iframe that records what was posted into it. */
16
16
  function fakeIframe(testid = 'frame') {
@@ -245,3 +245,31 @@ describe('attachFigure — the mount lifecycle a figure has to survive', () => {
245
245
  assert.equal(bridge.iframes.current.get('f1'), frame.el)
246
246
  })
247
247
  })
248
+
249
+ describe('readoutListener — one frame\'s relayed readout', () => {
250
+ const mine = {} as Window
251
+ const other = {} as Window
252
+ const info = { col: 3, row: 4, value: 7.5, exact: true, text: 'x:3 y:4 v:7.5' }
253
+ const msg = (data: unknown, source: unknown) =>
254
+ ({ data, source }) as unknown as MessageEvent
255
+
256
+ test('the frame\'s readout reaches the host, and null clears it', () => {
257
+ const heard: unknown[] = []
258
+ const listen = readoutListener(() => mine, (i) => { heard.push(i) })
259
+ listen(msg({ type: 'apl_readout', info }, mine))
260
+ listen(msg({ type: 'apl_readout', info: null }, mine))
261
+ assert.deepEqual(heard, [info, null])
262
+ })
263
+
264
+ test('another frame\'s readout, another message, or no frame is ignored', () => {
265
+ // A figure mounted twice: each mount hears only its own iframe.
266
+ const heard: unknown[] = []
267
+ readoutListener(() => mine, (i) => { heard.push(i) })(
268
+ msg({ type: 'apl_readout', info }, other))
269
+ readoutListener(() => mine, (i) => { heard.push(i) })(
270
+ msg({ type: 'awi_event', figId: 'f1', data: '{}' }, mine))
271
+ readoutListener(() => null, (i) => { heard.push(i) })(
272
+ msg({ type: 'apl_readout', info }, mine))
273
+ assert.deepEqual(heard, [])
274
+ })
275
+ })
@@ -90,6 +90,36 @@ export function attachFigure(bridge: FigureBridge, figId: string,
90
90
  }
91
91
  }
92
92
 
93
+ /** The hover readout anyplotlib computes on the SHOWN grid: `col`/`row` index the
94
+ * array the figure holds, `value` is its value there. */
95
+ export interface ReadoutInfo {
96
+ panel_id: string
97
+ img_x: number
98
+ img_y: number
99
+ col: number
100
+ row: number
101
+ xdata: number
102
+ ydata: number
103
+ units: string
104
+ value: number | null
105
+ exact: boolean
106
+ rgba: number[] | null
107
+ text: string
108
+ }
109
+
110
+ /** A window `message` listener passing on the readouts ONE frame relays
111
+ * (`fill_iframe_html`'s relay); `null` means the cursor left the image. */
112
+ export function readoutListener(
113
+ frame: () => Window | null | undefined,
114
+ onReadout: (info: ReadoutInfo | null) => void,
115
+ ): (e: MessageEvent) => void {
116
+ return (e) => {
117
+ const src = frame()
118
+ if (e.data?.type !== 'apl_readout' || !src || e.source !== src) return
119
+ onReadout(e.data.info ?? null)
120
+ }
121
+ }
122
+
93
123
 
94
124
  export function createFigureBridge(
95
125
  log: (label: string, detail: Record<string, unknown>) => void = () => {},
@@ -11,7 +11,7 @@
11
11
  export {
12
12
  createFigureBridge, useFigureBridge, useFigureEventForwarding,
13
13
  } from './figureBridge.react'
14
- export type { FigureBridge, BinaryFrame, RefLike } from './figureBridge'
14
+ export type { FigureBridge, BinaryFrame, RefLike, ReadoutInfo } from './figureBridge'
15
15
 
16
16
  export { FigureFrame } from './FigureFrame'
17
17
  export type { FigureFrameProps } from './FigureFrame'
@@ -30,7 +30,7 @@ function appRequire(name) {
30
30
  }
31
31
  const { _electron: electron } = appRequire('@playwright/test')
32
32
  const { spawnSync } = require('child_process')
33
- const { mkdtempSync } = require('fs')
33
+ const { mkdtempSync, rmSync } = require('fs')
34
34
  const { join } = require('path')
35
35
  const { tmpdir } = require('os')
36
36
 
@@ -51,6 +51,7 @@ async function launchApp(opts) {
51
51
  env = {}, timeout = 60_000,
52
52
  } = opts
53
53
  if (!appDir || !appId) throw new Error('launchApp needs { appDir, appId }')
54
+ const profileDir = mkdtempSync(join(tmpdir(), `${appId}-e2e-profile-`))
54
55
 
55
56
  const app = await electron.launch({
56
57
  // Resolve Electron from the APP's tree, not Playwright's. Without an
@@ -63,7 +64,7 @@ async function launchApp(opts) {
63
64
  executablePath: require(require.resolve('electron/index.js', { paths: [appDir] })),
64
65
  args: [
65
66
  join(appDir, 'out', 'main', 'index.js'),
66
- `--user-data-dir=${mkdtempSync(join(tmpdir(), `${appId}-e2e-profile-`))}`,
67
+ `--user-data-dir=${profileDir}`,
67
68
  ],
68
69
  env: { ...process.env, ...env },
69
70
  })
@@ -80,9 +81,9 @@ async function launchApp(opts) {
80
81
  for (const type of readyMessages) await backend.waitForMessage(type, timeout)
81
82
 
82
83
  return {
83
- app, page, backend, jsErrors,
84
+ app, page, backend, jsErrors, profileDir,
84
85
  assertNoJsErrors: () => assertNoJsErrors(jsErrors),
85
- close: (opts) => closeApp(app, opts),
86
+ close: (opts) => closeApp(app, { profileDir, ...opts }),
86
87
  }
87
88
  }
88
89
 
@@ -109,23 +110,37 @@ async function firstWindowWithLog(app, logBuffer, timeout = 60_000) {
109
110
  * runner's timeout, leaving an Electron tree alive that (for a DE app) still
110
111
  * owns the server's single connection — every later launch then hangs on a
111
112
  * connection that cannot be made. Returns 'closed' | 'killed' | 'noop' so a
112
- * caller can log what teardown actually did.
113
+ * caller can log what teardown actually did. `opts.profileDir` (the launch's
114
+ * temp profile) is removed once the process is down, on every path.
113
115
  */
114
116
  async function closeApp(app, opts = {}) {
115
- const { timeout = 15_000, killTree = hardKillTree } = opts
116
- if (!app) return 'noop'
117
- const pid = app.process()?.pid
118
- const closed = await new Promise((resolve) => {
119
- const timer = setTimeout(() => resolve(false), timeout)
120
- timer.unref?.()
121
- Promise.resolve()
122
- .then(() => app.close())
123
- .then(() => { clearTimeout(timer); resolve(true) },
124
- () => { clearTimeout(timer); resolve(false) })
125
- })
126
- if (closed) return 'closed'
127
- if (pid) killTree(pid)
128
- return pid ? 'killed' : 'noop'
117
+ const { timeout = 15_000, killTree = hardKillTree, profileDir = null } = opts
118
+ try {
119
+ if (!app) return 'noop'
120
+ const pid = app.process()?.pid
121
+ const closed = await new Promise((resolve) => {
122
+ const timer = setTimeout(() => resolve(false), timeout)
123
+ timer.unref?.()
124
+ Promise.resolve()
125
+ .then(() => app.close())
126
+ .then(() => { clearTimeout(timer); resolve(true) },
127
+ () => { clearTimeout(timer); resolve(false) })
128
+ })
129
+ if (closed) return 'closed'
130
+ if (pid) killTree(pid)
131
+ return pid ? 'killed' : 'noop'
132
+ } finally {
133
+ if (profileDir) removeProfileDir(profileDir)
134
+ }
135
+ }
136
+
137
+ /** Remove a launch's temp profile. Retries cover a handle Chromium is still
138
+ * letting go of on Windows; a directory that still will not go is left for the
139
+ * OS rather than failing the spec's teardown. */
140
+ function removeProfileDir(dir) {
141
+ try {
142
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
143
+ } catch { /* left behind; not the spec's failure */ }
129
144
  }
130
145
 
131
146
  /** Force-kill `pid` AND everything under it (the Python sidecar and its
@@ -240,5 +255,5 @@ async function countColorPixels(page, kind) {
240
255
 
241
256
  module.exports = {
242
257
  launchApp, createBackend, countColorPixels, assertNoJsErrors, errorLines,
243
- firstWindowWithLog, closeApp, hardKillTree,
258
+ firstWindowWithLog, closeApp, hardKillTree, removeProfileDir,
244
259
  }
@@ -15,6 +15,9 @@
15
15
  */
16
16
  const { test } = require('node:test')
17
17
  const assert = require('node:assert/strict')
18
+ const { mkdtempSync, existsSync, writeFileSync } = require('fs')
19
+ const { join } = require('path')
20
+ const { tmpdir } = require('os')
18
21
  const { firstWindowWithLog, closeApp } = require('./harness.cjs')
19
22
 
20
23
  test('a missing first window reports the backend log, not a bare timeout', async () => {
@@ -71,3 +74,30 @@ test('closeApp tolerates a missing app', async () => {
71
74
  assert.equal(await closeApp(null), 'noop')
72
75
  assert.equal(await closeApp(undefined), 'noop')
73
76
  })
77
+
78
+ /** A stand-in for the launch's --user-data-dir, with something inside it. */
79
+ function fakeProfileDir() {
80
+ const dir = mkdtempSync(join(tmpdir(), 'harness-test-profile-'))
81
+ writeFileSync(join(dir, 'Preferences'), '{}')
82
+ return dir
83
+ }
84
+
85
+ test('a clean close removes the profile dir', async () => {
86
+ const profileDir = fakeProfileDir()
87
+ const app = { process: () => ({ pid: 4242 }), close: async () => {} }
88
+ assert.equal(await closeApp(app, { timeout: 1000, killTree: () => {}, profileDir }), 'closed')
89
+ assert.equal(existsSync(profileDir), false, 'profile dir left behind')
90
+ })
91
+
92
+ test('a hard-killed close removes the profile dir too', async () => {
93
+ const profileDir = fakeProfileDir()
94
+ const app = { process: () => ({ pid: 4242 }), close: () => new Promise(() => {}) }
95
+ assert.equal(await closeApp(app, { timeout: 100, killTree: () => {}, profileDir }), 'killed')
96
+ assert.equal(existsSync(profileDir), false, 'profile dir left behind')
97
+ })
98
+
99
+ test('closeApp with no app still removes the profile dir', async () => {
100
+ const profileDir = fakeProfileDir()
101
+ assert.equal(await closeApp(null, { profileDir }), 'noop')
102
+ assert.equal(existsSync(profileDir), false, 'profile dir left behind')
103
+ })
@@ -32,6 +32,7 @@ from __future__ import annotations
32
32
 
33
33
  import logging
34
34
  import warnings
35
+ from collections.abc import Sequence
35
36
 
36
37
  import numpy as np
37
38
 
@@ -140,6 +141,15 @@ PIN_SCROLL = (
140
141
  "},{capture:true,passive:true})</script>"
141
142
  )
142
143
 
144
+ #: Relays anyplotlib's hover readout (`apl:readout`, which fires whether or not
145
+ #: the pill is shown) to the host page, for `FigureFrame`'s `onReadout`.
146
+ #: `info` is null when the cursor leaves the image.
147
+ RELAY_READOUT = (
148
+ "<script>document.addEventListener('apl:readout',function(e){"
149
+ "if(parent!==window)parent.postMessage({type:'apl_readout',info:e.detail},'*')"
150
+ "})</script>"
151
+ )
152
+
143
153
 
144
154
  def fill_iframe_html(html: str, *, background: str = FIGURE_BACKGROUND,
145
155
  extra_head: str = "") -> str:
@@ -161,7 +171,8 @@ def fill_iframe_html(html: str, *, background: str = FIGURE_BACKGROUND,
161
171
  f"#widget-root{{background:{background} !important;"
162
172
  "width:100% !important;height:100% !important;display:block !important}"
163
173
  "</style>")
164
- return html.replace("<body>", style + PIN_SCROLL + extra_head + "<body>", 1)
174
+ return html.replace("<body>",
175
+ style + PIN_SCROLL + RELAY_READOUT + extra_head + "<body>", 1)
165
176
 
166
177
 
167
178
  class FigureView:
@@ -454,6 +465,19 @@ class FigureView:
454
465
  log.debug("set_clim(%s, %s) failed: %s", vmin, vmax, e)
455
466
  return False
456
467
 
468
+ def set_readout_visible(self, visible: bool) -> bool:
469
+ """Show or hide the on-image hover pill. Hidden, the readout still reaches
470
+ the host through `FigureFrame`'s `onReadout`, so an app can print it in
471
+ its own units."""
472
+ if not self.is_open:
473
+ return False
474
+ try:
475
+ self._plot2d.set_readout_visible(bool(visible))
476
+ return True
477
+ except Exception as e:
478
+ log.debug("set_readout_visible(%s) failed: %s", visible, e)
479
+ return False
480
+
457
481
  def on_event(self, *event_types: str):
458
482
  """Register a handler for anyplotlib pointer/key events on this figure.
459
483
 
@@ -574,6 +598,61 @@ class FigureView:
574
598
  log.warning("could not observe line widget: %s", e)
575
599
  return w
576
600
 
601
+ def add_rectangle_widget(self, *, x: float, y: float, w: float, h: float,
602
+ color: str = "#00e5ff", on_change=None,
603
+ max_extent=None, show_handles: bool = True,
604
+ linewidth: float = 2):
605
+ """A draggable rectangle overlay. Returns the widget, or None.
606
+
607
+ `x, y` is the top-left corner. `max_extent` (a scalar, or
608
+ `(max_w, max_h)`) stops growth at the cap while a corner is dragged.
609
+ `on_change(x, y, w, h)` fires when a drag settles.
610
+ """
611
+ if not self.is_open:
612
+ return None
613
+ try:
614
+ rect = self._plot2d.add_rectangle_widget(
615
+ x=float(x), y=float(y), w=float(w), h=float(h), color=color,
616
+ linewidth=float(linewidth), show_handles=bool(show_handles),
617
+ max_extent=max_extent)
618
+ except Exception as e:
619
+ log.warning("could not add rectangle widget: %s", e)
620
+ return None
621
+
622
+ def _settled(_event, _w=rect):
623
+ try:
624
+ if on_change is not None:
625
+ on_change(float(_w.x), float(_w.y),
626
+ float(_w.w), float(_w.h))
627
+ except Exception as e:
628
+ log.debug("rectangle widget callback failed: %s", e)
629
+
630
+ if on_change is not None:
631
+ try:
632
+ rect.add_event_handler(_settled, "pointer_up")
633
+ except Exception as e:
634
+ log.warning("could not observe rectangle widget: %s", e)
635
+ return rect
636
+
637
+ def add_texts(self, offsets, texts, *, name: str | None = None,
638
+ color: str | Sequence[str] = "#ffffff", fontsize: int = 12,
639
+ fontweight: str = "normal", outline_color: str | None = None):
640
+ """Text labels at image-pixel positions (top-left anchors). Returns the
641
+ group, or None. The same `name` again replaces it in place;
642
+ `remove_widget` drops it. `color` is one colour or one per text.
643
+ `outline_color` draws a legibility halo.
644
+ """
645
+ if not self.is_open:
646
+ return None
647
+ try:
648
+ return self._plot2d.add_texts(
649
+ offsets, [str(t) for t in texts], name=name, color=color,
650
+ fontsize=fontsize, fontweight=fontweight,
651
+ outline_color=outline_color)
652
+ except Exception as e:
653
+ log.warning("could not add texts: %s", e)
654
+ return None
655
+
577
656
  @staticmethod
578
657
  def set_widget_geometry(widget, **geometry) -> bool:
579
658
  """Move an overlay from Python. Returns whether it landed.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: de-shell
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: The Electron + Python desktop-app shell Direct Electron's apps (SpyDE, Ground Crew, Autopilot) are assembled from
5
5
  Author-email: Carter Francis <cartsfrancis@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/directelectron/de-shell
8
8
  Project-URL: Repository, https://github.com/directelectron/de-shell
9
9
  Project-URL: Issues, https://github.com/directelectron/de-shell/issues
10
- Project-URL: Changelog, https://github.com/directelectron/de-shell/blob/main/CHANGELOG.md
10
+ Project-URL: Changelog, https://github.com/directelectron/de-shell/blob/main/CHANGELOG.rst
11
11
  Keywords: electron,desktop,anyplotlib,electron-microscopy,direct-electron
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: Developers
@@ -26,7 +26,7 @@ Requires-Python: >=3.10
26
26
  Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: numpy>=1.20.0
29
- Requires-Dist: anyplotlib>=0.8.0
29
+ Requires-Dist: anyplotlib>=0.10.0
30
30
  Requires-Dist: pyyaml
31
31
  Provides-Extra: tests
32
32
  Requires-Dist: pytest>=3.6; extra == "tests"
@@ -122,9 +122,10 @@ peer dependencies — react, electron, electron-updater, @playwright/test —
122
122
  are the app's to declare; every app already does. The e2e specs take the
123
123
  harness from `shell/testing/harness.cjs`.
124
124
 
125
- Autopilot and SpyDE ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
126
- are wired this way, pinned `de-shell>=0.2,<0.3`; Ground Crew still carries a
127
- copy under its `packages/` and is next.
125
+ All three apps are wired this way: Autopilot, SpyDE
126
+ ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
127
+ and Ground Crew ([directelectron/de_ground_crew#183](https://github.com/directelectron/de_ground_crew/pull/183)),
128
+ pinned to the `0.2` line.
128
129
 
129
130
  ## Developing it
130
131
 
@@ -149,8 +150,20 @@ tests, and builds the wheel and checks what it carries.
149
150
 
150
151
  The version is written once, in `de_shell/__init__.py`. To release:
151
152
 
152
- 1. Bump `__version__`, move the `CHANGELOG.md` entries under the new version.
153
- 2. Commit, tag `vX.Y.Z`, push the tag.
153
+ 1. Run **Prepare Release** from the Actions tab and pick the bump (`minor`,
154
+ `bugfix`, `major`, `pre-release`, or `finalize` to drop a `bN` suffix). It
155
+ bumps `__version__`, assembles `CHANGELOG.rst` from the news fragments in
156
+ `upcoming_changes/`, runs the pre-flight checks, and opens a release PR that
157
+ names the one tag that will pass.
158
+ 2. Review and merge that PR, then tag the merge commit and push the tag — the
159
+ PR body has the exact commands.
160
+
161
+ The tag has to match `__version__` exactly; going through the workflow makes
162
+ them agree by construction, because the PR *is* the bump. To assemble the
163
+ changelog by hand instead, `uv tool run towncrier build --version X.Y.Z` does
164
+ the same thing — but stage `upcoming_changes/` with `git add -A`, because
165
+ towncrier deletes the fragments it consumed and a plain `git add CHANGELOG.rst`
166
+ leaves the deletions behind for the next release to re-publish.
154
167
 
155
168
  `.github/workflows/publish.yml` builds the distributions, refuses a tag that
156
169
  does not match `__version__`, and uploads to PyPI through trusted publishing
@@ -190,6 +203,9 @@ SpyDE commit the app copies were taken from:
190
203
  * **The protocol is the contract.** `PLOTAPP:` JSON lines and `PLOTBIN:`
191
204
  binary frames over the sidecar's stdio. Both halves of it live in this one
192
205
  package on purpose; keep it that way.
206
+ * **Every pull request carries its own changelog entry**, as a news fragment
207
+ under [`upcoming_changes/`](upcoming_changes/README.rst) — one file per PR,
208
+ so two of them never conflict over the same lines of `CHANGELOG.rst`.
193
209
  * **LF line endings**, enforced by `.gitattributes`.
194
210
 
195
211
  ## License
@@ -1,5 +1,5 @@
1
1
  numpy>=1.20.0
2
- anyplotlib>=0.8.0
2
+ anyplotlib>=0.10.0
3
3
  pyyaml
4
4
 
5
5
  [tests]
@@ -42,7 +42,10 @@ dependencies = [
42
42
  # the frame's shape, given a tile source on its zeros placeholder, then
43
43
  # painted. On 0.7.3 a state push landing between the open and the first
44
44
  # frame could leave the pane showing the placeholder.
45
- "anyplotlib>=0.8.0",
45
+ #
46
+ # 0.10.0 for the hover readout's `apl:readout` event and `set_readout_visible`
47
+ # (0.9.0), and `add_texts`' `outline_color` halo (0.10.0).
48
+ "anyplotlib>=0.10.0",
46
49
  "pyyaml",
47
50
  ]
48
51
 
@@ -56,7 +59,7 @@ tests = [
56
59
  Homepage = "https://github.com/directelectron/de-shell"
57
60
  Repository = "https://github.com/directelectron/de-shell"
58
61
  Issues = "https://github.com/directelectron/de-shell/issues"
59
- Changelog = "https://github.com/directelectron/de-shell/blob/main/CHANGELOG.md"
62
+ Changelog = "https://github.com/directelectron/de-shell/blob/main/CHANGELOG.rst"
60
63
 
61
64
  # One version, in one place: de_shell.__version__. The release workflow refuses
62
65
  # a tag that does not match it.
@@ -87,3 +90,68 @@ de_shell = [
87
90
  [tool.pytest.ini_options]
88
91
  testpaths = ["tests"]
89
92
  timeout = 120
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Changelog management (towncrier)
96
+ # ---------------------------------------------------------------------------
97
+ # towncrier assembles CHANGELOG.rst from one fragment file per PR, written while
98
+ # the change is fresh. The alternative — deriving notes from commit subjects at
99
+ # release time — reads like a commit log, because that is what it is: it cannot
100
+ # say what a change means to an app upgrading, only what the author was doing.
101
+ # It also stops two pull requests conflicting over the same few changelog lines.
102
+ #
103
+ # `package` is set: this repo's version of record IS de_shell.__version__ (the
104
+ # publish workflow refuses a tag that disagrees with it), so towncrier can read
105
+ # the version itself and `--version` is only needed to build notes for a bump
106
+ # that has not been written yet. See upcoming_changes/README.rst.
107
+ [tool.towncrier]
108
+ package = "de_shell"
109
+ package_dir = "."
110
+ directory = "upcoming_changes"
111
+ filename = "CHANGELOG.rst"
112
+ start_string = ".. towncrier release notes start\n"
113
+ issue_format = "`#{issue} <https://github.com/directelectron/de-shell/pull/{issue}>`_"
114
+ title_format = "{version} ({project_date})"
115
+ underlines = ["=", "-", "~"]
116
+
117
+ # Behaviour changes lead the notes: they are the entries an app upgrading has
118
+ # to ACT on, and they are easy to miss filed under "Bug Fixes".
119
+ [[tool.towncrier.type]]
120
+ directory = "api_change"
121
+ name = "API and Behaviour Changes"
122
+ showcontent = true
123
+
124
+ [[tool.towncrier.type]]
125
+ directory = "new_feature"
126
+ name = "New Features"
127
+ showcontent = true
128
+
129
+ [[tool.towncrier.type]]
130
+ directory = "bugfix"
131
+ name = "Bug Fixes"
132
+ showcontent = true
133
+
134
+ [[tool.towncrier.type]]
135
+ directory = "performance"
136
+ name = "Performance"
137
+ showcontent = true
138
+
139
+ [[tool.towncrier.type]]
140
+ directory = "deprecation"
141
+ name = "Deprecations"
142
+ showcontent = true
143
+
144
+ [[tool.towncrier.type]]
145
+ directory = "removal"
146
+ name = "Removals"
147
+ showcontent = true
148
+
149
+ [[tool.towncrier.type]]
150
+ directory = "doc"
151
+ name = "Documentation"
152
+ showcontent = true
153
+
154
+ [[tool.towncrier.type]]
155
+ directory = "maintenance"
156
+ name = "Maintenance"
157
+ showcontent = true
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import numpy as np
11
11
  import pytest
12
12
 
13
- from de_shell.plotting.figure import FigureView, robust_levels
13
+ from de_shell.plotting.figure import FigureView, fill_iframe_html, robust_levels
14
14
 
15
15
 
16
16
  class TestRobustLevels:
@@ -156,3 +156,84 @@ class TestTileQuantisationBand:
156
156
  def test_small_frame_stays_untiled_and_unaffected(self):
157
157
  v, _ = self._open_and_show((64, 64))
158
158
  assert not v._plot2d._state.get("tile_enabled")
159
+
160
+
161
+ class TestOverlays:
162
+ """Widgets and labels on a real, open figure."""
163
+
164
+ @staticmethod
165
+ def _open():
166
+ v = FigureView(0, title="t")
167
+ v.open((64, 64))
168
+ return v
169
+
170
+ def test_a_rectangle_carries_its_geometry_style_and_cap(self):
171
+ w = self._open().add_rectangle_widget(
172
+ x=1, y=2, w=3, h=4, color="#123456", max_extent=10,
173
+ show_handles=False, linewidth=1)
174
+ assert (w.x, w.y, w.w, w.h) == (1.0, 2.0, 3.0, 4.0)
175
+ assert w.color == "#123456" and w.linewidth == 1.0
176
+ assert w.show_handles is False and (w.max_w, w.max_h) == (10.0, 10.0)
177
+
178
+ def test_a_rectangle_reports_where_a_drag_settles(self):
179
+ heard = []
180
+ w = self._open().add_rectangle_widget(
181
+ x=1, y=2, w=3, h=4, on_change=lambda *g: heard.append(g))
182
+ w._update_from_js({"x": 5.0, "y": 6.0}, "pointer_move")
183
+ assert heard == [] # once per drag, not per frame
184
+ w._update_from_js({"w": 7.0, "h": 8.0}, "pointer_up")
185
+ assert heard == [(5.0, 6.0, 7.0, 8.0)]
186
+
187
+ def test_a_rectangle_moved_from_python_lands_without_a_callback(self):
188
+ heard = []
189
+ w = self._open().add_rectangle_widget(
190
+ x=1, y=2, w=3, h=4, on_change=lambda *g: heard.append(g))
191
+ assert FigureView.set_widget_geometry(w, x=9, y=9, w=5, h=5)
192
+ assert (w.x, w.y, w.w, w.h) == (9.0, 9.0, 5.0, 5.0) and heard == []
193
+
194
+ def test_a_rectangle_on_a_closed_figure_is_none(self):
195
+ assert FigureView(0).add_rectangle_widget(x=0, y=0, w=1, h=1) is None
196
+
197
+ def test_a_refused_rectangle_is_none_and_a_warning(self, monkeypatch, caplog):
198
+ v = self._open()
199
+
200
+ def boom(**_kw):
201
+ raise RuntimeError("boom")
202
+
203
+ monkeypatch.setattr(v._plot2d, "add_rectangle_widget", boom)
204
+ assert v.add_rectangle_widget(x=0, y=0, w=1, h=1) is None
205
+ assert "rectangle" in caplog.text
206
+
207
+ def test_texts_are_drawn_and_a_name_replaces_them_in_place(self):
208
+ v = self._open()
209
+ v.add_texts([[4, 5]], ["edge (auto)"], name="labels",
210
+ outline_color="#000000")
211
+ g = v.add_texts([[6, 7]], ["noise"], name="labels")
212
+ texts = v._plot2d.markers["texts"]
213
+ assert len(texts) == 1 and g._data["texts"] == ["noise"]
214
+ v.remove_widget(g)
215
+ assert "labels" not in texts
216
+
217
+ def test_texts_accept_one_colour_per_text(self):
218
+ v = self._open()
219
+ g = v.add_texts([[4, 5], [6, 7]], ["a", "b"],
220
+ color=["#ff0000", "#00ff00"])
221
+ assert g._data["color"] == ["#ff0000", "#00ff00"]
222
+
223
+ def test_texts_on_a_closed_figure_are_none(self):
224
+ assert FigureView(0).add_texts([[0, 0]], ["x"]) is None
225
+
226
+
227
+ class TestReadout:
228
+ def test_the_pill_can_be_hidden_for_the_host_to_draw_its_own(self):
229
+ v = FigureView(0)
230
+ v.open((64, 64))
231
+ assert v.set_readout_visible(False) is True
232
+ assert v._plot2d._state["readout_visible"] is False
233
+
234
+ def test_hiding_the_pill_on_a_closed_figure_is_a_no_op(self):
235
+ assert FigureView(0).set_readout_visible(False) is False
236
+
237
+ def test_every_figure_frame_relays_its_readout_to_the_host(self):
238
+ head = fill_iframe_html("<head></head><body></body>").split("<body>")[0]
239
+ assert "apl:readout" in head and "apl_readout" in head
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