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.serialization.SerialName
import kotlinx.serialization.Serializable
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaRepr
import space.kscience.dataforge.meta.MutableMeta
import space.kscience.dataforge.meta.Value
import space.kscience.dataforge.meta.*
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.parseAsName
@Serializable
@SerialName("control")
public abstract class VisionControlEvent : VisionEvent, MetaRepr {
public sealed class VisionControlEvent : VisionEvent, MetaRepr {
public abstract val meta: Meta
override fun toMeta(): Meta = meta
@ -24,30 +23,41 @@ public abstract class VisionControlEvent : VisionEvent, MetaRepr {
public interface ControlVision : Vision {
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) {
dispatchControlEvent(event)
} else super.receiveEvent(event)
}
}
/**
* @param payload The optional payload associated with the click event.
*/
@Serializable
@SerialName("control.click")
public class VisionClickEvent(public val payload: Meta = Meta.EMPTY) : VisionControlEvent() {
override val meta: Meta get() = Meta { ::payload.name put payload }
public class VisionClickEvent(override val meta: Meta) : VisionControlEvent() {
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 {
/**
* Create and dispatch a click event
*/
public fun click(builder: MutableMeta.() -> Unit = {}) {
public suspend fun click(builder: MutableMeta.() -> Unit = {}) {
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 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 =
VisionValueChangeEvent(Meta { this.value = value })
public fun VisionValueChangeEvent(value: Value?, name: Name? = null): VisionValueChangeEvent = VisionValueChangeEvent(
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].
*/
public fun receiveEvent(event: VisionEvent) {
public suspend fun receiveEvent(event: VisionEvent) {
if(event is VisionChange) update(event)
else manager?.logger?.warn { "Undispatched event: $event" }
}

View File

@ -1,9 +1,7 @@
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
@ -32,10 +30,4 @@ public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: St
public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Boolean) {
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() }
subclass(NullVision.serializer())
subclass(SimpleVisionGroup.serializer())
subclass(VisionOfPlainHtml.serializer())
subclass(VisionOfHtmlInput.serializer())
subclass(VisionOfNumberField.serializer())
subclass(VisionOfTextField.serializer())
subclass(VisionOfCheckbox.serializer())
subclass(VisionOfRangeField.serializer())
subclass(VisionOfHtmlForm.serializer())
subclass(VisionOfHtmlButton.serializer())
}
}

View File

@ -1,9 +1,11 @@
package space.kscience.visionforge.html
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.html.DIV
import kotlinx.html.InputType
import kotlinx.html.TagConsumer
import kotlinx.html.div
import kotlinx.html.stream.createHTML
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -27,8 +29,10 @@ public class VisionOfPlainHtml : VisionOfHtml() {
public var content: String? by properties.string()
}
public inline fun VisionOfPlainHtml.content(block: TagConsumer<*>.() -> Unit) {
content = createHTML().apply(block).finalize()
public fun VisionOfPlainHtml.content(block: DIV.() -> Unit) {
content = createHTML().apply {
div(block = block)
}.finalize()
}
@Suppress("UnusedReceiverParameter")
@ -54,15 +58,7 @@ public enum class InputFeedbackMode {
NONE
}
@Serializable
@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()
public abstract class VisionOfHtmlControl: VisionOfHtml(), ControlVision{
@Transient
private val mutableControlEventFlow = MutableSharedFlow<VisionControlEvent>()
@ -70,14 +66,32 @@ public open class VisionOfHtmlInput(
override val controlEventFlow: SharedFlow<VisionControlEvent>
get() = mutableControlEventFlow
override fun dispatchControlEvent(event: VisionControlEvent) {
if(event is VisionValueChangeEvent){
this.value = event.value
}
mutableControlEventFlow.tryEmit(event)
override suspend fun dispatchControlEvent(event: VisionControlEvent) {
mutableControlEventFlow.emit(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")
public inline fun VisionOutput.htmlInput(
inputType: String,
@ -110,7 +124,7 @@ public inline fun VisionOutput.htmlCheckBox(
@Serializable
@SerialName("html.number")
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")
@ -125,14 +139,14 @@ public class VisionOfRangeField(
public val max: Double,
public val step: Double = 1.0,
) : 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")
public inline fun VisionOutput.htmlRangeField(
min: Double,
max: Double,
step: Double = 1.0,
min: Number,
max: Number,
step: Number = 1.0,
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 space.kscience.dataforge.meta.Meta
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
@ -16,7 +18,7 @@ import space.kscience.dataforge.meta.node
@SerialName("html.form")
public class VisionOfHtmlForm(
public val formId: String,
) : VisionOfHtml() {
) : VisionOfHtmlControl() {
public var values: Meta? by properties.node()
}
@ -26,4 +28,21 @@ public fun <R> TagConsumer<R>.bindForm(
): R = form {
this.id = visionOfForm.formId
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" }
vision.receiveEvent(event)
context.launch {
vision.receiveEvent(event)
}
} else {
logger.error { "WebSocket message data is not a string" }
}
@ -262,7 +264,8 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
numberVisionRenderer,
textVisionRenderer,
rangeVisionRenderer,
formVisionRenderer
formVisionRenderer,
buttonVisionRenderer
).associateByName()
} 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
import kotlinx.browser.document
import kotlinx.coroutines.launch
import kotlinx.dom.clear
import kotlinx.html.InputType
import kotlinx.html.div
import kotlinx.html.dom.append
import kotlinx.html.js.input
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
import space.kscience.dataforge.context.logger
import space.kscience.dataforge.meta.*
import space.kscience.dataforge.meta.Value
import space.kscience.dataforge.meta.asValue
import space.kscience.dataforge.meta.double
import space.kscience.dataforge.meta.string
import space.kscience.dataforge.names.Name
import space.kscience.visionforge.html.*
@ -24,7 +20,7 @@ import space.kscience.visionforge.html.*
*
* @param vision The vision to subscribe to.
*/
private fun HTMLElement.subscribeToVision(vision: VisionOfHtml) {
internal fun HTMLElement.subscribeToVision(vision: VisionOfHtml) {
vision.useProperty(VisionOfHtml::classes) {
classList.value = classes.joinToString(separator = " ")
}
@ -33,7 +29,7 @@ private fun HTMLElement.subscribeToVision(vision: VisionOfHtml) {
private fun VisionClient.sendInputEvent(name: Name, value: Value?) {
context.launch {
sendEvent(name, VisionValueChangeEvent(value))
sendEvent(name, VisionValueChangeEvent(value, name))
}
}
@ -51,43 +47,39 @@ private fun HTMLInputElement.subscribeToInput(inputVision: VisionOfHtmlInput) {
internal val htmlVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfPlainHtml> { _, _, vision, _ ->
div {}.also { div ->
div().also { div ->
div.subscribeToVision(vision)
vision.useProperty(VisionOfPlainHtml::content) {
div.clear()
div.append {
}
div.textContent = it
if (it != null) div.innerHTML = it
}
}
}
internal val inputVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfHtmlInput>(
acceptRating = ElementVisionRenderer.DEFAULT_RATING - 1
) { name, client, vision, _ ->
input {
type = InputType.text
}.also { htmlInputElement ->
val onEvent: (Event) -> Unit = {
client.sendInputEvent(name, htmlInputElement.value.asValue())
}
internal val inputVisionRenderer: ElementVisionRenderer = ElementVisionRenderer<VisionOfHtmlInput>(
acceptRating = ElementVisionRenderer.DEFAULT_RATING - 1
) { name, client, vision, _ ->
input {
type = InputType.text
}.also { htmlInputElement ->
val onEvent: (Event) -> Unit = {
client.sendInputEvent(name, htmlInputElement.value.asValue())
}
when (vision.feedbackMode) {
InputFeedbackMode.ONCHANGE -> htmlInputElement.onchange = onEvent
when (vision.feedbackMode) {
InputFeedbackMode.ONCHANGE -> htmlInputElement.onchange = onEvent
InputFeedbackMode.ONINPUT -> htmlInputElement.oninput = onEvent
InputFeedbackMode.NONE -> {}
}
InputFeedbackMode.ONINPUT -> htmlInputElement.oninput = onEvent
InputFeedbackMode.NONE -> {}
}
htmlInputElement.subscribeToInput(vision)
vision.useProperty(VisionOfHtmlInput::value) {
htmlInputElement.value = it?.string ?: ""
}
htmlInputElement.subscribeToInput(vision)
vision.useProperty(VisionOfHtmlInput::value) {
htmlInputElement.value = it?.string ?: ""
}
}
}
internal val checkboxVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfCheckbox> { name, client, vision, _ ->
@ -95,7 +87,7 @@ internal val checkboxVisionRenderer: ElementVisionRenderer =
type = InputType.checkBox
}.also { htmlInputElement ->
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 =
ElementVisionRenderer<VisionOfNumberField> { name, client, vision, _ ->
input {
type = InputType.text
type = InputType.number
}.also { htmlInputElement ->
val onEvent: (Event) -> Unit = {
@ -165,7 +157,7 @@ internal val numberVisionRenderer: ElementVisionRenderer =
internal val rangeVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfRangeField> { name, client, vision, _ ->
input {
type = InputType.text
type = InputType.range
min = vision.min.toString()
max = vision.max.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
}
}