Implement and test input elements

This commit is contained in:
Alexander Nozik 2023-12-10 17:06:58 +03:00
parent 05b87857f4
commit f40bed7bb9
10 changed files with 278 additions and 136 deletions

View File

@ -0,0 +1,59 @@
package space.kscience.visionforge.examples
import kotlinx.html.p
import space.kscience.visionforge.VisionControlEvent
import space.kscience.visionforge.html.*
import space.kscience.visionforge.onClick
fun main() = serve {
val events = ArrayDeque<VisionControlEvent>(10)
val html = VisionOfPlainHtml()
fun pushEvent(event: VisionControlEvent) {
events.addFirst(event)
if (events.size >= 10) {
events.removeLast()
}
html.content {
events.forEach { event ->
p {
text(event.toString())
}
}
}
}
vision {
htmlCheckBox {
checked = true
onValueChange(context) {
pushEvent(this)
}
}
}
vision {
htmlRangeField(1, 10) {
numberValue = 4
onValueChange(context) {
pushEvent(this)
}
}
}
vision {
button("Click me"){
onClick(context){
pushEvent(this)
}
}
}
vision(html)
}

View File

@ -8,14 +8,13 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.*
import space.kscience.dataforge.meta.MetaRepr import space.kscience.dataforge.names.Name
import space.kscience.dataforge.meta.MutableMeta import space.kscience.dataforge.names.parseAsName
import space.kscience.dataforge.meta.Value
@Serializable @Serializable
@SerialName("control") @SerialName("control")
public abstract class VisionControlEvent : VisionEvent, MetaRepr { public sealed class VisionControlEvent : VisionEvent, MetaRepr {
public abstract val meta: Meta public abstract val meta: Meta
override fun toMeta(): Meta = meta override fun toMeta(): Meta = meta
@ -24,30 +23,41 @@ public abstract class VisionControlEvent : VisionEvent, MetaRepr {
public interface ControlVision : Vision { public interface ControlVision : Vision {
public val controlEventFlow: SharedFlow<VisionControlEvent> public val controlEventFlow: SharedFlow<VisionControlEvent>
public fun dispatchControlEvent(event: VisionControlEvent) public suspend fun dispatchControlEvent(event: VisionControlEvent)
override fun receiveEvent(event: VisionEvent) { override suspend fun receiveEvent(event: VisionEvent) {
if (event is VisionControlEvent) { if (event is VisionControlEvent) {
dispatchControlEvent(event) dispatchControlEvent(event)
} else super.receiveEvent(event) } else super.receiveEvent(event)
} }
} }
/** /**
* @param payload The optional payload associated with the click event. * @param payload The optional payload associated with the click event.
*/ */
@Serializable @Serializable
@SerialName("control.click") @SerialName("control.click")
public class VisionClickEvent(public val payload: Meta = Meta.EMPTY) : VisionControlEvent() { public class VisionClickEvent(override val meta: Meta) : VisionControlEvent() {
override val meta: Meta get() = Meta { ::payload.name put payload } public val payload: Meta? by meta.node()
public val name: Name? get() = meta["name"].string?.parseAsName()
override fun toString(): String = meta.toString()
} }
public fun VisionClickEvent(payload: Meta = Meta.EMPTY, name: Name? = null): VisionClickEvent = VisionClickEvent(
Meta {
VisionClickEvent::payload.name put payload
VisionClickEvent::name.name put name.toString()
}
)
public interface ClickControl : ControlVision { public interface ClickControl : ControlVision {
/** /**
* Create and dispatch a click event * Create and dispatch a click event
*/ */
public fun click(builder: MutableMeta.() -> Unit = {}) { public suspend fun click(builder: MutableMeta.() -> Unit = {}) {
dispatchControlEvent(VisionClickEvent(Meta(builder))) dispatchControlEvent(VisionClickEvent(Meta(builder)))
} }
} }
@ -64,7 +74,18 @@ public fun ClickControl.onClick(scope: CoroutineScope, block: suspend VisionClic
public class VisionValueChangeEvent(override val meta: Meta) : VisionControlEvent() { public class VisionValueChangeEvent(override val meta: Meta) : VisionControlEvent() {
public val value: Value? get() = meta.value public val value: Value? get() = meta.value
/**
* The name of a control that fired the event
*/
public val name: Name? get() = meta["name"]?.string?.parseAsName()
override fun toString(): String = meta.toString()
} }
public fun VisionValueChangeEvent(value: Value?): VisionValueChangeEvent = public fun VisionValueChangeEvent(value: Value?, name: Name? = null): VisionValueChangeEvent = VisionValueChangeEvent(
VisionValueChangeEvent(Meta { this.value = value }) Meta {
this.value = value
name?.let { set("name", it.toString()) }
}
)

View File

@ -50,7 +50,7 @@ public interface Vision : Described {
/** /**
* Receive and process a generic [VisionEvent]. * Receive and process a generic [VisionEvent].
*/ */
public fun receiveEvent(event: VisionEvent) { public suspend fun receiveEvent(event: VisionEvent) {
if(event is VisionChange) update(event) if(event is VisionChange) update(event)
else manager?.logger?.warn { "Undispatched event: $event" } else manager?.logger?.warn { "Undispatched event: $event" }
} }

View File

@ -1,9 +1,7 @@
package space.kscience.visionforge package space.kscience.visionforge
import kotlinx.coroutines.launch
import space.kscience.dataforge.context.Plugin import space.kscience.dataforge.context.Plugin
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaRepr
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.parseAsName import space.kscience.dataforge.names.parseAsName
@ -33,9 +31,3 @@ public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: St
public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Boolean) { public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Boolean) {
notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item)) notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item))
} }
public fun VisionClient.sendMetaEvent(targetName: Name, payload: MetaRepr): Unit {
context.launch {
sendEvent(targetName, VisionMetaEvent(payload.toMeta()))
}
}

View File

@ -69,12 +69,14 @@ public class VisionManager(meta: Meta) : AbstractPlugin(meta), MutableVisionCont
defaultDeserializer { SimpleVisionGroup.serializer() } defaultDeserializer { SimpleVisionGroup.serializer() }
subclass(NullVision.serializer()) subclass(NullVision.serializer())
subclass(SimpleVisionGroup.serializer()) subclass(SimpleVisionGroup.serializer())
subclass(VisionOfPlainHtml.serializer())
subclass(VisionOfHtmlInput.serializer()) subclass(VisionOfHtmlInput.serializer())
subclass(VisionOfNumberField.serializer()) subclass(VisionOfNumberField.serializer())
subclass(VisionOfTextField.serializer()) subclass(VisionOfTextField.serializer())
subclass(VisionOfCheckbox.serializer()) subclass(VisionOfCheckbox.serializer())
subclass(VisionOfRangeField.serializer()) subclass(VisionOfRangeField.serializer())
subclass(VisionOfHtmlForm.serializer()) subclass(VisionOfHtmlForm.serializer())
subclass(VisionOfHtmlButton.serializer())
} }
} }

View File

@ -1,9 +1,11 @@
package space.kscience.visionforge.html package space.kscience.visionforge.html
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.html.DIV
import kotlinx.html.InputType import kotlinx.html.InputType
import kotlinx.html.TagConsumer import kotlinx.html.div
import kotlinx.html.stream.createHTML import kotlinx.html.stream.createHTML
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -27,8 +29,10 @@ public class VisionOfPlainHtml : VisionOfHtml() {
public var content: String? by properties.string() public var content: String? by properties.string()
} }
public inline fun VisionOfPlainHtml.content(block: TagConsumer<*>.() -> Unit) { public fun VisionOfPlainHtml.content(block: DIV.() -> Unit) {
content = createHTML().apply(block).finalize() content = createHTML().apply {
div(block = block)
}.finalize()
} }
@Suppress("UnusedReceiverParameter") @Suppress("UnusedReceiverParameter")
@ -54,15 +58,7 @@ public enum class InputFeedbackMode {
NONE NONE
} }
@Serializable public abstract class VisionOfHtmlControl: VisionOfHtml(), ControlVision{
@SerialName("html.input")
public open class VisionOfHtmlInput(
public val inputType: String,
public val feedbackMode: InputFeedbackMode = InputFeedbackMode.ONCHANGE,
) : VisionOfHtml(), ControlVision {
public var value: Value? by properties.value()
public var disabled: Boolean by properties.boolean { false }
public var fieldName: String? by properties.string()
@Transient @Transient
private val mutableControlEventFlow = MutableSharedFlow<VisionControlEvent>() private val mutableControlEventFlow = MutableSharedFlow<VisionControlEvent>()
@ -70,14 +66,32 @@ public open class VisionOfHtmlInput(
override val controlEventFlow: SharedFlow<VisionControlEvent> override val controlEventFlow: SharedFlow<VisionControlEvent>
get() = mutableControlEventFlow get() = mutableControlEventFlow
override fun dispatchControlEvent(event: VisionControlEvent) { override suspend fun dispatchControlEvent(event: VisionControlEvent) {
if(event is VisionValueChangeEvent){ mutableControlEventFlow.emit(event)
this.value = event.value
}
mutableControlEventFlow.tryEmit(event)
} }
} }
@Serializable
@SerialName("html.input")
public open class VisionOfHtmlInput(
public val inputType: String,
public val feedbackMode: InputFeedbackMode = InputFeedbackMode.ONCHANGE,
) : VisionOfHtmlControl() {
public var value: Value? by properties.value()
public var disabled: Boolean by properties.boolean { false }
public var fieldName: String? by properties.string()
}
/**
* Trigger [callback] on each value change
*/
public fun VisionOfHtmlInput.onValueChange(
scope: CoroutineScope = manager?.context ?: error("Coroutine context is not resolved for $this"),
callback: suspend VisionValueChangeEvent.() -> Unit,
): Job = controlEventFlow.filterIsInstance<VisionValueChangeEvent>().onEach(callback).launchIn(scope)
@Suppress("UnusedReceiverParameter") @Suppress("UnusedReceiverParameter")
public inline fun VisionOutput.htmlInput( public inline fun VisionOutput.htmlInput(
inputType: String, inputType: String,
@ -110,7 +124,7 @@ public inline fun VisionOutput.htmlCheckBox(
@Serializable @Serializable
@SerialName("html.number") @SerialName("html.number")
public class VisionOfNumberField : VisionOfHtmlInput(InputType.number.realValue) { public class VisionOfNumberField : VisionOfHtmlInput(InputType.number.realValue) {
public var number: Number? by properties.number(key = VisionOfHtmlInput::value.name.asName()) public var numberValue: Number? by properties.number(key = VisionOfHtmlInput::value.name.asName())
} }
@Suppress("UnusedReceiverParameter") @Suppress("UnusedReceiverParameter")
@ -125,14 +139,14 @@ public class VisionOfRangeField(
public val max: Double, public val max: Double,
public val step: Double = 1.0, public val step: Double = 1.0,
) : VisionOfHtmlInput(InputType.range.realValue) { ) : VisionOfHtmlInput(InputType.range.realValue) {
public var number: Number? by properties.number(key = VisionOfHtmlInput::value.name.asName()) public var numberValue: Number? by properties.number(key = VisionOfHtmlInput::value.name.asName())
} }
@Suppress("UnusedReceiverParameter") @Suppress("UnusedReceiverParameter")
public inline fun VisionOutput.htmlRangeField( public inline fun VisionOutput.htmlRangeField(
min: Double, min: Number,
max: Double, max: Number,
step: Double = 1.0, step: Number = 1.0,
block: VisionOfRangeField.() -> Unit = {}, block: VisionOfRangeField.() -> Unit = {},
): VisionOfRangeField = VisionOfRangeField(min, max, step).apply(block) ): VisionOfRangeField = VisionOfRangeField(min.toDouble(), max.toDouble(), step.toDouble()).apply(block)

View File

@ -8,6 +8,8 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.node import space.kscience.dataforge.meta.node
import space.kscience.dataforge.meta.string
import space.kscience.visionforge.ClickControl
/** /**
* @param formId an id of the element in rendered DOM, this form is bound to * @param formId an id of the element in rendered DOM, this form is bound to
@ -16,7 +18,7 @@ import space.kscience.dataforge.meta.node
@SerialName("html.form") @SerialName("html.form")
public class VisionOfHtmlForm( public class VisionOfHtmlForm(
public val formId: String, public val formId: String,
) : VisionOfHtml() { ) : VisionOfHtmlControl() {
public var values: Meta? by properties.node() public var values: Meta? by properties.node()
} }
@ -27,3 +29,20 @@ public fun <R> TagConsumer<R>.bindForm(
this.id = visionOfForm.formId this.id = visionOfForm.formId
builder() builder()
} }
@Serializable
@SerialName("html.button")
public class VisionOfHtmlButton : VisionOfHtmlControl(), ClickControl {
public var label: String? by properties.string()
}
@Suppress("UnusedReceiverParameter")
public inline fun VisionOutput.button(
text: String,
block: VisionOfHtmlButton.() -> Unit = {},
): VisionOfHtmlButton = VisionOfHtmlButton().apply {
label = text
block()
}

View File

@ -135,7 +135,9 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
} }
logger.debug { "Got $event for output with name $visionName" } logger.debug { "Got $event for output with name $visionName" }
context.launch {
vision.receiveEvent(event) vision.receiveEvent(event)
}
} else { } else {
logger.error { "WebSocket message data is not a string" } logger.error { "WebSocket message data is not a string" }
} }
@ -262,7 +264,8 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
numberVisionRenderer, numberVisionRenderer,
textVisionRenderer, textVisionRenderer,
rangeVisionRenderer, rangeVisionRenderer,
formVisionRenderer formVisionRenderer,
buttonVisionRenderer
).associateByName() ).associateByName()
} else super<AbstractPlugin>.content(target) } else super<AbstractPlugin>.content(target)

View File

@ -0,0 +1,90 @@
package space.kscience.visionforge
import kotlinx.browser.document
import kotlinx.coroutines.launch
import kotlinx.html.ButtonType
import kotlinx.html.js.button
import org.w3c.dom.HTMLFormElement
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.get
import org.w3c.xhr.FormData
import space.kscience.dataforge.context.debug
import space.kscience.dataforge.context.logger
import space.kscience.dataforge.meta.*
import space.kscience.dataforge.names.Name
import space.kscience.visionforge.html.VisionOfHtmlButton
import space.kscience.visionforge.html.VisionOfHtmlForm
internal fun FormData.toMeta(): Meta {
@Suppress("UNUSED_VARIABLE") val formData = this
//val res = js("Object.fromEntries(formData);")
val `object` = js("{}")
//language=JavaScript
js(
"""
formData.forEach(function(value, key){
// Reflect.has in favor of: object.hasOwnProperty(key)
if(!Reflect.has(object, key)){
object[key] = value;
return;
}
if(!Array.isArray(object[key])){
object[key] = [object[key]];
}
object[key].push(value);
});
"""
)
return DynamicMeta(`object`)
}
public fun VisionClient.sendMetaEvent(targetName: Name, payload: MetaRepr): Unit {
context.launch {
sendEvent(targetName, VisionMetaEvent(payload.toMeta()))
}
}
internal val formVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfHtmlForm> { name, client, vision, _ ->
val form = document.getElementById(vision.formId) as? HTMLFormElement
?: error("An element with id = '${vision.formId} is not a form")
form.subscribeToVision(vision)
vision.manager?.logger?.debug { "Adding hooks to form with id = '$vision.formId'" }
vision.useProperty(VisionOfHtmlForm::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()
}
}
form.onsubmit = { event ->
event.preventDefault()
val formData = FormData(form).toMeta()
client.sendMetaEvent(name, formData)
console.info("Sent: ${formData.toMap()}")
false
}
}
internal val buttonVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfHtmlButton> { name, client, vision, _ ->
button(type = ButtonType.button).also { button ->
button.subscribeToVision(vision)
button.onclick = {
client.context.launch {
client.sendEvent(name, VisionClickEvent(name = name))
}
}
vision.useProperty(VisionOfHtmlButton::label) {
button.innerHTML = it ?: ""
}
}
}

View File

@ -1,21 +1,17 @@
package space.kscience.visionforge package space.kscience.visionforge
import kotlinx.browser.document
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.dom.clear import kotlinx.dom.clear
import kotlinx.html.InputType import kotlinx.html.InputType
import kotlinx.html.div import kotlinx.html.div
import kotlinx.html.dom.append
import kotlinx.html.js.input import kotlinx.html.js.input
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLFormElement
import org.w3c.dom.HTMLInputElement import org.w3c.dom.HTMLInputElement
import org.w3c.dom.events.Event import org.w3c.dom.events.Event
import org.w3c.dom.get import space.kscience.dataforge.meta.Value
import org.w3c.xhr.FormData import space.kscience.dataforge.meta.asValue
import space.kscience.dataforge.context.debug import space.kscience.dataforge.meta.double
import space.kscience.dataforge.context.logger import space.kscience.dataforge.meta.string
import space.kscience.dataforge.meta.*
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.visionforge.html.* import space.kscience.visionforge.html.*
@ -24,7 +20,7 @@ import space.kscience.visionforge.html.*
* *
* @param vision The vision to subscribe to. * @param vision The vision to subscribe to.
*/ */
private fun HTMLElement.subscribeToVision(vision: VisionOfHtml) { internal fun HTMLElement.subscribeToVision(vision: VisionOfHtml) {
vision.useProperty(VisionOfHtml::classes) { vision.useProperty(VisionOfHtml::classes) {
classList.value = classes.joinToString(separator = " ") classList.value = classes.joinToString(separator = " ")
} }
@ -33,7 +29,7 @@ private fun HTMLElement.subscribeToVision(vision: VisionOfHtml) {
private fun VisionClient.sendInputEvent(name: Name, value: Value?) { private fun VisionClient.sendInputEvent(name: Name, value: Value?) {
context.launch { context.launch {
sendEvent(name, VisionValueChangeEvent(value)) sendEvent(name, VisionValueChangeEvent(value, name))
} }
} }
@ -51,20 +47,16 @@ private fun HTMLInputElement.subscribeToInput(inputVision: VisionOfHtmlInput) {
internal val htmlVisionRenderer: ElementVisionRenderer = internal val htmlVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfPlainHtml> { _, _, vision, _ -> ElementVisionRenderer<VisionOfPlainHtml> { _, _, vision, _ ->
div {}.also { div -> div().also { div ->
div.subscribeToVision(vision) div.subscribeToVision(vision)
vision.useProperty(VisionOfPlainHtml::content) { vision.useProperty(VisionOfPlainHtml::content) {
div.clear() div.clear()
div.append { if (it != null) div.innerHTML = it
}
div.textContent = it
} }
} }
} }
internal val inputVisionRenderer: ElementVisionRenderer = internal val inputVisionRenderer: ElementVisionRenderer = ElementVisionRenderer<VisionOfHtmlInput>(
ElementVisionRenderer<VisionOfHtmlInput>(
acceptRating = ElementVisionRenderer.DEFAULT_RATING - 1 acceptRating = ElementVisionRenderer.DEFAULT_RATING - 1
) { name, client, vision, _ -> ) { name, client, vision, _ ->
input { input {
@ -95,7 +87,7 @@ internal val checkboxVisionRenderer: ElementVisionRenderer =
type = InputType.checkBox type = InputType.checkBox
}.also { htmlInputElement -> }.also { htmlInputElement ->
val onEvent: (Event) -> Unit = { val onEvent: (Event) -> Unit = {
client.sendInputEvent(name, htmlInputElement.value.asValue()) client.sendInputEvent(name, htmlInputElement.checked.asValue())
} }
@ -140,7 +132,7 @@ internal val textVisionRenderer: ElementVisionRenderer =
internal val numberVisionRenderer: ElementVisionRenderer = internal val numberVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfNumberField> { name, client, vision, _ -> ElementVisionRenderer<VisionOfNumberField> { name, client, vision, _ ->
input { input {
type = InputType.text type = InputType.number
}.also { htmlInputElement -> }.also { htmlInputElement ->
val onEvent: (Event) -> Unit = { val onEvent: (Event) -> Unit = {
@ -165,7 +157,7 @@ internal val numberVisionRenderer: ElementVisionRenderer =
internal val rangeVisionRenderer: ElementVisionRenderer = internal val rangeVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfRangeField> { name, client, vision, _ -> ElementVisionRenderer<VisionOfRangeField> { name, client, vision, _ ->
input { input {
type = InputType.text type = InputType.range
min = vision.min.toString() min = vision.min.toString()
max = vision.max.toString() max = vision.max.toString()
step = vision.step.toString() step = vision.step.toString()
@ -189,53 +181,3 @@ internal val rangeVisionRenderer: ElementVisionRenderer =
} }
} }
} }
internal fun FormData.toMeta(): Meta {
@Suppress("UNUSED_VARIABLE") val formData = this
//val res = js("Object.fromEntries(formData);")
val `object` = js("{}")
//language=JavaScript
js(
"""
formData.forEach(function(value, key){
// Reflect.has in favor of: object.hasOwnProperty(key)
if(!Reflect.has(object, key)){
object[key] = value;
return;
}
if(!Array.isArray(object[key])){
object[key] = [object[key]];
}
object[key].push(value);
});
"""
)
return DynamicMeta(`object`)
}
internal val formVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfHtmlForm> { name, client, vision, _ ->
val form = document.getElementById(vision.formId) as? HTMLFormElement
?: error("An element with id = '${vision.formId} is not a form")
form.subscribeToVision(vision)
vision.manager?.logger?.debug { "Adding hooks to form with id = '$vision.formId'" }
vision.useProperty(VisionOfHtmlForm::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()
}
}
form.onsubmit = { event ->
event.preventDefault()
val formData = FormData(form).toMeta()
client.sendMetaEvent(name, formData)
console.info("Sent: ${formData.toMap()}")
false
}
}