Files
orx/orx-gui/src/main/kotlin/Gui.kt
2020-03-17 22:26:23 +01:00

653 lines
28 KiB
Kotlin

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<String, Any> = mutableMapOf())
private class SidebarState(var hidden: Boolean = false, var collapsed: Boolean = false, var scrollTop: Double = 0.0)
private class TrackedObjectBinding(
val parameters: List<Parameter>,
val parameterControls: MutableMap<Parameter, Element> = mutableMapOf()
)
private val persistentCompartmentStates = mutableMapOf<Long, MutableMap<String, CompartmentState>>()
private val persistentSidebarStates = mutableMapOf<Long, SidebarState>()
private fun sidebarState(): SidebarState = persistentSidebarStates.getOrPut(Driver.instance.contextID) {
SidebarState()
}
private fun <T : Any> getPersistedOrDefault(compartmentLabel: String, property: KMutableProperty1<Any, T>, 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 <T : Any> setAndPersist(compartmentLabel: String, property: KMutableProperty1<Any, T>, 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<Div>()
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<Any, Int>, obj, it.newValue.toInt())
(parameter.property as KMutableProperty1<Any, Int>).set(obj, value.toInt())
onChangeListener?.invoke(parameter.property!!.name, it.newValue)
}
getPersistedOrDefault(compartment.label, parameter.property as KMutableProperty1<Any, Int>, obj)?.let {
value = it.toDouble()
setAndPersist(compartment.label, parameter.property as KMutableProperty1<Any, Int>, 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<Any, Double>, obj, it.newValue)
onChangeListener?.invoke(parameter.property!!.name, it.newValue)
}
getPersistedOrDefault(compartment.label, parameter.property as KMutableProperty1<Any, Double>, 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<Any, Double>, 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<Any, Boolean>, obj, it.newValue)
onChangeListener?.invoke(parameter.property!!.name, it.newValue)
}
getPersistedOrDefault(compartment.label, parameter.property as KMutableProperty1<Any, Boolean>, obj)?.let {
value = it
setAndPersist(compartment.label, parameter.property as KMutableProperty1<Any, Boolean>, obj, it)
}
}
}
ParameterType.Text -> {
textfield {
label = parameter.label
events.valueChanged.subscribe {
setAndPersist(compartment.label, parameter.property as KMutableProperty1<Any, String>, obj, it.newValue)
onChangeListener?.invoke(parameter.property!!.name, it.newValue)
}
getPersistedOrDefault(compartment.label, parameter.property as KMutableProperty1<Any, String>, obj)?.let {
value = it
}
}
}
ParameterType.Color -> {
colorpickerButton {
label = parameter.label
events.valueChanged.subscribe {
setAndPersist(
compartment.label,
parameter.property as KMutableProperty1<Any, ColorRGBa>,
obj,
it.color
)
onChangeListener?.invoke(parameter.property!!.name, it.color)
}
getPersistedOrDefault(
compartment.label,
parameter.property as KMutableProperty1<Any, ColorRGBa>,
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<Any, Vector2>,
obj,
it.newValue
)
onChangeListener?.invoke(parameter.property!!.name, it.newValue)
}
}
}
}
}
private val trackedObjects = mutableMapOf<LabeledObject, TrackedObjectBinding>()
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 <T> KMutableProperty1<out Any, Any?>?.qget(obj: Any): T {
return (this as KMutableProperty1<Any, T>).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 <T> KMutableProperty1<out Any, Any?>?.qset(obj: Any, value: T) {
return (this as KMutableProperty1<Any, T>).set(obj, value)
}
val json = file.readText()
val typeToken = object : TypeToken<Map<String, Map<String, ParameterValue>>>() {}
val labeledValues: Map<String, Map<String, ParameterValue>> = 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<Any, Double>).get(labeledObject.obj)
}
ParameterType.Int -> {
(control as Slider).value = (parameter.property as KMutableProperty1<Any, Int>).get(labeledObject.obj).toDouble()
}
ParameterType.Text -> {
(control as Textfield).value = (parameter.property as KMutableProperty1<Any, String>).get(labeledObject.obj)
}
ParameterType.Color -> {
(control as ColorpickerButton).color = (parameter.property as KMutableProperty1<Any, ColorRGBa>).get(labeledObject.obj)
}
// ParameterType.Vector2 -> {
// (control as Vector2Control).value = (parameter.property as KMutableProperty1<Any, Vector2>).get(labeledObject.obj)
// }
ParameterType.Boolean -> {
(control as Toggle).value = (parameter.property as KMutableProperty1<Any, Boolean>).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<Any, Double>).get(labeledObject.obj)
val randomValue = Math.random() * (max - min) + min
val newValue = (1.0 - strength) * currentValue + randomValue * strength
(parameter.property as KMutableProperty1<Any, Double>).set(labeledObject.obj, newValue)
}
ParameterType.Int -> {
val min = parameter.intRange!!.first
val max = parameter.intRange!!.last
val currentValue = (parameter.property as KMutableProperty1<Any, Int>).get(labeledObject.obj)
val randomValue = Math.random() * (max - min) + min
val newValue = ((1.0 - strength) * currentValue + randomValue * strength).roundToInt()
(parameter.property as KMutableProperty1<Any, Int>).set(labeledObject.obj, newValue)
}
ParameterType.Boolean -> {
//I am not sure about randomizing boolean values here
//(parameter.property as KMutableProperty1<Any, Boolean>).set(labeledObject.obj, (Math.random() < 0.5))
}
ParameterType.Color -> {
val currentValue = (parameter.property as KMutableProperty1<Any, ColorRGBa>).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<Any, ColorRGBa>).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 <T : Any> 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 <T : Any> 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 : Any> T.addTo(gui: GUI, label:String? = this.title()): T {
gui.add(this, label)
return this
}