diff --git a/README.md b/README.md index 07cac28..010df89 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Each integration lives in the `integrations/` directory and has its own README. - [IntelliJ Platform](integrations/intellij-platform/README.md) — interactive access to IntelliJ Platform APIs from a running IDE process. - [HTTP Utilities](integrations/http-util/README.md) — JSON/serialization helpers and Ktor HTTP client wrappers for notebooks. - [Database](integrations/database/README.md) — helpers for configuring JDBC DataSources and working with Databases. +- [Widgets](integrations/widgets/README.md) — ipywidgets API for Kotlin notebooks. ## Contributing diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 403a25a..046923d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ # https://intellij-support.jetbrains.com/hc/en-us/articles/206544879-Selecting-the-JDK-version-the-IDE-will-run-under jvmTarget = "17" kotlin = "2.2.20" -kotlin-jupyter = "0.15.1-668" +kotlin-jupyter = "0.15.1-761-1" publishPlugin = "2.2.0-dev-71" intellijPlatformGradlePlugin = "2.10.3" intellijPlatform = "253-EAP-SNAPSHOT" # TODO: lower to 2025.1.3 GA whenever released with required changes diff --git a/integrations/intellij-platform/build.gradle.kts b/integrations/intellij-platform/build.gradle.kts index 856e12b..ef92eee 100644 --- a/integrations/intellij-platform/build.gradle.kts +++ b/integrations/intellij-platform/build.gradle.kts @@ -12,10 +12,6 @@ plugins { val spaceUsername: String by properties val spaceToken: String by properties -allprojects { - version = rootProject.version -} - kotlinJupyter { addApiDependency() } diff --git a/integrations/widgets/README.md b/integrations/widgets/README.md new file mode 100644 index 0000000..7b38523 --- /dev/null +++ b/integrations/widgets/README.md @@ -0,0 +1,35 @@ +[![JetBrains official project](https://jb.gg/badges/official.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub) +[![Kotlin experimental stability](https://img.shields.io/badge/project-experimental-kotlin.svg?colorA=555555&colorB=AC29EC&label=&logo=kotlin&logoColor=ffffff&logoWidth=10)](https://kotlinlang.org/docs/components-stability.html) + +# Kotlin Notebook Widgets Integration + +This integration provides a collection of interactive widgets for Kotlin Notebooks, such as sliders, labels, and more. It allows you to create a richer, more interactive experience within your notebooks. + +## Usage + +Use this API through the `%use widgets` magic command in a Kotlin Notebook. + +```kotlin +%use widgets + +val slider = intSliderWidget().apply { + min = 0 + max = 100 + value = 50 + description = "Select a value:" +} + +val label = labelWidget().apply { + value = "Current value: ${slider.value}" +} + +// Display the slider +slider +``` + +## Module structure + +This project consists of the following modules: + +- `widgets-api`: Contains the core widget implementations, protocols, and model definitions. +- `widgets-jupyter`: Provides the integration logic and useful helpers for Kotlin Jupyter notebooks. diff --git a/integrations/widgets/build.gradle.kts b/integrations/widgets/build.gradle.kts new file mode 100644 index 0000000..929ff19 --- /dev/null +++ b/integrations/widgets/build.gradle.kts @@ -0,0 +1,24 @@ +import org.jetbrains.kotlinx.publisher.apache2 +import org.jetbrains.kotlinx.publisher.githubRepo + +plugins { + alias(libs.plugins.publisher) +} + +kotlinPublications { + pom { + githubRepo("Kotlin", "kotlin-notebook-integrations") + inceptionYear = "2025" + licenses { + apache2() + } + developers { + developer { + id.set("kotlin-jupyter-team") + name.set("Kotlin Jupyter Team") + organization.set("JetBrains") + organizationUrl.set("https://www.jetbrains.com") + } + } + } +} diff --git a/integrations/widgets/widgets-api/build.gradle.kts b/integrations/widgets/widgets-api/build.gradle.kts new file mode 100644 index 0000000..cdde7f4 --- /dev/null +++ b/integrations/widgets/widgets-api/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.publisher) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.jupyter.api) +} + +dependencies { + compileOnly(libs.kotlinx.serialization.json) + implementation(libs.kotlin.reflect) +} + +kotlin { + jvmToolchain( + libs.versions.jvm.toolchain + .get() + .toInt(), + ) + explicitApi() +} + +kotlinPublications { + publication { + description.set("Kotlin APIs for IPython Widgets") + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/WidgetManager.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/WidgetManager.kt new file mode 100644 index 0000000..47031e3 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/WidgetManager.kt @@ -0,0 +1,17 @@ +package org.jetbrains.kotlinx.jupyter.widget + +import org.jetbrains.kotlinx.jupyter.api.DisplayResult +import org.jetbrains.kotlinx.jupyter.widget.model.WidgetFactoryRegistry +import org.jetbrains.kotlinx.jupyter.widget.model.WidgetModel + +public interface WidgetManager { + public val factoryRegistry: WidgetFactoryRegistry + + public fun getWidget(modelId: String): WidgetModel? + + public fun getWidgetId(widget: WidgetModel): String? + + public fun registerWidget(widget: WidgetModel) + + public fun renderWidget(widget: WidgetModel): DisplayResult +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/WidgetManagerImpl.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/WidgetManagerImpl.kt new file mode 100644 index 0000000..5dcc755 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/WidgetManagerImpl.kt @@ -0,0 +1,190 @@ +package org.jetbrains.kotlinx.jupyter.widget + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.jetbrains.kotlinx.jupyter.api.DisplayResult +import org.jetbrains.kotlinx.jupyter.api.MimeTypedResultEx +import org.jetbrains.kotlinx.jupyter.api.MimeTypes +import org.jetbrains.kotlinx.jupyter.api.libraries.Comm +import org.jetbrains.kotlinx.jupyter.api.libraries.CommManager +import org.jetbrains.kotlinx.jupyter.widget.model.DEFAULT_MAJOR_VERSION +import org.jetbrains.kotlinx.jupyter.widget.model.DEFAULT_MINOR_VERSION +import org.jetbrains.kotlinx.jupyter.widget.model.DefaultWidgetModel +import org.jetbrains.kotlinx.jupyter.widget.model.WidgetFactoryRegistry +import org.jetbrains.kotlinx.jupyter.widget.model.WidgetModel +import org.jetbrains.kotlinx.jupyter.widget.model.versionConstraintRegex +import org.jetbrains.kotlinx.jupyter.widget.protocol.CustomMessage +import org.jetbrains.kotlinx.jupyter.widget.protocol.RequestStateMessage +import org.jetbrains.kotlinx.jupyter.widget.protocol.RequestStatesMessage +import org.jetbrains.kotlinx.jupyter.widget.protocol.UpdateStatesMessage +import org.jetbrains.kotlinx.jupyter.widget.protocol.WidgetMessage +import org.jetbrains.kotlinx.jupyter.widget.protocol.WidgetOpenMessage +import org.jetbrains.kotlinx.jupyter.widget.protocol.WidgetStateMessage +import org.jetbrains.kotlinx.jupyter.widget.protocol.WidgetUpdateMessage +import org.jetbrains.kotlinx.jupyter.widget.protocol.getWireMessage +import org.jetbrains.kotlinx.jupyter.widget.protocol.toPatch + +private val widgetOpenMetadataJson = + buildJsonObject { + put("version", "${DEFAULT_MAJOR_VERSION}.${DEFAULT_MINOR_VERSION}") + } + +public class WidgetManagerImpl( + private val commManager: CommManager, + private val classLoaderProvider: () -> ClassLoader, +) : WidgetManager { + private val widgetTarget = "jupyter.widget" + private val widgetControlTarget = "jupyter.widget.control" + private val widgets = mutableMapOf() + private val widgetIdByWidget = mutableMapOf() + + override val factoryRegistry: WidgetFactoryRegistry = WidgetFactoryRegistry() + + init { + commManager.registerCommTarget(widgetControlTarget) { comm, _, _, _ -> + comm.onMessage { msg, _, _ -> + when (Json.decodeFromJsonElement(msg)) { + is RequestStatesMessage -> { + val fullStates = + widgets.mapValues { (id, widget) -> + widget.getFullState() + } + + val wireMessage = getWireMessage(fullStates) + val message = UpdateStatesMessage(wireMessage.state, wireMessage.bufferPaths) + + val data = Json.encodeToJsonElement(message).jsonObject + comm.send(data, null, emptyList()) + } + + else -> {} + } + } + } + + commManager.registerCommTarget(widgetTarget) { comm, data, _, buffers -> + val openMessage = Json.decodeFromJsonElement(data) + val modelName = openMessage.state["_model_name"]?.jsonPrimitive?.content!! + val widgetFactory = factoryRegistry.loadWidgetFactory(modelName, classLoaderProvider()) + + val widget = widgetFactory.create(this) + val patch = openMessage.toPatch(buffers) + widget.applyPatch(patch) + + initializeWidget(comm, widget) + } + } + + override fun getWidget(modelId: String): WidgetModel? = widgets[modelId] + + override fun getWidgetId(widget: WidgetModel): String? = widgetIdByWidget[widget] + + override fun registerWidget(widget: WidgetModel) { + if (getWidgetId(widget) != null) return + + val fullState = widget.getFullState() + val wireMessage = getWireMessage(fullState) + + val comm = + commManager.openComm( + widgetTarget, + Json + .encodeToJsonElement( + WidgetOpenMessage( + wireMessage.state, + wireMessage.bufferPaths, + ), + ).jsonObject, + widgetOpenMetadataJson, + wireMessage.buffers, + ) + + initializeWidget(comm, widget) + } + + override fun renderWidget(widget: WidgetModel): DisplayResult = + MimeTypedResultEx( + buildJsonObject { + val modelId = getWidgetId(widget) ?: error("Widget is not registered") + var versionMajor = DEFAULT_MAJOR_VERSION + var versionMinor = DEFAULT_MINOR_VERSION + var modelName: String? = null + if (widget is DefaultWidgetModel) { + modelName = widget.modelName + val version = widget.modelModuleVersion + val matchResult = versionConstraintRegex.find(version) + if (matchResult != null) { + versionMajor = matchResult.groupValues[1].toInt() + versionMinor = matchResult.groupValues[2].toInt() + } + } + if (modelName != null) { + put(MimeTypes.HTML, "$modelName(id=$modelId)") + } + put( + "application/vnd.jupyter.widget-view+json", + buildJsonObject { + put("version_major", versionMajor) + put("version_minor", versionMinor) + put("model_id", modelId) + }, + ) + }, + null, + ) + + private fun initializeWidget( + comm: Comm, + widget: WidgetModel, + ) { + val modelId = comm.id + widgetIdByWidget[widget] = modelId + widgets[modelId] = widget + + // Reflect kernel-side changes on the frontend + widget.addChangeListener { patch -> + val wireMessage = getWireMessage(patch) + val data = + Json + .encodeToJsonElement( + WidgetUpdateMessage( + wireMessage.state, + wireMessage.bufferPaths, + ), + ).jsonObject + comm.send(data, null, wireMessage.buffers) + } + + // Reflect frontend-side changes on kernel + comm.onMessage { msg, _, buffers -> + when (val message = Json.decodeFromJsonElement(msg)) { + is WidgetStateMessage -> { + widget.applyPatch(message.toPatch(buffers)) + } + + is RequestStateMessage -> { + val fullState = widget.getFullState() + val wireMessage = getWireMessage(fullState) + val data = + Json + .encodeToJsonElement( + WidgetUpdateMessage( + wireMessage.state, + wireMessage.bufferPaths, + ), + ).jsonObject + comm.send(data, null, wireMessage.buffers) + } + + is CustomMessage -> {} + + else -> {} + } + } + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/HtmlWidget.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/HtmlWidget.kt new file mode 100644 index 0000000..45cd67a --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/HtmlWidget.kt @@ -0,0 +1,20 @@ +package org.jetbrains.kotlinx.jupyter.widget.library + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.DefaultWidgetFactory +import org.jetbrains.kotlinx.jupyter.widget.model.DefaultWidgetModel +import org.jetbrains.kotlinx.jupyter.widget.model.controlsSpec +import org.jetbrains.kotlinx.jupyter.widget.model.createAndRegisterWidget + +public fun WidgetManager.html(): HtmlWidget = createAndRegisterWidget(HtmlWidget.Factory) + +private val spec = controlsSpec("HTML") + +public class HtmlWidget internal constructor( + widgetManager: WidgetManager, +) : DefaultWidgetModel(spec, widgetManager) { + internal object Factory : DefaultWidgetFactory(spec, ::HtmlWidget) + + public var value: String by stringProp("value") + public var layout: LayoutWidget? by widgetProp("layout") +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/IntSliderWidget.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/IntSliderWidget.kt new file mode 100644 index 0000000..3baf9eb --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/IntSliderWidget.kt @@ -0,0 +1,24 @@ +package org.jetbrains.kotlinx.jupyter.widget.library + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.DefaultWidgetFactory +import org.jetbrains.kotlinx.jupyter.widget.model.DefaultWidgetModel +import org.jetbrains.kotlinx.jupyter.widget.model.controlsSpec +import org.jetbrains.kotlinx.jupyter.widget.model.createAndRegisterWidget + +public fun WidgetManager.intSlider(): IntSliderWidget = createAndRegisterWidget(IntSliderWidget.Factory) + +private val spec = controlsSpec("IntSlider") + +public class IntSliderWidget internal constructor( + widgetManager: WidgetManager, +) : DefaultWidgetModel(spec, widgetManager) { + internal object Factory : DefaultWidgetFactory(spec, ::IntSliderWidget) + + public var value: Int by intProp("value", 0) + public var min: Int by intProp("min", 0) + public var max: Int by intProp("max", 100) + public var step: Int by intProp("step", 1) + public var description: String by stringProp("description", "") + public var layout: LayoutWidget? by widgetProp("layout") +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/LabelWidget.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/LabelWidget.kt new file mode 100644 index 0000000..68172b8 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/LabelWidget.kt @@ -0,0 +1,19 @@ +package org.jetbrains.kotlinx.jupyter.widget.library + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.DefaultWidgetFactory +import org.jetbrains.kotlinx.jupyter.widget.model.DefaultWidgetModel +import org.jetbrains.kotlinx.jupyter.widget.model.controlsSpec +import org.jetbrains.kotlinx.jupyter.widget.model.createAndRegisterWidget + +public fun WidgetManager.label(): LabelWidget = createAndRegisterWidget(LabelWidget.Factory) + +private val spec = controlsSpec("Label") + +public class LabelWidget internal constructor( + widgetManager: WidgetManager, +) : DefaultWidgetModel(spec, widgetManager) { + internal object Factory : DefaultWidgetFactory(spec, ::LabelWidget) + + public var value: String by stringProp("value", "") +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/LayoutWidget.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/LayoutWidget.kt new file mode 100644 index 0000000..ada61e7 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/LayoutWidget.kt @@ -0,0 +1,25 @@ +package org.jetbrains.kotlinx.jupyter.widget.library + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.DefaultWidgetFactory +import org.jetbrains.kotlinx.jupyter.widget.model.DefaultWidgetModel +import org.jetbrains.kotlinx.jupyter.widget.model.baseSpec +import org.jetbrains.kotlinx.jupyter.widget.model.createAndRegisterWidget + +public fun WidgetManager.layout(): LayoutWidget = createAndRegisterWidget(LayoutWidget.Factory) + +private val spec = baseSpec("Layout") + +public class LayoutWidget private constructor( + widgetManager: WidgetManager, +) : DefaultWidgetModel(spec, widgetManager) { + internal object Factory : DefaultWidgetFactory(spec, ::LayoutWidget) + + public var layout: String by stringProp("layout", "") + + public var width: String by stringProp("width", "") + public var height: String by stringProp("height", "") + public var display: String by stringProp("display", "") + public var margin: String by stringProp("margin", "") + public var padding: String by stringProp("padding", "") +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/OutputWidget.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/OutputWidget.kt new file mode 100644 index 0000000..76cb5a7 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/OutputWidget.kt @@ -0,0 +1,19 @@ +package org.jetbrains.kotlinx.jupyter.widget.library + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.DefaultWidgetFactory +import org.jetbrains.kotlinx.jupyter.widget.model.DefaultWidgetModel +import org.jetbrains.kotlinx.jupyter.widget.model.createAndRegisterWidget +import org.jetbrains.kotlinx.jupyter.widget.model.outputSpec + +public fun WidgetManager.output(): OutputWidget = createAndRegisterWidget(OutputWidget.Factory) + +private val spec = outputSpec("Output") + +public class OutputWidget internal constructor( + widgetManager: WidgetManager, +) : DefaultWidgetModel(spec, widgetManager) { + internal object Factory : DefaultWidgetFactory(spec, ::OutputWidget) + + public var layout: LayoutWidget? by widgetProp("layout", widgetManager.layout()) +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/registry/DefaultWidgetFactories.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/registry/DefaultWidgetFactories.kt new file mode 100644 index 0000000..43917a2 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/library/registry/DefaultWidgetFactories.kt @@ -0,0 +1,17 @@ +package org.jetbrains.kotlinx.jupyter.widget.library.registry + +import org.jetbrains.kotlinx.jupyter.widget.library.HtmlWidget +import org.jetbrains.kotlinx.jupyter.widget.library.IntSliderWidget +import org.jetbrains.kotlinx.jupyter.widget.library.LabelWidget +import org.jetbrains.kotlinx.jupyter.widget.library.LayoutWidget +import org.jetbrains.kotlinx.jupyter.widget.library.OutputWidget +import org.jetbrains.kotlinx.jupyter.widget.model.WidgetFactory + +internal val defaultWidgetFactories = + listOf>( + HtmlWidget.Factory, + IntSliderWidget.Factory, + LabelWidget.Factory, + LayoutWidget.Factory, + OutputWidget.Factory, + ) diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/ModelCreation.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/ModelCreation.kt new file mode 100644 index 0000000..4318e10 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/ModelCreation.kt @@ -0,0 +1,36 @@ +package org.jetbrains.kotlinx.jupyter.widget.model + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager + +public fun WidgetManager.createAndRegisterWidget(widgetFactory: (widgetManager: WidgetManager) -> M): M = + widgetFactory(this).also { widget -> registerWidget(widget) } + +public fun WidgetManager.createAndRegisterWidget(factory: WidgetFactory): M = createAndRegisterWidget(factory::create) + +public interface WidgetFactory { + public val spec: WidgetSpec + + public fun create(widgetManager: WidgetManager): M +} + +public abstract class DefaultWidgetFactory( + override val spec: WidgetSpec, + private val factory: (widgetManager: WidgetManager) -> M, +) : WidgetFactory { + public constructor(spec: WidgetSpec, factory: () -> M) : + this(spec, { _ -> factory() }) + + override fun create(widgetManager: WidgetManager): M = factory(widgetManager) +} + +public open class DefaultWidgetModel( + spec: WidgetSpec, + widgetManager: WidgetManager, +) : WidgetModel(widgetManager) { + public val modelName: String by stringProp("_model_name", spec.modelName) + public val modelModule: String by stringProp("_model_module", spec.modelModule) + public val modelModuleVersion: String by stringProp("_model_module_version", spec.modelModuleVersion) + public val viewName: String by stringProp("_view_name", spec.viewName) + public val viewModule: String by stringProp("_view_module", spec.viewModule) + public val viewModuleVersion: String by stringProp("_view_module_version", spec.viewModuleVersion) +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/WidgetFactoryRegistry.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/WidgetFactoryRegistry.kt new file mode 100644 index 0000000..c416e25 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/WidgetFactoryRegistry.kt @@ -0,0 +1,29 @@ +package org.jetbrains.kotlinx.jupyter.widget.model + +import org.jetbrains.kotlinx.jupyter.widget.library.registry.defaultWidgetFactories +import java.util.ServiceLoader +import java.util.concurrent.ConcurrentHashMap + +public class WidgetFactoryRegistry { + private val factoryCache = ConcurrentHashMap>() + + init { + for (factory in defaultWidgetFactories) { + registerWidgetFactory(factory) + } + } + + internal fun loadWidgetFactory( + modelName: String, + classLoader: ClassLoader, + ): WidgetFactory<*> = + factoryCache.getOrPut(modelName) { + ServiceLoader + .load(WidgetFactory::class.java, classLoader) + .firstOrNull { it.spec.modelName == modelName } ?: error("No factory for model $modelName") + } + + public fun registerWidgetFactory(factory: WidgetFactory<*>) { + factoryCache[factory.spec.modelName] = factory + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/WidgetModel.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/WidgetModel.kt new file mode 100644 index 0000000..dac3af8 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/WidgetModel.kt @@ -0,0 +1,153 @@ +package org.jetbrains.kotlinx.jupyter.widget.model + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.types.WidgetModelPropertyType +import org.jetbrains.kotlinx.jupyter.widget.model.types.primitive.BooleanType +import org.jetbrains.kotlinx.jupyter.widget.model.types.primitive.BytesType +import org.jetbrains.kotlinx.jupyter.widget.model.types.primitive.FloatType +import org.jetbrains.kotlinx.jupyter.widget.model.types.primitive.IntType +import org.jetbrains.kotlinx.jupyter.widget.model.types.primitive.StringType +import org.jetbrains.kotlinx.jupyter.widget.model.types.widget.WidgetReferenceType +import org.jetbrains.kotlinx.jupyter.widget.protocol.Patch +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +public abstract class WidgetModel( + protected val widgetManager: WidgetManager, +) { + private val properties = mutableMapOf>() + private val changeListeners = mutableListOf<(Patch) -> Unit>() + + public fun getFullState(): Patch = properties.mapValues { (_, property) -> property.serializedValue } + + public fun applyPatch(patch: Patch) { + for ((key, value) in patch) { + val property = properties[key] ?: continue + property.applyPatch(value) + } + } + + public fun addChangeListener(listener: (Patch) -> Unit) { + changeListeners.add(listener) + } + + private fun addProperty(property: WidgetModelProperty<*>) { + properties[property.name] = property + property.addChangeListener { patch -> + notifyChange(mapOf(property.name to patch)) + } + } + + private fun notifyChange(patch: Patch) { + for (listener in changeListeners) { + listener(patch) + } + } + + protected fun prop( + name: String, + type: WidgetModelPropertyType, + initialValue: T, + ): ReadWriteProperty = WidgetKtPropertyDelegate(name, type, initialValue) + + protected fun stringProp( + name: String, + initialValue: String = "", + ): ReadWriteProperty = prop(name, StringType, initialValue) + + protected fun intProp( + name: String, + initialValue: Int = 0, + ): ReadWriteProperty = prop(name, IntType, initialValue) + + protected fun doubleProp( + name: String, + initialValue: Double = 0.0, + ): ReadWriteProperty = prop(name, FloatType, initialValue) + + protected fun boolProp( + name: String, + initialValue: Boolean = false, + ): ReadWriteProperty = prop(name, BooleanType, initialValue) + + protected fun bytesProp( + name: String, + initialValue: ByteArray = byteArrayOf(), + ): ReadWriteProperty = prop(name, BytesType, initialValue) + + protected fun widgetProp( + name: String, + initialValue: M? = null, + ): ReadWriteProperty = prop(name, WidgetReferenceType(), initialValue) + + protected inner class WidgetKtPropertyDelegate( + private val property: WidgetModelProperty, + ) : ReadWriteProperty { + internal constructor(name: String, type: WidgetModelPropertyType, initialValue: T) : + this(WidgetModelPropertyImpl(name, type, initialValue, widgetManager)) + + init { + addProperty(property) + } + + override fun getValue( + thisRef: WidgetModel, + property: KProperty<*>, + ): T = this.property.value + + override fun setValue( + thisRef: WidgetModel, + property: KProperty<*>, + value: T, + ) { + this.property.value = value + } + } +} + +public interface WidgetModelProperty { + public val name: String + public val type: WidgetModelPropertyType + public var value: T + + public val serializedValue: Any? + + public fun applyPatch(patch: Any?) + + public fun addChangeListener(listener: (Any?) -> Unit) +} + +internal class WidgetModelPropertyImpl( + override val name: String, + override val type: WidgetModelPropertyType, + initialValue: T, + private val widgetManager: WidgetManager, +) : WidgetModelProperty { + private var _value: T = initialValue + private val listeners = mutableListOf<(Any?) -> Unit>() + + override var value: T + get() = _value + set(newValue) { + if (newValue == _value) return + _value = newValue + notifyListeners(newValue) + } + + override val serializedValue: Any? get() = type.serialize(value, widgetManager) + + override fun applyPatch(patch: Any?) { + value = type.deserialize(patch, widgetManager) + } + + override fun addChangeListener(listener: (Any?) -> Unit) { + listeners.add(listener) + } + + private fun notifyListeners(newValue: T) { + val serializedValue = type.serialize(newValue, widgetManager) + for (listener in listeners) { + listener(serializedValue) + } + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/WidgetSpec.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/WidgetSpec.kt new file mode 100644 index 0000000..37d7d8a --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/WidgetSpec.kt @@ -0,0 +1,53 @@ +package org.jetbrains.kotlinx.jupyter.widget.model + +import kotlinx.serialization.Serializable + +internal const val DEFAULT_MAJOR_VERSION = 2 +internal const val DEFAULT_MINOR_VERSION = 0 +internal const val DEFAULT_PATCH_VERSION = 0 + +private const val DEFAULT_VERSION_CONSTRAINT = "^$DEFAULT_MAJOR_VERSION.$DEFAULT_MINOR_VERSION.$DEFAULT_PATCH_VERSION" +internal val versionConstraintRegex = Regex("""\D*(\d+)\.(\d+)""") + +/** + * The set of six immutable properties that define a widget specification. + * They should be present in any widget and be distinct across all widgets. + */ +@Serializable +public data class WidgetSpec( + val modelName: String, + val modelModule: String, + val modelModuleVersion: String, + val viewName: String, + val viewModule: String, + val viewModuleVersion: String, +) + +public fun controlsSpec( + controlName: String, + versionConstraint: String = DEFAULT_VERSION_CONSTRAINT, +): WidgetSpec = iPyWidgetsSpec(controlName, "@jupyter-widgets/controls", versionConstraint) + +public fun baseSpec( + controlName: String, + versionConstraint: String = DEFAULT_VERSION_CONSTRAINT, +): WidgetSpec = iPyWidgetsSpec(controlName, "@jupyter-widgets/base", versionConstraint) + +public fun outputSpec( + controlName: String, + versionConstraint: String = DEFAULT_VERSION_CONSTRAINT, +): WidgetSpec = iPyWidgetsSpec(controlName, "@jupyter-widgets/output", versionConstraint) + +public fun iPyWidgetsSpec( + controlName: String, + moduleName: String, + versionConstraint: String = DEFAULT_VERSION_CONSTRAINT, +): WidgetSpec = + WidgetSpec( + modelName = controlName + "Model", + modelModule = moduleName, + modelModuleVersion = versionConstraint, + viewName = controlName + "View", + viewModule = moduleName, + viewModuleVersion = versionConstraint, + ) diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/AbstractWidgetModelPropertyType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/AbstractWidgetModelPropertyType.kt new file mode 100644 index 0000000..0531096 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/AbstractWidgetModelPropertyType.kt @@ -0,0 +1,7 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types + +public abstract class AbstractWidgetModelPropertyType( + override val name: String, +) : WidgetModelPropertyType { + override fun toString(): String = name +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/WidgetModelPropertyType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/WidgetModelPropertyType.kt new file mode 100644 index 0000000..5c28411 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/WidgetModelPropertyType.kt @@ -0,0 +1,18 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager + +public interface WidgetModelPropertyType { + public val name: String + public val default: T + + public fun serialize( + propertyValue: T, + widgetManager: WidgetManager, + ): Any? + + public fun deserialize( + patchValue: Any?, + widgetManager: WidgetManager, + ): T +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/compound/ArrayType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/compound/ArrayType.kt new file mode 100644 index 0000000..3411581 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/compound/ArrayType.kt @@ -0,0 +1,32 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.compound + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.types.AbstractWidgetModelPropertyType +import org.jetbrains.kotlinx.jupyter.widget.model.types.WidgetModelPropertyType + +public class ArrayType( + public val elementType: WidgetModelPropertyType, +) : AbstractWidgetModelPropertyType>("array<${elementType.name}>") { + override val default: List = emptyList() + + override fun serialize( + propertyValue: List, + widgetManager: WidgetManager, + ): List = + propertyValue.map { + elementType.serialize(it, widgetManager) + } + + @Suppress("UNCHECKED_CAST") + override fun deserialize( + patchValue: Any?, + widgetManager: WidgetManager, + ): List { + require(patchValue is List<*>) { + "Expected List for $name, got ${patchValue?.let { it::class.simpleName } ?: "null"}" + } + return patchValue.map { raw -> + elementType.deserialize(raw, widgetManager) + } + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/compound/NullableType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/compound/NullableType.kt new file mode 100644 index 0000000..c1890ca --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/compound/NullableType.kt @@ -0,0 +1,24 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.compound + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.types.AbstractWidgetModelPropertyType +import org.jetbrains.kotlinx.jupyter.widget.model.types.WidgetModelPropertyType + +public class NullableType( + private val inner: WidgetModelPropertyType, +) : AbstractWidgetModelPropertyType("${inner.name}?") { + override val default: T? = null + + override fun serialize( + propertyValue: T?, + widgetManager: WidgetManager, + ): Any? = + propertyValue?.let { + inner.serialize(it, widgetManager) + } + + override fun deserialize( + patchValue: Any?, + widgetManager: WidgetManager, + ): T? = if (patchValue == null) null else inner.deserialize(patchValue, widgetManager) +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/datetime/DateType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/datetime/DateType.kt new file mode 100644 index 0000000..aa0c8cd --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/datetime/DateType.kt @@ -0,0 +1,35 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.datetime + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.types.AbstractWidgetModelPropertyType +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.ResolverStyle + +public object DateType : AbstractWidgetModelPropertyType("date") { + override val default: LocalDate = LocalDate.EPOCH + + private val formatter: DateTimeFormatter = + DateTimeFormatter + .ofPattern("uuuu-MM-dd") + .withResolverStyle(ResolverStyle.STRICT) + + override fun serialize( + propertyValue: LocalDate, + widgetManager: WidgetManager, + ): Any? = propertyValue.format(formatter) + + override fun deserialize( + patchValue: Any?, + widgetManager: WidgetManager, + ): LocalDate { + require(patchValue is String) { + "Expected String for date, got ${patchValue?.let { it::class.simpleName } ?: "null"}" + } + return try { + LocalDate.parse(patchValue, formatter) + } catch (e: Exception) { + error("Invalid date format '$patchValue', expected uuuu-MM-dd: ${e.message}") + } + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/datetime/DatetimeType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/datetime/DatetimeType.kt new file mode 100644 index 0000000..e5ed0f2 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/datetime/DatetimeType.kt @@ -0,0 +1,38 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.datetime + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.types.AbstractWidgetModelPropertyType +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.ResolverStyle + +public object DatetimeType : AbstractWidgetModelPropertyType("datetime") { + override val default: Instant = Instant.EPOCH + + private val formatter: DateTimeFormatter = + DateTimeFormatter + .ofPattern("uuuu-MM-dd'T'HH:mm:ss'Z'") + .withResolverStyle(ResolverStyle.STRICT) + .withZone(ZoneOffset.UTC) + + override fun serialize( + propertyValue: Instant, + widgetManager: WidgetManager, + ): Any? = formatter.format(propertyValue) // respect 'Z' + + override fun deserialize( + patchValue: Any?, + widgetManager: WidgetManager, + ): Instant { + require(patchValue is String) { + "Expected String for datetime, got ${patchValue?.let { it::class.simpleName } ?: "null"}" + } + return try { + ZonedDateTime.parse(patchValue, formatter).toInstant() + } catch (e: Exception) { + error("Invalid datetime format '$patchValue', expected uuuu-MM-dd'T'HH:mm:ss'Z': ${e.message}") + } + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/datetime/TimeType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/datetime/TimeType.kt new file mode 100644 index 0000000..7748d9d --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/datetime/TimeType.kt @@ -0,0 +1,35 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.datetime + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.types.AbstractWidgetModelPropertyType +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.format.ResolverStyle + +public object TimeType : AbstractWidgetModelPropertyType("time") { + override val default: LocalTime = LocalTime.MIDNIGHT + + private val formatter: DateTimeFormatter = + DateTimeFormatter + .ofPattern("HH:mm:ss") + .withResolverStyle(ResolverStyle.STRICT) + + override fun serialize( + propertyValue: LocalTime, + widgetManager: WidgetManager, + ): Any? = propertyValue.format(formatter) + + override fun deserialize( + patchValue: Any?, + widgetManager: WidgetManager, + ): LocalTime { + require(patchValue is String) { + "Expected String for time, got ${patchValue?.let { it::class.simpleName } ?: "null"}" + } + return try { + LocalTime.parse(patchValue, formatter) + } catch (e: Exception) { + error("Invalid time format '$patchValue', expected HH:mm:ss: ${e.message}") + } + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/enums/WidgetEnum.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/enums/WidgetEnum.kt new file mode 100644 index 0000000..5fa10db --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/enums/WidgetEnum.kt @@ -0,0 +1,14 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.enums + +import kotlin.properties.ReadOnlyProperty + +public abstract class WidgetEnum> { + private val _entries = mutableListOf>() + public val entries: List> get() = _entries + + protected fun entry(name: String): ReadOnlyProperty, WidgetEnumEntry> { + val e = WidgetEnumEntry(name) + _entries.add(e) + return ReadOnlyProperty { _, _ -> e } + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/enums/WidgetEnumEntry.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/enums/WidgetEnumEntry.kt new file mode 100644 index 0000000..176d448 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/enums/WidgetEnumEntry.kt @@ -0,0 +1,5 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.enums + +public class WidgetEnumEntry>( + public val name: String, +) diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/enums/WidgetEnumType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/enums/WidgetEnumType.kt new file mode 100644 index 0000000..563fbdc --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/enums/WidgetEnumType.kt @@ -0,0 +1,26 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.enums + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.types.AbstractWidgetModelPropertyType + +public class WidgetEnumType>( + private val widgetEnum: E, + override val default: WidgetEnumEntry, +) : AbstractWidgetModelPropertyType>("enum") { + override fun serialize( + propertyValue: WidgetEnumEntry, + widgetManager: WidgetManager, + ): String = propertyValue.name + + override fun deserialize( + patchValue: Any?, + widgetManager: WidgetManager, + ): WidgetEnumEntry { + require(patchValue is String) { + "Expected String for enum, got ${patchValue?.let { it::class.simpleName } ?: "null"}" + } + return widgetEnum.entries.first { + it.name == patchValue + } + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/BooleanType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/BooleanType.kt new file mode 100644 index 0000000..a10fc20 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/BooleanType.kt @@ -0,0 +1,3 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.primitive + +public object BooleanType : PrimitiveWidgetModelPropertyType("boolean", false) diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/BytesType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/BytesType.kt new file mode 100644 index 0000000..b5ddbda --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/BytesType.kt @@ -0,0 +1,3 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.primitive + +public object BytesType : PrimitiveWidgetModelPropertyType("bytes", byteArrayOf()) diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/FloatType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/FloatType.kt new file mode 100644 index 0000000..507ccf4 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/FloatType.kt @@ -0,0 +1,3 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.primitive + +public object FloatType : PrimitiveWidgetModelPropertyType("float", 0.0) diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/IntType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/IntType.kt new file mode 100644 index 0000000..52de466 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/IntType.kt @@ -0,0 +1,3 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.primitive + +public object IntType : PrimitiveWidgetModelPropertyType("int", 0) diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/PrimitiveWidgetModelPropertyType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/PrimitiveWidgetModelPropertyType.kt new file mode 100644 index 0000000..a7919b4 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/PrimitiveWidgetModelPropertyType.kt @@ -0,0 +1,20 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.primitive + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.types.AbstractWidgetModelPropertyType + +public abstract class PrimitiveWidgetModelPropertyType( + name: String, + override val default: T, +) : AbstractWidgetModelPropertyType(name) { + override fun serialize( + propertyValue: T, + widgetManager: WidgetManager, + ): Any? = propertyValue + + @Suppress("UNCHECKED_CAST") + override fun deserialize( + patchValue: Any?, + widgetManager: WidgetManager, + ): T = patchValue as T +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/StringType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/StringType.kt new file mode 100644 index 0000000..b77897a --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/primitive/StringType.kt @@ -0,0 +1,3 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.primitive + +public object StringType : PrimitiveWidgetModelPropertyType("string", "") diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/widget/WidgetReferenceType.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/widget/WidgetReferenceType.kt new file mode 100644 index 0000000..355944f --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/model/types/widget/WidgetReferenceType.kt @@ -0,0 +1,39 @@ +package org.jetbrains.kotlinx.jupyter.widget.model.types.widget + +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.model.WidgetModel +import org.jetbrains.kotlinx.jupyter.widget.model.types.AbstractWidgetModelPropertyType + +private const val WIDGET_REF_PREFIX = "IPY_MODEL_" + +public class WidgetReferenceType : AbstractWidgetModelPropertyType("widget-ref") { + override val default: M? get() = null + + override fun serialize( + propertyValue: M?, + widgetManager: WidgetManager, + ): String? = + propertyValue?.let { + "$WIDGET_REF_PREFIX${widgetManager.getWidgetId(it)}" + } + + @Suppress("UNCHECKED_CAST") + override fun deserialize( + patchValue: Any?, + widgetManager: WidgetManager, + ): M? { + if (patchValue == null) return null + + require(patchValue is String) { + "Expected String for widget-ref, got ${patchValue::class.simpleName}" + } + require(patchValue.startsWith(WIDGET_REF_PREFIX)) { + "Invalid widget ref format: $patchValue" + } + val id = patchValue.removePrefix(WIDGET_REF_PREFIX) + val model = + widgetManager.getWidget(id) + ?: error("Widget with id=$id not found") + return model as M + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/BufferPathsSerializer.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/BufferPathsSerializer.kt new file mode 100644 index 0000000..d90da34 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/BufferPathsSerializer.kt @@ -0,0 +1,68 @@ +package org.jetbrains.kotlinx.jupyter.widget.protocol + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.listSerialDescriptor +import kotlinx.serialization.descriptors.serialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull + +internal object BufferPathsSerializer : KSerializer>> { + @OptIn(ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor = + listSerialDescriptor( + listSerialDescriptor( + serialDescriptor(), + ), + ) + + override fun serialize( + encoder: Encoder, + value: List>, + ) { + val jsonEncoder = + encoder as? JsonEncoder + ?: error("BufferPathsSerializer can only be used with JSON") + jsonEncoder.encodeJsonElement( + JsonArray( + value.map { path -> + JsonArray( + path.map { el -> + when (el) { + is String -> JsonPrimitive(el) + is Number -> JsonPrimitive(el) + else -> error("Unsupported buffer path element: $el (${el::class.simpleName})") + } + }, + ) + }, + ), + ) + } + + override fun deserialize(decoder: Decoder): List> { + val jsonDecoder = + decoder as? JsonDecoder + ?: error("BufferPathsSerializer can only be used with JSON") + val element = jsonDecoder.decodeJsonElement() + + require(element is JsonArray) { "Expected JSON array for buffer_paths, got: $element" } + + return element.map { pathEl -> + require(pathEl is JsonArray) { "Expected JSON array inside buffer_paths, got: $pathEl" } + pathEl.map { element -> + element as? JsonPrimitive ?: error("Expected JSON primitive inside buffer_paths, got: $element") + if (element.isString) return@map element.content + element.doubleOrNull ?: element.intOrNull ?: error("Unsupported buffer path element: $element") + } + } + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/Conversion.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/Conversion.kt new file mode 100644 index 0000000..eb9aaa5 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/Conversion.kt @@ -0,0 +1,3 @@ +package org.jetbrains.kotlinx.jupyter.widget.protocol + +internal fun WidgetStateMessage.toPatch(buffers: List): Patch = getPatch(WireMessage(state, bufferPaths, buffers)) diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/JsonSerialization.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/JsonSerialization.kt new file mode 100644 index 0000000..1fe6e47 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/JsonSerialization.kt @@ -0,0 +1,76 @@ +package org.jetbrains.kotlinx.jupyter.widget.protocol + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.doubleOrNull +import kotlin.collections.iterator + +internal fun serializeJsonMap(map: Patch): JsonElement = serialize(map) + +private fun serializeAny(obj: Any?): JsonElement = + when (obj) { + null -> JsonNull + is Map<*, *> -> serialize(obj) + is List<*> -> serialize(obj) + is String -> JsonPrimitive(obj) + is Boolean -> JsonPrimitive(obj) + is Number -> JsonPrimitive(obj) + is ByteArray -> JsonNull + else -> error("Don't know how to serialize object [$obj] of class ${obj::class}") + } + +private fun serialize(map: Map<*, *>): JsonObject = + buildJsonObject { + for ((key, value) in map) { + if (key !is String) error("Map key [$key] is of type ${key?.let { it::class }}. Don't know how to serialize it.") + put(key, serializeAny(value)) + } + } + +private fun serialize(list: List<*>): JsonArray = + buildJsonArray { + for (value in list) { + add(serializeAny(value)) + } + } + +internal fun deserializeJsonMap(json: JsonElement): MutableMap { + if (json !is JsonObject) error("Input json should be a key-value object, but it's $json") + return deserializeMap(json) +} + +private fun deserializeAny(json: JsonElement): Any? = + when (json) { + is JsonObject -> deserializeMap(json) + is JsonArray -> deserializeList(json) + is JsonPrimitive -> deserializePrimitive(json) + } + +private fun deserializePrimitive(json: JsonPrimitive): Any? = + when { + json is JsonNull -> null + json.isString -> json.content + else -> { + json.booleanOrNull ?: json.doubleOrNull ?: error("Unknown JSON primitive type: [$json]") + } + } + +private fun deserializeMap(json: JsonObject): MutableMap = + mutableMapOf().apply { + for ((key, value) in json) { + put(key, deserializeAny(value)) + } + } + +private fun deserializeList(jsonArray: JsonArray): MutableList = + mutableListOf().apply { + for (el in jsonArray) { + add(deserializeAny(el)) + } + } diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/PatchHydration.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/PatchHydration.kt new file mode 100644 index 0000000..2d29cdd --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/PatchHydration.kt @@ -0,0 +1,90 @@ +package org.jetbrains.kotlinx.jupyter.widget.protocol + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlin.collections.get +import kotlin.collections.iterator + +/** + * Patch with inserted byte buffers - we call it "hydrated patch" or just "patch" + * for the sake of simplicity + */ +public typealias Patch = Map + +internal data class WireMessage( + val state: JsonObject, + val bufferPaths: List>, + val buffers: List, +) + +internal fun getPatch(wireMessage: WireMessage): Patch { + val dehydratedMap = deserializeJsonMap(wireMessage.state) + for ((path, buf) in wireMessage.bufferPaths.zip(wireMessage.buffers)) { + var obj: Any? = dehydratedMap + for (key in path.dropLast(1)) obj = getAt(obj, key) + setAt(obj, path.last(), buf) + } + return dehydratedMap +} + +internal fun getWireMessage(patch: Patch): WireMessage { + val pathStack = mutableListOf() + val bufferPaths = mutableListOf>() + val buffers = mutableListOf() + + fun traverse(obj: Any?) { + when (obj) { + is Map<*, *> -> { + for ((key, value) in obj) { + pathStack.add(key as Any) + traverse(value) + pathStack.removeLast() + } + } + is List<*> -> { + for (i in obj.indices) { + pathStack.add(i) + traverse(obj[i]) + pathStack.removeLast() + } + } + is ByteArray -> { + bufferPaths.add(pathStack.toList()) + buffers.add(obj) + } + } + } + + traverse(patch) + val state = serializeJsonMap(patch).jsonObject + return WireMessage(state, bufferPaths, buffers) +} + +private fun getAt( + obj: Any?, + key: Any, +): Any? = + when (obj) { + is Map<*, *> -> obj[key as String] + is List<*> -> obj[key as Int] + else -> error("Unexpected object type: $obj") + } + +private fun setAt( + obj: Any?, + key: Any?, + value: Any?, +) { + when (obj) { + is MutableMap<*, *> -> { + @Suppress("UNCHECKED_CAST") + (obj as MutableMap)[key] = value + } + + is MutableList<*> -> { + @Suppress("UNCHECKED_CAST") + (obj as MutableList)[key as Int] = value + } + else -> error("Unexpected object type: $obj") + } +} diff --git a/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/WidgetMessage.kt b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/WidgetMessage.kt new file mode 100644 index 0000000..34303d0 --- /dev/null +++ b/integrations/widgets/widgets-api/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/protocol/WidgetMessage.kt @@ -0,0 +1,72 @@ +package org.jetbrains.kotlinx.jupyter.widget.protocol + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonObject + +@OptIn(ExperimentalSerializationApi::class) +@JsonClassDiscriminator("method") +@Serializable +internal sealed interface WidgetMessage + +internal interface WithBufferPaths { + val bufferPaths: List> +} + +internal interface WidgetStateMessage : WithBufferPaths { + val state: JsonObject +} + +@Serializable +internal class WidgetOpenMessage( + override val state: JsonObject, + @SerialName("buffer_paths") + @Serializable(with = BufferPathsSerializer::class) + override val bufferPaths: List>, +) : WidgetStateMessage + +@Serializable +@SerialName("update") +internal class WidgetUpdateMessage( + override val state: JsonObject, + @SerialName("buffer_paths") + @Serializable(with = BufferPathsSerializer::class) + override val bufferPaths: List>, +) : WidgetMessage, + WidgetStateMessage + +@Serializable +@SerialName("echo_update") +internal class WidgetEchoUpdateMessage( + override val state: JsonObject, + @SerialName("buffer_paths") + @Serializable(with = BufferPathsSerializer::class) + override val bufferPaths: List>, +) : WidgetMessage, + WidgetStateMessage + +@Serializable +@SerialName("request_state") +internal class RequestStateMessage : WidgetMessage + +@Serializable +@SerialName("custom") +internal class CustomMessage( + val content: JsonObject, +) : WidgetMessage + +@Serializable +@SerialName("request_states") +internal class RequestStatesMessage : WidgetMessage + +@Serializable +@SerialName("update_states") +internal class UpdateStatesMessage( + val states: JsonObject, + @SerialName("buffer_paths") + @Serializable(with = BufferPathsSerializer::class) + override val bufferPaths: List>, +) : WidgetMessage, + WithBufferPaths diff --git a/integrations/widgets/widgets-jupyter/build.gradle.kts b/integrations/widgets/widgets-jupyter/build.gradle.kts new file mode 100644 index 0000000..46de369 --- /dev/null +++ b/integrations/widgets/widgets-jupyter/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.publisher) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.jupyter.api) +} + +dependencies { + implementation(projects.integrations.widgets.widgetsApi) +} + +kotlin { + jvmToolchain( + libs.versions.jvm.toolchain + .get() + .toInt(), + ) + explicitApi() +} + +tasks.processJupyterApiResources { + libraryProducers = listOf("org.jetbrains.kotlinx.jupyter.widget.integration.WidgetJupyterIntegration") +} + +kotlinPublications { + publication { + description.set("Kotlin Jupyter kernel integration for IPython Widgets") + } +} diff --git a/integrations/widgets/widgets-jupyter/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/integration/JupyterWidgetLibrary.kt b/integrations/widgets/widgets-jupyter/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/integration/JupyterWidgetLibrary.kt new file mode 100644 index 0000000..10d6620 --- /dev/null +++ b/integrations/widgets/widgets-jupyter/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/integration/JupyterWidgetLibrary.kt @@ -0,0 +1,22 @@ +package org.jetbrains.kotlinx.jupyter.widget.integration + +import org.jetbrains.kotlinx.jupyter.widget.library.HtmlWidget +import org.jetbrains.kotlinx.jupyter.widget.library.IntSliderWidget +import org.jetbrains.kotlinx.jupyter.widget.library.LabelWidget +import org.jetbrains.kotlinx.jupyter.widget.library.LayoutWidget +import org.jetbrains.kotlinx.jupyter.widget.library.OutputWidget +import org.jetbrains.kotlinx.jupyter.widget.library.html +import org.jetbrains.kotlinx.jupyter.widget.library.intSlider +import org.jetbrains.kotlinx.jupyter.widget.library.label +import org.jetbrains.kotlinx.jupyter.widget.library.layout +import org.jetbrains.kotlinx.jupyter.widget.library.output + +public fun htmlWidget(): HtmlWidget = globalWidgetManager.html() + +public fun intSliderWidget(): IntSliderWidget = globalWidgetManager.intSlider() + +public fun labelWidget(): LabelWidget = globalWidgetManager.label() + +public fun layoutWidget(): LayoutWidget = globalWidgetManager.layout() + +public fun outputWidget(): OutputWidget = globalWidgetManager.output() diff --git a/integrations/widgets/widgets-jupyter/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/integration/WidgetJupyterIntegration.kt b/integrations/widgets/widgets-jupyter/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/integration/WidgetJupyterIntegration.kt new file mode 100644 index 0000000..224803a --- /dev/null +++ b/integrations/widgets/widgets-jupyter/src/main/kotlin/org/jetbrains/kotlinx/jupyter/widget/integration/WidgetJupyterIntegration.kt @@ -0,0 +1,34 @@ +package org.jetbrains.kotlinx.jupyter.widget.integration + +import org.jetbrains.kotlinx.jupyter.api.declare +import org.jetbrains.kotlinx.jupyter.api.libraries.JupyterIntegration +import org.jetbrains.kotlinx.jupyter.widget.WidgetManager +import org.jetbrains.kotlinx.jupyter.widget.WidgetManagerImpl +import org.jetbrains.kotlinx.jupyter.widget.model.WidgetModel + +private var myWidgetManager: WidgetManager? = null +internal val globalWidgetManager: WidgetManager get() = myWidgetManager!! + +public class WidgetJupyterIntegration : JupyterIntegration() { + override fun Builder.onLoaded() { + importPackage() + + var myLastClassLoader = WidgetJupyterIntegration::class.java.classLoader + val widgetManager = WidgetManagerImpl(notebook.commManager) { myLastClassLoader } + myWidgetManager = widgetManager + + onLoaded { + myLastClassLoader = this.lastClassLoader + + declare("widgetManager" to widgetManager) + } + + afterCellExecution { _, _ -> + myLastClassLoader = this.lastClassLoader + } + + renderWithHost { _, widget -> + widgetManager.renderWidget(widget) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c65133b..d53ac93 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,10 @@ projectStructure { project("database-integration-tests") } project("intellij-platform") + folder("widgets") { + project("widgets-api") + project("widgets-jupyter") + } } }