package org.openrndr.extra.gui import com.google.gson.Gson import com.google.gson.reflect.TypeToken import org.openrndr.Extension import org.openrndr.KEY_F11 import org.openrndr.KEY_LEFT_SHIFT import org.openrndr.KeyModifier import org.openrndr.Program import org.openrndr.color.ColorRGBa import org.openrndr.dialogs.openFileDialog import org.openrndr.dialogs.saveFileDialog import org.openrndr.draw.Drawer import org.openrndr.extra.parameters.* import org.openrndr.internal.Driver import org.openrndr.math.Vector2 import org.openrndr.panel.ControlManager import org.openrndr.panel.controlManager import org.openrndr.panel.elements.* import org.openrndr.panel.style.* import java.io.File import kotlin.math.roundToInt import kotlin.reflect.KMutableProperty1 /** Dear contributor, just in case you are here looking to add a new parameter type. There is a 6-step incantation to add a new parameter type 0) Add your parameter type to orx-parameters, follow the instructions provided there. 1) Setup a control style, very likely analogous to the styles already in place. 2) Add control creation code. 3) Add value serialization code, may need to update ParameterValue too. 4) Add value deserialization code. 5) Add value randomization code. 6) Add control update code. You can use your editor's search functionality to jump to "1)", "2)". */ private data class LabeledObject(val label: String, val obj: Any) private class CompartmentState(var collapsed: Boolean, val parameterValues: MutableMap = mutableMapOf()) private class SidebarState(var hidden: Boolean = false, var collapsed: Boolean = false, var scrollTop: Double = 0.0) private class TrackedObjectBinding( val parameters: List, val parameterControls: MutableMap = mutableMapOf() ) private val persistentCompartmentStates = mutableMapOf>() private val persistentSidebarStates = mutableMapOf() private fun sidebarState(): SidebarState = persistentSidebarStates.getOrPut(Driver.instance.contextID) { SidebarState() } private fun getPersistedOrDefault(compartmentLabel: String, property: KMutableProperty1, obj: Any): T? { val state = persistentCompartmentStates[Driver.instance.contextID]!![compartmentLabel] if (state == null) { return property.get(obj) } else { @Suppress("UNCHECKED_CAST") return (state.parameterValues[property.name] as? T?) ?: return property.get(obj) } } private fun setAndPersist(compartmentLabel: String, property: KMutableProperty1, obj: Any, value: T) { property.set(obj, value) val state = persistentCompartmentStates[Driver.instance.contextID]!![compartmentLabel]!! state.parameterValues[property.name] = value } @Suppress("unused", "UNCHECKED_CAST") class GUI : Extension { private var onChangeListener: ((name: String, value: Any?) -> Unit)? = null override var enabled = true var compartmentsCollapsedByDefault = true var doubleBind = false private lateinit var panel: ControlManager // Randomize button private var shiftDown = false private var randomizeButton: Button? = null // FIXME should this be null or is there a better way? fun onChange(listener: (name: String, value: Any?) -> Unit) { onChangeListener = listener } override fun setup(program: Program) { program.keyboard.keyDown.listen { if (it.key == KEY_F11) { enabled = !enabled panel.enabled = enabled sidebarState().hidden = !enabled } if (it.key == KEY_LEFT_SHIFT) { shiftDown = true randomizeButton!!.classes.add(ElementClass("randomize-strong")) } } program.keyboard.keyUp.listen { if (it.key == KEY_LEFT_SHIFT) { shiftDown = false randomizeButton!!.classes.remove(ElementClass("randomize-strong")) } } panel = program.controlManager { styleSheet(has class_ "container") { this.display = Display.FLEX this.flexDirection = FlexDirection.Column this.width = 200.px this.height = 100.percent } styleSheet(has class_ "collapse-border") { this.display = Display.FLEX this.flexDirection = FlexDirection.Column this.height = 5.px this.width = 100.percent this.background = Color.RGBa(ColorRGBa.GRAY.shade(0.9)) and(has state "hover") { this.background = Color.RGBa(ColorRGBa.GRAY.shade(1.1)) } } styleSheet(has class_ "toolbar") { this.height = 42.px this.width = 100.percent this.display = Display.FLEX this.flexDirection = FlexDirection.Row this.background = Color.RGBa(ColorRGBa.GRAY.copy(a = 0.99)) } styleSheet(has class_ "collapsed") { this.display = Display.NONE } styleSheet(has class_ "compartment") { this.paddingBottom = 20.px } styleSheet(has class_ "sidebar") { this.width = 200.px this.paddingBottom = 20.px this.paddingTop = 10.px this.paddingLeft = 10.px this.paddingRight = 10.px this.marginRight = 2.px this.height = 100.percent this.background = Color.RGBa(ColorRGBa.GRAY.copy(a = 0.99)) this.overflow = Overflow.Scroll /* 1) setup control style */ descendant(has type "colorpicker-button") { this.width = 175.px } descendant(has type "slider") { this.width = 175.px } descendant(has type "button") { this.width = 175.px } descendant(has type "textfield") { this.width = 175.px } descendant(has type "toggle") { this.width = 175.px } descendant(has type "vector2") { this.width = 175.px this.height = 175.px } } styleSheet(has class_ "randomize-strong") { color = Color.RGBa(ColorRGBa.PINK) and(has state "hover") { color = Color.RGBa(ColorRGBa.BLACK) background = Color.RGBa(ColorRGBa.PINK) } } styleSheet(has type "dropdown-button") { this.width = 175.px } layout { div("container") { id = "container" val header = div("toolbar") { randomizeButton = button { label = "Randomize" clicked { randomize(strength = if (shiftDown) .75 else .05) } } button { label = "Load" clicked { openFileDialog(supportedExtensions = listOf("json")) { loadParameters(it) } } } button { label = "Save" clicked { saveFileDialog(supportedExtensions = listOf("json")) { saveParameters(it) } } } } val collapseBorder = div("collapse-border") { } val collapsibles = mutableSetOf
() val sidebar = div("sidebar") { id = "sidebar" scrollTop = sidebarState().scrollTop for ((labeledObject, binding) in trackedObjects) { val (label, _) = labeledObject val header = h3 { label } val collapsible = div("compartment") { for (parameter in binding.parameters) { val element = addControl(labeledObject, parameter) binding.parameterControls[parameter] = element } } collapsibles.add(collapsible) val collapseClass = ElementClass("collapsed") /* this is guaranteed to be in the dictionary after insertion through add() */ val collapseState = persistentCompartmentStates[Driver.instance.contextID]!![label]!! if (collapseState.collapsed) { collapsible.classes.add(collapseClass) } header.mouse.pressed.subscribe { it.cancelPropagation() } header.mouse.clicked.subscribe { if (KeyModifier.CTRL in it.modifiers) { collapsible.classes.remove(collapseClass) persistentCompartmentStates[Driver.instance.contextID]!!.forEach { it.value.collapsed = true } collapseState.collapsed = false (collapsibles - collapsible).forEach { it.classes.add(collapseClass) } } else { if (collapseClass in collapsible.classes) { collapsible.classes.remove(collapseClass) collapseState.collapsed = false } else { collapsible.classes.add(collapseClass) collapseState.collapsed = true } } } } } collapseBorder.mouse.pressed.subscribe { it.cancelPropagation() } collapseBorder.mouse.clicked.subscribe { val collapsed = ElementClass("collapsed") if (collapsed in sidebar.classes) { sidebar.classes.remove(collapsed) sidebarState().collapsed = false } else { sidebar.classes.add(collapsed) sidebarState().collapsed = true } it.cancelPropagation() } sidebar.mouse.scrolled.subscribe { sidebarState().scrollTop = sidebar.scrollTop } if (sidebarState().collapsed) { sidebar.classes.add(ElementClass("collapsed")) } sidebar.scrollTop = sidebarState().scrollTop } } } if (sidebarState().hidden) { enabled = false panel.enabled = false } else { enabled = true panel.enabled = true } program.extend(panel) } private fun Div.addControl(compartment: LabeledObject, parameter: Parameter): Element { val obj = compartment.obj return when (parameter.parameterType) { /* 2) control creation. create control, set label, set range, setup event-handler, load values */ ParameterType.Int -> { slider { label = parameter.label range = Range(parameter.intRange!!.first.toDouble(), parameter.intRange!!.last.toDouble()) precision = 0 events.valueChanged.subscribe { setAndPersist(compartment.label, parameter.property as KMutableProperty1, obj, it.newValue.toInt()) (parameter.property as KMutableProperty1).set(obj, value.toInt()) onChangeListener?.invoke(parameter.property!!.name, it.newValue) } getPersistedOrDefault(compartment.label, parameter.property as KMutableProperty1, obj)?.let { value = it.toDouble() setAndPersist(compartment.label, parameter.property as KMutableProperty1, obj, it) } } } ParameterType.Double -> { slider { label = parameter.label range = Range(parameter.doubleRange!!.start, parameter.doubleRange!!.endInclusive) precision = parameter.precision!! events.valueChanged.subscribe { setAndPersist(compartment.label, parameter.property as KMutableProperty1, obj, it.newValue) onChangeListener?.invoke(parameter.property!!.name, it.newValue) } getPersistedOrDefault(compartment.label, parameter.property as KMutableProperty1, obj)?.let { value = it /* this is generally not needed, but when the persisted value is equal to the slider default it will not emit the newly set value */ setAndPersist(compartment.label, parameter.property as KMutableProperty1, obj, it) } } } ParameterType.Action -> { button { label = parameter.label events.clicked.subscribe { /* the `obj` we pass in here is the receiver */ parameter.function!!.call(obj) onChangeListener?.invoke(parameter.function!!.name, null) } } } ParameterType.Boolean -> { toggle { label = parameter.label events.valueChanged.subscribe { value = it.newValue setAndPersist(compartment.label, parameter.property as KMutableProperty1, obj, it.newValue) onChangeListener?.invoke(parameter.property!!.name, it.newValue) } getPersistedOrDefault(compartment.label, parameter.property as KMutableProperty1, obj)?.let { value = it setAndPersist(compartment.label, parameter.property as KMutableProperty1, obj, it) } } } ParameterType.Text -> { textfield { label = parameter.label events.valueChanged.subscribe { setAndPersist(compartment.label, parameter.property as KMutableProperty1, obj, it.newValue) onChangeListener?.invoke(parameter.property!!.name, it.newValue) } getPersistedOrDefault(compartment.label, parameter.property as KMutableProperty1, obj)?.let { value = it } } } ParameterType.Color -> { colorpickerButton { label = parameter.label events.valueChanged.subscribe { setAndPersist( compartment.label, parameter.property as KMutableProperty1, obj, it.color ) onChangeListener?.invoke(parameter.property!!.name, it.color) } getPersistedOrDefault( compartment.label, parameter.property as KMutableProperty1, obj )?.let { color = it } } } ParameterType.Vector2 -> { planarPad { minX = parameter.vectorRange!!.first.x minY = parameter.vectorRange!!.first.y maxX = parameter.vectorRange!!.second.x maxY = parameter.vectorRange!!.second.y events.valueChanged.subscribe { setAndPersist( compartment.label, parameter.property as KMutableProperty1, obj, it.newValue ) onChangeListener?.invoke(parameter.property!!.name, it.newValue) } } } } } private val trackedObjects = mutableMapOf() private fun updateControls() { for ((labeledObject, binding) in trackedObjects) { for ((parameter, control) in binding.parameterControls) { updateControl(labeledObject, parameter, control) } } } private class ParameterValue(var doubleValue: Double? = null, var intValue: Int? = null, var booleanValue: Boolean? = null, var colorValue: ColorRGBa? = null, var vectorValue: Vector2? = null, var textValue: String? = null) fun saveParameters(file: File) { fun KMutableProperty1?.qget(obj: Any): T { return (this as KMutableProperty1).get(obj) } val toSave = trackedObjects.entries.associate { (lo, b) -> Pair(lo.label, b.parameterControls.keys.associate { k -> Pair(k.property?.name ?: k.function?.name ?: error("no name"), when (k.parameterType) { /* 3) setup serializers */ ParameterType.Double -> ParameterValue(doubleValue = k.property.qget(lo.obj) as Double) ParameterType.Int -> ParameterValue(intValue = k.property.qget(lo.obj) as Int) ParameterType.Action -> ParameterValue() ParameterType.Color -> ParameterValue(colorValue = k.property.qget(lo.obj) as ColorRGBa) ParameterType.Text -> ParameterValue(textValue = k.property.qget(lo.obj) as String) ParameterType.Boolean -> ParameterValue(booleanValue = k.property.qget(lo.obj) as Boolean) ParameterType.Vector2 -> ParameterValue(vectorValue = k.property.qget(lo.obj) as Vector2) }) }) } file.writeText(Gson().toJson(toSave)) } fun loadParameters(file: File) { fun KMutableProperty1?.qset(obj: Any, value: T) { return (this as KMutableProperty1).set(obj, value) } val json = file.readText() val typeToken = object : TypeToken>>() {} val labeledValues: Map> = Gson().fromJson(json, typeToken.type) labeledValues.forEach { (label, ps) -> trackedObjects.keys.find { it.label == label }?.let { lo -> val binding = trackedObjects[lo]!! ps.forEach { (parameterName, parameterValue) -> binding.parameters.find { it.property?.name == parameterName }?.let { parameter -> when (parameter.parameterType) { /* 4) Set up deserializers */ ParameterType.Double -> parameterValue.doubleValue?.let { parameter.property.qset(lo.obj, it) } ParameterType.Int -> parameterValue.intValue?.let { parameter.property.qset(lo.obj, it) } ParameterType.Text -> parameterValue.textValue?.let { parameter.property.qset(lo.obj, it) } ParameterType.Color -> parameterValue.colorValue?.let { parameter.property.qset(lo.obj, it) } ParameterType.Vector2 -> parameterValue.vectorValue?.let { parameter.property.qset(lo.obj, it) } ParameterType.Boolean -> parameterValue.booleanValue?.let { parameter.property.qset(lo.obj, it) } ParameterType.Action -> { // intentionally do nothing } } } } } } updateControls() } private fun updateControl(labeledObject: LabeledObject, parameter: Parameter, control: Element) { when (parameter.parameterType) { /* 5) Update control from property value */ ParameterType.Double -> { (control as Slider).value = (parameter.property as KMutableProperty1).get(labeledObject.obj) } ParameterType.Int -> { (control as Slider).value = (parameter.property as KMutableProperty1).get(labeledObject.obj).toDouble() } ParameterType.Text -> { (control as Textfield).value = (parameter.property as KMutableProperty1).get(labeledObject.obj) } ParameterType.Color -> { (control as ColorpickerButton).color = (parameter.property as KMutableProperty1).get(labeledObject.obj) } // ParameterType.Vector2 -> { // (control as Vector2Control).value = (parameter.property as KMutableProperty1).get(labeledObject.obj) // } ParameterType.Boolean -> { (control as Toggle).value = (parameter.property as KMutableProperty1).get(labeledObject.obj) } ParameterType.Action -> { // intentionally do nothing } } } fun randomize(strength:Double = 0.05) { for ((labeledObject, binding) in trackedObjects) { // -- only randomize visible parameters for (parameter in binding.parameterControls.keys) { when (parameter.parameterType) { /* 6) Set up value randomizers */ ParameterType.Double -> { val min = parameter.doubleRange!!.start val max = parameter.doubleRange!!.endInclusive val currentValue = (parameter.property as KMutableProperty1).get(labeledObject.obj) val randomValue = Math.random() * (max - min) + min val newValue = (1.0 - strength) * currentValue + randomValue * strength (parameter.property as KMutableProperty1).set(labeledObject.obj, newValue) } ParameterType.Int -> { val min = parameter.intRange!!.first val max = parameter.intRange!!.last val currentValue = (parameter.property as KMutableProperty1).get(labeledObject.obj) val randomValue = Math.random() * (max - min) + min val newValue = ((1.0 - strength) * currentValue + randomValue * strength).roundToInt() (parameter.property as KMutableProperty1).set(labeledObject.obj, newValue) } ParameterType.Boolean -> { //I am not sure about randomizing boolean values here //(parameter.property as KMutableProperty1).set(labeledObject.obj, (Math.random() < 0.5)) } ParameterType.Color -> { val currentValue = (parameter.property as KMutableProperty1).get(labeledObject.obj) val randomValue = ColorRGBa(Math.random(), Math.random(), Math.random(), currentValue.a) val newValue = ColorRGBa((1.0 - strength) * currentValue.r + randomValue.r * strength, (1.0 - strength) * currentValue.g + randomValue.g * strength, (1.0 - strength) * currentValue.b + randomValue.b * strength) (parameter.property as KMutableProperty1).set(labeledObject.obj, newValue) } else -> { // intentionally do nothing } } } } updateControls() } /** * Recursively find a unique label * @param label to find an alternate for in case it already exist */ private fun resolveUniqueLabel(label: String): String { return trackedObjects.keys.find { it.label == label }?.let { lo -> resolveUniqueLabel(Regex("(.*) / ([0-9]+)").matchEntire(lo.label)?.let { "${it.groupValues[1]} / ${1 + it.groupValues[2].toInt()}" } ?: "$label / 2") } ?: label } /** * Add an object to the GUI * @param objectWithParameters an object of a class that annotated parameters * @param label an optional label that overrides the label supplied in a [Description] annotation * @return pass-through of [objectWithParameters] */ fun add(objectWithParameters: T, label: String? = objectWithParameters.title()): T { val parameters = objectWithParameters.listParameters() val uniqueLabel = resolveUniqueLabel(label ?: "No name") if (parameters.isNotEmpty()) { val collapseStates = persistentCompartmentStates.getOrPut(Driver.instance.contextID) { mutableMapOf() } collapseStates.getOrPut(uniqueLabel) { CompartmentState(compartmentsCollapsedByDefault) } trackedObjects[LabeledObject(uniqueLabel, objectWithParameters)] = TrackedObjectBinding(parameters) } return objectWithParameters } /** * Add an object to the GUI using a builder. * @param label an optional label that overrides the label supplied in a [Description] annotation * @return the built object */ fun add(label: String? = null, builder: () -> T): T { val t = builder() return add(t, label ?: t.title()) } override fun afterDraw(drawer: Drawer, program: Program) { if (doubleBind) { updateControls() } } } @JvmName("addToGui") fun T.addTo(gui: GUI, label:String? = this.title()): T { gui.add(this, label) return this }