From 0522d5ddb0b12851c56e30a5fe4a05f98a59b225 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Wed, 11 Mar 2026 14:37:49 +0100 Subject: [PATCH 1/6] Jupyter Widgets --- README.md | 35 + examples/widgets.ipynb | 362 ++++++ packages/javascript-kernel/src/comm.ts | 205 ++++ packages/javascript-kernel/src/index.ts | 2 + packages/javascript-kernel/src/kernel.ts | 91 +- .../javascript-kernel/src/runtime_backends.ts | 59 + .../src/runtime_evaluator.ts | 132 ++- .../javascript-kernel/src/runtime_protocol.ts | 45 + .../javascript-kernel/src/runtime_remote.ts | 29 + packages/javascript-kernel/src/widgets.ts | 1024 +++++++++++++++++ 10 files changed, 1967 insertions(+), 17 deletions(-) create mode 100644 examples/widgets.ipynb create mode 100644 packages/javascript-kernel/src/comm.ts create mode 100644 packages/javascript-kernel/src/widgets.ts diff --git a/README.md b/README.md index d1f8eac..8e744d7 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,41 @@ Limits of automatic cleanup: - It will not automatically remove all event listeners or timers. - It cannot safely revert all stateful third-party module internals. +## Jupyter Widgets + +The kernel provides built-in support for [Jupyter Widgets](https://ipywidgets.readthedocs.io/) (`ipywidgets`-compatible). Widget classes are available as globals in the kernel runtime — just instantiate and call `.display()`: + +```javascript +const slider = new IntSlider({ + value: 50, + min: 0, + max: 100, + description: 'My Slider' +}); +display(slider); + +slider.on('change:value', (newVal) => { + console.log('Slider value:', newVal); +}); +``` + +Widgets auto-display when they are the last expression in a cell. Use the global `display()` function to display a widget explicitly, for example when assigning to a variable. + +### Available widgets + +- **Numeric**: `IntSlider`, `FloatSlider`, `IntProgress`, `FloatProgress`, `IntText`, `FloatText`, `BoundedIntText`, `BoundedFloatText` +- **Boolean**: `Checkbox`, `ToggleButton`, `Valid` +- **Selection**: `Dropdown`, `RadioButtons`, `Select`, `ToggleButtons`, `SelectionSlider` +- **String**: `Text`, `Textarea`, `Password`, `Combobox` +- **Display**: `Label`, `HTML`, `HTMLMath` +- **Button**: `Button` (with `.onClick()` handler) +- **Color**: `ColorPicker` +- **Containers**: `Box`, `HBox`, `VBox`, `GridBox`, `Accordion`, `Tab`, `Stack` + +> **Note:** `jupyterlab-widgets` and `@jupyter-widgets/controls` must be available in the JupyterLite deployment for widgets to render. + +See the [example notebook](examples/widgets.ipynb) for more usage examples. + ### Enable or disable specific modes The two runtime modes are registered by separate plugins: diff --git a/examples/widgets.ipynb b/examples/widgets.ipynb new file mode 100644 index 0000000..e2c3b8c --- /dev/null +++ b/examples/widgets.ipynb @@ -0,0 +1,362 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Jupyter Widgets\n\nThe JavaScript kernel provides built-in widget classes that mirror the\n[ipywidgets](https://ipywidgets.readthedocs.io/) API. Widget classes are\navailable as globals — just instantiate and configure.\n\nWidgets **auto-display** when they are the last expression in a cell.\nYou can also call the global `display()` function explicitly, e.g. when assigning to a variable.\n\n> **Requirements:** `jupyterlab-widgets` and `@jupyter-widgets/controls` must\n> be available in the JupyterLite deployment." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Sliders" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "const slider = new IntSlider({\n value: 50,\n min: 0,\n max: 100,\n step: 1,\n description: 'Int Slider'\n});\ndisplay(slider);\n\nslider.on('change:value', (newVal) => {\n console.log('Slider value:', newVal);\n});" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new FloatSlider({\n value: 3.14,\n min: 0.0,\n max: 10.0,\n step: 0.01,\n description: 'Float Slider',\n readout_format: '.2f'\n})" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new SelectionSlider({\n options: ['Freezing', 'Cold', 'Warm', 'Hot', 'Blazing'],\n index: 2,\n description: 'Temp'\n})" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Progress Bars" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "const progress = new IntProgress({\n value: 0,\n min: 0,\n max: 100,\n description: 'Loading',\n bar_style: 'info'\n});\ndisplay(progress);\n\n// Animate the progress bar\nconst interval = setInterval(() => {\n progress.value += 5;\n if (progress.value >= 100) {\n clearInterval(interval);\n progress.bar_style = 'success';\n progress.description = 'Done';\n }\n}, 200);" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Numeric Inputs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new IntText({ value: 42, description: 'Integer', step: 1 })" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new FloatText({ value: 3.14, description: 'Float', step: 0.1 })" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Text Inputs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "const name = new Text({\n value: '',\n description: 'Name',\n placeholder: 'Type your name'\n});\ndisplay(name);\n\nname.on('change:value', (v) => console.log('Name:', v));" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new Textarea({\n value: '',\n description: 'Notes',\n placeholder: 'Enter some text...'\n})" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new Password({\n description: 'Secret',\n placeholder: 'Enter password'\n})" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Boolean Widgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "const cb = new Checkbox({\n value: false,\n description: 'Enable feature',\n indent: false\n});\ndisplay(cb);\n\ncb.on('change:value', (v) => console.log('Checked:', v));" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new ToggleButton({\n value: false,\n description: 'Toggle me',\n tooltip: 'Click to toggle',\n button_style: 'info'\n})" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new Valid({ value: true, description: 'Status', readout: 'All checks passed' })" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new Valid({ value: false, description: 'Status', readout: 'Something went wrong' })" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Selection Widgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "const dropdown = new Dropdown({\n options: ['Apple', 'Banana', 'Cherry', 'Date'],\n index: 0,\n description: 'Fruit'\n});\ndisplay(dropdown);\n\ndropdown.on('change:index', () => {\n console.log('Selected:', dropdown.selectedLabel);\n});" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new RadioButtons({\n options: ['Small', 'Medium', 'Large'],\n index: 1,\n description: 'Size'\n})" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new Select({\n options: ['Linux', 'macOS', 'Windows'],\n index: 1,\n description: 'OS',\n rows: 3\n})" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new ToggleButtons({\n options: ['Slow', 'Medium', 'Fast'],\n index: 1,\n description: 'Speed',\n tooltips: ['Turtle pace', 'Normal speed', 'Lightning fast']\n})" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Button\n", + "\n", + "Buttons fire `'click'` events instead of holding a value:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "let clickCount = 0;\n\nconst btn = new Button({\n description: 'Click me!',\n button_style: 'primary',\n tooltip: 'Go ahead, click it'\n});\ndisplay(btn);\n\nbtn.onClick(() => {\n clickCount++;\n console.log(`Clicked ${clickCount} time(s)`);\n});" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Display Widgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new HTML({\n value: '
Hello from an HTML widget!
Rendered by ipywidgets.
'\n})" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new Label({ value: 'This is a Label widget.' })" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Color Picker" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "const picker = new ColorPicker({\n value: '#4ecdc4',\n description: 'Color',\n concise: false\n});\ndisplay(picker);\n\npicker.on('change:value', (v) => console.log('Color:', v));" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Programmatic Updates\n", + "\n", + "Widget properties can be read and written at any time. Changes are\n", + "automatically synced with the frontend:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Read the current slider value\n", + "console.log('Current slider value:', slider.value);\n", + "\n", + "// Update it programmatically\n", + "slider.value = 75;\n", + "slider.description = 'Updated!';" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Linking Widgets\n", + "\n", + "Connect widgets together by listening for changes:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "const source = new IntSlider({ value: 50, description: 'Source' });\nconst mirror = new IntProgress({ value: 50, description: 'Mirror' });\nconst label = new Label({ value: 'Value: 50' });\ndisplay(source);\ndisplay(mirror);\ndisplay(label);\n\nsource.on('change:value', (v) => {\n mirror.value = v;\n label.value = `Value: ${v}`;\n});" + }, + { + "cell_type": "markdown", + "id": "efiw9mng9a6", + "source": "## Container / Layout Widgets\n\nContainer widgets group other widgets together and control their layout.\nThey accept a `children` array of widget instances.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "jdf53ak5lo", + "source": "// VBox arranges children vertically\nnew VBox({\n children: [\n new IntSlider({ value: 30, description: 'Slider A' }),\n new IntSlider({ value: 60, description: 'Slider B' }),\n new IntSlider({ value: 90, description: 'Slider C' })\n ]\n})", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "8889x2ewlx9", + "source": "// HBox arranges children horizontally\nnew HBox({\n children: [\n new Button({ description: 'Left', button_style: 'info' }),\n new Button({ description: 'Center', button_style: 'warning' }),\n new Button({ description: 'Right', button_style: 'danger' })\n ]\n})", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "g5mlio5w7jg", + "source": "// Tab shows one child at a time with tab headers\nnew Tab({\n children: [\n new IntSlider({ value: 50, description: 'Slider' }),\n new Text({ value: 'Hello', description: 'Name' }),\n new ColorPicker({ value: '#e74c3c', description: 'Color' })\n ],\n titles: ['Slider', 'Text', 'Color']\n})", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "pf5mvpnv12", + "source": "// Accordion shows one child at a time with collapsible sections\nnew Accordion({\n children: [\n new VBox({\n children: [\n new IntSlider({ description: 'X' }),\n new IntSlider({ description: 'Y' })\n ]\n }),\n new HBox({\n children: [\n new ToggleButton({ description: 'A', button_style: 'success' }),\n new ToggleButton({ description: 'B', button_style: 'info' })\n ]\n })\n ],\n titles: ['Sliders', 'Toggles']\n})", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "ug1fid6455", + "source": "// Stack displays only the selected child (controlled programmatically)\nconst stack = new Stack({\n children: [\n new HTML({ value: '

Page 1

First page content

' }),\n new HTML({ value: '

Page 2

Second page content

' }),\n new HTML({ value: '

Page 3

Third page content

' })\n ],\n titles: ['Page 1', 'Page 2', 'Page 3'],\n selected_index: 0\n});\ndisplay(stack);\n\n// Switch pages with a dropdown\nconst pagePicker = new Dropdown({\n options: ['Page 1', 'Page 2', 'Page 3'],\n description: 'Show page'\n});\ndisplay(pagePicker);\n\npagePicker.on('change:index', () => {\n stack.selected_index = pagePicker.index;\n});", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Low-level Comm API\n", + "\n", + "The widget classes above are built on `Jupyter.comm`, the raw comm protocol\n", + "API. You can use it directly for custom frontend↔kernel communication.\n", + "\n", + "> Custom target names that have no frontend handler will show\n", + "> `\"Exception opening new comm\"` in the browser console — this is expected." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Open a raw comm channel\n", + "const comm = Jupyter.comm.open('my.custom.target', { greeting: 'hello' });\n", + "console.log('Comm ID:', comm.commId);\n", + "\n", + "// Send / receive / close\n", + "comm.send({ method: 'update', state: { value: 42 } });\n", + "comm.onMsg = (data) => console.log('Received:', data);\n", + "comm.close();" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Register a target for frontend-initiated comms\n", + "Jupyter.comm.registerTarget('my.echo', (comm, data) => {\n", + " comm.onMsg = (msg) => comm.send({ echo: msg });\n", + "});" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "JavaScript", + "language": "javascript", + "name": "javascript" + }, + "language_info": { + "name": "javascript" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/packages/javascript-kernel/src/comm.ts b/packages/javascript-kernel/src/comm.ts new file mode 100644 index 0000000..6a0f21a --- /dev/null +++ b/packages/javascript-kernel/src/comm.ts @@ -0,0 +1,205 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import type { RuntimeOutputHandler } from './runtime_protocol'; + +/** + * Represents an open comm channel. + */ +export interface IComm { + readonly commId: string; + readonly targetName: string; + send( + data: Record, + metadata?: Record, + buffers?: ArrayBuffer[] + ): void; + close(data?: Record): void; + display(): void; + onMsg: + | ((data: Record, buffers?: ArrayBuffer[]) => void) + | null; + onClose: + | ((data: Record, buffers?: ArrayBuffer[]) => void) + | null; +} + +/** + * Handler invoked when the frontend opens a comm targeting a registered name. + */ +export type CommTargetHandler = ( + comm: IComm, + data: Record, + buffers?: ArrayBuffer[] +) => void; + +/** + * Manages comm lifecycle within the runtime. + */ +export class CommManager { + constructor(onOutput: RuntimeOutputHandler) { + this._onOutput = onOutput; + } + + /** + * Open a new comm channel. + */ + open( + targetName: string, + data: Record = {}, + metadata: Record = {}, + buffers?: ArrayBuffer[], + commId?: string + ): IComm { + const id = commId ?? crypto.randomUUID(); + const comm = this._createComm(id, targetName); + this._comms.set(id, comm); + + this._onOutput({ + type: 'comm_open', + content: { + comm_id: id, + target_name: targetName, + data + }, + metadata, + buffers + }); + + return comm; + } + + /** + * Register a handler for frontend-initiated comms targeting the given name. + */ + registerTarget(targetName: string, handler: CommTargetHandler): void { + this._targets.set(targetName, handler); + } + + /** + * Handle a comm_open message from the frontend. + */ + handleCommOpen( + commId: string, + targetName: string, + data: Record, + buffers?: ArrayBuffer[] + ): void { + const handler = this._targets.get(targetName); + if (!handler) { + console.warn( + `[javascript-kernel] No handler registered for comm target "${targetName}"` + ); + return; + } + const comm = this._createComm(commId, targetName); + this._comms.set(commId, comm); + handler(comm, data, buffers); + } + + /** + * Handle a comm_msg message from the frontend. + */ + handleCommMsg( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): void { + const comm = this._comms.get(commId); + comm?.onMsg?.(data, buffers); + } + + /** + * Handle a comm_close message from the frontend. + */ + handleCommClose( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): void { + const comm = this._comms.get(commId); + if (comm) { + comm.onClose?.(data, buffers); + this._comms.delete(commId); + } + } + + /** + * Display a widget identified by its comm ID. + */ + displayWidget(commId: string): void { + this._onOutput({ + type: 'display_data', + bundle: { + data: { + 'text/plain': 'Widget', + 'application/vnd.jupyter.widget-view+json': { + version_major: 2, + version_minor: 0, + model_id: commId + } + }, + metadata: {}, + transient: {} + } + }); + } + + /** + * Dispose all comms and clear state. + */ + dispose(): void { + this._comms.clear(); + this._targets.clear(); + } + + /** + * Create an IComm instance bound to this manager. + */ + private _createComm(commId: string, targetName: string): IComm { + const manager = this; + const comm: IComm = { + get commId() { + return commId; + }, + get targetName() { + return targetName; + }, + send( + data: Record, + metadata?: Record, + buffers?: ArrayBuffer[] + ): void { + manager._onOutput({ + type: 'comm_msg', + content: { + comm_id: commId, + data + }, + metadata, + buffers + }); + }, + close(data: Record = {}): void { + manager._onOutput({ + type: 'comm_close', + content: { + comm_id: commId, + data + } + }); + manager._comms.delete(commId); + }, + display(): void { + manager.displayWidget(commId); + }, + onMsg: null, + onClose: null + }; + return comm; + } + + private _onOutput: RuntimeOutputHandler; + private _comms = new Map(); + private _targets = new Map(); +} diff --git a/packages/javascript-kernel/src/index.ts b/packages/javascript-kernel/src/index.ts index c421980..5ee10b4 100644 --- a/packages/javascript-kernel/src/index.ts +++ b/packages/javascript-kernel/src/index.ts @@ -7,3 +7,5 @@ export * from './display'; export * from './runtime_protocol'; export * from './runtime_backends'; export * from './runtime_evaluator'; +export * from './comm'; +export * from './widgets'; diff --git a/packages/javascript-kernel/src/kernel.ts b/packages/javascript-kernel/src/kernel.ts index 543e0cf..974a2af 100644 --- a/packages/javascript-kernel/src/kernel.ts +++ b/packages/javascript-kernel/src/kernel.ts @@ -38,6 +38,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { return; } + this._comms.clear(); this._backend.dispose(); super.dispose(); } @@ -187,9 +188,15 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { async commInfoRequest( content: KernelMessage.ICommInfoRequestMsg['content'] ): Promise { + const comms: Record = {}; + for (const [commId, info] of this._comms) { + if (!content.target_name || info.target_name === content.target_name) { + comms[commId] = { target_name: info.target_name }; + } + } return { status: 'ok', - comms: {} + comms }; } @@ -204,21 +211,42 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { * Send an `comm_open` message. */ async commOpen(msg: KernelMessage.ICommOpenMsg): Promise { - this._logUnsupportedControlMessage('comm_open', msg.content.target_name); + const { comm_id, target_name, data } = msg.content; + this._comms.set(comm_id, { target_name }); + const buffers = this._toArrayBuffers(msg.buffers); + await this._backend.handleCommOpen( + comm_id, + target_name, + data as Record, + buffers + ); } /** * Send an `comm_msg` message. */ async commMsg(msg: KernelMessage.ICommMsgMsg): Promise { - this._logUnsupportedControlMessage('comm_msg'); + const { comm_id, data } = msg.content; + const buffers = this._toArrayBuffers(msg.buffers); + await this._backend.handleCommMsg( + comm_id, + data as Record, + buffers + ); } /** * Send an `comm_close` message. */ async commClose(msg: KernelMessage.ICommCloseMsg): Promise { - this._logUnsupportedControlMessage('comm_close'); + const { comm_id, data } = msg.content; + this._comms.delete(comm_id); + const buffers = this._toArrayBuffers(msg.buffers); + await this._backend.handleCommClose( + comm_id, + data as Record, + buffers + ); } /** @@ -306,6 +334,37 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { case 'execute_error': this.publishExecuteError(message.bundle, parentHeader); break; + case 'comm_open': + this._comms.set(message.content.comm_id, { + target_name: message.content.target_name + }); + this.handleComm( + 'comm_open', + message.content as any, + (message.metadata ?? {}) as any, + message.buffers as any, + parentHeader + ); + break; + case 'comm_msg': + this.handleComm( + 'comm_msg', + message.content as any, + (message.metadata ?? {}) as any, + message.buffers as any, + parentHeader + ); + break; + case 'comm_close': + this._comms.delete(message.content.comm_id); + this.handleComm( + 'comm_close', + message.content as any, + (message.metadata ?? {}) as any, + message.buffers as any, + parentHeader + ); + break; default: break; } @@ -346,11 +405,28 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { return error; } + /** + * Convert incoming message buffers to ArrayBuffer for Comlink transport. + */ + private _toArrayBuffers( + buffers?: (ArrayBuffer | ArrayBufferView)[] + ): ArrayBuffer[] | undefined { + if (!buffers || buffers.length === 0) { + return undefined; + } + return buffers.map(buf => { + if (ArrayBuffer.isView(buf)) { + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + } + return buf; + }); + } + /** * Warn once per unsupported control message type to avoid noisy consoles. */ private _logUnsupportedControlMessage( - type: 'input_reply' | 'comm_open' | 'comm_msg' | 'comm_close', + type: 'input_reply', detail?: string ): void { if (this._unsupportedControlMessages.has(type)) { @@ -365,9 +441,8 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { ); } - private _unsupportedControlMessages = new Set< - 'input_reply' | 'comm_open' | 'comm_msg' | 'comm_close' - >(); + private _unsupportedControlMessages = new Set<'input_reply'>(); + private _comms = new Map(); private _backend: IRuntimeBackend; private _executorFactory?: JavaScriptKernel.IExecutorFactory; private _runtimeMode: RuntimeMode; diff --git a/packages/javascript-kernel/src/runtime_backends.ts b/packages/javascript-kernel/src/runtime_backends.ts index 1116010..b0ce77d 100644 --- a/packages/javascript-kernel/src/runtime_backends.ts +++ b/packages/javascript-kernel/src/runtime_backends.ts @@ -48,6 +48,22 @@ export interface IRuntimeBackend { isComplete( code: string ): Promise; + handleCommOpen( + commId: string, + targetName: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise; + handleCommMsg( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise; + handleCommClose( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise; } /** @@ -110,6 +126,49 @@ abstract class AbstractRuntimeBackend implements IRuntimeBackend { return this._getRemote().isComplete(code); } + /** + * Forward comm_open to the remote runtime. + */ + async handleCommOpen( + commId: string, + targetName: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise { + await this.ready; + if (this._remote) { + await this._remote.handleCommOpen(commId, targetName, data, buffers); + } + } + + /** + * Forward comm_msg to the remote runtime. + */ + async handleCommMsg( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise { + await this.ready; + if (this._remote) { + await this._remote.handleCommMsg(commId, data, buffers); + } + } + + /** + * Forward comm_close to the remote runtime. + */ + async handleCommClose( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise { + await this.ready; + if (this._remote) { + await this._remote.handleCommClose(commId, data, buffers); + } + } + /** * Return remote runtime API or throw when not initialized. */ diff --git a/packages/javascript-kernel/src/runtime_evaluator.ts b/packages/javascript-kernel/src/runtime_evaluator.ts index d77c917..0a0bca1 100644 --- a/packages/javascript-kernel/src/runtime_evaluator.ts +++ b/packages/javascript-kernel/src/runtime_evaluator.ts @@ -5,7 +5,9 @@ import type { KernelMessage } from '@jupyterlab/services'; import { JavaScriptExecutor } from './executor'; import { normalizeError } from './errors'; +import { CommManager } from './comm'; import type { RuntimeOutputHandler } from './runtime_protocol'; +import { Widget, widgetClasses } from './widgets'; /** * Shared execution logic for iframe and worker runtime backends. @@ -20,6 +22,9 @@ export class JavaScriptRuntimeEvaluator { this._executor = options.executor ?? new JavaScriptExecutor(options.globalScope); + this._commManager = new CommManager(options.onOutput); + this._setupJupyterGlobal(); + this._setupWidgets(); this._setupDisplay(); this._setupConsoleOverrides(); } @@ -30,6 +35,9 @@ export class JavaScriptRuntimeEvaluator { dispose(): void { this._restoreConsoleOverrides(); this._restoreDisplay(); + this._restoreWidgets(); + this._restoreJupyterGlobal(); + this._commManager.dispose(); } /** @@ -73,15 +81,19 @@ export class JavaScriptRuntimeEvaluator { const result = await resultPromise; if (result !== undefined) { - const data = this._executor.getMimeBundle(result); - this._onOutput({ - type: 'execute_result', - bundle: { - execution_count: executionCount, - data, - metadata: {} - } - }); + if (result instanceof Widget) { + this._commManager.displayWidget(result.commId); + } else { + const data = this._executor.getMimeBundle(result); + this._onOutput({ + type: 'execute_result', + bundle: { + execution_count: executionCount, + data, + metadata: {} + } + }); + } } } else { await resultPromise; @@ -134,6 +146,47 @@ export class JavaScriptRuntimeEvaluator { return this._executor.isComplete(code); } + /** + * The comm manager instance. + */ + get commManager(): CommManager { + return this._commManager; + } + + /** + * Handle a comm_open message from the frontend. + */ + handleCommOpen( + commId: string, + targetName: string, + data: Record, + buffers?: ArrayBuffer[] + ): void { + this._commManager.handleCommOpen(commId, targetName, data, buffers); + } + + /** + * Handle a comm_msg message from the frontend. + */ + handleCommMsg( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): void { + this._commManager.handleCommMsg(commId, data, buffers); + } + + /** + * Handle a comm_close message from the frontend. + */ + handleCommClose( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): void { + this._commManager.handleCommClose(commId, data, buffers); + } + /** * Evaluate an async function within the configured global scope. */ @@ -283,6 +336,11 @@ export class JavaScriptRuntimeEvaluator { this._previousDisplay = this._globalScope.display; this._globalScope.display = (obj: any, metadata?: Record) => { + if (obj instanceof Widget) { + this._commManager.displayWidget(obj.commId); + return; + } + const data = this._executor.getMimeBundle(obj); this._onOutput({ @@ -308,9 +366,65 @@ export class JavaScriptRuntimeEvaluator { this._globalScope.display = this._previousDisplay; } + /** + * Install widget classes in the runtime global scope. + */ + private _setupWidgets(): void { + Widget.setDefaultManager(this._commManager); + + this._previousWidgetGlobals = {}; + for (const [name, cls] of Object.entries(widgetClasses)) { + this._previousWidgetGlobals[name] = this._globalScope[name]; + this._globalScope[name] = cls; + } + } + + /** + * Remove widget classes from the global scope and reset the manager. + */ + private _restoreWidgets(): void { + Widget.setDefaultManager(null); + + if (this._previousWidgetGlobals) { + for (const [name, prev] of Object.entries(this._previousWidgetGlobals)) { + if (prev === undefined) { + delete this._globalScope[name]; + } else { + this._globalScope[name] = prev; + } + } + this._previousWidgetGlobals = null; + } + } + + /** + * Install Jupyter.comm in runtime scope. + */ + private _setupJupyterGlobal(): void { + this._previousJupyter = this._globalScope.Jupyter; + this._globalScope.Jupyter = { + comm: this._commManager, + widgets: widgetClasses + }; + } + + /** + * Restore previous Jupyter binding. + */ + private _restoreJupyterGlobal(): void { + if (this._previousJupyter === undefined) { + delete this._globalScope.Jupyter; + return; + } + this._globalScope.Jupyter = this._previousJupyter; + } + private _globalScope: Record; private _onOutput: RuntimeOutputHandler; private _executor: JavaScriptExecutor; + private _commManager: CommManager; + private _previousWidgetGlobals: Record | null = null; + private _previousJupyter: any; private _previousDisplay: any; private _originalOnError: any; private _originalConsole: { diff --git a/packages/javascript-kernel/src/runtime_protocol.ts b/packages/javascript-kernel/src/runtime_protocol.ts index b5f1fee..3612c30 100644 --- a/packages/javascript-kernel/src/runtime_protocol.ts +++ b/packages/javascript-kernel/src/runtime_protocol.ts @@ -40,6 +40,35 @@ export type RuntimeOutputMessage = | { type: 'execute_error'; bundle: KernelMessage.IReplyErrorContent; + } + | { + type: 'comm_open'; + content: { + comm_id: string; + target_name: string; + data: Record; + target_module?: string; + }; + metadata?: Record; + buffers?: ArrayBuffer[]; + } + | { + type: 'comm_msg'; + content: { + comm_id: string; + data: Record; + }; + metadata?: Record; + buffers?: ArrayBuffer[]; + } + | { + type: 'comm_close'; + content: { + comm_id: string; + data: Record; + }; + metadata?: Record; + buffers?: ArrayBuffer[]; }; /** @@ -78,5 +107,21 @@ export interface IRemoteRuntimeApi { isComplete( code: string ): Promise; + handleCommOpen( + commId: string, + targetName: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise; + handleCommMsg( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise; + handleCommClose( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise; dispose(): Promise; } diff --git a/packages/javascript-kernel/src/runtime_remote.ts b/packages/javascript-kernel/src/runtime_remote.ts index 68aafc1..8005819 100644 --- a/packages/javascript-kernel/src/runtime_remote.ts +++ b/packages/javascript-kernel/src/runtime_remote.ts @@ -71,6 +71,31 @@ export function createRemoteRuntimeApi( return ensureEvaluator().isComplete(code); }, + async handleCommOpen( + commId: string, + targetName: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise { + ensureEvaluator().handleCommOpen(commId, targetName, data, buffers); + }, + + async handleCommMsg( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise { + ensureEvaluator().handleCommMsg(commId, data, buffers); + }, + + async handleCommClose( + commId: string, + data: Record, + buffers?: ArrayBuffer[] + ): Promise { + ensureEvaluator().handleCommClose(commId, data, buffers); + }, + async dispose(): Promise { evaluator?.dispose(); evaluator = null; @@ -120,6 +145,10 @@ function sanitize(value: any, seen: WeakSet, depth: number): any { return String(value); } + if (value instanceof ArrayBuffer) { + return value; + } + if (value instanceof Error) { return { name: value.name, diff --git a/packages/javascript-kernel/src/widgets.ts b/packages/javascript-kernel/src/widgets.ts new file mode 100644 index 0000000..7c2ee52 --- /dev/null +++ b/packages/javascript-kernel/src/widgets.ts @@ -0,0 +1,1024 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import type { CommManager, IComm } from './comm'; + +const WIDGET_PROTOCOL_VERSION = '2.1.0'; +const CONTROLS_MODULE = '@jupyter-widgets/controls'; +const CONTROLS_MODULE_VERSION = '2.0.0'; + +type WidgetEventCallback = (...args: any[]) => void; + +/** + * Base class for Jupyter widgets. + * + * Wraps the low-level comm protocol so user code can work with + * familiar property access and change events instead of raw messages. + */ +export class Widget { + static modelName = ''; + static viewName = ''; + + private static _defaultManager: CommManager | null = null; + + /** + * Set the default CommManager used by all Widget instances. + */ + static setDefaultManager(manager: CommManager | null): void { + Widget._defaultManager = manager; + } + + constructor(state?: Record) { + const manager = Widget._defaultManager; + if (!manager) { + throw new Error( + 'Widget manager not initialized. Widgets can only be created inside the kernel runtime.' + ); + } + + const ctor = this.constructor as typeof Widget; + this._state = { + ...this._defaults(), + ...state, + _model_name: ctor.modelName, + _model_module: CONTROLS_MODULE, + _model_module_version: CONTROLS_MODULE_VERSION, + _view_name: ctor.viewName, + _view_module: CONTROLS_MODULE, + _view_module_version: CONTROLS_MODULE_VERSION + }; + this._listeners = new Map(); + + this._comm = manager.open( + 'jupyter.widget', + { state: this._state, buffer_paths: [] }, + { version: WIDGET_PROTOCOL_VERSION } + ); + + this._comm.onMsg = (data, buffers) => { + this._handleMsg(data, buffers); + }; + this._comm.onClose = data => { + this._trigger('close', data); + }; + } + + /** + * Close the widget and its comm channel. + */ + close(): void { + this._comm.close(); + } + + /** + * Get a state property. + */ + get(name: string): unknown { + return this._state[name]; + } + + /** + * Set one or more state properties and sync to the frontend. + */ + set(name: string, value: unknown): void; + set(state: Record): void; + set(nameOrState: string | Record, value?: unknown): void { + const updates: Record = + typeof nameOrState === 'string' + ? { [nameOrState]: value } + : nameOrState; + + const changes: Array<[string, unknown, unknown]> = []; + for (const [key, val] of Object.entries(updates)) { + const old = this._state[key]; + if (old !== val) { + this._state[key] = val; + changes.push([key, val, old]); + } + } + + if (changes.length > 0) { + this._comm.send({ + method: 'update', + state: updates, + buffer_paths: [] + }); + for (const [key, val, old] of changes) { + this._trigger(`change:${key}`, val, old); + } + this._trigger('change', changes); + } + } + + /** + * Listen for widget events. + * + * Events: + * - `'change:propName'` — property changed `(newValue, oldValue)` + * - `'change'` — any property changed `(changes)` + * - `'close'` — comm closed + */ + on(event: string, callback: WidgetEventCallback): this { + if (!this._listeners.has(event)) { + this._listeners.set(event, new Set()); + } + this._listeners.get(event)!.add(callback); + return this; + } + + /** + * Remove an event listener. + */ + off(event: string, callback: WidgetEventCallback): this { + this._listeners.get(event)?.delete(callback); + return this; + } + + /** + * The widget's comm/model ID. + */ + get commId(): string { + return this._comm.commId; + } + + get description(): string { + return this.get('description') as string; + } + set description(v: string) { + this.set('description', v); + } + + get disabled(): boolean { + return this.get('disabled') as boolean; + } + set disabled(v: boolean) { + this.set('disabled', v); + } + + protected _defaults(): Record { + return { description: '', disabled: false }; + } + + protected _handleMsg( + data: Record, + buffers?: ArrayBuffer[] + ): void { + if (data.method === 'update' && data.state) { + const state = data.state as Record; + const changes: Array<[string, unknown, unknown]> = []; + for (const [key, val] of Object.entries(state)) { + const old = this._state[key]; + if (old !== val) { + this._state[key] = val; + changes.push([key, val, old]); + } + } + for (const [key, val, old] of changes) { + this._trigger(`change:${key}`, val, old); + } + if (changes.length > 0) { + this._trigger('change', changes); + } + } + + if (data.method === 'request_state') { + this._comm.send({ + method: 'update', + state: this._state, + buffer_paths: [] + }); + } + } + + protected _trigger(event: string, ...args: unknown[]): void { + for (const cb of this._listeners.get(event) ?? []) { + try { + cb(...args); + } catch (e) { + console.error(`[Widget] Error in '${event}' handler:`, e); + } + } + } + + protected _comm: IComm; + protected _state: Record; + private _listeners: Map>; +} + +// --------------------------------------------------------------------------- +// Numeric — sliders +// --------------------------------------------------------------------------- + +class _SliderBase extends Widget { + protected _defaults() { + return { + ...super._defaults(), + value: 0, + min: 0, + max: 100, + step: 1, + orientation: 'horizontal', + readout: true + }; + } + + get value(): number { + return this.get('value') as number; + } + set value(v: number) { + this.set('value', v); + } + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } + get step(): number { + return this.get('step') as number; + } + set step(v: number) { + this.set('step', v); + } + get orientation(): string { + return this.get('orientation') as string; + } + set orientation(v: string) { + this.set('orientation', v); + } + get readout(): boolean { + return this.get('readout') as boolean; + } + set readout(v: boolean) { + this.set('readout', v); + } +} + +export class IntSlider extends _SliderBase { + static override modelName = 'IntSliderModel'; + static override viewName = 'IntSliderView'; +} + +export class FloatSlider extends _SliderBase { + static override modelName = 'FloatSliderModel'; + static override viewName = 'FloatSliderView'; + + protected override _defaults() { + return { ...super._defaults(), max: 10.0, step: 0.1, readout_format: '.2f' }; + } + + get readout_format(): string { + return this.get('readout_format') as string; + } + set readout_format(v: string) { + this.set('readout_format', v); + } +} + +// --------------------------------------------------------------------------- +// Numeric — progress +// --------------------------------------------------------------------------- + +class _ProgressBase extends Widget { + protected _defaults() { + return { + ...super._defaults(), + value: 0, + min: 0, + max: 100, + bar_style: '', + orientation: 'horizontal' + }; + } + + get value(): number { + return this.get('value') as number; + } + set value(v: number) { + this.set('value', v); + } + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } + get bar_style(): string { + return this.get('bar_style') as string; + } + set bar_style(v: string) { + this.set('bar_style', v); + } + get orientation(): string { + return this.get('orientation') as string; + } + set orientation(v: string) { + this.set('orientation', v); + } +} + +export class IntProgress extends _ProgressBase { + static override modelName = 'IntProgressModel'; + static override viewName = 'ProgressView'; +} + +export class FloatProgress extends _ProgressBase { + static override modelName = 'FloatProgressModel'; + static override viewName = 'ProgressView'; + + protected override _defaults() { + return { ...super._defaults(), max: 10.0 }; + } +} + +// --------------------------------------------------------------------------- +// Numeric — text inputs +// --------------------------------------------------------------------------- + +class _NumericTextBase extends Widget { + protected _defaults() { + return { ...super._defaults(), value: 0, step: 1 }; + } + + get value(): number { + return this.get('value') as number; + } + set value(v: number) { + this.set('value', v); + } + get step(): number { + return this.get('step') as number; + } + set step(v: number) { + this.set('step', v); + } +} + +export class IntText extends _NumericTextBase { + static override modelName = 'IntTextModel'; + static override viewName = 'IntTextView'; +} + +export class FloatText extends _NumericTextBase { + static override modelName = 'FloatTextModel'; + static override viewName = 'FloatTextView'; + + protected override _defaults() { + return { ...super._defaults(), value: 0.0, step: 0.1 }; + } +} + +export class BoundedIntText extends _NumericTextBase { + static override modelName = 'BoundedIntTextModel'; + static override viewName = 'IntTextView'; + + protected override _defaults() { + return { ...super._defaults(), min: 0, max: 100 }; + } + + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } +} + +export class BoundedFloatText extends _NumericTextBase { + static override modelName = 'BoundedFloatTextModel'; + static override viewName = 'FloatTextView'; + + protected override _defaults() { + return { ...super._defaults(), value: 0.0, step: 0.1, min: 0.0, max: 100.0 }; + } + + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } +} + +// --------------------------------------------------------------------------- +// Boolean +// --------------------------------------------------------------------------- + +export class Checkbox extends Widget { + static override modelName = 'CheckboxModel'; + static override viewName = 'CheckboxView'; + + protected override _defaults() { + return { ...super._defaults(), value: false, indent: true }; + } + + get value(): boolean { + return this.get('value') as boolean; + } + set value(v: boolean) { + this.set('value', v); + } + get indent(): boolean { + return this.get('indent') as boolean; + } + set indent(v: boolean) { + this.set('indent', v); + } +} + +export class ToggleButton extends Widget { + static override modelName = 'ToggleButtonModel'; + static override viewName = 'ToggleButtonView'; + + protected override _defaults() { + return { + ...super._defaults(), + value: false, + tooltip: '', + icon: '', + button_style: '' + }; + } + + get value(): boolean { + return this.get('value') as boolean; + } + set value(v: boolean) { + this.set('value', v); + } + get tooltip(): string { + return this.get('tooltip') as string; + } + set tooltip(v: string) { + this.set('tooltip', v); + } + get icon(): string { + return this.get('icon') as string; + } + set icon(v: string) { + this.set('icon', v); + } + get button_style(): string { + return this.get('button_style') as string; + } + set button_style(v: string) { + this.set('button_style', v); + } +} + +export class Valid extends Widget { + static override modelName = 'ValidModel'; + static override viewName = 'ValidView'; + + protected override _defaults() { + return { ...super._defaults(), value: false, readout: 'Invalid' }; + } + + get value(): boolean { + return this.get('value') as boolean; + } + set value(v: boolean) { + this.set('value', v); + } + get readout(): string { + return this.get('readout') as string; + } + set readout(v: string) { + this.set('readout', v); + } +} + +// --------------------------------------------------------------------------- +// Selection +// --------------------------------------------------------------------------- + +class _SelectionBase extends Widget { + constructor(state?: Record & { options?: string[] }) { + const { options, ...rest } = state ?? {}; + if (options !== undefined) { + (rest as Record)._options_labels = options; + if (rest.index === undefined) { + (rest as Record).index = + options.length > 0 ? 0 : null; + } + } + super(rest as Record); + } + + protected override _defaults() { + return { ...super._defaults(), _options_labels: [], index: null }; + } + + get options(): string[] { + return this.get('_options_labels') as string[]; + } + set options(v: string[]) { + this.set({ _options_labels: v, index: v.length > 0 ? 0 : null }); + } + get index(): number | null { + return this.get('index') as number | null; + } + set index(v: number | null) { + this.set('index', v); + } + + /** + * The label of the currently selected option, or null if none. + */ + get selectedLabel(): string | null { + const idx = this.index; + if (idx === null || idx === undefined) { + return null; + } + return this.options[idx] ?? null; + } +} + +export class Dropdown extends _SelectionBase { + static override modelName = 'DropdownModel'; + static override viewName = 'DropdownView'; +} + +export class RadioButtons extends _SelectionBase { + static override modelName = 'RadioButtonsModel'; + static override viewName = 'RadioButtonsView'; +} + +export class Select extends _SelectionBase { + static override modelName = 'SelectModel'; + static override viewName = 'SelectView'; + + protected override _defaults() { + return { ...super._defaults(), rows: 5 }; + } + + get rows(): number { + return this.get('rows') as number; + } + set rows(v: number) { + this.set('rows', v); + } +} + +export class ToggleButtons extends _SelectionBase { + static override modelName = 'ToggleButtonsModel'; + static override viewName = 'ToggleButtonsView'; + + protected override _defaults() { + return { ...super._defaults(), tooltips: [], button_style: '', icons: [] }; + } + + get tooltips(): string[] { + return this.get('tooltips') as string[]; + } + set tooltips(v: string[]) { + this.set('tooltips', v); + } + get button_style(): string { + return this.get('button_style') as string; + } + set button_style(v: string) { + this.set('button_style', v); + } + get icons(): string[] { + return this.get('icons') as string[]; + } + set icons(v: string[]) { + this.set('icons', v); + } +} + +export class SelectionSlider extends _SelectionBase { + static override modelName = 'SelectionSliderModel'; + static override viewName = 'SelectionSliderView'; + + protected override _defaults() { + return { ...super._defaults(), orientation: 'horizontal', readout: true }; + } + + get orientation(): string { + return this.get('orientation') as string; + } + set orientation(v: string) { + this.set('orientation', v); + } + get readout(): boolean { + return this.get('readout') as boolean; + } + set readout(v: boolean) { + this.set('readout', v); + } +} + +// --------------------------------------------------------------------------- +// String / text +// --------------------------------------------------------------------------- + +class _TextBase extends Widget { + protected override _defaults() { + return { + ...super._defaults(), + value: '', + placeholder: '', + continuous_update: true + }; + } + + get value(): string { + return this.get('value') as string; + } + set value(v: string) { + this.set('value', v); + } + get placeholder(): string { + return this.get('placeholder') as string; + } + set placeholder(v: string) { + this.set('placeholder', v); + } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } +} + +export class Text extends _TextBase { + static override modelName = 'TextModel'; + static override viewName = 'TextView'; +} + +export class Textarea extends _TextBase { + static override modelName = 'TextareaModel'; + static override viewName = 'TextareaView'; +} + +export class Password extends _TextBase { + static override modelName = 'PasswordModel'; + static override viewName = 'PasswordView'; +} + +export class Combobox extends _TextBase { + static override modelName = 'ComboboxModel'; + static override viewName = 'ComboboxView'; + + constructor(state?: Record & { options?: string[] }) { + const { options, ...rest } = state ?? {}; + if (options !== undefined) { + (rest as Record).options = options; + } + super(rest as Record); + } + + protected override _defaults() { + return { ...super._defaults(), options: [], ensure_option: false }; + } + + get options(): string[] { + return this.get('options') as string[]; + } + set options(v: string[]) { + this.set('options', v); + } + get ensure_option(): boolean { + return this.get('ensure_option') as boolean; + } + set ensure_option(v: boolean) { + this.set('ensure_option', v); + } +} + +// --------------------------------------------------------------------------- +// Display / output +// --------------------------------------------------------------------------- + +class _DisplayBase extends Widget { + protected override _defaults() { + return { ...super._defaults(), value: '' }; + } + + get value(): string { + return this.get('value') as string; + } + set value(v: string) { + this.set('value', v); + } +} + +export class Label extends _DisplayBase { + static override modelName = 'LabelModel'; + static override viewName = 'LabelView'; +} + +export class HTML extends _DisplayBase { + static override modelName = 'HTMLModel'; + static override viewName = 'HTMLView'; +} + +export class HTMLMath extends _DisplayBase { + static override modelName = 'HTMLMathModel'; + static override viewName = 'HTMLMathView'; +} + +// --------------------------------------------------------------------------- +// Button +// --------------------------------------------------------------------------- + +export class Button extends Widget { + static override modelName = 'ButtonModel'; + static override viewName = 'ButtonView'; + + protected override _defaults() { + return { + ...super._defaults(), + tooltip: '', + icon: '', + button_style: '' + }; + } + + /** + * Register a click handler. + */ + onClick(callback: () => void): this { + return this.on('click', callback); + } + + get tooltip(): string { + return this.get('tooltip') as string; + } + set tooltip(v: string) { + this.set('tooltip', v); + } + get icon(): string { + return this.get('icon') as string; + } + set icon(v: string) { + this.set('icon', v); + } + get button_style(): string { + return this.get('button_style') as string; + } + set button_style(v: string) { + this.set('button_style', v); + } + + protected override _handleMsg( + data: Record, + buffers?: ArrayBuffer[] + ): void { + super._handleMsg(data, buffers); + if (data.method === 'custom') { + const content = data.content as Record | undefined; + if (content?.event === 'click') { + this._trigger('click'); + } + this._trigger('custom', content, buffers); + } + } +} + +// --------------------------------------------------------------------------- +// Color picker +// --------------------------------------------------------------------------- + +export class ColorPicker extends Widget { + static override modelName = 'ColorPickerModel'; + static override viewName = 'ColorPickerView'; + + protected override _defaults() { + return { ...super._defaults(), value: '#000000', concise: false }; + } + + get value(): string { + return this.get('value') as string; + } + set value(v: string) { + this.set('value', v); + } + get concise(): boolean { + return this.get('concise') as boolean; + } + set concise(v: boolean) { + this.set('concise', v); + } +} + +// --------------------------------------------------------------------------- +// Container / layout +// --------------------------------------------------------------------------- + +/** + * Serialize an array of Widget instances to `"IPY_MODEL_"` references + * as required by the Jupyter widget protocol. + */ +function _serializeChildren(children: Widget[]): string[] { + return children.map(w => `IPY_MODEL_${w.commId}`); +} + +export class Box extends Widget { + static override modelName = 'BoxModel'; + static override viewName = 'BoxView'; + + protected _children: Widget[]; + + constructor(state?: Record & { children?: Widget[] }) { + const { children, ...rest } = state ?? {}; + const childrenArr = children ?? []; + super({ ...rest, children: _serializeChildren(childrenArr) }); + this._children = [...childrenArr]; + } + + protected override _defaults() { + return { children: [], box_style: '' }; + } + + get children(): Widget[] { + return this._children; + } + set children(v: Widget[]) { + this._children = [...v]; + this.set('children', _serializeChildren(v)); + } + + get box_style(): string { + return this.get('box_style') as string; + } + set box_style(v: string) { + this.set('box_style', v); + } +} + +export class HBox extends Box { + static override modelName = 'HBoxModel'; + static override viewName = 'HBoxView'; +} + +export class VBox extends Box { + static override modelName = 'VBoxModel'; + static override viewName = 'VBoxView'; +} + +export class GridBox extends Box { + static override modelName = 'GridBoxModel'; + static override viewName = 'GridBoxView'; +} + +// --------------------------------------------------------------------------- +// Selection containers (Tab, Accordion, Stack) +// --------------------------------------------------------------------------- + +class _SelectionContainer extends Box { + constructor( + state?: Record & { + children?: Widget[]; + titles?: string[]; + } + ) { + const s = { ...(state ?? {}) }; + const childCount = (s.children as Widget[] | undefined)?.length ?? 0; + // Pad titles with empty strings to match children count + const titles = (s.titles as string[] | undefined) ?? []; + const padded = [...titles]; + while (padded.length < childCount) { + padded.push(''); + } + s.titles = padded; + super(s as Record & { children?: Widget[] }); + } + + protected override _defaults() { + return { ...super._defaults(), titles: [], selected_index: null }; + } + + get titles(): string[] { + return this.get('titles') as string[]; + } + set titles(v: string[]) { + this.set('titles', v); + } + + get selected_index(): number | null { + return this.get('selected_index') as number | null; + } + set selected_index(v: number | null) { + this.set('selected_index', v); + } + + /** + * Set the title of a container page. + */ + setTitle(index: number, title: string): void { + const t = [...this.titles]; + while (t.length <= index) { + t.push(''); + } + t[index] = title; + this.titles = t; + } + + /** + * Get the title of a container page. + */ + getTitle(index: number): string { + return this.titles[index] ?? ''; + } +} + +export class Accordion extends _SelectionContainer { + static override modelName = 'AccordionModel'; + static override viewName = 'AccordionView'; +} + +export class Tab extends _SelectionContainer { + static override modelName = 'TabModel'; + static override viewName = 'TabView'; + + constructor( + state?: Record & { + children?: Widget[]; + titles?: string[]; + } + ) { + const s = { ...(state ?? {}) }; + const children = (s.children as Widget[] | undefined) ?? []; + // Default to first tab selected when there are children + if (children.length > 0 && s.selected_index === undefined) { + s.selected_index = 0; + } + super(s as Record & { children?: Widget[]; titles?: string[] }); + } +} + +export class Stack extends _SelectionContainer { + static override modelName = 'StackModel'; + static override viewName = 'StackView'; +} + +// --------------------------------------------------------------------------- +// Convenience map of all exported widget classes +// --------------------------------------------------------------------------- + +/** + * All widget classes, keyed by class name. + */ +export const widgetClasses: Record = { + Widget, + IntSlider, + FloatSlider, + IntProgress, + FloatProgress, + IntText, + FloatText, + BoundedIntText, + BoundedFloatText, + Checkbox, + ToggleButton, + Valid, + Dropdown, + RadioButtons, + Select, + ToggleButtons, + SelectionSlider, + Text, + Textarea, + Password, + Combobox, + Label, + HTML, + HTMLMath, + Button, + ColorPicker, + Box, + HBox, + VBox, + GridBox, + Accordion, + Tab, + Stack +}; From 768fc4570bf37650e08cb08e3ad2ba7220644694 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Wed, 11 Mar 2026 14:46:54 +0100 Subject: [PATCH 2/6] lint --- README.md | 12 +++++------ packages/javascript-kernel/src/comm.ts | 17 +++++++-------- packages/javascript-kernel/src/kernel.ts | 5 ++++- packages/javascript-kernel/src/widgets.ts | 26 ++++++++++++++++------- 4 files changed, 36 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 8e744d7..ab2f92a 100644 --- a/README.md +++ b/README.md @@ -111,15 +111,15 @@ The kernel provides built-in support for [Jupyter Widgets](https://ipywidgets.re ```javascript const slider = new IntSlider({ - value: 50, - min: 0, - max: 100, - description: 'My Slider' + value: 50, + min: 0, + max: 100, + description: 'My Slider' }); display(slider); -slider.on('change:value', (newVal) => { - console.log('Slider value:', newVal); +slider.on('change:value', newVal => { + console.log('Slider value:', newVal); }); ``` diff --git a/packages/javascript-kernel/src/comm.ts b/packages/javascript-kernel/src/comm.ts index 6a0f21a..d9a14f0 100644 --- a/packages/javascript-kernel/src/comm.ts +++ b/packages/javascript-kernel/src/comm.ts @@ -157,7 +157,6 @@ export class CommManager { * Create an IComm instance bound to this manager. */ private _createComm(commId: string, targetName: string): IComm { - const manager = this; const comm: IComm = { get commId() { return commId; @@ -165,12 +164,12 @@ export class CommManager { get targetName() { return targetName; }, - send( + send: ( data: Record, metadata?: Record, buffers?: ArrayBuffer[] - ): void { - manager._onOutput({ + ): void => { + this._onOutput({ type: 'comm_msg', content: { comm_id: commId, @@ -180,18 +179,18 @@ export class CommManager { buffers }); }, - close(data: Record = {}): void { - manager._onOutput({ + close: (data: Record = {}): void => { + this._onOutput({ type: 'comm_close', content: { comm_id: commId, data } }); - manager._comms.delete(commId); + this._comms.delete(commId); }, - display(): void { - manager.displayWidget(commId); + display: (): void => { + this.displayWidget(commId); }, onMsg: null, onClose: null diff --git a/packages/javascript-kernel/src/kernel.ts b/packages/javascript-kernel/src/kernel.ts index 974a2af..e4f26b5 100644 --- a/packages/javascript-kernel/src/kernel.ts +++ b/packages/javascript-kernel/src/kernel.ts @@ -416,7 +416,10 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { } return buffers.map(buf => { if (ArrayBuffer.isView(buf)) { - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + return buf.buffer.slice( + buf.byteOffset, + buf.byteOffset + buf.byteLength + ); } return buf; }); diff --git a/packages/javascript-kernel/src/widgets.ts b/packages/javascript-kernel/src/widgets.ts index 7c2ee52..63ac31d 100644 --- a/packages/javascript-kernel/src/widgets.ts +++ b/packages/javascript-kernel/src/widgets.ts @@ -84,9 +84,7 @@ export class Widget { set(state: Record): void; set(nameOrState: string | Record, value?: unknown): void { const updates: Record = - typeof nameOrState === 'string' - ? { [nameOrState]: value } - : nameOrState; + typeof nameOrState === 'string' ? { [nameOrState]: value } : nameOrState; const changes: Array<[string, unknown, unknown]> = []; for (const [key, val] of Object.entries(updates)) { @@ -270,7 +268,12 @@ export class FloatSlider extends _SliderBase { static override viewName = 'FloatSliderView'; protected override _defaults() { - return { ...super._defaults(), max: 10.0, step: 0.1, readout_format: '.2f' }; + return { + ...super._defaults(), + max: 10.0, + step: 0.1, + readout_format: '.2f' + }; } get readout_format(): string { @@ -407,7 +410,13 @@ export class BoundedFloatText extends _NumericTextBase { static override viewName = 'FloatTextView'; protected override _defaults() { - return { ...super._defaults(), value: 0.0, step: 0.1, min: 0.0, max: 100.0 }; + return { + ...super._defaults(), + value: 0.0, + step: 0.1, + min: 0.0, + max: 100.0 + }; } get min(): number { @@ -522,8 +531,7 @@ class _SelectionBase extends Widget { if (options !== undefined) { (rest as Record)._options_labels = options; if (rest.index === undefined) { - (rest as Record).index = - options.length > 0 ? 0 : null; + (rest as Record).index = options.length > 0 ? 0 : null; } } super(rest as Record); @@ -971,7 +979,9 @@ export class Tab extends _SelectionContainer { if (children.length > 0 && s.selected_index === undefined) { s.selected_index = 0; } - super(s as Record & { children?: Widget[]; titles?: string[] }); + super( + s as Record & { children?: Widget[]; titles?: string[] } + ); } } From d6e80b593e1200a837914c8cb18c86da096d5b66 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Wed, 11 Mar 2026 17:54:24 +0100 Subject: [PATCH 3/6] fixes --- README.md | 4 +- examples/widgets.ipynb | 11 ++- packages/javascript-kernel/src/comm.ts | 26 +++++++ .../src/runtime_evaluator.ts | 39 ++++------- packages/javascript-kernel/src/widgets.ts | 67 +++++++++++++++++-- 5 files changed, 115 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index ab2f92a..9e692a3 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,11 @@ Limits of automatic cleanup: ## Jupyter Widgets -The kernel provides built-in support for [Jupyter Widgets](https://ipywidgets.readthedocs.io/) (`ipywidgets`-compatible). Widget classes are available as globals in the kernel runtime — just instantiate and call `.display()`: +The kernel provides built-in support for [Jupyter Widgets](https://ipywidgets.readthedocs.io/) (`ipywidgets`-compatible). Widget classes are available under `Jupyter.widgets`; destructure the ones you need before using them: ```javascript +const { IntSlider } = Jupyter.widgets; + const slider = new IntSlider({ value: 50, min: 0, diff --git a/examples/widgets.ipynb b/examples/widgets.ipynb index e2c3b8c..e530e2c 100644 --- a/examples/widgets.ipynb +++ b/examples/widgets.ipynb @@ -3,7 +3,14 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# Jupyter Widgets\n\nThe JavaScript kernel provides built-in widget classes that mirror the\n[ipywidgets](https://ipywidgets.readthedocs.io/) API. Widget classes are\navailable as globals — just instantiate and configure.\n\nWidgets **auto-display** when they are the last expression in a cell.\nYou can also call the global `display()` function explicitly, e.g. when assigning to a variable.\n\n> **Requirements:** `jupyterlab-widgets` and `@jupyter-widgets/controls` must\n> be available in the JupyterLite deployment." + "source": "# Jupyter Widgets\n\nThe JavaScript kernel provides built-in widget classes that mirror the\n[ipywidgets](https://ipywidgets.readthedocs.io/) API. Widget classes are\navailable under `Jupyter.widgets`.\n\nRun the setup cell below first to destructure the widget classes used in this\nnotebook.\n\nWidgets **auto-display** when they are the last expression in a cell.\nYou can also call the global `display()` function explicitly, e.g. when assigning to a variable.\n\n> **Requirements:** `jupyterlab-widgets` and `@jupyter-widgets/controls` must\n> be available in the JupyterLite deployment." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "const {\n IntSlider,\n FloatSlider,\n SelectionSlider,\n IntProgress,\n IntText,\n FloatText,\n Text,\n Textarea,\n Password,\n Checkbox,\n ToggleButton,\n Valid,\n Dropdown,\n RadioButtons,\n Select,\n ToggleButtons,\n Button,\n HTML,\n Label,\n ColorPicker,\n VBox,\n HBox,\n Tab,\n Accordion,\n Stack\n} = Jupyter.widgets;" }, { "cell_type": "markdown", @@ -359,4 +366,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/packages/javascript-kernel/src/comm.ts b/packages/javascript-kernel/src/comm.ts index d9a14f0..a2f1aa0 100644 --- a/packages/javascript-kernel/src/comm.ts +++ b/packages/javascript-kernel/src/comm.ts @@ -76,6 +76,27 @@ export class CommManager { this._targets.set(targetName, handler); } + /** + * Register a widget instance by comm ID. + */ + registerWidget(commId: string, widget: T): void { + this._widgets.set(commId, widget); + } + + /** + * Look up a widget instance by comm ID. + */ + getWidget(commId: string): T | undefined { + return this._widgets.get(commId) as T | undefined; + } + + /** + * Remove a widget registration. + */ + unregisterWidget(commId: string): void { + this._widgets.delete(commId); + } + /** * Handle a comm_open message from the frontend. */ @@ -120,6 +141,7 @@ export class CommManager { const comm = this._comms.get(commId); if (comm) { comm.onClose?.(data, buffers); + this._widgets.delete(commId); this._comms.delete(commId); } } @@ -149,6 +171,7 @@ export class CommManager { * Dispose all comms and clear state. */ dispose(): void { + this._widgets.clear(); this._comms.clear(); this._targets.clear(); } @@ -187,6 +210,8 @@ export class CommManager { data } }); + comm.onClose?.(data); + this._widgets.delete(commId); this._comms.delete(commId); }, display: (): void => { @@ -201,4 +226,5 @@ export class CommManager { private _onOutput: RuntimeOutputHandler; private _comms = new Map(); private _targets = new Map(); + private _widgets = new Map(); } diff --git a/packages/javascript-kernel/src/runtime_evaluator.ts b/packages/javascript-kernel/src/runtime_evaluator.ts index 0a0bca1..fee0e1e 100644 --- a/packages/javascript-kernel/src/runtime_evaluator.ts +++ b/packages/javascript-kernel/src/runtime_evaluator.ts @@ -7,7 +7,7 @@ import { JavaScriptExecutor } from './executor'; import { normalizeError } from './errors'; import { CommManager } from './comm'; import type { RuntimeOutputHandler } from './runtime_protocol'; -import { Widget, widgetClasses } from './widgets'; +import { Widget, createWidgetClasses } from './widgets'; /** * Shared execution logic for iframe and worker runtime backends. @@ -23,8 +23,8 @@ export class JavaScriptRuntimeEvaluator { options.executor ?? new JavaScriptExecutor(options.globalScope); this._commManager = new CommManager(options.onOutput); - this._setupJupyterGlobal(); this._setupWidgets(); + this._setupJupyterGlobal(); this._setupDisplay(); this._setupConsoleOverrides(); } @@ -367,34 +367,17 @@ export class JavaScriptRuntimeEvaluator { } /** - * Install widget classes in the runtime global scope. + * Create runtime-local widget classes. */ private _setupWidgets(): void { - Widget.setDefaultManager(this._commManager); - - this._previousWidgetGlobals = {}; - for (const [name, cls] of Object.entries(widgetClasses)) { - this._previousWidgetGlobals[name] = this._globalScope[name]; - this._globalScope[name] = cls; - } + this._widgetClasses = createWidgetClasses(this._commManager); } /** - * Remove widget classes from the global scope and reset the manager. + * Clear runtime-local widget classes. */ private _restoreWidgets(): void { - Widget.setDefaultManager(null); - - if (this._previousWidgetGlobals) { - for (const [name, prev] of Object.entries(this._previousWidgetGlobals)) { - if (prev === undefined) { - delete this._globalScope[name]; - } else { - this._globalScope[name] = prev; - } - } - this._previousWidgetGlobals = null; - } + this._widgetClasses = null; } /** @@ -402,9 +385,15 @@ export class JavaScriptRuntimeEvaluator { */ private _setupJupyterGlobal(): void { this._previousJupyter = this._globalScope.Jupyter; + const previousJupyter = + typeof this._previousJupyter === 'object' && + this._previousJupyter !== null + ? this._previousJupyter + : {}; this._globalScope.Jupyter = { + ...previousJupyter, comm: this._commManager, - widgets: widgetClasses + widgets: this._widgetClasses ?? {} }; } @@ -423,7 +412,7 @@ export class JavaScriptRuntimeEvaluator { private _onOutput: RuntimeOutputHandler; private _executor: JavaScriptExecutor; private _commManager: CommManager; - private _previousWidgetGlobals: Record | null = null; + private _widgetClasses: Record | null = null; private _previousJupyter: any; private _previousDisplay: any; private _originalOnError: any; diff --git a/packages/javascript-kernel/src/widgets.ts b/packages/javascript-kernel/src/widgets.ts index 63ac31d..552c1e2 100644 --- a/packages/javascript-kernel/src/widgets.ts +++ b/packages/javascript-kernel/src/widgets.ts @@ -19,24 +19,25 @@ export class Widget { static modelName = ''; static viewName = ''; - private static _defaultManager: CommManager | null = null; + protected static _defaultManager: CommManager | null = null; /** * Set the default CommManager used by all Widget instances. */ static setDefaultManager(manager: CommManager | null): void { - Widget._defaultManager = manager; + this._defaultManager = manager; } constructor(state?: Record) { - const manager = Widget._defaultManager; + const ctor = this.constructor as typeof Widget; + const manager = ctor._defaultManager; if (!manager) { throw new Error( 'Widget manager not initialized. Widgets can only be created inside the kernel runtime.' ); } - const ctor = this.constructor as typeof Widget; + this._manager = manager; this._state = { ...this._defaults(), ...state, @@ -59,8 +60,10 @@ export class Widget { this._handleMsg(data, buffers); }; this._comm.onClose = data => { + this._manager.unregisterWidget(this.commId); this._trigger('close', data); }; + this._manager.registerWidget(this.commId, this); } /** @@ -199,6 +202,7 @@ export class Widget { } protected _comm: IComm; + protected _manager: CommManager; protected _state: Record; private _listeners: Map>; } @@ -880,6 +884,20 @@ export class Box extends Widget { set box_style(v: string) { this.set('box_style', v); } + + protected override _handleMsg( + data: Record, + buffers?: ArrayBuffer[] + ): void { + super._handleMsg(data, buffers); + + if (data.method === 'update' && data.state) { + const state = data.state as Record; + if (Object.prototype.hasOwnProperty.call(state, 'children')) { + this._children = _deserializeChildren(this._manager, state.children); + } + } + } } export class HBox extends Box { @@ -1032,3 +1050,44 @@ export const widgetClasses: Record = { Tab, Stack }; + +/** + * Create runtime-local widget classes bound to a specific comm manager. + */ +export function createWidgetClasses( + manager: CommManager +): Record { + const classes: Record = {}; + + for (const [name, cls] of Object.entries(widgetClasses)) { + const BoundWidgetClass = class extends cls {}; + Object.defineProperty(BoundWidgetClass, 'name', { value: name }); + BoundWidgetClass.setDefaultManager(manager); + classes[name] = BoundWidgetClass; + } + + return classes; +} + +/** + * Resolve serialized `IPY_MODEL_` references back to known widget instances. + */ +function _deserializeChildren( + manager: CommManager, + children: unknown +): Widget[] { + if (!Array.isArray(children)) { + return []; + } + + return children + .map(child => { + if (typeof child !== 'string' || !child.startsWith('IPY_MODEL_')) { + return null; + } + return ( + manager.getWidget(child.slice('IPY_MODEL_'.length)) ?? null + ); + }) + .filter((child): child is Widget => child instanceof Widget); +} From 990c3b85adab320814927a1cf05a0546bbcef340 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Wed, 11 Mar 2026 21:32:53 +0100 Subject: [PATCH 4/6] consolidate --- README.md | 29 +- examples/widgets.ipynb | 58 +- packages/javascript-kernel/src/comm.ts | 15 + packages/javascript-kernel/src/kernel.ts | 15 +- .../javascript-kernel/src/runtime_backends.ts | 43 +- .../src/runtime_evaluator.ts | 130 +- .../javascript-kernel/src/runtime_protocol.ts | 12 +- .../javascript-kernel/src/runtime_remote.ts | 29 +- packages/javascript-kernel/src/widgets.ts | 1125 +++++++++++++++-- 9 files changed, 1276 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index 9e692a3..34243d8 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,10 @@ Limits of automatic cleanup: ## Jupyter Widgets -The kernel provides built-in support for [Jupyter Widgets](https://ipywidgets.readthedocs.io/) (`ipywidgets`-compatible). Widget classes are available under `Jupyter.widgets`; destructure the ones you need before using them: +The kernel provides built-in support for [Jupyter Widgets](https://ipywidgets.readthedocs.io/) (`ipywidgets`-compatible). Widget classes and helpers are available under `Jupyter.widgets`; destructure the ones you need before using them: ```javascript -const { IntSlider } = Jupyter.widgets; +const { IntSlider, IntProgress, jslink } = Jupyter.widgets; const slider = new IntSlider({ value: 50, @@ -118,27 +118,38 @@ const slider = new IntSlider({ max: 100, description: 'My Slider' }); +const progress = new IntProgress({ + value: 50, + min: 0, + max: 100, + description: 'Mirror' +}); display(slider); +display(progress); -slider.on('change:value', newVal => { - console.log('Slider value:', newVal); -}); +slider.observe(({ new: value }) => { + console.log('Slider value:', value); +}, 'value'); + +jslink([slider, 'value'], [progress, 'value']); ``` Widgets auto-display when they are the last expression in a cell. Use the global `display()` function to display a widget explicitly, for example when assigning to a variable. ### Available widgets -- **Numeric**: `IntSlider`, `FloatSlider`, `IntProgress`, `FloatProgress`, `IntText`, `FloatText`, `BoundedIntText`, `BoundedFloatText` +- **Numeric**: `IntSlider`, `FloatSlider`, `FloatLogSlider`, `IntRangeSlider`, `FloatRangeSlider`, `Play`, `IntProgress`, `FloatProgress`, `IntText`, `FloatText`, `BoundedIntText`, `BoundedFloatText` - **Boolean**: `Checkbox`, `ToggleButton`, `Valid` -- **Selection**: `Dropdown`, `RadioButtons`, `Select`, `ToggleButtons`, `SelectionSlider` +- **Selection**: `Dropdown`, `RadioButtons`, `Select`, `SelectMultiple`, `ToggleButtons`, `SelectionSlider`, `SelectionRangeSlider` - **String**: `Text`, `Textarea`, `Password`, `Combobox` -- **Display**: `Label`, `HTML`, `HTMLMath` +- **Display**: `Label`, `HTML`, `HTMLMath`, `Output` - **Button**: `Button` (with `.onClick()` handler) - **Color**: `ColorPicker` +- **Layout / Style**: `Layout`, `DescriptionStyle`, `SliderStyle`, `ProgressStyle`, `ButtonStyle`, `CheckboxStyle`, `ToggleButtonStyle`, `ToggleButtonsStyle`, `TextStyle`, `HTMLStyle`, `HTMLMathStyle`, `LabelStyle` - **Containers**: `Box`, `HBox`, `VBox`, `GridBox`, `Accordion`, `Tab`, `Stack` +- **Helpers**: `jslink`, `jsdlink` -> **Note:** `jupyterlab-widgets` and `@jupyter-widgets/controls` must be available in the JupyterLite deployment for widgets to render. +> **Note:** `jupyterlab-widgets`, `@jupyter-widgets/controls`, and `@jupyter-widgets/output` must be available in the JupyterLite deployment for the full widget set to render. See the [example notebook](examples/widgets.ipynb) for more usage examples. diff --git a/examples/widgets.ipynb b/examples/widgets.ipynb index e530e2c..6e5fe45 100644 --- a/examples/widgets.ipynb +++ b/examples/widgets.ipynb @@ -3,14 +3,14 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# Jupyter Widgets\n\nThe JavaScript kernel provides built-in widget classes that mirror the\n[ipywidgets](https://ipywidgets.readthedocs.io/) API. Widget classes are\navailable under `Jupyter.widgets`.\n\nRun the setup cell below first to destructure the widget classes used in this\nnotebook.\n\nWidgets **auto-display** when they are the last expression in a cell.\nYou can also call the global `display()` function explicitly, e.g. when assigning to a variable.\n\n> **Requirements:** `jupyterlab-widgets` and `@jupyter-widgets/controls` must\n> be available in the JupyterLite deployment." + "source": "# Jupyter Widgets\n\nThe JavaScript kernel provides built-in widget classes that mirror the\n[ipywidgets](https://ipywidgets.readthedocs.io/) API. Widget classes are\navailable under `Jupyter.widgets`.\n\nRun the setup cell below first to destructure the widget classes used in this\nnotebook.\n\nWidgets **auto-display** when they are the last expression in a cell.\nYou can also call the global `display()` function explicitly, e.g. when assigning to a variable.\n\n> **Requirements:** `jupyterlab-widgets`, `@jupyter-widgets/controls`, and\n> `@jupyter-widgets/output` must be available in the JupyterLite deployment." }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": "const {\n IntSlider,\n FloatSlider,\n SelectionSlider,\n IntProgress,\n IntText,\n FloatText,\n Text,\n Textarea,\n Password,\n Checkbox,\n ToggleButton,\n Valid,\n Dropdown,\n RadioButtons,\n Select,\n ToggleButtons,\n Button,\n HTML,\n Label,\n ColorPicker,\n VBox,\n HBox,\n Tab,\n Accordion,\n Stack\n} = Jupyter.widgets;" + "source": "const {\n IntSlider,\n FloatSlider,\n FloatLogSlider,\n IntRangeSlider,\n FloatRangeSlider,\n SelectionSlider,\n SelectionRangeSlider,\n IntProgress,\n Play,\n IntText,\n FloatText,\n Text,\n Textarea,\n Password,\n Checkbox,\n ToggleButton,\n Valid,\n Dropdown,\n RadioButtons,\n Select,\n SelectMultiple,\n ToggleButtons,\n Button,\n ButtonStyle,\n HTML,\n Label,\n Output,\n Layout,\n ColorPicker,\n VBox,\n HBox,\n Tab,\n Accordion,\n Stack,\n jslink\n} = Jupyter.widgets;" }, { "cell_type": "markdown", @@ -24,7 +24,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "const slider = new IntSlider({\n value: 50,\n min: 0,\n max: 100,\n step: 1,\n description: 'Int Slider'\n});\ndisplay(slider);\n\nslider.on('change:value', (newVal) => {\n console.log('Slider value:', newVal);\n});" + "source": "const slider = new IntSlider({\n value: 50,\n min: 0,\n max: 100,\n step: 1,\n description: 'Int Slider'\n});\ndisplay(slider);\n\nslider.observe(({ new: value }) => {\n console.log('Slider value:', value);\n}, 'value');" }, { "cell_type": "code", @@ -40,6 +40,13 @@ "outputs": [], "source": "new SelectionSlider({\n options: ['Freezing', 'Cold', 'Warm', 'Hot', 'Blazing'],\n index: 2,\n description: 'Temp'\n})" }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new VBox({\n children: [\n new IntRangeSlider({\n value: [20, 80],\n min: 0,\n max: 100,\n description: 'Range'\n }),\n new FloatLogSlider({\n value: 10,\n min: -2,\n max: 2,\n step: 0.1,\n description: 'Log'\n }),\n new FloatRangeSlider({\n value: [0.5, 2.5],\n min: 0,\n max: 5,\n step: 0.1,\n description: 'Float Range'\n })\n ]\n})" + }, { "cell_type": "markdown", "metadata": {}, @@ -150,7 +157,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "const dropdown = new Dropdown({\n options: ['Apple', 'Banana', 'Cherry', 'Date'],\n index: 0,\n description: 'Fruit'\n});\ndisplay(dropdown);\n\ndropdown.on('change:index', () => {\n console.log('Selected:', dropdown.selectedLabel);\n});" + "source": "const dropdown = new Dropdown({\n options: ['Apple', 'Banana', 'Cherry', 'Date'],\n index: 0,\n description: 'Fruit'\n});\ndisplay(dropdown);\n\ndropdown.observe(({ new: value }) => {\n console.log('Selected:', value);\n}, 'value');" }, { "cell_type": "code", @@ -173,6 +180,20 @@ "outputs": [], "source": "new ToggleButtons({\n options: ['Slow', 'Medium', 'Fast'],\n index: 1,\n description: 'Speed',\n tooltips: ['Turtle pace', 'Normal speed', 'Lightning fast']\n})" }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new SelectMultiple({\n options: ['Python', 'Julia', 'R', 'JavaScript'],\n index: [0, 3],\n description: 'Languages',\n rows: 4\n})" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new SelectionRangeSlider({\n options: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],\n index: [1, 3],\n description: 'Week'\n})" + }, { "cell_type": "markdown", "metadata": {}, @@ -254,7 +275,7 @@ "source": [ "## Linking Widgets\n", "\n", - "Connect widgets together by listening for changes:" + "Use the frontend link helpers when you want widgets to stay in sync without manual wiring:" ] }, { @@ -262,12 +283,35 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "const source = new IntSlider({ value: 50, description: 'Source' });\nconst mirror = new IntProgress({ value: 50, description: 'Mirror' });\nconst label = new Label({ value: 'Value: 50' });\ndisplay(source);\ndisplay(mirror);\ndisplay(label);\n\nsource.on('change:value', (v) => {\n mirror.value = v;\n label.value = `Value: ${v}`;\n});" + "source": "const play = new Play({ value: 20, min: 0, max: 100, interval: 150 });\nconst source = new IntSlider({ value: 20, min: 0, max: 100, description: 'Value' });\nconst mirror = new IntProgress({ value: 20, min: 0, max: 100, description: 'Mirror' });\nconst label = new Label({ value: 'Value: 20' });\n\njslink([play, 'value'], [source, 'value']);\njslink([source, 'value'], [mirror, 'value']);\nsource.observe(({ new: value }) => {\n label.value = `Value: ${value}`;\n}, 'value');\n\nnew VBox({\n children: [\n new HBox({ children: [play, source] }),\n mirror,\n label\n ]\n})" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Layout, Style, and Output\n", + "\n", + "Layout models, style models, and output widgets are regular widgets too." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "new Button({\n description: 'Styled button',\n button_style: 'info',\n layout: new Layout({\n width: '220px',\n justify_content: 'center'\n }),\n style: new ButtonStyle({\n button_color: '#ffd166',\n text_color: '#1f2937'\n })\n})" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "const out = new Output({\n layout: new Layout({\n border: '1px solid #d1d5db',\n padding: '8px',\n max_height: '140px',\n overflow: 'auto'\n })\n});\ndisplay(out);\n\nconst logToOutput = out.capture({ clearOutput: true })(() => {\n console.log('Captured stdout inside Output.');\n out.appendDisplayData({\n 'text/markdown': '**Direct append** from JavaScript',\n 'text/plain': 'Direct append from JavaScript'\n });\n});\nlogToOutput();" }, { "cell_type": "markdown", "id": "efiw9mng9a6", - "source": "## Container / Layout Widgets\n\nContainer widgets group other widgets together and control their layout.\nThey accept a `children` array of widget instances.", + "source": "## Container Widgets\n\nContainer widgets group other widgets together and control their layout.\nThey accept a `children` array of widget instances.", "metadata": {} }, { diff --git a/packages/javascript-kernel/src/comm.ts b/packages/javascript-kernel/src/comm.ts index a2f1aa0..696c38c 100644 --- a/packages/javascript-kernel/src/comm.ts +++ b/packages/javascript-kernel/src/comm.ts @@ -97,6 +97,20 @@ export class CommManager { this._widgets.delete(commId); } + /** + * Track the active parent message ID for output capture helpers. + */ + setCurrentMessageId(messageId: string | null): void { + this._currentMessageId = messageId; + } + + /** + * The active parent message ID, if any. + */ + getCurrentMessageId(): string | null { + return this._currentMessageId; + } + /** * Handle a comm_open message from the frontend. */ @@ -227,4 +241,5 @@ export class CommManager { private _comms = new Map(); private _targets = new Map(); private _widgets = new Map(); + private _currentMessageId: string | null = null; } diff --git a/packages/javascript-kernel/src/kernel.ts b/packages/javascript-kernel/src/kernel.ts index e4f26b5..e7e0b33 100644 --- a/packages/javascript-kernel/src/kernel.ts +++ b/packages/javascript-kernel/src/kernel.ts @@ -100,7 +100,11 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { ): Promise { try { await this.ready; - return await this._backend.execute(content.code, this.executionCount); + return await this._backend.execute( + content.code, + this.executionCount, + this.parentHeader?.msg_id + ); } catch (error) { const normalized = this.normalizeError(error); const traceback = [ @@ -218,7 +222,8 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { comm_id, target_name, data as Record, - buffers + buffers, + msg.header.msg_id ); } @@ -231,7 +236,8 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { await this._backend.handleCommMsg( comm_id, data as Record, - buffers + buffers, + msg.header.msg_id ); } @@ -245,7 +251,8 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { await this._backend.handleCommClose( comm_id, data as Record, - buffers + buffers, + msg.header.msg_id ); } diff --git a/packages/javascript-kernel/src/runtime_backends.ts b/packages/javascript-kernel/src/runtime_backends.ts index b0ce77d..44d856e 100644 --- a/packages/javascript-kernel/src/runtime_backends.ts +++ b/packages/javascript-kernel/src/runtime_backends.ts @@ -34,7 +34,8 @@ export interface IRuntimeBackend { dispose(): void; execute( code: string, - executionCount: number + executionCount: number, + parentMessageId?: string ): Promise; complete( code: string, @@ -52,17 +53,20 @@ export interface IRuntimeBackend { commId: string, targetName: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise; handleCommMsg( commId: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise; handleCommClose( commId: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise; } @@ -87,10 +91,11 @@ abstract class AbstractRuntimeBackend implements IRuntimeBackend { */ async execute( code: string, - executionCount: number + executionCount: number, + parentMessageId?: string ): Promise { await this.ready; - return this._getRemote().execute(code, executionCount); + return this._getRemote().execute(code, executionCount, parentMessageId); } /** @@ -133,11 +138,18 @@ abstract class AbstractRuntimeBackend implements IRuntimeBackend { commId: string, targetName: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise { await this.ready; if (this._remote) { - await this._remote.handleCommOpen(commId, targetName, data, buffers); + await this._remote.handleCommOpen( + commId, + targetName, + data, + buffers, + parentMessageId + ); } } @@ -147,11 +159,12 @@ abstract class AbstractRuntimeBackend implements IRuntimeBackend { async handleCommMsg( commId: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise { await this.ready; if (this._remote) { - await this._remote.handleCommMsg(commId, data, buffers); + await this._remote.handleCommMsg(commId, data, buffers, parentMessageId); } } @@ -161,11 +174,17 @@ abstract class AbstractRuntimeBackend implements IRuntimeBackend { async handleCommClose( commId: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise { await this.ready; if (this._remote) { - await this._remote.handleCommClose(commId, data, buffers); + await this._remote.handleCommClose( + commId, + data, + buffers, + parentMessageId + ); } } diff --git a/packages/javascript-kernel/src/runtime_evaluator.ts b/packages/javascript-kernel/src/runtime_evaluator.ts index fee0e1e..e332474 100644 --- a/packages/javascript-kernel/src/runtime_evaluator.ts +++ b/packages/javascript-kernel/src/runtime_evaluator.ts @@ -59,55 +59,58 @@ export class JavaScriptRuntimeEvaluator { */ async execute( code: string, - executionCount: number + executionCount: number, + parentMessageId?: string ): Promise { - // Parse-time errors are syntax errors, so show only `Name: message`. - let asyncFunction: () => Promise; - let withReturn: boolean; - try { - const parsed = this._executor.makeAsyncFromCode(code); - asyncFunction = parsed.asyncFunction; - withReturn = parsed.withReturn; - } catch (error) { - const normalized = normalizeError(error); - return this._emitError(executionCount, normalized, false); - } + return this._withParentMessageId(parentMessageId, async () => { + // Parse-time errors are syntax errors, so show only `Name: message`. + let asyncFunction: () => Promise; + let withReturn: boolean; + try { + const parsed = this._executor.makeAsyncFromCode(code); + asyncFunction = parsed.asyncFunction; + withReturn = parsed.withReturn; + } catch (error) { + const normalized = normalizeError(error); + return this._emitError(executionCount, normalized, false); + } - // Runtime errors may include useful eval frames from user code. - try { - const resultPromise = this._evalFunc(asyncFunction); - - if (withReturn) { - const result = await resultPromise; - - if (result !== undefined) { - if (result instanceof Widget) { - this._commManager.displayWidget(result.commId); - } else { - const data = this._executor.getMimeBundle(result); - this._onOutput({ - type: 'execute_result', - bundle: { - execution_count: executionCount, - data, - metadata: {} - } - }); + // Runtime errors may include useful eval frames from user code. + try { + const resultPromise = this._evalFunc(asyncFunction); + + if (withReturn) { + const result = await resultPromise; + + if (result !== undefined) { + if (result instanceof Widget) { + this._commManager.displayWidget(result.commId); + } else { + const data = this._executor.getMimeBundle(result); + this._onOutput({ + type: 'execute_result', + bundle: { + execution_count: executionCount, + data, + metadata: {} + } + }); + } } + } else { + await resultPromise; } - } else { - await resultPromise; - } - return { - status: 'ok', - execution_count: executionCount, - user_expressions: {} - }; - } catch (error) { - const normalized = normalizeError(error); - return this._emitError(executionCount, normalized, true); - } + return { + status: 'ok', + execution_count: executionCount, + user_expressions: {} + }; + } catch (error) { + const normalized = normalizeError(error); + return this._emitError(executionCount, normalized, true); + } + }); } /** @@ -160,9 +163,12 @@ export class JavaScriptRuntimeEvaluator { commId: string, targetName: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): void { - this._commManager.handleCommOpen(commId, targetName, data, buffers); + void this._withParentMessageId(parentMessageId, async () => { + this._commManager.handleCommOpen(commId, targetName, data, buffers); + }); } /** @@ -171,9 +177,12 @@ export class JavaScriptRuntimeEvaluator { handleCommMsg( commId: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): void { - this._commManager.handleCommMsg(commId, data, buffers); + void this._withParentMessageId(parentMessageId, async () => { + this._commManager.handleCommMsg(commId, data, buffers); + }); } /** @@ -182,9 +191,12 @@ export class JavaScriptRuntimeEvaluator { handleCommClose( commId: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): void { - this._commManager.handleCommClose(commId, data, buffers); + void this._withParentMessageId(parentMessageId, async () => { + this._commManager.handleCommClose(commId, data, buffers); + }); } /** @@ -194,6 +206,22 @@ export class JavaScriptRuntimeEvaluator { return asyncFunc.call(this._globalScope); } + /** + * Set the active parent message ID while invoking runtime code. + */ + private async _withParentMessageId( + parentMessageId: string | undefined, + callback: () => Promise | T + ): Promise { + const previousMessageId = this._commManager.getCurrentMessageId(); + this._commManager.setCurrentMessageId(parentMessageId ?? null); + try { + return await callback(); + } finally { + this._commManager.setCurrentMessageId(previousMessageId); + } + } + /** * Build and emit an execute error reply. */ @@ -412,7 +440,7 @@ export class JavaScriptRuntimeEvaluator { private _onOutput: RuntimeOutputHandler; private _executor: JavaScriptExecutor; private _commManager: CommManager; - private _widgetClasses: Record | null = null; + private _widgetClasses: Record | null = null; private _previousJupyter: any; private _previousDisplay: any; private _originalOnError: any; diff --git a/packages/javascript-kernel/src/runtime_protocol.ts b/packages/javascript-kernel/src/runtime_protocol.ts index 3612c30..37db748 100644 --- a/packages/javascript-kernel/src/runtime_protocol.ts +++ b/packages/javascript-kernel/src/runtime_protocol.ts @@ -93,7 +93,8 @@ export interface IRemoteRuntimeApi { ): Promise; execute( code: string, - executionCount: number + executionCount: number, + parentMessageId?: string ): Promise; complete( code: string, @@ -111,17 +112,20 @@ export interface IRemoteRuntimeApi { commId: string, targetName: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise; handleCommMsg( commId: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise; handleCommClose( commId: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise; dispose(): Promise; } diff --git a/packages/javascript-kernel/src/runtime_remote.ts b/packages/javascript-kernel/src/runtime_remote.ts index 8005819..5c85789 100644 --- a/packages/javascript-kernel/src/runtime_remote.ts +++ b/packages/javascript-kernel/src/runtime_remote.ts @@ -51,8 +51,12 @@ export function createRemoteRuntimeApi( }); }, - async execute(code: string, executionCount: number) { - return ensureEvaluator().execute(code, executionCount); + async execute( + code: string, + executionCount: number, + parentMessageId?: string + ) { + return ensureEvaluator().execute(code, executionCount, parentMessageId); }, async complete(code: string, cursorPos: number) { @@ -75,25 +79,34 @@ export function createRemoteRuntimeApi( commId: string, targetName: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise { - ensureEvaluator().handleCommOpen(commId, targetName, data, buffers); + ensureEvaluator().handleCommOpen( + commId, + targetName, + data, + buffers, + parentMessageId + ); }, async handleCommMsg( commId: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise { - ensureEvaluator().handleCommMsg(commId, data, buffers); + ensureEvaluator().handleCommMsg(commId, data, buffers, parentMessageId); }, async handleCommClose( commId: string, data: Record, - buffers?: ArrayBuffer[] + buffers?: ArrayBuffer[], + parentMessageId?: string ): Promise { - ensureEvaluator().handleCommClose(commId, data, buffers); + ensureEvaluator().handleCommClose(commId, data, buffers, parentMessageId); }, async dispose(): Promise { diff --git a/packages/javascript-kernel/src/widgets.ts b/packages/javascript-kernel/src/widgets.ts index 552c1e2..9aeb152 100644 --- a/packages/javascript-kernel/src/widgets.ts +++ b/packages/javascript-kernel/src/widgets.ts @@ -4,11 +4,32 @@ import type { CommManager, IComm } from './comm'; const WIDGET_PROTOCOL_VERSION = '2.1.0'; +const BASE_MODULE = '@jupyter-widgets/base'; +const BASE_MODULE_VERSION = '2.0.0'; const CONTROLS_MODULE = '@jupyter-widgets/controls'; const CONTROLS_MODULE_VERSION = '2.0.0'; +const OUTPUT_MODULE = '@jupyter-widgets/output'; +const OUTPUT_MODULE_VERSION = '1.0.0'; type WidgetEventCallback = (...args: any[]) => void; +export interface IWidgetChange { + name: string; + new: unknown; + old: unknown; + owner: Widget; + type: 'change'; +} + +export type WidgetObserveCallback = (change: IWidgetChange) => void; + +export type WidgetTraitPair = [Widget, string]; + +export interface IOutputCaptureOptions { + clearOutput?: boolean; + wait?: boolean; +} + /** * Base class for Jupyter widgets. * @@ -17,7 +38,11 @@ type WidgetEventCallback = (...args: any[]) => void; */ export class Widget { static modelName = ''; - static viewName = ''; + static viewName: string | null = ''; + static modelModule = CONTROLS_MODULE; + static modelModuleVersion = CONTROLS_MODULE_VERSION; + static viewModule = CONTROLS_MODULE; + static viewModuleVersion = CONTROLS_MODULE_VERSION; protected static _defaultManager: CommManager | null = null; @@ -41,18 +66,14 @@ export class Widget { this._state = { ...this._defaults(), ...state, - _model_name: ctor.modelName, - _model_module: CONTROLS_MODULE, - _model_module_version: CONTROLS_MODULE_VERSION, - _view_name: ctor.viewName, - _view_module: CONTROLS_MODULE, - _view_module_version: CONTROLS_MODULE_VERSION + ...this._modelState(ctor) }; this._listeners = new Map(); + this._observerWrappers = new Map(); this._comm = manager.open( 'jupyter.widget', - { state: this._state, buffer_paths: [] }, + { state: this._serializeState(this._state), buffer_paths: [] }, { version: WIDGET_PROTOCOL_VERSION } ); @@ -101,7 +122,7 @@ export class Widget { if (changes.length > 0) { this._comm.send({ method: 'update', - state: updates, + state: this._serializeState(updates), buffer_paths: [] }); for (const [key, val, old] of changes) { @@ -115,9 +136,9 @@ export class Widget { * Listen for widget events. * * Events: - * - `'change:propName'` — property changed `(newValue, oldValue)` - * - `'change'` — any property changed `(changes)` - * - `'close'` — comm closed + * - `'change:propName'` - property changed `(newValue, oldValue)` + * - `'change'` - any property changed `(changes)` + * - `'close'` - comm closed */ on(event: string, callback: WidgetEventCallback): this { if (!this._listeners.has(event)) { @@ -135,6 +156,47 @@ export class Widget { return this; } + /** + * Observe changes using an ipywidgets-style callback payload. + */ + observe(callback: WidgetObserveCallback, names?: string | string[]): this { + for (const name of this._normalizeObserveNames(names)) { + const eventName = name === '*' ? 'change' : `change:${name}`; + const wrapper = this._observerWrapper(callback, name); + this.on(eventName, wrapper); + } + return this; + } + + /** + * Remove an observe callback. + */ + unobserve(callback: WidgetObserveCallback, names?: string | string[]): this { + const wrappers = this._observerWrappers.get(callback); + if (!wrappers) { + return this; + } + + const keys = names + ? this._normalizeObserveNames(names) + : [...wrappers.keys()]; + for (const name of keys) { + const wrapper = wrappers.get(name); + if (!wrapper) { + continue; + } + const eventName = name === '*' ? 'change' : `change:${name}`; + this.off(eventName, wrapper); + wrappers.delete(name); + } + + if (wrappers.size === 0) { + this._observerWrappers.delete(callback); + } + + return this; + } + /** * The widget's comm/model ID. */ @@ -168,10 +230,11 @@ export class Widget { const state = data.state as Record; const changes: Array<[string, unknown, unknown]> = []; for (const [key, val] of Object.entries(state)) { + const next = this._deserializeProperty(key, val); const old = this._state[key]; - if (old !== val) { - this._state[key] = val; - changes.push([key, val, old]); + if (old !== next) { + this._state[key] = next; + changes.push([key, next, old]); } } for (const [key, val, old] of changes) { @@ -185,12 +248,30 @@ export class Widget { if (data.method === 'request_state') { this._comm.send({ method: 'update', - state: this._state, + state: this._serializeState(this._state), buffer_paths: [] }); } } + protected _serializeState( + state: Record + ): Record { + const serialized: Record = {}; + for (const [key, value] of Object.entries(state)) { + serialized[key] = this._serializeProperty(key, value); + } + return serialized; + } + + protected _serializeProperty(name: string, value: unknown): unknown { + return value; + } + + protected _deserializeProperty(name: string, value: unknown): unknown { + return value; + } + protected _trigger(event: string, ...args: unknown[]): void { for (const cb of this._listeners.get(event) ?? []) { try { @@ -201,18 +282,214 @@ export class Widget { } } + private _modelState(ctor: typeof Widget): Record { + const state: Record = { + _model_name: ctor.modelName, + _model_module: ctor.modelModule, + _model_module_version: ctor.modelModuleVersion + }; + + if (ctor.viewName !== null) { + state._view_name = ctor.viewName; + state._view_module = ctor.viewModule; + state._view_module_version = ctor.viewModuleVersion; + } + + return state; + } + + private _normalizeObserveNames(names?: string | string[]): string[] { + if (names === undefined) { + return ['*']; + } + return Array.isArray(names) ? names : [names]; + } + + private _observerWrapper( + callback: WidgetObserveCallback, + name: string + ): WidgetEventCallback { + let wrappers = this._observerWrappers.get(callback); + if (!wrappers) { + wrappers = new Map(); + this._observerWrappers.set(callback, wrappers); + } + + const existing = wrappers.get(name); + if (existing) { + return existing; + } + + const wrapper: WidgetEventCallback = + name === '*' + ? (changes: Array<[string, unknown, unknown]>) => { + for (const [changeName, next, old] of changes) { + callback({ + name: changeName, + new: next, + old, + owner: this, + type: 'change' + }); + } + } + : (next: unknown, old: unknown) => { + callback({ + name, + new: next, + old, + owner: this, + type: 'change' + }); + }; + + wrappers.set(name, wrapper); + return wrapper; + } + protected _comm: IComm; protected _manager: CommManager; protected _state: Record; private _listeners: Map>; + private _observerWrappers: Map< + WidgetObserveCallback, + Map + >; +} + +/** + * A widget with layout/style support. + */ +export class DOMWidget extends Widget { + protected override _defaults(): Record { + return { ...super._defaults(), layout: null, style: null }; + } + + get layout(): Layout | null { + return (this.get('layout') as Layout | null) ?? null; + } + set layout(v: Layout | null) { + this.set('layout', v); + } + + get style(): Style | null { + return (this.get('style') as Style | null) ?? null; + } + set style(v: Style | null) { + this.set('style', v); + } + + protected override _serializeProperty(name: string, value: unknown): unknown { + if ( + (name === 'layout' || name === 'style') && + (value instanceof Widget || value === null) + ) { + return _serializeWidgetReference(value); + } + return super._serializeProperty(name, value); + } + + protected override _deserializeProperty( + name: string, + value: unknown + ): unknown { + if (name === 'layout' || name === 'style') { + return _deserializeWidgetReference(this._manager, value); + } + return super._deserializeProperty(name, value); + } } // --------------------------------------------------------------------------- -// Numeric — sliders +// Layout and style models // --------------------------------------------------------------------------- -class _SliderBase extends Widget { - protected _defaults() { +export class Layout extends Widget { + static override modelName = 'LayoutModel'; + static override viewName = 'LayoutView'; + static override modelModule = BASE_MODULE; + static override modelModuleVersion = BASE_MODULE_VERSION; + static override viewModule = BASE_MODULE; + static override viewModuleVersion = BASE_MODULE_VERSION; + + protected override _defaults(): Record { + return {}; + } +} + +export class Style extends Widget { + static override modelName = 'StyleModel'; + static override viewName = 'StyleView'; + static override modelModule = BASE_MODULE; + static override modelModuleVersion = BASE_MODULE_VERSION; + static override viewModule = BASE_MODULE; + static override viewModuleVersion = BASE_MODULE_VERSION; + + protected override _defaults(): Record { + return {}; + } +} + +export class DescriptionStyle extends Style { + static override modelName = 'DescriptionStyleModel'; + static override modelModule = CONTROLS_MODULE; + static override modelModuleVersion = CONTROLS_MODULE_VERSION; +} + +export class SliderStyle extends DescriptionStyle { + static override modelName = 'SliderStyleModel'; +} + +export class ProgressStyle extends DescriptionStyle { + static override modelName = 'ProgressStyleModel'; +} + +export class ButtonStyle extends Style { + static override modelName = 'ButtonStyleModel'; + static override modelModule = CONTROLS_MODULE; + static override modelModuleVersion = CONTROLS_MODULE_VERSION; +} + +export class CheckboxStyle extends DescriptionStyle { + static override modelName = 'CheckboxStyleModel'; +} + +export class ToggleButtonStyle extends DescriptionStyle { + static override modelName = 'ToggleButtonStyleModel'; +} + +export class ToggleButtonsStyle extends DescriptionStyle { + static override modelName = 'ToggleButtonsStyleModel'; +} + +export class TextStyle extends DescriptionStyle { + static override modelName = 'TextStyleModel'; +} + +export class HTMLStyle extends Style { + static override modelName = 'HTMLStyleModel'; + static override modelModule = CONTROLS_MODULE; + static override modelModuleVersion = CONTROLS_MODULE_VERSION; +} + +export class HTMLMathStyle extends Style { + static override modelName = 'HTMLMathStyleModel'; + static override modelModule = CONTROLS_MODULE; + static override modelModuleVersion = CONTROLS_MODULE_VERSION; +} + +export class LabelStyle extends Style { + static override modelName = 'LabelStyleModel'; + static override modelModule = CONTROLS_MODULE; + static override modelModuleVersion = CONTROLS_MODULE_VERSION; +} + +// --------------------------------------------------------------------------- +// Numeric - sliders +// --------------------------------------------------------------------------- + +class _SliderBase extends DOMWidget { + protected override _defaults() { return { ...super._defaults(), value: 0, @@ -220,7 +497,9 @@ class _SliderBase extends Widget { max: 100, step: 1, orientation: 'horizontal', - readout: true + readout: true, + continuous_update: true, + behavior: 'drag-tap' }; } @@ -260,11 +539,37 @@ class _SliderBase extends Widget { set readout(v: boolean) { this.set('readout', v); } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } + get behavior(): string { + return this.get('behavior') as string; + } + set behavior(v: string) { + this.set('behavior', v); + } } export class IntSlider extends _SliderBase { static override modelName = 'IntSliderModel'; static override viewName = 'IntSliderView'; + + protected override _defaults() { + return { + ...super._defaults(), + readout_format: 'd' + }; + } + + get readout_format(): string { + return this.get('readout_format') as string; + } + set readout_format(v: string) { + this.set('readout_format', v); + } } export class FloatSlider extends _SliderBase { @@ -288,12 +593,224 @@ export class FloatSlider extends _SliderBase { } } +export class FloatLogSlider extends _SliderBase { + static override modelName = 'FloatLogSliderModel'; + static override viewName = 'FloatLogSliderView'; + + protected override _defaults() { + return { + ...super._defaults(), + min: 0.0, + max: 4.0, + base: 10.0, + value: 1.0, + step: 0.1, + readout_format: '.3g' + }; + } + + get base(): number { + return this.get('base') as number; + } + set base(v: number) { + this.set('base', v); + } + get readout_format(): string { + return this.get('readout_format') as string; + } + set readout_format(v: string) { + this.set('readout_format', v); + } +} + +class _RangeSliderBase extends DOMWidget { + protected override _defaults() { + return { + ...super._defaults(), + value: [0, 1], + min: 0, + max: 100, + step: 1, + orientation: 'horizontal', + readout: true, + continuous_update: true, + behavior: 'drag-tap' + }; + } + + get value(): [number, number] { + return this.get('value') as [number, number]; + } + set value(v: [number, number]) { + this.set('value', v); + } + get lower(): number { + return this.value[0]; + } + set lower(v: number) { + this.value = [v, this.value[1]]; + } + get upper(): number { + return this.value[1]; + } + set upper(v: number) { + this.value = [this.value[0], v]; + } + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } + get step(): number { + return this.get('step') as number; + } + set step(v: number) { + this.set('step', v); + } + get orientation(): string { + return this.get('orientation') as string; + } + set orientation(v: string) { + this.set('orientation', v); + } + get readout(): boolean { + return this.get('readout') as boolean; + } + set readout(v: boolean) { + this.set('readout', v); + } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } + get behavior(): string { + return this.get('behavior') as string; + } + set behavior(v: string) { + this.set('behavior', v); + } +} + +export class IntRangeSlider extends _RangeSliderBase { + static override modelName = 'IntRangeSliderModel'; + static override viewName = 'IntRangeSliderView'; + + protected override _defaults() { + return { ...super._defaults(), readout_format: 'd' }; + } + + get readout_format(): string { + return this.get('readout_format') as string; + } + set readout_format(v: string) { + this.set('readout_format', v); + } +} + +export class FloatRangeSlider extends _RangeSliderBase { + static override modelName = 'FloatRangeSliderModel'; + static override viewName = 'FloatRangeSliderView'; + + protected override _defaults() { + return { + ...super._defaults(), + value: [0.0, 1.0], + step: 0.1, + readout_format: '.2f' + }; + } + + get readout_format(): string { + return this.get('readout_format') as string; + } + set readout_format(v: string) { + this.set('readout_format', v); + } +} + +export class Play extends DOMWidget { + static override modelName = 'PlayModel'; + static override viewName = 'PlayView'; + + protected override _defaults() { + return { + ...super._defaults(), + value: 0, + min: 0, + max: 100, + step: 1, + repeat: false, + playing: false, + show_repeat: true, + interval: 100 + }; + } + + get value(): number { + return this.get('value') as number; + } + set value(v: number) { + this.set('value', v); + } + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } + get step(): number { + return this.get('step') as number; + } + set step(v: number) { + this.set('step', v); + } + get repeat(): boolean { + return this.get('repeat') as boolean; + } + set repeat(v: boolean) { + this.set('repeat', v); + } + get playing(): boolean { + return this.get('playing') as boolean; + } + set playing(v: boolean) { + this.set('playing', v); + } + get show_repeat(): boolean { + return this.get('show_repeat') as boolean; + } + set show_repeat(v: boolean) { + this.set('show_repeat', v); + } + get interval(): number { + return this.get('interval') as number; + } + set interval(v: number) { + this.set('interval', v); + } +} + // --------------------------------------------------------------------------- -// Numeric — progress +// Numeric - progress // --------------------------------------------------------------------------- -class _ProgressBase extends Widget { - protected _defaults() { +class _ProgressBase extends DOMWidget { + protected override _defaults() { return { ...super._defaults(), value: 0, @@ -351,12 +868,17 @@ export class FloatProgress extends _ProgressBase { } // --------------------------------------------------------------------------- -// Numeric — text inputs +// Numeric - text inputs // --------------------------------------------------------------------------- -class _NumericTextBase extends Widget { - protected _defaults() { - return { ...super._defaults(), value: 0, step: 1 }; +class _NumericTextBase extends DOMWidget { + protected override _defaults() { + return { + ...super._defaults(), + value: 0, + step: 1, + continuous_update: false + }; } get value(): number { @@ -371,6 +893,12 @@ class _NumericTextBase extends Widget { set step(v: number) { this.set('step', v); } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } } export class IntText extends _NumericTextBase { @@ -441,7 +969,7 @@ export class BoundedFloatText extends _NumericTextBase { // Boolean // --------------------------------------------------------------------------- -export class Checkbox extends Widget { +export class Checkbox extends DOMWidget { static override modelName = 'CheckboxModel'; static override viewName = 'CheckboxView'; @@ -463,7 +991,7 @@ export class Checkbox extends Widget { } } -export class ToggleButton extends Widget { +export class ToggleButton extends DOMWidget { static override modelName = 'ToggleButtonModel'; static override viewName = 'ToggleButtonView'; @@ -503,7 +1031,7 @@ export class ToggleButton extends Widget { } } -export class Valid extends Widget { +export class Valid extends DOMWidget { static override modelName = 'ValidModel'; static override viewName = 'ValidView'; @@ -529,7 +1057,7 @@ export class Valid extends Widget { // Selection // --------------------------------------------------------------------------- -class _SelectionBase extends Widget { +class _SelectionBase extends DOMWidget { constructor(state?: Record & { options?: string[] }) { const { options, ...rest } = state ?? {}; if (options !== undefined) { @@ -541,7 +1069,7 @@ class _SelectionBase extends Widget { super(rest as Record); } - protected override _defaults() { + protected override _defaults(): Record { return { ...super._defaults(), _options_labels: [], index: null }; } @@ -557,6 +1085,23 @@ class _SelectionBase extends Widget { set index(v: number | null) { this.set('index', v); } + get value(): string | null { + return this.selectedLabel; + } + set value(v: string | null) { + if (v === null) { + this.index = null; + return; + } + const idx = this.options.indexOf(v); + this.index = idx === -1 ? null : idx; + } + get label(): string | null { + return this.selectedLabel; + } + set label(v: string | null) { + this.value = v; + } /** * The label of the currently selected option, or null if none. @@ -570,6 +1115,54 @@ class _SelectionBase extends Widget { } } +class _MultipleSelectionBase extends DOMWidget { + constructor(state?: Record & { options?: string[] }) { + const { options, ...rest } = state ?? {}; + if (options !== undefined) { + (rest as Record)._options_labels = options; + if (rest.index === undefined) { + (rest as Record).index = []; + } + } + super(rest as Record); + } + + protected override _defaults(): Record { + return { ...super._defaults(), _options_labels: [], index: [] }; + } + + get options(): string[] { + return this.get('_options_labels') as string[]; + } + set options(v: string[]) { + this.set({ _options_labels: v, index: [] }); + } + get index(): number[] { + return [...((this.get('index') as number[]) ?? [])]; + } + set index(v: number[]) { + this.set('index', [...v]); + } + get value(): string[] { + return this.selectedLabels; + } + set value(v: string[]) { + this.index = v + .map(label => this.options.indexOf(label)) + .filter(idx => idx >= 0); + } + get label(): string[] { + return this.selectedLabels; + } + set label(v: string[]) { + this.value = v; + } + + get selectedLabels(): string[] { + return this.index.map(idx => this.options[idx]).filter(Boolean); + } +} + export class Dropdown extends _SelectionBase { static override modelName = 'DropdownModel'; static override viewName = 'DropdownView'; @@ -596,6 +1189,22 @@ export class Select extends _SelectionBase { } } +export class SelectMultiple extends _MultipleSelectionBase { + static override modelName = 'SelectMultipleModel'; + static override viewName = 'SelectMultipleView'; + + protected override _defaults() { + return { ...super._defaults(), rows: 5 }; + } + + get rows(): number { + return this.get('rows') as number; + } + set rows(v: number) { + this.set('rows', v); + } +} + export class ToggleButtons extends _SelectionBase { static override modelName = 'ToggleButtonsModel'; static override viewName = 'ToggleButtonsView'; @@ -629,7 +1238,13 @@ export class SelectionSlider extends _SelectionBase { static override viewName = 'SelectionSliderView'; protected override _defaults() { - return { ...super._defaults(), orientation: 'horizontal', readout: true }; + return { + ...super._defaults(), + orientation: 'horizontal', + readout: true, + continuous_update: true, + behavior: 'drag-tap' + }; } get orientation(): string { @@ -644,13 +1259,75 @@ export class SelectionSlider extends _SelectionBase { set readout(v: boolean) { this.set('readout', v); } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } + get behavior(): string { + return this.get('behavior') as string; + } + set behavior(v: string) { + this.set('behavior', v); + } +} + +export class SelectionRangeSlider extends _MultipleSelectionBase { + static override modelName = 'SelectionRangeSliderModel'; + static override viewName = 'SelectionRangeSliderView'; + + constructor(state?: Record & { options?: string[] }) { + const next = { ...(state ?? {}) }; + const options = next.options as string[] | undefined; + if (options && next.index === undefined) { + next.index = options.length > 0 ? [0, 0] : []; + } + super(next as Record & { options?: string[] }); + } + + protected override _defaults(): Record { + return { + ...super._defaults(), + index: [0, 0], + orientation: 'horizontal', + readout: true, + continuous_update: true, + behavior: 'drag-tap' + }; + } + + get orientation(): string { + return this.get('orientation') as string; + } + set orientation(v: string) { + this.set('orientation', v); + } + get readout(): boolean { + return this.get('readout') as boolean; + } + set readout(v: boolean) { + this.set('readout', v); + } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } + get behavior(): string { + return this.get('behavior') as string; + } + set behavior(v: string) { + this.set('behavior', v); + } } // --------------------------------------------------------------------------- // String / text // --------------------------------------------------------------------------- -class _TextBase extends Widget { +class _TextBase extends DOMWidget { protected override _defaults() { return { ...super._defaults(), @@ -729,7 +1406,7 @@ export class Combobox extends _TextBase { // Display / output // --------------------------------------------------------------------------- -class _DisplayBase extends Widget { +class _DisplayBase extends DOMWidget { protected override _defaults() { return { ...super._defaults(), value: '' }; } @@ -757,11 +1434,102 @@ export class HTMLMath extends _DisplayBase { static override viewName = 'HTMLMathView'; } +export class Output extends DOMWidget { + static override modelName = 'OutputModel'; + static override viewName = 'OutputView'; + static override modelModule = OUTPUT_MODULE; + static override modelModuleVersion = OUTPUT_MODULE_VERSION; + static override viewModule = OUTPUT_MODULE; + static override viewModuleVersion = OUTPUT_MODULE_VERSION; + + protected override _defaults() { + return { + ...super._defaults(), + msg_id: '', + outputs: [] + }; + } + + get msg_id(): string { + return this.get('msg_id') as string; + } + set msg_id(v: string) { + this.set('msg_id', v); + } + + get outputs(): Array> { + return [...((this.get('outputs') as Array>) ?? [])]; + } + set outputs(v: Array>) { + this.set('outputs', [...v]); + } + + get currentMessageId(): string | null { + return this._manager.getCurrentMessageId(); + } + + clearOutput(_options: { wait?: boolean } = {}): void { + this.outputs = []; + } + + appendStdout(text: string): void { + this._appendOutput({ output_type: 'stream', name: 'stdout', text }); + } + + appendStderr(text: string): void { + this._appendOutput({ output_type: 'stream', name: 'stderr', text }); + } + + appendDisplayData( + data: Record, + metadata: Record = {} + ): void { + this._appendOutput({ + output_type: 'display_data', + data, + metadata + }); + } + + capture any>(callback: T): T; + capture any>( + callback: T, + options: IOutputCaptureOptions + ): T; + capture( + options?: IOutputCaptureOptions + ): any>(callback: T) => T; + capture any>( + callbackOrOptions?: T | IOutputCaptureOptions, + options: IOutputCaptureOptions = {} + ): T | ((callback: T) => T) { + if (typeof callbackOrOptions === 'function') { + return this._captureWrapper(callbackOrOptions, options); + } + + const captureOptions = callbackOrOptions ?? {}; + return (callback: T) => this._captureWrapper(callback, captureOptions); + } + + private _appendOutput(output: Record): void { + this.outputs = [...this.outputs, output]; + } + + private _captureWrapper any>( + callback: T, + options: IOutputCaptureOptions + ): T { + return _wrapCapturedOutputCallback(this, callback, options); + } + + _captureDepth = 0; +} + // --------------------------------------------------------------------------- // Button // --------------------------------------------------------------------------- -export class Button extends Widget { +export class Button extends DOMWidget { static override modelName = 'ButtonModel'; static override viewName = 'ButtonView'; @@ -819,7 +1587,7 @@ export class Button extends Widget { // Color picker // --------------------------------------------------------------------------- -export class ColorPicker extends Widget { +export class ColorPicker extends DOMWidget { static override modelName = 'ColorPickerModel'; static override viewName = 'ColorPickerView'; @@ -853,29 +1621,24 @@ function _serializeChildren(children: Widget[]): string[] { return children.map(w => `IPY_MODEL_${w.commId}`); } -export class Box extends Widget { +export class Box extends DOMWidget { static override modelName = 'BoxModel'; static override viewName = 'BoxView'; - protected _children: Widget[]; - constructor(state?: Record & { children?: Widget[] }) { const { children, ...rest } = state ?? {}; - const childrenArr = children ?? []; - super({ ...rest, children: _serializeChildren(childrenArr) }); - this._children = [...childrenArr]; + super({ ...rest, children: children ?? [] }); } protected override _defaults() { - return { children: [], box_style: '' }; + return { ...super._defaults(), children: [], box_style: '' }; } get children(): Widget[] { - return this._children; + return [...((this.get('children') as Widget[]) ?? [])]; } set children(v: Widget[]) { - this._children = [...v]; - this.set('children', _serializeChildren(v)); + this.set('children', [...v]); } get box_style(): string { @@ -885,18 +1648,23 @@ export class Box extends Widget { this.set('box_style', v); } - protected override _handleMsg( - data: Record, - buffers?: ArrayBuffer[] - ): void { - super._handleMsg(data, buffers); + protected override _serializeProperty(name: string, value: unknown): unknown { + if (name === 'children') { + return _serializeChildren( + Array.isArray(value) ? (value as Widget[]) : [] + ); + } + return super._serializeProperty(name, value); + } - if (data.method === 'update' && data.state) { - const state = data.state as Record; - if (Object.prototype.hasOwnProperty.call(state, 'children')) { - this._children = _deserializeChildren(this._manager, state.children); - } + protected override _deserializeProperty( + name: string, + value: unknown + ): unknown { + if (name === 'children') { + return _deserializeChildren(this._manager, value); } + return super._deserializeProperty(name, value); } } @@ -926,16 +1694,15 @@ class _SelectionContainer extends Box { titles?: string[]; } ) { - const s = { ...(state ?? {}) }; - const childCount = (s.children as Widget[] | undefined)?.length ?? 0; - // Pad titles with empty strings to match children count - const titles = (s.titles as string[] | undefined) ?? []; + const next = { ...(state ?? {}) }; + const childCount = (next.children as Widget[] | undefined)?.length ?? 0; + const titles = (next.titles as string[] | undefined) ?? []; const padded = [...titles]; while (padded.length < childCount) { padded.push(''); } - s.titles = padded; - super(s as Record & { children?: Widget[] }); + next.titles = padded; + super(next as Record & { children?: Widget[] }); } protected override _defaults() { @@ -960,12 +1727,12 @@ class _SelectionContainer extends Box { * Set the title of a container page. */ setTitle(index: number, title: string): void { - const t = [...this.titles]; - while (t.length <= index) { - t.push(''); + const next = [...this.titles]; + while (next.length <= index) { + next.push(''); } - t[index] = title; - this.titles = t; + next[index] = title; + this.titles = next; } /** @@ -991,14 +1758,16 @@ export class Tab extends _SelectionContainer { titles?: string[]; } ) { - const s = { ...(state ?? {}) }; - const children = (s.children as Widget[] | undefined) ?? []; - // Default to first tab selected when there are children - if (children.length > 0 && s.selected_index === undefined) { - s.selected_index = 0; + const next = { ...(state ?? {}) }; + const children = (next.children as Widget[] | undefined) ?? []; + if (children.length > 0 && next.selected_index === undefined) { + next.selected_index = 0; } super( - s as Record & { children?: Widget[]; titles?: string[] } + next as Record & { + children?: Widget[]; + titles?: string[]; + } ); } } @@ -1008,6 +1777,81 @@ export class Stack extends _SelectionContainer { static override viewName = 'StackView'; } +// --------------------------------------------------------------------------- +// Frontend-only link helpers +// --------------------------------------------------------------------------- + +export class DirectionalLink extends Widget { + static override modelName = 'DirectionalLinkModel'; + static override viewName = null; + + protected override _defaults() { + return { + source: null, + target: null + }; + } + + constructor( + state?: Record & { + source?: WidgetTraitPair; + target?: WidgetTraitPair; + } + ) { + super(state as Record); + } + + get source(): WidgetTraitPair | null { + return (this.get('source') as WidgetTraitPair | null) ?? null; + } + set source(v: WidgetTraitPair | null) { + this.set('source', v); + } + + get target(): WidgetTraitPair | null { + return (this.get('target') as WidgetTraitPair | null) ?? null; + } + set target(v: WidgetTraitPair | null) { + this.set('target', v); + } + + unlink(): void { + this.close(); + } + + protected override _serializeProperty(name: string, value: unknown): unknown { + if ((name === 'source' || name === 'target') && value !== undefined) { + return _serializeWidgetPair(value as WidgetTraitPair | null); + } + return super._serializeProperty(name, value); + } + + protected override _deserializeProperty( + name: string, + value: unknown + ): unknown { + if (name === 'source' || name === 'target') { + return _deserializeWidgetPair(this._manager, value); + } + return super._deserializeProperty(name, value); + } +} + +export class Link extends DirectionalLink { + static override modelName = 'LinkModel'; +} + +export function jslink(source: WidgetTraitPair, target: WidgetTraitPair): Link { + return new Link({ source, target }); +} + +export function jsdlink( + source: WidgetTraitPair, + target: WidgetTraitPair +): DirectionalLink { + return new DirectionalLink({ source, target }); +} + // --------------------------------------------------------------------------- // Convenience map of all exported widget classes // --------------------------------------------------------------------------- @@ -1017,8 +1861,26 @@ export class Stack extends _SelectionContainer { */ export const widgetClasses: Record = { Widget, + DOMWidget, + Layout, + Style, + DescriptionStyle, + SliderStyle, + ProgressStyle, + ButtonStyle, + CheckboxStyle, + ToggleButtonStyle, + ToggleButtonsStyle, + TextStyle, + HTMLStyle, + HTMLMathStyle, + LabelStyle, IntSlider, FloatSlider, + FloatLogSlider, + IntRangeSlider, + FloatRangeSlider, + Play, IntProgress, FloatProgress, IntText, @@ -1031,8 +1893,10 @@ export const widgetClasses: Record = { Dropdown, RadioButtons, Select, + SelectMultiple, ToggleButtons, SelectionSlider, + SelectionRangeSlider, Text, Textarea, Password, @@ -1040,6 +1904,7 @@ export const widgetClasses: Record = { Label, HTML, HTMLMath, + Output, Button, ColorPicker, Box, @@ -1048,7 +1913,9 @@ export const widgetClasses: Record = { GridBox, Accordion, Tab, - Stack + Stack, + Link, + DirectionalLink }; /** @@ -1056,8 +1923,8 @@ export const widgetClasses: Record = { */ export function createWidgetClasses( manager: CommManager -): Record { - const classes: Record = {}; +): Record { + const classes: Record = {}; for (const [name, cls] of Object.entries(widgetClasses)) { const BoundWidgetClass = class extends cls {}; @@ -1066,6 +1933,15 @@ export function createWidgetClasses( classes[name] = BoundWidgetClass; } + const LinkClass = classes.Link as typeof Link; + const DirectionalLinkClass = + classes.DirectionalLink as typeof DirectionalLink; + + classes.jslink = (source: WidgetTraitPair, target: WidgetTraitPair) => + new LinkClass({ source, target }); + classes.jsdlink = (source: WidgetTraitPair, target: WidgetTraitPair) => + new DirectionalLinkClass({ source, target }); + return classes; } @@ -1081,13 +1957,92 @@ function _deserializeChildren( } return children - .map(child => { - if (typeof child !== 'string' || !child.startsWith('IPY_MODEL_')) { - return null; - } - return ( - manager.getWidget(child.slice('IPY_MODEL_'.length)) ?? null - ); - }) + .map(child => _deserializeWidgetReference(manager, child)) .filter((child): child is Widget => child instanceof Widget); } + +function _serializeWidgetReference(widget: Widget | null): string | null { + return widget ? `IPY_MODEL_${widget.commId}` : null; +} + +function _deserializeWidgetReference( + manager: CommManager, + value: unknown +): Widget | null { + if (value === null) { + return null; + } + if (typeof value !== 'string' || !value.startsWith('IPY_MODEL_')) { + return null; + } + return manager.getWidget(value.slice('IPY_MODEL_'.length)) ?? null; +} + +function _serializeWidgetPair( + pair: WidgetTraitPair | null +): [string, string] | null { + if (!pair) { + return null; + } + return [`IPY_MODEL_${pair[0].commId}`, pair[1]]; +} + +function _deserializeWidgetPair( + manager: CommManager, + value: unknown +): WidgetTraitPair | null { + if (!Array.isArray(value) || value.length !== 2) { + return null; + } + + const widget = _deserializeWidgetReference(manager, value[0]); + const name = value[1]; + if (!(widget instanceof Widget) || typeof name !== 'string') { + return null; + } + + return [widget, name]; +} + +function _wrapCapturedOutputCallback any>( + output: Output, + callback: T, + options: IOutputCaptureOptions +): T { + return function (this: unknown, ...args: any[]): any { + const shouldClear = options.clearOutput ?? false; + if (shouldClear) { + output.clearOutput({ wait: options.wait }); + } + + const messageId = output.currentMessageId; + if (messageId) { + if (output._captureDepth === 0) { + output.msg_id = messageId; + } + output._captureDepth += 1; + } + + const finish = (): void => { + if (!messageId) { + return; + } + output._captureDepth = Math.max(0, output._captureDepth - 1); + if (output._captureDepth === 0 && output.msg_id === messageId) { + output.msg_id = ''; + } + }; + + try { + const result = callback.apply(this, args); + if (result && typeof (result as Promise).then === 'function') { + return Promise.resolve(result).finally(finish); + } + finish(); + return result; + } catch (error) { + finish(); + throw error; + } + } as T; +} From 5ce1e75b974cee90b26ebbe63169b26bfca7f9e9 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Thu, 12 Mar 2026 21:17:48 +0100 Subject: [PATCH 5/6] refactor --- packages/javascript-kernel/src/comm/index.ts | 4 + .../src/{comm.ts => comm/manager.ts} | 2 +- packages/javascript-kernel/src/widgets.ts | 2048 ----------------- .../javascript-kernel/src/widgets/index.ts | 167 ++ .../javascript-kernel/src/widgets/version.ts | 10 + .../javascript-kernel/src/widgets/widget.ts | 442 ++++ .../src/widgets/widget_bool.ts | 88 + .../src/widgets/widget_box.ts | 87 + .../src/widgets/widget_button.ts | 58 + .../src/widgets/widget_color.ts | 26 + .../src/widgets/widget_float.ts | 127 + .../src/widgets/widget_int.ts | 145 ++ .../src/widgets/widget_layout.ts | 18 + .../src/widgets/widget_link.ts | 80 + .../src/widgets/widget_number.ts | 220 ++ .../src/widgets/widget_output.ts | 144 ++ .../src/widgets/widget_selection.ts | 270 +++ .../src/widgets/widget_selectioncontainer.ts | 95 + .../src/widgets/widget_string.ts | 107 + .../src/widgets/widget_style.ts | 77 + 20 files changed, 2166 insertions(+), 2049 deletions(-) create mode 100644 packages/javascript-kernel/src/comm/index.ts rename packages/javascript-kernel/src/{comm.ts => comm/manager.ts} (98%) delete mode 100644 packages/javascript-kernel/src/widgets.ts create mode 100644 packages/javascript-kernel/src/widgets/index.ts create mode 100644 packages/javascript-kernel/src/widgets/version.ts create mode 100644 packages/javascript-kernel/src/widgets/widget.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_bool.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_box.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_button.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_color.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_float.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_int.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_layout.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_link.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_number.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_output.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_selection.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_selectioncontainer.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_string.ts create mode 100644 packages/javascript-kernel/src/widgets/widget_style.ts diff --git a/packages/javascript-kernel/src/comm/index.ts b/packages/javascript-kernel/src/comm/index.ts new file mode 100644 index 0000000..b176b8c --- /dev/null +++ b/packages/javascript-kernel/src/comm/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +export * from './manager'; diff --git a/packages/javascript-kernel/src/comm.ts b/packages/javascript-kernel/src/comm/manager.ts similarity index 98% rename from packages/javascript-kernel/src/comm.ts rename to packages/javascript-kernel/src/comm/manager.ts index 696c38c..151067b 100644 --- a/packages/javascript-kernel/src/comm.ts +++ b/packages/javascript-kernel/src/comm/manager.ts @@ -1,7 +1,7 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import type { RuntimeOutputHandler } from './runtime_protocol'; +import type { RuntimeOutputHandler } from '../runtime_protocol'; /** * Represents an open comm channel. diff --git a/packages/javascript-kernel/src/widgets.ts b/packages/javascript-kernel/src/widgets.ts deleted file mode 100644 index 9aeb152..0000000 --- a/packages/javascript-kernel/src/widgets.ts +++ /dev/null @@ -1,2048 +0,0 @@ -// Copyright (c) Jupyter Development Team. -// Distributed under the terms of the Modified BSD License. - -import type { CommManager, IComm } from './comm'; - -const WIDGET_PROTOCOL_VERSION = '2.1.0'; -const BASE_MODULE = '@jupyter-widgets/base'; -const BASE_MODULE_VERSION = '2.0.0'; -const CONTROLS_MODULE = '@jupyter-widgets/controls'; -const CONTROLS_MODULE_VERSION = '2.0.0'; -const OUTPUT_MODULE = '@jupyter-widgets/output'; -const OUTPUT_MODULE_VERSION = '1.0.0'; - -type WidgetEventCallback = (...args: any[]) => void; - -export interface IWidgetChange { - name: string; - new: unknown; - old: unknown; - owner: Widget; - type: 'change'; -} - -export type WidgetObserveCallback = (change: IWidgetChange) => void; - -export type WidgetTraitPair = [Widget, string]; - -export interface IOutputCaptureOptions { - clearOutput?: boolean; - wait?: boolean; -} - -/** - * Base class for Jupyter widgets. - * - * Wraps the low-level comm protocol so user code can work with - * familiar property access and change events instead of raw messages. - */ -export class Widget { - static modelName = ''; - static viewName: string | null = ''; - static modelModule = CONTROLS_MODULE; - static modelModuleVersion = CONTROLS_MODULE_VERSION; - static viewModule = CONTROLS_MODULE; - static viewModuleVersion = CONTROLS_MODULE_VERSION; - - protected static _defaultManager: CommManager | null = null; - - /** - * Set the default CommManager used by all Widget instances. - */ - static setDefaultManager(manager: CommManager | null): void { - this._defaultManager = manager; - } - - constructor(state?: Record) { - const ctor = this.constructor as typeof Widget; - const manager = ctor._defaultManager; - if (!manager) { - throw new Error( - 'Widget manager not initialized. Widgets can only be created inside the kernel runtime.' - ); - } - - this._manager = manager; - this._state = { - ...this._defaults(), - ...state, - ...this._modelState(ctor) - }; - this._listeners = new Map(); - this._observerWrappers = new Map(); - - this._comm = manager.open( - 'jupyter.widget', - { state: this._serializeState(this._state), buffer_paths: [] }, - { version: WIDGET_PROTOCOL_VERSION } - ); - - this._comm.onMsg = (data, buffers) => { - this._handleMsg(data, buffers); - }; - this._comm.onClose = data => { - this._manager.unregisterWidget(this.commId); - this._trigger('close', data); - }; - this._manager.registerWidget(this.commId, this); - } - - /** - * Close the widget and its comm channel. - */ - close(): void { - this._comm.close(); - } - - /** - * Get a state property. - */ - get(name: string): unknown { - return this._state[name]; - } - - /** - * Set one or more state properties and sync to the frontend. - */ - set(name: string, value: unknown): void; - set(state: Record): void; - set(nameOrState: string | Record, value?: unknown): void { - const updates: Record = - typeof nameOrState === 'string' ? { [nameOrState]: value } : nameOrState; - - const changes: Array<[string, unknown, unknown]> = []; - for (const [key, val] of Object.entries(updates)) { - const old = this._state[key]; - if (old !== val) { - this._state[key] = val; - changes.push([key, val, old]); - } - } - - if (changes.length > 0) { - this._comm.send({ - method: 'update', - state: this._serializeState(updates), - buffer_paths: [] - }); - for (const [key, val, old] of changes) { - this._trigger(`change:${key}`, val, old); - } - this._trigger('change', changes); - } - } - - /** - * Listen for widget events. - * - * Events: - * - `'change:propName'` - property changed `(newValue, oldValue)` - * - `'change'` - any property changed `(changes)` - * - `'close'` - comm closed - */ - on(event: string, callback: WidgetEventCallback): this { - if (!this._listeners.has(event)) { - this._listeners.set(event, new Set()); - } - this._listeners.get(event)!.add(callback); - return this; - } - - /** - * Remove an event listener. - */ - off(event: string, callback: WidgetEventCallback): this { - this._listeners.get(event)?.delete(callback); - return this; - } - - /** - * Observe changes using an ipywidgets-style callback payload. - */ - observe(callback: WidgetObserveCallback, names?: string | string[]): this { - for (const name of this._normalizeObserveNames(names)) { - const eventName = name === '*' ? 'change' : `change:${name}`; - const wrapper = this._observerWrapper(callback, name); - this.on(eventName, wrapper); - } - return this; - } - - /** - * Remove an observe callback. - */ - unobserve(callback: WidgetObserveCallback, names?: string | string[]): this { - const wrappers = this._observerWrappers.get(callback); - if (!wrappers) { - return this; - } - - const keys = names - ? this._normalizeObserveNames(names) - : [...wrappers.keys()]; - for (const name of keys) { - const wrapper = wrappers.get(name); - if (!wrapper) { - continue; - } - const eventName = name === '*' ? 'change' : `change:${name}`; - this.off(eventName, wrapper); - wrappers.delete(name); - } - - if (wrappers.size === 0) { - this._observerWrappers.delete(callback); - } - - return this; - } - - /** - * The widget's comm/model ID. - */ - get commId(): string { - return this._comm.commId; - } - - get description(): string { - return this.get('description') as string; - } - set description(v: string) { - this.set('description', v); - } - - get disabled(): boolean { - return this.get('disabled') as boolean; - } - set disabled(v: boolean) { - this.set('disabled', v); - } - - protected _defaults(): Record { - return { description: '', disabled: false }; - } - - protected _handleMsg( - data: Record, - buffers?: ArrayBuffer[] - ): void { - if (data.method === 'update' && data.state) { - const state = data.state as Record; - const changes: Array<[string, unknown, unknown]> = []; - for (const [key, val] of Object.entries(state)) { - const next = this._deserializeProperty(key, val); - const old = this._state[key]; - if (old !== next) { - this._state[key] = next; - changes.push([key, next, old]); - } - } - for (const [key, val, old] of changes) { - this._trigger(`change:${key}`, val, old); - } - if (changes.length > 0) { - this._trigger('change', changes); - } - } - - if (data.method === 'request_state') { - this._comm.send({ - method: 'update', - state: this._serializeState(this._state), - buffer_paths: [] - }); - } - } - - protected _serializeState( - state: Record - ): Record { - const serialized: Record = {}; - for (const [key, value] of Object.entries(state)) { - serialized[key] = this._serializeProperty(key, value); - } - return serialized; - } - - protected _serializeProperty(name: string, value: unknown): unknown { - return value; - } - - protected _deserializeProperty(name: string, value: unknown): unknown { - return value; - } - - protected _trigger(event: string, ...args: unknown[]): void { - for (const cb of this._listeners.get(event) ?? []) { - try { - cb(...args); - } catch (e) { - console.error(`[Widget] Error in '${event}' handler:`, e); - } - } - } - - private _modelState(ctor: typeof Widget): Record { - const state: Record = { - _model_name: ctor.modelName, - _model_module: ctor.modelModule, - _model_module_version: ctor.modelModuleVersion - }; - - if (ctor.viewName !== null) { - state._view_name = ctor.viewName; - state._view_module = ctor.viewModule; - state._view_module_version = ctor.viewModuleVersion; - } - - return state; - } - - private _normalizeObserveNames(names?: string | string[]): string[] { - if (names === undefined) { - return ['*']; - } - return Array.isArray(names) ? names : [names]; - } - - private _observerWrapper( - callback: WidgetObserveCallback, - name: string - ): WidgetEventCallback { - let wrappers = this._observerWrappers.get(callback); - if (!wrappers) { - wrappers = new Map(); - this._observerWrappers.set(callback, wrappers); - } - - const existing = wrappers.get(name); - if (existing) { - return existing; - } - - const wrapper: WidgetEventCallback = - name === '*' - ? (changes: Array<[string, unknown, unknown]>) => { - for (const [changeName, next, old] of changes) { - callback({ - name: changeName, - new: next, - old, - owner: this, - type: 'change' - }); - } - } - : (next: unknown, old: unknown) => { - callback({ - name, - new: next, - old, - owner: this, - type: 'change' - }); - }; - - wrappers.set(name, wrapper); - return wrapper; - } - - protected _comm: IComm; - protected _manager: CommManager; - protected _state: Record; - private _listeners: Map>; - private _observerWrappers: Map< - WidgetObserveCallback, - Map - >; -} - -/** - * A widget with layout/style support. - */ -export class DOMWidget extends Widget { - protected override _defaults(): Record { - return { ...super._defaults(), layout: null, style: null }; - } - - get layout(): Layout | null { - return (this.get('layout') as Layout | null) ?? null; - } - set layout(v: Layout | null) { - this.set('layout', v); - } - - get style(): Style | null { - return (this.get('style') as Style | null) ?? null; - } - set style(v: Style | null) { - this.set('style', v); - } - - protected override _serializeProperty(name: string, value: unknown): unknown { - if ( - (name === 'layout' || name === 'style') && - (value instanceof Widget || value === null) - ) { - return _serializeWidgetReference(value); - } - return super._serializeProperty(name, value); - } - - protected override _deserializeProperty( - name: string, - value: unknown - ): unknown { - if (name === 'layout' || name === 'style') { - return _deserializeWidgetReference(this._manager, value); - } - return super._deserializeProperty(name, value); - } -} - -// --------------------------------------------------------------------------- -// Layout and style models -// --------------------------------------------------------------------------- - -export class Layout extends Widget { - static override modelName = 'LayoutModel'; - static override viewName = 'LayoutView'; - static override modelModule = BASE_MODULE; - static override modelModuleVersion = BASE_MODULE_VERSION; - static override viewModule = BASE_MODULE; - static override viewModuleVersion = BASE_MODULE_VERSION; - - protected override _defaults(): Record { - return {}; - } -} - -export class Style extends Widget { - static override modelName = 'StyleModel'; - static override viewName = 'StyleView'; - static override modelModule = BASE_MODULE; - static override modelModuleVersion = BASE_MODULE_VERSION; - static override viewModule = BASE_MODULE; - static override viewModuleVersion = BASE_MODULE_VERSION; - - protected override _defaults(): Record { - return {}; - } -} - -export class DescriptionStyle extends Style { - static override modelName = 'DescriptionStyleModel'; - static override modelModule = CONTROLS_MODULE; - static override modelModuleVersion = CONTROLS_MODULE_VERSION; -} - -export class SliderStyle extends DescriptionStyle { - static override modelName = 'SliderStyleModel'; -} - -export class ProgressStyle extends DescriptionStyle { - static override modelName = 'ProgressStyleModel'; -} - -export class ButtonStyle extends Style { - static override modelName = 'ButtonStyleModel'; - static override modelModule = CONTROLS_MODULE; - static override modelModuleVersion = CONTROLS_MODULE_VERSION; -} - -export class CheckboxStyle extends DescriptionStyle { - static override modelName = 'CheckboxStyleModel'; -} - -export class ToggleButtonStyle extends DescriptionStyle { - static override modelName = 'ToggleButtonStyleModel'; -} - -export class ToggleButtonsStyle extends DescriptionStyle { - static override modelName = 'ToggleButtonsStyleModel'; -} - -export class TextStyle extends DescriptionStyle { - static override modelName = 'TextStyleModel'; -} - -export class HTMLStyle extends Style { - static override modelName = 'HTMLStyleModel'; - static override modelModule = CONTROLS_MODULE; - static override modelModuleVersion = CONTROLS_MODULE_VERSION; -} - -export class HTMLMathStyle extends Style { - static override modelName = 'HTMLMathStyleModel'; - static override modelModule = CONTROLS_MODULE; - static override modelModuleVersion = CONTROLS_MODULE_VERSION; -} - -export class LabelStyle extends Style { - static override modelName = 'LabelStyleModel'; - static override modelModule = CONTROLS_MODULE; - static override modelModuleVersion = CONTROLS_MODULE_VERSION; -} - -// --------------------------------------------------------------------------- -// Numeric - sliders -// --------------------------------------------------------------------------- - -class _SliderBase extends DOMWidget { - protected override _defaults() { - return { - ...super._defaults(), - value: 0, - min: 0, - max: 100, - step: 1, - orientation: 'horizontal', - readout: true, - continuous_update: true, - behavior: 'drag-tap' - }; - } - - get value(): number { - return this.get('value') as number; - } - set value(v: number) { - this.set('value', v); - } - get min(): number { - return this.get('min') as number; - } - set min(v: number) { - this.set('min', v); - } - get max(): number { - return this.get('max') as number; - } - set max(v: number) { - this.set('max', v); - } - get step(): number { - return this.get('step') as number; - } - set step(v: number) { - this.set('step', v); - } - get orientation(): string { - return this.get('orientation') as string; - } - set orientation(v: string) { - this.set('orientation', v); - } - get readout(): boolean { - return this.get('readout') as boolean; - } - set readout(v: boolean) { - this.set('readout', v); - } - get continuous_update(): boolean { - return this.get('continuous_update') as boolean; - } - set continuous_update(v: boolean) { - this.set('continuous_update', v); - } - get behavior(): string { - return this.get('behavior') as string; - } - set behavior(v: string) { - this.set('behavior', v); - } -} - -export class IntSlider extends _SliderBase { - static override modelName = 'IntSliderModel'; - static override viewName = 'IntSliderView'; - - protected override _defaults() { - return { - ...super._defaults(), - readout_format: 'd' - }; - } - - get readout_format(): string { - return this.get('readout_format') as string; - } - set readout_format(v: string) { - this.set('readout_format', v); - } -} - -export class FloatSlider extends _SliderBase { - static override modelName = 'FloatSliderModel'; - static override viewName = 'FloatSliderView'; - - protected override _defaults() { - return { - ...super._defaults(), - max: 10.0, - step: 0.1, - readout_format: '.2f' - }; - } - - get readout_format(): string { - return this.get('readout_format') as string; - } - set readout_format(v: string) { - this.set('readout_format', v); - } -} - -export class FloatLogSlider extends _SliderBase { - static override modelName = 'FloatLogSliderModel'; - static override viewName = 'FloatLogSliderView'; - - protected override _defaults() { - return { - ...super._defaults(), - min: 0.0, - max: 4.0, - base: 10.0, - value: 1.0, - step: 0.1, - readout_format: '.3g' - }; - } - - get base(): number { - return this.get('base') as number; - } - set base(v: number) { - this.set('base', v); - } - get readout_format(): string { - return this.get('readout_format') as string; - } - set readout_format(v: string) { - this.set('readout_format', v); - } -} - -class _RangeSliderBase extends DOMWidget { - protected override _defaults() { - return { - ...super._defaults(), - value: [0, 1], - min: 0, - max: 100, - step: 1, - orientation: 'horizontal', - readout: true, - continuous_update: true, - behavior: 'drag-tap' - }; - } - - get value(): [number, number] { - return this.get('value') as [number, number]; - } - set value(v: [number, number]) { - this.set('value', v); - } - get lower(): number { - return this.value[0]; - } - set lower(v: number) { - this.value = [v, this.value[1]]; - } - get upper(): number { - return this.value[1]; - } - set upper(v: number) { - this.value = [this.value[0], v]; - } - get min(): number { - return this.get('min') as number; - } - set min(v: number) { - this.set('min', v); - } - get max(): number { - return this.get('max') as number; - } - set max(v: number) { - this.set('max', v); - } - get step(): number { - return this.get('step') as number; - } - set step(v: number) { - this.set('step', v); - } - get orientation(): string { - return this.get('orientation') as string; - } - set orientation(v: string) { - this.set('orientation', v); - } - get readout(): boolean { - return this.get('readout') as boolean; - } - set readout(v: boolean) { - this.set('readout', v); - } - get continuous_update(): boolean { - return this.get('continuous_update') as boolean; - } - set continuous_update(v: boolean) { - this.set('continuous_update', v); - } - get behavior(): string { - return this.get('behavior') as string; - } - set behavior(v: string) { - this.set('behavior', v); - } -} - -export class IntRangeSlider extends _RangeSliderBase { - static override modelName = 'IntRangeSliderModel'; - static override viewName = 'IntRangeSliderView'; - - protected override _defaults() { - return { ...super._defaults(), readout_format: 'd' }; - } - - get readout_format(): string { - return this.get('readout_format') as string; - } - set readout_format(v: string) { - this.set('readout_format', v); - } -} - -export class FloatRangeSlider extends _RangeSliderBase { - static override modelName = 'FloatRangeSliderModel'; - static override viewName = 'FloatRangeSliderView'; - - protected override _defaults() { - return { - ...super._defaults(), - value: [0.0, 1.0], - step: 0.1, - readout_format: '.2f' - }; - } - - get readout_format(): string { - return this.get('readout_format') as string; - } - set readout_format(v: string) { - this.set('readout_format', v); - } -} - -export class Play extends DOMWidget { - static override modelName = 'PlayModel'; - static override viewName = 'PlayView'; - - protected override _defaults() { - return { - ...super._defaults(), - value: 0, - min: 0, - max: 100, - step: 1, - repeat: false, - playing: false, - show_repeat: true, - interval: 100 - }; - } - - get value(): number { - return this.get('value') as number; - } - set value(v: number) { - this.set('value', v); - } - get min(): number { - return this.get('min') as number; - } - set min(v: number) { - this.set('min', v); - } - get max(): number { - return this.get('max') as number; - } - set max(v: number) { - this.set('max', v); - } - get step(): number { - return this.get('step') as number; - } - set step(v: number) { - this.set('step', v); - } - get repeat(): boolean { - return this.get('repeat') as boolean; - } - set repeat(v: boolean) { - this.set('repeat', v); - } - get playing(): boolean { - return this.get('playing') as boolean; - } - set playing(v: boolean) { - this.set('playing', v); - } - get show_repeat(): boolean { - return this.get('show_repeat') as boolean; - } - set show_repeat(v: boolean) { - this.set('show_repeat', v); - } - get interval(): number { - return this.get('interval') as number; - } - set interval(v: number) { - this.set('interval', v); - } -} - -// --------------------------------------------------------------------------- -// Numeric - progress -// --------------------------------------------------------------------------- - -class _ProgressBase extends DOMWidget { - protected override _defaults() { - return { - ...super._defaults(), - value: 0, - min: 0, - max: 100, - bar_style: '', - orientation: 'horizontal' - }; - } - - get value(): number { - return this.get('value') as number; - } - set value(v: number) { - this.set('value', v); - } - get min(): number { - return this.get('min') as number; - } - set min(v: number) { - this.set('min', v); - } - get max(): number { - return this.get('max') as number; - } - set max(v: number) { - this.set('max', v); - } - get bar_style(): string { - return this.get('bar_style') as string; - } - set bar_style(v: string) { - this.set('bar_style', v); - } - get orientation(): string { - return this.get('orientation') as string; - } - set orientation(v: string) { - this.set('orientation', v); - } -} - -export class IntProgress extends _ProgressBase { - static override modelName = 'IntProgressModel'; - static override viewName = 'ProgressView'; -} - -export class FloatProgress extends _ProgressBase { - static override modelName = 'FloatProgressModel'; - static override viewName = 'ProgressView'; - - protected override _defaults() { - return { ...super._defaults(), max: 10.0 }; - } -} - -// --------------------------------------------------------------------------- -// Numeric - text inputs -// --------------------------------------------------------------------------- - -class _NumericTextBase extends DOMWidget { - protected override _defaults() { - return { - ...super._defaults(), - value: 0, - step: 1, - continuous_update: false - }; - } - - get value(): number { - return this.get('value') as number; - } - set value(v: number) { - this.set('value', v); - } - get step(): number { - return this.get('step') as number; - } - set step(v: number) { - this.set('step', v); - } - get continuous_update(): boolean { - return this.get('continuous_update') as boolean; - } - set continuous_update(v: boolean) { - this.set('continuous_update', v); - } -} - -export class IntText extends _NumericTextBase { - static override modelName = 'IntTextModel'; - static override viewName = 'IntTextView'; -} - -export class FloatText extends _NumericTextBase { - static override modelName = 'FloatTextModel'; - static override viewName = 'FloatTextView'; - - protected override _defaults() { - return { ...super._defaults(), value: 0.0, step: 0.1 }; - } -} - -export class BoundedIntText extends _NumericTextBase { - static override modelName = 'BoundedIntTextModel'; - static override viewName = 'IntTextView'; - - protected override _defaults() { - return { ...super._defaults(), min: 0, max: 100 }; - } - - get min(): number { - return this.get('min') as number; - } - set min(v: number) { - this.set('min', v); - } - get max(): number { - return this.get('max') as number; - } - set max(v: number) { - this.set('max', v); - } -} - -export class BoundedFloatText extends _NumericTextBase { - static override modelName = 'BoundedFloatTextModel'; - static override viewName = 'FloatTextView'; - - protected override _defaults() { - return { - ...super._defaults(), - value: 0.0, - step: 0.1, - min: 0.0, - max: 100.0 - }; - } - - get min(): number { - return this.get('min') as number; - } - set min(v: number) { - this.set('min', v); - } - get max(): number { - return this.get('max') as number; - } - set max(v: number) { - this.set('max', v); - } -} - -// --------------------------------------------------------------------------- -// Boolean -// --------------------------------------------------------------------------- - -export class Checkbox extends DOMWidget { - static override modelName = 'CheckboxModel'; - static override viewName = 'CheckboxView'; - - protected override _defaults() { - return { ...super._defaults(), value: false, indent: true }; - } - - get value(): boolean { - return this.get('value') as boolean; - } - set value(v: boolean) { - this.set('value', v); - } - get indent(): boolean { - return this.get('indent') as boolean; - } - set indent(v: boolean) { - this.set('indent', v); - } -} - -export class ToggleButton extends DOMWidget { - static override modelName = 'ToggleButtonModel'; - static override viewName = 'ToggleButtonView'; - - protected override _defaults() { - return { - ...super._defaults(), - value: false, - tooltip: '', - icon: '', - button_style: '' - }; - } - - get value(): boolean { - return this.get('value') as boolean; - } - set value(v: boolean) { - this.set('value', v); - } - get tooltip(): string { - return this.get('tooltip') as string; - } - set tooltip(v: string) { - this.set('tooltip', v); - } - get icon(): string { - return this.get('icon') as string; - } - set icon(v: string) { - this.set('icon', v); - } - get button_style(): string { - return this.get('button_style') as string; - } - set button_style(v: string) { - this.set('button_style', v); - } -} - -export class Valid extends DOMWidget { - static override modelName = 'ValidModel'; - static override viewName = 'ValidView'; - - protected override _defaults() { - return { ...super._defaults(), value: false, readout: 'Invalid' }; - } - - get value(): boolean { - return this.get('value') as boolean; - } - set value(v: boolean) { - this.set('value', v); - } - get readout(): string { - return this.get('readout') as string; - } - set readout(v: string) { - this.set('readout', v); - } -} - -// --------------------------------------------------------------------------- -// Selection -// --------------------------------------------------------------------------- - -class _SelectionBase extends DOMWidget { - constructor(state?: Record & { options?: string[] }) { - const { options, ...rest } = state ?? {}; - if (options !== undefined) { - (rest as Record)._options_labels = options; - if (rest.index === undefined) { - (rest as Record).index = options.length > 0 ? 0 : null; - } - } - super(rest as Record); - } - - protected override _defaults(): Record { - return { ...super._defaults(), _options_labels: [], index: null }; - } - - get options(): string[] { - return this.get('_options_labels') as string[]; - } - set options(v: string[]) { - this.set({ _options_labels: v, index: v.length > 0 ? 0 : null }); - } - get index(): number | null { - return this.get('index') as number | null; - } - set index(v: number | null) { - this.set('index', v); - } - get value(): string | null { - return this.selectedLabel; - } - set value(v: string | null) { - if (v === null) { - this.index = null; - return; - } - const idx = this.options.indexOf(v); - this.index = idx === -1 ? null : idx; - } - get label(): string | null { - return this.selectedLabel; - } - set label(v: string | null) { - this.value = v; - } - - /** - * The label of the currently selected option, or null if none. - */ - get selectedLabel(): string | null { - const idx = this.index; - if (idx === null || idx === undefined) { - return null; - } - return this.options[idx] ?? null; - } -} - -class _MultipleSelectionBase extends DOMWidget { - constructor(state?: Record & { options?: string[] }) { - const { options, ...rest } = state ?? {}; - if (options !== undefined) { - (rest as Record)._options_labels = options; - if (rest.index === undefined) { - (rest as Record).index = []; - } - } - super(rest as Record); - } - - protected override _defaults(): Record { - return { ...super._defaults(), _options_labels: [], index: [] }; - } - - get options(): string[] { - return this.get('_options_labels') as string[]; - } - set options(v: string[]) { - this.set({ _options_labels: v, index: [] }); - } - get index(): number[] { - return [...((this.get('index') as number[]) ?? [])]; - } - set index(v: number[]) { - this.set('index', [...v]); - } - get value(): string[] { - return this.selectedLabels; - } - set value(v: string[]) { - this.index = v - .map(label => this.options.indexOf(label)) - .filter(idx => idx >= 0); - } - get label(): string[] { - return this.selectedLabels; - } - set label(v: string[]) { - this.value = v; - } - - get selectedLabels(): string[] { - return this.index.map(idx => this.options[idx]).filter(Boolean); - } -} - -export class Dropdown extends _SelectionBase { - static override modelName = 'DropdownModel'; - static override viewName = 'DropdownView'; -} - -export class RadioButtons extends _SelectionBase { - static override modelName = 'RadioButtonsModel'; - static override viewName = 'RadioButtonsView'; -} - -export class Select extends _SelectionBase { - static override modelName = 'SelectModel'; - static override viewName = 'SelectView'; - - protected override _defaults() { - return { ...super._defaults(), rows: 5 }; - } - - get rows(): number { - return this.get('rows') as number; - } - set rows(v: number) { - this.set('rows', v); - } -} - -export class SelectMultiple extends _MultipleSelectionBase { - static override modelName = 'SelectMultipleModel'; - static override viewName = 'SelectMultipleView'; - - protected override _defaults() { - return { ...super._defaults(), rows: 5 }; - } - - get rows(): number { - return this.get('rows') as number; - } - set rows(v: number) { - this.set('rows', v); - } -} - -export class ToggleButtons extends _SelectionBase { - static override modelName = 'ToggleButtonsModel'; - static override viewName = 'ToggleButtonsView'; - - protected override _defaults() { - return { ...super._defaults(), tooltips: [], button_style: '', icons: [] }; - } - - get tooltips(): string[] { - return this.get('tooltips') as string[]; - } - set tooltips(v: string[]) { - this.set('tooltips', v); - } - get button_style(): string { - return this.get('button_style') as string; - } - set button_style(v: string) { - this.set('button_style', v); - } - get icons(): string[] { - return this.get('icons') as string[]; - } - set icons(v: string[]) { - this.set('icons', v); - } -} - -export class SelectionSlider extends _SelectionBase { - static override modelName = 'SelectionSliderModel'; - static override viewName = 'SelectionSliderView'; - - protected override _defaults() { - return { - ...super._defaults(), - orientation: 'horizontal', - readout: true, - continuous_update: true, - behavior: 'drag-tap' - }; - } - - get orientation(): string { - return this.get('orientation') as string; - } - set orientation(v: string) { - this.set('orientation', v); - } - get readout(): boolean { - return this.get('readout') as boolean; - } - set readout(v: boolean) { - this.set('readout', v); - } - get continuous_update(): boolean { - return this.get('continuous_update') as boolean; - } - set continuous_update(v: boolean) { - this.set('continuous_update', v); - } - get behavior(): string { - return this.get('behavior') as string; - } - set behavior(v: string) { - this.set('behavior', v); - } -} - -export class SelectionRangeSlider extends _MultipleSelectionBase { - static override modelName = 'SelectionRangeSliderModel'; - static override viewName = 'SelectionRangeSliderView'; - - constructor(state?: Record & { options?: string[] }) { - const next = { ...(state ?? {}) }; - const options = next.options as string[] | undefined; - if (options && next.index === undefined) { - next.index = options.length > 0 ? [0, 0] : []; - } - super(next as Record & { options?: string[] }); - } - - protected override _defaults(): Record { - return { - ...super._defaults(), - index: [0, 0], - orientation: 'horizontal', - readout: true, - continuous_update: true, - behavior: 'drag-tap' - }; - } - - get orientation(): string { - return this.get('orientation') as string; - } - set orientation(v: string) { - this.set('orientation', v); - } - get readout(): boolean { - return this.get('readout') as boolean; - } - set readout(v: boolean) { - this.set('readout', v); - } - get continuous_update(): boolean { - return this.get('continuous_update') as boolean; - } - set continuous_update(v: boolean) { - this.set('continuous_update', v); - } - get behavior(): string { - return this.get('behavior') as string; - } - set behavior(v: string) { - this.set('behavior', v); - } -} - -// --------------------------------------------------------------------------- -// String / text -// --------------------------------------------------------------------------- - -class _TextBase extends DOMWidget { - protected override _defaults() { - return { - ...super._defaults(), - value: '', - placeholder: '', - continuous_update: true - }; - } - - get value(): string { - return this.get('value') as string; - } - set value(v: string) { - this.set('value', v); - } - get placeholder(): string { - return this.get('placeholder') as string; - } - set placeholder(v: string) { - this.set('placeholder', v); - } - get continuous_update(): boolean { - return this.get('continuous_update') as boolean; - } - set continuous_update(v: boolean) { - this.set('continuous_update', v); - } -} - -export class Text extends _TextBase { - static override modelName = 'TextModel'; - static override viewName = 'TextView'; -} - -export class Textarea extends _TextBase { - static override modelName = 'TextareaModel'; - static override viewName = 'TextareaView'; -} - -export class Password extends _TextBase { - static override modelName = 'PasswordModel'; - static override viewName = 'PasswordView'; -} - -export class Combobox extends _TextBase { - static override modelName = 'ComboboxModel'; - static override viewName = 'ComboboxView'; - - constructor(state?: Record & { options?: string[] }) { - const { options, ...rest } = state ?? {}; - if (options !== undefined) { - (rest as Record).options = options; - } - super(rest as Record); - } - - protected override _defaults() { - return { ...super._defaults(), options: [], ensure_option: false }; - } - - get options(): string[] { - return this.get('options') as string[]; - } - set options(v: string[]) { - this.set('options', v); - } - get ensure_option(): boolean { - return this.get('ensure_option') as boolean; - } - set ensure_option(v: boolean) { - this.set('ensure_option', v); - } -} - -// --------------------------------------------------------------------------- -// Display / output -// --------------------------------------------------------------------------- - -class _DisplayBase extends DOMWidget { - protected override _defaults() { - return { ...super._defaults(), value: '' }; - } - - get value(): string { - return this.get('value') as string; - } - set value(v: string) { - this.set('value', v); - } -} - -export class Label extends _DisplayBase { - static override modelName = 'LabelModel'; - static override viewName = 'LabelView'; -} - -export class HTML extends _DisplayBase { - static override modelName = 'HTMLModel'; - static override viewName = 'HTMLView'; -} - -export class HTMLMath extends _DisplayBase { - static override modelName = 'HTMLMathModel'; - static override viewName = 'HTMLMathView'; -} - -export class Output extends DOMWidget { - static override modelName = 'OutputModel'; - static override viewName = 'OutputView'; - static override modelModule = OUTPUT_MODULE; - static override modelModuleVersion = OUTPUT_MODULE_VERSION; - static override viewModule = OUTPUT_MODULE; - static override viewModuleVersion = OUTPUT_MODULE_VERSION; - - protected override _defaults() { - return { - ...super._defaults(), - msg_id: '', - outputs: [] - }; - } - - get msg_id(): string { - return this.get('msg_id') as string; - } - set msg_id(v: string) { - this.set('msg_id', v); - } - - get outputs(): Array> { - return [...((this.get('outputs') as Array>) ?? [])]; - } - set outputs(v: Array>) { - this.set('outputs', [...v]); - } - - get currentMessageId(): string | null { - return this._manager.getCurrentMessageId(); - } - - clearOutput(_options: { wait?: boolean } = {}): void { - this.outputs = []; - } - - appendStdout(text: string): void { - this._appendOutput({ output_type: 'stream', name: 'stdout', text }); - } - - appendStderr(text: string): void { - this._appendOutput({ output_type: 'stream', name: 'stderr', text }); - } - - appendDisplayData( - data: Record, - metadata: Record = {} - ): void { - this._appendOutput({ - output_type: 'display_data', - data, - metadata - }); - } - - capture any>(callback: T): T; - capture any>( - callback: T, - options: IOutputCaptureOptions - ): T; - capture( - options?: IOutputCaptureOptions - ): any>(callback: T) => T; - capture any>( - callbackOrOptions?: T | IOutputCaptureOptions, - options: IOutputCaptureOptions = {} - ): T | ((callback: T) => T) { - if (typeof callbackOrOptions === 'function') { - return this._captureWrapper(callbackOrOptions, options); - } - - const captureOptions = callbackOrOptions ?? {}; - return (callback: T) => this._captureWrapper(callback, captureOptions); - } - - private _appendOutput(output: Record): void { - this.outputs = [...this.outputs, output]; - } - - private _captureWrapper any>( - callback: T, - options: IOutputCaptureOptions - ): T { - return _wrapCapturedOutputCallback(this, callback, options); - } - - _captureDepth = 0; -} - -// --------------------------------------------------------------------------- -// Button -// --------------------------------------------------------------------------- - -export class Button extends DOMWidget { - static override modelName = 'ButtonModel'; - static override viewName = 'ButtonView'; - - protected override _defaults() { - return { - ...super._defaults(), - tooltip: '', - icon: '', - button_style: '' - }; - } - - /** - * Register a click handler. - */ - onClick(callback: () => void): this { - return this.on('click', callback); - } - - get tooltip(): string { - return this.get('tooltip') as string; - } - set tooltip(v: string) { - this.set('tooltip', v); - } - get icon(): string { - return this.get('icon') as string; - } - set icon(v: string) { - this.set('icon', v); - } - get button_style(): string { - return this.get('button_style') as string; - } - set button_style(v: string) { - this.set('button_style', v); - } - - protected override _handleMsg( - data: Record, - buffers?: ArrayBuffer[] - ): void { - super._handleMsg(data, buffers); - if (data.method === 'custom') { - const content = data.content as Record | undefined; - if (content?.event === 'click') { - this._trigger('click'); - } - this._trigger('custom', content, buffers); - } - } -} - -// --------------------------------------------------------------------------- -// Color picker -// --------------------------------------------------------------------------- - -export class ColorPicker extends DOMWidget { - static override modelName = 'ColorPickerModel'; - static override viewName = 'ColorPickerView'; - - protected override _defaults() { - return { ...super._defaults(), value: '#000000', concise: false }; - } - - get value(): string { - return this.get('value') as string; - } - set value(v: string) { - this.set('value', v); - } - get concise(): boolean { - return this.get('concise') as boolean; - } - set concise(v: boolean) { - this.set('concise', v); - } -} - -// --------------------------------------------------------------------------- -// Container / layout -// --------------------------------------------------------------------------- - -/** - * Serialize an array of Widget instances to `"IPY_MODEL_"` references - * as required by the Jupyter widget protocol. - */ -function _serializeChildren(children: Widget[]): string[] { - return children.map(w => `IPY_MODEL_${w.commId}`); -} - -export class Box extends DOMWidget { - static override modelName = 'BoxModel'; - static override viewName = 'BoxView'; - - constructor(state?: Record & { children?: Widget[] }) { - const { children, ...rest } = state ?? {}; - super({ ...rest, children: children ?? [] }); - } - - protected override _defaults() { - return { ...super._defaults(), children: [], box_style: '' }; - } - - get children(): Widget[] { - return [...((this.get('children') as Widget[]) ?? [])]; - } - set children(v: Widget[]) { - this.set('children', [...v]); - } - - get box_style(): string { - return this.get('box_style') as string; - } - set box_style(v: string) { - this.set('box_style', v); - } - - protected override _serializeProperty(name: string, value: unknown): unknown { - if (name === 'children') { - return _serializeChildren( - Array.isArray(value) ? (value as Widget[]) : [] - ); - } - return super._serializeProperty(name, value); - } - - protected override _deserializeProperty( - name: string, - value: unknown - ): unknown { - if (name === 'children') { - return _deserializeChildren(this._manager, value); - } - return super._deserializeProperty(name, value); - } -} - -export class HBox extends Box { - static override modelName = 'HBoxModel'; - static override viewName = 'HBoxView'; -} - -export class VBox extends Box { - static override modelName = 'VBoxModel'; - static override viewName = 'VBoxView'; -} - -export class GridBox extends Box { - static override modelName = 'GridBoxModel'; - static override viewName = 'GridBoxView'; -} - -// --------------------------------------------------------------------------- -// Selection containers (Tab, Accordion, Stack) -// --------------------------------------------------------------------------- - -class _SelectionContainer extends Box { - constructor( - state?: Record & { - children?: Widget[]; - titles?: string[]; - } - ) { - const next = { ...(state ?? {}) }; - const childCount = (next.children as Widget[] | undefined)?.length ?? 0; - const titles = (next.titles as string[] | undefined) ?? []; - const padded = [...titles]; - while (padded.length < childCount) { - padded.push(''); - } - next.titles = padded; - super(next as Record & { children?: Widget[] }); - } - - protected override _defaults() { - return { ...super._defaults(), titles: [], selected_index: null }; - } - - get titles(): string[] { - return this.get('titles') as string[]; - } - set titles(v: string[]) { - this.set('titles', v); - } - - get selected_index(): number | null { - return this.get('selected_index') as number | null; - } - set selected_index(v: number | null) { - this.set('selected_index', v); - } - - /** - * Set the title of a container page. - */ - setTitle(index: number, title: string): void { - const next = [...this.titles]; - while (next.length <= index) { - next.push(''); - } - next[index] = title; - this.titles = next; - } - - /** - * Get the title of a container page. - */ - getTitle(index: number): string { - return this.titles[index] ?? ''; - } -} - -export class Accordion extends _SelectionContainer { - static override modelName = 'AccordionModel'; - static override viewName = 'AccordionView'; -} - -export class Tab extends _SelectionContainer { - static override modelName = 'TabModel'; - static override viewName = 'TabView'; - - constructor( - state?: Record & { - children?: Widget[]; - titles?: string[]; - } - ) { - const next = { ...(state ?? {}) }; - const children = (next.children as Widget[] | undefined) ?? []; - if (children.length > 0 && next.selected_index === undefined) { - next.selected_index = 0; - } - super( - next as Record & { - children?: Widget[]; - titles?: string[]; - } - ); - } -} - -export class Stack extends _SelectionContainer { - static override modelName = 'StackModel'; - static override viewName = 'StackView'; -} - -// --------------------------------------------------------------------------- -// Frontend-only link helpers -// --------------------------------------------------------------------------- - -export class DirectionalLink extends Widget { - static override modelName = 'DirectionalLinkModel'; - static override viewName = null; - - protected override _defaults() { - return { - source: null, - target: null - }; - } - - constructor( - state?: Record & { - source?: WidgetTraitPair; - target?: WidgetTraitPair; - } - ) { - super(state as Record); - } - - get source(): WidgetTraitPair | null { - return (this.get('source') as WidgetTraitPair | null) ?? null; - } - set source(v: WidgetTraitPair | null) { - this.set('source', v); - } - - get target(): WidgetTraitPair | null { - return (this.get('target') as WidgetTraitPair | null) ?? null; - } - set target(v: WidgetTraitPair | null) { - this.set('target', v); - } - - unlink(): void { - this.close(); - } - - protected override _serializeProperty(name: string, value: unknown): unknown { - if ((name === 'source' || name === 'target') && value !== undefined) { - return _serializeWidgetPair(value as WidgetTraitPair | null); - } - return super._serializeProperty(name, value); - } - - protected override _deserializeProperty( - name: string, - value: unknown - ): unknown { - if (name === 'source' || name === 'target') { - return _deserializeWidgetPair(this._manager, value); - } - return super._deserializeProperty(name, value); - } -} - -export class Link extends DirectionalLink { - static override modelName = 'LinkModel'; -} - -export function jslink(source: WidgetTraitPair, target: WidgetTraitPair): Link { - return new Link({ source, target }); -} - -export function jsdlink( - source: WidgetTraitPair, - target: WidgetTraitPair -): DirectionalLink { - return new DirectionalLink({ source, target }); -} - -// --------------------------------------------------------------------------- -// Convenience map of all exported widget classes -// --------------------------------------------------------------------------- - -/** - * All widget classes, keyed by class name. - */ -export const widgetClasses: Record = { - Widget, - DOMWidget, - Layout, - Style, - DescriptionStyle, - SliderStyle, - ProgressStyle, - ButtonStyle, - CheckboxStyle, - ToggleButtonStyle, - ToggleButtonsStyle, - TextStyle, - HTMLStyle, - HTMLMathStyle, - LabelStyle, - IntSlider, - FloatSlider, - FloatLogSlider, - IntRangeSlider, - FloatRangeSlider, - Play, - IntProgress, - FloatProgress, - IntText, - FloatText, - BoundedIntText, - BoundedFloatText, - Checkbox, - ToggleButton, - Valid, - Dropdown, - RadioButtons, - Select, - SelectMultiple, - ToggleButtons, - SelectionSlider, - SelectionRangeSlider, - Text, - Textarea, - Password, - Combobox, - Label, - HTML, - HTMLMath, - Output, - Button, - ColorPicker, - Box, - HBox, - VBox, - GridBox, - Accordion, - Tab, - Stack, - Link, - DirectionalLink -}; - -/** - * Create runtime-local widget classes bound to a specific comm manager. - */ -export function createWidgetClasses( - manager: CommManager -): Record { - const classes: Record = {}; - - for (const [name, cls] of Object.entries(widgetClasses)) { - const BoundWidgetClass = class extends cls {}; - Object.defineProperty(BoundWidgetClass, 'name', { value: name }); - BoundWidgetClass.setDefaultManager(manager); - classes[name] = BoundWidgetClass; - } - - const LinkClass = classes.Link as typeof Link; - const DirectionalLinkClass = - classes.DirectionalLink as typeof DirectionalLink; - - classes.jslink = (source: WidgetTraitPair, target: WidgetTraitPair) => - new LinkClass({ source, target }); - classes.jsdlink = (source: WidgetTraitPair, target: WidgetTraitPair) => - new DirectionalLinkClass({ source, target }); - - return classes; -} - -/** - * Resolve serialized `IPY_MODEL_` references back to known widget instances. - */ -function _deserializeChildren( - manager: CommManager, - children: unknown -): Widget[] { - if (!Array.isArray(children)) { - return []; - } - - return children - .map(child => _deserializeWidgetReference(manager, child)) - .filter((child): child is Widget => child instanceof Widget); -} - -function _serializeWidgetReference(widget: Widget | null): string | null { - return widget ? `IPY_MODEL_${widget.commId}` : null; -} - -function _deserializeWidgetReference( - manager: CommManager, - value: unknown -): Widget | null { - if (value === null) { - return null; - } - if (typeof value !== 'string' || !value.startsWith('IPY_MODEL_')) { - return null; - } - return manager.getWidget(value.slice('IPY_MODEL_'.length)) ?? null; -} - -function _serializeWidgetPair( - pair: WidgetTraitPair | null -): [string, string] | null { - if (!pair) { - return null; - } - return [`IPY_MODEL_${pair[0].commId}`, pair[1]]; -} - -function _deserializeWidgetPair( - manager: CommManager, - value: unknown -): WidgetTraitPair | null { - if (!Array.isArray(value) || value.length !== 2) { - return null; - } - - const widget = _deserializeWidgetReference(manager, value[0]); - const name = value[1]; - if (!(widget instanceof Widget) || typeof name !== 'string') { - return null; - } - - return [widget, name]; -} - -function _wrapCapturedOutputCallback any>( - output: Output, - callback: T, - options: IOutputCaptureOptions -): T { - return function (this: unknown, ...args: any[]): any { - const shouldClear = options.clearOutput ?? false; - if (shouldClear) { - output.clearOutput({ wait: options.wait }); - } - - const messageId = output.currentMessageId; - if (messageId) { - if (output._captureDepth === 0) { - output.msg_id = messageId; - } - output._captureDepth += 1; - } - - const finish = (): void => { - if (!messageId) { - return; - } - output._captureDepth = Math.max(0, output._captureDepth - 1); - if (output._captureDepth === 0 && output.msg_id === messageId) { - output.msg_id = ''; - } - }; - - try { - const result = callback.apply(this, args); - if (result && typeof (result as Promise).then === 'function') { - return Promise.resolve(result).finally(finish); - } - finish(); - return result; - } catch (error) { - finish(); - throw error; - } - } as T; -} diff --git a/packages/javascript-kernel/src/widgets/index.ts b/packages/javascript-kernel/src/widgets/index.ts new file mode 100644 index 0000000..23f07e1 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/index.ts @@ -0,0 +1,167 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +export * from './version'; +export * from './widget'; +export * from './widget_layout'; +export * from './widget_style'; +export * from './widget_int'; +export * from './widget_float'; +export * from './widget_bool'; +export * from './widget_selection'; +export * from './widget_string'; +export * from './widget_output'; +export * from './widget_button'; +export * from './widget_color'; +export * from './widget_box'; +export * from './widget_selectioncontainer'; +export * from './widget_link'; + +import type { CommManager } from '../comm'; + +import { DOMWidget, Widget, type WidgetTraitPair } from './widget'; +import { Layout } from './widget_layout'; +import { + ButtonStyle, + CheckboxStyle, + DescriptionStyle, + HTMLMathStyle, + HTMLStyle, + LabelStyle, + ProgressStyle, + SliderStyle, + Style, + TextStyle, + ToggleButtonsStyle, + ToggleButtonStyle +} from './widget_style'; +import { + BoundedIntText, + IntProgress, + IntRangeSlider, + IntSlider, + IntText, + Play +} from './widget_int'; +import { + BoundedFloatText, + FloatLogSlider, + FloatProgress, + FloatRangeSlider, + FloatSlider, + FloatText +} from './widget_float'; +import { Checkbox, ToggleButton, Valid } from './widget_bool'; +import { + Dropdown, + RadioButtons, + Select, + SelectionRangeSlider, + SelectionSlider, + SelectMultiple, + ToggleButtons +} from './widget_selection'; +import { + Combobox, + HTML, + HTMLMath, + Label, + Password, + Text, + Textarea +} from './widget_string'; +import { Output } from './widget_output'; +import { Button } from './widget_button'; +import { ColorPicker } from './widget_color'; +import { Box, GridBox, HBox, VBox } from './widget_box'; +import { Accordion, Stack, Tab } from './widget_selectioncontainer'; +import { DirectionalLink, Link } from './widget_link'; + +/** + * All widget classes, keyed by class name. + */ +export const widgetClasses: Record = { + Widget, + DOMWidget, + Layout, + Style, + DescriptionStyle, + SliderStyle, + ProgressStyle, + ButtonStyle, + CheckboxStyle, + ToggleButtonStyle, + ToggleButtonsStyle, + TextStyle, + HTMLStyle, + HTMLMathStyle, + LabelStyle, + IntSlider, + FloatSlider, + FloatLogSlider, + IntRangeSlider, + FloatRangeSlider, + Play, + IntProgress, + FloatProgress, + IntText, + FloatText, + BoundedIntText, + BoundedFloatText, + Checkbox, + ToggleButton, + Valid, + Dropdown, + RadioButtons, + Select, + SelectMultiple, + ToggleButtons, + SelectionSlider, + SelectionRangeSlider, + Text, + Textarea, + Password, + Combobox, + Label, + HTML, + HTMLMath, + Output, + Button, + ColorPicker, + Box, + HBox, + VBox, + GridBox, + Accordion, + Tab, + Stack, + Link, + DirectionalLink +}; + +/** + * Create runtime-local widget classes bound to a specific comm manager. + */ +export function createWidgetClasses( + manager: CommManager +): Record { + const classes: Record = {}; + + for (const [name, cls] of Object.entries(widgetClasses)) { + const BoundWidgetClass = class extends cls {}; + Object.defineProperty(BoundWidgetClass, 'name', { value: name }); + BoundWidgetClass.setDefaultManager(manager); + classes[name] = BoundWidgetClass; + } + + const LinkClass = classes.Link as typeof Link; + const DirectionalLinkClass = + classes.DirectionalLink as typeof DirectionalLink; + + classes.jslink = (source: WidgetTraitPair, target: WidgetTraitPair) => + new LinkClass({ source, target }); + classes.jsdlink = (source: WidgetTraitPair, target: WidgetTraitPair) => + new DirectionalLinkClass({ source, target }); + + return classes; +} diff --git a/packages/javascript-kernel/src/widgets/version.ts b/packages/javascript-kernel/src/widgets/version.ts new file mode 100644 index 0000000..fb015c0 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/version.ts @@ -0,0 +1,10 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +export const WIDGET_PROTOCOL_VERSION = '2.1.0'; +export const BASE_MODULE = '@jupyter-widgets/base'; +export const BASE_MODULE_VERSION = '2.0.0'; +export const CONTROLS_MODULE = '@jupyter-widgets/controls'; +export const CONTROLS_MODULE_VERSION = '2.0.0'; +export const OUTPUT_MODULE = '@jupyter-widgets/output'; +export const OUTPUT_MODULE_VERSION = '1.0.0'; diff --git a/packages/javascript-kernel/src/widgets/widget.ts b/packages/javascript-kernel/src/widgets/widget.ts new file mode 100644 index 0000000..4259666 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget.ts @@ -0,0 +1,442 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import type { CommManager, IComm } from '../comm'; + +import { + CONTROLS_MODULE, + CONTROLS_MODULE_VERSION, + WIDGET_PROTOCOL_VERSION +} from './version'; + +import type { Layout } from './widget_layout'; +import type { Style } from './widget_style'; + +export type WidgetEventCallback = (...args: any[]) => void; + +export interface IWidgetChange { + name: string; + new: unknown; + old: unknown; + owner: Widget; + type: 'change'; +} + +export type WidgetObserveCallback = (change: IWidgetChange) => void; + +export type WidgetTraitPair = [Widget, string]; + +/** + * Base class for Jupyter widgets. + * + * Wraps the low-level comm protocol so user code can work with + * familiar property access and change events instead of raw messages. + */ +export class Widget { + static modelName = ''; + static viewName: string | null = ''; + static modelModule = CONTROLS_MODULE; + static modelModuleVersion = CONTROLS_MODULE_VERSION; + static viewModule = CONTROLS_MODULE; + static viewModuleVersion = CONTROLS_MODULE_VERSION; + + protected static _defaultManager: CommManager | null = null; + + /** + * Set the default CommManager used by all Widget instances. + */ + static setDefaultManager(manager: CommManager | null): void { + this._defaultManager = manager; + } + + constructor(state?: Record) { + const ctor = this.constructor as typeof Widget; + const manager = ctor._defaultManager; + if (!manager) { + throw new Error( + 'Widget manager not initialized. Widgets can only be created inside the kernel runtime.' + ); + } + + this._manager = manager; + this._state = { + ...this._defaults(), + ...state, + ...this._modelState(ctor) + }; + this._listeners = new Map(); + this._observerWrappers = new Map(); + + this._comm = manager.open( + 'jupyter.widget', + { state: this._serializeState(this._state), buffer_paths: [] }, + { version: WIDGET_PROTOCOL_VERSION } + ); + + this._comm.onMsg = (data, buffers) => { + this._handleMsg(data, buffers); + }; + this._comm.onClose = data => { + this._manager.unregisterWidget(this.commId); + this._trigger('close', data); + }; + this._manager.registerWidget(this.commId, this); + } + + /** + * Close the widget and its comm channel. + */ + close(): void { + this._comm.close(); + } + + /** + * Get a state property. + */ + get(name: string): unknown { + return this._state[name]; + } + + /** + * Set one or more state properties and sync to the frontend. + */ + set(name: string, value: unknown): void; + set(state: Record): void; + set(nameOrState: string | Record, value?: unknown): void { + const updates: Record = + typeof nameOrState === 'string' ? { [nameOrState]: value } : nameOrState; + + const changes: Array<[string, unknown, unknown]> = []; + for (const [key, val] of Object.entries(updates)) { + const old = this._state[key]; + if (old !== val) { + this._state[key] = val; + changes.push([key, val, old]); + } + } + + if (changes.length > 0) { + this._comm.send({ + method: 'update', + state: this._serializeState(updates), + buffer_paths: [] + }); + for (const [key, val, old] of changes) { + this._trigger(`change:${key}`, val, old); + } + this._trigger('change', changes); + } + } + + /** + * Listen for widget events. + * + * Events: + * - `'change:propName'` - property changed `(newValue, oldValue)` + * - `'change'` - any property changed `(changes)` + * - `'close'` - comm closed + */ + on(event: string, callback: WidgetEventCallback): this { + if (!this._listeners.has(event)) { + this._listeners.set(event, new Set()); + } + this._listeners.get(event)!.add(callback); + return this; + } + + /** + * Remove an event listener. + */ + off(event: string, callback: WidgetEventCallback): this { + this._listeners.get(event)?.delete(callback); + return this; + } + + /** + * Observe changes using an ipywidgets-style callback payload. + */ + observe(callback: WidgetObserveCallback, names?: string | string[]): this { + for (const name of this._normalizeObserveNames(names)) { + const eventName = name === '*' ? 'change' : `change:${name}`; + const wrapper = this._observerWrapper(callback, name); + this.on(eventName, wrapper); + } + return this; + } + + /** + * Remove an observe callback. + */ + unobserve(callback: WidgetObserveCallback, names?: string | string[]): this { + const wrappers = this._observerWrappers.get(callback); + if (!wrappers) { + return this; + } + + const keys = names + ? this._normalizeObserveNames(names) + : [...wrappers.keys()]; + for (const name of keys) { + const wrapper = wrappers.get(name); + if (!wrapper) { + continue; + } + const eventName = name === '*' ? 'change' : `change:${name}`; + this.off(eventName, wrapper); + wrappers.delete(name); + } + + if (wrappers.size === 0) { + this._observerWrappers.delete(callback); + } + + return this; + } + + /** + * The widget's comm/model ID. + */ + get commId(): string { + return this._comm.commId; + } + + get description(): string { + return this.get('description') as string; + } + set description(v: string) { + this.set('description', v); + } + + get disabled(): boolean { + return this.get('disabled') as boolean; + } + set disabled(v: boolean) { + this.set('disabled', v); + } + + protected _defaults(): Record { + return { description: '', disabled: false }; + } + + protected _handleMsg( + data: Record, + buffers?: ArrayBuffer[] + ): void { + if (data.method === 'update' && data.state) { + const state = data.state as Record; + const changes: Array<[string, unknown, unknown]> = []; + for (const [key, val] of Object.entries(state)) { + const next = this._deserializeProperty(key, val); + const old = this._state[key]; + if (old !== next) { + this._state[key] = next; + changes.push([key, next, old]); + } + } + for (const [key, val, old] of changes) { + this._trigger(`change:${key}`, val, old); + } + if (changes.length > 0) { + this._trigger('change', changes); + } + } + + if (data.method === 'request_state') { + this._comm.send({ + method: 'update', + state: this._serializeState(this._state), + buffer_paths: [] + }); + } + } + + protected _serializeState( + state: Record + ): Record { + const serialized: Record = {}; + for (const [key, value] of Object.entries(state)) { + serialized[key] = this._serializeProperty(key, value); + } + return serialized; + } + + protected _serializeProperty(_name: string, value: unknown): unknown { + return value; + } + + protected _deserializeProperty(_name: string, value: unknown): unknown { + return value; + } + + protected _trigger(event: string, ...args: unknown[]): void { + for (const cb of this._listeners.get(event) ?? []) { + try { + cb(...args); + } catch (e) { + console.error(`[Widget] Error in '${event}' handler:`, e); + } + } + } + + private _modelState(ctor: typeof Widget): Record { + const state: Record = { + _model_name: ctor.modelName, + _model_module: ctor.modelModule, + _model_module_version: ctor.modelModuleVersion + }; + + if (ctor.viewName !== null) { + state._view_name = ctor.viewName; + state._view_module = ctor.viewModule; + state._view_module_version = ctor.viewModuleVersion; + } + + return state; + } + + private _normalizeObserveNames(names?: string | string[]): string[] { + if (names === undefined) { + return ['*']; + } + return Array.isArray(names) ? names : [names]; + } + + private _observerWrapper( + callback: WidgetObserveCallback, + name: string + ): WidgetEventCallback { + let wrappers = this._observerWrappers.get(callback); + if (!wrappers) { + wrappers = new Map(); + this._observerWrappers.set(callback, wrappers); + } + + const existing = wrappers.get(name); + if (existing) { + return existing; + } + + const wrapper: WidgetEventCallback = + name === '*' + ? (changes: Array<[string, unknown, unknown]>) => { + for (const [changeName, next, old] of changes) { + callback({ + name: changeName, + new: next, + old, + owner: this, + type: 'change' + }); + } + } + : (next: unknown, old: unknown) => { + callback({ + name, + new: next, + old, + owner: this, + type: 'change' + }); + }; + + wrappers.set(name, wrapper); + return wrapper; + } + + protected _comm: IComm; + protected _manager: CommManager; + protected _state: Record; + private _listeners: Map>; + private _observerWrappers: Map< + WidgetObserveCallback, + Map + >; +} + +/** + * A widget with layout/style support. + */ +export class DOMWidget extends Widget { + protected override _defaults(): Record { + return { ...super._defaults(), layout: null, style: null }; + } + + get layout(): Layout | null { + return (this.get('layout') as Layout | null) ?? null; + } + set layout(v: Layout | null) { + this.set('layout', v); + } + + get style(): Style | null { + return (this.get('style') as Style | null) ?? null; + } + set style(v: Style | null) { + this.set('style', v); + } + + protected override _serializeProperty(name: string, value: unknown): unknown { + if ( + (name === 'layout' || name === 'style') && + (value instanceof Widget || value === null) + ) { + return _serializeWidgetReference(value); + } + return super._serializeProperty(name, value); + } + + protected override _deserializeProperty( + name: string, + value: unknown + ): unknown { + if (name === 'layout' || name === 'style') { + return _deserializeWidgetReference(this._manager, value); + } + return super._deserializeProperty(name, value); + } +} + +export function _serializeWidgetReference( + widget: Widget | null +): string | null { + return widget ? `IPY_MODEL_${widget.commId}` : null; +} + +export function _deserializeWidgetReference( + manager: CommManager, + value: unknown +): Widget | null { + if (value === null) { + return null; + } + if (typeof value !== 'string' || !value.startsWith('IPY_MODEL_')) { + return null; + } + return manager.getWidget(value.slice('IPY_MODEL_'.length)) ?? null; +} + +export function _serializeWidgetPair( + pair: WidgetTraitPair | null +): [string, string] | null { + if (!pair) { + return null; + } + return [`IPY_MODEL_${pair[0].commId}`, pair[1]]; +} + +export function _deserializeWidgetPair( + manager: CommManager, + value: unknown +): WidgetTraitPair | null { + if (!Array.isArray(value) || value.length !== 2) { + return null; + } + + const widget = _deserializeWidgetReference(manager, value[0]); + const name = value[1]; + if (!(widget instanceof Widget) || typeof name !== 'string') { + return null; + } + + return [widget, name]; +} diff --git a/packages/javascript-kernel/src/widgets/widget_bool.ts b/packages/javascript-kernel/src/widgets/widget_bool.ts new file mode 100644 index 0000000..b52eac7 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_bool.ts @@ -0,0 +1,88 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { DOMWidget } from './widget'; + +export class Checkbox extends DOMWidget { + static override modelName = 'CheckboxModel'; + static override viewName = 'CheckboxView'; + + protected override _defaults() { + return { ...super._defaults(), value: false, indent: true }; + } + + get value(): boolean { + return this.get('value') as boolean; + } + set value(v: boolean) { + this.set('value', v); + } + get indent(): boolean { + return this.get('indent') as boolean; + } + set indent(v: boolean) { + this.set('indent', v); + } +} + +export class ToggleButton extends DOMWidget { + static override modelName = 'ToggleButtonModel'; + static override viewName = 'ToggleButtonView'; + + protected override _defaults() { + return { + ...super._defaults(), + value: false, + tooltip: '', + icon: '', + button_style: '' + }; + } + + get value(): boolean { + return this.get('value') as boolean; + } + set value(v: boolean) { + this.set('value', v); + } + get tooltip(): string { + return this.get('tooltip') as string; + } + set tooltip(v: string) { + this.set('tooltip', v); + } + get icon(): string { + return this.get('icon') as string; + } + set icon(v: string) { + this.set('icon', v); + } + get button_style(): string { + return this.get('button_style') as string; + } + set button_style(v: string) { + this.set('button_style', v); + } +} + +export class Valid extends DOMWidget { + static override modelName = 'ValidModel'; + static override viewName = 'ValidView'; + + protected override _defaults() { + return { ...super._defaults(), value: false, readout: 'Invalid' }; + } + + get value(): boolean { + return this.get('value') as boolean; + } + set value(v: boolean) { + this.set('value', v); + } + get readout(): string { + return this.get('readout') as string; + } + set readout(v: string) { + this.set('readout', v); + } +} diff --git a/packages/javascript-kernel/src/widgets/widget_box.ts b/packages/javascript-kernel/src/widgets/widget_box.ts new file mode 100644 index 0000000..a8406e8 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_box.ts @@ -0,0 +1,87 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { DOMWidget, Widget, _deserializeWidgetReference } from './widget'; + +/** + * Serialize an array of Widget instances to `"IPY_MODEL_"` references + * as required by the Jupyter widget protocol. + */ +function _serializeChildren(children: Widget[]): string[] { + return children.map(w => `IPY_MODEL_${w.commId}`); +} + +function _deserializeChildren( + manager: DOMWidget['_manager'], + children: unknown +): Widget[] { + if (!Array.isArray(children)) { + return []; + } + + return children + .map(child => _deserializeWidgetReference(manager, child)) + .filter((child): child is Widget => child instanceof Widget); +} + +export class Box extends DOMWidget { + static override modelName = 'BoxModel'; + static override viewName = 'BoxView'; + + constructor(state?: Record & { children?: Widget[] }) { + const { children, ...rest } = state ?? {}; + super({ ...rest, children: children ?? [] }); + } + + protected override _defaults() { + return { ...super._defaults(), children: [], box_style: '' }; + } + + get children(): Widget[] { + return [...((this.get('children') as Widget[]) ?? [])]; + } + set children(v: Widget[]) { + this.set('children', [...v]); + } + + get box_style(): string { + return this.get('box_style') as string; + } + set box_style(v: string) { + this.set('box_style', v); + } + + protected override _serializeProperty(name: string, value: unknown): unknown { + if (name === 'children') { + return _serializeChildren( + Array.isArray(value) ? (value as Widget[]) : [] + ); + } + return super._serializeProperty(name, value); + } + + protected override _deserializeProperty( + name: string, + value: unknown + ): unknown { + if (name === 'children') { + return _deserializeChildren(this._manager, value); + } + return super._deserializeProperty(name, value); + } +} + +export class HBox extends Box { + static override modelName = 'HBoxModel'; + static override viewName = 'HBoxView'; +} + +export class VBox extends Box { + static override modelName = 'VBoxModel'; + static override viewName = 'VBoxView'; +} + +export class GridBox extends Box { + static override modelName = 'GridBoxModel'; + static override viewName = 'GridBoxView'; +} diff --git a/packages/javascript-kernel/src/widgets/widget_button.ts b/packages/javascript-kernel/src/widgets/widget_button.ts new file mode 100644 index 0000000..a0854de --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_button.ts @@ -0,0 +1,58 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { DOMWidget } from './widget'; + +export class Button extends DOMWidget { + static override modelName = 'ButtonModel'; + static override viewName = 'ButtonView'; + + protected override _defaults() { + return { + ...super._defaults(), + tooltip: '', + icon: '', + button_style: '' + }; + } + + /** + * Register a click handler. + */ + onClick(callback: () => void): this { + return this.on('click', callback); + } + + get tooltip(): string { + return this.get('tooltip') as string; + } + set tooltip(v: string) { + this.set('tooltip', v); + } + get icon(): string { + return this.get('icon') as string; + } + set icon(v: string) { + this.set('icon', v); + } + get button_style(): string { + return this.get('button_style') as string; + } + set button_style(v: string) { + this.set('button_style', v); + } + + protected override _handleMsg( + data: Record, + buffers?: ArrayBuffer[] + ): void { + super._handleMsg(data, buffers); + if (data.method === 'custom') { + const content = data.content as Record | undefined; + if (content?.event === 'click') { + this._trigger('click'); + } + this._trigger('custom', content, buffers); + } + } +} diff --git a/packages/javascript-kernel/src/widgets/widget_color.ts b/packages/javascript-kernel/src/widgets/widget_color.ts new file mode 100644 index 0000000..1768861 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_color.ts @@ -0,0 +1,26 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { DOMWidget } from './widget'; + +export class ColorPicker extends DOMWidget { + static override modelName = 'ColorPickerModel'; + static override viewName = 'ColorPickerView'; + + protected override _defaults() { + return { ...super._defaults(), value: '#000000', concise: false }; + } + + get value(): string { + return this.get('value') as string; + } + set value(v: string) { + this.set('value', v); + } + get concise(): boolean { + return this.get('concise') as boolean; + } + set concise(v: boolean) { + this.set('concise', v); + } +} diff --git a/packages/javascript-kernel/src/widgets/widget_float.ts b/packages/javascript-kernel/src/widgets/widget_float.ts new file mode 100644 index 0000000..d35bdaa --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_float.ts @@ -0,0 +1,127 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + _NumericTextBase, + _ProgressBase, + _RangeSliderBase, + _SliderBase +} from './widget_number'; + +export class FloatSlider extends _SliderBase { + static override modelName = 'FloatSliderModel'; + static override viewName = 'FloatSliderView'; + + protected override _defaults() { + return { + ...super._defaults(), + max: 10.0, + step: 0.1, + readout_format: '.2f' + }; + } + + get readout_format(): string { + return this.get('readout_format') as string; + } + set readout_format(v: string) { + this.set('readout_format', v); + } +} + +export class FloatLogSlider extends _SliderBase { + static override modelName = 'FloatLogSliderModel'; + static override viewName = 'FloatLogSliderView'; + + protected override _defaults() { + return { + ...super._defaults(), + min: 0.0, + max: 4.0, + base: 10.0, + value: 1.0, + step: 0.1, + readout_format: '.3g' + }; + } + + get base(): number { + return this.get('base') as number; + } + set base(v: number) { + this.set('base', v); + } + get readout_format(): string { + return this.get('readout_format') as string; + } + set readout_format(v: string) { + this.set('readout_format', v); + } +} + +export class FloatRangeSlider extends _RangeSliderBase { + static override modelName = 'FloatRangeSliderModel'; + static override viewName = 'FloatRangeSliderView'; + + protected override _defaults() { + return { + ...super._defaults(), + value: [0.0, 1.0], + step: 0.1, + readout_format: '.2f' + }; + } + + get readout_format(): string { + return this.get('readout_format') as string; + } + set readout_format(v: string) { + this.set('readout_format', v); + } +} + +export class FloatProgress extends _ProgressBase { + static override modelName = 'FloatProgressModel'; + static override viewName = 'ProgressView'; + + protected override _defaults() { + return { ...super._defaults(), max: 10.0 }; + } +} + +export class FloatText extends _NumericTextBase { + static override modelName = 'FloatTextModel'; + static override viewName = 'FloatTextView'; + + protected override _defaults() { + return { ...super._defaults(), value: 0.0, step: 0.1 }; + } +} + +export class BoundedFloatText extends _NumericTextBase { + static override modelName = 'BoundedFloatTextModel'; + static override viewName = 'FloatTextView'; + + protected override _defaults() { + return { + ...super._defaults(), + value: 0.0, + step: 0.1, + min: 0.0, + max: 100.0 + }; + } + + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } +} diff --git a/packages/javascript-kernel/src/widgets/widget_int.ts b/packages/javascript-kernel/src/widgets/widget_int.ts new file mode 100644 index 0000000..53af87a --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_int.ts @@ -0,0 +1,145 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + _NumericTextBase, + _ProgressBase, + _RangeSliderBase, + _SliderBase +} from './widget_number'; +import { DOMWidget } from './widget'; + +export class IntSlider extends _SliderBase { + static override modelName = 'IntSliderModel'; + static override viewName = 'IntSliderView'; + + protected override _defaults() { + return { + ...super._defaults(), + readout_format: 'd' + }; + } + + get readout_format(): string { + return this.get('readout_format') as string; + } + set readout_format(v: string) { + this.set('readout_format', v); + } +} + +export class IntRangeSlider extends _RangeSliderBase { + static override modelName = 'IntRangeSliderModel'; + static override viewName = 'IntRangeSliderView'; + + protected override _defaults() { + return { ...super._defaults(), readout_format: 'd' }; + } + + get readout_format(): string { + return this.get('readout_format') as string; + } + set readout_format(v: string) { + this.set('readout_format', v); + } +} + +export class Play extends DOMWidget { + static override modelName = 'PlayModel'; + static override viewName = 'PlayView'; + + protected override _defaults() { + return { + ...super._defaults(), + value: 0, + min: 0, + max: 100, + step: 1, + repeat: false, + playing: false, + show_repeat: true, + interval: 100 + }; + } + + get value(): number { + return this.get('value') as number; + } + set value(v: number) { + this.set('value', v); + } + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } + get step(): number { + return this.get('step') as number; + } + set step(v: number) { + this.set('step', v); + } + get repeat(): boolean { + return this.get('repeat') as boolean; + } + set repeat(v: boolean) { + this.set('repeat', v); + } + get playing(): boolean { + return this.get('playing') as boolean; + } + set playing(v: boolean) { + this.set('playing', v); + } + get show_repeat(): boolean { + return this.get('show_repeat') as boolean; + } + set show_repeat(v: boolean) { + this.set('show_repeat', v); + } + get interval(): number { + return this.get('interval') as number; + } + set interval(v: number) { + this.set('interval', v); + } +} + +export class IntProgress extends _ProgressBase { + static override modelName = 'IntProgressModel'; + static override viewName = 'ProgressView'; +} + +export class IntText extends _NumericTextBase { + static override modelName = 'IntTextModel'; + static override viewName = 'IntTextView'; +} + +export class BoundedIntText extends _NumericTextBase { + static override modelName = 'BoundedIntTextModel'; + static override viewName = 'IntTextView'; + + protected override _defaults() { + return { ...super._defaults(), min: 0, max: 100 }; + } + + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } +} diff --git a/packages/javascript-kernel/src/widgets/widget_layout.ts b/packages/javascript-kernel/src/widgets/widget_layout.ts new file mode 100644 index 0000000..014ace5 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_layout.ts @@ -0,0 +1,18 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Widget } from './widget'; +import { BASE_MODULE, BASE_MODULE_VERSION } from './version'; + +export class Layout extends Widget { + static override modelName = 'LayoutModel'; + static override viewName = 'LayoutView'; + static override modelModule = BASE_MODULE; + static override modelModuleVersion = BASE_MODULE_VERSION; + static override viewModule = BASE_MODULE; + static override viewModuleVersion = BASE_MODULE_VERSION; + + protected override _defaults(): Record { + return {}; + } +} diff --git a/packages/javascript-kernel/src/widgets/widget_link.ts b/packages/javascript-kernel/src/widgets/widget_link.ts new file mode 100644 index 0000000..f0d066b --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_link.ts @@ -0,0 +1,80 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + Widget, + type WidgetTraitPair, + _deserializeWidgetPair, + _serializeWidgetPair +} from './widget'; + +export class DirectionalLink extends Widget { + static override modelName = 'DirectionalLinkModel'; + static override viewName = null; + + protected override _defaults() { + return { + source: null, + target: null + }; + } + + constructor( + state?: Record & { + source?: WidgetTraitPair; + target?: WidgetTraitPair; + } + ) { + super(state as Record); + } + + get source(): WidgetTraitPair | null { + return (this.get('source') as WidgetTraitPair | null) ?? null; + } + set source(v: WidgetTraitPair | null) { + this.set('source', v); + } + + get target(): WidgetTraitPair | null { + return (this.get('target') as WidgetTraitPair | null) ?? null; + } + set target(v: WidgetTraitPair | null) { + this.set('target', v); + } + + unlink(): void { + this.close(); + } + + protected override _serializeProperty(name: string, value: unknown): unknown { + if ((name === 'source' || name === 'target') && value !== undefined) { + return _serializeWidgetPair(value as WidgetTraitPair | null); + } + return super._serializeProperty(name, value); + } + + protected override _deserializeProperty( + name: string, + value: unknown + ): unknown { + if (name === 'source' || name === 'target') { + return _deserializeWidgetPair(this._manager, value); + } + return super._deserializeProperty(name, value); + } +} + +export class Link extends DirectionalLink { + static override modelName = 'LinkModel'; +} + +export function jslink(source: WidgetTraitPair, target: WidgetTraitPair): Link { + return new Link({ source, target }); +} + +export function jsdlink( + source: WidgetTraitPair, + target: WidgetTraitPair +): DirectionalLink { + return new DirectionalLink({ source, target }); +} diff --git a/packages/javascript-kernel/src/widgets/widget_number.ts b/packages/javascript-kernel/src/widgets/widget_number.ts new file mode 100644 index 0000000..4741b87 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_number.ts @@ -0,0 +1,220 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { DOMWidget } from './widget'; + +export class _SliderBase extends DOMWidget { + protected override _defaults() { + return { + ...super._defaults(), + value: 0, + min: 0, + max: 100, + step: 1, + orientation: 'horizontal', + readout: true, + continuous_update: true, + behavior: 'drag-tap' + }; + } + + get value(): number { + return this.get('value') as number; + } + set value(v: number) { + this.set('value', v); + } + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } + get step(): number { + return this.get('step') as number; + } + set step(v: number) { + this.set('step', v); + } + get orientation(): string { + return this.get('orientation') as string; + } + set orientation(v: string) { + this.set('orientation', v); + } + get readout(): boolean { + return this.get('readout') as boolean; + } + set readout(v: boolean) { + this.set('readout', v); + } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } + get behavior(): string { + return this.get('behavior') as string; + } + set behavior(v: string) { + this.set('behavior', v); + } +} + +export class _RangeSliderBase extends DOMWidget { + protected override _defaults() { + return { + ...super._defaults(), + value: [0, 1], + min: 0, + max: 100, + step: 1, + orientation: 'horizontal', + readout: true, + continuous_update: true, + behavior: 'drag-tap' + }; + } + + get value(): [number, number] { + return this.get('value') as [number, number]; + } + set value(v: [number, number]) { + this.set('value', v); + } + get lower(): number { + return this.value[0]; + } + set lower(v: number) { + this.value = [v, this.value[1]]; + } + get upper(): number { + return this.value[1]; + } + set upper(v: number) { + this.value = [this.value[0], v]; + } + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } + get step(): number { + return this.get('step') as number; + } + set step(v: number) { + this.set('step', v); + } + get orientation(): string { + return this.get('orientation') as string; + } + set orientation(v: string) { + this.set('orientation', v); + } + get readout(): boolean { + return this.get('readout') as boolean; + } + set readout(v: boolean) { + this.set('readout', v); + } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } + get behavior(): string { + return this.get('behavior') as string; + } + set behavior(v: string) { + this.set('behavior', v); + } +} + +export class _ProgressBase extends DOMWidget { + protected override _defaults() { + return { + ...super._defaults(), + value: 0, + min: 0, + max: 100, + bar_style: '', + orientation: 'horizontal' + }; + } + + get value(): number { + return this.get('value') as number; + } + set value(v: number) { + this.set('value', v); + } + get min(): number { + return this.get('min') as number; + } + set min(v: number) { + this.set('min', v); + } + get max(): number { + return this.get('max') as number; + } + set max(v: number) { + this.set('max', v); + } + get bar_style(): string { + return this.get('bar_style') as string; + } + set bar_style(v: string) { + this.set('bar_style', v); + } + get orientation(): string { + return this.get('orientation') as string; + } + set orientation(v: string) { + this.set('orientation', v); + } +} + +export class _NumericTextBase extends DOMWidget { + protected override _defaults() { + return { + ...super._defaults(), + value: 0, + step: 1, + continuous_update: false + }; + } + + get value(): number { + return this.get('value') as number; + } + set value(v: number) { + this.set('value', v); + } + get step(): number { + return this.get('step') as number; + } + set step(v: number) { + this.set('step', v); + } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } +} diff --git a/packages/javascript-kernel/src/widgets/widget_output.ts b/packages/javascript-kernel/src/widgets/widget_output.ts new file mode 100644 index 0000000..e322bb9 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_output.ts @@ -0,0 +1,144 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { DOMWidget } from './widget'; +import { OUTPUT_MODULE, OUTPUT_MODULE_VERSION } from './version'; + +export interface IOutputCaptureOptions { + clearOutput?: boolean; + wait?: boolean; +} + +export class Output extends DOMWidget { + static override modelName = 'OutputModel'; + static override viewName = 'OutputView'; + static override modelModule = OUTPUT_MODULE; + static override modelModuleVersion = OUTPUT_MODULE_VERSION; + static override viewModule = OUTPUT_MODULE; + static override viewModuleVersion = OUTPUT_MODULE_VERSION; + + protected override _defaults() { + return { + ...super._defaults(), + msg_id: '', + outputs: [] + }; + } + + get msg_id(): string { + return this.get('msg_id') as string; + } + set msg_id(v: string) { + this.set('msg_id', v); + } + + get outputs(): Array> { + return [...((this.get('outputs') as Array>) ?? [])]; + } + set outputs(v: Array>) { + this.set('outputs', [...v]); + } + + get currentMessageId(): string | null { + return this._manager.getCurrentMessageId(); + } + + clearOutput(_options: { wait?: boolean } = {}): void { + this.outputs = []; + } + + appendStdout(text: string): void { + this._appendOutput({ output_type: 'stream', name: 'stdout', text }); + } + + appendStderr(text: string): void { + this._appendOutput({ output_type: 'stream', name: 'stderr', text }); + } + + appendDisplayData( + data: Record, + metadata: Record = {} + ): void { + this._appendOutput({ + output_type: 'display_data', + data, + metadata + }); + } + + capture any>(callback: T): T; + capture any>( + callback: T, + options: IOutputCaptureOptions + ): T; + capture( + options?: IOutputCaptureOptions + ): any>(callback: T) => T; + capture any>( + callbackOrOptions?: T | IOutputCaptureOptions, + options: IOutputCaptureOptions = {} + ): T | ((callback: T) => T) { + if (typeof callbackOrOptions === 'function') { + return this._captureWrapper(callbackOrOptions, options); + } + + const captureOptions = callbackOrOptions ?? {}; + return (callback: T) => this._captureWrapper(callback, captureOptions); + } + + private _appendOutput(output: Record): void { + this.outputs = [...this.outputs, output]; + } + + private _captureWrapper any>( + callback: T, + options: IOutputCaptureOptions + ): T { + return _wrapCapturedOutputCallback(this, callback, options); + } + + _captureDepth = 0; +} + +function _wrapCapturedOutputCallback any>( + output: Output, + callback: T, + options: IOutputCaptureOptions +): T { + return function (this: unknown, ...args: any[]): any { + const shouldClear = options.clearOutput ?? false; + if (shouldClear) { + output.clearOutput({ wait: options.wait }); + } + + const messageId = output.currentMessageId; + if (messageId) { + if (output._captureDepth === 0) { + output.msg_id = messageId; + } + output._captureDepth += 1; + } + + const finish = (): void => { + if (!messageId) { + return; + } + output._captureDepth = Math.max(0, output._captureDepth - 1); + if (output._captureDepth === 0 && output.msg_id === messageId) { + output.msg_id = ''; + } + }; + + try { + const result = callback.apply(this, args); + if (result && typeof (result as Promise).then === 'function') { + return Promise.resolve(result).finally(finish); + } + finish(); + return result; + } catch (error) { + finish(); + throw error; + } + } as T; +} diff --git a/packages/javascript-kernel/src/widgets/widget_selection.ts b/packages/javascript-kernel/src/widgets/widget_selection.ts new file mode 100644 index 0000000..2945f24 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_selection.ts @@ -0,0 +1,270 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { DOMWidget } from './widget'; + +class _SelectionBase extends DOMWidget { + constructor(state?: Record & { options?: string[] }) { + const { options, ...rest } = state ?? {}; + if (options !== undefined) { + (rest as Record)._options_labels = options; + if (rest.index === undefined) { + (rest as Record).index = options.length > 0 ? 0 : null; + } + } + super(rest as Record); + } + + protected override _defaults(): Record { + return { ...super._defaults(), _options_labels: [], index: null }; + } + + get options(): string[] { + return this.get('_options_labels') as string[]; + } + set options(v: string[]) { + this.set({ _options_labels: v, index: v.length > 0 ? 0 : null }); + } + get index(): number | null { + return this.get('index') as number | null; + } + set index(v: number | null) { + this.set('index', v); + } + get value(): string | null { + return this.selectedLabel; + } + set value(v: string | null) { + if (v === null) { + this.index = null; + return; + } + const idx = this.options.indexOf(v); + this.index = idx === -1 ? null : idx; + } + get label(): string | null { + return this.selectedLabel; + } + set label(v: string | null) { + this.value = v; + } + + /** + * The label of the currently selected option, or null if none. + */ + get selectedLabel(): string | null { + const idx = this.index; + if (idx === null || idx === undefined) { + return null; + } + return this.options[idx] ?? null; + } +} + +class _MultipleSelectionBase extends DOMWidget { + constructor(state?: Record & { options?: string[] }) { + const { options, ...rest } = state ?? {}; + if (options !== undefined) { + (rest as Record)._options_labels = options; + if (rest.index === undefined) { + (rest as Record).index = []; + } + } + super(rest as Record); + } + + protected override _defaults(): Record { + return { ...super._defaults(), _options_labels: [], index: [] }; + } + + get options(): string[] { + return this.get('_options_labels') as string[]; + } + set options(v: string[]) { + this.set({ _options_labels: v, index: [] }); + } + get index(): number[] { + return [...((this.get('index') as number[]) ?? [])]; + } + set index(v: number[]) { + this.set('index', [...v]); + } + get value(): string[] { + return this.selectedLabels; + } + set value(v: string[]) { + this.index = v + .map(label => this.options.indexOf(label)) + .filter(idx => idx >= 0); + } + get label(): string[] { + return this.selectedLabels; + } + set label(v: string[]) { + this.value = v; + } + + get selectedLabels(): string[] { + return this.index.map(idx => this.options[idx]).filter(Boolean); + } +} + +export class Dropdown extends _SelectionBase { + static override modelName = 'DropdownModel'; + static override viewName = 'DropdownView'; +} + +export class RadioButtons extends _SelectionBase { + static override modelName = 'RadioButtonsModel'; + static override viewName = 'RadioButtonsView'; +} + +export class Select extends _SelectionBase { + static override modelName = 'SelectModel'; + static override viewName = 'SelectView'; + + protected override _defaults() { + return { ...super._defaults(), rows: 5 }; + } + + get rows(): number { + return this.get('rows') as number; + } + set rows(v: number) { + this.set('rows', v); + } +} + +export class SelectMultiple extends _MultipleSelectionBase { + static override modelName = 'SelectMultipleModel'; + static override viewName = 'SelectMultipleView'; + + protected override _defaults() { + return { ...super._defaults(), rows: 5 }; + } + + get rows(): number { + return this.get('rows') as number; + } + set rows(v: number) { + this.set('rows', v); + } +} + +export class ToggleButtons extends _SelectionBase { + static override modelName = 'ToggleButtonsModel'; + static override viewName = 'ToggleButtonsView'; + + protected override _defaults() { + return { ...super._defaults(), tooltips: [], button_style: '', icons: [] }; + } + + get tooltips(): string[] { + return this.get('tooltips') as string[]; + } + set tooltips(v: string[]) { + this.set('tooltips', v); + } + get button_style(): string { + return this.get('button_style') as string; + } + set button_style(v: string) { + this.set('button_style', v); + } + get icons(): string[] { + return this.get('icons') as string[]; + } + set icons(v: string[]) { + this.set('icons', v); + } +} + +export class SelectionSlider extends _SelectionBase { + static override modelName = 'SelectionSliderModel'; + static override viewName = 'SelectionSliderView'; + + protected override _defaults() { + return { + ...super._defaults(), + orientation: 'horizontal', + readout: true, + continuous_update: true, + behavior: 'drag-tap' + }; + } + + get orientation(): string { + return this.get('orientation') as string; + } + set orientation(v: string) { + this.set('orientation', v); + } + get readout(): boolean { + return this.get('readout') as boolean; + } + set readout(v: boolean) { + this.set('readout', v); + } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } + get behavior(): string { + return this.get('behavior') as string; + } + set behavior(v: string) { + this.set('behavior', v); + } +} + +export class SelectionRangeSlider extends _MultipleSelectionBase { + static override modelName = 'SelectionRangeSliderModel'; + static override viewName = 'SelectionRangeSliderView'; + + constructor(state?: Record & { options?: string[] }) { + const next = { ...(state ?? {}) }; + const options = next.options as string[] | undefined; + if (options && next.index === undefined) { + next.index = options.length > 0 ? [0, 0] : []; + } + super(next as Record & { options?: string[] }); + } + + protected override _defaults(): Record { + return { + ...super._defaults(), + index: [0, 0], + orientation: 'horizontal', + readout: true, + continuous_update: true, + behavior: 'drag-tap' + }; + } + + get orientation(): string { + return this.get('orientation') as string; + } + set orientation(v: string) { + this.set('orientation', v); + } + get readout(): boolean { + return this.get('readout') as boolean; + } + set readout(v: boolean) { + this.set('readout', v); + } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } + get behavior(): string { + return this.get('behavior') as string; + } + set behavior(v: string) { + this.set('behavior', v); + } +} diff --git a/packages/javascript-kernel/src/widgets/widget_selectioncontainer.ts b/packages/javascript-kernel/src/widgets/widget_selectioncontainer.ts new file mode 100644 index 0000000..74988d1 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_selectioncontainer.ts @@ -0,0 +1,95 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import type { Widget } from './widget'; +import { Box } from './widget_box'; + +class _SelectionContainer extends Box { + constructor( + state?: Record & { + children?: Widget[]; + titles?: string[]; + } + ) { + const next = { ...(state ?? {}) }; + const childCount = (next.children as Widget[] | undefined)?.length ?? 0; + const titles = (next.titles as string[] | undefined) ?? []; + const padded = [...titles]; + while (padded.length < childCount) { + padded.push(''); + } + next.titles = padded; + super(next as Record & { children?: Widget[] }); + } + + protected override _defaults() { + return { ...super._defaults(), titles: [], selected_index: null }; + } + + get titles(): string[] { + return this.get('titles') as string[]; + } + set titles(v: string[]) { + this.set('titles', v); + } + + get selected_index(): number | null { + return this.get('selected_index') as number | null; + } + set selected_index(v: number | null) { + this.set('selected_index', v); + } + + /** + * Set the title of a container page. + */ + setTitle(index: number, title: string): void { + const next = [...this.titles]; + while (next.length <= index) { + next.push(''); + } + next[index] = title; + this.titles = next; + } + + /** + * Get the title of a container page. + */ + getTitle(index: number): string { + return this.titles[index] ?? ''; + } +} + +export class Accordion extends _SelectionContainer { + static override modelName = 'AccordionModel'; + static override viewName = 'AccordionView'; +} + +export class Tab extends _SelectionContainer { + static override modelName = 'TabModel'; + static override viewName = 'TabView'; + + constructor( + state?: Record & { + children?: Widget[]; + titles?: string[]; + } + ) { + const next = { ...(state ?? {}) }; + const children = (next.children as Widget[] | undefined) ?? []; + if (children.length > 0 && next.selected_index === undefined) { + next.selected_index = 0; + } + super( + next as Record & { + children?: Widget[]; + titles?: string[]; + } + ); + } +} + +export class Stack extends _SelectionContainer { + static override modelName = 'StackModel'; + static override viewName = 'StackView'; +} diff --git a/packages/javascript-kernel/src/widgets/widget_string.ts b/packages/javascript-kernel/src/widgets/widget_string.ts new file mode 100644 index 0000000..f3f4061 --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_string.ts @@ -0,0 +1,107 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { DOMWidget } from './widget'; + +class _TextBase extends DOMWidget { + protected override _defaults() { + return { + ...super._defaults(), + value: '', + placeholder: '', + continuous_update: true + }; + } + + get value(): string { + return this.get('value') as string; + } + set value(v: string) { + this.set('value', v); + } + get placeholder(): string { + return this.get('placeholder') as string; + } + set placeholder(v: string) { + this.set('placeholder', v); + } + get continuous_update(): boolean { + return this.get('continuous_update') as boolean; + } + set continuous_update(v: boolean) { + this.set('continuous_update', v); + } +} + +export class Text extends _TextBase { + static override modelName = 'TextModel'; + static override viewName = 'TextView'; +} + +export class Textarea extends _TextBase { + static override modelName = 'TextareaModel'; + static override viewName = 'TextareaView'; +} + +export class Password extends _TextBase { + static override modelName = 'PasswordModel'; + static override viewName = 'PasswordView'; +} + +export class Combobox extends _TextBase { + static override modelName = 'ComboboxModel'; + static override viewName = 'ComboboxView'; + + constructor(state?: Record & { options?: string[] }) { + const { options, ...rest } = state ?? {}; + if (options !== undefined) { + (rest as Record).options = options; + } + super(rest as Record); + } + + protected override _defaults() { + return { ...super._defaults(), options: [], ensure_option: false }; + } + + get options(): string[] { + return this.get('options') as string[]; + } + set options(v: string[]) { + this.set('options', v); + } + get ensure_option(): boolean { + return this.get('ensure_option') as boolean; + } + set ensure_option(v: boolean) { + this.set('ensure_option', v); + } +} + +class _DisplayBase extends DOMWidget { + protected override _defaults() { + return { ...super._defaults(), value: '' }; + } + + get value(): string { + return this.get('value') as string; + } + set value(v: string) { + this.set('value', v); + } +} + +export class Label extends _DisplayBase { + static override modelName = 'LabelModel'; + static override viewName = 'LabelView'; +} + +export class HTML extends _DisplayBase { + static override modelName = 'HTMLModel'; + static override viewName = 'HTMLView'; +} + +export class HTMLMath extends _DisplayBase { + static override modelName = 'HTMLMathModel'; + static override viewName = 'HTMLMathView'; +} diff --git a/packages/javascript-kernel/src/widgets/widget_style.ts b/packages/javascript-kernel/src/widgets/widget_style.ts new file mode 100644 index 0000000..0a9f89c --- /dev/null +++ b/packages/javascript-kernel/src/widgets/widget_style.ts @@ -0,0 +1,77 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Widget } from './widget'; +import { + BASE_MODULE, + BASE_MODULE_VERSION, + CONTROLS_MODULE, + CONTROLS_MODULE_VERSION +} from './version'; + +export class Style extends Widget { + static override modelName = 'StyleModel'; + static override viewName = 'StyleView'; + static override modelModule = BASE_MODULE; + static override modelModuleVersion = BASE_MODULE_VERSION; + static override viewModule = BASE_MODULE; + static override viewModuleVersion = BASE_MODULE_VERSION; + + protected override _defaults(): Record { + return {}; + } +} + +export class DescriptionStyle extends Style { + static override modelName = 'DescriptionStyleModel'; + static override modelModule = CONTROLS_MODULE; + static override modelModuleVersion = CONTROLS_MODULE_VERSION; +} + +export class SliderStyle extends DescriptionStyle { + static override modelName = 'SliderStyleModel'; +} + +export class ProgressStyle extends DescriptionStyle { + static override modelName = 'ProgressStyleModel'; +} + +export class ButtonStyle extends Style { + static override modelName = 'ButtonStyleModel'; + static override modelModule = CONTROLS_MODULE; + static override modelModuleVersion = CONTROLS_MODULE_VERSION; +} + +export class CheckboxStyle extends DescriptionStyle { + static override modelName = 'CheckboxStyleModel'; +} + +export class ToggleButtonStyle extends DescriptionStyle { + static override modelName = 'ToggleButtonStyleModel'; +} + +export class ToggleButtonsStyle extends DescriptionStyle { + static override modelName = 'ToggleButtonsStyleModel'; +} + +export class TextStyle extends DescriptionStyle { + static override modelName = 'TextStyleModel'; +} + +export class HTMLStyle extends Style { + static override modelName = 'HTMLStyleModel'; + static override modelModule = CONTROLS_MODULE; + static override modelModuleVersion = CONTROLS_MODULE_VERSION; +} + +export class HTMLMathStyle extends Style { + static override modelName = 'HTMLMathStyleModel'; + static override modelModule = CONTROLS_MODULE; + static override modelModuleVersion = CONTROLS_MODULE_VERSION; +} + +export class LabelStyle extends Style { + static override modelName = 'LabelStyleModel'; + static override modelModule = CONTROLS_MODULE; + static override modelModuleVersion = CONTROLS_MODULE_VERSION; +} From b333260f89390c394f1ec3ad3220b6fc07f09b0d Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Thu, 12 Mar 2026 21:59:33 +0100 Subject: [PATCH 6/6] ported widget table --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 34243d8..d48635b 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,27 @@ Widgets auto-display when they are the last expression in a cell. Use the global - **Containers**: `Box`, `HBox`, `VBox`, `GridBox`, `Accordion`, `Tab`, `Stack` - **Helpers**: `jslink`, `jsdlink` +### Ported widget modules + +The widget runtime is split into files that roughly follow the upstream `ipywidgets` package structure so it is easier to track what has been ported. + +| Upstream `ipywidgets` file | Local file | Status | Notes | +| ---------------------------------------------------- | --------------------------------------------------------------------- | ------- | -------------------------------------------------------------------- | +| `packages/base/src/widget.ts` | `packages/javascript-kernel/src/widgets/widget.ts` | Ported | Kernel-side `Widget` and `DOMWidget` equivalents | +| `packages/base/src/widget_layout.ts` | `packages/javascript-kernel/src/widgets/widget_layout.ts` | Ported | Layout models | +| `packages/base/src/widget_style.ts` | `packages/javascript-kernel/src/widgets/widget_style.ts` | Ported | Shared style models, plus control-specific styles gathered here | +| `packages/controls/src/widget_int.ts` | `packages/javascript-kernel/src/widgets/widget_int.ts` | Ported | Integer widgets, play, progress, and text inputs | +| `packages/controls/src/widget_float.ts` | `packages/javascript-kernel/src/widgets/widget_float.ts` | Ported | Float widgets | +| `packages/controls/src/widget_bool.ts` | `packages/javascript-kernel/src/widgets/widget_bool.ts` | Ported | Boolean widgets; related styles live in `widget_style.ts` | +| `packages/controls/src/widget_selection.ts` | `packages/javascript-kernel/src/widgets/widget_selection.ts` | Partial | Selection semantics still differ from `ipywidgets` in some cases | +| `packages/controls/src/widget_string.ts` | `packages/javascript-kernel/src/widgets/widget_string.ts` | Ported | String and display widgets; related styles live in `widget_style.ts` | +| `packages/output/src/output.ts` | `packages/javascript-kernel/src/widgets/widget_output.ts` | Partial | Output capture is supported but not feature-complete | +| `packages/controls/src/widget_button.ts` | `packages/javascript-kernel/src/widgets/widget_button.ts` | Partial | Button widget is present, but callback behavior differs slightly | +| `packages/controls/src/widget_color.ts` | `packages/javascript-kernel/src/widgets/widget_color.ts` | Ported | Color picker | +| `packages/controls/src/widget_box.ts` | `packages/javascript-kernel/src/widgets/widget_box.ts` | Ported | Box, HBox, VBox, GridBox | +| `packages/controls/src/widget_selectioncontainer.ts` | `packages/javascript-kernel/src/widgets/widget_selectioncontainer.ts` | Ported | Accordion, Tab, Stack | +| `packages/controls/src/widget_link.ts` | `packages/javascript-kernel/src/widgets/widget_link.ts` | Ported | `jslink`, `jsdlink`, `Link`, and `DirectionalLink` | + > **Note:** `jupyterlab-widgets`, `@jupyter-widgets/controls`, and `@jupyter-widgets/output` must be available in the JupyterLite deployment for the full widget set to render. See the [example notebook](examples/widgets.ipynb) for more usage examples.