diff --git a/README.md b/README.md index d1f8eac..d48635b 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,75 @@ 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 and helpers are available under `Jupyter.widgets`; destructure the ones you need before using them: + +```javascript +const { IntSlider, IntProgress, jslink } = Jupyter.widgets; + +const slider = new IntSlider({ + value: 50, + min: 0, + max: 100, + description: 'My Slider' +}); +const progress = new IntProgress({ + value: 50, + min: 0, + max: 100, + description: 'Mirror' +}); +display(slider); +display(progress); + +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`, `FloatLogSlider`, `IntRangeSlider`, `FloatRangeSlider`, `Play`, `IntProgress`, `FloatProgress`, `IntText`, `FloatText`, `BoundedIntText`, `BoundedFloatText` +- **Boolean**: `Checkbox`, `ToggleButton`, `Valid` +- **Selection**: `Dropdown`, `RadioButtons`, `Select`, `SelectMultiple`, `ToggleButtons`, `SelectionSlider`, `SelectionRangeSlider` +- **String**: `Text`, `Textarea`, `Password`, `Combobox` +- **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` + +### 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. + ### 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..6e5fe45 --- /dev/null +++ b/examples/widgets.ipynb @@ -0,0 +1,413 @@ +{ + "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 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 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", + "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.observe(({ new: value }) => {\n console.log('Slider value:', value);\n}, 'value');" + }, + { + "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": "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": {}, + "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.observe(({ new: value }) => {\n console.log('Selected:', value);\n}, 'value');" + }, + { + "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": "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": {}, + "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", + "Use the frontend link helpers when you want widgets to stay in sync without manual wiring:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "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 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 +} 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/manager.ts b/packages/javascript-kernel/src/comm/manager.ts new file mode 100644 index 0000000..151067b --- /dev/null +++ b/packages/javascript-kernel/src/comm/manager.ts @@ -0,0 +1,245 @@ +// 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); + } + + /** + * 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); + } + + /** + * 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. + */ + 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._widgets.delete(commId); + 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._widgets.clear(); + this._comms.clear(); + this._targets.clear(); + } + + /** + * Create an IComm instance bound to this manager. + */ + private _createComm(commId: string, targetName: string): IComm { + const comm: IComm = { + get commId() { + return commId; + }, + get targetName() { + return targetName; + }, + send: ( + data: Record, + metadata?: Record, + buffers?: ArrayBuffer[] + ): void => { + this._onOutput({ + type: 'comm_msg', + content: { + comm_id: commId, + data + }, + metadata, + buffers + }); + }, + close: (data: Record = {}): void => { + this._onOutput({ + type: 'comm_close', + content: { + comm_id: commId, + data + } + }); + comm.onClose?.(data); + this._widgets.delete(commId); + this._comms.delete(commId); + }, + display: (): void => { + this.displayWidget(commId); + }, + onMsg: null, + onClose: null + }; + return comm; + } + + private _onOutput: RuntimeOutputHandler; + private _comms = new Map(); + private _targets = new Map(); + private _widgets = new Map(); + private _currentMessageId: string | null = null; +} 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..e7e0b33 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(); } @@ -99,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 = [ @@ -187,9 +192,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 +215,45 @@ 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, + msg.header.msg_id + ); } /** * 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, + msg.header.msg_id + ); } /** * 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, + msg.header.msg_id + ); } /** @@ -306,6 +341,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 +412,31 @@ 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 +451,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..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, @@ -48,6 +49,25 @@ export interface IRuntimeBackend { isComplete( code: string ): Promise; + handleCommOpen( + commId: string, + targetName: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): Promise; + handleCommMsg( + commId: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): Promise; + handleCommClose( + commId: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): Promise; } /** @@ -71,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); } /** @@ -110,6 +131,63 @@ 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[], + parentMessageId?: string + ): Promise { + await this.ready; + if (this._remote) { + await this._remote.handleCommOpen( + commId, + targetName, + data, + buffers, + parentMessageId + ); + } + } + + /** + * Forward comm_msg to the remote runtime. + */ + async handleCommMsg( + commId: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): Promise { + await this.ready; + if (this._remote) { + await this._remote.handleCommMsg(commId, data, buffers, parentMessageId); + } + } + + /** + * Forward comm_close to the remote runtime. + */ + async handleCommClose( + commId: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): Promise { + await this.ready; + if (this._remote) { + await this._remote.handleCommClose( + commId, + data, + buffers, + parentMessageId + ); + } + } + /** * 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..e332474 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, createWidgetClasses } 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._setupWidgets(); + this._setupJupyterGlobal(); this._setupDisplay(); this._setupConsoleOverrides(); } @@ -30,6 +35,9 @@ export class JavaScriptRuntimeEvaluator { dispose(): void { this._restoreConsoleOverrides(); this._restoreDisplay(); + this._restoreWidgets(); + this._restoreJupyterGlobal(); + this._commManager.dispose(); } /** @@ -51,51 +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) { - 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); + } + }); } /** @@ -134,6 +149,56 @@ 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[], + parentMessageId?: string + ): void { + void this._withParentMessageId(parentMessageId, async () => { + this._commManager.handleCommOpen(commId, targetName, data, buffers); + }); + } + + /** + * Handle a comm_msg message from the frontend. + */ + handleCommMsg( + commId: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): void { + void this._withParentMessageId(parentMessageId, async () => { + this._commManager.handleCommMsg(commId, data, buffers); + }); + } + + /** + * Handle a comm_close message from the frontend. + */ + handleCommClose( + commId: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): void { + void this._withParentMessageId(parentMessageId, async () => { + this._commManager.handleCommClose(commId, data, buffers); + }); + } + /** * Evaluate an async function within the configured global scope. */ @@ -141,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. */ @@ -283,6 +364,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 +394,54 @@ export class JavaScriptRuntimeEvaluator { this._globalScope.display = this._previousDisplay; } + /** + * Create runtime-local widget classes. + */ + private _setupWidgets(): void { + this._widgetClasses = createWidgetClasses(this._commManager); + } + + /** + * Clear runtime-local widget classes. + */ + private _restoreWidgets(): void { + this._widgetClasses = null; + } + + /** + * Install Jupyter.comm in runtime scope. + */ + 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: this._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 _widgetClasses: 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..37db748 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[]; }; /** @@ -64,7 +93,8 @@ export interface IRemoteRuntimeApi { ): Promise; execute( code: string, - executionCount: number + executionCount: number, + parentMessageId?: string ): Promise; complete( code: string, @@ -78,5 +108,24 @@ export interface IRemoteRuntimeApi { isComplete( code: string ): Promise; + handleCommOpen( + commId: string, + targetName: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): Promise; + handleCommMsg( + commId: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): Promise; + handleCommClose( + commId: string, + data: Record, + 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 68aafc1..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) { @@ -71,6 +75,40 @@ export function createRemoteRuntimeApi( return ensureEvaluator().isComplete(code); }, + async handleCommOpen( + commId: string, + targetName: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): Promise { + ensureEvaluator().handleCommOpen( + commId, + targetName, + data, + buffers, + parentMessageId + ); + }, + + async handleCommMsg( + commId: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): Promise { + ensureEvaluator().handleCommMsg(commId, data, buffers, parentMessageId); + }, + + async handleCommClose( + commId: string, + data: Record, + buffers?: ArrayBuffer[], + parentMessageId?: string + ): Promise { + ensureEvaluator().handleCommClose(commId, data, buffers, parentMessageId); + }, + async dispose(): Promise { evaluator?.dispose(); evaluator = null; @@ -120,6 +158,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/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; +}