From d51c460fe795659604618e0751d431fa0480c6f2 Mon Sep 17 00:00:00 2001 From: rnentjes Date: Thu, 27 Jun 2024 12:29:50 +0200 Subject: [PATCH] Initial commit --- build.gradle.kts | 7 +- settings.gradle.kts | 2 +- .../nl/astraeus/vst/util/FormatFloat.kt | 86 +++++ .../nl/astraeus/vst/util/SVGFunctions.kt | 158 ++++++++++ .../kotlin/nl/astraeus/vst/Externals.kt | 43 --- .../vst/ui/components/BaseKnobComponent.kt | 296 ++++++++++++++++++ .../vst/ui/components/ExpKnobComponent.kt | 37 +++ .../vst/ui/components/KnobComponent.kt | 36 +++ .../kotlin/nl/astraeus/vst/ui/css/Css.kt | 121 +++++++ .../kotlin/nl/astraeus/vst/ui/css/CssName.kt | 78 +++++ 10 files changed, 819 insertions(+), 45 deletions(-) create mode 100644 src/commonMain/kotlin/nl/astraeus/vst/util/FormatFloat.kt create mode 100644 src/commonMain/kotlin/nl/astraeus/vst/util/SVGFunctions.kt delete mode 100644 src/jsMain/kotlin/nl/astraeus/vst/Externals.kt create mode 100644 src/jsMain/kotlin/nl/astraeus/vst/ui/components/BaseKnobComponent.kt create mode 100644 src/jsMain/kotlin/nl/astraeus/vst/ui/components/ExpKnobComponent.kt create mode 100644 src/jsMain/kotlin/nl/astraeus/vst/ui/components/KnobComponent.kt create mode 100644 src/jsMain/kotlin/nl/astraeus/vst/ui/css/Css.kt create mode 100644 src/jsMain/kotlin/nl/astraeus/vst/ui/css/CssName.kt diff --git a/build.gradle.kts b/build.gradle.kts index 8c286f8..8ae307a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,9 +23,14 @@ kotlin { sourceSets { val commonMain by getting { dependencies { + api("nl.astraeus:kotlin-css-generator:1.0.7") + } + } + val jsMain by getting { + dependencies { + implementation("nl.astraeus:kotlin-komponent-js:1.2.2") } } - val jsMain by getting val jsTest by getting { dependencies { implementation(kotlin("test-js")) diff --git a/settings.gradle.kts b/settings.gradle.kts index 94df3f5..882778a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,5 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" } -rootProject.name = "vst-worklet-base" +rootProject.name = "vst-ui-base" diff --git a/src/commonMain/kotlin/nl/astraeus/vst/util/FormatFloat.kt b/src/commonMain/kotlin/nl/astraeus/vst/util/FormatFloat.kt new file mode 100644 index 0000000..63f170e --- /dev/null +++ b/src/commonMain/kotlin/nl/astraeus/vst/util/FormatFloat.kt @@ -0,0 +1,86 @@ +package nl.astraeus.vst.util + +/** + * User: rnentjes + * Date: 28-3-16 + * Time: 14:12 + */ + +fun formatFloat(value: Float, decimals: Int): String { + val valueString = value.toString() + + var result = "" + var foundDot = false + var foundDecimals = 0 + + for (index in valueString.indices) { + if (foundDot) { + foundDecimals++ + } + + if (decimals == 0 && valueString[index] == '.') { + return result + } + + if (foundDecimals > decimals) { + return result + } else { + result = result + valueString[index] + } + + if (!foundDot) { + foundDot = valueString[index] == '.' + } + } + + if (!foundDot && decimals > 0) { + result += "." + } + + while (decimals > 0 && foundDecimals < decimals) { + result += "0" + foundDecimals++ + } + + return result +} + + +fun formatDouble(value: Double, decimals: Int): String { + val valueString = value.toString() + + var result = "" + var foundDot = false + var foundDecimals = 0 + + for (index in valueString.indices) { + if (foundDot) { + foundDecimals++ + } + + if (decimals == 0 && valueString[index] == '.') { + return result + } + + if (foundDecimals > decimals) { + return result + } else { + result = result + valueString[index] + } + + if (!foundDot) { + foundDot = valueString[index] == '.' + } + } + + if (!foundDot && decimals > 0) { + result += "." + } + + while (decimals > 0 && foundDecimals < decimals) { + result += "0" + foundDecimals++ + } + + return result +} diff --git a/src/commonMain/kotlin/nl/astraeus/vst/util/SVGFunctions.kt b/src/commonMain/kotlin/nl/astraeus/vst/util/SVGFunctions.kt new file mode 100644 index 0000000..4fd946c --- /dev/null +++ b/src/commonMain/kotlin/nl/astraeus/vst/util/SVGFunctions.kt @@ -0,0 +1,158 @@ +package nl.astraeus.vst.util + +import kotlinx.html.SVG +import kotlinx.html.unsafe +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +fun SVG.width(width: Int) { + this.attributes["width"] = "$width" +} + +fun SVG.height(height: Int) { + this.attributes["height"] = "$height" +} + +fun SVG.svgStyle( + name: String, + vararg props: Pair +) { + val result = StringBuilder() + result.append("\n") + unsafe { + + "$result" + } +} + +fun SVG.rect( + x: Int, + y: Int, + width: Int, + height: Int, + rx: Int, + cls: String +) { + this.unsafe { + + """ + + """.trimIndent() + } +} + +fun SVG.circle( + x: Int, + y: Int, + radius: Int, + cls: String +) { + this.unsafe { + + """ + + """.trimIndent() + } +} + +fun SVG.control( + pnt: Pair, + cls: String +) { + circle(pnt.first, pnt.second, 4, cls) +} + +private val Pair.crds: String + get() = "${this.first},${this.second}" + +// https://svg-path-visualizer.netlify.app/bezier-curve/ +fun SVG.curve( + start: Pair, + control1: Pair, + control2: Pair, + end: Pair, + smoothEnd: Pair? = null, + vararg smooth: Pair, + cls: String +) { + val smoothStr = StringBuilder() + for (crd in smooth) { + smoothStr.append("S ") + smoothStr.append(crd.crds) + smoothStr.append(" ") + } + val smoothEndStr = if (smoothEnd != null) { + smoothEnd.crds + } else { + "" + } + + this.unsafe { + + """ + + """.trimIndent() + } + + this.control(start, cls = cls) + this.control(control1, cls = cls) + this.control(control2, cls = cls) + this.control(end, cls = cls) + for (sm in smooth) { + this.control(sm, cls = cls) + } + if (smoothEnd != null) { + this.control(smoothEnd, cls = cls) + } + +} + +/* + +"""""" + */ +fun SVG.arc( + x: Int, + y: Int, + radius: Int, + startAngle: Int, + endAngle: Int, + cls: String +) { + val start = polarToCartesian(x, y, radius, endAngle) + val end = polarToCartesian(x, y, radius, startAngle) + + val largeArcFlag = if (endAngle - startAngle <= 180) { + "0" + } else { + "1" + } + + unsafe { + +"""""" + } +} + +private fun polarToCartesian( + centerX: Int, + centerY: Int, + radius: Int, + angleInDegrees: Int +): Pair { + val angleInRadians = (angleInDegrees - 90).toDouble() * PI / 180.0 + + return Pair( + centerX + (radius * cos(angleInRadians)), + centerY + (radius * sin(angleInRadians)) + ) +} diff --git a/src/jsMain/kotlin/nl/astraeus/vst/Externals.kt b/src/jsMain/kotlin/nl/astraeus/vst/Externals.kt deleted file mode 100644 index ccbe28c..0000000 --- a/src/jsMain/kotlin/nl/astraeus/vst/Externals.kt +++ /dev/null @@ -1,43 +0,0 @@ -package nl.astraeus.vst - -import org.khronos.webgl.Float32Array -import org.w3c.dom.MessagePort - -enum class AutomationRate( - val rate: String -) { - A_RATE("a-rate"), - K_RATE("k-rate") -} - -interface AudioParam { - var value: Double - var automationRate: AutomationRate - val defaultValue: Double - val minValue: Double - val maxValue: Double -} - -interface AudioParamMap { - operator fun get(name: String): AudioParam -} - -abstract external class AudioWorkletProcessor { - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/AudioWorkletNode/parameters) */ - //val parameters: AudioParamMap; - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/AudioWorkletNode/port) */ - @JsName("port") - val port: MessagePort - - @JsName("process") - open fun process ( - inputs: Array>, - outputs: Array>, - parameters: dynamic - ) : Boolean { definedExternally } - -} - -external fun registerProcessor(name: String, processorCtor: JsClass<*>) -external val sampleRate: Int -external val currentTime: Double diff --git a/src/jsMain/kotlin/nl/astraeus/vst/ui/components/BaseKnobComponent.kt b/src/jsMain/kotlin/nl/astraeus/vst/ui/components/BaseKnobComponent.kt new file mode 100644 index 0000000..34c6fda --- /dev/null +++ b/src/jsMain/kotlin/nl/astraeus/vst/ui/components/BaseKnobComponent.kt @@ -0,0 +1,296 @@ +package nl.astraeus.vst.ui.components + +import kotlinx.html.classes +import kotlinx.html.js.onMouseDownFunction +import kotlinx.html.js.onMouseUpFunction +import kotlinx.html.js.onMouseWheelFunction +import kotlinx.html.span +import kotlinx.html.style +import kotlinx.html.svg +import nl.astraeus.css.properties.* +import nl.astraeus.css.style.cls +import nl.astraeus.komp.HtmlBuilder +import nl.astraeus.komp.Komponent +import nl.astraeus.vst.ui.css.ActiveCls +import nl.astraeus.vst.ui.css.Css +import nl.astraeus.vst.ui.css.Css.defineCss +import nl.astraeus.vst.ui.css.CssId +import nl.astraeus.vst.ui.css.CssName +import nl.astraeus.vst.util.arc +import nl.astraeus.vst.util.height +import nl.astraeus.vst.util.width +import org.w3c.dom.events.MouseEvent +import org.w3c.dom.events.WheelEvent +import kotlin.math.PI +import kotlin.math.max +import kotlin.math.min + +/** + * User: rnentjes + * Date: 26-11-17 + * Time: 16:52 + */ + +private const val START_ANGLE_DEG = 230 +private const val END_ANGLE_DEG = 130 +private const val ANGLE_RANGE_DEG = 260 + +private const val START_ANGLE = PI * START_ANGLE_DEG / 180.toFloat() - PI / 2 +private const val END_ANGLE = PI * END_ANGLE_DEG / 180.toFloat() - PI / 2 +private const val ANGLE_RANGE = PI * ANGLE_RANGE_DEG / 180.toFloat() + +open class BaseKnobComponent( + val value: Double, + val label: String, + val minValue: Double, + val maxValue: Double, + val step: Double, + val discrete: Boolean, + val width: Int, + val height: Int, + val pixelStep: Double, + val valueToActual: (Double) -> Double, + val actualToValue: (Double) -> Double, + val renderer: (Double) -> String, + val callback: (Double) -> Unit +) : Komponent() { + val actualMinimumValue = valueToActual(minValue) + val actualMaximumValue = valueToActual(maxValue) + var actualValue = valueToActual(value) + + var activated = false + var mouseX = 0.0 + var mouseY = 0.0 + var startValue = 0.0 + + private fun getMiddleX() = width / 2 + private fun getMiddleY() = ((height - 16) / 2) + 16 + private fun getRadius() = min(getMiddleX(), getMiddleY() - 16) - 5 + + override fun HtmlBuilder.render() { + span(KnobCls.name) { + style = "width: ${width}px; height: ${height}px" + + svg(KnobSvgCls.name) { + if (activated) { + classes = classes + ActiveCls.name + } + + width(width) + height(height) + + val middle = ( + ((ANGLE_RANGE_DEG.toFloat() * + (actualValue - actualMinimumValue)) / + (actualMaximumValue - actualMinimumValue) + + START_ANGLE_DEG.toFloat()).toInt() + ) + + val middleX = getMiddleX() + val middleY = getMiddleY() + val radius = getRadius() + + if (middle < 360) { + arc( + middleX, + middleY, + radius, + START_ANGLE_DEG, + middle, + KnobVolumeCls.name + ) + arc( + middleX, + middleY, + radius, + middle, + 360, + KnobVolumeBackgroundCls.name + ) + arc( + middleX, + middleY, + radius, + 0, + END_ANGLE_DEG, + KnobVolumeBackgroundCls.name + ) + } else { + arc( + middleX, + middleY, + radius, + START_ANGLE_DEG, + middle, + KnobVolumeCls.name + ) + arc( + middleX, + middleY, + radius, + middle, + END_ANGLE_DEG, + KnobVolumeBackgroundCls.name + ) + } + + } + + span(KnobTextCls.name) { + +label + } + + val renderedValue = renderer(actualToValue(actualValue)) + span(KnobValueCls.name) { + +renderedValue + } + + onMouseWheelFunction = { + if (it is WheelEvent) { + val delta = if (it.deltaY > 0) { + 1.0 + } else { + -1.0 + } //it.deltaY / 250.0 + + var newValue = actualValue - delta * step + + newValue = min(newValue, actualMaximumValue) + newValue = max(newValue, actualMinimumValue) + + actualValue = newValue + + callback(actualToValue(newValue)) + + requestUpdate() + + it.preventDefault() + } + } + +/* onMouseDownFunction = { + if (it is MouseEvent) { + activated = true + mouseX = it.clientX.toDouble() + mouseY = it.clientY.toDouble() + startValue = actualValue + + mainView.globalMouseListener = { me -> + if (activated && me.buttons == 1.toShort()) { + setValueByMouseDelta(me, actualMinimumValue, actualMaximumValue, callback) + } + } + + requestUpdate() + } + } + + onMouseUpFunction = { + if (it is MouseEvent) { + activated = false + mainView.globalMouseListener = null + requestUpdate() + } + }*/ + } + } + + private fun setValueByMouseDelta( + it: MouseEvent, + minValue: Double = 0.0, + maxValue: Double = 5.0, + callback: (value: Double) -> Unit + ) { + val deltaX = it.clientX.toDouble() - mouseX + val deltaY = it.clientY.toDouble() - mouseY + var length = - deltaX + deltaY + + if (it.offsetX < mouseX || it.offsetY < mouseY) { + length = -length + } + + var value = startValue + length * pixelStep + + if (discrete) { + value -= (value % step) + } + + value = max(value, minValue) + value = min(value, maxValue) + + actualValue = value + callback(actualToValue(value)) + + requestUpdate() + } + + companion object : CssId("knob") { + object KnobCls : CssName + object KnobSvgCls : CssName + object KnobTextCls : CssName + object KnobValueCls : CssName + object KnobBackgroundCls : CssName + + object KnobVolumeCls : CssName + object KnobVolumeBackgroundCls : CssName + + init { + defineCss { + select(cls(KnobCls)) { + position(Position.relative) + margin(5.px) + + and(cls(ActiveCls)) {} + + select(cls(KnobSvgCls)) { + plain("stroke", "none") + plain("stroke-opacity", "1.0") + plain("fill", "none") + plain("fill-opacity", "0.0") + position(Position.absolute) + backgroundColor(Color.transparent) + + and(cls(ActiveCls)) { + color(Css.currentStyle.mainFontColor) + borderRadius(4.px) + } + } + + select(cls(KnobTextCls)) { + position(Position.absolute) + width(100.prc) + textAlign(TextAlign.center) + fontSize(1.0.em) + color(Css.currentStyle.mainFontColor) + } + + select(cls(KnobValueCls)) { + position(Position.absolute) + width(100.prc) + top((52).prc) + textAlign(TextAlign.center) + fontSize(0.75.em) + color(Css.currentStyle.mainFontColor) + } + } + + select(cls(KnobVolumeCls)) { + plain("fill", Color.transparent) + color(Css.currentStyle.mainFontColor) + plain("stroke-width", "5") + //plain("stroke-dasharray", "4") + plain("fill-opacity", "0.5") + } + + select(cls(KnobVolumeBackgroundCls)) { + plain("fill", Color.transparent) + color(Css.currentStyle.mainFontColor) + plain("stroke-width", "5") + //plain("stroke-dasharray", "4") + plain("fill-opacity", "0.5") + } + } + } + } + +} diff --git a/src/jsMain/kotlin/nl/astraeus/vst/ui/components/ExpKnobComponent.kt b/src/jsMain/kotlin/nl/astraeus/vst/ui/components/ExpKnobComponent.kt new file mode 100644 index 0000000..cb05634 --- /dev/null +++ b/src/jsMain/kotlin/nl/astraeus/vst/ui/components/ExpKnobComponent.kt @@ -0,0 +1,37 @@ +package nl.astraeus.vst.ui.components + +import nl.astraeus.vst.util.formatDouble +import kotlin.math.log10 +import kotlin.math.pow + +/** + * User: rnentjes + * Date: 26-11-17 + * Time: 16:52 + */ + +class ExpKnobComponent( + value: Double = 1.0, + label: String = "", + minValue: Double = 0.0, + maxValue: Double = 5.0, + step: Double = 0.1, + width: Int = 50, + height: Int = 60, + renderer: (Double) -> String = { nv -> formatDouble(nv, 2) }, + callback: (Double) -> Unit = {} +) : BaseKnobComponent( + value, + label, + minValue, + maxValue, + log10(maxValue / (maxValue - step)), + false, + width, + height, + 0.005, + { log10(it) }, + { 10.0.pow(it) }, + renderer, + callback +) diff --git a/src/jsMain/kotlin/nl/astraeus/vst/ui/components/KnobComponent.kt b/src/jsMain/kotlin/nl/astraeus/vst/ui/components/KnobComponent.kt new file mode 100644 index 0000000..a5dd13e --- /dev/null +++ b/src/jsMain/kotlin/nl/astraeus/vst/ui/components/KnobComponent.kt @@ -0,0 +1,36 @@ +package nl.astraeus.vst.ui.components + +import nl.astraeus.vst.util.formatDouble + +/** + * User: rnentjes + * Date: 26-11-17 + * Time: 16:52 + */ + +class KnobComponent( + value: Double = 1.0, + label: String = "", + minValue: Double = 0.0, + maxValue: Double = 5.0, + step: Double = 0.1, + pixelStep: Double = step / 25.0, + width: Int = 50, + height: Int = 60, + renderer: (Double) -> String = { nv -> formatDouble(nv, 2) }, + callback: (Double) -> Unit = {} +) : BaseKnobComponent( + value, + label, + minValue, + maxValue, + step, + true, + width, + height, + pixelStep, + { it }, + { it }, + renderer, + callback +) diff --git a/src/jsMain/kotlin/nl/astraeus/vst/ui/css/Css.kt b/src/jsMain/kotlin/nl/astraeus/vst/ui/css/Css.kt new file mode 100644 index 0000000..784418c --- /dev/null +++ b/src/jsMain/kotlin/nl/astraeus/vst/ui/css/Css.kt @@ -0,0 +1,121 @@ +package nl.astraeus.vst.ui.css + +import kotlinx.browser.document +import nl.astraeus.css.properties.* +import nl.astraeus.css.style +import nl.astraeus.css.style.ConditionalStyle +import nl.astraeus.css.style.DescriptionProvider +import nl.astraeus.css.style.Style + +class StyleDefinition( + val mainFontColor: Color = hsla(178, 70, 55, 1.0), + val mainBackgroundColor: Color = hsl(239, 50, 10), + //val entryFontColor: Color = hsl(Css.mainFontColorNumber, 70, 55), + val inputBackgroundColor : Color = mainBackgroundColor.lighten(15), + val buttonBackgroundColor : Color = mainBackgroundColor.lighten(15), + val buttonBorderColor : Color = mainFontColor.changeAlpha(0.25), + val buttonBorderWidth : Measurement = 1.px, +) + +object NoTextSelectCls : CssName { + override val name = "no-text-select" +} +object SelectedCls : CssName { + override val name = "selected" +} +object ActiveCls : CssName { + override val name = "active" +} + +fun Color.hover(): Color = if (Css.currentStyle == Css.darkStyle) { + this.lighten(15) +} else { + this.darken(15) +} + +object Css { + var dynamicStyles = mutableMapOf Unit>() + + fun DescriptionProvider.defineCss(conditionalStyle: ConditionalStyle.() -> Unit) { + check(!dynamicStyles.containsKey(this)) { + "CssId with name ${this.description()} already defined!" + } + + updateCss(conditionalStyle) + } + + private fun DescriptionProvider.updateCss(conditionalStyle: ConditionalStyle.() -> Unit) { + val elementId = this.description() + var dynamicStyleElement = document.getElementById(elementId) + + dynamicStyles[this] = conditionalStyle + + if (dynamicStyleElement == null) { + dynamicStyleElement = document.createElement("style") + dynamicStyleElement.id = elementId + + document.head?.append(dynamicStyleElement) + } + + val css = style(conditionalStyle) + + dynamicStyleElement.innerHTML = css.generateCss(minified = CssSettings.minified) + } + + val darkStyle = StyleDefinition( + ) + + val lightStyle = StyleDefinition( + mainBackgroundColor = hsl(239+180, 50, 15), + ) + + var currentStyle: StyleDefinition = darkStyle + + fun updateStyle() { + for ((cssId, dynStyle) in dynamicStyles) { + cssId.apply { + updateCss(dynStyle) + } + } + } + + fun switchLayout() { + currentStyle = if (currentStyle == darkStyle) { + lightStyle + } else { + darkStyle + } + + updateStyle() + } + + fun Style.transition() { + transition("all 0.5s ease") + } + + fun Style.noTextSelect() { + plain("-webkit-touch-callout", "none") + plain("-webkit-user-select", "none") + plain("-moz-user-select", "none") + plain("-ms-user-select", "none") + + userSelect(UserSelect.none) + + select("::selection") { + background("none") + } + } + + object GenericCss : CssId("generic") { + init { + fun generateStyle(): String { + val css = style { + + } + + return css.generateCss(minified = CssSettings.minified) + } + } + } + +} diff --git a/src/jsMain/kotlin/nl/astraeus/vst/ui/css/CssName.kt b/src/jsMain/kotlin/nl/astraeus/vst/ui/css/CssName.kt new file mode 100644 index 0000000..76211f0 --- /dev/null +++ b/src/jsMain/kotlin/nl/astraeus/vst/ui/css/CssName.kt @@ -0,0 +1,78 @@ +package nl.astraeus.vst.ui.css + +import nl.astraeus.css.style.DescriptionProvider +import nl.astraeus.css.style.cls + +private val CAPITAL_LETTER = Regex("[A-Z]") + +fun String.hyphenize(): String { + var result = replace(CAPITAL_LETTER) { + "-${it.value.lowercase()}" + } + + if (result.startsWith('-')) { + result = result.substring(1) + } + + return result +} + +private var nextCssId = 1 + +object CssSettings { + var preFix = "css" + var shortId = false + var minified = false +} + +private fun nextShortId(): String { + var id = nextCssId++ + val result = StringBuilder() + + while(id > 0) { + val ch = ((id % 26) + 'a'.code).toChar() + result.append(ch) + + id /= 26 + } + + return result.toString() +} + +interface CssName : DescriptionProvider { + val name: String + get() = if (CssSettings.shortId) { + nextShortId() + } else { + "${CssSettings.preFix}-${this::class.simpleName?.hyphenize() ?: this::class}" + } + + fun cls() : DescriptionProvider = cls(this) + + fun cssName(): String = "${this::class.simpleName?.hyphenize() ?: this::class}" + + override fun description() = name +} + +open class CssId(name: String) : DescriptionProvider { + val name: String = if (CssSettings.shortId) { + nextShortId() + } else { + "daw-$name-css" + } + override fun description() = name + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CssId) return false + + if (name != other.name) return false + + return true + } + + override fun hashCode(): Int { + return name.hashCode() + } + +}