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.
- de_shell/__init__.py +25 -0
- de_shell/actions/__init__.py +0 -0
- de_shell/actions/context.py +62 -0
- de_shell/actions/figure_registry.py +53 -0
- de_shell/actions/lifecycle.py +295 -0
- de_shell/actions/registry.py +141 -0
- de_shell/actions/wizard.py +115 -0
- de_shell/app.py +170 -0
- de_shell/compute.py +103 -0
- de_shell/debug_flags.py +69 -0
- de_shell/ipc.py +236 -0
- de_shell/js/__init__.py +38 -0
- de_shell/js/__main__.py +4 -0
- de_shell/js/main/backendProcess.test.ts +70 -0
- de_shell/js/main/backendProcess.ts +330 -0
- de_shell/js/main/config.ts +53 -0
- de_shell/js/main/dialogs.ts +62 -0
- de_shell/js/main/envProgress.ts +126 -0
- de_shell/js/main/errorReport.ts +261 -0
- de_shell/js/main/index.ts +57 -0
- de_shell/js/main/problemLog.ts +53 -0
- de_shell/js/main/pythonEnv.test.ts +125 -0
- de_shell/js/main/pythonEnv.ts +442 -0
- de_shell/js/main/sentryEnvelope.test.ts +94 -0
- de_shell/js/main/sentryEnvelope.ts +100 -0
- de_shell/js/main/updater.ts +322 -0
- de_shell/js/main/updaterErrors.test.ts +111 -0
- de_shell/js/main/updaterErrors.ts +65 -0
- de_shell/js/main/window.ts +141 -0
- de_shell/js/package.json +5 -0
- de_shell/js/preload/index.ts +130 -0
- de_shell/js/renderer/FigureFrame.tsx +88 -0
- de_shell/js/renderer/figureBridge.react.ts +58 -0
- de_shell/js/renderer/figureBridge.test.ts +184 -0
- de_shell/js/renderer/figureBridge.ts +169 -0
- de_shell/js/renderer/index.ts +34 -0
- de_shell/js/renderer/protocol.ts +164 -0
- de_shell/js/renderer/shellState.test.ts +193 -0
- de_shell/js/renderer/shellState.ts +310 -0
- de_shell/js/testing/harness.cjs +244 -0
- de_shell/js/testing/harness.test.cjs +73 -0
- de_shell/log_stream.py +185 -0
- de_shell/plotting/__init__.py +0 -0
- de_shell/plotting/colormaps.py +27 -0
- de_shell/plotting/figure.py +601 -0
- de_shell/plotting/selectors/__init__.py +0 -0
- de_shell/plotting/selectors/utils.py +29 -0
- de_shell/plotting/stream.py +172 -0
- de_shell/process_guard.py +190 -0
- de_shell/session.py +211 -0
- de_shell/testing/__init__.py +0 -0
- de_shell/timing.py +28 -0
- de_shell-0.2.0.dist-info/METADATA +196 -0
- de_shell-0.2.0.dist-info/RECORD +57 -0
- de_shell-0.2.0.dist-info/WHEEL +5 -0
- de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
- de_shell-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* window.ts — the app window, and getting backend messages into it.
|
|
3
|
+
*
|
|
4
|
+
* Both live apps had written this identically: create a BrowserWindow with the
|
|
5
|
+
* preload attached, buffer backend messages until the renderer is listening,
|
|
6
|
+
* flush them in order, and tee renderer console output to the terminal.
|
|
7
|
+
*
|
|
8
|
+
* The buffering is the part that is not obvious. Backend messages can arrive
|
|
9
|
+
* before the renderer has finished loading and registered its `ipcRenderer`
|
|
10
|
+
* listener, and `webContents.send()` DROPS anything sent before then — silently.
|
|
11
|
+
* That swallowed the first message after any quiet period: in SpyDE, the
|
|
12
|
+
* nav-shape prompt on opening a file (the dialog only appeared once a later load
|
|
13
|
+
* pushed more messages); in a live app, the very first figure. So messages queue
|
|
14
|
+
* until `did-finish-load` and are then flushed in order.
|
|
15
|
+
*/
|
|
16
|
+
import { BrowserWindow } from 'electron'
|
|
17
|
+
import { join } from 'path'
|
|
18
|
+
import { channel, shellConfig } from './config'
|
|
19
|
+
|
|
20
|
+
export interface ShellWindowOptions {
|
|
21
|
+
/** Directory of the running main bundle — normally `__dirname`. Preload and
|
|
22
|
+
* renderer are resolved relative to it (`../preload`, `../renderer`). */
|
|
23
|
+
mainDir: string
|
|
24
|
+
width?: number
|
|
25
|
+
height?: number
|
|
26
|
+
backgroundColor?: string
|
|
27
|
+
/** Extra BrowserWindow options, merged last. */
|
|
28
|
+
browserWindow?: Electron.BrowserWindowConstructorOptions
|
|
29
|
+
/** Tee renderer + figure-iframe console output to this process's stdout.
|
|
30
|
+
* Warnings and errors always; `logFilter` opts extra lines in. */
|
|
31
|
+
teeConsole?: boolean
|
|
32
|
+
/** Return true to tee a console message that is below warning level. */
|
|
33
|
+
logFilter?: (message: string) => boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ShellWindow {
|
|
37
|
+
/** The window. Null once it has been closed. */
|
|
38
|
+
get(): BrowserWindow | null
|
|
39
|
+
/** Send a backend message to the renderer, buffering until it is listening. */
|
|
40
|
+
sendToRenderer(msg: Record<string, unknown>): void
|
|
41
|
+
/** Raw backend stdout/stderr, on the preload's `onStream` channel. Not
|
|
42
|
+
* buffered: a line that arrives before the renderer listens is a line
|
|
43
|
+
* nobody was going to read. */
|
|
44
|
+
sendStream(text: string, kind: 'stdout' | 'stderr'): void
|
|
45
|
+
/** True when there is a live window whose webContents is not destroyed. */
|
|
46
|
+
alive(): boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create the app's window and its message pipe.
|
|
51
|
+
*
|
|
52
|
+
* Loads `ELECTRON_RENDERER_URL` when electron-vite's dev server set it, and the
|
|
53
|
+
* built `../renderer/index.html` otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export function createShellWindow(opts: ShellWindowOptions): ShellWindow {
|
|
56
|
+
const cfg = shellConfig()
|
|
57
|
+
const messageChannel = channel('message')
|
|
58
|
+
const streamChannel = channel('stream')
|
|
59
|
+
|
|
60
|
+
let win: BrowserWindow | null = null
|
|
61
|
+
let rendererReady = false
|
|
62
|
+
const pending: Array<Record<string, unknown>> = []
|
|
63
|
+
|
|
64
|
+
const alive = () =>
|
|
65
|
+
!!win && !win.isDestroyed() && !win.webContents.isDestroyed()
|
|
66
|
+
|
|
67
|
+
const flush = () => {
|
|
68
|
+
if (!alive()) return
|
|
69
|
+
while (pending.length) win!.webContents.send(messageChannel, pending.shift())
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sendToRenderer = (msg: Record<string, unknown>) => {
|
|
73
|
+
if (!rendererReady || !alive()) { pending.push(msg); return }
|
|
74
|
+
win!.webContents.send(messageChannel, msg)
|
|
75
|
+
}
|
|
76
|
+
const sendStream = (text: string, kind: 'stdout' | 'stderr') => {
|
|
77
|
+
if (rendererReady && alive()) win!.webContents.send(streamChannel, text, kind)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
win = new BrowserWindow({
|
|
81
|
+
width: opts.width ?? 1280,
|
|
82
|
+
height: opts.height ?? 860,
|
|
83
|
+
backgroundColor: opts.backgroundColor ?? '#14161c',
|
|
84
|
+
// Shown on ready-to-show rather than immediately, so the user never sees an
|
|
85
|
+
// empty white frame while the renderer boots.
|
|
86
|
+
show: false,
|
|
87
|
+
webPreferences: {
|
|
88
|
+
preload: join(opts.mainDir, '..', 'preload', 'index.js'),
|
|
89
|
+
sandbox: false,
|
|
90
|
+
},
|
|
91
|
+
...opts.browserWindow,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
win.once('ready-to-show', () => win?.show())
|
|
95
|
+
win.webContents.on('did-finish-load', () => { rendererReady = true; flush() })
|
|
96
|
+
// A reload (dev HMR, Ctrl+R) tears the listener down with the page. Close the
|
|
97
|
+
// gate again so messages queue for the NEW page instead of being sent into a
|
|
98
|
+
// frame that is going away — the exact loss the buffering exists to prevent.
|
|
99
|
+
win.webContents.on('did-start-navigation', (_e, _url, isInPlace, isMainFrame) => {
|
|
100
|
+
if (isMainFrame && !isInPlace) rendererReady = false
|
|
101
|
+
})
|
|
102
|
+
win.on('closed', () => { win = null; rendererReady = false })
|
|
103
|
+
|
|
104
|
+
if (opts.teeConsole !== false) {
|
|
105
|
+
// Renderer AND figure-iframe console output, so a JS error inside a figure
|
|
106
|
+
// frame is visible without opening devtools and switching frame context.
|
|
107
|
+
//
|
|
108
|
+
// TWO event signatures, and getting this wrong fails SILENTLY — the tee
|
|
109
|
+
// simply stops teeing, which is the worst way for a diagnostic to break.
|
|
110
|
+
// Electron <35: (event, level: number, message, line, sourceId), where
|
|
111
|
+
// level is 0=log 1=warning 2=error 3=info. Electron >=35: (event, details)
|
|
112
|
+
// with a STRING level. Reading `level >= 1` off the details OBJECT is
|
|
113
|
+
// always false, so every message is dropped.
|
|
114
|
+
win.webContents.on('console-message', (...args: unknown[]) => {
|
|
115
|
+
const [, second, third] = args
|
|
116
|
+
let message: string
|
|
117
|
+
let warnOrWorse: boolean
|
|
118
|
+
if (second !== null && typeof second === 'object') {
|
|
119
|
+
const d = second as { message?: string; level?: string | number }
|
|
120
|
+
message = String(d.message ?? '')
|
|
121
|
+
warnOrWorse = typeof d.level === 'number'
|
|
122
|
+
? d.level >= 1
|
|
123
|
+
: d.level === 'warning' || d.level === 'error'
|
|
124
|
+
} else {
|
|
125
|
+
message = String(third ?? '')
|
|
126
|
+
warnOrWorse = Number(second) >= 1
|
|
127
|
+
}
|
|
128
|
+
if (warnOrWorse || opts.logFilter?.(message)) {
|
|
129
|
+
console.log(`[${cfg.appId} renderer] ${message}`)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (process.env.ELECTRON_RENDERER_URL) {
|
|
135
|
+
win.loadURL(process.env.ELECTRON_RENDERER_URL)
|
|
136
|
+
} else {
|
|
137
|
+
win.loadFile(join(opts.mainDir, '..', 'renderer', 'index.html'))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { get: () => win, sendToRenderer, sendStream, alive }
|
|
141
|
+
}
|
de_shell/js/package.json
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @de/shell-preload — the contextBridge surface every shell app exposes.
|
|
3
|
+
*
|
|
4
|
+
* Both apps had written the same handful of channels by hand: receive backend
|
|
5
|
+
* messages and raw stdio, send an action, forward a figure event, report a
|
|
6
|
+
* figure resize. This builds that core from the app's id and lets the app spread
|
|
7
|
+
* its own extras alongside.
|
|
8
|
+
*
|
|
9
|
+
* ## Disposers are the whole point of the `on*` shape
|
|
10
|
+
*
|
|
11
|
+
* Every listener registration returns an UNSUBSCRIBE function. The renderer
|
|
12
|
+
* registers these in a `useEffect`, and without cleanup React StrictMode's
|
|
13
|
+
* double-invoke — and every HMR remount — stacks duplicate `ipcRenderer`
|
|
14
|
+
* listeners, so each message is dispatched twice, then three times, and the app
|
|
15
|
+
* degrades as you work. Returning a disposer lets the effect remove the exact
|
|
16
|
+
* listener it added. Do not "simplify" these to bare `ipcRenderer.on`.
|
|
17
|
+
*/
|
|
18
|
+
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
|
19
|
+
|
|
20
|
+
/** An Electron dialog file filter, restated so the renderer need not depend on
|
|
21
|
+
* electron's types. */
|
|
22
|
+
export interface FileFilter { name: string; extensions: string[] }
|
|
23
|
+
|
|
24
|
+
export interface ShellBridgeOptions {
|
|
25
|
+
/** Matches @de/shell-main's `appId` — channels are `<appId>:<name>`. */
|
|
26
|
+
appId: string
|
|
27
|
+
/** Env var the main process sets from `app.isPackaged`, if the app gates
|
|
28
|
+
* test-only hooks on it. Defaults to `<APPID>_PACKAGED`. */
|
|
29
|
+
packagedEnvVar?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The channels every shell app shares. Returned rather than exposed, so an app
|
|
34
|
+
* can spread its own on top:
|
|
35
|
+
*
|
|
36
|
+
* contextBridge.exposeInMainWorld('myapp', {
|
|
37
|
+
* ...createShellBridge({ appId: 'myapp' }),
|
|
38
|
+
* myOwnThing: () => ipcRenderer.invoke('myapp:thing'),
|
|
39
|
+
* })
|
|
40
|
+
*/
|
|
41
|
+
export function createShellBridge(opts: ShellBridgeOptions) {
|
|
42
|
+
const { appId } = opts
|
|
43
|
+
const packagedVar = opts.packagedEnvVar
|
|
44
|
+
?? `${appId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PACKAGED`
|
|
45
|
+
const channel = (name: string) => `${appId}:${name}`
|
|
46
|
+
|
|
47
|
+
/** Register `handler` on `ch`, returning its exact disposer. */
|
|
48
|
+
const on = <A extends unknown[]>(
|
|
49
|
+
ch: string, cb: (...args: A) => void,
|
|
50
|
+
): (() => void) => {
|
|
51
|
+
const h = (_: unknown, ...args: A) => cb(...args)
|
|
52
|
+
ipcRenderer.on(ch, h as never)
|
|
53
|
+
return () => { ipcRenderer.removeListener(ch, h as never) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
/** 'darwin' | 'win32' | 'linux' — hosts lay out the title bar from it. */
|
|
58
|
+
platform: process.platform,
|
|
59
|
+
|
|
60
|
+
/** True only in a packaged production app. Dev and the Playwright e2e
|
|
61
|
+
* (which launches the BUILT bundle by path, not a packaged app) leave the
|
|
62
|
+
* env var unset, so test-only hooks stay live in both. */
|
|
63
|
+
isPackaged: process.env[packagedVar] === '1',
|
|
64
|
+
|
|
65
|
+
/** Any backend message. Returns an unsubscribe fn. */
|
|
66
|
+
onMessage: (cb: (msg: Record<string, unknown>) => void) =>
|
|
67
|
+
on<[Record<string, unknown>]>(channel('message'), cb),
|
|
68
|
+
|
|
69
|
+
/** Raw stdout/stderr lines from the backend. Returns an unsubscribe fn. */
|
|
70
|
+
onStream: (cb: (text: string, kind: 'stdout' | 'stderr') => void) =>
|
|
71
|
+
on<[string, 'stdout' | 'stderr']>(channel('stream'), cb),
|
|
72
|
+
|
|
73
|
+
/** Send an action to the backend. */
|
|
74
|
+
action: (action: string, payload: Record<string, unknown> = {}, windowId?: number) =>
|
|
75
|
+
ipcRenderer.send(channel('action'), action, payload, windowId),
|
|
76
|
+
|
|
77
|
+
/** Forward an interaction event from an anyplotlib iframe to the backend. */
|
|
78
|
+
figureEvent: (figId: string, eventJson: string) =>
|
|
79
|
+
ipcRenderer.send(channel('figure-event'), figId, eventJson),
|
|
80
|
+
|
|
81
|
+
/** Tell the backend a figure's container resized, so its layout keeps up. */
|
|
82
|
+
resizeFigure: (figId: string, width: number, height: number) =>
|
|
83
|
+
ipcRenderer.send(channel('resize'), figId, width, height),
|
|
84
|
+
|
|
85
|
+
/** Open a URL in the user's browser. Main allowlists the protocol. */
|
|
86
|
+
openExternal: (url: string) => ipcRenderer.send('open-external', url),
|
|
87
|
+
|
|
88
|
+
/** OS path of a dropped File. A sandboxed renderer has no `File.path`, so
|
|
89
|
+
* drag-and-drop of datasets needs this. */
|
|
90
|
+
pathForFile: (file: File): string | null => {
|
|
91
|
+
try {
|
|
92
|
+
return webUtils.getPathForFile(file) || null
|
|
93
|
+
} catch {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/** Native open dialog. Resolves to a path, or null if cancelled.
|
|
99
|
+
*
|
|
100
|
+
* `invoke`, not `send`: the caller needs the answer, and threading a
|
|
101
|
+
* reply back through the message channel would mean correlating requests
|
|
102
|
+
* by hand. Main owns the dialog because a sandboxed renderer cannot make
|
|
103
|
+
* one, and because main is where the parent window is. */
|
|
104
|
+
openFile: (filters?: FileFilter[]): Promise<string | null> =>
|
|
105
|
+
ipcRenderer.invoke(channel('open-file'), filters),
|
|
106
|
+
|
|
107
|
+
/** Native directory picker. Resolves to a path, or null if cancelled. */
|
|
108
|
+
openDirectory: (): Promise<string | null> =>
|
|
109
|
+
ipcRenderer.invoke(channel('open-directory')),
|
|
110
|
+
|
|
111
|
+
/** Native save dialog. Resolves to a path, or null if cancelled. */
|
|
112
|
+
saveFile: (filters?: FileFilter[], defaultPath?: string): Promise<string | null> =>
|
|
113
|
+
ipcRenderer.invoke(channel('save-file'), filters, defaultPath),
|
|
114
|
+
|
|
115
|
+
/** Escape hatch for an app's own channels, so it does not have to
|
|
116
|
+
* re-implement the disposer discipline above. */
|
|
117
|
+
onChannel: on,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Build the core surface and expose it as `window[appId]`, merged with
|
|
122
|
+
* `extra`. The common case; use `createShellBridge` directly if the app needs
|
|
123
|
+
* to name the global something else. */
|
|
124
|
+
export function exposeShellBridge(
|
|
125
|
+
opts: ShellBridgeOptions, extra: Record<string, unknown> = {},
|
|
126
|
+
): void {
|
|
127
|
+
contextBridge.exposeInMainWorld(opts.appId, {
|
|
128
|
+
...createShellBridge(opts), ...extra,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FigureFrame.tsx — an anyplotlib figure in an iframe, wired to the bridge.
|
|
3
|
+
*
|
|
4
|
+
* Handles the three things every host of a figure has to get right, and which
|
|
5
|
+
* are easy to omit without any error appearing:
|
|
6
|
+
*
|
|
7
|
+
* * **Register with the bridge**, so state can be routed to this frame — and
|
|
8
|
+
* deregister on unmount, so a dead element is not held.
|
|
9
|
+
* * **Replay on load**, passing THIS element. State that arrived before the
|
|
10
|
+
* frame mounted was posted into the void; replay is the only thing that
|
|
11
|
+
* recovers it, and a figure mounted twice must serve itself rather than
|
|
12
|
+
* whichever mount happens to hold the registry slot.
|
|
13
|
+
* * **Report its size**, so the backend can lay the figure out to fit. Without
|
|
14
|
+
* it the figure renders at anyplotlib's default size and overflows its pane.
|
|
15
|
+
*
|
|
16
|
+
* `srcdoc` vs `src`: a backend that inlines the figure's ESM can be mounted
|
|
17
|
+
* directly from `html`. One that swaps the bundle for a shared URL (SpyDE does,
|
|
18
|
+
* so Chromium reuses the V8 code cache across many figure iframes) MUST be
|
|
19
|
+
* served over a real origin and passes `fileUrl` instead — a srcdoc frame
|
|
20
|
+
* cannot load it. Note that a srcdoc frame INHERITS the parent page's CSP, so
|
|
21
|
+
* the host page needs `script-src … blob:` for anyplotlib's ESM boot.
|
|
22
|
+
*/
|
|
23
|
+
import React, { useEffect, useRef } from 'react'
|
|
24
|
+
import type { FigureBridge } from './figureBridge'
|
|
25
|
+
|
|
26
|
+
export interface FigureFrameProps {
|
|
27
|
+
bridge: FigureBridge
|
|
28
|
+
figId: string
|
|
29
|
+
/** Inline figure HTML (mounted via srcdoc). Ignored when `fileUrl` is set. */
|
|
30
|
+
html?: string
|
|
31
|
+
/** Figure URL served over the app's own scheme. Takes precedence over `html`. */
|
|
32
|
+
fileUrl?: string | null
|
|
33
|
+
title?: string
|
|
34
|
+
/** Called with the frame's pixel size whenever it changes. */
|
|
35
|
+
onResize?: (width: number, height: number) => void
|
|
36
|
+
className?: string
|
|
37
|
+
style?: React.CSSProperties
|
|
38
|
+
'data-testid'?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function FigureFrame({
|
|
42
|
+
bridge, figId, html, fileUrl, title, onResize, className, style,
|
|
43
|
+
'data-testid': testId,
|
|
44
|
+
}: FigureFrameProps) {
|
|
45
|
+
const ref = useRef<HTMLIFrameElement | null>(null)
|
|
46
|
+
|
|
47
|
+
// Report size to the backend. Fires once on mount and on every resize; the
|
|
48
|
+
// zero-size guard skips the frame's first layout pass, which would otherwise
|
|
49
|
+
// tell the backend to lay the figure out at 0×0.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const el = ref.current
|
|
52
|
+
if (!el || !onResize) return
|
|
53
|
+
const send = () => {
|
|
54
|
+
const r = el.getBoundingClientRect()
|
|
55
|
+
if (r.width > 0 && r.height > 0) {
|
|
56
|
+
onResize(Math.round(r.width), Math.round(r.height))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
send()
|
|
60
|
+
const ro = new ResizeObserver(send)
|
|
61
|
+
ro.observe(el)
|
|
62
|
+
return () => ro.disconnect()
|
|
63
|
+
}, [figId, onResize])
|
|
64
|
+
|
|
65
|
+
// Deregister on unmount so the bridge never holds a detached element.
|
|
66
|
+
useEffect(() => () => bridge.registerIframe(figId, null), [bridge, figId])
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<iframe
|
|
70
|
+
ref={(el) => { ref.current = el; bridge.registerIframe(figId, el) }}
|
|
71
|
+
// Keyed by figId so React REUSES the element across repaints. Remounting
|
|
72
|
+
// per frame would tear down the figure's WebGPU context and throw away
|
|
73
|
+
// the user's zoom.
|
|
74
|
+
key={figId}
|
|
75
|
+
title={title ?? figId}
|
|
76
|
+
{...(fileUrl ? { src: fileUrl } : { srcDoc: html })}
|
|
77
|
+
className={className}
|
|
78
|
+
// `display: block` FIRST, so a caller's style can still override it. An
|
|
79
|
+
// iframe is inline by default, which reserves descender space under it:
|
|
80
|
+
// a host sized to `height: 100%` then overflows by ~4-5 px and grows a
|
|
81
|
+
// scrollbar around a figure that looks correctly sized.
|
|
82
|
+
style={{ display: 'block', ...style }}
|
|
83
|
+
data-testid={testId}
|
|
84
|
+
// ITSELF, never whichever mount currently holds the registry slot.
|
|
85
|
+
onLoad={() => bridge.replay(figId, ref.current ?? undefined)}
|
|
86
|
+
/>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* figureBridge.react.ts — the React binding for the figure bridge.
|
|
3
|
+
*
|
|
4
|
+
* Split from `figureBridge.ts` so the bridge itself stays plain TypeScript:
|
|
5
|
+
* it is stateful but not reactive, and keeping it free of React means it can be
|
|
6
|
+
* unit-tested without a renderer.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useRef } from 'react'
|
|
9
|
+
import { createFigureBridge, type FigureBridge } from './figureBridge'
|
|
10
|
+
|
|
11
|
+
export { createFigureBridge }
|
|
12
|
+
export type { FigureBridge }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One bridge per component tree, with a STABLE identity for the life of the
|
|
16
|
+
* component.
|
|
17
|
+
*
|
|
18
|
+
* The stability matters: the bridge holds every figure's retained state, so a
|
|
19
|
+
* bridge rebuilt on re-render would drop it, and any figure that had already
|
|
20
|
+
* painted would go blank the next time its iframe reloaded. It is also
|
|
21
|
+
* depended on by effects — a changing identity would re-run them every render.
|
|
22
|
+
*/
|
|
23
|
+
export function useFigureBridge(
|
|
24
|
+
log?: (label: string, detail: Record<string, unknown>) => void,
|
|
25
|
+
): FigureBridge {
|
|
26
|
+
const ref = useRef<FigureBridge | null>(null)
|
|
27
|
+
if (ref.current === null) ref.current = createFigureBridge(log)
|
|
28
|
+
return ref.current
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Forward anyplotlib interaction events from the figure iframes to the backend.
|
|
34
|
+
*
|
|
35
|
+
* An anyplotlib figure posts `{type: 'awi_event', figId, data}` up to its host
|
|
36
|
+
* window when the user clicks, drags or presses a key inside it. Nothing
|
|
37
|
+
* happens to that message unless someone listens and relays it — so WITHOUT
|
|
38
|
+
* this hook every `plot.add_event_handler(...)` registered in Python is silently
|
|
39
|
+
* dead, which is exactly how it presented: arming a measurement worked, and the
|
|
40
|
+
* drag that followed reached nothing.
|
|
41
|
+
*
|
|
42
|
+
* Call once, high in the app. `send` is normally the preload's `figureEvent`.
|
|
43
|
+
*/
|
|
44
|
+
export function useFigureEventForwarding(
|
|
45
|
+
send: (figId: string, eventJson: string) => void,
|
|
46
|
+
): void {
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const onMessage = (e: MessageEvent) => {
|
|
49
|
+
const d = e.data
|
|
50
|
+
if (d?.type !== 'awi_event' || !d.figId) return
|
|
51
|
+
// `data` is already a JSON STRING on the wire; stringify an object form
|
|
52
|
+
// rather than sending "[object Object]" down the protocol.
|
|
53
|
+
send(d.figId, typeof d.data === 'string' ? d.data : JSON.stringify(d.data))
|
|
54
|
+
}
|
|
55
|
+
window.addEventListener('message', onMessage)
|
|
56
|
+
return () => window.removeEventListener('message', onMessage)
|
|
57
|
+
}, [send])
|
|
58
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* figureBridge.test.ts — the retention rules, pinned.
|
|
3
|
+
*
|
|
4
|
+
* Every case here corresponds to a bug that shipped: a blank presented slide, a
|
|
5
|
+
* multi-panel figure with one panel drawn, a stash emptied by its own replay.
|
|
6
|
+
* They are cheap to assert and expensive to rediscover.
|
|
7
|
+
*
|
|
8
|
+
* Run by `npm run test:unit` (node:test, native TS type-stripping).
|
|
9
|
+
*/
|
|
10
|
+
import { test, describe } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
|
|
13
|
+
import { createFigureBridge } from './figureBridge.ts'
|
|
14
|
+
|
|
15
|
+
/** A stand-in iframe that records what was posted into it. */
|
|
16
|
+
function fakeIframe(testid = 'frame') {
|
|
17
|
+
const posted: Array<{ message: any; transfer: any }> = []
|
|
18
|
+
return {
|
|
19
|
+
posted,
|
|
20
|
+
el: {
|
|
21
|
+
contentWindow: {
|
|
22
|
+
postMessage(message: any, _origin: string, transfer?: any) {
|
|
23
|
+
posted.push({ message, transfer })
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
getAttribute: () => testid,
|
|
27
|
+
getBoundingClientRect: () => ({ width: 10, height: 10 }),
|
|
28
|
+
} as unknown as HTMLIFrameElement,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('state forwarding and retention', () => {
|
|
33
|
+
test('a state posted before any iframe mounts is retained and replayed', () => {
|
|
34
|
+
const bridge = createFigureBridge()
|
|
35
|
+
// Nothing registered yet — this post goes nowhere, silently. The retention
|
|
36
|
+
// is the only thing that saves it.
|
|
37
|
+
bridge.applyState('f1', 'title', 'hello')
|
|
38
|
+
|
|
39
|
+
const frame = fakeIframe()
|
|
40
|
+
bridge.registerIframe('f1', frame.el)
|
|
41
|
+
bridge.replay('f1')
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(frame.posted.map(p => p.message),
|
|
44
|
+
[{ type: 'awi_state', key: 'title', value: 'hello' }])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('only the latest value per key is retained', () => {
|
|
48
|
+
const bridge = createFigureBridge()
|
|
49
|
+
bridge.applyState('f1', 'k', 1)
|
|
50
|
+
bridge.applyState('f1', 'k', 2)
|
|
51
|
+
const frame = fakeIframe()
|
|
52
|
+
bridge.registerIframe('f1', frame.el)
|
|
53
|
+
bridge.replay('f1')
|
|
54
|
+
assert.deepEqual(frame.posted.map(p => p.message.value), [2])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('state is posted live to a mounted frame', () => {
|
|
58
|
+
const bridge = createFigureBridge()
|
|
59
|
+
const frame = fakeIframe()
|
|
60
|
+
bridge.registerIframe('f1', frame.el)
|
|
61
|
+
bridge.applyState('f1', 'k', 'v')
|
|
62
|
+
assert.equal(frame.posted.length, 1)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('binary frames', () => {
|
|
67
|
+
test('panels are stashed separately, keyed by geom', () => {
|
|
68
|
+
// The multi-panel bug: `key` is the pixel FIELD and is identical across
|
|
69
|
+
// panels, so stashing by key alone left one frame however many panels the
|
|
70
|
+
// figure had — and a presented copy drew one panel and blanks.
|
|
71
|
+
const bridge = createFigureBridge()
|
|
72
|
+
bridge.applyBinary('f1', 'image_b64', { geom: 'panel_a' }, new Uint8Array([1]))
|
|
73
|
+
bridge.applyBinary('f1', 'image_b64', { geom: 'panel_b' }, new Uint8Array([2]))
|
|
74
|
+
|
|
75
|
+
const slot = bridge.binaryStates.current.get('f1')!
|
|
76
|
+
assert.deepEqual([...slot.keys()].sort(),
|
|
77
|
+
['panel_a::image_b64', 'panel_b::image_b64'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('a frame with no geom falls back to the pixel key', () => {
|
|
81
|
+
const bridge = createFigureBridge()
|
|
82
|
+
bridge.applyBinary('f1', 'image_b64', {}, new Uint8Array([1]))
|
|
83
|
+
assert.deepEqual([...bridge.binaryStates.current.get('f1')!.keys()], ['image_b64'])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('the live post transfers the buffer', () => {
|
|
87
|
+
const bridge = createFigureBridge()
|
|
88
|
+
const frame = fakeIframe()
|
|
89
|
+
bridge.registerIframe('f1', frame.el)
|
|
90
|
+
const bytes = new Uint8Array([1, 2, 3])
|
|
91
|
+
bridge.applyBinary('f1', 'image_b64', { geom: 'g' }, bytes)
|
|
92
|
+
assert.deepEqual(frame.posted[0].transfer, [bytes.buffer])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('the stash survives its own replay, and every replay sends a fresh copy', () => {
|
|
96
|
+
// Transfer DETACHES the buffer it sends. Replaying the stashed array itself
|
|
97
|
+
// would empty the stash on first use, so the second mount of a figure — the
|
|
98
|
+
// presented copy — would get nothing.
|
|
99
|
+
const bridge = createFigureBridge()
|
|
100
|
+
bridge.applyBinary('f1', 'image_b64', { geom: 'g' }, new Uint8Array([7, 8]))
|
|
101
|
+
|
|
102
|
+
const first = fakeIframe('first')
|
|
103
|
+
bridge.registerIframe('f1', first.el)
|
|
104
|
+
bridge.replay('f1')
|
|
105
|
+
|
|
106
|
+
const second = fakeIframe('second')
|
|
107
|
+
bridge.replay('f1', second.el)
|
|
108
|
+
|
|
109
|
+
for (const frame of [first, second]) {
|
|
110
|
+
const msg = frame.posted.at(-1)!.message
|
|
111
|
+
assert.equal(msg.type, 'awi_state_binary')
|
|
112
|
+
assert.deepEqual([...msg.buffer], [7, 8], 'replayed into a detached buffer')
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('replay serves the TARGET, not whichever mount holds the registry slot', () => {
|
|
117
|
+
// The blank-presented-deck bug. Two mounts share a figId; the map holds one.
|
|
118
|
+
// A freshly-loaded frame passes itself and must be the one served.
|
|
119
|
+
const bridge = createFigureBridge()
|
|
120
|
+
bridge.applyState('f1', 'k', 'v')
|
|
121
|
+
|
|
122
|
+
const winner = fakeIframe('winner')
|
|
123
|
+
const loader = fakeIframe('loader')
|
|
124
|
+
bridge.registerIframe('f1', winner.el) // last registration wins the map
|
|
125
|
+
bridge.replay('f1', loader.el) // ...but THIS frame just loaded
|
|
126
|
+
|
|
127
|
+
assert.equal(loader.posted.length, 1)
|
|
128
|
+
assert.equal(winner.posted.length, 0, 'state went to the sibling mount')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('registry and eviction', () => {
|
|
133
|
+
test('registering null deregisters', () => {
|
|
134
|
+
const bridge = createFigureBridge()
|
|
135
|
+
const frame = fakeIframe()
|
|
136
|
+
bridge.registerIframe('f1', frame.el)
|
|
137
|
+
bridge.registerIframe('f1', null)
|
|
138
|
+
assert.equal(bridge.iframes.current.has('f1'), false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('evict drops every trace of a figure', () => {
|
|
142
|
+
// Without this, a long report-editing session grows the maps forever: each
|
|
143
|
+
// re-render of a cell mints a new figId and the old one's pixels stay.
|
|
144
|
+
const bridge = createFigureBridge()
|
|
145
|
+
const frame = fakeIframe()
|
|
146
|
+
bridge.registerIframe('f1', frame.el)
|
|
147
|
+
bridge.applyState('f1', 'k', 'v')
|
|
148
|
+
bridge.applyBinary('f1', 'image_b64', { geom: 'g' }, new Uint8Array([1]))
|
|
149
|
+
|
|
150
|
+
bridge.evict('f1')
|
|
151
|
+
|
|
152
|
+
assert.equal(bridge.states.current.has('f1'), false)
|
|
153
|
+
assert.equal(bridge.binaryStates.current.has('f1'), false)
|
|
154
|
+
assert.equal(bridge.iframes.current.has('f1'), false)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('replaying an unmounted figure is a no-op, not a throw', () => {
|
|
158
|
+
const bridge = createFigureBridge()
|
|
159
|
+
bridge.applyState('f1', 'k', 'v')
|
|
160
|
+
bridge.replay('f1')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('dump reports retained counts per figure', () => {
|
|
164
|
+
const bridge = createFigureBridge()
|
|
165
|
+
const frame = fakeIframe()
|
|
166
|
+
bridge.registerIframe('f1', frame.el)
|
|
167
|
+
bridge.applyState('f1', 'k', 'v')
|
|
168
|
+
bridge.applyBinary('f1', 'image_b64', { geom: 'g' }, new Uint8Array([1]))
|
|
169
|
+
|
|
170
|
+
const row = bridge.dump().find(r => r.figId === 'f1')!
|
|
171
|
+
assert.equal(row.jsonKeys, 1)
|
|
172
|
+
assert.equal(row.binaryKeys, 1)
|
|
173
|
+
assert.equal(row.binaryKeyNames, 'g::image_b64')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('dump includes figures with retained state but no mounted frame', () => {
|
|
177
|
+
// The diagnostic's whole job: telling "never arrived" from "arrived, not
|
|
178
|
+
// mounted". A figure missing from the dump would be indistinguishable.
|
|
179
|
+
const bridge = createFigureBridge()
|
|
180
|
+
bridge.applyState('ghost', 'k', 'v')
|
|
181
|
+
const row = bridge.dump().find(r => r.figId === 'ghost')!
|
|
182
|
+
assert.equal(row.registeredIn, 'NONE')
|
|
183
|
+
})
|
|
184
|
+
})
|