Skip to content

Commit e21a270

Browse files
rgbkrkclaude
andauthored
Move canvas routing from CanvasWidget to CanvasManagerWidget (#134)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 9507097 commit e21a270

File tree

6 files changed

+186
-51
lines changed

6 files changed

+186
-51
lines changed

content/docs/widgets/ipycanvas.mdx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,15 +163,43 @@ canvas.on_mouse_down(on_mouse_down)
163163

164164
ipycanvas uses two widget models:
165165

166-
- **`CanvasManagerModel`** - Headless widget that receives drawing commands as binary custom messages. Routes commands to the active canvas via `switchCanvas`.
167-
- **`CanvasModel`** - Visual widget that renders the HTML `<canvas>` element. Subscribes to its manager's custom messages and executes drawing commands on the 2D context.
166+
- **`CanvasManagerModel`** Headless singleton (`_view_name: null`) that receives ALL drawing commands from Python as binary custom messages. Uses `switchCanvas` to indicate which canvas each command targets.
167+
- **`CanvasModel`** Visual widget that renders an HTML `<canvas>` element. Subscribes to its own comm_id and executes drawing commands on the 2D context.
168168

169169
Commands are serialized as binary buffers for performance. The first buffer contains JSON-encoded command metadata, and subsequent buffers carry binary data (e.g., NumPy arrays for batch operations).
170170

171171
<Callout type="info">
172-
The `hold_canvas()` context manager in Python batches many drawing commands into a single message for better performance.
172+
The `hold_canvas()` context manager in Python batches many drawing commands into a single message for better performance. Without it, each drawing call is a separate message.
173173
</Callout>
174174

175+
## Implementation Notes
176+
177+
Our implementation differs from ipycanvas's original frontend in how command routing works. The binary protocol and command set are identical.
178+
179+
### Store-level routing
180+
181+
In ipycanvas's original Backbone.js frontend, each `CanvasView` subscribes to the `CanvasManagerModel`'s comm channel and tracks `switchCanvas` state locally. This means every canvas sees every command for every other canvas — they just filter locally.
182+
183+
We route at the store level instead. `createCanvasManagerRouter` (in `canvas-manager-subscriptions.ts`) watches for `CanvasManagerModel` instances, subscribes to their messages, parses `switchCanvas` targets, and re-emits each message to only the targeted canvas's comm_id. Each `CanvasWidget` subscribes to its own comm_id and processes everything it receives — no filtering, no shared state.
184+
185+
```
186+
Original ipycanvas:
187+
Manager → broadcast to all canvases → each canvas filters locally
188+
189+
nteract-elements:
190+
Manager → store router parses switchCanvas → emits to target canvas only
191+
```
192+
193+
This matters for correctness: with the broadcast approach, rapid animations on one canvas can interfere with other canvases' `switchCanvas` tracking. Store-level routing eliminates this by isolating each canvas completely.
194+
195+
### Headless manager routing
196+
197+
`CanvasManagerModel` has `_view_name: null` — it's never in the widget render tree and has no visual representation. The original frontend handles this inside `CanvasView` by reaching into the manager's model. We handle it at the store level via `createCanvasManagerRouter`, which follows the same pattern as `createLinkManager` for `jslink`/`jsdlink`. This runs in `WidgetStoreProvider` and requires no component to be mounted.
198+
199+
### No Backbone.js
200+
201+
ipycanvas's original frontend uses Backbone.js models and views. We use a pure React widget store with `useSyncExternalStore` for state and store-level subscriptions for custom messages.
202+
175203
## Not Yet Supported
176204

177205
These features require cross-widget model resolution and are planned for a future release:

content/docs/widgets/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"anywidget-view",
77
"controls",
88
"output-widget",
9+
"---Third Party Widgets---",
910
"ipycanvas"
1011
]
1112
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Store-level routing for ipycanvas CanvasManagerModel.
3+
*
4+
* CanvasManagerModel is a headless widget (_view_name: null) that receives
5+
* ALL drawing commands from Python. This module subscribes to each manager's
6+
* custom messages, parses switchCanvas targets, and re-emits to each target
7+
* canvas's comm_id — isolating canvases from each other.
8+
*
9+
* Same pattern as link-subscriptions.ts for LinkModel/DirectionalLinkModel.
10+
*
11+
* Usage:
12+
* const cleanup = createCanvasManagerRouter(store);
13+
* // ... later, to tear down all routing:
14+
* cleanup();
15+
*
16+
* WidgetStoreProvider calls this automatically. For non-React integrations
17+
* (e.g. iframe isolation), call createCanvasManagerRouter directly.
18+
*/
19+
20+
import { COMMANDS, getTypedArray } from "./ipycanvas/ipycanvas-commands";
21+
import type { WidgetStore } from "./widget-store";
22+
23+
/**
24+
* Walk a command structure and collect switchCanvas target IDs.
25+
* Calls setTarget as a side effect so subsequent messages without
26+
* switchCanvas route to the last known target.
27+
*/
28+
function collectSwitchCanvasTargets(
29+
commands: unknown,
30+
targets: Set<string>,
31+
setTarget: (id: string) => void,
32+
): void {
33+
if (!Array.isArray(commands) || commands.length === 0) return;
34+
35+
if (Array.isArray(commands[0])) {
36+
for (const sub of commands) {
37+
collectSwitchCanvasTargets(sub, targets, setTarget);
38+
}
39+
} else {
40+
const cmdIndex = commands[0] as number;
41+
if (COMMANDS[cmdIndex] === "switchCanvas") {
42+
const args = commands[1] as string[] | undefined;
43+
const ref = args?.[0] ?? "";
44+
const targetId = ref.startsWith("IPY_MODEL_") ? ref.slice(10) : ref;
45+
setTarget(targetId);
46+
targets.add(targetId);
47+
}
48+
}
49+
}
50+
51+
/**
52+
* Set up message routing for a single CanvasManagerModel.
53+
* Returns a cleanup function to tear down the subscription.
54+
*/
55+
function setupManagerRouting(
56+
store: WidgetStore,
57+
managerId: string,
58+
): () => void {
59+
let currentTarget: string | null = null;
60+
61+
return store.subscribeToCustomMessage(managerId, (content, buffers) => {
62+
if (!buffers || buffers.length === 0) return;
63+
64+
try {
65+
const metadata = content as { dtype: string };
66+
const typedArray = getTypedArray(buffers[0], metadata);
67+
const jsonStr = new TextDecoder("utf-8").decode(typedArray);
68+
const commands = JSON.parse(jsonStr);
69+
70+
const targets = new Set<string>();
71+
collectSwitchCanvasTargets(commands, targets, (id) => {
72+
currentTarget = id;
73+
});
74+
75+
if (targets.size === 0 && currentTarget) {
76+
targets.add(currentTarget);
77+
}
78+
79+
const rawBuffers = buffers.map((dv) => dv.buffer as ArrayBuffer);
80+
for (const targetId of targets) {
81+
store.emitCustomMessage(targetId, content, rawBuffers);
82+
}
83+
} catch (err) {
84+
console.warn("[ipycanvas] Manager routing error:", err);
85+
}
86+
});
87+
}
88+
89+
/**
90+
* Create a canvas manager router that monitors the store for
91+
* CanvasManagerModel widgets and routes their messages to target canvases.
92+
*
93+
* Returns a cleanup function that tears down all active routing.
94+
*
95+
* Called automatically by WidgetStoreProvider. For non-React integrations
96+
* (e.g. iframe isolation), call this directly after creating the store.
97+
*/
98+
export function createCanvasManagerRouter(store: WidgetStore): () => void {
99+
const activeRoutes = new Map<string, () => void>();
100+
let lastSize = -1;
101+
102+
function scan() {
103+
const models = store.getSnapshot();
104+
105+
if (models.size === lastSize) return;
106+
lastSize = models.size;
107+
108+
models.forEach((model, id) => {
109+
if (activeRoutes.has(id)) return;
110+
111+
if (model.modelName === "CanvasManagerModel") {
112+
activeRoutes.set(id, setupManagerRouting(store, id));
113+
}
114+
});
115+
116+
for (const [id, cleanup] of activeRoutes) {
117+
if (!models.has(id)) {
118+
cleanup();
119+
activeRoutes.delete(id);
120+
}
121+
}
122+
}
123+
124+
const unsubscribe = store.subscribe(scan);
125+
scan();
126+
127+
return () => {
128+
unsubscribe();
129+
activeRoutes.forEach((cleanup) => cleanup());
130+
activeRoutes.clear();
131+
};
132+
}

registry/widgets/ipycanvas/canvas-widget.tsx

Lines changed: 12 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,15 @@
44
* ipycanvas Canvas widget.
55
*
66
* Renders an HTML <canvas> element and processes drawing commands sent from
7-
* Python via the ipycanvas binary protocol. Commands arrive as custom messages
8-
* on the CanvasManagerModel and are executed on the canvas 2D context.
7+
* Python via the ipycanvas binary protocol. Drawing commands arrive at each
8+
* canvas's comm_id, routed by createCanvasManagerRouter at the store level.
99
*
1010
* @see https://ipycanvas.readthedocs.io/
1111
*/
1212

1313
import { useCallback, useEffect, useRef } from "react";
1414
import { cn } from "@/lib/utils";
1515
import type { WidgetComponentProps } from "../widget-registry";
16-
import { parseModelRef } from "../widget-store";
1716
import {
1817
useWidgetModelValue,
1918
useWidgetStoreRequired,
@@ -29,16 +28,9 @@ export function CanvasWidget({ modelId, className }: WidgetComponentProps) {
2928
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
3029
// Track an async command processing chain so commands execute in order
3130
const processingRef = useRef<Promise<void>>(Promise.resolve());
32-
// Track which canvas the manager last targeted via switchCanvas.
33-
// In non-caching mode (the default, without hold_canvas()), each drawing
34-
// command is a separate custom message. We need to persist the switchCanvas
35-
// target across messages so each canvas only processes its own commands.
36-
const activeCanvasRef = useRef<string | null>(null);
3731

3832
const width = useWidgetModelValue<number>(modelId, "width") ?? 200;
3933
const height = useWidgetModelValue<number>(modelId, "height") ?? 200;
40-
const canvasManagerRef =
41-
useWidgetModelValue<string>(modelId, "_canvas_manager") ?? null;
4234
const sendClientReady =
4335
useWidgetModelValue<boolean>(modelId, "_send_client_ready_event") ?? true;
4436
const imageData = useWidgetModelValue<Uint8ClampedArray | null>(
@@ -67,18 +59,12 @@ export function CanvasWidget({ modelId, className }: WidgetComponentProps) {
6759
img.src = url;
6860
}, [imageData]);
6961

70-
// Subscribe to custom messages on the CanvasManagerModel, then send client_ready.
71-
// These must be in the same effect so the subscription is active before
72-
// Python replays drawing commands in response to client_ready.
62+
// Subscribe to messages routed to this canvas's comm_id, then send
63+
// client_ready. Routing is handled by createCanvasManagerRouter at the
64+
// store level — this widget just processes what it receives.
7365
useEffect(() => {
74-
if (!canvasManagerRef) return;
75-
76-
const managerModelId = parseModelRef(canvasManagerRef);
77-
if (!managerModelId) return;
78-
79-
// Subscribe FIRST
8066
const unsubscribe = store.subscribeToCustomMessage(
81-
managerModelId,
67+
modelId,
8268
(content, buffers) => {
8369
const canvas = canvasRef.current;
8470
const ctx = ctxRef.current;
@@ -96,39 +82,27 @@ export function CanvasWidget({ modelId, className }: WidgetComponentProps) {
9682
// Remaining buffers are binary data for batch operations
9783
const dataBuffers = buffers.slice(1);
9884

99-
// Determine if this canvas is the active target based on the
100-
// last switchCanvas we saw. Start inactive — a canvas should not
101-
// draw until switchCanvas explicitly targets it.
102-
const isActive = activeCanvasRef.current === modelId;
103-
104-
const result = await processCommands(
85+
await processCommands(
10586
ctx,
10687
commands,
10788
dataBuffers,
10889
canvas,
10990
modelId,
110-
isActive,
91+
true,
11192
);
112-
113-
// Persist switchCanvas target across messages
114-
if (result.switchedTo !== null) {
115-
activeCanvasRef.current = result.switchedTo;
116-
}
11793
} catch (err) {
11894
console.warn("[ipycanvas] Error processing commands:", err);
11995
}
12096
});
12197
},
12298
);
12399

124-
// THEN send client_ready — subscription is now active so
125-
// replayed commands from Python will be received
126100
if (sendClientReady) {
127101
sendCustom(modelId, { event: "client_ready" });
128102
}
129103

130104
return unsubscribe;
131-
}, [canvasManagerRef, store, modelId, sendClientReady, sendCustom]);
105+
}, [store, modelId, sendClientReady, sendCustom]);
132106

133107
// Mouse event helpers
134108
const getCoordinates = useCallback(
@@ -223,9 +197,9 @@ export function CanvasWidget({ modelId, className }: WidgetComponentProps) {
223197
// === CanvasManagerWidget ===
224198

225199
/**
226-
* Headless widget for CanvasManagerModel.
227-
* The manager coordinates drawing commands but has no visual representation.
228-
* Command processing is handled via custom message subscriptions from CanvasWidget.
200+
* Stub for CanvasManagerModel. Routing is handled at the store level
201+
* by createCanvasManagerRouter. CanvasManagerModel has _view_name: null
202+
* and is never rendered.
229203
*/
230204
export function CanvasManagerWidget(_props: WidgetComponentProps) {
231205
return null;

registry/widgets/ipycanvas/ipycanvas-commands.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,6 @@ function getArg(metadata: unknown, buffers: DataView[]): Arg {
213213
// === Drawing Helpers ===
214214

215215
function drawRects(
216-
ctx: CanvasRenderingContext2D,
217216
args: unknown[],
218217
buffers: DataView[],
219218
callback: (x: number, y: number, w: number, h: number) => void,
@@ -230,7 +229,6 @@ function drawRects(
230229
}
231230

232231
function drawCircles(
233-
ctx: CanvasRenderingContext2D,
234232
args: unknown[],
235233
buffers: DataView[],
236234
callback: (x: number, y: number, r: number) => void,
@@ -246,7 +244,6 @@ function drawCircles(
246244
}
247245

248246
function drawArcs(
249-
ctx: CanvasRenderingContext2D,
250247
args: unknown[],
251248
buffers: DataView[],
252249
callback: (
@@ -701,24 +698,24 @@ export async function processCommands(
701698

702699
// --- Batch drawing ---
703700
case "fillRects":
704-
drawRects(ctx, args, buffers, (x, y, w, h) => ctx.fillRect(x, y, w, h));
701+
drawRects(args, buffers, (x, y, w, h) => ctx.fillRect(x, y, w, h));
705702
break;
706703
case "strokeRects":
707-
drawRects(ctx, args, buffers, (x, y, w, h) => ctx.strokeRect(x, y, w, h));
704+
drawRects(args, buffers, (x, y, w, h) => ctx.strokeRect(x, y, w, h));
708705
break;
709706
case "fillCircles":
710-
drawCircles(ctx, args, buffers, (x, y, r) => fillCircle(ctx, x, y, r));
707+
drawCircles(args, buffers, (x, y, r) => fillCircle(ctx, x, y, r));
711708
break;
712709
case "strokeCircles":
713-
drawCircles(ctx, args, buffers, (x, y, r) => strokeCircle(ctx, x, y, r));
710+
drawCircles(args, buffers, (x, y, r) => strokeCircle(ctx, x, y, r));
714711
break;
715712
case "fillArcs":
716-
drawArcs(ctx, args, buffers, (x, y, r, sa, ea, ac) =>
713+
drawArcs(args, buffers, (x, y, r, sa, ea, ac) =>
717714
fillArc(ctx, x, y, r, sa, ea, ac),
718715
);
719716
break;
720717
case "strokeArcs":
721-
drawArcs(ctx, args, buffers, (x, y, r, sa, ea, ac) =>
718+
drawArcs(args, buffers, (x, y, r, sa, ea, ac) =>
722719
strokeArc(ctx, x, y, r, sa, ea, ac),
723720
);
724721
break;

registry/widgets/widget-store-context.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
useRef,
2222
useSyncExternalStore,
2323
} from "react";
24+
import { createCanvasManagerRouter } from "./canvas-manager-subscriptions";
2425
import { createLinkManager } from "./link-subscriptions";
2526
import {
2627
type JupyterCommMessage,
@@ -91,6 +92,7 @@ export function WidgetStoreProvider({
9192
// Headless widgets like LinkModel have _view_name: null and won't be
9293
// in any container's children, so they need store-level subscriptions.
9394
useEffect(() => createLinkManager(store), [store]);
95+
useEffect(() => createCanvasManagerRouter(store), [store]);
9496

9597
// Use the comm router hook for message handling
9698
const { handleMessage, sendUpdate, sendCustom, closeComm } = useCommRouter({
@@ -235,7 +237,8 @@ export function useWasWidgetClosed(modelId: string): boolean {
235237
return store.wasModelClosed(modelId);
236238
}
237239

238-
// Re-export link manager for non-React integrations (e.g. iframe isolation)
240+
// Re-export store-level managers for non-React integrations (e.g. iframe isolation)
241+
export { createCanvasManagerRouter } from "./canvas-manager-subscriptions";
239242
export { createLinkManager } from "./link-subscriptions";
240243
export type {
241244
JupyterCommMessage,

0 commit comments

Comments
 (0)