Compare commits

...

5 Commits

26 changed files with 881 additions and 78 deletions

View File

@ -7,7 +7,7 @@
- Custom client-side events and thier processing in VisionServer - Custom client-side events and thier processing in VisionServer
### Changed ### Changed
- Color accessor property is now `colorProperty`. Color uses `invoke` instead of `set` - Color accessor property is now `colorProperty`. Color uses non-nullable `invoke` instead of `set`.
- API update for server and pages - API update for server and pages
- Edges moved to solids module for easier construction - Edges moved to solids module for easier construction
- Visions **must** be rooted in order to subscribe to updates. - Visions **must** be rooted in order to subscribe to updates.

View File

@ -12,7 +12,7 @@ val fxVersion by extra("11")
allprojects { allprojects {
group = "space.kscience" group = "space.kscience"
version = "0.3.0-dev-14" version = "0.3.0-dev-16"
} }
subprojects { subprojects {

View File

@ -13,7 +13,6 @@ kscience {
useKtor() useKtor()
fullStack( fullStack(
"muon-monitor.js", "muon-monitor.js",
development = true,
jvmConfig = { withJava() }, jvmConfig = { withJava() },
jsConfig = { useCommonJs() } jsConfig = { useCommonJs() }
) { ) {
@ -47,9 +46,6 @@ application {
mainClass.set("ru.mipt.npm.muon.monitor.server.MMServerKt") mainClass.set("ru.mipt.npm.muon.monitor.server.MMServerKt")
} }
//TODO ???
tasks.getByName("jsBrowserProductionWebpack").dependsOn("jsDevelopmentExecutableCompileSync")
//distributions { //distributions {
// main { // main {
// contents { // contents {

View File

@ -8,6 +8,6 @@ org.gradle.jvmargs=-Xmx4G
org.jetbrains.compose.experimental.jscanvas.enabled=true org.jetbrains.compose.experimental.jscanvas.enabled=true
toolsVersion=0.15.0-kotlin-1.9.20-RC2 toolsVersion=0.15.0-kotlin-1.9.20
#kotlin.experimental.tryK2=true #kotlin.experimental.tryK2=true
#kscience.wasm.disabled=true #kscience.wasm.disabled=true

View File

@ -45,7 +45,7 @@ include(
":ui:ring", ":ui:ring",
// ":ui:material", // ":ui:material",
":ui:bootstrap", ":ui:bootstrap",
// ":ui:compose", ":ui:compose",
":visionforge-core", ":visionforge-core",
":visionforge-solid", ":visionforge-solid",
// ":visionforge-fx", // ":visionforge-fx",

View File

@ -0,0 +1,41 @@
plugins {
id("space.kscience.gradle.mpp")
id("org.jetbrains.compose") version "1.5.10"
// id("com.android.library")
}
kscience{
jvm()
js()
// wasm()
}
kotlin {
// android()
sourceSets {
val commonMain by getting {
dependencies {
}
}
val jvmMain by getting {
dependencies {
api(compose.runtime)
api(compose.foundation)
api(compose.material)
api(compose.preview)
}
}
val jsMain by getting{
dependencies {
api(compose.html.core)
api("app.softwork:bootstrap-compose:0.1.15")
api("app.softwork:bootstrap-compose-icons:0.1.15")
api(projects.visionforge.visionforgeThreejs)
}
}
}
}

View File

@ -0,0 +1,87 @@
package space.kscience.visionforge.compose
import androidx.compose.runtime.*
import org.jetbrains.compose.web.css.AlignItems
import org.jetbrains.compose.web.css.alignItems
import org.jetbrains.compose.web.dom.A
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.Span
import org.jetbrains.compose.web.dom.Text
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.descriptors.MetaDescriptor
import space.kscience.dataforge.meta.descriptors.get
import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.isLeaf
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.NameToken
import space.kscience.dataforge.names.lastOrNull
import space.kscience.dataforge.names.plus
@Composable
private fun MetaViewerItem(root: Meta, name: Name, rootDescriptor: MetaDescriptor? = null) {
var expanded: Boolean by remember { mutableStateOf(true) }
val item: Meta? = root[name]
val descriptorItem: MetaDescriptor? = rootDescriptor?.get(name)
val actualValue = item?.value ?: descriptorItem?.defaultValue
val actualMeta = item ?: descriptorItem?.defaultNode
val token = name.lastOrNull()?.toString() ?: ""
FlexRow(attrs = {
classes("metaItem")
style {
alignItems(AlignItems.Center)
}
}) {
if (actualMeta?.isLeaf == false) {
Span({
classes(TreeStyles.treeCaret)
if (expanded) {
classes(TreeStyles.treeCaretDown)
}
onClick { expanded = !expanded }
})
}
Span({
classes(TreeStyles.treeLabel)
if (item == null) {
classes(TreeStyles.treeLabelInactive)
}
}) {
Text(token)
}
Div {
A {
Text(actualValue.toString())
}
}
}
if (expanded) {
FlexColumn({
classes(TreeStyles.tree)
}) {
val keys = buildSet {
descriptorItem?.children?.keys?.forEach {
add(NameToken(it))
}
actualMeta!!.items.keys.let { addAll(it) }
}
keys.filter { !it.body.startsWith("@") }.forEach { token ->
Div({
classes(TreeStyles.treeItem)
}) {
MetaViewerItem(root, name + token, rootDescriptor)
}
}
}
}
}
@Composable
public fun MetaViewer(meta: Meta, descriptor: MetaDescriptor? = null) {
MetaViewerItem(meta, Name.EMPTY, descriptor)
}

View File

@ -0,0 +1,189 @@
package space.kscience.visionforge.compose
import androidx.compose.runtime.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.web.attributes.disabled
import org.jetbrains.compose.web.css.AlignItems
import org.jetbrains.compose.web.css.alignItems
import org.jetbrains.compose.web.css.px
import org.jetbrains.compose.web.css.width
import org.jetbrains.compose.web.dom.Button
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.Span
import org.jetbrains.compose.web.dom.Text
import space.kscience.dataforge.meta.MutableMeta
import space.kscience.dataforge.meta.ObservableMutableMeta
import space.kscience.dataforge.meta.descriptors.MetaDescriptor
import space.kscience.dataforge.meta.descriptors.ValueRequirement
import space.kscience.dataforge.meta.descriptors.get
import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.remove
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.NameToken
import space.kscience.dataforge.names.isEmpty
import space.kscience.dataforge.names.lastOrNull
import space.kscience.visionforge.hidden
/**
* The display state of a property
*/
public sealed class EditorPropertyState {
public object Defined : EditorPropertyState()
public class Default(public val source: String = "unknown") : EditorPropertyState()
public object Undefined : EditorPropertyState()
}
/**
* @param rootDescriptor Full path to the displayed node in [meta]. Could be empty
*/
@Composable
private fun PropertyEditorItem(
/**
* Root config object - always non-null
*/
meta: MutableMeta,
getPropertyState: (Name) -> EditorPropertyState,
scope: CoroutineScope,
updates: Flow<Name>,
name: Name,
rootDescriptor: MetaDescriptor?,
initialExpanded: Boolean? = null,
) {
var expanded: Boolean by remember { mutableStateOf(initialExpanded ?: true) }
val descriptor: MetaDescriptor? = remember(rootDescriptor, name) { rootDescriptor?.get(name) }
var property: MutableMeta by remember { mutableStateOf(meta.getOrCreate(name)) }
var editorPropertyState: EditorPropertyState by remember { mutableStateOf(getPropertyState(name)) }
val keys = remember(descriptor) {
buildSet {
descriptor?.children?.filterNot {
it.key.startsWith("@") || it.value.hidden
}?.forEach {
add(NameToken(it.key))
}
//ownProperty?.items?.keys?.filterNot { it.body.startsWith("@") }?.let { addAll(it) }
}
}
val token = name.lastOrNull()?.toString() ?: "Properties"
fun update() {
property = meta.getOrCreate(name)
editorPropertyState = getPropertyState(name)
}
LaunchedEffect(meta) {
updates.collect { updatedName ->
if (updatedName == name) {
update()
}
}
}
FlexRow({
style {
alignItems(AlignItems.Center)
}
}) {
if (keys.isNotEmpty()) {
Span({
classes(TreeStyles.treeCaret)
if (expanded) {
classes(TreeStyles.treeCaretDown)
}
onClick { expanded = !expanded }
})
}
Span({
classes(TreeStyles.treeLabel)
if (editorPropertyState != EditorPropertyState.Defined) {
classes(TreeStyles.treeLabelInactive)
}
}) {
Text(token)
}
if (!name.isEmpty() && descriptor?.valueRequirement != ValueRequirement.ABSENT) {
Div({
style {
width(160.px)
marginAll(1.px, 5.px)
}
}) {
ValueChooser(descriptor, editorPropertyState, property.value) {
property.value = it
editorPropertyState = getPropertyState(name)
}
}
Button({
classes(TreeStyles.propertyEditorButton)
if (editorPropertyState != EditorPropertyState.Defined) {
disabled()
} else {
onClick {
meta.remove(name)
update()
}
}
}) {
Text("\u00D7")
}
}
}
if (expanded) {
FlexColumn({
classes(TreeStyles.tree)
}) {
keys.forEach { token ->
Div({
classes(TreeStyles.treeItem)
}) {
PropertyEditorItem(meta, getPropertyState, scope, updates, name, descriptor, expanded)
}
}
}
}
}
@Composable
public fun PropertyEditor(
scope: CoroutineScope,
properties: ObservableMutableMeta,
descriptor: MetaDescriptor? = null,
expanded: Boolean? = null,
) {
PropertyEditorItem(
meta = properties,
getPropertyState = { name ->
if (properties[name] != null) {
EditorPropertyState.Defined
} else if (descriptor?.get(name)?.defaultValue != null) {
EditorPropertyState.Default("descriptor")
} else {
EditorPropertyState.Undefined
}
},
scope = scope,
updates = callbackFlow {
properties.onChange(scope) { name ->
scope.launch {
send(name)
}
}
invokeOnClose {
properties.removeListener(scope)
}
},
name = Name.EMPTY,
rootDescriptor = descriptor,
initialExpanded = expanded,
)
}

View File

@ -0,0 +1,52 @@
package space.kscience.visionforge.compose
import androidx.compose.runtime.*
import kotlinx.dom.clear
import org.jetbrains.compose.web.css.*
import org.jetbrains.compose.web.dom.Div
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.request
import space.kscience.dataforge.names.Name
import space.kscience.visionforge.solid.Solid
import space.kscience.visionforge.solid.specifications.Canvas3DOptions
import space.kscience.visionforge.solid.three.ThreeCanvas
import space.kscience.visionforge.solid.three.ThreePlugin
@Composable
public fun ThreeCanvas(
context: Context,
options: Canvas3DOptions?,
solid: Solid?,
selected: Name?,
) {
val three: ThreePlugin by derivedStateOf { context.request(ThreePlugin) }
Div({
style {
maxWidth(100.vw)
maxHeight(100.vh)
width(100.percent)
height(100.percent)
}
}) {
var canvas: ThreeCanvas? = null
DisposableEffect(options) {
canvas = ThreeCanvas(three, scopeElement, options ?: Canvas3DOptions())
onDispose {
scopeElement.clear()
canvas = null
}
}
LaunchedEffect(solid) {
if (solid != null) {
canvas?.render(solid)
} else {
canvas?.clear()
}
}
LaunchedEffect(selected) {
canvas?.select(selected)
}
}
}

View File

@ -1,11 +0,0 @@
package space.kscience.visionforge.compose
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
@Composable
public fun ThreeJs(){
Surface {
}
}

View File

@ -0,0 +1,94 @@
package space.kscience.visionforge.compose
import org.jetbrains.compose.web.ExperimentalComposeWebApi
import org.jetbrains.compose.web.css.*
@OptIn(ExperimentalComposeWebApi::class)
public object TreeStyles : StyleSheet() {
/**
* Remove default bullets
*/
public val tree: String by style {
paddingLeft(5.px)
marginLeft(0.px)
listStyleType("none")
}
/**
* Style the caret/arrow
*/
public val treeCaret: String by style {
cursor("pointer")
userSelect(UserSelect.none)
/* Create the caret/arrow with a unicode, and style it */
before {
content("\u25B6")
color(Color.black)
display(DisplayStyle.InlineBlock)
marginRight(6.px)
}
}
/**
* Rotate the caret/arrow icon when clicked on (using JavaScript)
*/
public val treeCaretDown: String by style {
before {
content("\u25B6")
color(Color.black)
display(DisplayStyle.InlineBlock)
marginRight(6.px)
transform { rotate(90.deg) }
}
}
public val treeItem: String by style {
alignItems(AlignItems.Center)
paddingLeft(10.px)
border {
left {
width(1.px)
color(Color.lightgray)
style = LineStyle.Dashed
}
}
}
public val treeLabel: String by style {
border(style = LineStyle.None)
paddingAll(left = 4.pt, right = 4.pt)
textAlign("left")
flex(1)
}
public val treeLabelInactive: String by style {
color(Color.lightgray)
}
public val treeLabelSelected: String by style {
backgroundColor(Color.lightblue)
}
public val propertyEditorButton: String by style {
width(24.px)
alignSelf(AlignSelf.Stretch)
marginAll(1.px, 5.px)
backgroundColor(Color.white)
border{
style(LineStyle.Solid)
}
borderRadius(2.px)
textAlign("center")
textDecoration("none")
cursor("pointer")
disabled {
cursor("auto")
border{
style(LineStyle.Dashed)
}
color(Color.lightgray)
}
}
}

View File

@ -0,0 +1,35 @@
package space.kscience.visionforge.compose
import org.jetbrains.compose.web.css.*
public enum class UserSelect {
inherit, initial, revert, revertLayer, unset,
none, auto, text, contain, all;
}
public fun StyleScope.userSelect(value: UserSelect) {
property("user-select", value.name)
}
public fun StyleScope.content(value: String) {
property("content", "'$value'")
}
public fun StyleScope.paddingAll(
top: CSSNumeric = 0.pt,
right: CSSNumeric = top,
bottom: CSSNumeric = top,
left: CSSNumeric = right,
) {
padding(top, right, bottom, left)
}
public fun StyleScope.marginAll(
top: CSSNumeric = 0.pt,
right: CSSNumeric = top,
bottom: CSSNumeric = top,
left: CSSNumeric = right,
) {
margin(top, right, bottom, left)
}

View File

@ -0,0 +1,41 @@
package space.kscience.visionforge.compose
import androidx.compose.runtime.Composable
import org.jetbrains.compose.web.css.DisplayStyle
import org.jetbrains.compose.web.css.FlexDirection
import org.jetbrains.compose.web.css.display
import org.jetbrains.compose.web.css.flexDirection
import org.jetbrains.compose.web.dom.AttrBuilderContext
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.ElementScope
import org.w3c.dom.HTMLDivElement
@Composable
public fun FlexColumn(
attrs: AttrBuilderContext<HTMLDivElement>? = null,
content: @Composable ElementScope<HTMLDivElement>.() -> Unit,
): Unit = Div(
attrs = {
style {
display(DisplayStyle.Flex)
flexDirection(FlexDirection.Column)
}
attrs?.invoke(this)
},
content
)
@Composable
public fun FlexRow(
attrs: AttrBuilderContext<HTMLDivElement>? = null,
content: @Composable ElementScope<HTMLDivElement>.() -> Unit,
): Unit = Div(
attrs = {
style {
display(DisplayStyle.Flex)
flexDirection(FlexDirection.Row)
}
attrs?.invoke(this)
},
content
)

View File

@ -0,0 +1,268 @@
@file:Suppress("UNUSED_PARAMETER")
package space.kscience.visionforge.compose
import androidx.compose.runtime.*
import org.jetbrains.compose.web.attributes.*
import org.jetbrains.compose.web.css.percent
import org.jetbrains.compose.web.css.px
import org.jetbrains.compose.web.css.width
import org.jetbrains.compose.web.dom.Input
import org.jetbrains.compose.web.dom.Option
import org.jetbrains.compose.web.dom.Select
import org.jetbrains.compose.web.dom.Text
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLOptionElement
import org.w3c.dom.asList
import space.kscience.dataforge.meta.*
import space.kscience.dataforge.meta.descriptors.MetaDescriptor
import space.kscience.dataforge.meta.descriptors.ValueRequirement
import space.kscience.dataforge.meta.descriptors.allowedValues
import space.kscience.visionforge.Colors
import space.kscience.visionforge.widgetType
import three.math.Color
@Composable
public fun StringValueChooser(
descriptor: MetaDescriptor?,
state: EditorPropertyState,
value: Value?,
onValueChange: (Value?) -> Unit,
) {
var stringValue by remember { mutableStateOf(value?.string ?: "") }
Input(type = InputType.Text) {
style {
width(100.percent)
}
value(stringValue)
onKeyDown { event ->
if (event.type == "keydown" && event.asDynamic().key == "Enter") {
stringValue = (event.target as HTMLInputElement).value
onValueChange(stringValue.asValue())
}
}
onChange {
stringValue = it.target.value
}
}
}
@Composable
public fun BooleanValueChooser(
descriptor: MetaDescriptor?,
state: EditorPropertyState,
value: Value?,
onValueChange: (Value?) -> Unit,
) {
Input(type = InputType.Checkbox) {
style {
width(100.percent)
}
//this.attributes["indeterminate"] = (props.item == null).toString()
checked(value?.boolean ?: false)
onChange {
val newValue = it.target.checked
onValueChange(newValue.asValue())
}
}
}
@Composable
public fun NumberValueChooser(
descriptor: MetaDescriptor?,
state: EditorPropertyState,
value: Value?,
onValueChange: (Value?) -> Unit,
) {
var innerValue by remember { mutableStateOf(value?.string ?: "") }
Input(type = InputType.Number) {
style {
width(100.percent)
}
value(innerValue)
onKeyDown { event ->
if (event.type == "keydown" && event.asDynamic().key == "Enter") {
innerValue = (event.target as HTMLInputElement).value
val number = innerValue.toDoubleOrNull()
if (number == null) {
console.error("The input value $innerValue is not a number")
} else {
onValueChange(number.asValue())
}
}
}
onChange {
innerValue = it.target.value
}
descriptor?.attributes?.get("step").number?.let {
step(it)
}
descriptor?.attributes?.get("min").string?.let {
min(it)
}
descriptor?.attributes?.get("max").string?.let {
max(it)
}
}
}
@Composable
public fun ComboValueChooser(
descriptor: MetaDescriptor?,
state: EditorPropertyState,
value: Value?,
onValueChange: (Value?) -> Unit,
) {
var selected by remember { mutableStateOf(value?.string ?: "") }
Select({
style {
width(100.percent)
}
onChange {
selected = it.target.value
onValueChange(selected.asValue())
}
}, multiple = false) {
descriptor?.allowedValues?.forEach {
Option(it.string, { if (it == value) selected() }) {
Text(it.string)
}
}
}
}
@Composable
public fun ColorValueChooser(
descriptor: MetaDescriptor?,
state: EditorPropertyState,
value: Value?,
onValueChange: (Value?) -> Unit,
) {
Input(type = InputType.Color) {
style {
width(100.percent)
marginAll(0.px)
}
value(
value?.let { value ->
if (value.type == ValueType.NUMBER) Colors.rgbToString(value.int)
else "#" + Color(value.string).getHexString()
} ?: "#000000"
)
onChange {
onValueChange(it.target.value.asValue())
}
}
}
@Composable
public fun MultiSelectChooser(
descriptor: MetaDescriptor?,
state: EditorPropertyState,
value: Value?,
onValueChange: (Value?) -> Unit,
) {
Select({
onChange { event ->
val newSelected = event.target.selectedOptions.asList()
.map { (it as HTMLOptionElement).value.asValue() }
onValueChange(newSelected.asValue())
}
}, multiple = true) {
descriptor?.allowedValues?.forEach { optionValue ->
Option(optionValue.string, {
value?.list?.let { if (optionValue in it) selected() }
}) {
Text(optionValue.string)
}
}
}
}
@Composable
public fun RangeValueChooser(
descriptor: MetaDescriptor?,
state: EditorPropertyState,
value: Value?,
onValueChange: (Value?) -> Unit,
) {
var innerValue by remember { mutableStateOf(value?.double) }
var rangeDisabled: Boolean by remember { mutableStateOf(state != EditorPropertyState.Defined) }
FlexRow {
if (descriptor?.valueRequirement != ValueRequirement.REQUIRED) {
Input(type = InputType.Checkbox) {
if (!rangeDisabled) defaultChecked()
onChange {
val checkBoxValue = it.target.checked
rangeDisabled = !checkBoxValue
onValueChange(
if (!checkBoxValue) {
null
} else {
innerValue?.asValue()
}
)
}
}
}
}
Input(type = InputType.Range) {
style {
width(100.percent)
}
if (rangeDisabled) disabled()
value(innerValue?.toString() ?: "")
onChange {
val newValue = it.target.value
onValueChange(newValue.toDoubleOrNull()?.asValue())
innerValue = newValue.toDoubleOrNull()
}
descriptor?.attributes?.get("min").string?.let {
min(it)
}
descriptor?.attributes?.get("max").string?.let {
max(it)
}
descriptor?.attributes?.get("step").number?.let {
step(it)
}
}
}
@Composable
public fun ValueChooser(
descriptor: MetaDescriptor?,
state: EditorPropertyState,
value: Value?,
onValueChange: (Value?) -> Unit,
) {
val rawInput by remember { mutableStateOf(false) }
val type = descriptor?.valueTypes?.firstOrNull()
when {
rawInput -> StringValueChooser(descriptor, state, value, onValueChange)
descriptor?.widgetType == "color" -> ColorValueChooser(descriptor, state, value, onValueChange)
descriptor?.widgetType == "multiSelect" -> MultiSelectChooser(descriptor, state, value, onValueChange)
descriptor?.widgetType == "range" -> RangeValueChooser(descriptor, state, value, onValueChange)
type == ValueType.BOOLEAN -> BooleanValueChooser(descriptor, state, value, onValueChange)
type == ValueType.NUMBER -> NumberValueChooser(descriptor, state, value, onValueChange)
descriptor?.allowedValues?.isNotEmpty() ?: false -> ComboValueChooser(descriptor, state, value, onValueChange)
//TODO handle lists
else -> StringValueChooser(descriptor, state, value, onValueChange)
}
}

View File

@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.asValue import space.kscience.dataforge.meta.asValue
import space.kscience.dataforge.meta.boolean import space.kscience.dataforge.meta.boolean
import space.kscience.dataforge.meta.descriptors.Described import space.kscience.dataforge.meta.descriptors.Described
@ -36,7 +37,7 @@ public interface Vision : Described {
/** /**
* Update this vision using a dif represented by [VisionChange]. * Update this vision using a dif represented by [VisionChange].
*/ */
public fun update(change: VisionChange) { public fun receiveChange(change: VisionChange) {
if (change.children?.isNotEmpty() == true) { if (change.children?.isNotEmpty() == true) {
error("Vision is not a group") error("Vision is not a group")
} }
@ -45,6 +46,20 @@ public interface Vision : Described {
} }
} }
public fun onMetaEvent(meta: Meta){
//Do nothing by default
}
/**
* Receive and process a generic [VisionEvent].
*/
public fun receiveEvent(event: VisionEvent) {
when (event) {
is VisionChange -> receiveChange(event)
is VisionMetaEvent -> onMetaEvent(event.meta)
}
}
override val descriptor: MetaDescriptor? override val descriptor: MetaDescriptor?
public companion object { public companion object {

View File

@ -63,8 +63,7 @@ public data class VisionChange(
public val vision: Vision? = null, public val vision: Vision? = null,
public val properties: Meta? = null, public val properties: Meta? = null,
public val children: Map<Name, VisionChange>? = null, public val children: Map<Name, VisionChange>? = null,
) ) : VisionEvent
/** /**
* An update for a [Vision] * An update for a [Vision]

View File

@ -13,7 +13,7 @@ import space.kscience.dataforge.names.parseAsName
public interface VisionClient: Plugin { public interface VisionClient: Plugin {
public val visionManager: VisionManager public val visionManager: VisionManager
public suspend fun sendEvent(event: VisionEvent) 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?)
} }
@ -35,8 +35,8 @@ public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: St
notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item)) notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item))
} }
public fun VisionClient.sendEvent(visionName: Name, event: MetaRepr): Unit { public fun VisionClient.sendEvent(targetName: Name, payload: MetaRepr): Unit {
context.launch { context.launch {
sendEvent(VisionMetaEvent(visionName, event.toMeta())) sendEvent(targetName, VisionMetaEvent(payload.toMeta()))
} }
} }

View File

@ -11,10 +11,8 @@ import space.kscience.dataforge.names.Name
* An event propagated from client to a server * An event propagated from client to a server
*/ */
@Serializable @Serializable
public sealed interface VisionEvent{ public sealed interface VisionEvent {
public val targetName: Name public companion object {
public companion object{
public val CLICK_EVENT_KEY: Name get() = Name.of("events", "click", "payload") public val CLICK_EVENT_KEY: Name get() = Name.of("events", "click", "payload")
} }
} }
@ -24,17 +22,14 @@ public sealed interface VisionEvent{
*/ */
@Serializable @Serializable
@SerialName("meta") @SerialName("meta")
public class VisionMetaEvent(override val targetName: Name, public val meta: Meta) : VisionEvent public class VisionMetaEvent(public val meta: Meta) : VisionEvent
@Serializable
@SerialName("change")
public class VisionChangeEvent(override val targetName: Name, public val change: VisionChange) : VisionEvent
public val Vision.Companion.CLICK_EVENT_KEY: Name get() = Name.of("events", "click", "payload") public val Vision.Companion.CLICK_EVENT_KEY: Name get() = Name.of("events", "click", "payload")
/** /**
* Set the payload to be sent to server on click * Set the payload to be sent to server on click
*/ */
public fun Vision.onClickPayload(payloadBuilder: MutableMeta.() -> Unit){ public fun Vision.onClickPayload(payloadBuilder: MutableMeta.() -> Unit) {
properties[VisionEvent.CLICK_EVENT_KEY] = Meta(payloadBuilder) properties[VisionEvent.CLICK_EVENT_KEY] = Meta(payloadBuilder)
} }

View File

@ -17,12 +17,12 @@ import space.kscience.visionforge.Vision.Companion.STYLE_KEY
public interface VisionGroup : Vision { public interface VisionGroup : Vision {
public val children: VisionChildren public val children: VisionChildren
override fun update(change: VisionChange) { override fun receiveChange(change: VisionChange) {
change.children?.forEach { (name, change) -> change.children?.forEach { (name, change) ->
if (change.vision != null || change.vision == NullVision) { if (change.vision != null || change.vision == NullVision) {
error("VisionGroup is read-only") error("VisionGroup is read-only")
} else { } else {
children.getChild(name)?.update(change) children.getChild(name)?.receiveChange(change)
} }
} }
change.properties?.let { change.properties?.let {
@ -37,12 +37,12 @@ public interface MutableVisionGroup : VisionGroup {
public fun createGroup(): MutableVisionGroup public fun createGroup(): MutableVisionGroup
override fun update(change: VisionChange) { override fun receiveChange(change: VisionChange) {
change.children?.forEach { (name, change) -> change.children?.forEach { (name, change) ->
when { when {
change.vision == NullVision -> children.setChild(name, null) change.vision == NullVision -> children.setChild(name, null)
change.vision != null -> children.setChild(name, change.vision) change.vision != null -> children.setChild(name, change.vision)
else -> children.getChild(name)?.update(change) else -> children.getChild(name)?.receiveChange(change)
} }
} }
change.properties?.let { change.properties?.let {

View File

@ -81,15 +81,14 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
} }
private val eventCollector by lazy { private val eventCollector by lazy {
MutableSharedFlow<VisionEvent>(meta["feedback.eventCache"].int ?: 100) MutableSharedFlow<Pair<Name, VisionEvent>>(meta["feedback.eventCache"].int ?: 100)
} }
/** /**
* Send a custom feedback event * Send a custom feedback event
*/ */
override suspend fun sendEvent(event: VisionEvent) { override suspend fun sendEvent(targetName: Name, event: VisionEvent) {
eventCollector.emit(event) eventCollector.emit(targetName to event)
} }
private fun renderVision(element: Element, name: Name, vision: Vision, outputMeta: Meta) { private fun renderVision(element: Element, name: Name, vision: Vision, outputMeta: Meta) {
@ -120,19 +119,21 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
onmessage = { messageEvent -> onmessage = { messageEvent ->
val stringData: String? = messageEvent.data as? String val stringData: String? = messageEvent.data as? String
if (stringData != null) { if (stringData != null) {
val change: VisionChange = visionManager.jsonFormat.decodeFromString( val event: VisionEvent = visionManager.jsonFormat.decodeFromString(
VisionChange.serializer(), VisionEvent.serializer(),
stringData stringData
) )
// If change contains root vision replacement, do it // If change contains root vision replacement, do it
change.vision?.let { vision -> if(event is VisionChange) {
renderVision(element, name, vision, outputMeta) event.vision?.let { vision ->
renderVision(element, name, vision, outputMeta)
}
} }
logger.debug { "Got update $change for output with name $name" } logger.debug { "Got $event for output with name $name" }
if (vision == null) error("Can't update vision because it is not loaded.") if (vision == null) error("Can't update vision because it is not loaded.")
vision.update(change) vision.receiveEvent(event)
} else { } else {
logger.error { "WebSocket message data is not a string" } logger.error { "WebSocket message data is not a string" }
} }
@ -147,8 +148,8 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
onopen = { onopen = {
feedbackJob = visionManager.context.launch { feedbackJob = visionManager.context.launch {
eventCollector.filter { it.targetName == name }.onEach { eventCollector.filter { it.first == name }.onEach {
send(visionManager.jsonFormat.encodeToString(VisionEvent.serializer(), it)) send(visionManager.jsonFormat.encodeToString(VisionEvent.serializer(), it.second))
}.launchIn(this) }.launchIn(this)
while (isActive) { while (isActive) {
@ -156,7 +157,7 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
val change = changeCollector[name] ?: continue val change = changeCollector[name] ?: continue
if (!change.isEmpty()) { if (!change.isEmpty()) {
mutex.withLock { mutex.withLock {
eventCollector.emit(VisionChangeEvent(name, change.deepCopy(visionManager))) eventCollector.emit(name to change.deepCopy(visionManager))
change.reset() change.reset()
} }
} }

View File

@ -14,6 +14,7 @@ import io.ktor.server.util.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import io.ktor.util.pipeline.* import io.ktor.util.pipeline.*
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -71,14 +72,11 @@ public class VisionRoute(
/** /**
* Serve visions in a given [route] without providing a page template. * Serve visions in a given [route] without providing a page template.
* [visions] could be changed during the service. * [visions] could be changed during the service.
*
* @return a [Flow] of backward events, including vision change events
*/ */
public fun Application.serveVisionData( public fun Application.serveVisionData(
configuration: VisionRoute, configuration: VisionRoute,
onEvent: suspend Vision.(VisionEvent) -> Unit = { event ->
if (event is VisionChangeEvent) {
update(event.change)
}
},
resolveVision: (Name) -> Vision?, resolveVision: (Name) -> Vision?,
) { ) {
require(WebSockets) require(WebSockets)
@ -102,16 +100,17 @@ public fun Application.serveVisionData(
val event = configuration.visionManager.jsonFormat.decodeFromString( val event = configuration.visionManager.jsonFormat.decodeFromString(
VisionEvent.serializer(), data VisionEvent.serializer(), data
) )
vision.onEvent(event)
vision.receiveEvent(event)
} }
} }
try { try {
withContext(configuration.context.coroutineContext) { withContext(configuration.context.coroutineContext) {
vision.flowChanges(configuration.updateInterval.milliseconds).onEach { update -> vision.flowChanges(configuration.updateInterval.milliseconds).onEach { event ->
val json = configuration.visionManager.jsonFormat.encodeToString( val json = configuration.visionManager.jsonFormat.encodeToString(
VisionChange.serializer(), VisionEvent.serializer(),
update event
) )
application.log.debug("Sending update for $name: \n$json") application.log.debug("Sending update for $name: \n$json")
outgoing.send(Frame.Text(json)) outgoing.send(Frame.Text(json))
@ -147,6 +146,8 @@ public fun Application.serveVisionData(
/** /**
* Serve a page, potentially containing any number of visions at a given [route] with given [header]. * Serve a page, potentially containing any number of visions at a given [route] with given [header].
*
* @return a [Flow] containing backward propagated events, including vision change events
*/ */
public fun Application.visionPage( public fun Application.visionPage(
route: String, route: String,
@ -154,7 +155,7 @@ public fun Application.visionPage(
headers: Collection<HtmlFragment>, headers: Collection<HtmlFragment>,
connector: EngineConnectorConfig? = null, connector: EngineConnectorConfig? = null,
visionFragment: HtmlVisionFragment, visionFragment: HtmlVisionFragment,
) { ){
require(WebSockets) require(WebSockets)
val collector: MutableMap<Name, Vision> = mutableMapOf() val collector: MutableMap<Name, Vision> = mutableMapOf()

View File

@ -34,33 +34,33 @@ public fun Vision.colorProperty(
ColorAccessor(properties.root(true), propertyName ?: property.name.asName()) ColorAccessor(properties.root(true), propertyName ?: property.name.asName())
} }
public var ColorAccessor?.string: String? public var ColorAccessor.string: String?
get() = this?.value?.let { if (it == Null) null else it.string } get() = value?.let { if (it == Null) null else it.string }
set(value) { set(value) {
this?.value = value?.asValue() this.value = value?.asValue()
} }
/** /**
* Set [webcolor](https://en.wikipedia.org/wiki/Web_colors) as string * Set [webcolor](https://en.wikipedia.org/wiki/Web_colors) as string
*/ */
public operator fun ColorAccessor?.invoke(webColor: String) { public operator fun ColorAccessor.invoke(webColor: String) {
this?.value = webColor.asValue() value = webColor.asValue()
} }
/** /**
* Set color as RGB integer * Set color as RGB integer
*/ */
public operator fun ColorAccessor?.invoke(rgb: Int) { public operator fun ColorAccessor.invoke(rgb: Int) {
this?.value = Colors.rgbToString(rgb).asValue() value = Colors.rgbToString(rgb).asValue()
} }
/** /**
* Set color as RGB * Set color as RGB
*/ */
public operator fun ColorAccessor?.invoke(r: UByte, g: UByte, b: UByte) { public operator fun ColorAccessor.invoke(r: UByte, g: UByte, b: UByte) {
this?.value = Colors.rgbToString(r, g, b).asValue() value = Colors.rgbToString(r, g, b).asValue()
} }
public fun ColorAccessor?.clear() { public fun ColorAccessor.clear() {
this?.value = null value = null
} }

View File

@ -94,7 +94,7 @@ class SolidPropertyTest {
} }
} }
} }
assertEquals("#555555", box?.color.string) assertEquals("#555555", box?.color?.string)
} }
@Test @Test

View File

@ -31,7 +31,7 @@ class SolidReferenceTest {
fun testReferenceSerialization(){ fun testReferenceSerialization(){
val serialized = Solids.jsonForSolids.encodeToJsonElement(groupWithReference) val serialized = Solids.jsonForSolids.encodeToJsonElement(groupWithReference)
val deserialized = Solids.jsonForSolids.decodeFromJsonElement(SolidGroup.serializer(), serialized) val deserialized = Solids.jsonForSolids.decodeFromJsonElement(SolidGroup.serializer(), serialized)
assertEquals(groupWithReference.items["test"]?.color.string, deserialized.items["test"]?.color.string) assertEquals(groupWithReference.items["test"]?.color?.string, deserialized.items["test"]?.color?.string)
assertEquals("blue", (deserialized.children.getChild("test") as Solid).color.string) assertEquals("blue", (deserialized.children.getChild("test") as Solid).color.string)
} }
} }

View File

@ -28,7 +28,7 @@ internal class VisionUpdateTest {
propertyChanged("top".asName(), SolidMaterial.MATERIAL_COLOR_KEY, Meta("red".asValue())) propertyChanged("top".asName(), SolidMaterial.MATERIAL_COLOR_KEY, Meta("red".asValue()))
propertyChanged("origin".asName(), SolidMaterial.MATERIAL_COLOR_KEY, Meta("red".asValue())) propertyChanged("origin".asName(), SolidMaterial.MATERIAL_COLOR_KEY, Meta("red".asValue()))
} }
targetVision.update(dif) targetVision.receiveChange(dif)
assertTrue { targetVision.children.getChild("top") is SolidGroup } assertTrue { targetVision.children.getChild("top") is SolidGroup }
assertEquals("red", (targetVision.children.getChild("origin") as Solid).color.string) // Should work assertEquals("red", (targetVision.children.getChild("origin") as Solid).color.string) // Should work
assertEquals( assertEquals(

View File

@ -2,7 +2,7 @@ plugins {
id("space.kscience.gradle.mpp") id("space.kscience.gradle.mpp")
} }
val tablesVersion = "0.2.0-dev-4" val tablesVersion = "0.2.1"
kscience { kscience {
jvm() jvm()
@ -22,8 +22,8 @@ kscience {
api("space.kscience:tables-kt:${tablesVersion}") api("space.kscience:tables-kt:${tablesVersion}")
} }
dependencies(jsMain) { dependencies(jsMain) {
implementation(npm("tabulator-tables", "5.4.4")) implementation(npm("tabulator-tables", "5.5.2"))
implementation(npm("@types/tabulator-tables", "5.4.8")) implementation(npm("@types/tabulator-tables", "5.5.3"))
} }
useSerialization() useSerialization()
} }