diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionClient.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionClient.kt index 76d8aa80..9d6a5561 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionClient.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionClient.kt @@ -2,10 +2,8 @@ package space.kscience.visionforge import kotlinx.coroutines.launch import space.kscience.dataforge.context.Plugin -import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.MetaRepr import space.kscience.dataforge.names.Name -import space.kscience.dataforge.names.parseAsName /** * A feedback client that communicates with a server and provides ability to propagate events and changes back to the model @@ -15,25 +13,25 @@ public interface VisionClient: Plugin { public suspend fun sendEvent(targetName: Name, event: VisionEvent) - public fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?) +// public fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?) } -public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Meta?) { - notifyPropertyChanged(visionName, propertyName.parseAsName(true), item) -} - -public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Number) { - notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item)) -} - -public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: String) { - notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item)) -} - -public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Boolean) { - notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item)) -} +//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Meta?) { +// notifyPropertyChanged(visionName, propertyName.parseAsName(true), item) +//} +// +//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Number) { +// notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item)) +//} +// +//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: String) { +// notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item)) +//} +// +//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Boolean) { +// notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item)) +//} public fun VisionClient.sendEvent(targetName: Name, payload: MetaRepr): Unit { context.launch { diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionManager.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionManager.kt index e4ca1cdb..8799a73f 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionManager.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionManager.kt @@ -13,10 +13,7 @@ import space.kscience.dataforge.meta.descriptors.MetaDescriptor import space.kscience.dataforge.meta.toJson import space.kscience.dataforge.meta.toMeta import space.kscience.dataforge.names.Name -import space.kscience.visionforge.html.VisionOfCheckbox -import space.kscience.visionforge.html.VisionOfHtmlForm -import space.kscience.visionforge.html.VisionOfNumberField -import space.kscience.visionforge.html.VisionOfTextField +import space.kscience.visionforge.html.* public class VisionManager(meta: Meta) : AbstractPlugin(meta), MutableVisionContainer { override val tag: PluginTag get() = Companion.tag @@ -72,9 +69,11 @@ public class VisionManager(meta: Meta) : AbstractPlugin(meta), MutableVisionCont defaultDeserializer { SimpleVisionGroup.serializer() } subclass(NullVision.serializer()) subclass(SimpleVisionGroup.serializer()) + subclass(VisionOfHtmlInput.serializer()) subclass(VisionOfNumberField.serializer()) subclass(VisionOfTextField.serializer()) subclass(VisionOfCheckbox.serializer()) + subclass(VisionOfRangeField.serializer()) subclass(VisionOfHtmlForm.serializer()) } } diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionOfHtml.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionOfHtml.kt index d9f09ec1..51458047 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionOfHtml.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/html/VisionOfHtml.kt @@ -1,6 +1,8 @@ package space.kscience.visionforge.html import kotlinx.html.InputType +import kotlinx.html.TagConsumer +import kotlinx.html.stream.createHTML import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import space.kscience.dataforge.meta.* @@ -9,16 +11,45 @@ import space.kscience.visionforge.AbstractVision @Serializable -public abstract class VisionOfHtml: AbstractVision(){ +public abstract class VisionOfHtml : AbstractVision() { public var classes: List by properties.stringList(*emptyArray()) } +@Serializable +@SerialName("html.plain") +public class VisionOfPlainHtml : VisionOfHtml() { + public var content: String? by properties.string() +} + +public inline fun VisionOfPlainHtml.content(block: TagConsumer<*>.() -> Unit) { + content = createHTML().apply(block).finalize() +} + +@Serializable +public enum class InputFeedbackMode{ + /** + * Fire feedback event on `onchange` event + */ + ONCHANGE, + + /** + * Fire feedback event on `oninput` event + */ + ONINPUT, + + /** + * provide only manual feedback + */ + NONE +} + @Serializable @SerialName("html.input") public open class VisionOfHtmlInput( public val inputType: String, + public val feedbackMode: InputFeedbackMode = InputFeedbackMode.ONCHANGE ) : VisionOfHtml() { - public var value : Value? by properties.value() + public var value: Value? by properties.value() public var disabled: Boolean by properties.boolean { false } public var fieldName: String? by properties.string() } diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/useProperty.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/useProperty.kt index f6f95a6f..cee3a002 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/useProperty.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/useProperty.kt @@ -39,9 +39,22 @@ public fun Vision.useProperty( callback: (Meta) -> Unit, ): Job = useProperty(propertyName.parseAsName(), inherit, includeStyles, scope, callback) +/** + * Observe changes to the specific property without passing the initial value. + */ +public fun V.onPropertyChange( + property: KProperty1, + scope: CoroutineScope = manager?.context ?: error("Orphan Vision can't observe properties"), + callback: suspend V.(T) -> Unit, +): Job = properties.changes.onEach { name -> + if (name.startsWith(property.name.asName())) { + callback(property.get(this)) + } +}.launchIn(scope) + public fun V.useProperty( property: KProperty1, - scope: CoroutineScope? = manager?.context, + scope: CoroutineScope = manager?.context ?: error("Orphan Vision can't observe properties"), callback: V.(T) -> Unit, ): Job { //Pass initial value. @@ -50,5 +63,5 @@ public fun V.useProperty( if (name.startsWith(property.name.asName())) { callback(property.get(this@useProperty)) } - }.launchIn(scope ?: error("Orphan Vision can't observe properties")) + }.launchIn(scope) } \ No newline at end of file diff --git a/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/ElementVisionRenderer.kt b/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/ElementVisionRenderer.kt index 75ec785a..8842e08e 100644 --- a/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/ElementVisionRenderer.kt +++ b/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/ElementVisionRenderer.kt @@ -26,7 +26,7 @@ public interface ElementVisionRenderer : Named { /** * Give a [vision] integer rating based on this renderer capabilities. [ZERO_RATING] or negative values means that this renderer * can't process a vision. The value of [DEFAULT_RATING] used for default renderer. Specialized renderers could specify - * higher value in order to "steal" rendering job + * higher value to "steal" rendering job */ public fun rateVision(vision: Vision): Int diff --git a/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/JsVisionClient.kt b/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/JsVisionClient.kt index d164e5ea..388ed2af 100644 --- a/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/JsVisionClient.kt +++ b/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/JsVisionClient.kt @@ -65,20 +65,20 @@ public class JsVisionClient : AbstractPlugin(), VisionClient { private fun Element.getFlag(attribute: String): Boolean = attributes[attribute]?.value != null - private val mutex = Mutex() +// private val mutex = Mutex() - private val changeCollector = VisionChangeBuilder() - /** - * Communicate vision property changed from rendering engine to model - */ - override fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?) { - context.launch { - mutex.withLock { - changeCollector.propertyChanged(visionName, propertyName, item) - } - } - } + +// /** +// * Communicate vision property changed from rendering engine to model +// */ +// private fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?) { +// context.launch { +// mutex.withLock { +// changeCollector.propertyChanged(visionName, propertyName, item) +// } +// } +// } private val eventCollector by lazy { MutableSharedFlow>(meta["feedback.eventCache"].int ?: 100) @@ -97,7 +97,7 @@ public class JsVisionClient : AbstractPlugin(), VisionClient { renderer.render(element, name, vision, outputMeta) } - private fun startVisionUpdate(element: Element, name: Name, vision: Vision?, outputMeta: Meta) { + private fun startVisionUpdate(element: Element, visionName: Name, vision: Vision, outputMeta: Meta) { element.attributes[OUTPUT_CONNECT_ATTRIBUTE]?.let { attr -> val wsUrl = if (attr.value.isBlank() || attr.value == VisionTagConsumer.AUTO_DATA_ATTRIBUTE) { val endpoint = resolveEndpoint(element) @@ -109,9 +109,10 @@ public class JsVisionClient : AbstractPlugin(), VisionClient { URL(attr.value) }.apply { protocol = "ws" - searchParams.append("name", name.toString()) + searchParams.append("name", visionName.toString()) } + logger.info { "Updating vision data from $wsUrl" } //Individual websocket for this vision @@ -125,14 +126,13 @@ public class JsVisionClient : AbstractPlugin(), VisionClient { ) // If change contains root vision replacement, do it - if(event is VisionChange) { + if (event is VisionChange) { event.vision?.let { vision -> - renderVision(element, name, vision, outputMeta) + renderVision(element, visionName, vision, outputMeta) } } - logger.debug { "Got $event for output with name $name" } - if (vision == null) error("Can't update vision because it is not loaded.") + logger.debug { "Got $event for output with name $visionName" } vision.receiveEvent(event) } else { logger.error { "WebSocket message data is not a string" } @@ -147,32 +147,44 @@ public class JsVisionClient : AbstractPlugin(), VisionClient { val feedbackAggregationTime = meta["feedback.aggregationTime"]?.int ?: 300 onopen = { + + + val mutex = Mutex() + + val changeCollector = VisionChangeBuilder() + feedbackJob = visionManager.context.launch { - eventCollector.filter { it.first == name }.onEach { + //launch a separate coroutine to send events to the backend + eventCollector.filter { it.first == visionName }.onEach { send(visionManager.jsonFormat.encodeToString(VisionEvent.serializer(), it.second)) }.launchIn(this) + //launch backward property propagation + vision.properties.changes.onEach { propertyName: Name -> + changeCollector.propertyChanged(visionName, propertyName, vision.properties.getMeta(propertyName)) + }.launchIn(this) + + //aggregate atomic changes while (isActive) { delay(feedbackAggregationTime.milliseconds) - val change = changeCollector[name] ?: continue - if (!change.isEmpty()) { + if (!changeCollector.isEmpty()) { mutex.withLock { - eventCollector.emit(name to change.deepCopy(visionManager)) - change.reset() + eventCollector.emit(visionName to changeCollector.deepCopy(visionManager)) + changeCollector.reset() } } } } - logger.info { "WebSocket feedback channel established for output '$name'" } + logger.info { "WebSocket feedback channel established for output '$visionName'" } } onclose = { feedbackJob?.cancel() - logger.info { "WebSocket feedback channel closed for output '$name'" } + logger.info { "WebSocket feedback channel closed for output '$visionName'" } } onerror = { feedbackJob?.cancel() - logger.error { "WebSocket feedback channel error for output '$name'" } + logger.error { "WebSocket feedback channel error for output '$visionName'" } } } } @@ -241,9 +253,9 @@ public class JsVisionClient : AbstractPlugin(), VisionClient { } //Try to load vision via websocket - element.attributes[OUTPUT_CONNECT_ATTRIBUTE] != null -> { - startVisionUpdate(element, name, null, outputMeta) - } +// element.attributes[OUTPUT_CONNECT_ATTRIBUTE] != null -> { +// startVisionUpdate(element, name, null, outputMeta) +// } else -> error("No embedded vision data / fetch url for $name") } @@ -252,9 +264,12 @@ public class JsVisionClient : AbstractPlugin(), VisionClient { override fun content(target: String): Map = if (target == ElementVisionRenderer.TYPE) { listOf( - numberVisionRenderer(), - textVisionRenderer(), - formVisionRenderer() + inputVisionRenderer, + checkboxVisionRenderer, + numberVisionRenderer, + textVisionRenderer, + rangeVisionRenderer, + formVisionRenderer ).associateByName() } else super.content(target) diff --git a/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/inputRenderers.kt b/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/inputRenderers.kt index 8b07e177..4e984cb0 100644 --- a/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/inputRenderers.kt +++ b/visionforge-core/src/jsMain/kotlin/space/kscience/visionforge/inputRenderers.kt @@ -3,10 +3,10 @@ package space.kscience.visionforge import kotlinx.browser.document import kotlinx.html.InputType import kotlinx.html.js.input -import kotlinx.html.js.onChangeFunction import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLFormElement import org.w3c.dom.HTMLInputElement +import org.w3c.dom.events.Event import org.w3c.dom.get import org.w3c.xhr.FormData import space.kscience.dataforge.context.debug @@ -14,7 +14,11 @@ import space.kscience.dataforge.context.logger import space.kscience.dataforge.meta.* import space.kscience.visionforge.html.* - +/** + * Subscribes the HTML element to a given vision. + * + * @param vision The vision to subscribe to. + */ private fun HTMLElement.subscribeToVision(vision: VisionOfHtml) { vision.useProperty(VisionOfHtml::classes) { classList.value = classes.joinToString(separator = " ") @@ -22,6 +26,11 @@ private fun HTMLElement.subscribeToVision(vision: VisionOfHtml) { } +/** + * Subscribes the HTML input element to a given vision. + * + * @param inputVision The input vision to subscribe to. + */ private fun HTMLInputElement.subscribeToInput(inputVision: VisionOfHtmlInput) { subscribeToVision(inputVision) inputVision.useProperty(VisionOfHtmlInput::disabled) { @@ -29,33 +38,123 @@ private fun HTMLInputElement.subscribeToInput(inputVision: VisionOfHtmlInput) { } } - -internal fun JsVisionClient.textVisionRenderer(): ElementVisionRenderer = - ElementVisionRenderer { visionName, vision, _ -> +internal val inputVisionRenderer: ElementVisionRenderer = + ElementVisionRenderer(acceptRating = ElementVisionRenderer.DEFAULT_RATING - 1) { _, vision, _ -> input { type = InputType.text - onChangeFunction = { - notifyPropertyChanged(visionName, VisionOfTextField::text.name, value) - } }.apply { + val onEvent: (Event) -> Unit = { + vision.value = value.asValue() + } + + + when (vision.feedbackMode) { + InputFeedbackMode.ONCHANGE -> onchange = onEvent + + InputFeedbackMode.ONINPUT -> oninput = onEvent + InputFeedbackMode.NONE -> {} + } + subscribeToInput(vision) - vision.useProperty(VisionOfTextField::text) { - value = (it ?: "").asValue() + vision.useProperty(VisionOfHtmlInput::value) { + this@apply.value = it?.string ?: "" } } } -internal fun JsVisionClient.numberVisionRenderer(): ElementVisionRenderer = - ElementVisionRenderer { visionName, vision, _ -> +internal val checkboxVisionRenderer: ElementVisionRenderer = + ElementVisionRenderer { _, vision, _ -> + input { + type = InputType.checkBox + }.apply { + val onEvent: (Event) -> Unit = { + vision.checked = checked + } + + + when (vision.feedbackMode) { + InputFeedbackMode.ONCHANGE -> onchange = onEvent + + InputFeedbackMode.ONINPUT -> oninput = onEvent + InputFeedbackMode.NONE -> {} + } + + subscribeToInput(vision) + vision.useProperty(VisionOfCheckbox::checked) { + this@apply.checked = it ?: false + } + } + } + +internal val textVisionRenderer: ElementVisionRenderer = + ElementVisionRenderer { _, vision, _ -> input { type = InputType.text - onChangeFunction = { - notifyPropertyChanged(visionName, VisionOfNumberField::value.name, value) - } }.apply { + val onEvent: (Event) -> Unit = { + vision.text = value + } + + + when (vision.feedbackMode) { + InputFeedbackMode.ONCHANGE -> onchange = onEvent + + InputFeedbackMode.ONINPUT -> oninput = onEvent + InputFeedbackMode.NONE -> {} + } + + subscribeToInput(vision) + vision.useProperty(VisionOfTextField::text) { + this@apply.value = it ?: "" + } + } + } + +internal val numberVisionRenderer: ElementVisionRenderer = + ElementVisionRenderer { _, vision, _ -> + input { + type = InputType.text + }.apply { + + val onEvent: (Event) -> Unit = { + value.toDoubleOrNull()?.let { vision.number = it } + } + + when (vision.feedbackMode) { + InputFeedbackMode.ONCHANGE -> onchange = onEvent + + InputFeedbackMode.ONINPUT -> oninput = onEvent + InputFeedbackMode.NONE -> {} + } subscribeToInput(vision) vision.useProperty(VisionOfNumberField::value) { - value = (it?.double ?: 0.0).asValue() + this@apply.valueAsNumber = it?.double ?: 0.0 + } + } + } + +internal val rangeVisionRenderer: ElementVisionRenderer = + ElementVisionRenderer { _, vision, _ -> + input { + type = InputType.text + min = vision.min.toString() + max = vision.max.toString() + step = vision.step.toString() + }.apply { + + val onEvent: (Event) -> Unit = { + value.toDoubleOrNull()?.let { vision.number = it } + } + + when (vision.feedbackMode) { + InputFeedbackMode.ONCHANGE -> onchange = onEvent + + InputFeedbackMode.ONINPUT -> oninput = onEvent + InputFeedbackMode.NONE -> {} + } + subscribeToInput(vision) + vision.useProperty(VisionOfRangeField::value) { + this@apply.valueAsNumber = it?.double ?: 0.0 } } } @@ -83,7 +182,7 @@ internal fun FormData.toMeta(): Meta { return DynamicMeta(`object`) } -internal fun JsVisionClient.formVisionRenderer(): ElementVisionRenderer = +internal val formVisionRenderer: ElementVisionRenderer = ElementVisionRenderer { visionName, vision, _ -> val form = document.getElementById(vision.formId) as? HTMLFormElement @@ -91,10 +190,10 @@ internal fun JsVisionClient.formVisionRenderer(): ElementVisionRenderer = form.subscribeToVision(vision) - logger.debug { "Adding hooks to form with id = '$vision.formId'" } + vision.manager?.logger?.debug { "Adding hooks to form with id = '$vision.formId'" } vision.useProperty(VisionOfHtmlForm::values) { values -> - logger.debug { "Updating form '${vision.formId}' with values $values" } + vision.manager?.logger?.debug { "Updating form '${vision.formId}' with values $values" } val inputs = form.getElementsByTagName("input") values?.valueSequence()?.forEach { (token, value) -> (inputs[token.toString()] as? HTMLInputElement)?.value = value.toString() @@ -104,7 +203,7 @@ internal fun JsVisionClient.formVisionRenderer(): ElementVisionRenderer = form.onsubmit = { event -> event.preventDefault() val formData = FormData(form).toMeta() - notifyPropertyChanged(visionName, VisionOfHtmlForm::values.name, formData) + vision.values = formData console.info("Sent: ${formData.toMap()}") false }