From d10bb137631bcea0b2aaeac4bcf4a750f8f7a41a Mon Sep 17 00:00:00 2001 From: DJtheRedstoner <52044242+DJtheRedstoner@users.noreply.github.com> Date: Fri, 16 Feb 2024 11:14:31 -0500 Subject: [PATCH 01/66] UIComponent: clear stoppedTimers in animationFrame Otherwise, this Set will grow in size infinitely, potentially causing performance and memory issues. GitHub: #133 Linear: EM-2325 --- src/main/kotlin/gg/essential/elementa/UIComponent.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/gg/essential/elementa/UIComponent.kt b/src/main/kotlin/gg/essential/elementa/UIComponent.kt index b0290af9..9331f819 100644 --- a/src/main/kotlin/gg/essential/elementa/UIComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/UIComponent.kt @@ -752,6 +752,7 @@ abstract class UIComponent : Observable(), ReferenceHolder { } stoppedTimers.forEach { activeTimers.remove(it) } + stoppedTimers.clear() } open fun alwaysDrawChildren(): Boolean { From 281319fb21c50f33494d474dfac7d4322cf437f2 Mon Sep 17 00:00:00 2001 From: Sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 29 Feb 2024 08:01:04 -0500 Subject: [PATCH 02/66] New: unstable projects These are (successful) experiments, originally developed internally at Essential, of future Elementa features. While a feature is classified as an unstable project, no guarantees with respect to backwards compatibility are made. We will however attempt to avoid non-obvious behavioral changes and try to provide a gradual migration path where possible. Note: Due to these features not being backwards compatible, they are also not yet included in the main Elementa publication (which does guarantee indefinite backwards compatibility). Instead if you want to use one of these, you must depend on the respective unstable artifact, relocate it to a dedicated package within your mod and bundle this relocated version in your jar (that way your mod always gets the version it needs, and you can freely choose when to upgrade to a newer version). GitHub: #134 Co-authored-by: Jonas Herzig --- build.gradle.kts | 2 +- settings.gradle.kts | 2 ++ unstable/layoutdsl/build.gradle.kts | 39 +++++++++++++++++++++++++++++ unstable/statev2/build.gradle.kts | 38 ++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 unstable/layoutdsl/build.gradle.kts create mode 100644 unstable/statev2/build.gradle.kts diff --git a/build.gradle.kts b/build.gradle.kts index 0ee9179a..211df882 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,7 +49,7 @@ dependencies { } apiValidation { - ignoredProjects.add("platform") + ignoredProjects.addAll(listOf("platform", "statev2", "layoutdsl")) ignoredPackages.add("com.example") nonPublicMarkers.add("org.jetbrains.annotations.ApiStatus\$Internal") } diff --git a/settings.gradle.kts b/settings.gradle.kts index e9984556..ce43e72e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,6 +22,8 @@ project(":platform").apply { projectDir = file("versions/") buildFileName = "root.gradle.kts" } +include(":unstable:statev2") +include(":unstable:layoutdsl") listOf( "1.8.9-forge", diff --git a/unstable/layoutdsl/build.gradle.kts b/unstable/layoutdsl/build.gradle.kts new file mode 100644 index 00000000..76435d66 --- /dev/null +++ b/unstable/layoutdsl/build.gradle.kts @@ -0,0 +1,39 @@ +import gg.essential.gradle.multiversion.StripReferencesTransform.Companion.registerStripReferencesAttribute +import gg.essential.gradle.util.setJvmDefault +import gg.essential.gradle.util.versionFromBuildIdAndBranch + +plugins { + kotlin("jvm") + id("gg.essential.defaults") + id("maven-publish") +} + +version = versionFromBuildIdAndBranch() +group = "gg.essential" + +dependencies { + compileOnly(project(":")) + api(project(":unstable:statev2")) + + val common = registerStripReferencesAttribute("common") { + excludes.add("net.minecraft") + } + compileOnly(libs.versions.universalcraft.map { "gg.essential:universalcraft-1.8.9-forge:$it" }) { + attributes { attribute(common, true) } + } +} +tasks.compileKotlin.setJvmDefault("all") + +kotlin.jvmToolchain { + (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(8)) +} + +publishing { + publications { + register("maven") { + from(components["java"]) + + artifactId = "elementa-unstable-${project.name}" + } + } +} \ No newline at end of file diff --git a/unstable/statev2/build.gradle.kts b/unstable/statev2/build.gradle.kts new file mode 100644 index 00000000..5967b7b5 --- /dev/null +++ b/unstable/statev2/build.gradle.kts @@ -0,0 +1,38 @@ +import gg.essential.gradle.multiversion.StripReferencesTransform.Companion.registerStripReferencesAttribute +import gg.essential.gradle.util.setJvmDefault +import gg.essential.gradle.util.versionFromBuildIdAndBranch + +plugins { + kotlin("jvm") + id("gg.essential.defaults") + id("maven-publish") +} + +version = versionFromBuildIdAndBranch() +group = "gg.essential" + +dependencies { + compileOnly(project(":")) + + val common = registerStripReferencesAttribute("common") { + excludes.add("net.minecraft") + } + compileOnly(libs.versions.universalcraft.map { "gg.essential:universalcraft-1.8.9-forge:$it" }) { + attributes { attribute(common, true) } + } +} +tasks.compileKotlin.setJvmDefault("all") + +kotlin.jvmToolchain { + (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(8)) +} + +publishing { + publications { + register("maven") { + from(components["java"]) + + artifactId = "elementa-unstable-${project.name}" + } + } +} \ No newline at end of file From e37deef7f66f2874c68a8b828a794f33e341aaf0 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Wed, 28 Feb 2024 20:31:02 +0100 Subject: [PATCH 03/66] statev2: Import from Essential Source-Commit: d15cb7684623e5662975c6c977e71ff498a6d411 --- .../v2/collections/MutableTrackedList.kt | 217 ++++++++++++ .../state/v2/collections/MutableTrackedSet.kt | 214 ++++++++++++ .../state/v2/collections/TrackedList.kt | 89 +++++ .../state/v2/collections/TrackedSet.kt | 56 ++++ .../elementa/state/v2/collections/utils.kt | 31 ++ .../elementa/state/v2/color/color.kt | 11 + .../elementa/state/v2/combinators/booleans.kt | 14 + .../elementa/state/v2/combinators/state.kt | 32 ++ .../elementa/state/v2/combinators/strings.kt | 10 + .../elementa/state/v2/combinators/utils.kt | 6 + .../elementa/state/v2/compatibility.kt | 86 +++++ .../gg/essential/elementa/state/v2/flatten.kt | 3 + .../gg/essential/elementa/state/v2/list.kt | 43 +++ .../elementa/state/v2/listCombinators.kt | 136 ++++++++ .../gg/essential/elementa/state/v2/set.kt | 36 ++ .../elementa/state/v2/setCombinators.kt | 80 +++++ .../gg/essential/elementa/state/v2/state.kt | 308 ++++++++++++++++++ .../gg/essential/elementa/state/v2/stateBy.kt | 49 +++ 18 files changed, 1421 insertions(+) create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedList.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedSet.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedList.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedSet.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/utils.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/color/color.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/booleans.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/state.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/strings.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/utils.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/compatibility.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/flatten.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/set.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedList.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedList.kt new file mode 100644 index 00000000..ad8df114 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedList.kt @@ -0,0 +1,217 @@ +package gg.essential.elementa.state.v2.collections + +import kotlin.collections.AbstractList + +/** + * An immutable List type that remembers the changes that have been applied to it, allowing one to very cheaply obtain + * a "diff" between its current and an older version via [getChangesSince]. + * + * To maintain good performance, the implementation assumes that the standard use case involves only a single chain + * of changes and that older lists are only ever compared to newer list, not read from directly. + * For this standard use case, it maintains performance characteristics similar to the regular [mutableListOf] in terms + * of memory and runtime. + * + * Non-standard use cases are supported but will generally have performance of `O(n+m)` where `n` is the size of the + * latest list and `m` is the amount of changes that have happened between this list and the latest list (i.e. only the + * latest list contains the full array of values, previous lists only contain a change and their successor list). + */ +class MutableTrackedList private constructor( + /** Counter increased with every change. Used to quickly determine which of two lists is older. */ + private val generation: Int, + realList: MutableList, +) : AbstractList(), TrackedList { + + private var maybeRealList: MutableList? = realList + private val realList: MutableList + get() = maybeRealList ?: computeRealList() + + private var nextList: MutableTrackedList? = null + private var nextDiff: Diff? = null + + /** Computes the real list for this list from the next list(s). */ + private fun computeRealList(): MutableList { + val generations = generateSequence(this) { if (it.maybeRealList != null) null else it.nextList }.toList() + val list = generations.last().realList.toMutableList() + for (i in generations.indices.reversed()) { + generations[i].nextDiff?.revert(list) + } + maybeRealList = list + return list + } + + /** Creates a child list based on this list with the given diff. */ + private fun fork( + diff: Diff, + child: MutableTrackedList = MutableTrackedList(generation + 1, realList), + ): MutableTrackedList { + + // Relinquish ownership of our real list, it now belongs to the child + maybeRealList = null + + // We only want to update our next pointer if we don't yet have one, otherwise we risk changing the result of + // future diff calls (compared to what they previously returned). + if (nextList == null) { + nextList = child + nextDiff = diff + } + + // Finally, apply the diff + diff.apply(child.realList) + + return child + } + + override fun getChangesSince(other: TrackedList): Sequence> { + return if (other is MutableTrackedList) { + getChangesSince(other) + } else { + TrackedList.Change.estimate(other, this).asSequence() + } + } + + fun getChangesSince(other: MutableTrackedList): Sequence> { + // Trivial case: no changes + if (other == this) { + return emptySequence() + } + + // Fast path: single diff only + if (other.nextList == this) { + return other.nextDiff!!.asChangeSequence() + } + + if (other.generation < this.generation) { + // Regular diff + val generations = generateSequence(other) { if (it == this) null else it.nextList }.toMutableList() + if (generations.removeLast() != this) return TrackedList.Change.estimate(other, this).asSequence() + return generations.asSequence().flatMap { it.nextDiff!!.asChangeSequence() } + } else { + // Reverse diff + val generations = generateSequence(this) { if (it == other) null else it.nextList }.toMutableList() + if (generations.removeLast() != other) return TrackedList.Change.estimate(this, other).asSequence() + return generations.asReversed().asSequence().flatMap { it.nextDiff!!.asInverseChangeSequence() } + } + } + + constructor(mutableList: MutableList = mutableListOf()) : this(0, mutableList) + + override val size: Int + get() = realList.size + + override fun get(index: Int): E = realList[index] + + fun set(index: Int, element: E) = + fork(Diff.Multiple(listOf(Diff.Removal(index, realList[index]), Diff.Addition(index, element)))) + + fun add(element: E) = add(size, element) + fun add(index: Int, element: E) = fork(Diff.Addition(index, element)) + fun addAll(elements: Collection) = addAll(size, elements) + fun addAll(index: Int, elements: Collection) = fork(Diff.Multiple(elements.mapIndexed { i, e -> Diff.Addition(index + i, e) })) + + fun clear(): MutableTrackedList = fork(Diff.Clear(realList), MutableTrackedList(generation + 1, mutableListOf())) + + fun remove(element: E): MutableTrackedList { + val index = indexOf(element) + return if (index == -1) this else fork(Diff.Removal(index, element)) + } + fun removeAt(index: Int) = fork(Diff.Removal(index, this[index])) + fun removeAll(elements: Collection): MutableTrackedList { + val diffs = elements.mapNotNull { element -> + val index = indexOf(element) + if (index == -1) null else Diff.Removal(index, element) + }.sortedBy { -it.index } + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + fun retainAll(elements: Collection): MutableTrackedList { + val diffs = realList.mapIndexedNotNull { index, element -> + if (element in elements) null else Diff.Removal(index, element) + }.reversed() + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + + fun applyChanges(changes: List>): MutableTrackedList { + if (changes.isEmpty()) return this + return fork(changes.map { + when (it) { + is TrackedList.Add -> Diff.Addition(it.element.index, it.element.value) + is TrackedList.Remove -> Diff.Removal(it.element.index, it.element.value) + is TrackedList.Clear -> Diff.Clear(it.oldElements.toList()) + } + }.let { it.singleOrNull() ?: Diff.Multiple(it) }) + } + + private sealed interface Diff { + fun apply(list: MutableList) + fun revert(list: MutableList) + fun asChangeSequence(): Sequence> + fun asInverseChangeSequence(): Sequence> + + data class Addition(val index: Int, val element: E) : Diff { + override fun apply(list: MutableList) { + list.add(index, element) + } + + override fun revert(list: MutableList) { + list.removeAt(index) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedList.Add(IndexedValue(index, element))) + + override fun asInverseChangeSequence(): Sequence> = + sequenceOf(TrackedList.Remove(IndexedValue(index, element))) + } + + data class Removal(val index: Int, val element: E) : Diff { + override fun apply(list: MutableList) { + list.removeAt(index) + } + + override fun revert(list: MutableList) { + list.add(index, element) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedList.Remove(IndexedValue(index, element))) + + override fun asInverseChangeSequence(): Sequence> = + sequenceOf(TrackedList.Add(IndexedValue(index, element))) + } + + data class Clear(val oldList: List) : Diff { + override fun apply(list: MutableList) { + list.clear() + } + + override fun revert(list: MutableList) { + list.addAll(oldList) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedList.Clear(oldList)) + + override fun asInverseChangeSequence(): Sequence> = + oldList.withIndex().asSequence().map { TrackedList.Add(it) } + } + + data class Multiple(val diffs: List>) : Diff { + override fun revert(list: MutableList) { + for (i in diffs.indices.reversed()) { + diffs[i].revert(list) + } + } + + override fun apply(list: MutableList) { + for (change in diffs) { + change.apply(list) + } + } + + override fun asChangeSequence(): Sequence> = + diffs.asSequence().flatMap { it.asChangeSequence() } + + override fun asInverseChangeSequence(): Sequence> = + diffs.asReversed().asSequence().flatMap { it.asInverseChangeSequence() } + } + } +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedSet.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedSet.kt new file mode 100644 index 00000000..839f9288 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedSet.kt @@ -0,0 +1,214 @@ +package gg.essential.elementa.state.v2.collections + +/** + * An immutable Set type that remembers the changes that have been applied to it, allowing one to very cheaply obtain + * a "diff" between its current and an older version via [getChangesSince]. + * + * To maintain good performance, the implementation assumes that the standard use case involves only a single chain + * of changes and that older sets are only ever compared to newer sets, not read from directly. + * For this standard use case, it maintains performance characteristics similar to the regular [mutableSetOf] in terms + * of memory and runtime. + * + * Non-standard use cases are supported but will generally have performance of `O(n+m)` where `n` is the size of the + * latest set and `m` is the amount of changes that have happened between this set and the latest set (i.e. only the + * latest set contains the full array of values, previous sets only contain a change and their successor set). + * + * In the standard use case, the iteration order for the latest version matches insertion order. For all other use cases + * and versions, iteration order is undefined. + */ +class MutableTrackedSet private constructor( + /** Counter increased with every change. Used to quickly determine which of two sets is older. */ + private val generation: Int, + realSet: MutableSet, +) : AbstractSet(), TrackedSet { + + private var maybeRealSet: MutableSet? = realSet + private val realSet: MutableSet + get() = maybeRealSet ?: computeRealSet() + + private var nextSet: MutableTrackedSet? = null + private var nextDiff: Diff? = null + + /** Computes the real set for this set from the next set(s). */ + private fun computeRealSet(): MutableSet { + val generations = generateSequence(this) { if (it.maybeRealSet != null) null else it.nextSet }.toList() + val set = generations.last().realSet.toMutableSet() + for (i in generations.indices.reversed()) { + generations[i].nextDiff?.revert(set) + } + maybeRealSet = set + return set + } + + /** Creates a child set based on this set with the given diff. */ + private fun fork( + diff: Diff, + child: MutableTrackedSet = MutableTrackedSet(generation + 1, realSet), + ): MutableTrackedSet { + + // Relinquish ownership of our real set, it now belongs to the child + maybeRealSet = null + + // We only want to update our next pointer if we don't yet have one, otherwise we risk changing the result of + // future diff calls (compared to what they previously returned). + if (nextSet == null) { + nextSet = child + nextDiff = diff + } + + // Finally, apply the diff + diff.apply(child.realSet) + + return child + } + + override fun getChangesSince(other: TrackedSet): Sequence> { + return if (other is MutableTrackedSet) { + getChangesSince(other) + } else { + TrackedSet.Change.estimate(other, this).asSequence() + } + } + + fun getChangesSince(other: MutableTrackedSet): Sequence> { + // Trivial case: no changes + if (other == this) { + return emptySequence() + } + + // Fast path: single diff only + if (other.nextSet == this) { + return other.nextDiff!!.asChangeSequence() + } + + if (other.generation < this.generation) { + // Regular diff + val generations = generateSequence(other) { if (it == this) null else it.nextSet }.toMutableList() + if (generations.removeLast() != this) return TrackedSet.Change.estimate(other, this).asSequence() + return generations.asSequence().flatMap { it.nextDiff!!.asChangeSequence() } + } else { + // Reverse diff + val generations = generateSequence(this) { if (it == other) null else it.nextSet }.toMutableList() + if (generations.removeLast() != other) return TrackedSet.Change.estimate(this, other).asSequence() + return generations.asReversed().asSequence().flatMap { it.nextDiff!!.asInverseChangeSequence() } + } + } + + constructor(mutableSet: MutableSet = mutableSetOf()) : this(0, mutableSet) + + override val size: Int + get() = realSet.size + + override fun iterator(): Iterator = realSet.iterator() + override fun contains(element: E): Boolean = realSet.contains(element) + + fun add(element: E) = if (element in this) this else fork(Diff.Addition(element)) + fun addAll(elements: Collection): MutableTrackedSet { + val diffs = elements.mapNotNull { element -> + if (element in this) null else Diff.Addition(element) + } + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + + fun clear(): MutableTrackedSet = fork(Diff.Clear(realSet), MutableTrackedSet(generation + 1, mutableSetOf())) + + fun remove(element: E) = if (element in this) fork(Diff.Removal(element)) else this + fun removeAll(elements: Collection): MutableTrackedSet { + val diffs = elements.mapNotNull { element -> + if (element in this) Diff.Removal(element) else null + } + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + fun retainAll(elements: Collection): MutableTrackedSet { + val diffs = realSet.mapNotNull { element -> + if (element in elements) null else Diff.Removal(element) + } + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + + fun applyChanges(changes: List>): MutableTrackedSet { + if (changes.isEmpty()) return this + return fork(changes.map { + when (it) { + is TrackedSet.Add -> Diff.Addition(it.element) + is TrackedSet.Remove -> Diff.Removal(it.element) + is TrackedSet.Clear -> Diff.Clear(it.oldElements.toSet()) + } + }.let { it.singleOrNull() ?: Diff.Multiple(it) }) + } + + private sealed interface Diff { + fun apply(set: MutableSet) + fun revert(set: MutableSet) + fun asChangeSequence(): Sequence> + fun asInverseChangeSequence(): Sequence> + + data class Addition(val element: E) : Diff { + override fun apply(set: MutableSet) { + set.add(element) + } + + override fun revert(set: MutableSet) { + set.remove(element) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Add(element)) + + override fun asInverseChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Remove(element)) + } + + data class Removal(val element: E) : Diff { + override fun apply(set: MutableSet) { + set.remove(element) + } + + override fun revert(set: MutableSet) { + set.add(element) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Remove(element)) + + override fun asInverseChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Add(element)) + } + + data class Clear(val oldSet: Set) : Diff { + override fun apply(set: MutableSet) { + set.clear() + } + + override fun revert(set: MutableSet) { + set.addAll(oldSet) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Clear(oldSet)) + + override fun asInverseChangeSequence(): Sequence> = + oldSet.asSequence().map { TrackedSet.Add(it) } + } + + data class Multiple(val diffs: List>) : Diff { + override fun revert(set: MutableSet) { + for (i in diffs.indices.reversed()) { + diffs[i].revert(set) + } + } + + override fun apply(set: MutableSet) { + for (change in diffs) { + change.apply(set) + } + } + + override fun asChangeSequence(): Sequence> = + diffs.asSequence().flatMap { it.asChangeSequence() } + + override fun asInverseChangeSequence(): Sequence> = + diffs.asReversed().asSequence().flatMap { it.asInverseChangeSequence() } + } + } +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedList.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedList.kt new file mode 100644 index 00000000..67dbeee9 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedList.kt @@ -0,0 +1,89 @@ +package gg.essential.elementa.state.v2.collections + +/** + * An immutable List type that remembers the changes that have been applied to construct it, allowing one to very + * cheaply obtain a "diff" between it and one of the lists it has been constructed from. + * + * The exact meaning of "very cheaply" may differ depending on the specific implementation but should never be worse + * than `O(n+m)` where `n` is the size of both lists and `m` is the amount of changes that have happened between + * two lists. In the best case it should just be `O(m)`. + * + * If two unrelated tracked lists are compared with each other, the result will usually just equal [Change.estimate], + * which takes `O(n)`. + * + * Beware that even though lists of this type appear to be immutable, they are not guaranteed to be internally immutable + * (for performance reasons) and as such are not generally thread-safe. + */ +interface TrackedList : List { + /** Returns changes one would have to apply to [other] to obtain `this`. */ + fun getChangesSince(other: TrackedList<@UnsafeVariance E>): Sequence> + + data class Add(val element: IndexedValue) : Change + data class Remove(val element: IndexedValue) : Change + data class Clear(val oldElements: List) : Change + + sealed interface Change { + companion object { + /** + * Estimates the changes one would have to apply to [oldList] to obtain [newList]. + * + * Note that while the estimate is correct (i.e. the changes will result in [newList]), it is not + * necessarily minimal (i.e. there may be a shorter list of changes that would also result in [newList]), + * nor is it accurate (i.e. even if both arguments are [MutableTrackedList]s, the returned changes may + * differ from how those lists were actually created). + * + * The result is however minimal if only one of additions, removals, or updates (`set`) were applied between + * [oldList] and [newList]. It may also be minimal if a mix of these was applied but no guarantees are made + * in that case. + */ + fun estimate(oldList: List, newList: List): List> { + return if (newList.isEmpty()) { + if (oldList.isEmpty()) { + emptyList() + } else { + listOf(Clear(oldList)) + } + } else { + val changes = mutableListOf>() + + var oldIndex = 0 + var newIndex = 0 + + while (oldIndex <= oldList.lastIndex && newIndex <= newList.lastIndex) { + val oldValue = oldList[oldIndex] + val newValue = newList[newIndex] + if (oldValue == newValue) { + oldIndex++ + newIndex++ + continue + } + if (newList.size == oldList.size) { + changes.add(Remove(IndexedValue(newIndex, oldValue))) + changes.add(Add(IndexedValue(newIndex, newValue))) + oldIndex++ + newIndex++ + } else if (newList.size - newIndex > oldList.size - oldIndex) { + changes.add(Add(IndexedValue(newIndex, newValue))) + newIndex++ + } else { + changes.add(Remove(IndexedValue(newIndex, oldValue))) + oldIndex++ + } + } + + while (newIndex <= newList.lastIndex) { + changes.add(Add(IndexedValue(newIndex, newList[newIndex]))) + newIndex++ + } + + while (oldIndex <= oldList.lastIndex) { + changes.add(Remove(IndexedValue(newIndex, oldList[oldIndex]))) + oldIndex++ + } + + changes + } + } + } + } +} \ No newline at end of file diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedSet.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedSet.kt new file mode 100644 index 00000000..1002190c --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedSet.kt @@ -0,0 +1,56 @@ +package gg.essential.elementa.state.v2.collections + +/** + * An immutable Set type that remembers the changes that have been applied to construct it, allowing one to very + * cheaply obtain a "diff" between it and one of the sets it has been constructed from. + * + * The exact meaning of "very cheaply" may differ depending on the specific implementation but should never be worse + * than `O(n+m)` where `n` is the size of both sets and `m` is the amount of changes that have happened between + * two sets. In the best case it should just be `O(m)`. + * + * If two unrelated tracked sets are compared with each other, the result will usually just equal [Change.estimate], + * which takes `O(n)`. + * + * Beware that even though sets of this type appear to be immutable, they are not guaranteed to be internally immutable + * (for performance reasons) and as such are not generally thread-safe. + */ +interface TrackedSet : Set { + /** Returns changes one would have to apply to [other] to obtain `this`. */ + fun getChangesSince(other: TrackedSet<@UnsafeVariance E>): Sequence> + + data class Add(val element: E) : Change + data class Remove(val element: E) : Change + data class Clear(val oldElements: Set) : Change + + sealed interface Change { + companion object { + /** + * Estimates the changes one would have to apply to [oldSet] to obtain [newSet]. + */ + fun estimate(oldSet: Set, newSet: Set): List> { + return if (newSet.isEmpty()) { + if (oldSet.isEmpty()) { + emptyList() + } else { + listOf(Clear(oldSet)) + } + } else { + val changes = mutableListOf>() + + for (newValue in newSet) { + if (newValue !in oldSet) { + changes.add(Add(newValue)) + } + } + for (oldValue in oldSet) { + if (oldValue !in newSet) { + changes.add(Remove(oldValue)) + } + } + + changes + } + } + } + } +} \ No newline at end of file diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/utils.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/utils.kt new file mode 100644 index 00000000..28cd272d --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/utils.kt @@ -0,0 +1,31 @@ +package gg.essential.elementa.state.v2.collections + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.v2.ListState + +// FIXME this is assuming there are no duplicate keys (good enough for now) +fun ListState.asMap(owner: ReferenceHolder, block: (T) -> Pair): Map { + var oldList = get() + val map = oldList.associateTo(mutableMapOf(), block) + val keys = map.keys.toMutableList() + onSetValue(owner) { newList -> + val changes = newList.getChangesSince(oldList).also { oldList = newList } + for (change in changes) { + when (change) { + is TrackedList.Add -> { + val (k, v) = block(change.element.value) + keys.add(change.element.index, k) + map[k] = v + } + is TrackedList.Remove -> { + map.remove(keys.removeAt(change.element.index)) + } + is TrackedList.Clear -> { + map.clear() + keys.clear() + } + } + } + } + return map +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/color/color.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/color/color.kt new file mode 100644 index 00000000..b2a12431 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/color/color.kt @@ -0,0 +1,11 @@ +package gg.essential.elementa.state.v2.color + +import gg.essential.elementa.constraints.ColorConstraint +import gg.essential.elementa.dsl.basicColorConstraint +import gg.essential.elementa.state.v2.State +import java.awt.Color + +fun State.toConstraint() = basicColorConstraint { get() } + +val State.constraint: ColorConstraint + get() = toConstraint() diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/booleans.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/booleans.kt new file mode 100644 index 00000000..d6249124 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/booleans.kt @@ -0,0 +1,14 @@ +package gg.essential.elementa.state.v2.combinators + +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.State + +infix fun State.and(other: State) = + zip(other).map { (a, b) -> a && b } + +infix fun State.or(other: State) = + zip(other).map { (a, b) -> a || b } + +operator fun State.not() = map { !it } + +operator fun MutableState.not() = bimap({ !it }, { !it }) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/state.kt new file mode 100644 index 00000000..abbf08be --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/state.kt @@ -0,0 +1,32 @@ +package gg.essential.elementa.state.v2.combinators + +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.derivedState + +/** Maps this state into a new state */ +fun State.map(mapper: (T) -> U): State { + return derivedState(mapper(get())) { owner, derivedState -> + onSetValue(owner) { derivedState.set(mapper(it)) } + } +} + +/** Maps this mutable state into a new mutable state. */ +fun MutableState.bimap(map: (T) -> U, unmap: (U) -> T): MutableState { + return object : MutableState, State by this.map(map) { + override fun set(mapper: (U) -> U) { + this@bimap.set { unmap(mapper(map(it))) } + } + } +} + +/** Zips this state with another state */ +fun State.zip(other: State): State> = zip(other, ::Pair) + +/** Zips this state with another state using [mapper] */ +fun State.zip(other: State, mapper: (T, U) -> V): State { + return derivedState(mapper(this.get(), other.get())) { owner, derivedState -> + this.onSetValue(owner) { derivedState.set(mapper(it, other.get())) } + other.onSetValue(owner) { derivedState.set(mapper(this.get(), it)) } + } +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/strings.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/strings.kt new file mode 100644 index 00000000..6d2d0cad --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/strings.kt @@ -0,0 +1,10 @@ +package gg.essential.elementa.state.v2.combinators + +import gg.essential.elementa.state.v2.State + +fun State.contains(other: State, ignoreCase: Boolean = false) = + zip(other).map { (a, b) -> a.contains(b, ignoreCase) } + +fun State.isEmpty() = map { it.isEmpty() } + +fun State.isNotEmpty() = map { it.isNotEmpty() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/utils.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/utils.kt new file mode 100644 index 00000000..1d555a53 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/utils.kt @@ -0,0 +1,6 @@ +package gg.essential.elementa.state.v2.combinators + +import gg.essential.elementa.state.v2.MutableState + +fun MutableState.reorder(vararg mapping: Int) = + bimap({ mapping[it] }, { mapping.indexOf(it) }) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/compatibility.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/compatibility.kt new file mode 100644 index 00000000..0ba859a3 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/compatibility.kt @@ -0,0 +1,86 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.State as V1State + +private class V2AsV1State(private val v2State: State, owner: ReferenceHolder) : V1State() { + // Stored in a field, so the listener is kept alive at least as long as this legacy state instance exists + private val listener: (T) -> Unit = { super.set(it) } + + init { + v2State.onSetValue(owner, listener) + } + + override fun get(): T = v2State.get() + + override fun set(value: T) { + if (v2State is MutableState<*>) { + (v2State as MutableState).set { value } + } else { + super.set(value) + } + } +} + +private class V1AsV2State(private val v1State: V1State) : MutableState { + override fun get(): T = + v1State.get() + + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit = + v1State.onSetValue(listener) + + override fun set(mapper: (T) -> T) = + v1State.set(mapper) +} + +/** + * Converts this state into a v1 [State][V1State]. + * + * If [V1State.set] is called on the returned state and this value is a [MutableState], then the call is forwarded to + * [MutableState.set], otherwise only the internal field of the v1 state will be updated (and overwritten again the next + * time this state changes; much like the old mapped states). + * + * Note that as with any listener on a v2 state, the returned v1 state may be garbage collected once there are no more + * strong references to it. This v2 state will not by itself keep it alive. + * The [owner] argument serves to prevent this from happening too early, see [State.onSetValue]. + */ +fun State.toV1(owner: ReferenceHolder): V1State = V2AsV1State(this, owner) + +/** + * Converts this state into a v2 [MutableState]. + * + * Note that unlike regular v2 state, listeners registered on this state will not by default be automatically + * garbage-collected unless the entire v1 state itself can be garbage collected. + * This matches v1 state behavior. If this is not desired, stop using v1 state. + */ +fun V1State.toV2(): MutableState = V1AsV2State(this) + +/** + * Returns a delegating state with internal mutability. That is, the value of the returned state generally follows the + * value of the input state (or the state passed to [DelegatingState.rebind]), but [MutableState.set] is not forwarded + * to the bound state. Instead the new value is stored internally and returned until the input state changes again, at + * which point it'll be overwritten again. + * + * Using such a state (`input.map { it }`) with a `rebindState` method and direct getter+setter methods for the state + * content was a common anti-pattern used in many places throughout Element. + * To preserve backwards compatibility for this behavior, this method exists to quickly construct such a state in the v2 + * world. + * New code should instead just use a regular delegating state and have the setter rebind it to a new immutable state. + */ +internal fun State.wrapWithDelegatingMutableState(): MutableDelegatingState { + val delegatingState = stateDelegatingTo(this) + val derivedState = + derivedState(get()) { owner, derivedState -> + delegatingState.onSetValue(owner) { derivedState.set(it) } + } + // Note: this in an implementation detail of `derivedState`, do not rely on it outside of Elementa + val mutableState = derivedState as MutableState + + return object : DelegatingState, MutableState by mutableState, MutableDelegatingState { + override fun rebind(newState: State) { + delegatingState.rebind(newState) + } + } +} + +internal interface MutableDelegatingState : DelegatingState, MutableState diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/flatten.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/flatten.kt new file mode 100644 index 00000000..5b4f9b5e --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/flatten.kt @@ -0,0 +1,3 @@ +package gg.essential.elementa.state.v2 + +fun State>.flatten() = stateBy { this@flatten()() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt new file mode 100644 index 00000000..9504b1e3 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt @@ -0,0 +1,43 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.collections.MutableTrackedList +import gg.essential.elementa.state.v2.collections.TrackedList + +typealias ListState = State> +typealias MutableListState = MutableState> + +fun State>.toListState(): ListState { + return derivedState(MutableTrackedList(get().toMutableList())) { owner, derivedState -> + onSetValue(owner) { newList -> + derivedState.set { it.applyChanges(TrackedList.Change.estimate(it, newList)) } + } + } +} + +fun ListState.mapChanges(init: (TrackedList) -> U, update: (old: U, changes: Sequence>) -> U): State { + var oldList = get() + return derivedState(init(oldList)) { owner, derivedState -> + onSetValue(owner) { newList -> + val changes = newList.getChangesSince(oldList).also { oldList = newList } + derivedState.set { update(it, changes) } + } + } +} + +fun ListState.mapChange(init: (TrackedList) -> U, update: (old: U, change: TrackedList.Change) -> U): State = + mapChanges(init) { old, changes -> changes.fold(old, update) } + +fun listStateOf(vararg elements: T): ListState = + stateOf(MutableTrackedList(mutableListOf(*elements))) + +fun mutableListStateOf(vararg elements: T): MutableListState = + mutableStateOf(MutableTrackedList(mutableListOf(*elements))) + +fun MutableListState.set(index: Int, element: T) = set { it.set(index, element) } +fun MutableListState.setAll(newList: List) = set { it.applyChanges(TrackedList.Change.estimate(it, newList)) } +fun MutableListState.add(element: T) = set { it.add(element) } +fun MutableListState.add(index: Int, element: T) = set { it.add(index, element) } +fun MutableListState.addAll(elements: List) = set { it.addAll(elements) } +fun MutableListState.remove(element: T) = set { it.remove(element) } +fun MutableListState.removeAt(index: Int) = set { it.removeAt(index) } +fun MutableListState.clear() = set { it.clear() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt new file mode 100644 index 00000000..96bcdba6 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt @@ -0,0 +1,136 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.collections.MutableTrackedList +import gg.essential.elementa.state.v2.collections.MutableTrackedSet +import gg.essential.elementa.state.v2.collections.TrackedList +import gg.essential.elementa.state.v2.combinators.map +import gg.essential.elementa.state.v2.combinators.zip + +fun ListState.toSet(): SetState { + val count = mutableMapOf() + for (element in get()) { + count.compute(element) { _, c -> (c ?: 0) + 1 } + } + return mapChange({ MutableTrackedSet(it.toMutableSet()) }, { set, change -> + when (change) { + is TrackedList.Add -> { + if (count.compute(change.element.value) { _, c -> (c ?: 0) + 1 } == 1) { + set.add(change.element.value) + } else { + set + } + } + is TrackedList.Remove -> { + if (count.compute(change.element.value) { _, c -> (c!! - 1).takeUnless { it == 0 } } == null) { + set.remove(change.element.value) + } else { + set + } + } + is TrackedList.Clear -> set.clear() + } + }) +} + +// mapList { it.filter(filter) } +fun ListState.filter(filter: (T) -> Boolean): ListState { + val indices = mutableListOf() + val init = MutableTrackedList(mutableListOf().also { filteredList -> + for (elem in get()) { + if (filter(elem)) { + indices.add(filteredList.size) + filteredList.add(elem) + } else { + indices.add(-1) + } + } + }) + return mapChange({ init }) { list, change -> + when (change) { + is TrackedList.Add -> { + if (filter(change.element.value)) { + val mappedIndex = if (change.element.index == indices.size) { + // Fast path, add to end + list.size + } else { + // Slow path, to find the index of the newly added element, we need to find the index + // of the previous (non-filtered) element + var mappedIndex = 0 + for (i in (0 until change.element.index).reversed()) { + val index = indices[i] + if (index != -1) { + mappedIndex = index + 1 + break + } + } + // And then also increment the index of all elements that are after it + for (i in change.element.index .. indices.lastIndex) { + val index = indices[i] + if (index != -1) { + indices[i] = index + 1 + } + } + mappedIndex + } + indices.add(change.element.index, mappedIndex) + list.add(mappedIndex, change.element.value) + } else { + indices.add(change.element.index, -1) + list + } + } + is TrackedList.Remove -> { + val mappedIndex = indices.removeAt(change.element.index) + if (mappedIndex != -1) { + for (i in change.element.index .. indices.lastIndex) { + val index = indices[i] + if (index != -1) { + indices[i] = index - 1 + } + } + list.removeAt(mappedIndex) + } else { + list + } + } + is TrackedList.Clear -> { + indices.clear() + list.clear() + } + } + } +} + +// mapList { it.map(mapper) } +fun ListState.mapEach(mapper: (T) -> U): ListState = + mapChange({ MutableTrackedList(it.mapTo(mutableListOf(), mapper)) }) { list, change -> + when (change) { + is TrackedList.Add -> list.add(change.element.index, mapper(change.element.value)) + is TrackedList.Remove -> list.removeAt(change.element.index) + is TrackedList.Clear -> list.clear() + } + } + + +// TODO: all of these are based on mapList and as such are quite inefficient, might make sense to implement some as efficient primitives instead + +fun ListState.mapList(mapper: (List) -> List): ListState = + map(mapper).toListState() + +fun ListState.zipWithEachElement(otherState: State, transform: (T, U) -> V) = + zip(otherState).map { (list, other) -> list.map { transform(it, other) } }.toListState() + +fun ListState.zipElements(otherList: ListState, transform: (T, U) -> V) = + zip(otherList).map { (a, b) -> a.zip(b, transform) }.toListState() + +fun ListState.mapEachNotNull(mapper: (T) -> U?) = mapList { it.mapNotNull(mapper) } + +fun ListState.filterNotNull() = mapList { it.filterNotNull() } + +inline fun ListState<*>.filterIsInstance(): ListState = map { it.filterIsInstance() }.toListState() + +fun ListState.flatMap(block: (T) -> Iterable) = mapList { it.flatMap(block) } + +fun ListState.isEmpty() = map { it.isEmpty() } + +fun ListState.isNotEmpty() = map { it.isNotEmpty() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/set.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/set.kt new file mode 100644 index 00000000..d208ba05 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/set.kt @@ -0,0 +1,36 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.collections.* + +typealias SetState = State> +typealias MutableSetState = MutableState> + +fun State>.toSetState(): SetState { + return derivedState(MutableTrackedSet(get().toMutableSet())) { owner, derivedState -> + onSetValue(owner) { newSet -> + derivedState.set { it.applyChanges(TrackedSet.Change.estimate(it, newSet)) } + } + } +} + +fun SetState.mapChanges(init: (TrackedSet) -> U, update: (old: U, changes: Sequence>) -> U): State { + var oldSet = get() + return derivedState(init(oldSet)) { owner, derivedState -> + onSetValue(owner) { newSet -> + val changes = newSet.getChangesSince(oldSet).also { oldSet = newSet } + derivedState.set { update(it, changes) } + } + } +} + +fun SetState.mapChange(init: (TrackedSet) -> U, update: (old: U, change: TrackedSet.Change) -> U): State = + mapChanges(init) { old, changes -> changes.fold(old, update) } + +fun mutableSetState(vararg elements: T): MutableSetState = + mutableStateOf(MutableTrackedSet(mutableSetOf(*elements))) + +fun MutableSetState.add(element: T) = set { it.add(element) } +fun MutableSetState.addAll(toAdd: Collection) = set { it.addAll(toAdd) } +fun MutableSetState.setAll(newSet: Set) = set { it.applyChanges(TrackedSet.Change.estimate(it, newSet)) } +fun MutableSetState.remove(element: T) = set { it.remove(element) } +fun MutableSetState.clear() = set { it.clear() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt new file mode 100644 index 00000000..99bde64b --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt @@ -0,0 +1,80 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.collections.MutableTrackedList +import gg.essential.elementa.state.v2.collections.MutableTrackedSet +import gg.essential.elementa.state.v2.collections.TrackedSet +import gg.essential.elementa.state.v2.combinators.map +import gg.essential.elementa.state.v2.combinators.zip + +fun SetState.toList(): ListState { + return mapChange({ MutableTrackedList(it.toMutableList()) }, { list, change -> + when (change) { + is TrackedSet.Add -> list.add(change.element) + is TrackedSet.Remove -> list.remove(change.element) + is TrackedSet.Clear -> list.clear() + } + }) +} + +fun SetState.filter(filter: (T) -> Boolean): SetState = + mapChange({ MutableTrackedSet(it.filterTo(mutableSetOf(), filter)) }) { set, change -> + when (change) { + is TrackedSet.Add -> { + if (filter(change.element)) { + set.add(change.element) + } else { + set + } + } + is TrackedSet.Remove -> set.remove(change.element) + is TrackedSet.Clear -> set.clear() + } + } + +fun SetState.mapEach(mapper: (T) -> U): SetState { + val mappedValues = mutableMapOf() + val mappedCount = mutableMapOf() + val init = MutableTrackedSet(get().mapTo(mutableSetOf()) { value -> + mapper(value).also { mappedValue -> + mappedValues[value] = mappedValue + mappedCount.compute(mappedValue) { _, i -> (i ?: 0) + 1} + } + }) + return mapChange({ init }) { list, change -> + when (change) { + is TrackedSet.Add -> { + val mappedValue = mapper(change.element) + mappedValues[change.element] = mappedValue + mappedCount.compute(mappedValue) { _, i -> (i ?: 0) + 1 } + list.add(mappedValue) + } + is TrackedSet.Remove -> { + val mappedValue = mappedValues.remove(change.element)!! + if (mappedCount.computeIfPresent(mappedValue) { _, i -> (i - 1).takeIf { i > 0 } } == null) { + list.remove(mappedValue) + } else { + list + } + } + is TrackedSet.Clear -> { + mappedValues.clear() + mappedCount.clear() + list.clear() + } + } + } +} + +// TODO: all of these are based on mapSet and as such are quite inefficient, might make sense to implement some as efficient primitives instead + +fun SetState.mapSet(mapper: (Set) -> Set): SetState = + map(mapper).toSetState() + +fun SetState.zipWithEachElement(otherState: State, transform: (T, U) -> V) = + zip(otherState).map { (set, other) -> set.mapTo(mutableSetOf()) { transform(it, other) } }.toSetState() + +fun SetState.mapEachNotNull(mapper: (T) -> U?) = mapSet { it.mapNotNullTo(mutableSetOf(), mapper) } + +fun SetState.filterNotNull() = mapSet { it.filterNotNullTo(mutableSetOf()) } + +inline fun SetState<*>.filterIsInstance(): SetState = map { it.filterIsInstanceTo>(mutableSetOf()) }.toSetState() diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt new file mode 100644 index 00000000..07be4037 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt @@ -0,0 +1,308 @@ +package gg.essential.elementa.state.v2 + + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.state.v2.ReferenceHolder +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +/** + * The base for all Elementa State objects. + * + * State objects are essentially just a wrapper around a value. However, the ability to be deeply + * integrated into an Elementa component allows some nice functionality. + * + * The primary advantage of using state is that a single state object can be shared between multiple + * components or constraints. This allows one value update to be seen by multiple components or + * constraints. For example, if a component has many text children, and they all share the same + * color state variable, then whenever the value of the state object is updated, all of the text + * components will instantly change color. + * + * Another advantage arises when using Kotlin, as States can be delegated to. For more information, + * see delegation.kt. + */ +interface State { + /** Get the value of this State object */ + fun get(): T + + /** + * Register a listener which will be called whenever the value of this State object changes + * + * The listener registration is weak by default. This means that no strong reference to the + * listener is kept in this State object and your listener may be garbage collected if no other + * strong references to it exist. Once a listener is garbage collected, it will (obviously) no + * longer receive updates. + * + * Keeping a strong reference to your own listener is easy to forget, so this method requires you + * to explicitly pass in an object which will maintain a strong reference to your listener for + * you. With that, your listener will stay active **at least** as long as the given [owner] is + * alive (unless the returned callback in invoked). + * + * In general, the lifetime of your listener should match the lifetime of the passed [owner], + * usually the thing (e.g. [UIComponent]) the listener is modifying. If the owner far outlives + * your listener, you may be leaking memory because the owner will keep all those listeners and + * anything they reference alive far beyond the point where they are needed. If your listener + * outlives the owner, then it may become inactive sooner than you expected and whatever it is + * updating might no longer update properly. + * + * If you wish to manually keep your listener alive, pass [ReferenceHolder.Weak] as the owner. + * + * @return A callback which, when invoked, removes this listener + */ + fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit +} + +/* ReferenceHolder is defined in Elementa as: +/** + * Holds strong references to listeners to prevent them from being garbage collected. + * @see State.onSetValue + */ +interface ReferenceHolder { + fun holdOnto(listener: Any): () -> Unit + + object Weak : ReferenceHolder { + override fun holdOnto(listener: Any): () -> Unit = {} + } +} + */ + +/** A [State] with a value that can be changed via [set] */ +@JvmDefaultWithoutCompatibility +interface MutableState : State { + /** + * Update the value of this State object. + * + * After the value has been updated, all listeners of this State object are notified. + * + * The provided lambda must be a pure function which will return the new value for this State give + * the current value. + * + * Note that while most basic State implementations will call the lambda and notify listeners + * immediately, there is no general requirement for them to do so, and specialized State + * implementations may delay either or both to e.g. batch multiple updates together. + */ + fun set(mapper: (T) -> T) + + /** + * Update the value of this State object. + * + * After the value has been updated, all listeners of this State object are notified. + * + * Note that while most basic State implementations will update and notify listeners immediately, + * there is no general requirement for them to do so, and specialized State implementations may + * delay either or both to e.g. batch multiple updates together. + * + * @see [set] + */ + fun set(value: T) = set { value } +} + +/** A [State] delegating to a configurable target [State] */ +interface DelegatingState : State { + fun rebind(newState: State) +} + +/** A [MutableState] delegating to a configurable target [MutableState] */ +@JvmDefaultWithoutCompatibility +interface DelegatingMutableState : MutableState { + fun rebind(newState: MutableState) +} + +/** Creates a new [State] with the given value. */ +fun stateOf(value: T): State = ImmutableState(value) + +/** Creates a new [MutableState] with the given initial value. */ +fun mutableStateOf(value: T): MutableState = BasicState(value) + +/** Creates a new [DelegatingState] with the given target [State]. */ +fun stateDelegatingTo(state: State): DelegatingState = DelegatingStateImpl(state) + +/** Creates a new [DelegatingMutableState] with the given target [MutableState]. */ +fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState = + DelegatingMutableStateImpl(state) + +/** Creates a [State] which derives its value in a user-defined way from one or more other states */ +fun derivedState( + initialValue: T, + builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit, +): State { + return ReferenceHoldingBasicState(initialValue).apply { builder(this, this) } +} + +/** A simple, immutable implementation of [State] */ +private class ImmutableState(private val value: T) : State { + override fun get(): T = value + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit = {} +} + +/** A simple implementation of [MutableState], containing only a backing field */ +private open class BasicState(private var valueBacker: T) : MutableState { + private val referenceQueue = ReferenceQueue() + private val listeners = mutableListOf>() + + /** + * Contains the size of the [listeners] list which we currently iterate over. + * We must not directly modify these entries as that may mess up the iteration, anything after those entries is fair + * game though. + * Additions always happen at the end of the list, so those are trivial. + * For removals we instead set the [ListenerEntry.removed] flag and let the iteration code clean up the entry when + * it passes over it. + * We can't solely rely on that for all cleanup because we only iterate the listener list when the value of the state + * changes, so if it doesn't, we need to clean up entries immediately. + */ + private var liveSize = 0 + + override fun get() = valueBacker + + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { + cleanupStaleListeners() + val ownerCallback = WeakReference(owner.holdOnto(Pair(this, listener))) + return ListenerEntry(this, listener, ownerCallback).also { listeners.add(it) } + } + + override fun set(mapper: (T) -> T) { + val oldValue = valueBacker + val newValue = mapper(oldValue) + if (oldValue == newValue) { + return + } + + valueBacker = newValue + + // Iterate over listeners while allowing for concurrent add to the end of the list (newly added entries will not get + // called) and concurrent remove from anywhere in the list (via `removed` flag in each entry, or directly for newly + // added listeners). See [liveSize] docs. + liveSize = listeners.size + var i = 0 + while (i < liveSize) { + val entry = listeners[i] + if (entry.removed) { + listeners.removeAt(i) + liveSize-- + } else { + entry.get()?.invoke(newValue) + i++ + } + } + liveSize = 0 + } + + private fun cleanupStaleListeners() { + while (true) { + val reference = referenceQueue.poll() ?: break + (reference as ListenerEntry<*>).invoke() + } + } + + private class ListenerEntry( + private val state: BasicState, + listenerCallback: (T) -> Unit, + private val ownerCallback: WeakReference<() -> Unit>, + ) : WeakReference<(T) -> Unit>(listenerCallback, state.referenceQueue), () -> Unit { + var removed = false + + override fun invoke() { + // If we do not currently iterate over the listener list, we can directly remove this entry from the list, + // otherwise we merely mark it as deleted and let the iteration code take care of it. + val index = state.listeners.indexOf(this@ListenerEntry) + if (index >= state.liveSize) { + state.listeners.removeAt(index) + } else { + removed = true + } + + ownerCallback.get()?.invoke() + } + } +} + +/** Base class for implementations of Delegating(Mutable)State classes. */ +private open class DelegatingStateBase>(protected var delegate: S) : State { + private val referenceQueue = ReferenceQueue() + private var listeners = mutableListOf>() + + override fun get(): T = delegate.get() + + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { + cleanupStaleListeners() + val ownerCallback = WeakReference(owner.holdOnto(Pair(this, listener))) + val removeCallback = delegate.onSetValue(ReferenceHolder.Weak, listener) + return ListenerEntry(this, listener, removeCallback, ownerCallback).also { listeners.add(it) } + } + + + fun rebind(newState: S) { + val oldState = delegate + if (oldState == newState) { + return + } + + delegate = newState + + listeners = + listeners.mapNotNullTo(mutableListOf()) { entry -> + entry.removeCallback() + val listenerCallback = entry.get() ?: return@mapNotNullTo null + val removeCallback = newState.onSetValue(ReferenceHolder.Weak, listenerCallback) + ListenerEntry(this, listenerCallback, removeCallback, entry.ownerCallback) + } + + val oldValue = oldState.get() + val newValue = newState.get() + if (oldValue != newValue) { + listeners.forEach { it.get()?.invoke(newValue) } + } + } + + private fun cleanupStaleListeners() { + while (true) { + val reference = referenceQueue.poll() ?: break + (reference as ListenerEntry<*>).invoke() + } + } + + private class ListenerEntry( + private val state: DelegatingStateBase, + listenerCallback: (T) -> Unit, + val removeCallback: () -> Unit, + val ownerCallback: WeakReference<() -> Unit>, + ) : WeakReference<(T) -> Unit>(listenerCallback, state.referenceQueue), () -> Unit { + override fun invoke() { + state.listeners.remove(this@ListenerEntry) + removeCallback() + ownerCallback.get()?.invoke() + } + } +} + +/** Default implementation of [DelegatingState] */ +private class DelegatingStateImpl(delegate: State) : + DelegatingStateBase>(delegate), DelegatingState + +/** Default implementation of [DelegatingMutableState] */ +private class DelegatingMutableStateImpl(delegate: MutableState) : + DelegatingStateBase>(delegate), DelegatingMutableState { + override fun set(mapper: (T) -> T) { + delegate.set(mapper) + } +} + +/** A [BasicState] which additionally implements [ReferenceHolder] */ +private class ReferenceHoldingBasicState(value: T) : BasicState(value), ReferenceHolder { + private val heldReferences = mutableListOf() + + override fun holdOnto(listener: Any): () -> Unit { + heldReferences.add(listener) + return { heldReferences.remove(listener) } + } +} + +/** A simple implementation of [ReferenceHolder] */ +class ReferenceHolderImpl : ReferenceHolder { + private val heldReferences = mutableListOf() + + override fun holdOnto(listener: Any): () -> Unit { + heldReferences.add(listener) + return { heldReferences.remove(listener) } + } +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt new file mode 100644 index 00000000..ee3f50e4 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt @@ -0,0 +1,49 @@ +package gg.essential.elementa.state.v2 + +/** + * Creates a state that derives its value using the given [block]. The value of any state may be accessed within this + * block via [StateByScope.invoke]. These accesses are tracked and the block is automatically re-evaluated whenever any + * one of them changes. + * + * Note that while this is generally easier to use than [derivedState], it also comes with greater overhead. + */ +fun stateBy(block: StateByScope.() -> T): State { + val subscribed = mutableMapOf, () -> Unit>() + val observed = mutableSetOf>() + val scope = object : StateByScope { + override fun State.invoke(): T { + observed.add(this) + return get() + } + } + + return derivedState(initialValue = block(scope)) { owner, derivedState -> + fun updateSubscriptions() { + for (state in observed) { + if (state in subscribed) continue + + subscribed[state] = state.onSetValue(owner) { + val newValue = block(scope) + updateSubscriptions() + derivedState.set(newValue) + } + } + + subscribed.entries.removeAll { (state, unregister) -> + if (state !in observed) { + unregister() + true + } else { + false + } + } + + observed.clear() + } + updateSubscriptions() + } +} + +interface StateByScope { + operator fun State.invoke(): T +} From 720ac60ced37b9da236a63ebea921915d53a5374 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Wed, 28 Feb 2024 20:31:28 +0100 Subject: [PATCH 04/66] layoutdsl: Import from Essential Source-Commit: d15cb7684623e5662975c6c977e71ff498a6d411 --- .../elementa/common/HollowUIContainer.kt | 16 + .../gg/essential/elementa/common/ListState.kt | 189 +++ .../gg/essential/elementa/common/Spacer.kt | 27 + .../common/constraints/AlternateConstraint.kt | 66 + .../constraints/CenterPixelConstraint.kt | 42 + .../FillConstraintIncludingPadding.kt | 99 ++ .../SpacedCramSiblingConstraint.kt | 144 +++ .../elementa/common/stateExtensions.kt | 11 + .../gg/essential/elementa/layoutdsl/README.md | 1098 +++++++++++++++++ .../essential/elementa/layoutdsl/alignment.kt | 55 + .../elementa/layoutdsl/arrangement.kt | 259 ++++ .../gg/essential/elementa/layoutdsl/axis.kt | 6 + .../elementa/layoutdsl/basicModifiers.kt | 62 + .../gg/essential/elementa/layoutdsl/color.kt | 63 + .../elementa/layoutdsl/containers.kt | 249 ++++ .../gg/essential/elementa/layoutdsl/events.kt | 71 ++ .../gg/essential/elementa/layoutdsl/float.kt | 7 + .../gg/essential/elementa/layoutdsl/layout.kt | 266 ++++ .../gg/essential/elementa/layoutdsl/lazy.kt | 35 + .../essential/elementa/layoutdsl/modifier.kt | 34 + .../gg/essential/elementa/layoutdsl/size.kt | 149 +++ .../gg/essential/elementa/layoutdsl/state.kt | 50 + .../gg/essential/elementa/layoutdsl/util.kt | 47 + .../elementa/util/elementaExtensions.kt | 322 +++++ 24 files changed, 3367 insertions(+) create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/HollowUIContainer.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/ListState.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/Spacer.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/AlternateConstraint.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/CenterPixelConstraint.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/FillConstraintIncludingPadding.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/SpacedCramSiblingConstraint.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/stateExtensions.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/README.md create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/alignment.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/axis.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/basicModifiers.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/float.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/size.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/util.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/HollowUIContainer.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/HollowUIContainer.kt new file mode 100644 index 00000000..bf83452e --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/HollowUIContainer.kt @@ -0,0 +1,16 @@ +package gg.essential.elementa.common + +import gg.essential.elementa.components.UIContainer + +/** + * A UIContainer that does not return true for [isPointInside] unless + * any of the child are hovered + */ +open class HollowUIContainer : UIContainer() { + + override fun isPointInside(x: Float, y: Float): Boolean { + return children.any { + it.isPointInside(x, y) + } + } +} \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/ListState.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/ListState.kt new file mode 100644 index 00000000..7a931c7c --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/ListState.kt @@ -0,0 +1,189 @@ +package gg.essential.elementa.common + +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.state.v2.collections.TrackedList + +typealias AddListener = (index: Int, element: T) -> Unit +typealias SetListener = (index: Int, element: T, oldElement: T) -> Unit +typealias RemoveListener = AddListener +typealias ClearListener = (list: List) -> Unit + +@Deprecated("Using StateV1 is discouraged, use StateV2 instead", ReplaceWith("mutableListState(state)", "gg.essential.elementa.state.v2.ListKt.mutableListState")) +class ListState(initialList: MutableList = mutableListOf()) : BasicState>(initialList) { + private val addListeners = Listeners>() + private val setListeners = Listeners>() + private val removeListeners = Listeners>() + private val clearListeners = Listeners>() + + fun onAdd(action: AddListener) = apply { + addListeners.add(action) + } + + fun onSet(action: SetListener) = apply { + setListeners.add(action) + } + + fun onRemove(action: RemoveListener) = apply { + removeListeners.add(action) + } + + fun onClear(action: ClearListener) = apply { + clearListeners.add(action) + } + + fun add(element: T) = apply { + add(get().size, element) + } + + fun add(index: Int, element: T) = apply { + get().add(index, element) + addListeners.forEach { it(index, element) } + } + + fun set(index: Int, element: T) = apply { + get().also { list -> + val oldValue = list[index] + if (element == oldValue) + return@also + list[index] = element + setListeners.forEach { it(index, element, oldValue) } + } + } + + fun remove(element: T) = apply { + removeAt(get().indexOf(element)) + } + + fun removeAt(index: Int) = apply { + get().also { list -> + val element = list.removeAt(index) + removeListeners.forEach { it(index, element) } + } + } + + fun clear() = apply { + val list = get() + val values = list.toList() + list.clear() + clearListeners.forEach { it(values) } + } + + fun onElementAddedOrPresent(action: (element: T) -> Unit) = apply { + onElementAdded(action) + get().forEach(action) + } + + fun onElementAdded(action: (element: T) -> Unit) = apply { + onAdd { _, element -> + action(element) + } + + onSet { _, element, _ -> + action(element) + } + } + + fun onElementRemoved(action: (element: T) -> Unit) = apply { + onSet { _, _, oldElement -> + action(oldElement) + } + + onRemove { _, element -> + action(element) + } + + onClear { + it.forEach(action) + } + } + + fun onElementAddedOrRemoved(action: (element: T) -> Unit) = apply { + onElementAdded(action) + onElementRemoved(action) + } + + fun onElementAddedOrRemovedOrPresent(action: (element: T) -> Unit) = apply { + onElementAddedOrRemoved(action) + get().forEach(action) + } + + operator fun contains(element: T) = element in get() + + fun reduce(mapper: (List) -> U) = MappedListState(this, mapper) + + companion object { + fun from(state: State>): ListState { + val listState = ListState() + state.onSetValueAndNow { newList -> + for (change in TrackedList.Change.estimate(listState.get(), newList)) { + when (change) { + is TrackedList.Clear -> listState.clear() + is TrackedList.Add -> listState.add(change.element.index, change.element.value) + is TrackedList.Remove -> listState.removeAt(change.element.index) + } + } + } + return listState + } + } +} + +fun ListState.mapList(mapper: (List) -> List): ListState = ListState.from(reduce(mapper)) + +// TODO: all of these are quite inefficient, might make sense to implement some as efficient primitives instead + +fun ListState.filter(filter: (T) -> Boolean) = mapList { it.filter(filter) } + +fun ListState.map(mapper: (T) -> U) = mapList { it.map(mapper) } + +fun ListState.zip(otherState: State, transform: (T, U) -> V) = + ListState.from(reduce { it.toList() }.zip(otherState).map { (list, other) -> list.map { transform(it, other) } }) + +fun ListState.zip(otherList: ListState, transform: (T, U) -> V) = + ListState.from(reduce { it.toList() }.zip(otherList.reduce { it.toList() }).map { (a, b) -> a.zip(b, transform) }) + +fun ListState.mapNotNull(mapper: (T) -> U?) = mapList { it.mapNotNull(mapper) } + +fun ListState.filterNotNull() = mapList { it.filterNotNull() } + +inline fun ListState<*>.filterIsInstance() = mapList { it.filterIsInstance() } + +@Deprecated("Using StateV1 is discouraged, use StateV2 instead", ReplaceWith("state.map", "gg.essential.elementa.state.v2.combinators.StateKt.map")) +class MappedListState(state: ListState, mapper: (List) -> U) : BasicState(mapper(state.get())) { + init { + state.onAdd { _, _ -> + set(mapper(state.get())) + } + + state.onSet { _, _, _ -> + set(mapper(state.get())) + } + + state.onRemove { _, _ -> + set(mapper(state.get())) + } + + state.onClear { + set(mapper(emptyList())) + } + } +} + +/** A mutable list of listeners that is safe to extend while being iterated. */ +private class Listeners { + private val active = mutableListOf() + private val new = mutableListOf() + + fun add(listener: T) { + new.add(listener) + } + + fun forEach(caller: (T) -> Unit) { + if (new.isNotEmpty()) { + active.addAll(new) + new.clear() + } + active.forEach(caller) + } +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/Spacer.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/Spacer.kt new file mode 100644 index 00000000..f2ada434 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/Spacer.kt @@ -0,0 +1,27 @@ +package gg.essential.elementa.common + +import gg.essential.elementa.constraints.HeightConstraint +import gg.essential.elementa.constraints.SiblingConstraint +import gg.essential.elementa.constraints.WidthConstraint +import gg.essential.elementa.dsl.constrain +import gg.essential.elementa.dsl.pixels + +/** + * A simple UIContainer where you can specify [width], [height], or both. + * + * If only [width] is specified, X-axis will be constrained to [SiblingConstraint]. + * + * If only [height] is specified, Y-axis will be constrained to [SiblingConstraint]. + */ +class Spacer(width: WidthConstraint = 0.pixels, height: HeightConstraint = 0.pixels) : HollowUIContainer() { + constructor(width: Float, _desc: Int = 0) : this(width = width.pixels) { setX(SiblingConstraint()) } + constructor(height: Float, _desc: Short = 0) : this(height = height.pixels) { setY(SiblingConstraint()) } + constructor(width: Float, height: Float) : this(width = width.pixels, height = height.pixels) + + init { + constrain { + this.width = width + this.height = height + } + } +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/AlternateConstraint.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/AlternateConstraint.kt new file mode 100644 index 00000000..190a1e01 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/AlternateConstraint.kt @@ -0,0 +1,66 @@ +package gg.essential.elementa.common.constraints + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.SizeConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor + +/** + * Constraint which tries to evaluate the given constraint but falls back to another constraint if the first constraint + * results in a circular constraint chain. + * + * You probably shouldn't use this. With great power comes great responsibility. + * Be sure to fully understand how this works and interacts with other constraints before using, otherwise you may see + * undefined behavior such as unstable results, stack overflow, etc. if any of the involved constraints are not pure + * or more generally not safe to evaluate recursively (this one for example isn't, so don't ever use multiple). + */ +class AlternateConstraint( + val primary: SizeConstraint, + val fallback: SizeConstraint, +) : SizeConstraint { + override var recalculate: Boolean = true + override var cachedValue: Float = 0f + override var constrainTo: UIComponent? + get() = null + set(value) = throw UnsupportedOperationException() + + private var tryingPrimary = false + private var primaryWasRecursive = false + + override fun animationFrame() { + primary.animationFrame() + fallback.animationFrame() + + super.animationFrame() + } + + private inline fun eval(eval: (SizeConstraint) -> Float): Float { + if (!tryingPrimary) { + tryingPrimary = true + try { + primaryWasRecursive = false + val value = eval(primary) + if (!primaryWasRecursive) { + return value + } + } finally { + tryingPrimary = false + } + } else { + primaryWasRecursive = true + } + return eval(fallback) + } + + + override fun getWidthImpl(component: UIComponent): Float = + eval { it.getWidth(component) } + + override fun getHeightImpl(component: UIComponent): Float = + eval { it.getHeight(component) } + + override fun getRadiusImpl(component: UIComponent): Float = + eval { it.getRadius(component) } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {} +} \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/CenterPixelConstraint.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/CenterPixelConstraint.kt new file mode 100644 index 00000000..64d72bd4 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/CenterPixelConstraint.kt @@ -0,0 +1,42 @@ +package gg.essential.elementa.common.constraints + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.PositionConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor +import kotlin.math.ceil +import kotlin.math.floor + +/** Centers the component to whole pixels, rounding down unless [roundUp] is true */ +class CenterPixelConstraint(private val roundUp: Boolean = false) : PositionConstraint { + + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + override fun getXPositionImpl(component: UIComponent): Float { + val parent = constrainTo ?: component.parent + + val center = if (component.isPositionCenter()) { + parent.getWidth() / 2 + } else { + parent.getWidth() / 2 - component.getWidth() / 2 + } + + return parent.getLeft() + if (roundUp) ceil(center) else floor(center) + } + + override fun getYPositionImpl(component: UIComponent): Float { + val parent = constrainTo ?: component.parent + + val center = if (component.isPositionCenter()) { + parent.getHeight() / 2 + } else { + parent.getHeight() / 2 - component.getHeight() / 2 + } + + return parent.getTop() + if (roundUp) ceil(center) else floor(center) + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {} +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/FillConstraintIncludingPadding.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/FillConstraintIncludingPadding.kt new file mode 100644 index 00000000..edd26d74 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/FillConstraintIncludingPadding.kt @@ -0,0 +1,99 @@ +package gg.essential.elementa.common.constraints + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.PaddingConstraint +import gg.essential.elementa.constraints.SizeConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor + +/** + * @see FillConstraint but includes padding in width and height calculations to correctly position the component + */ +class FillConstraintIncludingPadding @JvmOverloads constructor(private val useSiblings: Boolean = true) : SizeConstraint { + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + override fun getWidthImpl(component: UIComponent): Float { + val target = constrainTo ?: component.parent + + return if (useSiblings) { + target.getWidth() - target.children.filter { it != component }.sumOf { + it.getWidth().toDouble() + ((it.constraints.x as? PaddingConstraint)?.getHorizontalPadding(it) ?: 0f).toDouble() + }.toFloat() + } else target.getRight() - component.getLeft() + ((target.constraints.x as? PaddingConstraint)?.getHorizontalPadding(target) ?: 0f) + } + + override fun getHeightImpl(component: UIComponent): Float { + val target = constrainTo ?: component.parent + + return if (useSiblings) { + target.getHeight() - target.children.filter { it != component }.sumOf { + it.getHeight().toDouble() + ((it.constraints.y as? PaddingConstraint)?.getVerticalPadding(it) ?: 0f).toDouble() + }.toFloat() + } else target.getBottom() - component.getTop() + ((target.constraints.y as? PaddingConstraint)?.getVerticalPadding(target) ?: 0f) + } + + override fun getRadiusImpl(component: UIComponent): Float { + val target = constrainTo ?: component.parent + + return if (useSiblings) { + target.getRadius() - target.children.filter { it != component }.sumOf { + it.getRadius().toDouble() + }.toFloat() + } else (target.getRadius() - component.getLeft()) / 2f + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + when (type) { + ConstraintType.WIDTH -> { + visitor.visitParent(ConstraintType.WIDTH) + + if (useSiblings) { + val indexInParent = visitor.component.let { it.parent.children.indexOf(it) } + val numParentChildren = visitor.component.parent.children.size + + for (i in 0 until numParentChildren) { + if (indexInParent != i) + visitor.visitSibling(ConstraintType.WIDTH, i) + } + } else { + visitor.visitParent(ConstraintType.X) + visitor.visitSelf(ConstraintType.X) + } + } + ConstraintType.HEIGHT -> { + visitor.visitParent(ConstraintType.HEIGHT) + + if (useSiblings) { + val indexInParent = visitor.component.let { it.parent.children.indexOf(it) } + val numParentChildren = visitor.component.parent.children.size + + for (i in 0 until numParentChildren) { + if (indexInParent != i) + visitor.visitSibling(ConstraintType.HEIGHT, i) + } + } else { + visitor.visitParent(ConstraintType.Y) + visitor.visitSelf(ConstraintType.Y) + } + } + ConstraintType.RADIUS -> { + visitor.visitParent(ConstraintType.RADIUS) + + if (useSiblings) { + val indexInParent = visitor.component.let { it.parent.children.indexOf(it) } + val numParentChildren = visitor.component.parent.children.size + + for (i in 0 until numParentChildren) { + if (indexInParent != i) + visitor.visitSibling(ConstraintType.RADIUS, i) + } + } else { + visitor.visitSelf(ConstraintType.X) + } + } + else -> throw IllegalArgumentException(type.prettyName) + } + } +} \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/SpacedCramSiblingConstraint.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/SpacedCramSiblingConstraint.kt new file mode 100644 index 00000000..d72eecae --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/SpacedCramSiblingConstraint.kt @@ -0,0 +1,144 @@ +package gg.essential.elementa.common.constraints + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.SiblingConstraint +import gg.essential.elementa.constraints.WidthConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor + +/** + * Note: All items are assumed to be same width + */ +class SpacedCramSiblingConstraint( + private val minSeparation: WidthConstraint, + private val margin: WidthConstraint, + private val verticalSeparation: WidthConstraint? = null, +) : + SiblingConstraint() { + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + override fun getXPositionImpl(component: UIComponent): Float { + val index = component.parent.children.indexOf(component) + + val marginPixels = margin.getWidth(component).toInt() + val minSeparationPixels = minSeparation.getWidth(component).toInt() + val totalWidth = component.parent.getWidth() - marginPixels * 2 + val itemWidth = component.getWidth() + val itemsPerRow = ((totalWidth + minSeparationPixels) / (itemWidth + minSeparationPixels)).toInt() + if (itemsPerRow <= 1) { + return component.parent.getLeft() + ((totalWidth - itemWidth) / 2f) + } + if (index == 0) { + return component.parent.getLeft() + marginPixels + } + val itemSep = (totalWidth - itemsPerRow * itemWidth) / (itemsPerRow - 1) + val sibling = component.parent.children[index - 1] + if (sibling.getRight() + component.getWidth() + minSeparationPixels <= component.parent.getRight() + floatErrorMargin) { + return sibling.getRight() + itemSep + } + + return component.parent.getLeft() + marginPixels + } + + override fun getYPositionImpl(component: UIComponent): Float { + val index = component.parent.children.indexOf(component) + + val marginPixels = margin.getWidth(component).toInt() + if (index == 0) { + return component.parent.getTop() + marginPixels + } + + val minSeparationPixels = minSeparation.getWidth(component).toInt() + val totalWidth = component.parent.getWidth() - marginPixels * 2 + val itemWidth = component.getWidth() + val itemsPerRow = ((totalWidth + minSeparationPixels) / (itemWidth + minSeparationPixels)).toInt() + val sibling = component.parent.children[index - 1] + if (itemsPerRow <= 1) { + return sibling.getBottom() + minSeparationPixels + } + val itemSep = (totalWidth - itemsPerRow * itemWidth) / (itemsPerRow - 1) + + if (sibling.getRight() + component.getWidth() + minSeparationPixels <= component.parent.getRight() + floatErrorMargin) { + return sibling.getTop() + } else if (sibling.javaClass != component.javaClass) { + // FIXME This workaround is broken and should never have been added in the first place. Instead of mixing + // different components with SpacedCramSiblingConstraints, just put a wrapper component around the + // grid. + // Should be removed once the old CosmeticStudio is dead. + // If the previous item not a cosmetic option, position right after it so vertical padding + // can be made consistent. Otherwise, `itemSep` can vary and lead to inconsistent padding. + return sibling.getBottom() + } + val verticalSep = verticalSeparation?.getWidth(component.parent) + ?: itemSep.coerceAtLeast(minSeparationPixels.toFloat()) + return getLowestPoint(sibling, component.parent, index) + verticalSep + } + + // This allows ChildBasedSizeConstraint to function for the parent height by emitting negative padding for + // items that are layed out in-line. + // Note: For simplicity this assumes all items are the same size, both horizontally (as the constraint as a whole + // already assumes) but also vertically. + // It also does not support margin as that's just unnecessary complexity (just add a wrapper if you need it). + override fun getVerticalPadding(component: UIComponent): Float { + val index = component.parent.children.indexOf(component) + if (index == 0) { + return 0f + } + + val minSeparationPixels = minSeparation.getWidth(component).toInt() + val totalWidth = component.parent.getWidth() + val itemWidth = component.getWidth() + val itemsPerRow = ((totalWidth + minSeparationPixels) / (itemWidth + minSeparationPixels)).toInt() + if (itemsPerRow <= 1) { + return minSeparationPixels.toFloat() + } + val itemSep = (totalWidth - itemsPerRow * itemWidth) / (itemsPerRow - 1) + if (index % itemsPerRow == 0) { + return verticalSeparation?.getWidth(component.parent) + ?: itemSep.coerceAtLeast(minSeparationPixels.toFloat()) + } + return -component.getHeight() + } + + override fun to(component: UIComponent) = apply { + throw UnsupportedOperationException("Constraint.to(UIComponent) is not available in this context!") + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + val indexInParent = visitor.component.let { it.parent.children.indexOf(it) } + + when (type) { + ConstraintType.X -> { + if (indexInParent == 0) { + visitor.visitParent(ConstraintType.X) + return + } + + visitor.visitSelf(ConstraintType.WIDTH) + visitor.visitSibling(ConstraintType.X, indexInParent - 1) + visitor.visitSibling(ConstraintType.WIDTH, indexInParent - 1) + visitor.visitParent(ConstraintType.WIDTH) + visitor.visitParent(ConstraintType.X) + } + ConstraintType.Y -> { + if (indexInParent == 0) { + visitor.visitParent(ConstraintType.Y) + return + } + + visitor.visitSelf(ConstraintType.WIDTH) + visitor.visitSibling(ConstraintType.X, indexInParent - 1) + visitor.visitSibling(ConstraintType.WIDTH, indexInParent - 1) + visitor.visitParent(ConstraintType.WIDTH) + visitor.visitParent(ConstraintType.X) + } + else -> throw IllegalArgumentException(type.prettyName) + } + } + + companion object { + private const val floatErrorMargin = 0.001f + } +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/stateExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/stateExtensions.kt new file mode 100644 index 00000000..955445e6 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/stateExtensions.kt @@ -0,0 +1,11 @@ +package gg.essential.elementa.common + +import gg.essential.elementa.state.State +import gg.essential.elementa.state.v2.ReferenceHolder + +fun State.onSetValueAndNow(listener: (T) -> Unit) = onSetValue(listener).also { listener(get()) } + +fun gg.essential.elementa.state.v2.State.onSetValueAndNow(owner: ReferenceHolder, listener: (T) -> Unit) = + onSetValue(owner, listener).also { listener(get()) } + +operator fun State.not() = map { !it } \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/README.md b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/README.md new file mode 100644 index 00000000..0b5c1b0b --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/README.md @@ -0,0 +1,1098 @@ +# Layout DSL + +The Layout DSL provides a high-level DSL to declare the overall structure and layout of Elementa components or entire +screens. + +Note: This document does not cover the `State` API (in part because State V2 is still being worked on). + The Layout DSL does make heavy use of it though (at least for anything dynamic). Until someone writes a guide for + it, here's a one paragraph primer: + Most of the high-level state of your GUI lives in multiple `MutableState` instances, one per factum. You then + `map` or `zip` these to derive various other `State`s (like the text in a specific gui label) from them. Most gui + components as well as the dynamic parts of the Layout DSL can accept `State`s and will then automatically update + whenever you change any of your `MutableState`s. There's also special support for `List`s in states (though V1's + special case for this, ListState, has various footguns), with V2 also for `Set`s in states. And the last thing + that should be mentioned is `stateBy` for when `map` and `zip` don't cut it (may actually become the new standard + for V2, still to be decided). + For something more detailed, take a look at the public members of the main file (and potentially other files) of + State V2 [1]. + V1 is similar but more messy, see [2] for an overview of differences. + Though you will still need to look at various existing uses (outside of Elementa where backwards-compatibility + complicates everything; would recommend the Wardrobe as it's the most recently written one and also uses the + Layout DSL) to see something real. + +[1]: https://github.com/EssentialGG/Elementa/blob/feature/state-v2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt +[2]: https://github.com/EssentialGG/Elementa/pull/88#issue-1347835067 + +## Motivation + +This section explains the main problem(s) the Layout DSL was meant to solve by starting from a regular, old Elementa +example and gradually transforming it to use the Layout DSL instead. +This assumes you have at least a rough idea of how Elementa components and constraints work. + +The main issue the Layout DSL tries to solve is that in a regular Elementa screen, which consists of many sub-components +are that usually all declared in a field of the screen class, it is difficult to grasp how all these components relate +to each other without carefully following all `childOf` calls while also keeping an eye on all the constraints at all +times. +And the constraints part is not to be underestimated because the way Elementa constraints are currently declared does +not make it particularly easy to understand the layout of a particular component's children from just glancing at one +child. + +Consider for example this relatively simple screen that's just two equally-sized boxes (as big as possible with a fixed +padding) next to each other, the left has a centered text that reads Left, the right one is split vertically with the +center of the top half saying Top and the bottom half saying Bottom: +``` +|-----------------------------| +| | +| |----------| |----------| | +| | | | | | +| | | | TOP | | +| | LEFT | | | | +| | | | BOTTOM | | +| | | | | | +| |----------| |----------| | +| | +|-----------------------------| +``` + +The regular Elementa code for this would look something like this: +```kotlin +val window: Window = WindowScreen() + +// This extra wrapper may seem redundant here, the reason we have it will become clear later +val wrapper by UIContainer().constrain { + width = 100.percent + height = 100.percent +} childOf window + +val content by UIContainer().constrain { + x = CenterConstraint() + y = CenterConstraint() + width = 100.percent - 2.pixels + height = 100.percent - 2.pixels +} childOf wrapper + +val left by UIContainer().constrain { + width = 50.percent - 1.5.pixels + height = 100.percent +} childOf content + +val right by UIContainer().constrain { + x = 0.pixels(alignOpposite = true) + width = 50.percent - 1.5.pixels + height = 100.percent +} childOf content + +val leftText by UIText("Left").constrain { + x = CenterConstraint() + y = CenterConstraint() +} childOf left + +val top by UIContainer().constrain { + width = 100.percent + height = 50.percent +} childOf right + +val bottom by UIContainer().constrain { + y = 0.pixels(alignOpposite = true) + width = 100.percent + height = 50.percent +} childOf right + +val topText by UIText("Top").constrain { + x = CenterConstraint() + y = CenterConstraint() +} childOf top + +val bottomText by UIText("Bottom").constrain { + x = CenterConstraint() + y = CenterConstraint() +} childOf bottom +``` + +And this kind of code is **extremely** common since almost everything in most UIs is hierarchical. +But, without the ascii sketch above, it's unreasonably difficult to tell what this will actually look like until you +run it (or, with quite some effort, mentally evaluate it). + +It shouldn't be hard to imagine how bad this can get with more complex layouts. +It gets even worse once you start making some things dynamic because then you really need to go searching to find the +parent/children. +And reading the constraints can become quite difficult too because Elementa does not at all force you to actually use +additional wrapper components to define the layout, you could (and frequently it's convenient in the short term) totally +just define the three text components and give them highly complex constraints which compute the same thing. + +In the simplest case, layout dsl can at least allow you to more easily understand the parent-child relations. +For this first version, we effectively keep everything from above except for the `childOf` calls which are now handled +by the layout dsl: +```kotlin +window.layout { + wrapper { + content { + left { + leftText() + } + right { + top { + topText() + } + bottom { + bottomText() + } + } + } + } +} +``` +With that, it is immediately clear now that the top and bottom parts are children of the right side only. +But, if we did not name our components by their direction, it would still be difficult to tell whether things are layed +out vertically, horizontally or some other way. Additionally, things like size and alignment also still require you to +look at the component definitions. + +This is where `Modifier`s come in. A modifier expresses a set of configurations/modifications that one wishes to apply +to a given component. There are modifiers for a variety of things like position, size, color, effects, callbacks, etc. +Modifiers can be chained together so you get a single modifier that applies multiple modifications. +There even exist higher-order modifiers that e.g. apply a given modifier only while the component is being hovered. + +For now, let's use only the basic ones to exactly replicate the above example, but this time we can also remove the +`constrain` blocks from the original code: +```kotlin +val halfWidth = Modifier.fillWidth(fraction = 0.5f, padding = 1.5f).fillHeight() +window.layout { + wrapper(Modifier.fillParent()) { + content(Modifier.alignBoth(Alignment.Center).fillParent(padding = 1f)) { + left(Modifier.alignHorizontal(Alignment.Start).then(halfWidth)) { + leftText(Modifier.alignBoth(Alignment.Center)) + } + right(Modifier.alignHorizontal(Alignment.End).then(halfWidth)) { + top(Modifier.alignHorizontal(Alignment.Start).fillWidth().fillHeight(0.5f)) { + topText(Modifier.alignBoth(Alignment.Center)) + } + bottom(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) { + bottomText(Modifier.alignBoth(Alignment.Center)) + } + } + } + } +} +``` + +You may notice that while some of these map relatively directly on existing constraints (e.g. +`fillWidth(fraction, padding)` is just `fraction.percent - (padding * 2).pixels`), there are also plenty higher-level +modifiers (e.g. `fillParent` which is both `fillWidth` as well as `fillHeight`) to reduce repetition and make it easier +to understand what a modifier does at first glance. +There are also modifiers that let you set constraints directly (`BasicXModifier`) but these exist only as an escape +hatch and you should ideally never need them. + +Ok, so the above is definitely more compact than the original code, and you can kind of tell the general layout if you +look carefully at the modifiers. But we can still do **a lot** better. + +For starters, all that is left in the fields at this point are the constructor calls, so it might be tempting to inline +them. And generally there's nothing wrong with this as long as they really are as simple as in the example. +If there's still a lot of component configuration left, for example click handlers, then you'll usually want to keep the +fields as to not blow up the DSL block (remember: it is meant to show the layout, not every last details; and it is +supposed to be easy to grasp as a whole, a 50 line click handler in the middle makes that a lot harder). + +```kotlin +// With fields: +bottom(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) { + bottomText(Modifier.alignBoth(Alignment.Center)) +} +// Constructors inlined: +UIComponent()(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) { + UIText("Bottom")(Modifier.alignBoth(Alignment.Center)) +} +// Because text and simple containers are quite common, there exist `box` and `text` methods which will create the +// components with the given modifiers. +box(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) { + text("Bottom", modifier = Modifier.alignBoth(Alignment.Center)) +} +``` + +Next, notice how there's quite a lot of `align(Center)`? +That's actually quite common and arguably because Elementa has bad default constraints. +Yes, `0.pixels` can make sense some times, but usually, if it is the only child and there is wiggle room in the parent, +you want your components to be centered, you don't want everything slanted to the left. + +To remedy this, any children of `box` will automatically be centered by default. You can still overwrite the positioning +by explicitly specifying an alignment as above, but the default is already what you want in quite a lot of cases. + +The default for size in Elementa is even worse. You practically never want your component to be `0.pixels` in size, yet +that's what you get by default. +Layout DSL improves that as well: The default size of a `box` is `ChildBasedSizeConstraint()`. It doesn't come up in our +example because it is sized fully top-down but for components that are sized bottom-up, this is a much better default +than the useless `0.pixels`. + +Applying this to our example, we get: +```kotlin +val halfWidth = Modifier.fillWidth(fraction = 0.5f, padding = 1.5f).fillHeight() +window.layout { + box(Modifier.fillParent()) { + box(Modifier.fillParent(padding = 1f)) { + box(Modifier.alignHorizontal(Alignment.Start).then(halfWidth)) { + text("Left") + } + box(Modifier.alignHorizontal(Alignment.End).then(halfWidth)) { + box(Modifier.alignHorizontal(Alignment.Start).fillWidth().fillHeight(0.5f)) { + text("Top") + } + box(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) { + text("Bottom") + } + } + } + } +} +``` + +Note: We cannot, at this point, get rid of an `alignBoth` on the outer-most box because its parent is a `Window`, not + another `box`. This case doesn't actually happen very often in practice because it only happens on the outermost + Layout DSL layer, and if you're building a component to be used by other code, then that other code is usually the + one that specifies the position for you. + This is why we introduced the extra `wrapper` component in our original example, with it being full-size, we don't + need to align it. And more importantly, everything inside of it can fully use the Layout DSL with no distractions. + +The final two observations: There's still quite a lot of `align` happening, and unlike the field names `box` doesn't +really tell us anything about the relative positioning of the components, we have to look at the specific `align` calls. + +But there's no reason we can't introduce more methods like `box` with more meaningful names and more defaults. The two +common builtin ones are `row` and `column`: + +```kotlin +val halfWidth = Modifier.fillWidth(fraction = 0.5f, padding = 1.5f).fillHeight() +window.layout { + box(Modifier.fillParent()) { + row(Modifier.fillParent(padding = 1f), Arrangement.SpaceBetween) { + box(halfWidth) { + text("Left") + } + column(halfWidth) { + box(Modifier.fillWidth().fillHeight(0.5f)) { + text("Top") + } + box(Modifier.fillWidth().fillHeight(0.5f)) { + text("Bottom") + } + } + } + } +} +``` + +`row` and `column`, unlike `box`, use a new `Arrangement`-based layout system on their primary axis and the already +introduced `Alignment`s in another optional argument as the default for their secondary axis (defaulting to Center if +not specified). + +They also have different default sizes, their primary axis being sized by the same `Arrangement` system with their +secondary axis sized by a `ChildBasedMaxSizeConstraint` (i.e. a `row` is as high as its highest child and as wide as all +its children together, plus some additional spacing depending on the arrangement). + +## Usage + +### LayoutScope + +The Layout DSL may be used to lay out the children of any Elementa component. +To use it, simply call the `layout` extension function on the component. + +The `layout` function takes an optional `Modifier` to be applied to the component itself as well as a block which, via +its receiver, has access to a `LayoutScope` instance through which it can add children to the component: +```kotlin +val myComponent = UIContainer() +val myChild = UIContainer() +val myInnerChild = UIContainer() + +myComponent.layout(Modifier.width(100).height(20)) { + // Adds `myChild` as a child of `myComponent` + invoke(myChild) + // Or, because it's actually an extension function on UIComponent, one could also call it like this: + myChild.invoke() + // The name may seem a bit odd, but that's because it's also an operator function, + // so the normal way to call it is actually just: + myChild() + + // This call may also receive a Modifier to be applied to the child as well as a block that opens another + // `LayoutScope`, this time for the child: + myChild(Modifier.fillParent()) { + // Adds `myInnerChild` as a child of `myChild` + myInnerChild() + } + + // But `myChild` doesn't have to be declared in a variable outside, it could also be declared inline, though + // this is usually discouraged if it's more than just a simple constructor call: + UIContainer()() + // Note the double `()`: the first one is the constructor `UIContainer` call, the second is the call to `invoke` + // that adds it as a child and can receive a Modifier and a block that opens another layout scope. +} +``` + +### Modifier + +A `Modifier` expresses a set of configurations/modifications that one wishes to apply to a given component. +There are modifiers for a variety of things like position, size, color, effects, callbacks, etc. + +One modifier, unlike a `Constraint`, is also explicitly meant to be re-usable on multiple components, such that a more +complex modifier can be build once, stored in a variable and then re-used for multiple components. + +#### DSL + +Modifiers have their own mini-DSL that allows them to be easily composed. + +You can get an empty modifier that does nothing with simply `Modifier` (syntactically, that's the companion object of +thi `Modifier` interface). You can then call various extension methods on this `Modifier` to add extra modifications +that should happen, like `Modifier.width(100).height(20)`. + +So with most modifiers, you don't actually get a `Modifier` instance directly, you merely get a constructor extension +method to tag the modifier onto your existing modifier because that's usually more convenient. + +When you do however have two modifiers instances that you want to chain together, you can do so via the `then` method, +like `modifierA.then(modifierB)`. The resulting modifier will first apply all modifications from `modifierA` and then +all modifications from `modifierB`. You can also call `then` as an infix operator, like `modifierA then modifierB`. + +Most of the extension methods should simply be defined as `fun Modifier.something() = this then ...`. + +#### Sizing modifiers + +The two main ways to size component hierarchies is either top-down or bottom-up, i.e. either the parent component has a +fixed size (such as the screen) and its children try to grow as big as there is space, or the child has a fixed size and +the parent tries to shrink as far as possible while still containing the child. + +Usually any given screen will make use of both methods and they will meet at some point in the middle, e.g. a button is +sized as big as the text it contains plus some padding but the container in which the button resides in is as big as +possible (and the button may for example be centered within it). + +The point at which these meet is also frequently different depending on the axis. E.g. a button may be as high as +required by its text but as wide as its parent permits. + +Elementa does not currently allow for both approaches to be applied to the same component at the same time. + +##### Fixed size + +The `width`/`height` modifiers will assign a fixed size in pixels to a component. + +They do have overloads that accept another component and copy its size, though these are rarely used. + +Not quite fixed but dependent on neither parent nor children, the `widthAspect`/`heightAspect` modifiers will set the +width/height of a component to a multiple of its height/width. + +##### Top-down + +The single most common modifier for trying to grow a component as big as its parent permits is `fillParent`. +Its first argument is the fraction it should attempt to grow to (e.g. `0.5` would make it grow to 50% of the parent's +size). +Its second argument is a fixed padding in pixels that it should maintain on each side. + +E.g. if the parent is 10 pixels wide, then with `Modifier.fillParent(0.5, 1)` the child will be `0.5 * 10 - 1 * 2 = 3` + +Similarly `fillWidth` and `fillHeight` can be used to configure a single axis. + +Another, less commonly used but still important modifier is `fillRemainingWidth`/`fillRemainingHeight` which can only +be used by a single child and will cause that child to take up any remaining space in the parent. + +##### Bottom-up + +The `childBasedWidth`/`childBasedHeight` will size the component to match the total size of its children along the +respective axis. +The `childBasedMaxWidth`/`childBasedMaxHeight` will size the component to match the biggest of its children along the +respective axis. + +Both of the above accept an optional `padding` parameter which will add an extra, fixed amount of pixels for each side +to the width/height of this component. That is, the padding is between this component and all its children. Not between +any two children individually: `this.width = padding + sum(child.width) + padding`. + +It should be noted that, unless you're using the padding parameter, you usually don't need to explicitly use any of +these because the common `box`, `row`, and `column` containers use them by default. + +#### Alignment modifiers + +If there is more spaces in your parent than your child needs, you may need to specify how it should be aligned inside +its parent. The `alignHorizontal`/`alignVertical` modifiers will set the `x`/`y` position of the component according to +the given `Alignment`. The `alignBoth` modifier will use the same alignment for both axes. + +Note that the common `box` container, as well as the secondary axes of `row` and `column` already use +`Aligntment.Center` by default for all their children, so often you do not need to explicitly set the alignment. +The primary axes of `row` and `column` use the `Arrangement` system for positioning, so Alignment does not apply there. + +The three most common alignments are `Start`, `Center` and `End`. + +`Start` and `End` can additionally accept an optional `padding` parameter. + +`Center` puts the component in the center of its parent aligned to the nearest full MC pixel in the context of its +parent. E.g. if the component is 2 in height and its parent is 5 in height, then this will place the component at one +pixel distance from the top of its parent, and two pixels from the bottom. +This is usually preferred design-wise. +You can get the true center (1.5 in above example) with the `TrueCenter` alignment. + +#### Constraint modifiers + +There exist `BasicXModifier` where `X` may be replaced with any of the standard constraint types which simply set the +given constraint on the component. + +Note that usually you shouldn't need these, there's usually a more high-level modifier or container you can use instead. +These only exist as an escape hatch. + +#### Conditional modifiers + +Modifiers can be dynamically applied and reverted in response to the value of a `State`. + +The main primitive is an overload of `then` that takes a `State` as its argument and applies the modifier in +the State, reverting it and re-applying whenever the State changes. + +For the special case of `State` a `whenTrue` modifier exists that applies a given modifier only while the given +state is `true` (and optionally applies a different modifier while it isn't). + +#### Event modifiers + +There exist modifiers that register a callback on the component for various events such as mouse enter, mouse leave, +left click, etc. + +Note that you usually don't want to use the mouse enter/leave callbacks because they are rather coarse, see the Hovering +section instead. + +#### Custom modifiers + +So what exactly is a modifier? How would I define my own? +Simply put, it's a function that can apply a change to a component, and returns another function to undo the change +again: +```kotlin +interface Modifier { + fun applyToComponent(component: UIComponent): () -> Unit +} +``` + +If it is not possible to cleanly undo the change, or if it is difficult to implement and highly unlikely to ever be +used, the undo function may simply throw an `UnsupportedOperationException`. + +There even exists an overload of `then` that takes such a function directly, so you can easily define your own modifier +extensions like this: +```kotlin +fun Modifier.something() = this then { + // The component is passed as the receiver, so you can simply call its methods + val orgConstraint = constraints.x + constrain { + // Do keep in mind that modifiers are supposed to be re-usable, so you need to create a new constraint here + // every time, you cannot for example re-use a single constraint passed via arguments. + // That's why the BasicXModifier takes a constraint factory as its argument rather than a single constraint. + x = 10.pixels + } + + // And finally return a function that will clean up your change (or throw a NotImplementedError if your modifier + // can/does not support that) + { + constrain { + x = orgConstraint + } + } +} +``` + +### Containers + +While not strictly enforced by Elementa, a component tree is generally built from a whole bunch of arbitrarily nested +containers (tree nodes) with content components (tree leafs) at the bottom. + +Most UIs can be broken down into just three types of fundamental container types: +- Simple `row`s that contain multiple children left to right +- Simple `column`s that contain multiple children top to bottom +- Plain `box`es that contain one or more children in no particular layout + +Due to how common these are, the Layout DSL has dedicated methods to easily create them and most importantly apply their +layouts in an intuitive way. + +```kotlin +window.layout { + box(Modifier.width(500).height(500)) { + column { + row { + text("top left") + text("top right") + } + text("*second row*") + } + } +} +``` + +If for some reason you need to refer to one of these at a later point, you can store their return value in a local +variable or field: +```kotlin +val wrapper: UIComponent +val content: UIComponent +window.layout { + wrapper = box(Modifier.width(500).height(500)) { + content = column { + // ... + } + } +} +``` + +#### box + +A `box` is a plain container, fairly similar to `UIContainer`. +It does however have different defaults for its size as well as the position of all its children, and it functions as +expected with the `color` modifier (it's more like `UIBlock` in that respect). + +By default a `box` will try to match the size of its children. Or rather, child, because if there are multiple things +that should go into the box, it's usually better to wrap those with either `row` or `column`. +`box` is usually only used to add padding or a fixed size and/or background color. + +The default position for children of `box` is `Alignment.Center`. + +E.g. a button 100x20 with a 1px outline: +```kotlin +box(Modifier.width(100f).height(20f).color(outlineColor)) { + box(Modifier.fillParent(padding = 1f).color(backgroundColor)) { + text(label) + } +} +``` + +#### row + +A `row` is a container fairly similar to `box` except that it is meant to handle multiple children arranged horizontally +in some way. +As such, it can accept not just a modifier but also a horizontal `Arrangement` and a default vertical `Alignment`. + +By default a `row` will try to match the height of its tallest child and the width of all its children summed up plus +any padding as specified by the arrangement. +That is, it will try to be as small as it can be, just like all the other common containers. + +If a child is less tall than its parent row, i.e. if it could float up and down, it will be positioned vertically +according to the passed `Alignment` (unless a different alignment was applied to the specific child directly). + +The way surplus space is distributed on the main, horizontal axis is determined by the `Arrangement`. +See the Arrangement section for more information. + +Note: Currently the default Alignment is `spacedBy(0, FloatPosition.Left)`, this may be changed to + `FloatPosition.Center` in the future. + +#### column + +See `row` and swap horizontal and vertical. + +#### flowContainer + +A `flowContainer` acts similar to a `row` except that it expects to be limited in width and will start new rows when +no more items fit into the current one. + +The `minSeparation` argument determines the minimal horizontal padding between any two children in the same row. +The `verticalSeparation` argument determines the vertical padding between rows. + +Note: This container is likely subject to change in the future because its design wasn't very thought out and it + currently only serves a single use-case. + In particular it currently suffers from the following assumptions: + +- it assumes that all children are the same size +- it assumes `Arrangement.SpaceBetween` for any surplus space +- it assumes the row as its primary axis, there's no way to change it to fill columns first + +#### scrollable + +A `scrollable` is like a `box` with a single child which can be scrolled vertically and/or horizontally if it is larger +than the scrollable on the given axis. +Content that ends up outside the bounds of the scrollable will not be rendered. + +If the child is smaller than the parent, it will by default be centered (just like `box`). +If you wish to have multiple children, it is recommended that you wrap them in a `column`, `row` or other container +according to your needs as there is no way to change the default arrangement of the scrollable. + +```kotlin +scrollable(Modifier.fillHeight(), vertictal = true) { + column { + text("top") + spacer(height = 1000) + text("bottom") + } +} +``` + +Note: This component has not yet seen much use and may still need some refinement. + +Note: The `scrollable` method returns an instance of `ScrollComponent`. This may change in the future and you are + advised to refrain from using most of its functionality as it is very overloaded and will often act different + than what you would expect. + Generally the only things that are safe to use are the scroll events and `scrollTo`-type methods. + +#### lazyBox + +Lazily initializes the inner scope by first only placing a `box` as described by the given `modifier` without any +children and only initializing the inner scope once that box has been rendered once. + +This should be a last reserve for initializing a large list of poorly optimized components, not a common shortcut to +"make it not lag". Properly profiling and fixing initialization performance issues should always be preferred. + +### Content + +Similar to the previous "Containers" section, while one could just declare all their components in a field or directly +in-line, some components are so common that more convenient shorthands exist. + +There's not really anything special about most of these, so they don't need much explanation: +- `text`: Creates single line of text (`EssentialUIText`) +- `wrappedText`: Creates text that wraps into multiple lines if there is not enough space in its parent (`EssentialUIWrappedText`) +- `icon`: Creates an icon with a shadow (`ShadowIcon`) + +#### spacer + +The `spacer` method creates simple, invisible, one-dimensional components. Their sole purpose is to take up a specific +amount of space at one specific place anywhere between/before/after regular components/containers. + +```kotlin +row { + spacer(width = 2f) + text("Hello") + spacer(width = 10f) + text("World") +} +// results in: | Hello World| +``` + +When to use `spacer` or `Arrangement` often depends more on the intend behind the layout than anything else. +If you just want some arbitrary amount of extra space somewhere, then `spacer` is probably want you want. +If you want there to be a symmetrical padding inside your component, then maybe `spacer` isn't the best for the job. + +If you have non-symmetrical padding, frequently that can be broken down into a symmetrical part and an extra part (but +only do so if that makes from a layout point of view), and then both can be wrapped up into a `row` or `container` +depending on the axis you're working with. + +```kotlin +// V V one space each +// | a b c | +// ^ 7 spaces ^ 2 spaces +// could be written as: +row { + spacer(width = 7f) + row(Arrangement.spacedBy(1f)) { + text("a") + text("b") + text("c") + } + spacer(width = 2f) +} +// and depending on why you want the space to be there, that may be totally reasonable. +// But if parts of the space are meant as padding around the text, and the remainder is just to keep space from +// whatever is to the left of the row, then introducing another container may be preferable as now if we want to +// increase the padding around the content, we don't have to modify two magic numbers: +row { + spacer(width = 5f) + box(Modifier.childBasedWidth(padding = 2f)) { + row(Arrangement.spacedBy(1f)) { + text("a") + text("b") + text("c") + } + } +} +``` + +Frequently, introducing another layer, even if it is seemingly redundant based on what is drawn, does actually make more +sense than using spacers because it has semantic significance in the layout. + +The only pattern that should categorically be avoided is using spacer in a `row`/`column` that itself is using +`spacedBy` or a top-down layout with surplus space to contribute, except in the case where the spacer actually +represents an empty entry in the container. +For the above example, this would be: +```kotlin +// This does give the same result as above, but neither of the spacers represents anything tangible and the actual space +// before / after the text is different than what you would think after quickly skimming the code. +row(Arrangement.spacedBy(1f)) { + spacer(width = 6f) + text("a") + text("b") + text("c") + spacer(width = 1f) +} +``` + +#### scrollGradient + +The `scrollGradient` method adds a shadow-like gradient at the top and the bottom of a `scrollable`. +The gradient will fade in/out as you the scrollable is scrolled. That is, the top gradient won't be visible if it is +scrolled to the very top, and the bottom gradient will become invisible when it is scrolled to the very bottom. + +They will usually be added directly after the scroller in a shared box that matches the size of the scrollable: +```kotlin +box(Modifier.fillParent()) { + val scroller = scrollable(Modifier.fillParent(), vertictal = true) { + column { + text("top") + spacer(height = 1000) + text("bottom") + } + } + + val gradientHeight = Modifier.height(30) + scrollGradient(scroller, top = true, gradientHeight) + scrollGradient(scroller, top = false, gradientHeight) +} +``` + +Note: This component has not yet seen much use and may still need some refinement. + +#### Custom components + +##### Function components + +While the regular sub-class way of creating custom Elementa components can be used just fine with the Layout DSL, a +pattern that's ofter easier is to simply pull out certain parts of your Layout DSL tree into separate functions: + +```kotlin +fun LayoutScope.button(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + box(Modifier.width(100f).height(20f).color(Palette.buttonOutlineColor).onLeftClick(onClick).then(modifier)) { + box(Modifier.fillParent(padding = 1f).color(Palette.buttonBackgroundColor)) { + text(label) + } + } +} + +window.layout { + column(Arrangement.spacedBy(5f)) { + row(Arrangement.spacedBy(3f)) { + button("Yes", ::accept) + button("No", ::reject) + } + button("Cancel", ::cancel, Modifier.width(30f).height(10f)) + } +} +``` + +There is nothing magical about these functions. +They are just regular extension functions which have `LayoutScope` as their receiver and follow the general feel of +builtin content or container methods. + +They will frequently live either as inner functions (if the component is specific to one use-case) or as top-level +functions in their own file if they are reusable or big enough to warrant their own file. + +They usually have an optional Modifier argument (by convention it's usually the first optional argument) used to +configure the component (primarily its position). +Custom containers will also have an optional `block: LayoutScope.() -> Unit` argument (usually the final argument, so +it is eligible as the DSL-like trailing lambda) that configures the children. + +It is of course also possible for a function component to add multiple children in the passed scope, however this should +be used with care because the relative position / spacing of these children is not usually defined by the caller and so +the function by itself is ambiguous. And similarly the caller might expect the function to define a single child and by +then surprised that it throws off things because there's suddenly more children than expected. +So the only time this functionality may be useful is in local helper functions that are defined very close to their +usage (acting more like a template than a function component at that point); though even in these cases, often it makes +sense to add a wrapper container in the function component anyway. + +##### Class components + +Sometimes you need your custom component to be a full blown, regular Elementa component class. +But you can still use the Layout DSL to configure the inner working of such components: + +```kotlin +class Button(label: String, onClick: () -> Unit) : UIContainer() { + init { + layout(Modifier.width(100f).height(20f).color(Palette.buttonOutlineColor).onLeftClick(onClick)) { + box(Modifier.alignBoth(Alignment.Center).fillParent(padding = 1f).color(Palette.buttonBackgroundColor)) { + text(label) + } + } + } +} + +window.layout { + column(Arrangement.spacedBy(5f)) { + row(Arrangement.spacedBy(3f)) { + Button("Yes", ::accept)() + Button("No", ::reject)() + } + Button("Cancel", ::cancel)(Modifier.width(30f).height(10f)) + } +} +``` + +The main disadvantage here is that your custom component can no longer be a `box`/`row`/`column`, you need to deal with +positioning of your immediate children manually. And, if your component is sized bottom-up, you also need to deal with +the sizing of it manually. + +### Arrangement + +`Arrangement` provides a way to declare how multiple components should be arranged (i.e. where surplus space goes) on +a particular axis. + +In terms old regular Elementa, it provides position constraints (for one axis) for all children of a given container and +centrally decides where all components will go. + +Note: A single `Arrangement` cannot currently be shared between multiple rows/columns; this should be fixed at some + point, because `Alignment` and `Modifier` do allow for this (and even explicitly encourage it). + +Suppose we have a row with three equally sized children and 8 pixels of surplus space: +```kotlin +row(Modifier.width(38), arrangementGoesHere) { + box(Modifier.width(10)) + box(Modifier.width(10)) + box(Modifier.width(10)) +} +``` + +#### SpacedAround + +Simply divides the available free space in two and places it on both sides of the children: +``` +| |--------||--------||--------| | +``` + +#### SpacedBetween + +Divides up the available free space and places it between the children: +``` +||--------| |--------| |--------|| +``` + +#### SpacedEvenly + +Divides up the available free space and places it between and around the children: +``` +| |--------| |--------| |--------| | +``` + +#### spacedBy + +Uses a given fixed `spacing` between the children and positions the entire block according to the given `float`: +``` +Arrangement.spacedBy(1f, FloatPosition.Start) +||--------| |--------| |--------| | +Arrangement.spacedBy(1f, FloatPosition.Center) +| |--------| |--------| |--------| | +Arrangement.spacedBy(1f, FloatPosition.End) +| |--------| |--------| |--------|| +``` + +Unlike the previous arrangements, `spacedBy` is usually used for bottom-up layouts. If no explicit width is set on the +row, its width will be the sum of the widths of its children plus the spacing between them: + +```kotlin +row(Arrangement.spacedBy(1)) { + box(Modifier.width(10)) + box(Modifier.width(10)) + box(Modifier.width(10)) +} +// Results in ||--------| |--------| |--------|| +``` + +Note: Currently the default FloatPosition is `Start`, this may be changed to `Center` in the future. + Use of the floating parameter is actually quite rare because spacedBy in top-down layouts is quite rare and + because the same effect can be achieved by putting a box around a bottom-up spacedBy row and then simply + controlling the float of the entire row within that box. + +#### equalWeight + +Uses a given fixed `spacing` between the children and distributes remaining space **into** the children. +That is, it overwrites the width of all its children and sets them all to the same width such that no surplus space +remains. + +``` +Arrangement.equalWeight(1f) +||----------| |----------| |----------|| +``` + +Note how the children end up being 12 wide, not 10. +But it can also shrink the children: + +``` +Arrangement.equalWeight(10f) +||----| |----| |----|| +``` + +### Dynamic content + +So far we have only built static component trees but quite frequently components will only be visible under certain +circumstances (like when a certain State is true), usually this boils down calling `hide` and `show` on the component +from the state change listener. But doing this correctly is actually deceptively hard (especially keeping the correct +order between multiple conditional components). + +With Layout DSL, this is now possible and it's stupidly simple (at least to use; the implementation, not so much): +```kotlin +val myBoolState = mutableStateOf(true) +window.layout { + text("Before") + if_(myBoolState) { + text("It's true!") + } `else` { + box(Modifier.color(Color.RED)) { + text("Oh no") + } + } + text("After") +} +``` + +This will at first only evaluate one of the two inner blocks. +When the value changes, then it'll then remove all children from that block and evaluate the other block. +By default, if the value then changes again, it will have remembered the components of the original block and simple add +them back after removing the ones from the other block. + +This is usually what you want because it makes switching back and forth fast at the usually small cost of keeping +components for both in memory. +If for some reason you do not want to keep the inactive components around, you can pass `cache = false` in the `if_` +call to disable this caching. It will then re-evaluate the branches on each change. + +Note that without the cache, care must be taken to not create any memory leaks when using StateV1, as change listeners +registered on StateV1 do not get cleaned up automatically until both the state and all its listeners are eligible for +garbage collection. + +#### bind + +But what if you have more than just true and false? +`bind` will accept any state, and re-evaluate the block whenever its value changes. + +Note that unlike with `if`, since there can theoretically be an unbounded amount of values, caching is disabled for +`bind` by default. You can enable it via the optional parameter and probably should do so wherever it makes sense. + +```kotlin +val myStrState = mutableStateOf("Test") +window.layout { + bind(myStrState) { myStr -> + text("My string is $myStr") + } +} +``` + +Because it is quite common, there is a specialized variant meant for states that can be null: +```kotlin +val myStrState = mutableStateOf(null) +window.layout { + ifNotNull(myStrState) { myStr -> + text("My string is $myStr (and never null)") + } + + // Effectively equivalent to: + bind(myStrState) { myStr -> + if (myStr == null) return@bind + text("My string is $myStr (and never null)") + } +} +``` + +#### forEach + +But what if you want a variable number of components? +`forEach` will accept a `ListState` and call the block for each `T`, disposing of the correct scopes when values are +removed from the state and inserting new scopes at the right place as new values are added to the scope. + +Note that unlike with `if`, since there can theoretically be an unbounded amount of values, caching is disabled for +`forEach` by default. +You can enable it via the optional parameter and probably should do so wherever it makes sense. This is especially true +if you have a practically limited amount of values but want to implement something like search where having to re-create +all the components whenever you remove characters from your search term would be quite expensive. + +```kotlin +val myListState = mutableListStateOf("a", "b", "c") +window.layout { + forEach(myListState) { myStr -> + text(myStr) + } +} +``` + +### Hovering + +Components will frequently change their looks when they are hovered. +This is generally achieved with the `whenHovered` modifier. +For many modifiers there also exist variants with the `hovered` prefix (e.g. `hoveredColor`) which are shortcuts for +this modifier. + +```kotlin +// A box that's red when hovered and black otherwise +box(Modifier.whenHovered(Modifier.color(Color.RED), Modifier.color(Color.BLACK)).then(size)) +// or, same thing, a black box that turns red when hovered: +box(Modifier.color(Color.Black).whenHovered(Modifier.color(Color.RED)).then(size)) +// or, same thing, with the `hovered`-prefixed `color` modifier +box(Modifier.color(Color.Black).hoverColor(Color.RED).then(size)) +``` + +A hover scope is **required** to use these (see next section). +This is because aside from toy examples, you usually want one. + +#### Hover Scope + +Usually however, we don't actually care about whether any specific component, like the text of a button, is hovered. +What we really care about is whether the button as a whole is hovered. +And, if it is, then all children of the button should act as if they are hovered as well. + +Such a scope of elements (specifically a sub-tree of components), that should all act together with respect to hovering, +is declared with the `hoverScope` modifier. + +If declared with default arguments on a component, the hover state of that container will be tracked, and all +(direct and indirect) children as well as the component itself will follow that state for their `whenHovered` modifiers. + +If more control is required over when the hover state is true or false, the `hoverScope` modifier can optionally +receive a `State` to use as the hover state. + +(TODO this currently uses StateV1, and as such may cause leaks if the children are highly dynamic; need to update to V2) + +Note: The `hoverScope` modifier should not be confused with the `UIComponent.hoverScope` extension function. + The former is used to declare a new hover scope while the latter is used to retrieve the hover scope applicable + to a component like `whenHovered` does. + It should also not be confused with the `UIComponent.hoverState` extension function, which is a lower-level + function commonly used prior to the introduction of hover scopes. It simply returns a State for whether that + specific component is hovered. That is what is used by the `hoverScope` modifier if you do not pass a custom + State. + +#### Default hover scope and inheritance + +There are standalone components which will usually want to be treated as a single hover scope, e.g. a button component +will in the vast majority of cases be the root of a hover scope. +To that end, they will usually apply the `hoverScope` modifier to themselves (or `makeHoverScope` for class components). + +But what if we want to disable hovering of such a component (assuming the component doesn't have a dedicated way to do +that)? + +This is not much of a problem, calling `hoverScope` again on the same component will simply replace the default one +installed by the component itself: `Button()(Modifier.hoverScope(BasicState(false)))` + +But what if you want to use such a component as part of a larger component where hovering anywhere on the larger +component will affect that component as well? + +By default hover scopes are not inherited, meaning even though both scopes will show as hovered when you place your +cursor in such a way that it is inside both, the same is not true when it is only over the larger one. In that case, by +default, only the larger component will appear hovered. +We can however override the hover scope of the inner component as above and simply pass the hover state of the outer +component for it to use. The `inheritHoverScope` modifier when applied to the inner component does exactly that. + +## Style Guide + +This section list various code style rules related to the Layout DSL and surrounding mechanisms. +Most of these are fuzzy and much less strict than general code style guidelines and should be considered recommendations +rather than hard rules. +Where possible, you should follow these as they aid in making the code easier to read for anyone used to seeing code +that follows these rules, but if they worsen readability in some specific case, then you should not feel obliged to +follow them just for the sake of it. + +This list is likely incomplete and should be expanded whenever we find us adhering to any yet unwritten rules. + +The guiding principle which most of these follow is to keep in mind the original purpose of the Layout DSL as explained +in the "Motivation" section: Being able to understand the overall structure/layout of a GUI without having to run or +laboriously mentally evaluate them. + +### Keep it short + +Within the DSL, keep the closing parenthesis on the same line as the respective opening parenthesis. +If that makes the line too long, you're probably doing too much in there. Some of your options are: + +If you have a click handler or any other non-trivial lambda in there, move it to a function outside the Layout DSL. + +If your modifier chain is too long, remember that modifiers were meant to be re-usable, so there's usually nothing +wrong with declaring a local variable with the modifier beforehand and then using that (potentially in multiple places). +Do try to keep non-custom layout information (i.e. positioning and sizing modifiers) in-line though, as these are +usually required to understand the layout, which is the point of the DSL after all. +Another exception to this is the `hoverScope` modifier due to it conceptually being more of a property of the entire +sub-tree rather than any specific component. + +If you are deeply indented (or even if you are not yet), consider extracting out function components where it makes +semantic sense. +This is especially useful for things with click handler or other lambdas (like mapped states) as these can nicely be +put at the start of the function component, where they're still close to their usage, just not too close. + +### Miscellaneous + +- Instead of `whenHovered`, prefer using the `hovered` variants and the regular variant where those exist, + e.g. `.color(regular).hoveredColor(hovered)`. Easier to read because the regular/non-hovered variant can go first. +- When you need a `row` or `column` with non-standard arrangement but no special modifier, use the overload instead + of using a keyword argument to pass the arrangement. The keyword is quite long and standard arrangements are prefixed + by `Arrangement.` already. +- Usually `align(Center)` is redundant. See the "Containers" section. +- Avoid `onMouseEnter`/`onMouseLeave`/`whenMouseEntered`. These do not even handle occlusion properly. + See the "Hovering" section instead. +- When order of modifiers does not matter semantically, prefer + - size before position before everything else, `hoverScope` last + - width before height, x before Y diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/alignment.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/alignment.kt new file mode 100644 index 00000000..4fac8234 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/alignment.kt @@ -0,0 +1,55 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.constraints.PositionConstraint +import gg.essential.elementa.dsl.pixels +import gg.essential.elementa.common.constraints.CenterPixelConstraint + +interface Alignment { + fun applyHorizontal(component: UIComponent): () -> Unit + fun applyVertical(component: UIComponent): () -> Unit + + companion object { + @Suppress("FunctionName") + fun Start(padding: Float): Alignment = BasicAlignment { padding.pixels() } + @Suppress("FunctionName") + fun Center(roundUp: Boolean): Alignment = BasicAlignment { CenterPixelConstraint(roundUp) } + @Suppress("FunctionName") + fun End(padding: Float): Alignment = BasicAlignment { padding.pixels(alignOpposite = true) } + + val Start: Alignment = Start(0f) + val Center: Alignment = BasicAlignment { CenterPixelConstraint() } + val End: Alignment = End(0f) + + val TrueCenter: Alignment = BasicAlignment { CenterConstraint() } + } +} + +private class BasicAlignment(private val constraintFactory: () -> PositionConstraint) : Alignment { + override fun applyHorizontal(component: UIComponent): () -> Unit { + return BasicXModifier(constraintFactory).applyToComponent(component) + } + + override fun applyVertical(component: UIComponent): () -> Unit { + return BasicYModifier(constraintFactory).applyToComponent(component) + } +} + +fun Modifier.alignBoth(alignment: Alignment) = alignHorizontal(alignment).alignVertical(alignment) + +fun Modifier.alignHorizontal(alignment: Alignment) = this then HorizontalAlignmentModifier(alignment) + +fun Modifier.alignVertical(alignment: Alignment) = this then VerticalAlignmentModifier(alignment) + +private class HorizontalAlignmentModifier(private val alignment: Alignment) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + return alignment.applyHorizontal(component) + } +} + +private class VerticalAlignmentModifier(private val alignment: Alignment) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + return alignment.applyVertical(component) + } +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt new file mode 100644 index 00000000..0070125b --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt @@ -0,0 +1,259 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.config.FeatureFlags +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.* +import gg.essential.elementa.constraints.resolution.ConstraintVisitor +import gg.essential.elementa.utils.ObservableAddEvent +import gg.essential.elementa.utils.ObservableClearEvent +import gg.essential.elementa.utils.ObservableListEvent +import gg.essential.elementa.utils.ObservableRemoveEvent +import gg.essential.elementa.utils.roundToRealPixels + +abstract class Arrangement { + internal lateinit var mainAxis: Axis + internal var recalculatePositions = true + internal var recalculateSizes = true + + protected lateinit var boundComponent: UIComponent + private set + protected val lastPosValues = hashMapOf() + protected val lastSizeValues = hashMapOf() + + abstract fun layoutPositions() + open fun layoutSizes() {} + abstract fun getPadding(child: UIComponent): Float + + fun getPosValue(component: UIComponent): Float { + if (recalculatePositions) { + layoutPositions() + recalculatePositions = false + } + return lastPosValues[component] + ?: error("Component $component's position was not laid out by arrangement $this") + } + + fun getSizeValue(component: UIComponent): Float { + if (recalculateSizes) { + layoutSizes() + recalculateSizes = false + } + return lastSizeValues[component] + ?: error("Component $component's size was not laid out by arrangement $this") + } + + @Suppress("UNCHECKED_CAST") + open fun initialize(component: UIComponent) { + boundComponent = component + component.children.forEach(::conformChild) + component.children.addObserver { _, arg -> + when (val event = arg as? ObservableListEvent ?: return@addObserver) { + is ObservableAddEvent -> conformChild(event.element.value) + is ObservableRemoveEvent -> { + lastPosValues.remove(event.element.value) + lastSizeValues.remove(event.element.value) + } + is ObservableClearEvent -> { + lastPosValues.clear() + lastSizeValues.clear() + } + } + } + } + + open fun conformChild(child: UIComponent) { + when (mainAxis) { + Axis.HORIZONTAL -> child.setX(ArrangementControlledPositionConstraint(this)) + Axis.VERTICAL -> child.setY(ArrangementControlledPositionConstraint(this)) + } + } + + protected fun UIComponent.getMainAxisSize() = when (mainAxis) { + Axis.HORIZONTAL -> getWidth() + Axis.VERTICAL -> getHeight() + } + + protected fun UIComponent.getCrossAxisSize() = when (mainAxis) { + Axis.HORIZONTAL -> getHeight() + Axis.VERTICAL -> getWidth() + } + + protected fun UIComponent.getMainAxisStart() = when (mainAxis) { + Axis.HORIZONTAL -> getLeft() + Axis.VERTICAL -> getTop() + } + + protected fun UIComponent.getCrossAxisStart() = when (mainAxis) { + Axis.HORIZONTAL -> getTop() + Axis.VERTICAL -> getLeft() + } + + companion object { + val SpaceAround: Arrangement get() = SpaceAroundArrangement() + val SpaceBetween: Arrangement get() = SpaceBetweenArrangement() + val SpaceEvenly: Arrangement get() = SpaceEvenlyArrangement() + + fun spacedBy(spacing: Float = 0f, float: FloatPosition? = null): Arrangement = SpacedArrangement(spacing, float) + fun equalWeight(spacing: Float = 0f): Arrangement = EqualWeightArrangement(spacing) + } +} + +private open class SpacedArrangement( + protected val spacing: Float = 0f, + protected val floatPosition: FloatPosition? = null, +) : Arrangement() { + private var floatWarningFrames = 0 + private var floatWarningBacktrace: Throwable? = if (FeatureFlags.INTERNAL_ENABLED) + Throwable("Default for `float` will change. " + + "For the time being you should explicitly pass the value you want in cases where it matters.") + else null + + open fun getSpacing(parent: UIComponent) = spacing + + open fun getStartOffset(parent: UIComponent, spacing: Float): Float { + val childrenSize = parent.children.sumOf { it.getMainAxisSize() } + spacing * (parent.children.size - 1) + return when (floatPosition) { + null -> { + if (FeatureFlags.INTERNAL_ENABLED) { + val startResult = 0f + val centerResult = parent.getMainAxisSize() / 2 - childrenSize / 2 + if (startResult == centerResult.roundToRealPixels()) { + floatWarningFrames = 0 + startResult + } else { + // Only log if it's for more than ten frames. Temporarily incorrect results can easily happen + // because Elementa does not invalidate all constraints every frame, so if a child is added, its + // parent size might already be fixed until the next animationFrame. + if (floatWarningFrames++ > 10) { + floatWarningBacktrace?.printStackTrace() + floatWarningBacktrace = null + } + 100000f // should hopefully get their attention + } + } else { + 0f + } + } + FloatPosition.START -> 0f + FloatPosition.CENTER -> parent.getMainAxisSize() / 2 - childrenSize / 2 + FloatPosition.END -> parent.getMainAxisSize() - childrenSize + } + } + + override fun layoutPositions() { + val spacing = getSpacing(boundComponent) + var nextStart = boundComponent.getMainAxisStart() + getStartOffset(boundComponent, spacing) + boundComponent.children.forEach { + lastPosValues[it] = nextStart + nextStart += it.getMainAxisSize() + spacing + } + } + + override fun getPadding(child: UIComponent): Float { + return if (child === boundComponent.children.last()) 0f else getSpacing(boundComponent) + } +} + +private class SpaceBetweenArrangement : SpacedArrangement() { + override fun getSpacing(parent: UIComponent): Float { + return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / (parent.children.size - 1) + } +} + +private class SpaceEvenlyArrangement : SpacedArrangement() { + override fun getSpacing(parent: UIComponent): Float { + return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / (parent.children.size + 1) + } + + override fun getStartOffset(parent: UIComponent, spacing: Float): Float { + return spacing + } +} + +private class SpaceAroundArrangement : SpacedArrangement() { + override fun getSpacing(parent: UIComponent): Float { + return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / parent.children.size + } + + override fun getStartOffset(parent: UIComponent, spacing: Float): Float { + return spacing / 2 + } +} + +private class EqualWeightArrangement(spacing: Float) : SpacedArrangement(spacing, FloatPosition.CENTER) { + override fun conformChild(child: UIComponent) { + super.conformChild(child) + when (mainAxis) { + Axis.HORIZONTAL -> child.setWidth(ArrangementControlledSizeConstraint(this)) + Axis.VERTICAL -> child.setHeight(ArrangementControlledSizeConstraint(this)) + } + } + + override fun layoutSizes() { + val childCount = boundComponent.children.size + val childSize = (boundComponent.getMainAxisSize() - (childCount - 1) * spacing) / childCount + boundComponent.children.forEach { + lastSizeValues[it] = childSize + } + } +} + +private class ArrangementControlledPositionConstraint(private val arrangement: Arrangement) : PositionConstraint, PaddingConstraint { + override var cachedValue = 0f + override var recalculate = true + set(value) { + field = value + if (value) { + arrangement.recalculatePositions = true + } + } + override var constrainTo: UIComponent? + get() = null + set(_) = error("Cannot bind an arrangement-controlled constraint to another component!") + + init { + arrangement.recalculatePositions = true + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + } + + override fun getXPositionImpl(component: UIComponent) = arrangement.getPosValue(component) + + override fun getYPositionImpl(component: UIComponent) = arrangement.getPosValue(component) + + override fun getHorizontalPadding(component: UIComponent): Float { + return if (arrangement.mainAxis == Axis.HORIZONTAL) arrangement.getPadding(component) else 0f + } + + override fun getVerticalPadding(component: UIComponent): Float { + return if (arrangement.mainAxis == Axis.VERTICAL) arrangement.getPadding(component) else 0f + } +} + +private class ArrangementControlledSizeConstraint(private val arrangement: Arrangement) : SizeConstraint { + override var cachedValue = 0f + override var recalculate = true + set(value) { + field = value + if (value) { + arrangement.recalculateSizes = true + } + } + override var constrainTo: UIComponent? + get() = null + set(_) = error("Cannot bind an arrangement-controlled constraint to another component!") + + init { + arrangement.recalculateSizes = true + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + } + + override fun getWidthImpl(component: UIComponent) = arrangement.getSizeValue(component) + + override fun getHeightImpl(component: UIComponent) = arrangement.getSizeValue(component) + + override fun getRadiusImpl(component: UIComponent) = arrangement.getSizeValue(component) +} \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/axis.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/axis.kt new file mode 100644 index 00000000..81ba74c4 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/axis.kt @@ -0,0 +1,6 @@ +package gg.essential.elementa.layoutdsl + +enum class Axis { + HORIZONTAL, + VERTICAL +} \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/basicModifiers.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/basicModifiers.kt new file mode 100644 index 00000000..511a8acd --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/basicModifiers.kt @@ -0,0 +1,62 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.* + +infix fun Modifier.then(other: UIComponent.() -> () -> Unit) = this then BasicModifier(other) + +private class BasicModifier(private val setup: UIComponent.() -> () -> Unit) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + return component.setup() + } +} + +class BasicXModifier(private val constraint: () -> XConstraint) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldX = component.constraints.x + component.setX(constraint()) + return { + component.setX(oldX) + } + } +} + +class BasicYModifier(private val constraint: () -> YConstraint) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldY = component.constraints.y + component.setY(constraint()) + return { + component.setY(oldY) + } + } +} + +class BasicWidthModifier(private val constraint: () -> WidthConstraint) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldWidth = component.constraints.width + component.setWidth(constraint()) + return { + component.setWidth(oldWidth) + } + } +} + +class BasicHeightModifier(private val constraint: () -> HeightConstraint) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldHeight = component.constraints.height + component.setHeight(constraint()) + return { + component.setHeight(oldHeight) + } + } +} + +class BasicColorModifier(private val constraint: () -> ColorConstraint) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldColor = component.constraints.color + component.setColor(constraint()) + return { + component.setColor(oldColor) + } + } +} \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt new file mode 100644 index 00000000..68745546 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt @@ -0,0 +1,63 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.ColorConstraint +import gg.essential.elementa.constraints.animation.Animations +import gg.essential.elementa.dsl.animate +import gg.essential.elementa.dsl.toConstraint +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.state.toConstraint +import gg.essential.elementa.common.onSetValueAndNow +import gg.essential.elementa.state.v2.color.toConstraint +import gg.essential.elementa.state.v2.toV2 +import gg.essential.elementa.util.hasWindow +import java.awt.Color +import gg.essential.elementa.state.v2.State as StateV2 + +fun Modifier.color(color: Color) = color(color.toConstraint()) + +@Deprecated("Using StateV1 is discouraged, use StateV2 instead") +fun Modifier.color(color: State) = color(color.toConstraint()) + +fun Modifier.color(color: ColorConstraint) = this then BasicColorModifier { color } + +fun Modifier.color(color: StateV2) = color(color.toConstraint()) + +fun Modifier.hoverColor(color: Color, duration: Float = 0f) = hoverColor(BasicState(color), duration) + +@Deprecated("Using StateV1 is discouraged, use StateV2 instead") +fun Modifier.hoverColor(color: State, duration: Float = 0f) = whenHovered(if (duration == 0f) Modifier.color(color) else Modifier.animateColor(color, duration)) + +fun Modifier.hoverColor(color: StateV2, duration: Float = 0f) = whenHovered(if (duration == 0f) Modifier.color(color) else Modifier.animateColor(color, duration)) + +fun Modifier.animateColor(color: Color, duration: Float = .3f) = animateColor(BasicState(color), duration) + +fun Modifier.animateColor(color: State, duration: Float = .3f) = animateColor(color.toV2(), duration) + +fun Modifier.animateColor(color: StateV2, duration: Float = .3f) = this then AnimateColorModifier(color, duration) + +private class AnimateColorModifier(private val colorState: StateV2, private val duration: Float) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldColor = component.constraints.color + + fun animate(color: ColorConstraint) { + if (component.hasWindow) { + component.animate { + setColorAnimation(Animations.OUT_EXP, duration, color) + } + } else { + component.setColor(color) + } + } + + val removeListenerCallback = colorState.onSetValueAndNow(component) { + animate(it.toConstraint()) + } + + return { + removeListenerCallback() + animate(oldColor) + } + } +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt new file mode 100644 index 00000000..c0fce169 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt @@ -0,0 +1,249 @@ +@file:OptIn(ExperimentalContracts::class) + +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.ScrollComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.ChildBasedMaxSizeConstraint +import gg.essential.elementa.constraints.ChildBasedSizeConstraint +import gg.essential.elementa.constraints.WidthConstraint +import gg.essential.elementa.dsl.boundTo +import gg.essential.elementa.dsl.childOf +import gg.essential.elementa.dsl.coerceAtLeast +import gg.essential.elementa.dsl.percent +import gg.essential.elementa.dsl.pixels +import gg.essential.elementa.common.HollowUIContainer +import gg.essential.elementa.common.constraints.AlternateConstraint +import gg.essential.elementa.common.constraints.SpacedCramSiblingConstraint +import gg.essential.elementa.state.v2.* +import gg.essential.universal.UMatrixStack +import java.awt.Color +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +fun LayoutScope.box(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit = {}): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + val container = TransparentBlock().apply { + componentName = "BoxContainer" + setWidth(ChildBasedSizeConstraint()) + setHeight(ChildBasedSizeConstraint()) + } + container.addChildModifier(Modifier.alignHorizontal(Alignment.Center).alignVertical(Alignment.Center)) + return container(modifier = modifier, block = block) +} + +fun LayoutScope.row(horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + return row(Modifier, horizontalArrangement, verticalAlignment, block) +} +fun LayoutScope.row(modifier: Modifier, horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + val rowContainer = TransparentBlock().apply { + componentName = "RowContainer" + setWidth(ChildBasedSizeConstraint()) + setHeight(ChildBasedMaxSizeConstraint()) + } + + rowContainer.addChildModifier(Modifier.alignVertical(verticalAlignment)) + + rowContainer(modifier = modifier, block = block) + horizontalArrangement.mainAxis = Axis.HORIZONTAL + horizontalArrangement.initialize(rowContainer) + + return rowContainer +} + +fun LayoutScope.column(verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + return column(Modifier, verticalArrangement, horizontalAlignment, block) +} +fun LayoutScope.column(modifier: Modifier, verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + val columnContainer = TransparentBlock().apply { + componentName = "ColumnContainer" + setWidth(ChildBasedMaxSizeConstraint()) + setHeight(ChildBasedSizeConstraint()) + } + + columnContainer.addChildModifier(Modifier.alignHorizontal(horizontalAlignment)) + + columnContainer(modifier = modifier, block = block) + verticalArrangement.mainAxis = Axis.VERTICAL + verticalArrangement.initialize(columnContainer) + + return columnContainer +} + +fun LayoutScope.flowContainer( + modifier: Modifier = Modifier, + // TODO ideally we can make this use Arrangement on a per-row basis, currently it's just always SpaceBetween + minSeparation: () -> WidthConstraint = { 0.pixels }, + verticalSeparation: () -> WidthConstraint = { 0.pixels }, + block: LayoutScope.() -> Unit = {}, +): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + val flowContainer = TransparentBlock().apply { + componentName = "FlowContainer" + setHeight(ChildBasedSizeConstraint()) + } + + val childModifier = Modifier + .then(BasicXModifier { SpacedCramSiblingConstraint(minSeparation(), 0.pixels) }) + .then(BasicYModifier { SpacedCramSiblingConstraint(minSeparation(), 0.pixels, verticalSeparation()) }) + flowContainer.addChildModifier(childModifier) + + flowContainer(modifier = modifier, block = block) + + return flowContainer +} + +fun LayoutScope.scrollable( + modifier: Modifier = Modifier, + horizontal: Boolean = false, + vertical: Boolean = false, + pixelsPerScroll: Float = 15f, + block: LayoutScope.() -> Unit = {}, +): ScrollComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + if (!horizontal && !vertical) { + throw IllegalArgumentException("Either `horizontal` or `vertical` or both must be `true`.") + } + + val outer = ScrollComponent( + horizontalScrollEnabled = horizontal, + verticalScrollEnabled = vertical, + pixelsPerScroll = pixelsPerScroll, + ) + val inner = outer.children.first() + // Need an extra wrapper because ScrollComponent does stupid things which breaks padding in the inner component + val content = HollowUIContainer() childOf outer // actually adds to `inner` because ScrollComponent redirects it + + outer.apply { + componentName = "scrollable" + setWidth(ChildBasedSizeConstraint() boundTo content) + setHeight(ChildBasedSizeConstraint() boundTo content) + } + inner.apply { + componentName = "scrollableInternal" + setWidth(100.percent boundTo content) + setHeight(100.percent boundTo content) + } + content.apply { + componentName = "scrollableContent" + setWidth(AlternateConstraint(ChildBasedSizeConstraint(), 100.percent boundTo outer).coerceAtLeast(AlternateConstraint(100.percent boundTo outer, 0.pixels))) + setHeight(AlternateConstraint(ChildBasedSizeConstraint(), 100.percent boundTo outer).coerceAtLeast(AlternateConstraint(100.percent boundTo outer, 0.pixels))) + addChildModifier(Modifier.alignBoth(Alignment.Center)) + } + + outer(modifier = modifier) + + block(LayoutScope(content, this)) + + return outer +} + +fun LayoutScope.floatingBox( + modifier: Modifier = Modifier, + floating: State = stateOf(true), + block: LayoutScope.() -> Unit = {}, +): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + + fun UIComponent.isMounted(): Boolean = + parent == this || (this in parent.children && parent.isMounted()) + + // Elementa's floating system is quite tricky to work with because components that are floating are added into a + // persistent list but will not automatically be removed from that list when they're removed from the component + // tree, and as such will continue to render. + // This class tries to work around that by canceling `draw` and automatically un-floating itself in such cases, + // as well as automatically adding itself back to the floating list when it is put back into the component tree. + class FloatableContainer : UIBlock(Color(0, 0, 0, 0)) { + val shouldBeFloating: Boolean + get() = floating.get() + + // Keeps track of the current floating state because the parent field of the same name is private + @set:JvmName("setFloating_") + var isFloating: Boolean = false + set(value) { + if (field == value) return + field = value + setFloating(value) + } + + override fun animationFrame() { + // animationFrame is called from the regular tree traversal, so it's safe to directly update the floating + // list from here + isFloating = shouldBeFloating + + super.animationFrame() + } + + override fun draw(matrixStack: UMatrixStack) { + // If we're no longer mounted in the component tree, we should no longer draw + if (!isMounted()) { + // and if we're still floating (likely the case because that'll be why we're still drawing), then + // we also need to un-float ourselves + if (isFloating) { + // since this is likely called from the code that iterates over the floating list to draw each + // component, modifying the floating list here would result in a CME, so we need to delay this. + Window.enqueueRenderOperation { + // Note: we must not assume that our shouldBe state hasn't changed since we scheduled this + isFloating = shouldBeFloating && isMounted() + } + } + return + } + + // If we should be floating but aren't right now, then this isn't being called from the floating draw loop + // and it should be safe for us to immediately set us as floating. + // Doing so will add us to the floating draw loop and thereby allow us to draw later. + if (shouldBeFloating && !isFloating) { + isFloating = true + return + } + + // If we should not be floating but are right now, then this is similar to the no-longer-mounted case above + // i.e. we want to un-float ourselves. + // Except we're still mounted so we do still want to draw the content (this means it'll be floating for one + // more frame than it's supposed to but there isn't anything we can really do about that because the regular + // draw loop has already concluded by this point). + if (!shouldBeFloating && isFloating) { + Window.enqueueRenderOperation { isFloating = shouldBeFloating } + super.draw(matrixStack) + return + } + + // All as it should be, can just draw it + super.draw(matrixStack) + } + } + + val container = FloatableContainer().apply { + componentName = "floatingBox" + setWidth(ChildBasedSizeConstraint()) + setHeight(ChildBasedSizeConstraint()) + } + container.addChildModifier(Modifier.alignBoth(Alignment.Center)) + return container(modifier = modifier, block = block) +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt new file mode 100644 index 00000000..c3073234 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt @@ -0,0 +1,71 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.events.UIClickEvent +import gg.essential.elementa.state.v2.toV2 +import gg.essential.elementa.util.hoverScope +import gg.essential.elementa.util.makeHoverScope + +import gg.essential.elementa.state.State as StateV1 +import gg.essential.elementa.state.v2.State as StateV2 + +fun Modifier.onMouseEnter(callback: UIComponent.() -> Unit) = this then { + onMouseEnter(callback) + return@then { mouseEnterListeners.remove(callback) } +} + +fun Modifier.onMouseLeave(callback: UIComponent.() -> Unit) = this then { + onMouseLeave(callback) + return@then { mouseLeaveListeners.remove(callback) } +} + +inline fun Modifier.onLeftClick(crossinline callback: UIComponent.() -> Unit) = this then { + val listener: UIComponent.(event: UIClickEvent) -> Unit = { + if (it.mouseButton == 0) { + callback() + } + } + onMouseClick(listener) + return@then { mouseClickListeners.remove(listener) } +} + +fun Modifier.whenMouseEntered(hoverModifier: Modifier): Modifier { + lateinit var reverse: () -> Unit + return this + .onMouseEnter { reverse = hoverModifier.applyToComponent(this) } + .onMouseLeave { reverse() } +} + +/** Declare this component and its children to be in a hover scope. See [makeHoverScope]. */ +fun Modifier.hoverScope(state: StateV1? = null) = + then { makeHoverScope(state); { throw NotImplementedError() } } + +/** Declare this component and its children to be in a hover scope. See [makeHoverScope]. */ +fun Modifier.hoverScope(state: gg.essential.elementa.state.v2.State) = + then { makeHoverScope(state); { throw NotImplementedError() } } + +/** + * Replaces the existing hover scope declared on this component with one which simply inherits from the parent scope. + * Can effectively be used to remove a scope from an otherwise self-contained component to join it with other custom + * components surrounding it. + */ +fun Modifier.inheritHoverScope() = + then { makeHoverScope(hoverScope(parentOnly = true)); { throw NotImplementedError() } } + +/** + * Applies [hoverModifier] while the component is hovered, otherwise applies [noHoverModifier] (or nothing by default). + * + * Whether a component is considered "hovered" depends solely on whether its [hoverScope] says that it is. + * It is not necessarily related to whether the mouse cursor is on top of the component (e.g. the label of a button may + * be considered hovered when the overall button is hovered, even when the cursor isn't on the text itself). + * + * A [Modifier.hoverScope] is **require** on the component or one of its parents. + */ +fun Modifier.whenHovered(hoverModifier: Modifier, noHoverModifier: Modifier = Modifier): Modifier = + then { Modifier.whenTrue(hoverScope(), hoverModifier, noHoverModifier).applyToComponent(this) } + +/** + * Provides the [hoverScope] to be evaluated in a lambda which returns a modifier + */ +fun Modifier.withHoverState(func: (StateV2) -> Modifier) = + then { func(hoverScope().toV2()).applyToComponent(this) } diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/float.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/float.kt new file mode 100644 index 00000000..d257d5e6 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/float.kt @@ -0,0 +1,7 @@ +package gg.essential.elementa.layoutdsl + +enum class FloatPosition { + START, + CENTER, + END +} \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt new file mode 100644 index 00000000..83d92e3f --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt @@ -0,0 +1,266 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.state.State +import gg.essential.elementa.common.ListState +import gg.essential.elementa.common.not +import gg.essential.elementa.state.v2.* +import gg.essential.elementa.util.hoveredState +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import gg.essential.elementa.state.v2.ListState as ListStateV2 +import gg.essential.elementa.state.v2.State as StateV2 + +class LayoutScope( + private val component: UIComponent, + private val parentScope: LayoutScope?, +) { + /** + * You should only use this for calling `toV1`, or any State-related methods that require a [gg.essential.elementa.state.v2.ReferenceHolder]. + */ + val stateScope: UIComponent + get() = component + + private val childrenScopes = mutableListOf() + + operator fun T.invoke(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit = {}): T { + this@LayoutScope.component.getChildModifier().applyToComponent(this) + modifier.applyToComponent(this) + + val childScope = LayoutScope(this, this@LayoutScope) + childrenScopes.add(childScope) + + childScope.block() + + val index = childScope.findNextIndexIn(component) ?: 0 + component.insertChildAt(this, index) + + return this + } + + operator fun LayoutDslComponent.invoke(modifier: Modifier = Modifier) = layout(modifier) + + @Deprecated("Use Modifier.hoverScope() and Modifier.whenHovered(), instead.") + fun hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true) = component.hoveredState(hitTest, layoutSafe) + + @Suppress("FunctionName") + fun if_(state: State, cache: Boolean = true, block: LayoutScope.() -> Unit): IfDsl { + forEach(ListState.from(state.map { if (it) listOf(Unit) else emptyList() }), cache) { block() } + return IfDsl({ !state }, cache) + } + + fun if_(state: StateV2, cache: Boolean = true, block: LayoutScope.() -> Unit): IfDsl { + return if_(state.toV1(component), cache, block) + } + + fun ifNotNull(state: State, cache: Boolean = false, block: LayoutScope.(T) -> Unit): IfDsl { + forEach(ListState.from(state.map { listOfNotNull(it) }), cache) { block(it) } + return IfDsl({ state.map { it == null } }, true) + } + + fun ifNotNull(state: StateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit): IfDsl { + return ifNotNull(state.toV1(component), cache, block) + } + + fun if_(condition: StateByScope.() -> Boolean, cache: Boolean = false, block: LayoutScope.() -> Unit): IfDsl { + return if_(stateBy(condition), cache, block) + } + + fun ifNotNull(stateBlock: StateByScope.() -> T?, cache: Boolean = false, block: LayoutScope.(T) -> Unit): IfDsl { + return ifNotNull(stateBy(stateBlock), cache, block) + } + + class IfDsl(internal val elseState: () -> State, internal var cache: Boolean) + + infix fun IfDsl.`else`(block: LayoutScope.() -> Unit) { + if_(elseState(), cache, block) + } + + /** Makes available to the inner scope the value of the given [state]. */ + fun bind(state: State, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { + forEach(ListState.from(state.map { listOf(it) }), cache) { block(it) } + } + + /** Makes available to the inner scope the value of the given [state]. */ + fun bind(state: StateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { + bind(state.toV1(component), cache, block) + } + + /** Makes available to the inner scope the value derived from the given [stateBlock]. */ + fun bind(stateBlock: StateByScope.() -> T, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { + bind(stateBy(stateBlock), cache, block) + } + + /** + * Repeats the inner block for each element in the given list state. + * If the list state changes, components from old scopes are removed and new scopes are created and initialized as + * required. + * Order relative to other components within the same [layout] call is kept automatically at all times. + * + * Note that given old scopes are discarded, care must be taken to not inadvertently leak child components, e.g. via + * listener subscriptions or other links that cannot be cleaned up automatically. + * If the space of possible [T] is very limited, [cache] may be set to `true` to retain old scopes after they are + * removed and to re-use them if their corresponding [T] value is re-introduced at a later time. + * This requires that [T] be usable as a key in a HashMap. + */ + fun forEach(state: ListState, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { + val forEachScope = LayoutScope(component, this@LayoutScope) + childrenScopes.add(forEachScope) + + val cacheMap = + if (cache) mutableMapOf>() + else null + fun getCacheEntry(key: T) = cacheMap?.getOrPut(key) { mutableListOf() } + + fun add(index: Int, element: T) { + val cachedScope = getCacheEntry(element)?.removeLastOrNull() + if (cachedScope != null) { + forEachScope.childrenScopes.add(index, cachedScope) + if (forEachScope.isVirtualScopeMounted()) { + cachedScope.remount() + } + } else { + val newScope = LayoutScope(component, forEachScope) + forEachScope.childrenScopes.add(index, newScope) + newScope.block(element) + if (!forEachScope.isVirtualScopeMounted()) { + newScope.unmount() + } + } + } + + fun remove(index: Int, element: T) { + val removedScope = forEachScope.childrenScopes.removeAt(index) + removedScope.unmount() + getCacheEntry(element)?.add(removedScope) + } + + fun clear(elements: List) { + forEachScope.childrenScopes.forEachIndexed { index, layoutScope -> + layoutScope.unmount() + getCacheEntry(elements[index])?.add(layoutScope) + } + forEachScope.childrenScopes.clear() + } + + state.get().forEachIndexed(::add) + state.onAdd(::add) + state.onRemove(::remove) + state.onSet { index, element, oldElement -> + remove(index, oldElement) + add(index, element) + } + state.onClear(::clear) + } + + /** + * StateV2 support for forEach + */ + fun forEach(list: ListStateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit) = + forEach(ListState.from(list.toV1(component)), cache, block) + + /** Whether this scope is a virtual "forEach" scope. These share their target component with their parent scope. */ + private fun isVirtual(): Boolean { + return parentScope?.component == component + } + + /** Whether this virtual ("forEach") scope is presently (virtually) mounted inside its parent [component]. */ + private fun isVirtualScopeMounted(): Boolean { + val parent = parentScope ?: return true // if we don't have a parent, we can only assume that we're mounted + + // Check if this scope is currently mounted in its parent scope + if (this !in parent.childrenScopes) { + return false + } + + // If the parent scope is a virtual scope as well, we can only be mounted if it is + if (parent.isVirtual()) { + return parent.isVirtualScopeMounted() + } + + return true + } + + /** Removes from [component] all components that where added within this scope. */ + private fun unmount() { + for (childScope in childrenScopes) { + if (childScope.component == this.component) { + // This is a forEach scope, recurse down into its children + childScope.unmount() + } else { + component.removeChild(childScope.component) + } + } + } + + /** Inverse of [unmount]. Re-adds to [component] all components that where added within this scope. */ + private fun remount() { + for (childScope in childrenScopes) { + if (childScope.component == this.component) { + // This is a forEach scope, recurse down into its children + childScope.remount() + } else { + val index = childScope.findNextIndexIn(component) ?: 0 + component.insertChildAt(childScope.component, index) + } + } + } + + /** + * Finds the index in [parent]'s children at which a component should be inserted to end up right after [component]. + * Works even when [component] is not currently present in [parent] by recursively searching the layout tree. + * If [parent] has no children in the layout tree, `null` is returned. + */ + private fun findNextIndexIn(parent: UIComponent): Int? { + /** Searches this subtree for an index. */ + fun LayoutScope.searchSubTree(range: IntProgression = childrenScopes.indices.reversed()): Int? { + if (component == parent) { + // This is a node in the subtree belonging to [parent] (e.g. the main scope, or a forEach scope), + // so we recursively search the children + for (index in range) { + childrenScopes[index].searchSubTree() + ?.let { return it } + } + return null + } else { + // Check if this child is currently present within its parent + return parent.children.indexOf(component).takeIf { it != -1 } + } + } + + /** Searches by recursively traversing upwards the tree if no index can be found in this subtree. */ + fun LayoutScope.search(beforeScope: LayoutScope): Int? { + val beforeIndex = childrenScopes.indexOf(beforeScope) + + // Check all preceding siblings + searchSubTree((0 until beforeIndex).reversed()) + ?.let { return it } + + // If we can't find anything there, check the siblings one level up, recursively + val parentScope = parentScope ?: return null + // Though once we've found a scope that targets [parent], then we can stop ascending if we find a scope + // that doesn't target [parent] (i.e. one for parent's parent) because we only want to search all scopes + // targeting [parent]. + if (component == parent && parentScope.component != parent) { + return null + } + return parentScope.search(this) + } + + return parentScope?.search(this)?.let { it + 1 } + } +} + +@OptIn(ExperimentalContracts::class) +inline fun UIComponent.layout(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit) { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + modifier.applyToComponent(this) + LayoutScope(this, null).block() +} + +interface LayoutDslComponent { + fun LayoutScope.layout(modifier: Modifier = Modifier) +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt new file mode 100644 index 00000000..4641cef1 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt @@ -0,0 +1,35 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.Window +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.universal.UMatrixStack + +/** + * Lazily initializes the inner scope by first only placing a [box] as described by [modifier] without any children and + * only initializing the inner scope once that box has been rendered once. + * + * This should be a last reserve for initializing a large list of poorly optimized components, not a common shortcut to + * "make it not lag". Properly profiling and fixing initialization performance issues should always be preferred. + */ +fun LayoutScope.lazyBox(modifier: Modifier = Modifier.fillParent(), block: LayoutScope.() -> Unit) { + val initialized = BasicState(false) + box(modifier) { + if_(initialized, cache = false /** don't need it; once initialized, we are never going back */) { + block() + } `else` { + LazyComponent(initialized)(Modifier.fillParent()) + } + } +} + +private class LazyComponent(private val initialized: State) : UIContainer() { + override fun draw(matrixStack: UMatrixStack) { + super.draw(matrixStack) + + Window.enqueueRenderOperation { + initialized.set(true) + } + } +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt new file mode 100644 index 00000000..fe3e08d5 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt @@ -0,0 +1,34 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.UIConstraints +import gg.essential.elementa.effects.Effect + +interface Modifier { + /** + * Applies this modifier to the given component, and returns a function which can be called to undo the applied changes. + */ + fun applyToComponent(component: UIComponent): () -> Unit + + infix fun then(other: Modifier) = if (other === Modifier) this else CombinedModifier(this, other) + + companion object : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit = {} + + override infix fun then(other: Modifier) = other + } +} + +private class CombinedModifier( + private val first: Modifier, + private val second: Modifier +) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val undoFirst = first.applyToComponent(component) + val undoSecond = second.applyToComponent(component) + return { + undoSecond() + undoFirst() + } + } +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/size.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/size.kt new file mode 100644 index 00000000..8980568c --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/size.kt @@ -0,0 +1,149 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.* +import gg.essential.elementa.constraints.animation.* +import gg.essential.elementa.dsl.* +import gg.essential.elementa.common.constraints.FillConstraintIncludingPadding +import gg.essential.elementa.common.onSetValueAndNow +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.stateOf +import gg.essential.elementa.util.hasWindow + +fun Modifier.fillParent(fraction: Float = 1f, padding: Float = 0f) = + fillWidth(fraction, padding).fillHeight(fraction, padding) + +/** Fills [fraction] of parent width minus [leftPadding] and aligns [leftPadding] pixels from the left */ +fun Modifier.fillWidth(fraction: Float = 1f, leftPadding: Float, _desc: Int = 0) = + fillWidth(fraction, leftPadding, false).alignHorizontal(Alignment.Start(leftPadding)) + +/** Fills [fraction] of parent width minus [rightPadding] and aligns [rightPadding] pixels from the right */ +fun Modifier.fillWidth(fraction: Float = 1f, rightPadding: Float, _desc: Short = 0) = + fillWidth(fraction, rightPadding, false).alignHorizontal(Alignment.End(rightPadding)) + +/** Fills [fraction] of parent width minus [padding] from both sides */ +fun Modifier.fillWidth(fraction: Float = 1f, padding: Float = 0f) = fillWidth(fraction, padding, true) + +private fun Modifier.fillWidth(fraction: Float, padding: Float, doublePadding: Boolean) = + this then BasicWidthModifier { RelativeConstraint(fraction) - padding.pixels() * if (doublePadding) 2 else 1 } + +/** Fills [fraction] of parent height minus [topPadding] and aligns [topPadding] pixels from the top */ +fun Modifier.fillHeight(fraction: Float = 1f, topPadding: Float, _desc: Int = 0) = + fillHeight(fraction, topPadding, false).alignVertical(Alignment.Start(topPadding)) + +/** Fills [fraction] of parent height minus [bottomPadding] and aligns [bottomPadding] pixels from the bottom */ +fun Modifier.fillHeight(fraction: Float = 1f, bottomPadding: Float, _desc: Short = 0) = + fillHeight(fraction, bottomPadding, false).alignVertical(Alignment.End(bottomPadding)) + +/** Fills [fraction] of parent height minus [padding] from both sides */ +fun Modifier.fillHeight(fraction: Float = 1f, padding: Float = 0f) = fillHeight(fraction, padding, true) + +private fun Modifier.fillHeight(fraction: Float, padding: Float, doublePadding: Boolean) = + this then BasicHeightModifier { RelativeConstraint(fraction) - padding.pixels() * if (doublePadding) 2 else 1 } + +fun Modifier.childBasedSize(padding: Float = 0f) = childBasedWidth(padding).childBasedHeight(padding) + +fun Modifier.childBasedWidth(padding: Float = 0f) = this then BasicWidthModifier { ChildBasedSizeConstraint() + (padding.pixels * 2) } + +fun Modifier.childBasedHeight(padding: Float = 0f) = this then BasicHeightModifier { ChildBasedSizeConstraint() + (padding.pixels * 2) } + +fun Modifier.childBasedMaxSize(padding: Float = 0f) = childBasedMaxWidth(padding).childBasedMaxHeight(padding) + +fun Modifier.childBasedMaxWidth(padding: Float = 0f) = this then BasicWidthModifier { ChildBasedMaxSizeConstraint() + (padding.pixels * 2) } + +fun Modifier.childBasedMaxHeight(padding: Float = 0f) = this then BasicHeightModifier { ChildBasedMaxSizeConstraint() + (padding.pixels * 2) } + +fun Modifier.fillRemainingWidth() = this then BasicWidthModifier { FillConstraintIncludingPadding(useSiblings = true) } + +fun Modifier.fillRemainingHeight() = this then BasicHeightModifier { FillConstraintIncludingPadding(useSiblings = true) } + +fun Modifier.width(width: Float) = this then BasicWidthModifier { width.pixels() } + +fun Modifier.height(height: Float) = this then BasicHeightModifier { height.pixels() } + +fun Modifier.width(other: UIComponent) = this then BasicWidthModifier { CopyConstraintFloat() boundTo other } + +fun Modifier.height(other: UIComponent) = this then BasicHeightModifier { CopyConstraintFloat() boundTo other } + +fun Modifier.widthAspect(aspect: Float) = this then BasicWidthModifier { AspectConstraint(aspect) } + +fun Modifier.heightAspect(aspect: Float) = this then BasicHeightModifier { AspectConstraint(aspect) } + +fun Modifier.animateWidth(width: Float, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = animateWidth(stateOf { width.pixels }, duration, strategy) + +fun Modifier.animateHeight(height: Float, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = animateHeight(stateOf { height.pixels }, duration, strategy) + +fun Modifier.animateWidth(width: State<() -> WidthConstraint>, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = this then AnimateWidthModifier(width, duration, strategy) + +fun Modifier.animateHeight(height: State<() -> HeightConstraint>, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = this then AnimateHeightModifier(height, duration, strategy) + +fun Modifier.maxWidth(width: Float) = this then MaxWidthModifier(width) + +fun Modifier.maxHeight(height: Float) = this then MaxHeightModifier(height) + +private class AnimateWidthModifier(private val newWidth: State<() -> WidthConstraint>, private val duration: Float, private val strategy: AnimationStrategy) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldWidth = component.constraints.width + + fun animate(widthConstraint: WidthConstraint) { + if (component.hasWindow) { + component.animate { + setWidthAnimation(strategy, duration, widthConstraint) + } + } else { + component.setWidth(widthConstraint) + } + } + + val removeListenerCallback = newWidth.onSetValueAndNow(component) { animate(it()) } + + return { + removeListenerCallback() + animate(oldWidth) + } + } +} + +private class AnimateHeightModifier(private val newHeight: State<() -> HeightConstraint>, private val duration: Float, private val strategy: AnimationStrategy) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldHeight = component.constraints.height + + fun animate(heightConstraint: HeightConstraint) { + if (component.hasWindow) { + component.animate { + setHeightAnimation(strategy, duration, heightConstraint) + } + } else { + component.setHeight(heightConstraint) + } + } + + val removeListenerCallback = newHeight.onSetValueAndNow(component) { animate(it()) } + + return { + removeListenerCallback() + animate(oldHeight) + } + } +} + +private class MaxWidthModifier(private val width: Float) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldWidth = component.constraints.width + component.setWidth(min(oldWidth, width.pixels)) + + return { + component.setWidth(oldWidth) + } + } +} + +private class MaxHeightModifier(private val height: Float) : Modifier { + override fun applyToComponent(component: UIComponent): () -> Unit { + val oldHeight = component.constraints.height + component.setHeight(min(oldHeight, height.pixels)) + return { + component.setHeight(oldHeight) + } + } +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt new file mode 100644 index 00000000..6eb7087f --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt @@ -0,0 +1,50 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.state.State +import gg.essential.elementa.common.onSetValueAndNow +import gg.essential.elementa.state.v2.combinators.map +import java.awt.Color +import gg.essential.elementa.state.v2.State as StateV2 + +@Deprecated("Using StateV1 is discouraged, use StateV2 instead") +fun Modifier.then(state: State): Modifier { + return this then { + var reverse: (() -> Unit)? = null + + val cleanupState = state.onSetValueAndNow { + reverse?.invoke() + reverse = it.applyToComponent(this) + }; + + { + cleanupState() + reverse?.invoke() + reverse = null + } + } +} + +fun Modifier.then(state: StateV2): Modifier { + return this then { + var reverse: (() -> Unit)? = state.get().applyToComponent(this) + + val cleanupState = state.onSetValue(this) { + reverse?.invoke() + reverse = it.applyToComponent(this) + }; + + { + cleanupState() + reverse?.invoke() + reverse = null + } + } +} + +@Suppress("DeprecatedCallableAddReplaceWith") +@Deprecated("Using StateV1 is discouraged, use StateV2 instead") +fun Modifier.whenTrue(state: State, activeModifier: Modifier, inactiveModifier: Modifier = Modifier): Modifier = + then(state.map { if (it) activeModifier else inactiveModifier }) + +fun Modifier.whenTrue(state: StateV2, activeModifier: Modifier, inactiveModifier: Modifier = Modifier): Modifier = + then(state.map { if (it) activeModifier else inactiveModifier }) \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/util.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/util.kt new file mode 100644 index 00000000..30bacb36 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/util.kt @@ -0,0 +1,47 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.dsl.boundTo +import gg.essential.elementa.dsl.percent +import gg.essential.elementa.dsl.pixels +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.common.Spacer +import java.awt.Color + +@Suppress("FunctionName") +fun TransparentBlock() = UIBlock(Color(0, 0, 0, 0)) + +fun LayoutScope.spacer(width: Float, height: Float) = Spacer(width = width.pixels, height = height.pixels)() +fun LayoutScope.spacer(width: Float, _desc: WidthDesc = Desc) = spacer(width, 0f) +fun LayoutScope.spacer(height: Float, _desc: HeightDesc = Desc) = spacer(0f, height) +fun LayoutScope.spacer(width: UIComponent, height: UIComponent) = Spacer(100.percent boundTo width, 100.percent boundTo height)() +fun LayoutScope.spacer(width: UIComponent, _desc: WidthDesc = Desc) = Spacer(100.percent boundTo width, 0f.pixels)() +fun LayoutScope.spacer(height: UIComponent, _desc: HeightDesc = Desc) = Spacer(0f.pixels, 100.percent boundTo height)() + +sealed interface WidthDesc +sealed interface HeightDesc +private object Desc : WidthDesc, HeightDesc + +// How is this not in the stdlib? +internal inline fun Iterable.sumOf(selector: (T) -> Float): Float { + var sum = 0f + for (element in this) { + sum += selector(element) + } + return sum +} + +fun UIComponent.getChildModifier() = + effects + .filterIsInstance() + .map { it.childModifier } + .reduceOrNull { acc, it -> acc then it } + ?: Modifier + +fun UIComponent.addChildModifier(modifier: Modifier) { + enableEffect(ChildModifierMarker(modifier)) +} + +// Serves as a marker only. FIXME: integrate directly into the component class when we transition this DSL to Elementa? +private class ChildModifierMarker(val childModifier: Modifier) : Effect() \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt new file mode 100644 index 00000000..a574a5e3 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt @@ -0,0 +1,322 @@ +package gg.essential.elementa.util + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.Window +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.utils.ObservableAddEvent +import gg.essential.elementa.utils.ObservableClearEvent +import gg.essential.elementa.utils.ObservableList +import gg.essential.elementa.utils.ObservableRemoveEvent +import gg.essential.elementa.common.onSetValueAndNow +import gg.essential.elementa.state.v2.* +import gg.essential.elementa.state.v2.collections.MutableTrackedList +import gg.essential.universal.UMouse +import gg.essential.universal.UResolution +import gg.essential.elementa.state.v2.ListState as ListStateV2 +import gg.essential.elementa.state.v2.State as StateV2 + +val UIComponent.hasWindow: Boolean + get() = this is Window || hasParent && parent.hasWindow + +fun UIComponent.pollingState(initialValue: T? = null, getter: () -> T): State { + val state = BasicState(initialValue ?: getter()) + enableEffect(object : Effect() { + override fun animationFrame() { + state.set(getter()) + } + }) + return state +} + +fun UIComponent.pollingStateV2(initialValue: T? = null, getter: () -> T): StateV2 { + val state = mutableStateOf(initialValue ?: getter()) + enableEffect(object : Effect() { + override fun animationFrame() { + state.set(getter()) + } + }) + return state +} + +fun UIComponent.layoutSafePollingState(initialValue: T? = null, getter: () -> T): StateV2 { + val state = mutableStateOf(initialValue ?: getter()) + enableEffect(object : Effect() { + override fun animationFrame() { + val window = Window.of(boundComponent) + // Start one-shot timer which will trigger immediately once the current `animationFrame` is complete + window.startTimer(0) { timerId -> + window.stopTimer(timerId) + + state.set(getter()) + } + } + }) + return state +} + +/** + * Creates a state that derives its value using the given [block]. The value of any state may be accessed within this + * block via [StateScope.invoke]. These accesses are tracked and the block is automatically re-evaluated whenever any + * one of them changes. + */ +@Deprecated("Using StateV1 is discouraged, use StateV2 instead", ReplaceWith("stateBy", "gg.essential.elementa.state.v2.StateByKt.stateBy")) +fun stateBy(block: StateScope.() -> T): State { + val subscribed = mutableMapOf, () -> Unit>() + val observed = mutableSetOf>() + val scope = object : StateScope { + override fun State.invoke(): T { + observed.add(this) + return get() + } + } + + val result = BasicState(block(scope)) + + fun updateSubscriptions() { + observed.forEach { state -> + if (state !in subscribed) { + val unregister = state.onSetValue { + // FIXME this should really just run immediately but State is currently very prone to CME if you + // register or remove a listener while it its callback, so we need to delay here until that's fixed + Window.enqueueRenderOperation { + val newValue = block(scope) + updateSubscriptions() + result.set(newValue) + } + } + subscribed[state] = unregister + } + } + + subscribed.entries.removeAll { (state, unregister) -> + if (state !in observed) { + unregister() + true + } else { + false + } + } + + observed.clear() + } + updateSubscriptions() + + return result +} + +interface StateScope { + operator fun State.invoke(): T +} + +/** + * Executes the supplied [block] on this component's animationFrame + */ +fun UIComponent.onAnimationFrame(block: () -> Unit) = + enableEffect(object : Effect() { + override fun animationFrame() { + block() + } + }) + +/** + * Returns a state representing whether this UIComponent is hovered + * + * [hitTest] will perform a hit test to make sure the user is actually hovered over this component + * as compared to the mouse just being within its content bounds while being hovered over another + * component rendered above this. + * + * [layoutSafe] will delay the state change until a time in which it is safe to make layout changes. + * This option will induce an additional delay of one frame because the state is updated during the next + * [Window.enqueueRenderOperation] after the hoverState changes. + */ +fun UIComponent.hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true): State { + // "Unsafe" means that it is not safe to depend on this for layout changes + val unsafeHovered = BasicState(false) + + // "Safe" because layout changes can directly happen when this changes (ie in onSetValue) + val safeHovered = BasicState(false) + + // Performs a hit test based on the current mouse x / y + fun hitTestHovered(): Boolean { + // Positions the mouse in the center of pixels so isPointInside will + // pass for items 1 pixel wide objects. See ElementaVersion v2 for more details + val halfPixel = 0.5f / UResolution.scaleFactor.toFloat() + val mouseX = UMouse.Scaled.x.toFloat() + halfPixel + val mouseY = UMouse.Scaled.y.toFloat() + halfPixel + return if (isPointInside(mouseX, mouseY)) { + + val window = Window.of(this) + val hit = (window.hoveredFloatingComponent?.hitTest(mouseX, mouseY)) ?: window.hitTest(mouseX, mouseY) + + hit.isComponentInParentChain(this) || hit == this + } else { + false + } + } + + if (hitTest) { + // It's possible the animation framerate will exceed that of the actual frame rate + // Therefore, in order to avoid redundantly performing the hit test multiple times + // in the same frame, this boolean is used to ensure that hit testing is performed + // at most only a single time each frame + var registerHitTest = true + + onAnimationFrame { + if (registerHitTest) { + registerHitTest = false + Window.enqueueRenderOperation { + // The next animation frame should register another renderOperation + registerHitTest = true + + // It is possible that this component or a component in its parent tree + // was removed from the component tree between the last call to animationFrame + // and this evaluation in enqueueRenderOperation. If that is the case, we should not + // perform the hit test because it will throw an exception. + if (!this.isInComponentTree()) { + // Unset the hovered state because a component can no longer + // be hovered if it is not in the component tree + unsafeHovered.set(false) + return@enqueueRenderOperation + } + + // Since enqueueRenderOperation will keep polling the queue until there are no more items, + // the forwarding of any update to the safeHovered state will still happen this frame + unsafeHovered.set(hitTestHovered()) + } + } + } + } + onMouseEnter { + if (hitTest) { + unsafeHovered.set(hitTestHovered()) + } else { + unsafeHovered.set(true) + } + } + + onMouseLeave { + unsafeHovered.set(false) + } + + return if (layoutSafe) { + unsafeHovered.onSetValue { + Window.enqueueRenderOperation { + safeHovered.set(it) + } + } + safeHovered + } else { + unsafeHovered + } +} + +/** Marker effect for [makeHoverScope]/[hoverScope]. */ +private class HoverScope(val state: State) : Effect() + +/** + * This method declares this component and its children to be part of one hover scope. + * Whether any component inside a hover scope is considered "hovered" depends on whether the scope declares it as such. + * By default the scope is considered hovered based on the [hoveredState] of this component but this may be overridden + * by passing a custom non-null [state]. + * + * Scopes are resolved once on the first draw. As such they should be declared before the component is first drawn, + * cannot be removed, and are not updated if components are moved between different parents. + * + * If multiple scopes are nested, components within the inner scope will solely follow their direct parent scope and + * be completely oblivious to the outer scope. + * This can easily be customized by passing a different [state], e.g. passing + * `hoverScope(parentOnly = true) or hoveredState()` to make children appear as hovered when either the other or the + * inner scope is hovered. + * + * A hover scope may be re-declared on the same component to overwrite its source `state`. This allows a mostly + * self-contained component to declare a hover scope on itself by default; and if this default hover scope is not + * appropriate for some use case, the user may call `makeHoverScope` again on the component from the outside with a + * custom [state] (e.g. with `hoverScope(parentOnly = true)` to simply make it inherit from an outer scope as if it + * wasn't declared in the first place). + * Note that the same rules about first-time resolving still apply. + */ +fun UIComponent.makeHoverScope(state: State? = null) = apply { + removeEffect() + enableEffect(HoverScope(state ?: hoveredState())) +} + +fun UIComponent.makeHoverScope(state: StateV2) = makeHoverScope(state.toV1(this)) + +/** + * Receives the hover scope which this component is subject to. + * + * This method must not be called on components which are not part of any hover scope. + * + * @see [makeHoverScope] + */ +fun UIComponent.hoverScope(parentOnly: Boolean = false): State { + class HoverScopeConsumer : Effect() { + val state = BasicState(false) + + override fun setup() { + val sequence = if (parentOnly) parent.selfAndParents() else selfAndParents() + val scope = + sequence.firstNotNullOfOrNull { component -> + component.effects.firstNotNullOfOrNull { it as? HoverScope } + } ?: throw IllegalStateException("No hover scope found for ${this@hoverScope}.") + Window.enqueueRenderOperation { + scope.state.onSetValueAndNow { state.set(it) } + } + } + } + val consumer = HoverScopeConsumer() + enableEffect(consumer) + return consumer.state +} + +/** Returns a [Sequence] consisting of this component and its parents (including the Window) in that order. */ +fun UIComponent.selfAndParents() = + generateSequence(this) { if (it.parent != it) it.parent else null } + + +fun UIComponent.isComponentInParentChain(target: UIComponent): Boolean { + var component: UIComponent = this + while (component.hasParent && component !is Window) { + component = component.parent + if (component == target) + return true + } + + return false +} + +fun UIComponent.isInComponentTree(): Boolean = + this is Window || hasParent && this in parent.children && parent.isInComponentTree() + +fun ObservableList.onItemRemoved(callback: (E) -> Unit) { + addObserver { _, arg -> + if (arg is ObservableRemoveEvent<*>) { + callback(arg.element.value as E) + } + } +} + +fun ObservableList.onItemAdded(callback: (E) -> Unit) { + addObserver { _, arg -> + if (arg is ObservableAddEvent<*>) { + callback(arg.element.value as E) + } + } +} + +@Suppress("UNCHECKED_CAST") +fun ObservableList.toStateV2List(): ListStateV2 { + val stateList = mutableStateOf(MutableTrackedList(this.toMutableList())) + + this.addObserver { _, arg -> + when (arg) { + is ObservableAddEvent<*> -> stateList.add(arg.element.index, arg.element.value as E) + is ObservableClearEvent<*> -> stateList.clear() + is ObservableRemoveEvent<*> -> stateList.removeAt(arg.element.index) + } + } + + return stateList +} From 49e3a3f7f93d0749b4206a4e7e2e6dd21b231c19 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 29 Jan 2024 15:02:07 +0100 Subject: [PATCH 05/66] LayoutDSL: Change default `float` of `spacedBy` to `CENTER` Source-Commit: b6aabb62f7a3c09bda97f1853ee805bf92e3b477 --- .../elementa/layoutdsl/arrangement.kt | 33 ++----------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt index 0070125b..d95ace98 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt @@ -1,6 +1,5 @@ package gg.essential.elementa.layoutdsl -import gg.essential.config.FeatureFlags import gg.essential.elementa.UIComponent import gg.essential.elementa.constraints.* import gg.essential.elementa.constraints.resolution.ConstraintVisitor @@ -8,7 +7,6 @@ import gg.essential.elementa.utils.ObservableAddEvent import gg.essential.elementa.utils.ObservableClearEvent import gg.essential.elementa.utils.ObservableListEvent import gg.essential.elementa.utils.ObservableRemoveEvent -import gg.essential.elementa.utils.roundToRealPixels abstract class Arrangement { internal lateinit var mainAxis: Axis @@ -93,47 +91,20 @@ abstract class Arrangement { val SpaceBetween: Arrangement get() = SpaceBetweenArrangement() val SpaceEvenly: Arrangement get() = SpaceEvenlyArrangement() - fun spacedBy(spacing: Float = 0f, float: FloatPosition? = null): Arrangement = SpacedArrangement(spacing, float) + fun spacedBy(spacing: Float = 0f, float: FloatPosition = FloatPosition.CENTER): Arrangement = SpacedArrangement(spacing, float) fun equalWeight(spacing: Float = 0f): Arrangement = EqualWeightArrangement(spacing) } } private open class SpacedArrangement( protected val spacing: Float = 0f, - protected val floatPosition: FloatPosition? = null, + protected val floatPosition: FloatPosition = FloatPosition.CENTER, ) : Arrangement() { - private var floatWarningFrames = 0 - private var floatWarningBacktrace: Throwable? = if (FeatureFlags.INTERNAL_ENABLED) - Throwable("Default for `float` will change. " + - "For the time being you should explicitly pass the value you want in cases where it matters.") - else null - open fun getSpacing(parent: UIComponent) = spacing open fun getStartOffset(parent: UIComponent, spacing: Float): Float { val childrenSize = parent.children.sumOf { it.getMainAxisSize() } + spacing * (parent.children.size - 1) return when (floatPosition) { - null -> { - if (FeatureFlags.INTERNAL_ENABLED) { - val startResult = 0f - val centerResult = parent.getMainAxisSize() / 2 - childrenSize / 2 - if (startResult == centerResult.roundToRealPixels()) { - floatWarningFrames = 0 - startResult - } else { - // Only log if it's for more than ten frames. Temporarily incorrect results can easily happen - // because Elementa does not invalidate all constraints every frame, so if a child is added, its - // parent size might already be fixed until the next animationFrame. - if (floatWarningFrames++ > 10) { - floatWarningBacktrace?.printStackTrace() - floatWarningBacktrace = null - } - 100000f // should hopefully get their attention - } - } else { - 0f - } - } FloatPosition.START -> 0f FloatPosition.CENTER -> parent.getMainAxisSize() / 2 - childrenSize / 2 FloatPosition.END -> parent.getMainAxisSize() - childrenSize From e73c152c9cde178a32e6b619170360553da7a9b3 Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Thu, 15 Feb 2024 10:16:09 +0000 Subject: [PATCH 06/66] elementaExtensions+LayoutDSL: Add `Tag` marker and `findChildrenByTag` This tag system allows us to reduce the amount of piping required when attempting to find child components which are multiple layers deep, that don't have a concrete type (e.g. components using LayoutDSL). To use this, first, create an object (or data class) which inherits `Tag`: ```kt object MyTag : Tag ``` Then, apply it: ```kt forEach(list) { item -> box(Modifier.tag(MyTag) { ... } }``` Finally, further up the call tree, you can use `findChildrenByTag`: ```kt val component = somethingThatEventuallyCallsTheAboveCode() val children = component.findChildrenByTag(MyTag, recursive = true) ``` Source-Commit: 5fb37595459b10263b81d4c49c23c1369973ea92 --- .../gg/essential/elementa/layoutdsl/events.kt | 6 ++++ .../elementa/util/elementaExtensions.kt | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt index c3073234..3b2f472e 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt @@ -3,8 +3,11 @@ package gg.essential.elementa.layoutdsl import gg.essential.elementa.UIComponent import gg.essential.elementa.events.UIClickEvent import gg.essential.elementa.state.v2.toV2 +import gg.essential.elementa.util.Tag import gg.essential.elementa.util.hoverScope import gg.essential.elementa.util.makeHoverScope +import gg.essential.elementa.util.addTag +import gg.essential.elementa.util.removeTag import gg.essential.elementa.state.State as StateV1 import gg.essential.elementa.state.v2.State as StateV2 @@ -69,3 +72,6 @@ fun Modifier.whenHovered(hoverModifier: Modifier, noHoverModifier: Modifier = Mo */ fun Modifier.withHoverState(func: (StateV2) -> Modifier) = then { func(hoverScope().toV2()).applyToComponent(this) } + +/** Applies a Tag to this component. See [UIComponent.addTag]. */ +fun Modifier.tag(tag: Tag) = then { addTag(tag); { removeTag(tag) } } diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt index a574a5e3..f764ea99 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt @@ -271,6 +271,38 @@ fun UIComponent.hoverScope(parentOnly: Boolean = false): State { return consumer.state } +/** Once inherited, you can apply this to a component via [addTag] to be able to [findChildrenByTag]. */ +interface Tag + +/** Holder effect for a [Tag] */ +private class TagEffect(val tag: Tag) : Effect() + +/** Applies a [Tag] to this component. */ +fun UIComponent.addTag(tag: Tag) = apply { enableEffect(TagEffect(tag)) } + +/** Removes a [Tag] from this component. */ +fun UIComponent.removeTag(tag: Tag) = apply { effects.removeIf { it is TagEffect && it.tag == tag } } + +/** + * Searches for any children which contain a certain [Tag]. + * See [addTag] for applying a [Tag] to a component. + */ +fun UIComponent.findChildrenByTag(tag: Tag, recursive: Boolean = false): List { + val found = mutableListOf() + + for (child in children) { + if (child.effects.filterIsInstance().any { it.tag == tag }) { + found.add(child) + } + + if (recursive) { + found.addAll(child.findChildrenByTag(tag, true)) + } + } + + return found +} + /** Returns a [Sequence] consisting of this component and its parents (including the Window) in that order. */ fun UIComponent.selfAndParents() = generateSequence(this) { if (it.parent != it) it.parent else null } From 6510024f9ed1a690254c0f5a7165cef112db7068 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Fri, 16 Feb 2024 21:02:23 +0100 Subject: [PATCH 07/66] Misc: Remove unused imports As determined by feature-flags-processor. Source-Commit: be277773771aec8e52fda63bdd1cf0a72c06823d --- .../src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt | 2 -- .../src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt | 1 - .../src/main/kotlin/gg/essential/elementa/state/v2/state.kt | 1 - 3 files changed, 4 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt index fe3e08d5..13245b9b 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt @@ -1,8 +1,6 @@ package gg.essential.elementa.layoutdsl import gg.essential.elementa.UIComponent -import gg.essential.elementa.UIConstraints -import gg.essential.elementa.effects.Effect interface Modifier { /** diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt index 6eb7087f..bdfe0726 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt @@ -3,7 +3,6 @@ package gg.essential.elementa.layoutdsl import gg.essential.elementa.state.State import gg.essential.elementa.common.onSetValueAndNow import gg.essential.elementa.state.v2.combinators.map -import java.awt.Color import gg.essential.elementa.state.v2.State as StateV2 @Deprecated("Using StateV1 is discouraged, use StateV2 instead") diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt index 07be4037..f3a00d68 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt @@ -1,7 +1,6 @@ package gg.essential.elementa.state.v2 -import gg.essential.elementa.UIComponent import gg.essential.elementa.state.v2.ReferenceHolder import java.lang.ref.ReferenceQueue import java.lang.ref.WeakReference From 430372f04ef1f18e169496259076bd146933b748 Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Wed, 21 Feb 2024 14:04:15 +0000 Subject: [PATCH 08/66] StateV2/combinators: Allow for destructuring of `State>` Implementing `component1` and `component2` on `State>` allows it to be destructured into State and State through the following syntax: ```kt val (a, b) = stateBy> { firstState() to secondState() } ``` https://kotlinlang.org/docs/destructuring-declarations.html Source-Commit: f3adf93fca1a35446adb039a64167da9e72324ac --- .../gg/essential/elementa/state/v2/combinators/pair.kt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/pair.kt diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/pair.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/pair.kt new file mode 100644 index 00000000..4a3b6ffa --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/pair.kt @@ -0,0 +1,6 @@ +package gg.essential.elementa.state.v2.combinators + +import gg.essential.elementa.state.v2.State + +operator fun State>.component1(): State = this.map { it.first } +operator fun State>.component2(): State = this.map { it.second } From 84bee44ec53c5df25ae05da3a59e745a44698da0 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Thu, 8 Feb 2024 14:51:57 +0000 Subject: [PATCH 09/66] LayoutDSL: Fix inconsistent rounding in arrangement position calculations Source-Commit: ade472fdc50a18f98a0b5a84ba3e97404b593b08 --- .../kotlin/gg/essential/elementa/layoutdsl/arrangement.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt index d95ace98..5daef73f 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt @@ -7,6 +7,7 @@ import gg.essential.elementa.utils.ObservableAddEvent import gg.essential.elementa.utils.ObservableClearEvent import gg.essential.elementa.utils.ObservableListEvent import gg.essential.elementa.utils.ObservableRemoveEvent +import gg.essential.elementa.utils.roundToRealPixels abstract class Arrangement { internal lateinit var mainAxis: Axis @@ -112,8 +113,8 @@ private open class SpacedArrangement( } override fun layoutPositions() { - val spacing = getSpacing(boundComponent) - var nextStart = boundComponent.getMainAxisStart() + getStartOffset(boundComponent, spacing) + val spacing = getSpacing(boundComponent).roundToRealPixels() + var nextStart = boundComponent.getMainAxisStart() + getStartOffset(boundComponent, spacing).roundToRealPixels() boundComponent.children.forEach { lastPosValues[it] = nextStart nextStart += it.getMainAxisSize() + spacing @@ -121,7 +122,7 @@ private open class SpacedArrangement( } override fun getPadding(child: UIComponent): Float { - return if (child === boundComponent.children.last()) 0f else getSpacing(boundComponent) + return if (child === boundComponent.children.last()) 0f else getSpacing(boundComponent).roundToRealPixels() } } From df7d7cdfd1cbeb7ce7c1df7acabe06b9bcaa7cea Mon Sep 17 00:00:00 2001 From: Sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 4 Mar 2024 11:42:26 -0500 Subject: [PATCH 10/66] Build: fix unstable project publishing configurations GitHub: #137 --- unstable/layoutdsl/build.gradle.kts | 6 ++---- unstable/statev2/build.gradle.kts | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/unstable/layoutdsl/build.gradle.kts b/unstable/layoutdsl/build.gradle.kts index 76435d66..e3db468f 100644 --- a/unstable/layoutdsl/build.gradle.kts +++ b/unstable/layoutdsl/build.gradle.kts @@ -5,7 +5,7 @@ import gg.essential.gradle.util.versionFromBuildIdAndBranch plugins { kotlin("jvm") id("gg.essential.defaults") - id("maven-publish") + id("gg.essential.defaults.maven-publish") } version = versionFromBuildIdAndBranch() @@ -30,9 +30,7 @@ kotlin.jvmToolchain { publishing { publications { - register("maven") { - from(components["java"]) - + named("maven") { artifactId = "elementa-unstable-${project.name}" } } diff --git a/unstable/statev2/build.gradle.kts b/unstable/statev2/build.gradle.kts index 5967b7b5..ac2d5e8f 100644 --- a/unstable/statev2/build.gradle.kts +++ b/unstable/statev2/build.gradle.kts @@ -5,7 +5,7 @@ import gg.essential.gradle.util.versionFromBuildIdAndBranch plugins { kotlin("jvm") id("gg.essential.defaults") - id("maven-publish") + id("gg.essential.defaults.maven-publish") } version = versionFromBuildIdAndBranch() @@ -29,9 +29,7 @@ kotlin.jvmToolchain { publishing { publications { - register("maven") { - from(components["java"]) - + named("maven") { artifactId = "elementa-unstable-${project.name}" } } From e697779286d5a062fb2f24c0e7bd56ba9826ee26 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Thu, 2 May 2024 10:31:09 +0200 Subject: [PATCH 11/66] AbstractTextInput: Hard-code isAllowedCharacter It shouldn't ever change and this way the same Elementa build continues to be compatible on 1.20.5+ where the MC method was moved to another class. GitHub: #138 --- .../elementa/components/input/AbstractTextInput.kt | 9 ++++++++- src/main/kotlin/gg/essential/elementa/impl/Platform.kt | 2 -- .../java/gg/essential/elementa/impl/PlatformImpl.java | 6 ------ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt b/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt index 4ae4ddc0..83a8cded 100644 --- a/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt +++ b/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt @@ -104,7 +104,7 @@ abstract class AbstractTextInput( val operationToRedo = redoStack.pop() operationToRedo.redo() undoStack.push(operationToRedo) - } else if (platform.isAllowedInChat(typedChar)) { // Most of the ASCII characters + } else if (isAllowedCharacter(typedChar)) { // Most of the ASCII characters commitTextAddition(typedChar.toString()) } else if (keyCode == UKeyboard.KEY_LEFT) { val holdingShift = UKeyboard.isShiftKeyDown() @@ -978,4 +978,11 @@ abstract class AbstractTextInput( removeTextOperation.undo() } } + + private companion object { + // Mirroring ChatAllowedCharacters.isAllowedCharacter + private fun isAllowedCharacter(chr: Char): Boolean { + return chr.code != 167 && chr >= ' ' && chr.code != 127 + } + } } diff --git a/src/main/kotlin/gg/essential/elementa/impl/Platform.kt b/src/main/kotlin/gg/essential/elementa/impl/Platform.kt index 9eec5117..1fe62845 100644 --- a/src/main/kotlin/gg/essential/elementa/impl/Platform.kt +++ b/src/main/kotlin/gg/essential/elementa/impl/Platform.kt @@ -9,8 +9,6 @@ interface Platform { var currentScreen: Any? - fun isAllowedInChat(char: Char): Boolean - fun enableStencil() fun isCallingFromMinecraftThread(): Boolean diff --git a/versions/src/main/java/gg/essential/elementa/impl/PlatformImpl.java b/versions/src/main/java/gg/essential/elementa/impl/PlatformImpl.java index 272ef877..faac816b 100644 --- a/versions/src/main/java/gg/essential/elementa/impl/PlatformImpl.java +++ b/versions/src/main/java/gg/essential/elementa/impl/PlatformImpl.java @@ -3,7 +3,6 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiScreen; import net.minecraft.client.shader.Framebuffer; -import net.minecraft.util.ChatAllowedCharacters; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; @@ -39,11 +38,6 @@ public void setCurrentScreen(@Nullable Object screen) { Minecraft.getMinecraft().displayGuiScreen((GuiScreen) screen); } - @Override - public boolean isAllowedInChat(char c) { - return ChatAllowedCharacters.isAllowedCharacter(c); - } - @Override public void enableStencil() { //#if MC<11500 From 20a9ab2a7e2ab02b67813226f3637dfabc6accaf Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Thu, 2 May 2024 11:05:02 +0200 Subject: [PATCH 12/66] Build: Add optional coroutines support to unstable statev2 project --- gradle/libs.versions.toml | 2 ++ unstable/statev2/build.gradle.kts | 1 + 2 files changed, 3 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc1504e2..0cbe6a10 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] kotlin = "1.5.10" +kotlinx-coroutines = "1.5.2" jetbrains-annotations = "23.0.0" universalcraft = "211" commonmark = "0.17.1" @@ -8,6 +9,7 @@ dom4j = "2.1.1" [libraries] kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" } commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } diff --git a/unstable/statev2/build.gradle.kts b/unstable/statev2/build.gradle.kts index ac2d5e8f..cff1694a 100644 --- a/unstable/statev2/build.gradle.kts +++ b/unstable/statev2/build.gradle.kts @@ -13,6 +13,7 @@ group = "gg.essential" dependencies { compileOnly(project(":")) + compileOnly(libs.kotlinx.coroutines.core) val common = registerStripReferencesAttribute("common") { excludes.add("net.minecraft") From b54c5a5e4325938327eb723555b43cf23c4f88d2 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Thu, 22 Feb 2024 14:35:46 +0100 Subject: [PATCH 13/66] Misc: Add `State.await` coroutine extension function Source-Commit: 9d057cfe4b55088e455f32cbc8850a7e9519105a --- .../essential/elementa/state/v2/coroutine.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/coroutine.kt diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/coroutine.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/coroutine.kt new file mode 100644 index 00000000..88483488 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/coroutine.kt @@ -0,0 +1,37 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.ReferenceHolder +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** Waits until this [State] has a value which [equals] the given [value]. */ +suspend fun State.awaitValue(value: T): T = await { it == value } + +/** Waits until this [State] has a value for which [accept] returns `true` and returns that value. */ +suspend fun State.await(accept: (T) -> Boolean): T { + // Fast-path + get().let { if (accept(it)) return it } + + // Slow path + return suspendCancellableCoroutine { continuation -> + lateinit var unregister: () -> Unit + var listener: ((T) -> Unit)? + listener = { value -> + if (accept(value)) { + unregister() + continuation.resume(value) + } + } + unregister = onSetValue(ReferenceHolder.Weak, listener) + listener(get()) + continuation.invokeOnCancellation { + // Note: we cannot call `unregister` here because `invokeOnCancellation` makes no guarantee about which + // thread we run on, and `unregister` isn't thread safe. + // So we'll instead merely drop our reference to the listener and leave it to State's weakness properties + // to clean up the registration. + // This does mean our callback will continue to be invoked, but `CancellableCoroutine` is fine with that + // because cancellation may race with `resume` in pretty much any code. + listener = null + } + } +} From 6143d5aa4213216fce5e3d3230c5979f9ffbbce2 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 4 Mar 2024 09:21:04 +0100 Subject: [PATCH 14/66] StateV2: Move implementation into separate file And also behind an interface so we can try out different ones. Source-Commit: c80c8be4f78f72e00e40e6fcca4348204f186e25 --- .../essential/elementa/state/v2/impl/Impl.kt | 20 ++ .../elementa/state/v2/impl/legacy/impl.kt | 187 ++++++++++++++++++ .../gg/essential/elementa/state/v2/state.kt | 179 +---------------- 3 files changed, 215 insertions(+), 171 deletions(-) create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt new file mode 100644 index 00000000..5b7f1405 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt @@ -0,0 +1,20 @@ +package gg.essential.elementa.state.v2.impl + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.v2.DelegatingMutableState +import gg.essential.elementa.state.v2.DelegatingState +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.State + +internal interface Impl { + fun mutableState(value: T): MutableState + + fun stateDelegatingTo(state: State): DelegatingState + + fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState + + fun derivedState( + initialValue: T, + builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit, + ): State +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt new file mode 100644 index 00000000..a2b089e3 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt @@ -0,0 +1,187 @@ +package gg.essential.elementa.state.v2.impl.legacy + + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.v2.DelegatingMutableState +import gg.essential.elementa.state.v2.DelegatingState +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.impl.Impl +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +/** Legacy implementation based around `onSetValue` which makes no attempt at being glitch-free. */ +internal object LegacyImpl : Impl { + override fun mutableState(value: T): MutableState = BasicState(value) + + override fun stateDelegatingTo(state: State): DelegatingState = DelegatingStateImpl(state) + + override fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState = DelegatingMutableStateImpl(state) + + override fun derivedState( + initialValue: T, + builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit + ): State = ReferenceHoldingBasicState(initialValue).apply { builder(this, this) } +} + +/** A simple implementation of [MutableState], containing only a backing field */ +private open class BasicState(private var valueBacker: T) : MutableState { + private val referenceQueue = ReferenceQueue() + private val listeners = mutableListOf>() + + /** + * Contains the size of the [listeners] list which we currently iterate over. + * We must not directly modify these entries as that may mess up the iteration, anything after those entries is fair + * game though. + * Additions always happen at the end of the list, so those are trivial. + * For removals we instead set the [ListenerEntry.removed] flag and let the iteration code clean up the entry when + * it passes over it. + * We can't solely rely on that for all cleanup because we only iterate the listener list when the value of the state + * changes, so if it doesn't, we need to clean up entries immediately. + */ + private var liveSize = 0 + + override fun get() = valueBacker + + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { + cleanupStaleListeners() + val ownerCallback = WeakReference(owner.holdOnto(Pair(this, listener))) + return ListenerEntry(this, listener, ownerCallback).also { listeners.add(it) } + } + + override fun set(mapper: (T) -> T) { + val oldValue = valueBacker + val newValue = mapper(oldValue) + if (oldValue == newValue) { + return + } + + valueBacker = newValue + + // Iterate over listeners while allowing for concurrent add to the end of the list (newly added entries will not get + // called) and concurrent remove from anywhere in the list (via `removed` flag in each entry, or directly for newly + // added listeners). See [liveSize] docs. + liveSize = listeners.size + var i = 0 + while (i < liveSize) { + val entry = listeners[i] + if (entry.removed) { + listeners.removeAt(i) + liveSize-- + } else { + entry.get()?.invoke(newValue) + i++ + } + } + liveSize = 0 + } + + private fun cleanupStaleListeners() { + while (true) { + val reference = referenceQueue.poll() ?: break + (reference as ListenerEntry<*>).invoke() + } + } + + private class ListenerEntry( + private val state: BasicState, + listenerCallback: (T) -> Unit, + private val ownerCallback: WeakReference<() -> Unit>, + ) : WeakReference<(T) -> Unit>(listenerCallback, state.referenceQueue), () -> Unit { + var removed = false + + override fun invoke() { + // If we do not currently iterate over the listener list, we can directly remove this entry from the list, + // otherwise we merely mark it as deleted and let the iteration code take care of it. + val index = state.listeners.indexOf(this@ListenerEntry) + if (index >= state.liveSize) { + state.listeners.removeAt(index) + } else { + removed = true + } + + ownerCallback.get()?.invoke() + } + } +} + +/** Base class for implementations of Delegating(Mutable)State classes. */ +private open class DelegatingStateBase>(protected var delegate: S) : State { + private val referenceQueue = ReferenceQueue() + private var listeners = mutableListOf>() + + override fun get(): T = delegate.get() + + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { + cleanupStaleListeners() + val ownerCallback = WeakReference(owner.holdOnto(Pair(this, listener))) + val removeCallback = delegate.onSetValue(ReferenceHolder.Weak, listener) + return ListenerEntry(this, listener, removeCallback, ownerCallback).also { listeners.add(it) } + } + + + fun rebind(newState: S) { + val oldState = delegate + if (oldState == newState) { + return + } + + delegate = newState + + listeners = + listeners.mapNotNullTo(mutableListOf()) { entry -> + entry.removeCallback() + val listenerCallback = entry.get() ?: return@mapNotNullTo null + val removeCallback = newState.onSetValue(ReferenceHolder.Weak, listenerCallback) + ListenerEntry(this, listenerCallback, removeCallback, entry.ownerCallback) + } + + val oldValue = oldState.get() + val newValue = newState.get() + if (oldValue != newValue) { + listeners.forEach { it.get()?.invoke(newValue) } + } + } + + private fun cleanupStaleListeners() { + while (true) { + val reference = referenceQueue.poll() ?: break + (reference as ListenerEntry<*>).invoke() + } + } + + private class ListenerEntry( + private val state: DelegatingStateBase, + listenerCallback: (T) -> Unit, + val removeCallback: () -> Unit, + val ownerCallback: WeakReference<() -> Unit>, + ) : WeakReference<(T) -> Unit>(listenerCallback, state.referenceQueue), () -> Unit { + override fun invoke() { + state.listeners.remove(this@ListenerEntry) + removeCallback() + ownerCallback.get()?.invoke() + } + } +} + +/** Default implementation of [DelegatingState] */ +private class DelegatingStateImpl(delegate: State) : + DelegatingStateBase>(delegate), DelegatingState + +/** Default implementation of [DelegatingMutableState] */ +private class DelegatingMutableStateImpl(delegate: MutableState) : + DelegatingStateBase>(delegate), DelegatingMutableState { + override fun set(mapper: (T) -> T) { + delegate.set(mapper) + } +} + +/** A [BasicState] which additionally implements [ReferenceHolder] */ +private class ReferenceHoldingBasicState(value: T) : BasicState(value), ReferenceHolder { + private val heldReferences = mutableListOf() + + override fun holdOnto(listener: Any): () -> Unit { + heldReferences.add(listener) + return { heldReferences.remove(listener) } + } +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt index f3a00d68..2297f1a8 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt @@ -1,9 +1,10 @@ package gg.essential.elementa.state.v2 - import gg.essential.elementa.state.v2.ReferenceHolder -import java.lang.ref.ReferenceQueue -import java.lang.ref.WeakReference +import gg.essential.elementa.state.v2.impl.Impl +import gg.essential.elementa.state.v2.impl.legacy.LegacyImpl + +private val impl: Impl = LegacyImpl /** * The base for all Elementa State objects. @@ -111,22 +112,20 @@ interface DelegatingMutableState : MutableState { fun stateOf(value: T): State = ImmutableState(value) /** Creates a new [MutableState] with the given initial value. */ -fun mutableStateOf(value: T): MutableState = BasicState(value) +fun mutableStateOf(value: T): MutableState = impl.mutableState(value) /** Creates a new [DelegatingState] with the given target [State]. */ -fun stateDelegatingTo(state: State): DelegatingState = DelegatingStateImpl(state) +fun stateDelegatingTo(state: State): DelegatingState = impl.stateDelegatingTo(state) /** Creates a new [DelegatingMutableState] with the given target [MutableState]. */ fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState = - DelegatingMutableStateImpl(state) + impl.mutableStateDelegatingTo(state) /** Creates a [State] which derives its value in a user-defined way from one or more other states */ fun derivedState( initialValue: T, builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit, -): State { - return ReferenceHoldingBasicState(initialValue).apply { builder(this, this) } -} +): State = impl.derivedState(initialValue, builder) /** A simple, immutable implementation of [State] */ private class ImmutableState(private val value: T) : State { @@ -134,168 +133,6 @@ private class ImmutableState(private val value: T) : State { override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit = {} } -/** A simple implementation of [MutableState], containing only a backing field */ -private open class BasicState(private var valueBacker: T) : MutableState { - private val referenceQueue = ReferenceQueue() - private val listeners = mutableListOf>() - - /** - * Contains the size of the [listeners] list which we currently iterate over. - * We must not directly modify these entries as that may mess up the iteration, anything after those entries is fair - * game though. - * Additions always happen at the end of the list, so those are trivial. - * For removals we instead set the [ListenerEntry.removed] flag and let the iteration code clean up the entry when - * it passes over it. - * We can't solely rely on that for all cleanup because we only iterate the listener list when the value of the state - * changes, so if it doesn't, we need to clean up entries immediately. - */ - private var liveSize = 0 - - override fun get() = valueBacker - - override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { - cleanupStaleListeners() - val ownerCallback = WeakReference(owner.holdOnto(Pair(this, listener))) - return ListenerEntry(this, listener, ownerCallback).also { listeners.add(it) } - } - - override fun set(mapper: (T) -> T) { - val oldValue = valueBacker - val newValue = mapper(oldValue) - if (oldValue == newValue) { - return - } - - valueBacker = newValue - - // Iterate over listeners while allowing for concurrent add to the end of the list (newly added entries will not get - // called) and concurrent remove from anywhere in the list (via `removed` flag in each entry, or directly for newly - // added listeners). See [liveSize] docs. - liveSize = listeners.size - var i = 0 - while (i < liveSize) { - val entry = listeners[i] - if (entry.removed) { - listeners.removeAt(i) - liveSize-- - } else { - entry.get()?.invoke(newValue) - i++ - } - } - liveSize = 0 - } - - private fun cleanupStaleListeners() { - while (true) { - val reference = referenceQueue.poll() ?: break - (reference as ListenerEntry<*>).invoke() - } - } - - private class ListenerEntry( - private val state: BasicState, - listenerCallback: (T) -> Unit, - private val ownerCallback: WeakReference<() -> Unit>, - ) : WeakReference<(T) -> Unit>(listenerCallback, state.referenceQueue), () -> Unit { - var removed = false - - override fun invoke() { - // If we do not currently iterate over the listener list, we can directly remove this entry from the list, - // otherwise we merely mark it as deleted and let the iteration code take care of it. - val index = state.listeners.indexOf(this@ListenerEntry) - if (index >= state.liveSize) { - state.listeners.removeAt(index) - } else { - removed = true - } - - ownerCallback.get()?.invoke() - } - } -} - -/** Base class for implementations of Delegating(Mutable)State classes. */ -private open class DelegatingStateBase>(protected var delegate: S) : State { - private val referenceQueue = ReferenceQueue() - private var listeners = mutableListOf>() - - override fun get(): T = delegate.get() - - override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { - cleanupStaleListeners() - val ownerCallback = WeakReference(owner.holdOnto(Pair(this, listener))) - val removeCallback = delegate.onSetValue(ReferenceHolder.Weak, listener) - return ListenerEntry(this, listener, removeCallback, ownerCallback).also { listeners.add(it) } - } - - - fun rebind(newState: S) { - val oldState = delegate - if (oldState == newState) { - return - } - - delegate = newState - - listeners = - listeners.mapNotNullTo(mutableListOf()) { entry -> - entry.removeCallback() - val listenerCallback = entry.get() ?: return@mapNotNullTo null - val removeCallback = newState.onSetValue(ReferenceHolder.Weak, listenerCallback) - ListenerEntry(this, listenerCallback, removeCallback, entry.ownerCallback) - } - - val oldValue = oldState.get() - val newValue = newState.get() - if (oldValue != newValue) { - listeners.forEach { it.get()?.invoke(newValue) } - } - } - - private fun cleanupStaleListeners() { - while (true) { - val reference = referenceQueue.poll() ?: break - (reference as ListenerEntry<*>).invoke() - } - } - - private class ListenerEntry( - private val state: DelegatingStateBase, - listenerCallback: (T) -> Unit, - val removeCallback: () -> Unit, - val ownerCallback: WeakReference<() -> Unit>, - ) : WeakReference<(T) -> Unit>(listenerCallback, state.referenceQueue), () -> Unit { - override fun invoke() { - state.listeners.remove(this@ListenerEntry) - removeCallback() - ownerCallback.get()?.invoke() - } - } -} - -/** Default implementation of [DelegatingState] */ -private class DelegatingStateImpl(delegate: State) : - DelegatingStateBase>(delegate), DelegatingState - -/** Default implementation of [DelegatingMutableState] */ -private class DelegatingMutableStateImpl(delegate: MutableState) : - DelegatingStateBase>(delegate), DelegatingMutableState { - override fun set(mapper: (T) -> T) { - delegate.set(mapper) - } -} - -/** A [BasicState] which additionally implements [ReferenceHolder] */ -private class ReferenceHoldingBasicState(value: T) : BasicState(value), ReferenceHolder { - private val heldReferences = mutableListOf() - - override fun holdOnto(listener: Any): () -> Unit { - heldReferences.add(listener) - return { heldReferences.remove(listener) } - } -} - /** A simple implementation of [ReferenceHolder] */ class ReferenceHolderImpl : ReferenceHolder { private val heldReferences = mutableListOf() From 30d4b36ff4b0e06474256c5253186a3a439f73f7 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 4 Mar 2024 09:28:00 +0100 Subject: [PATCH 15/66] StateV2: Clean up redundant `map` after `zip` Source-Commit: 9512ff54029a748f22e7b076536da56e846cdbef --- .../gg/essential/elementa/state/v2/combinators/booleans.kt | 4 ++-- .../gg/essential/elementa/state/v2/combinators/strings.kt | 2 +- .../kotlin/gg/essential/elementa/state/v2/listCombinators.kt | 4 ++-- .../kotlin/gg/essential/elementa/state/v2/setCombinators.kt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/booleans.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/booleans.kt index d6249124..afa9cb32 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/booleans.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/booleans.kt @@ -4,10 +4,10 @@ import gg.essential.elementa.state.v2.MutableState import gg.essential.elementa.state.v2.State infix fun State.and(other: State) = - zip(other).map { (a, b) -> a && b } + zip(other) { a, b -> a && b } infix fun State.or(other: State) = - zip(other).map { (a, b) -> a || b } + zip(other) { a, b -> a || b } operator fun State.not() = map { !it } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/strings.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/strings.kt index 6d2d0cad..0eb99655 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/strings.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/strings.kt @@ -3,7 +3,7 @@ package gg.essential.elementa.state.v2.combinators import gg.essential.elementa.state.v2.State fun State.contains(other: State, ignoreCase: Boolean = false) = - zip(other).map { (a, b) -> a.contains(b, ignoreCase) } + zip(other) { a, b -> a.contains(b, ignoreCase) } fun State.isEmpty() = map { it.isEmpty() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt index 96bcdba6..c656c35b 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt @@ -118,10 +118,10 @@ fun ListState.mapList(mapper: (List) -> List): ListState = map(mapper).toListState() fun ListState.zipWithEachElement(otherState: State, transform: (T, U) -> V) = - zip(otherState).map { (list, other) -> list.map { transform(it, other) } }.toListState() + zip(otherState) { list, other -> list.map { transform(it, other) } }.toListState() fun ListState.zipElements(otherList: ListState, transform: (T, U) -> V) = - zip(otherList).map { (a, b) -> a.zip(b, transform) }.toListState() + zip(otherList) { a, b -> a.zip(b, transform) }.toListState() fun ListState.mapEachNotNull(mapper: (T) -> U?) = mapList { it.mapNotNull(mapper) } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt index 99bde64b..406d6897 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt @@ -71,7 +71,7 @@ fun SetState.mapSet(mapper: (Set) -> Set): SetState = map(mapper).toSetState() fun SetState.zipWithEachElement(otherState: State, transform: (T, U) -> V) = - zip(otherState).map { (set, other) -> set.mapTo(mutableSetOf()) { transform(it, other) } }.toSetState() + zip(otherState) { set, other -> set.mapTo(mutableSetOf()) { transform(it, other) } }.toSetState() fun SetState.mapEachNotNull(mapper: (T) -> U?) = mapSet { it.mapNotNullTo(mutableSetOf(), mapper) } From 754ecc8bf7dda78ac725bedd35f09e1c62b87be6 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 4 Mar 2024 09:30:30 +0100 Subject: [PATCH 16/66] StateV2: Move initialization into `init` lambda Allows `mapChange` to become completely lazy in the future. Source-Commit: dcfabf9bfe0f3843c2f173133afe767a4c9996f2 --- .../elementa/state/v2/listCombinators.kt | 31 ++++++++++--------- .../elementa/state/v2/setCombinators.kt | 15 ++++----- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt index c656c35b..1d036a7b 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt @@ -8,10 +8,12 @@ import gg.essential.elementa.state.v2.combinators.zip fun ListState.toSet(): SetState { val count = mutableMapOf() - for (element in get()) { - count.compute(element) { _, c -> (c ?: 0) + 1 } - } - return mapChange({ MutableTrackedSet(it.toMutableSet()) }, { set, change -> + return mapChange({ list -> + for (element in list) { + count.compute(element) { _, c -> (c ?: 0) + 1 } + } + MutableTrackedSet(list.toMutableSet()) + }, { set, change -> when (change) { is TrackedList.Add -> { if (count.compute(change.element.value) { _, c -> (c ?: 0) + 1 } == 1) { @@ -35,17 +37,18 @@ fun ListState.toSet(): SetState { // mapList { it.filter(filter) } fun ListState.filter(filter: (T) -> Boolean): ListState { val indices = mutableListOf() - val init = MutableTrackedList(mutableListOf().also { filteredList -> - for (elem in get()) { - if (filter(elem)) { - indices.add(filteredList.size) - filteredList.add(elem) - } else { - indices.add(-1) + return mapChange({ list -> + MutableTrackedList(mutableListOf().also { filteredList -> + for (elem in list) { + if (filter(elem)) { + indices.add(filteredList.size) + filteredList.add(elem) + } else { + indices.add(-1) + } } - } - }) - return mapChange({ init }) { list, change -> + }) + }) { list, change -> when (change) { is TrackedList.Add -> { if (filter(change.element.value)) { diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt index 406d6897..21165f35 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt @@ -34,13 +34,14 @@ fun SetState.filter(filter: (T) -> Boolean): SetState = fun SetState.mapEach(mapper: (T) -> U): SetState { val mappedValues = mutableMapOf() val mappedCount = mutableMapOf() - val init = MutableTrackedSet(get().mapTo(mutableSetOf()) { value -> - mapper(value).also { mappedValue -> - mappedValues[value] = mappedValue - mappedCount.compute(mappedValue) { _, i -> (i ?: 0) + 1} - } - }) - return mapChange({ init }) { list, change -> + return mapChange({ set -> + MutableTrackedSet(set.mapTo(mutableSetOf()) { value -> + mapper(value).also { mappedValue -> + mappedValues[value] = mappedValue + mappedCount.compute(mappedValue) { _, i -> (i ?: 0) + 1} + } + }) + }) { list, change -> when (change) { is TrackedSet.Add -> { val mappedValue = mapper(change.element) From 28cf9ff0c673e763eca9a00d8e263e7ea0e4c12e Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 4 Mar 2024 09:55:04 +0100 Subject: [PATCH 17/66] StateV2: Introduce new primitives The previous `derivedState`/`onSetValue` primitives will be replaced by stateBy-based `memo`/`effect`. The `memo` primitive works exactly like `stateBy` (it is separate because future state implementations will implement it different). Additionally this allows us to deprecate `stateBy` to encourage users to choose between `memo` (which caches its result and therefore creates a full-blown state) and a simple `State { other() + 1 }` which will simply forward on every call to `other` without caching its own result and may be preferable for small changes where re-computing on each access is acceptable and update avoidance isn't required. The `effect` primitive works like `onSetValueAndNow` but with `stateBy` capabilities. The `AndNow` part is important because we need to run the block at least once before we can observe the states it depends on. For cases where one really only cares about changes and not the initial value, a simple `State.onChange` extension function is provided. Source-Commit: 728a1fa0041b3a1688dc6289fee2c43d5c9a34dc --- .../elementa/common/stateExtensions.kt | 1 + .../essential/elementa/state/v2/impl/Impl.kt | 3 + .../elementa/state/v2/impl/legacy/impl.kt | 61 ++++++++++++++++++- .../gg/essential/elementa/state/v2/state.kt | 54 +++++++++++++++- .../gg/essential/elementa/state/v2/stateBy.kt | 40 +++--------- 5 files changed, 121 insertions(+), 38 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/stateExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/stateExtensions.kt index 955445e6..85650584 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/stateExtensions.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/stateExtensions.kt @@ -5,6 +5,7 @@ import gg.essential.elementa.state.v2.ReferenceHolder fun State.onSetValueAndNow(listener: (T) -> Unit) = onSetValue(listener).also { listener(get()) } +@Deprecated("See `State.onSetValue`. Use `stateBy`/`effect` instead.") fun gg.essential.elementa.state.v2.State.onSetValueAndNow(owner: ReferenceHolder, listener: (T) -> Unit) = onSetValue(owner, listener).also { listener(get()) } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt index 5b7f1405..71a3b7f4 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt @@ -4,10 +4,13 @@ import gg.essential.elementa.state.v2.ReferenceHolder import gg.essential.elementa.state.v2.DelegatingMutableState import gg.essential.elementa.state.v2.DelegatingState import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.Observer import gg.essential.elementa.state.v2.State internal interface Impl { fun mutableState(value: T): MutableState + fun memo(func: Observer.() -> T): State + fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit fun stateDelegatingTo(state: State): DelegatingState diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt index a2b089e3..9cb8fffa 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt @@ -5,6 +5,7 @@ import gg.essential.elementa.state.v2.ReferenceHolder import gg.essential.elementa.state.v2.DelegatingMutableState import gg.essential.elementa.state.v2.DelegatingState import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.Observer import gg.essential.elementa.state.v2.State import gg.essential.elementa.state.v2.impl.Impl import java.lang.ref.ReferenceQueue @@ -14,6 +15,50 @@ import java.lang.ref.WeakReference internal object LegacyImpl : Impl { override fun mutableState(value: T): MutableState = BasicState(value) + override fun memo(func: Observer.() -> T): State { + val subscribed = mutableMapOf, () -> Unit>() + val observed = mutableSetOf>() + val scope = ObserverImpl(observed) + + return derivedState(initialValue = func(scope)) { owner, derivedState -> + fun updateSubscriptions() { + for (state in observed) { + if (state in subscribed) continue + + subscribed[state] = state.onSetValue(owner) { + val newValue = func(scope) + updateSubscriptions() + derivedState.set(newValue) + } + } + + subscribed.entries.removeAll { (state, unregister) -> + if (state !in observed) { + unregister() + true + } else { + false + } + } + + observed.clear() + } + updateSubscriptions() + } + } + + override fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit { + var disposed = false + val release = referenceHolder.holdOnto(memo { + if (disposed) return@memo + func() + }) + return { + disposed = true + release() + } + } + override fun stateDelegatingTo(state: State): DelegatingState = DelegatingStateImpl(state) override fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState = DelegatingMutableStateImpl(state) @@ -24,6 +69,8 @@ internal object LegacyImpl : Impl { ): State = ReferenceHoldingBasicState(initialValue).apply { builder(this, this) } } +private class ObserverImpl(val observed: MutableSet>) : Observer + /** A simple implementation of [MutableState], containing only a backing field */ private open class BasicState(private var valueBacker: T) : MutableState { private val referenceQueue = ReferenceQueue() @@ -41,7 +88,12 @@ private open class BasicState(private var valueBacker: T) : MutableState { */ private var liveSize = 0 - override fun get() = valueBacker + override fun Observer.get(): T { + (this@get as? ObserverImpl)?.observed?.add(this@BasicState) + return getUntracked() + } + + override fun getUntracked(): T = valueBacker override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { cleanupStaleListeners() @@ -110,7 +162,12 @@ private open class DelegatingStateBase>(protected var delegate: private val referenceQueue = ReferenceQueue() private var listeners = mutableListOf>() - override fun get(): T = delegate.get() + override fun Observer.get(): T { + (this@get as? ObserverImpl)?.observed?.add(this@DelegatingStateBase) + return getUntracked() + } + + override fun getUntracked(): T = delegate.get() override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { cleanupStaleListeners() diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt index 2297f1a8..0c6a1c30 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt @@ -6,6 +6,32 @@ import gg.essential.elementa.state.v2.impl.legacy.LegacyImpl private val impl: Impl = LegacyImpl +interface Observer { + /** + * Get the current value of the State object and subscribe the observer to be re-evaluated when it changes. + */ + operator fun State.invoke(): T = with(this@Observer) { get() } +} + +object Untracked : Observer + +fun memo(func: Observer.() -> T): State = impl.memo(func) +fun State.memo(): State = memo inner@{ this@memo() } + +fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit = impl.effect(referenceHolder, func) + +fun State.onChange(referenceHolder: ReferenceHolder, func: Observer.(value: T) -> Unit): () -> Unit { + var first = true + return effect(referenceHolder) { + val value = this@onChange() + if (first) { + first = false + } else { + func(value) + } + } +} + /** * The base for all Elementa State objects. * @@ -21,9 +47,20 @@ private val impl: Impl = LegacyImpl * Another advantage arises when using Kotlin, as States can be delegated to. For more information, * see delegation.kt. */ -interface State { +fun interface State { + /** + * Get the current value of this State object and subscribe the observer to be re-evaluated when it changes. + */ + fun Observer.get(): T + + /** + * Get the current value of this State object. + */ + fun getUntracked(): T = with(Untracked) { get() } + /** Get the value of this State object */ - fun get(): T + @Deprecated("Calls to this method are not tracked. If this is intentional, use `getUntracked` instead.") + fun get(): T = getUntracked() /** * Register a listener which will be called whenever the value of this State object changes @@ -49,7 +86,15 @@ interface State { * * @return A callback which, when invoked, removes this listener */ - fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit + @Deprecated("If this method is used to update dependent states, use `stateBy` instead.\n" + + "Otherwise the State system cannot be guaranteed that downsteam states have a consistent view of upstream" + + "values (i.e. so called \"glitches\" may occur) and all dependences will be forced to evaluate eagerly" + + "instead of the usual lazy behavior (where states are only updated if there is a consumer).\n" + + "\n" + + "If this method is used to drive a final effect (e.g. updating some non-State UI property), and you also" + + "care about the initial value of the state, consider using `effect` instead.\n" + + "If you really only care about changes and not the inital value, use `onChange`.") + fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit = onChange(owner) { listener(it) } } /* ReferenceHolder is defined in Elementa as: @@ -122,6 +167,7 @@ fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState impl.mutableStateDelegatingTo(state) /** Creates a [State] which derives its value in a user-defined way from one or more other states */ +@Deprecated("See `State.onSetValue`. Use `stateBy` instead.") fun derivedState( initialValue: T, builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit, @@ -131,6 +177,8 @@ fun derivedState( private class ImmutableState(private val value: T) : State { override fun get(): T = value override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit = {} + override fun Observer.get(): T = value + override fun getUntracked(): T = value } /** A simple implementation of [ReferenceHolder] */ diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt index ee3f50e4..159bd160 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt @@ -4,46 +4,20 @@ package gg.essential.elementa.state.v2 * Creates a state that derives its value using the given [block]. The value of any state may be accessed within this * block via [StateByScope.invoke]. These accesses are tracked and the block is automatically re-evaluated whenever any * one of them changes. - * - * Note that while this is generally easier to use than [derivedState], it also comes with greater overhead. */ +@Deprecated("Use `memo` (result is cached) or `State` lambda (result is not cached)", level = DeprecationLevel.WARNING) fun stateBy(block: StateByScope.() -> T): State { - val subscribed = mutableMapOf, () -> Unit>() - val observed = mutableSetOf>() - val scope = object : StateByScope { - override fun State.invoke(): T { - observed.add(this) - return get() - } - } - - return derivedState(initialValue = block(scope)) { owner, derivedState -> - fun updateSubscriptions() { - for (state in observed) { - if (state in subscribed) continue - - subscribed[state] = state.onSetValue(owner) { - val newValue = block(scope) - updateSubscriptions() - derivedState.set(newValue) - } + return memo { + val scope = object : StateByScope { + override fun State.invoke(): T { + return with(this@memo) { get() } } - - subscribed.entries.removeAll { (state, unregister) -> - if (state !in observed) { - unregister() - true - } else { - false - } - } - - observed.clear() } - updateSubscriptions() + block(scope) } } +@Deprecated("Superseded by `Observer`", level = DeprecationLevel.WARNING) interface StateByScope { operator fun State.invoke(): T } From ea95a8f8fab0b7816f3b5229894711a57f4a904e Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 4 Mar 2024 10:20:36 +0100 Subject: [PATCH 18/66] StateV2: Re-implement `toV2` with more indirection This way any listeners registered on the returned v2 State are weak again (as would be expected from any v2 State), and only the v2 State itself stays registered on the v1 state indefinitely. Also allows us to move away from `onSetValue` as a primitive because we no longer pass that callback to the v1 directly. Source-Commit: 43a0362a909429224b0ca267f1be3cb33695a1c9 --- .../elementa/state/v2/compatibility.kt | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/compatibility.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/compatibility.kt index 0ba859a3..a92255a5 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/compatibility.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/compatibility.kt @@ -1,6 +1,7 @@ package gg.essential.elementa.state.v2 import gg.essential.elementa.state.v2.ReferenceHolder +import java.util.function.Consumer import gg.essential.elementa.state.State as V1State private class V2AsV1State(private val v2State: State, owner: ReferenceHolder) : V1State() { @@ -22,17 +23,6 @@ private class V2AsV1State(private val v2State: State, owner: ReferenceHold } } -private class V1AsV2State(private val v1State: V1State) : MutableState { - override fun get(): T = - v1State.get() - - override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit = - v1State.onSetValue(listener) - - override fun set(mapper: (T) -> T) = - v1State.set(mapper) -} - /** * Converts this state into a v1 [State][V1State]. * @@ -49,11 +39,30 @@ fun State.toV1(owner: ReferenceHolder): V1State = V2AsV1State(this, ow /** * Converts this state into a v2 [MutableState]. * - * Note that unlike regular v2 state, listeners registered on this state will not by default be automatically - * garbage-collected unless the entire v1 state itself can be garbage collected. + * The returned state is registered as a listener on the v1 state and as such will live as long as the v1 state. * This matches v1 state behavior. If this is not desired, stop using v1 state. */ -fun V1State.toV2(): MutableState = V1AsV2State(this) +fun V1State.toV2(): MutableState { + val referenceHolder = ReferenceHolderImpl() + val v1 = this + val v2 = mutableStateOf(get()) + + v2.onSetValue(referenceHolder) { value -> + if (v1.get() != value) { + v1.set(value) + } + } + v1.onSetValue(object : Consumer { + @Suppress("unused") // keep this alive for as long as the v1 state + val referenceHolder = referenceHolder + + override fun accept(value: T) { + v2.set(value) + } + }) + + return v2 +} /** * Returns a delegating state with internal mutability. That is, the value of the returned state generally follows the From 11129b94915c2d5de16d3cb58f60b48f49e6afc0 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 4 Mar 2024 10:43:34 +0100 Subject: [PATCH 19/66] StateV2: Add new lazy, glitch-free, stateBy-based implementation Source-Commit: 3f6f19fe8d2c3f01d3ee5f9e0474f4f3efda7652 --- .../essential/elementa/state/v2/impl/Impl.kt | 28 +- .../elementa/state/v2/impl/minimal/impl.kt | 287 ++++++++++++++++++ .../gg/essential/elementa/state/v2/state.kt | 4 +- 3 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt index 71a3b7f4..13451b74 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt @@ -5,19 +5,41 @@ import gg.essential.elementa.state.v2.DelegatingMutableState import gg.essential.elementa.state.v2.DelegatingState import gg.essential.elementa.state.v2.MutableState import gg.essential.elementa.state.v2.Observer +import gg.essential.elementa.state.v2.ReferenceHolderImpl import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.mutableStateOf internal interface Impl { fun mutableState(value: T): MutableState fun memo(func: Observer.() -> T): State fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit - fun stateDelegatingTo(state: State): DelegatingState + fun stateDelegatingTo(state: State): DelegatingState = + object : DelegatingState { + private val target = mutableStateOf(state) + override fun rebind(newState: State) = target.set(newState) + override fun Observer.get(): T = target()() + } - fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState + fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState = + object : DelegatingMutableState { + private val target = mutableStateOf(state) + override fun set(mapper: (T) -> T) = target.getUntracked().set(mapper) + override fun rebind(newState: MutableState) = target.set(newState) + override fun Observer.get(): T = target()() + } fun derivedState( initialValue: T, builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit, - ): State + ): State = + object : State { + val referenceHolder = ReferenceHolderImpl() // keep this alive for at least as long as the returned state + val derivedState = mutableStateOf(initialValue) + init { + builder(referenceHolder, derivedState) + } + + override fun Observer.get(): T = derivedState() + } } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt new file mode 100644 index 00000000..e3648b21 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt @@ -0,0 +1,287 @@ +package gg.essential.elementa.state.v2.impl.minimal + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.Observer +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.impl.Impl +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +/** + * Minimal mark-then-pull-based node graph implementation. + * + * This implementation operates in two simple phases: + * - The first phase pushes the may-be-dirty state through the graph to all potentially affected nodes + * - The second phase goes through all potentially affected effect nodes and recursively checks if they need to be + * updated. + * + * This does make for a fully correct reference implementation. + * However it always needs to visit all potentially affected effects on every update. + * In particular if we have a large mutable state (like a list) which is then split off into many smaller ones (like its + * items), all effects attached to all of the smaller nodes need to be visited, even if only a single item was modified + * in the list. + */ +internal object MarkThenPullImpl : Impl { + override fun mutableState(value: T): MutableState { + val node = Node(NodeKind.Mutable, NodeState.Clean, UNREACHABLE, value) + return object : State by node, MutableState { + override fun set(mapper: (T) -> T) { + node.set(mapper(node.getUntracked())) + } + } + } + + override fun memo(func: Observer.() -> T): State = + Node(NodeKind.Memo, NodeState.Dirty, func, null) + + override fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit { + val node = Node(NodeKind.Effect, NodeState.Dirty, func, Unit) + node.update(Update.get()) + val refCleanup = referenceHolder.holdOnto(node) + return { + node.cleanup() + refCleanup() + } + } + +} + +private enum class NodeKind { + /** + * A leaf node which represents a manually updated value which only changes when [Node.set] is invoked. + * It does not have any dependencies nor a [Node.func]. + */ + Mutable, + + /** + * An intermediate node which is lazily computed and lazily updated via [Node.func]. + * May have any number of both dependencies and dependents. + */ + Memo, + + /** + * A node which represents the root of a dependency tree. + * It does not have any dependents and does not produce any value. + * + * Unlike [Memo], it is not lazy and will be updated when any of its dependencies change. + * If any of its dependencies are lazy, they too will be updated as necessary for this node to obtain a complete + * view of up-to-date values. + */ + Effect, +} + +private enum class NodeState { + /** + * The [Node.value] is up-to-date. + * For [NodeKind.Effect], the [Node.func] has been run with the latest values. + */ + Clean, + + /** + * Some of the node's dependencies, including transitive one, may be [Dirty] and need to be checked. + */ + ToBeChecked, + + /** + * The [Node.value] is outdated and needs to be re-evaluated. + * For [NodeKind.Effect], the [Node.func] needs to be re-run. + */ + Dirty, +} + +private class Node( + val kind: NodeKind, + private var state: NodeState, + private val func: Observer.() -> T, + private var value: T?, +) : State, Observer { + private val observed = mutableSetOf>() + private val dependencies = mutableListOf>() + private val dependents: MutableList>> = mutableListOf() + + override fun Observer.get(): T { + return getTracked(this@get) + } + + fun getTracked(observer: Observer): T { + if (observer is Node<*>) { + observer.observed.add(this) + } + return getUntracked() + } + + override fun getUntracked(): T { + if (state != NodeState.Clean) { + update(Update.get()) + } + @Suppress("UNCHECKED_CAST") + return value as T + } + + fun set(newValue: T) { + assert(kind == NodeKind.Mutable) + + if (value == newValue) { + return + } + + value = newValue + + val update = Update.get() + for (dep in dependents.iter()) { + dep.markDirty(update) + } + update.flush() + } + + private fun mark(update: Update, newState: NodeState) { + val oldState = state + if (oldState.ordinal >= newState.ordinal) { + return + } + + if (kind == NodeKind.Effect && oldState == NodeState.Clean) { + update.queueNode(this) + } + + state = newState + } + + private fun markDirty(update: Update) { + mark(update, NodeState.Dirty) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + private fun markToBeChecked(update: Update) { + if (state != NodeState.Clean) return + + mark(update, NodeState.ToBeChecked) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + fun update(update: Update) { + if (state == NodeState.Clean) { + return + } + + if (state == NodeState.ToBeChecked) { + for (dep in dependencies) { + dep.update(update) + if (state == NodeState.Dirty) { + break + } + } + } + + if (state == NodeState.Dirty) { + val newValue = func(this) + + for (i in dependencies.indices.reversed()) { + val dep = dependencies[i] + if (dep !in observed) { + dependencies.removeAt(i) + dep.removeDependent(this) + } + } + for (dep in observed) { + if (dep !in dependencies) { + dependencies.add(dep) + dep.addDependent(this) + } + } + observed.clear() + + if (value != newValue) { + value = newValue + + for (dep in dependents.iter()) { + dep.mark(update, NodeState.Dirty) + } + } + } + + state = NodeState.Clean + } + + fun cleanup() { + for (dep in dependencies) { + dep.removeDependent(this) + } + dependencies.clear() + } + + private var referenceQueueField: ReferenceQueue>? = null + private val referenceQueue: ReferenceQueue> + get() = referenceQueueField ?: ReferenceQueue>().also { referenceQueueField = it } + + private fun addDependent(node: Node<*>) { + cleanupStaleReferences() + dependents.add(WeakReference(node, referenceQueue)) + } + + private fun removeDependent(node: Node<*>) { + val index = dependents.indexOfFirst { it.get() == node } + if (index >= 0) { + dependents.removeAt(index) + } + } + + private fun cleanupStaleReferences() { + val queue = referenceQueueField ?: return + + if (queue.poll() == null) { + return + } + + @Suppress("ControlFlowWithEmptyBody") + while (queue.poll() != null); + + dependents.removeIf { it.get() == null } + } + + private fun MutableList>>.iter(): Iterator> { + return asSequence().mapNotNull { it.get() }.iterator() + } +} + +private class Update { + private var queue: MutableList> = mutableListOf() + private var processing: Boolean = false + + fun queueNode(node: Node<*>) { + queue.add(node) + } + + fun flush() { + if (processing || queue.isEmpty()) { + return + } + + processing = true + try { + var i = 0 + while (true) { + val node = queue.getOrNull(i) ?: break + node.update(this) + i++ + } + queue.clear() + } finally { + processing = false + } + } + + companion object { + private val INSTANCE = ThreadLocal.withInitial { Update() } + fun get(): Update = INSTANCE.get() + } +} + +private val UNREACHABLE: Observer.() -> Nothing = { error("unreachable") } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt index 0c6a1c30..cac3e4ee 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt @@ -2,9 +2,9 @@ package gg.essential.elementa.state.v2 import gg.essential.elementa.state.v2.ReferenceHolder import gg.essential.elementa.state.v2.impl.Impl -import gg.essential.elementa.state.v2.impl.legacy.LegacyImpl +import gg.essential.elementa.state.v2.impl.simple.MarkThenPullImpl -private val impl: Impl = LegacyImpl +private val impl: Impl = MarkThenPullImpl interface Observer { /** From ef0d5b5ce7942d785f55749a4a729d34f169b34b Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 4 Mar 2024 15:46:27 +0100 Subject: [PATCH 20/66] StateV2: Duplicate minimal implementation Source-Commit: 3e35dde200508848ffceae864e134bf135fd1790 --- .../elementa/state/v2/impl/basic/impl.kt | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt new file mode 100644 index 00000000..59a9207e --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt @@ -0,0 +1,287 @@ +package gg.essential.elementa.state.v2.impl.basic + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.Observer +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.impl.Impl +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +/** + * Minimal mark-then-pull-based node graph implementation. + * + * This implementation operates in two simple phases: + * - The first phase pushes the may-be-dirty state through the graph to all potentially affected nodes + * - The second phase goes through all potentially affected effect nodes and recursively checks if they need to be + * updated. + * + * This does make for a fully correct reference implementation. + * However it always needs to visit all potentially affected effects on every update. + * In particular if we have a large mutable state (like a list) which is then split off into many smaller ones (like its + * items), all effects attached to all of the smaller nodes need to be visited, even if only a single item was modified + * in the list. + */ +internal object MarkThenPullImpl : Impl { + override fun mutableState(value: T): MutableState { + val node = Node(NodeKind.Mutable, NodeState.Clean, UNREACHABLE, value) + return object : State by node, MutableState { + override fun set(mapper: (T) -> T) { + node.set(mapper(node.getUntracked())) + } + } + } + + override fun memo(func: Observer.() -> T): State = + Node(NodeKind.Memo, NodeState.Dirty, func, null) + + override fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit { + val node = Node(NodeKind.Effect, NodeState.Dirty, func, Unit) + node.update(Update.get()) + val refCleanup = referenceHolder.holdOnto(node) + return { + node.cleanup() + refCleanup() + } + } + +} + +private enum class NodeKind { + /** + * A leaf node which represents a manually updated value which only changes when [Node.set] is invoked. + * It does not have any dependencies nor a [Node.func]. + */ + Mutable, + + /** + * An intermediate node which is lazily computed and lazily updated via [Node.func]. + * May have any number of both dependencies and dependents. + */ + Memo, + + /** + * A node which represents the root of a dependency tree. + * It does not have any dependents and does not produce any value. + * + * Unlike [Memo], it is not lazy and will be updated when any of its dependencies change. + * If any of its dependencies are lazy, they too will be updated as necessary for this node to obtain a complete + * view of up-to-date values. + */ + Effect, +} + +private enum class NodeState { + /** + * The [Node.value] is up-to-date. + * For [NodeKind.Effect], the [Node.func] has been run with the latest values. + */ + Clean, + + /** + * Some of the node's dependencies, including transitive one, may be [Dirty] and need to be checked. + */ + ToBeChecked, + + /** + * The [Node.value] is outdated and needs to be re-evaluated. + * For [NodeKind.Effect], the [Node.func] needs to be re-run. + */ + Dirty, +} + +private class Node( + val kind: NodeKind, + private var state: NodeState, + private val func: Observer.() -> T, + private var value: T?, +) : State, Observer { + private val observed = mutableSetOf>() + private val dependencies = mutableListOf>() + private val dependents: MutableList>> = mutableListOf() + + override fun Observer.get(): T { + return getTracked(this@get) + } + + fun getTracked(observer: Observer): T { + if (observer is Node<*>) { + observer.observed.add(this) + } + return getUntracked() + } + + override fun getUntracked(): T { + if (state != NodeState.Clean) { + update(Update.get()) + } + @Suppress("UNCHECKED_CAST") + return value as T + } + + fun set(newValue: T) { + assert(kind == NodeKind.Mutable) + + if (value == newValue) { + return + } + + value = newValue + + val update = Update.get() + for (dep in dependents.iter()) { + dep.markDirty(update) + } + update.flush() + } + + private fun mark(update: Update, newState: NodeState) { + val oldState = state + if (oldState.ordinal >= newState.ordinal) { + return + } + + if (kind == NodeKind.Effect && oldState == NodeState.Clean) { + update.queueNode(this) + } + + state = newState + } + + private fun markDirty(update: Update) { + mark(update, NodeState.Dirty) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + private fun markToBeChecked(update: Update) { + if (state != NodeState.Clean) return + + mark(update, NodeState.ToBeChecked) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + fun update(update: Update) { + if (state == NodeState.Clean) { + return + } + + if (state == NodeState.ToBeChecked) { + for (dep in dependencies) { + dep.update(update) + if (state == NodeState.Dirty) { + break + } + } + } + + if (state == NodeState.Dirty) { + val newValue = func(this) + + for (i in dependencies.indices.reversed()) { + val dep = dependencies[i] + if (dep !in observed) { + dependencies.removeAt(i) + dep.removeDependent(this) + } + } + for (dep in observed) { + if (dep !in dependencies) { + dependencies.add(dep) + dep.addDependent(this) + } + } + observed.clear() + + if (value != newValue) { + value = newValue + + for (dep in dependents.iter()) { + dep.mark(update, NodeState.Dirty) + } + } + } + + state = NodeState.Clean + } + + fun cleanup() { + for (dep in dependencies) { + dep.removeDependent(this) + } + dependencies.clear() + } + + private var referenceQueueField: ReferenceQueue>? = null + private val referenceQueue: ReferenceQueue> + get() = referenceQueueField ?: ReferenceQueue>().also { referenceQueueField = it } + + private fun addDependent(node: Node<*>) { + cleanupStaleReferences() + dependents.add(WeakReference(node, referenceQueue)) + } + + private fun removeDependent(node: Node<*>) { + val index = dependents.indexOfFirst { it.get() == node } + if (index >= 0) { + dependents.removeAt(index) + } + } + + private fun cleanupStaleReferences() { + val queue = referenceQueueField ?: return + + if (queue.poll() == null) { + return + } + + @Suppress("ControlFlowWithEmptyBody") + while (queue.poll() != null); + + dependents.removeIf { it.get() == null } + } + + private fun MutableList>>.iter(): Iterator> { + return asSequence().mapNotNull { it.get() }.iterator() + } +} + +private class Update { + private var queue: MutableList> = mutableListOf() + private var processing: Boolean = false + + fun queueNode(node: Node<*>) { + queue.add(node) + } + + fun flush() { + if (processing || queue.isEmpty()) { + return + } + + processing = true + try { + var i = 0 + while (true) { + val node = queue.getOrNull(i) ?: break + node.update(this) + i++ + } + queue.clear() + } finally { + processing = false + } + } + + companion object { + private val INSTANCE = ThreadLocal.withInitial { Update() } + fun get(): Update = INSTANCE.get() + } +} + +private val UNREACHABLE: Observer.() -> Nothing = { error("unreachable") } From 7035e487cb786479d9a51905ae7956dbcd121999 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 4 Mar 2024 15:53:25 +0100 Subject: [PATCH 21/66] StateV2: Implement more performant update propagation Source-Commit: 7e57f17ffc99e7b96d685041ff63520987d08ec4 --- .../elementa/state/v2/impl/basic/impl.kt | 33 ++++++++++++------- .../elementa/state/v2/impl/minimal/impl.kt | 2 ++ .../gg/essential/elementa/state/v2/state.kt | 4 +-- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt index 59a9207e..c1c42f5d 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt @@ -9,20 +9,29 @@ import java.lang.ref.ReferenceQueue import java.lang.ref.WeakReference /** - * Minimal mark-then-pull-based node graph implementation. + * Semi-lazy node graph implementation. * - * This implementation operates in two simple phases: - * - The first phase pushes the may-be-dirty state through the graph to all potentially affected nodes - * - The second phase goes through all potentially affected effect nodes and recursively checks if they need to be - * updated. + * The actual code is extremely similar to [gg.essential.elementa.state.v2.impl.minimal.MarkThenPullImpl] (literally + * only a single line difference), however the mechanism by which it functions is not. + * The code has been duplicated, so we continue to have a simple reference implementation even when this implementation + * evolves further. * - * This does make for a fully correct reference implementation. - * However it always needs to visit all potentially affected effects on every update. - * In particular if we have a large mutable state (like a list) which is then split off into many smaller ones (like its - * items), all effects attached to all of the smaller nodes need to be visited, even if only a single item was modified - * in the list. + * This implementation operates in three phases: + * - The first phase propagates a may-be-dirty state to all potentially affected nodes + * - The second phase goes through all dirty nodes and run the third phase for each of them + * - The phase phase checks if the given node needs to be updated, recursively. And if so, updates it, marks all its + * direct dependents as dirty (to be processed by the second phase), and then returns to the second phase. + * + * Unlike [gg.essential.elementa.state.v2.impl.minimal.MarkThenPullImpl], this means that sub-graphs which are + * potentially affected but whose dependencies have not actually changed, will not be visited (more than once per + * them actually changing; as opposed to having to re-visit every time they are potentially affected). + * That does mean that this implementation will in exchange potentially visit intermediate nodes which do not actually + * have any effects attached to them any more (hence it only being "semi lazy"). + * However, in practice, non-affected nodes usually vastly outnumber dead intermediate nodes (especially because + * those are usually garbage collected together with the respective effects that used them) by one to two orders of + * magnitude, making this well worth it. */ -internal object MarkThenPullImpl : Impl { +internal object MarkThenPushAndPullImpl : Impl { override fun mutableState(value: T): MutableState { val node = Node(NodeKind.Mutable, NodeState.Clean, UNREACHABLE, value) return object : State by node, MutableState { @@ -141,7 +150,7 @@ private class Node( return } - if (kind == NodeKind.Effect && oldState == NodeState.Clean) { + if (newState == NodeState.Dirty) { update.queueNode(this) } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt index e3648b21..d3b1a5d5 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt @@ -21,6 +21,8 @@ import java.lang.ref.WeakReference * In particular if we have a large mutable state (like a list) which is then split off into many smaller ones (like its * items), all effects attached to all of the smaller nodes need to be visited, even if only a single item was modified * in the list. + * + * For a more performant algorithm, see [gg.essential.elementa.state.v2.impl.markpushpull.MarkThenPushAndPullImpl]. */ internal object MarkThenPullImpl : Impl { override fun mutableState(value: T): MutableState { diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt index cac3e4ee..4d8c8891 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt @@ -2,9 +2,9 @@ package gg.essential.elementa.state.v2 import gg.essential.elementa.state.v2.ReferenceHolder import gg.essential.elementa.state.v2.impl.Impl -import gg.essential.elementa.state.v2.impl.simple.MarkThenPullImpl +import gg.essential.elementa.state.v2.impl.basic.MarkThenPushAndPullImpl -private val impl: Impl = MarkThenPullImpl +private val impl: Impl = MarkThenPushAndPullImpl interface Observer { /** From acd2a1f17d1e6ffb0dd16816fe13fb1e83050884 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 4 Mar 2024 17:51:49 +0100 Subject: [PATCH 22/66] StateV2: Switch away from `derivedState` Source-Commit: 66af5946b9a9f626b5ea2ef942146d17893be784 --- .../elementa/state/v2/combinators/state.kt | 11 ++----- .../gg/essential/elementa/state/v2/list.kt | 31 +++++++++++++------ .../gg/essential/elementa/state/v2/set.kt | 31 +++++++++++++------ 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/state.kt index abbf08be..539aefa3 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/state.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/state.kt @@ -2,13 +2,11 @@ package gg.essential.elementa.state.v2.combinators import gg.essential.elementa.state.v2.MutableState import gg.essential.elementa.state.v2.State -import gg.essential.elementa.state.v2.derivedState +import gg.essential.elementa.state.v2.memo /** Maps this state into a new state */ fun State.map(mapper: (T) -> U): State { - return derivedState(mapper(get())) { owner, derivedState -> - onSetValue(owner) { derivedState.set(mapper(it)) } - } + return memo { mapper(get()) } } /** Maps this mutable state into a new mutable state. */ @@ -25,8 +23,5 @@ fun State.zip(other: State): State> = zip(other, ::Pair) /** Zips this state with another state using [mapper] */ fun State.zip(other: State, mapper: (T, U) -> V): State { - return derivedState(mapper(this.get(), other.get())) { owner, derivedState -> - this.onSetValue(owner) { derivedState.set(mapper(it, other.get())) } - other.onSetValue(owner) { derivedState.set(mapper(this.get(), it)) } - } + return memo { mapper(this@zip(), other()) } } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt index 9504b1e3..2234acaa 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt @@ -7,20 +7,31 @@ typealias ListState = State> typealias MutableListState = MutableState> fun State>.toListState(): ListState { - return derivedState(MutableTrackedList(get().toMutableList())) { owner, derivedState -> - onSetValue(owner) { newList -> - derivedState.set { it.applyChanges(TrackedList.Change.estimate(it, newList)) } - } + var oldList = MutableTrackedList() + return memo { + val newList = get() + oldList.applyChanges(TrackedList.Change.estimate(oldList, newList)).also { oldList = it } } } fun ListState.mapChanges(init: (TrackedList) -> U, update: (old: U, changes: Sequence>) -> U): State { - var oldList = get() - return derivedState(init(oldList)) { owner, derivedState -> - onSetValue(owner) { newList -> - val changes = newList.getChangesSince(oldList).also { oldList = newList } - derivedState.set { update(it, changes) } - } + var trackedList: TrackedList? = null + var trackedValue: U? = null + return memo { + val newList = get() + val oldList = trackedList + val newValue = + if (oldList == null) { + init(newList) + } else { + @Suppress("UNCHECKED_CAST") + update(trackedValue as U, newList.getChangesSince(oldList)) + } + + trackedList = newList + trackedValue = newValue + + newValue } } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/set.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/set.kt index d208ba05..afa981cb 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/set.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/set.kt @@ -6,20 +6,31 @@ typealias SetState = State> typealias MutableSetState = MutableState> fun State>.toSetState(): SetState { - return derivedState(MutableTrackedSet(get().toMutableSet())) { owner, derivedState -> - onSetValue(owner) { newSet -> - derivedState.set { it.applyChanges(TrackedSet.Change.estimate(it, newSet)) } - } + var oldSet = MutableTrackedSet() + return memo { + val newSet = get() + oldSet.applyChanges(TrackedSet.Change.estimate(oldSet, newSet)).also { oldSet = it } } } fun SetState.mapChanges(init: (TrackedSet) -> U, update: (old: U, changes: Sequence>) -> U): State { - var oldSet = get() - return derivedState(init(oldSet)) { owner, derivedState -> - onSetValue(owner) { newSet -> - val changes = newSet.getChangesSince(oldSet).also { oldSet = newSet } - derivedState.set { update(it, changes) } - } + var trackedSet: TrackedSet? = null + var trackedValue: U? = null + return memo { + val newSet = get() + val oldSet = trackedSet + val newValue = + if (oldSet == null) { + init(newSet) + } else { + @Suppress("UNCHECKED_CAST") + update(trackedValue as U, newSet.getChangesSince(oldSet)) + } + + trackedSet = newSet + trackedValue = newValue + + newValue } } From 1d5b7dbd92094f34c5041a242a27ee19f120a618 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 5 Mar 2024 14:34:27 +0100 Subject: [PATCH 23/66] StateV2: Add/update documentation for new primitives Source-Commit: e4fa2d97755b256e1b3b2f7f458cdd3583150750 --- .../gg/essential/elementa/state/v2/state.kt | 125 +++++++++++++++++- 1 file changed, 119 insertions(+), 6 deletions(-) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt index 4d8c8891..a0b9a58b 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt @@ -6,6 +6,17 @@ import gg.essential.elementa.state.v2.impl.basic.MarkThenPushAndPullImpl private val impl: Impl = MarkThenPushAndPullImpl +/** + * A marker interface for an object which may observe which states are being accessed, such that it can then subscribe + * to these states to be updated when they change. + * + * Note that the duration during which a given [Observer] can be used is usually limited to the call in which it was + * received. + * It should not be stored (neither in a field, nor implicitly in an asynchronous lambda) and then used at a later time. + * + * Note: This interface must not be be implemented by user code. The State implementation may cast it to its internal + * implementation type without checking. + */ interface Observer { /** * Get the current value of the State object and subscribe the observer to be re-evaluated when it changes. @@ -13,13 +24,94 @@ interface Observer { operator fun State.invoke(): T = with(this@Observer) { get() } } +/** + * An [Observer] which does not track accesses. + * + * May be used to evaluate a method which requires an [Observer] once to get the current value when you do not care + * about future changes. + * To get the current value of a [State], one can also use the [State.getUntracked] shortcut. + */ object Untracked : Observer +/** + * Creates a [State] which lazily computes its value via the given pure function [func] and caches the result until + * one of the observed dependencies changes. + * + * You **MUST NOT** use [memo] when [func] triggers any side effects; there are no guarantees for when or even how often + * [func] is called (it is however guaranteed to always see a consistent view of all other [State]s). + * To have an external system react to changes in the State system (i.e. for it to "have an effect"), use [effect]. + * + * The two main use cases for [memo] are: + * - [func] represents a non-trivial / expensive computation which you do not want to re-evaluate on each access + * - [func] simplifies its dependencies (e.g. picks one item out of a list) and you do not want its dependents to + * unnecessarily be re-evaluated even though the simplified value is unchanged (e.g. whenever any other entry in the + * list is changed) + * + * If neither of the above applies, consider simply creating a custom [State] implementation which computes your [func] + * every time its [State.get] is called (e.g. instead of `memo { myState() + 1 }` write `State { myState() + 1 }`). + * Doing so has significantly lower overhead (just the cost of a single lambda) than [memo]. + */ fun memo(func: Observer.() -> T): State = impl.memo(func) + +/** + * Creates a [State] which lazily [get][State.get]s its value from `this` State and caches the result until one of the + * observed dependencies changes. + * May return `this` if it is already such a State. + * + * Semantically `State { func() }.memo()` is equivalent to `memo { func() }`. + * + * @see [memo] + */ fun State.memo(): State = memo inner@{ this@memo() } +/** + * Runs the given function [func] once immediately and whenever any of the [State]s it [observes][Observer] change. + * + * A "cleanup" function is returned which when invoked will unregister the effect, such that it will no longer be called + * thereafter. + * + * Hint: If a [State] you wish to use often has unrelated changes you do not care about, consider breaking it down into + * a smaller [State] ahead of time using [memo]. + * + * ### Lifetime + * + * The effect registration is weak by default. + * This means that it may be garbage collected if no other strong references to the returned function exist. + * Once an effect is garbage collected, it will (obviously) no longer be called. + * + * Keeping a strong reference to the returned function is easy to forget, so this method requires you + * to explicitly pass in an object which will maintain a strong reference to it for you. + * With that, your effect will stay active **at least** as long as the given [owner] is alive (unless the returned + * function is explicitly invoked, in which case it ceases operation immediately). + * + * In general, the lifetime of your effect should match the lifetime of the passed [owner], usually the thing + * (e.g. [UIComponent]) the effect is modifying. + * If the owner far outlives your effect, you may be unnecessarily running your effect and leaking memory because owner + * will keep all those effects and anything they reference alive far beyond the point where they are needed. + * If your effect outlives the owner, then it may become inactive sooner than you expected and whatever it is + * updating might no longer update properly. + * + * If you wish to manually keep your effect alive (by holding on to the returned function), pass [ReferenceHolder.Weak] + * as the owner. + * + * ### Recursion + * + * You should avoid calling [MutableState.set] from the given function. + * + * While the State system does support recursion, such nested state changes cannot be performed atomically and as such + * it is very much possible that another [effect] has already observed both the value that trigger your [effect] but + * also the old value of the state you want to update; + * it will then be invoked again which, depending on what it does, may have unintended consequences. + * + * To have the value of [State] depend on one or more other [State]s, use [memo] to create it. + */ fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit = impl.effect(referenceHolder, func) +/** + * Runs the given function [func] whenever the value of `this` State changes. + * + * See [effect] for details. + */ fun State.onChange(referenceHolder: ReferenceHolder, func: Observer.(value: T) -> Unit): () -> Unit { var first = true return effect(referenceHolder) { @@ -35,17 +127,38 @@ fun State.onChange(referenceHolder: ReferenceHolder, func: Observer.(valu /** * The base for all Elementa State objects. * - * State objects are essentially just a wrapper around a value. However, the ability to be deeply - * integrated into an Elementa component allows some nice functionality. + * State objects are essentially just a wrapper around a (potentially computed) value with the ability to subscribe to + * changes. * * The primary advantage of using state is that a single state object can be shared between multiple - * components or constraints. This allows one value update to be seen by multiple components or - * constraints. For example, if a component has many text children, and they all share the same + * components or constraints as well as re-used and combined to derive other State from it. + * All in a declarative way, i.e. no need to manually go and remember to update every piece of GUI, you only update + * the base [MutableState] instance, and everyone who cares will have subscribed (directly or indirectly) and + * automatically be updated accordingly. + * + * This allows one value update to be seen by multiple components or constraints. + * For example, if a component has many text children, and they all share the same * color state variable, then whenever the value of the state object is updated, all of the text * components will instantly change color. * - * Another advantage arises when using Kotlin, as States can be delegated to. For more information, - * see delegation.kt. + * State also composes well, e.g. a function which returns a `State` for whether a component is hovered can + * easily be mapped to one or more `State` (potentially taking into account other state too) which can then be + * used to color the background/outline/etc. of the same or other components. + * + * The most important primitives of the State system: + * - To create a simple [MutableState] which can be updated manually, use [mutableStateOf]. + * - To create [State] which derives its value from other [State], use [memo] or a custom [State] implementation (see + * the documentation on the former for details). + * - To make external systems react to [State] changes, use [effect]. + * + * The Elementa State system also provides a bunch of more subtle functionality that may not be apparent at first + * glance. E.g. it will allow state and effect nodes to be be garbage collected when they are no longer needed, and it + * will generally guarantee that all views of the State system are consistent, i.e. when there are states derived from + * other states, you'll either see the old value of all of them, or the updated values for all of them, but never an + * inconsistent mix of the two. + * + * Those readers familiar with other reactive/signal libraries (e.g. SolidJS, Leptos, Angular, MobX) may notice + * many similarities to these because [State] is pretty much Elementa's solution to the same set of problems. */ fun interface State { /** From cbdae20bf12d1d7c6f9d48e29781617273c12ed8 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 5 Mar 2024 14:35:54 +0100 Subject: [PATCH 24/66] Misc: Clean up redundant explicit deprecation level Source-Commit: a6c8ae695d318884d31b9f947405d670da946aa2 --- .../src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt index 159bd160..62ad1da6 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt @@ -5,7 +5,7 @@ package gg.essential.elementa.state.v2 * block via [StateByScope.invoke]. These accesses are tracked and the block is automatically re-evaluated whenever any * one of them changes. */ -@Deprecated("Use `memo` (result is cached) or `State` lambda (result is not cached)", level = DeprecationLevel.WARNING) +@Deprecated("Use `memo` (result is cached) or `State` lambda (result is not cached)") fun stateBy(block: StateByScope.() -> T): State { return memo { val scope = object : StateByScope { @@ -17,7 +17,7 @@ fun stateBy(block: StateByScope.() -> T): State { } } -@Deprecated("Superseded by `Observer`", level = DeprecationLevel.WARNING) +@Deprecated("Superseded by `Observer`") interface StateByScope { operator fun State.invoke(): T } From 46ee62865041532377f9c933f693a3f107cae5fa Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Thu, 7 Mar 2024 18:31:35 +0100 Subject: [PATCH 25/66] StateV2: Fix effect staying alive when unregistered from func When `cleanup` was called for an effect from the `func` of that effect, then after the `func` returned, we'd immediately re-subscribe it to its dependencies, thereby making the `cleanup` ineffective and keeping the effect alive for longer than it is supposed to be. This commit fixes the issue by introducing `NodeState.Dead` which is set when the node gets cleaned up. We can then skip re-subscribing when the node is in this state. Source-Commit: 840aaf6988d47a0b50401b376c5ea5acc4b98097 --- .../gg/essential/elementa/state/v2/impl/basic/impl.kt | 11 +++++++++++ .../essential/elementa/state/v2/impl/minimal/impl.kt | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt index c1c42f5d..57a17071 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt @@ -97,6 +97,11 @@ private enum class NodeState { * For [NodeKind.Effect], the [Node.func] needs to be re-run. */ Dirty, + + /** + * The node has been disposed off and should no longer be updated. + */ + Dead, } private class Node( @@ -192,6 +197,10 @@ private class Node( if (state == NodeState.Dirty) { val newValue = func(this) + if (state == NodeState.Dead) { + return + } + for (i in dependencies.indices.reversed()) { val dep = dependencies[i] if (dep !in observed) { @@ -224,6 +233,8 @@ private class Node( dep.removeDependent(this) } dependencies.clear() + + state = NodeState.Dead } private var referenceQueueField: ReferenceQueue>? = null diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt index d3b1a5d5..f7793b15 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt @@ -90,6 +90,11 @@ private enum class NodeState { * For [NodeKind.Effect], the [Node.func] needs to be re-run. */ Dirty, + + /** + * The node has been disposed off and should no longer be updated. + */ + Dead, } private class Node( @@ -185,6 +190,10 @@ private class Node( if (state == NodeState.Dirty) { val newValue = func(this) + if (state == NodeState.Dead) { + return + } + for (i in dependencies.indices.reversed()) { val dep = dependencies[i] if (dep !in observed) { @@ -217,6 +226,8 @@ private class Node( dep.removeDependent(this) } dependencies.clear() + + state = NodeState.Dead } private var referenceQueueField: ReferenceQueue>? = null From 2e378879f4fc94b1dd3bd4cae38425ae588453d1 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Fri, 8 Mar 2024 09:15:01 +0100 Subject: [PATCH 26/66] LayoutDSL: Add layoutAsBox/Row/Column entrypoints Unlike the raw `layout`, these will provide the expected defaults for children positioning. It must however be noted that they do not change the size of the target component, because constraints of such plain components have often already been set up beforehand, often with more complex expressions which we do not want to overwrite. Source-Commit: cb90448da5cf8d051ae5dc4c7b7e93d6315960b1 --- .../gg/essential/elementa/layoutdsl/layout.kt | 91 ++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt index 83d92e3f..cd56411f 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt @@ -1,3 +1,4 @@ +@file:OptIn(ExperimentalContracts::class) package gg.essential.elementa.layoutdsl import gg.essential.elementa.UIComponent @@ -252,7 +253,17 @@ class LayoutScope( } } -@OptIn(ExperimentalContracts::class) +/** + * Runs [block] to lay out children of `this` component. + * + * The passed [modifier], if any, is applied to `this` component. + * + * Note: This does **not** change the constraints of `this`. These must be set up manually or via the passed [modifier]. + * + * Note: Direct children of `this` will by default be top-left aligned as with all plain Elementa components. + * Consider using one of [layoutAsBox], [layoutAsRow], or [layoutAsColumn] instead to get the default center alignment + * that is typical for Layout DSL. + */ inline fun UIComponent.layout(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit) { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) @@ -261,6 +272,84 @@ inline fun UIComponent.layout(modifier: Modifier = Modifier, block: LayoutScope. LayoutScope(this, null).block() } +/** + * Runs [block] to lay out children of `this` component as if it was a [box]. + * + * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier]. + */ +fun UIComponent.layoutAsBox(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit) { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + addChildModifier(Modifier.alignBoth(Alignment.Center)) + layout(modifier, block) +} + +/** + * Runs [block] to lay out children of `this` component as if it was a [row]. + * + * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier]. + * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedWidth]. + * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedMaxHeight]. + */ +fun UIComponent.layoutAsRow(modifier: Modifier, horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + addChildModifier(Modifier.alignVertical(verticalAlignment)) + layout(modifier, block) + horizontalArrangement.mainAxis = Axis.HORIZONTAL + horizontalArrangement.initialize(this) + return this +} + +/** + * Runs [block] to lay out children of `this` component as if it was a [column]. + * + * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier]. + * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedMaxWidth]. + * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedHeight]. + */ +fun UIComponent.layoutAsColumn(modifier: Modifier, verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + addChildModifier(Modifier.alignHorizontal(horizontalAlignment)) + layout(modifier, block) + verticalArrangement.mainAxis = Axis.VERTICAL + verticalArrangement.initialize(this) + return this +} + +// Overloads without Modifier argument +/** + * Runs [block] to lay out children of `this` component as if it was a [row]. + * + * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier]. + * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedWidth]. + * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedMaxHeight]. + */ +fun UIComponent.layoutAsRow(horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + return layoutAsRow(Modifier, horizontalArrangement, verticalAlignment, block) +} +/** + * Runs [block] to lay out children of `this` component as if it was a [column]. + * + * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier]. + * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedMaxWidth]. + * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedHeight]. + */ +fun UIComponent.layoutAsColumn(verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + return layoutAsColumn(Modifier, verticalArrangement, horizontalAlignment, block) +} + + interface LayoutDslComponent { fun LayoutScope.layout(modifier: Modifier = Modifier) } From 6a50880ca7c045b6f6d1d4ab6bc92f488b5cce21 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 18 Mar 2024 16:00:28 +0100 Subject: [PATCH 27/66] LayoutDSL: Remove primitive/legacy mouse enter/leave modifiers These inherit the various issues of the Elementa primitives. `hoverScope`/`whenHovered` should be used instead. Source-Commit: 5006ee21748337b870e38d9226fbc002975a1bf9 --- .../gg/essential/elementa/layoutdsl/events.kt | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt index 3b2f472e..05e9594f 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt @@ -12,16 +12,6 @@ import gg.essential.elementa.util.removeTag import gg.essential.elementa.state.State as StateV1 import gg.essential.elementa.state.v2.State as StateV2 -fun Modifier.onMouseEnter(callback: UIComponent.() -> Unit) = this then { - onMouseEnter(callback) - return@then { mouseEnterListeners.remove(callback) } -} - -fun Modifier.onMouseLeave(callback: UIComponent.() -> Unit) = this then { - onMouseLeave(callback) - return@then { mouseLeaveListeners.remove(callback) } -} - inline fun Modifier.onLeftClick(crossinline callback: UIComponent.() -> Unit) = this then { val listener: UIComponent.(event: UIClickEvent) -> Unit = { if (it.mouseButton == 0) { @@ -32,13 +22,6 @@ inline fun Modifier.onLeftClick(crossinline callback: UIComponent.() -> Unit) = return@then { mouseClickListeners.remove(listener) } } -fun Modifier.whenMouseEntered(hoverModifier: Modifier): Modifier { - lateinit var reverse: () -> Unit - return this - .onMouseEnter { reverse = hoverModifier.applyToComponent(this) } - .onMouseLeave { reverse() } -} - /** Declare this component and its children to be in a hover scope. See [makeHoverScope]. */ fun Modifier.hoverScope(state: StateV1? = null) = then { makeHoverScope(state); { throw NotImplementedError() } } From 3d6d98a7fcb26ead4d9ec04e7893038556f31549 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Wed, 20 Mar 2024 13:42:50 +0100 Subject: [PATCH 28/66] LayoutDSL: Fix Arrangement not being reusable Prior to this commit it was not possible to use one `Arrangement` instance for multiple e.g. rows. This was unexpected because it looks from the outside to be reusable, doubly so because `Modifier` is reusable. This commit fixes the issue by renaming `Arrangement` to `ArrangementInstance` and putting a factory style interface in its place instead. Source-Commit: 8a7df4692856360641e93d4ad0ba2184489e2b53 --- .../elementa/layoutdsl/arrangement.kt | 75 ++++++++++++++----- .../elementa/layoutdsl/containers.kt | 6 +- .../gg/essential/elementa/layoutdsl/layout.kt | 6 +- 3 files changed, 61 insertions(+), 26 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt index 5daef73f..bd46202c 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt @@ -9,8 +9,23 @@ import gg.essential.elementa.utils.ObservableListEvent import gg.essential.elementa.utils.ObservableRemoveEvent import gg.essential.elementa.utils.roundToRealPixels -abstract class Arrangement { - internal lateinit var mainAxis: Axis +interface Arrangement { + fun initialize(component: UIComponent, axis: Axis) + + companion object { + val SpaceAround: Arrangement get() = SpaceAroundArrangement.Factory + val SpaceBetween: Arrangement get() = SpaceBetweenArrangement.Factory + val SpaceEvenly: Arrangement get() = SpaceEvenlyArrangement.Factory + + fun spacedBy(): Arrangement = SpacedArrangement.DefaultFactory + fun spacedBy(spacing: Float = 0f, float: FloatPosition = FloatPosition.CENTER): Arrangement = SpacedArrangement.Factory(spacing, float) + fun equalWeight(spacing: Float = 0f): Arrangement = EqualWeightArrangement.Factory(spacing) + } +} + +abstract class ArrangementInstance( + val mainAxis: Axis, +) { internal var recalculatePositions = true internal var recalculateSizes = true @@ -86,21 +101,13 @@ abstract class Arrangement { Axis.HORIZONTAL -> getTop() Axis.VERTICAL -> getLeft() } - - companion object { - val SpaceAround: Arrangement get() = SpaceAroundArrangement() - val SpaceBetween: Arrangement get() = SpaceBetweenArrangement() - val SpaceEvenly: Arrangement get() = SpaceEvenlyArrangement() - - fun spacedBy(spacing: Float = 0f, float: FloatPosition = FloatPosition.CENTER): Arrangement = SpacedArrangement(spacing, float) - fun equalWeight(spacing: Float = 0f): Arrangement = EqualWeightArrangement(spacing) - } } private open class SpacedArrangement( + axis: Axis, protected val spacing: Float = 0f, protected val floatPosition: FloatPosition = FloatPosition.CENTER, -) : Arrangement() { +) : ArrangementInstance(axis) { open fun getSpacing(parent: UIComponent) = spacing open fun getStartOffset(parent: UIComponent, spacing: Float): Float { @@ -124,15 +131,29 @@ private open class SpacedArrangement( override fun getPadding(child: UIComponent): Float { return if (child === boundComponent.children.last()) 0f else getSpacing(boundComponent).roundToRealPixels() } + + data class Factory(val spacing: Float, val floatPosition: FloatPosition) : Arrangement { + override fun initialize(component: UIComponent, axis: Axis) { + SpacedArrangement(axis, spacing, floatPosition).initialize(component) + } + } + + object DefaultFactory : Arrangement by Factory(0f, FloatPosition.CENTER) } -private class SpaceBetweenArrangement : SpacedArrangement() { +private class SpaceBetweenArrangement(axis: Axis) : SpacedArrangement(axis) { override fun getSpacing(parent: UIComponent): Float { return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / (parent.children.size - 1) } + + object Factory : Arrangement { + override fun initialize(component: UIComponent, axis: Axis) { + SpaceBetweenArrangement(axis).initialize(component) + } + } } -private class SpaceEvenlyArrangement : SpacedArrangement() { +private class SpaceEvenlyArrangement(axis: Axis) : SpacedArrangement(axis) { override fun getSpacing(parent: UIComponent): Float { return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / (parent.children.size + 1) } @@ -140,9 +161,15 @@ private class SpaceEvenlyArrangement : SpacedArrangement() { override fun getStartOffset(parent: UIComponent, spacing: Float): Float { return spacing } + + object Factory : Arrangement { + override fun initialize(component: UIComponent, axis: Axis) { + SpaceEvenlyArrangement(axis).initialize(component) + } + } } -private class SpaceAroundArrangement : SpacedArrangement() { +private class SpaceAroundArrangement(axis: Axis) : SpacedArrangement(axis) { override fun getSpacing(parent: UIComponent): Float { return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / parent.children.size } @@ -150,9 +177,15 @@ private class SpaceAroundArrangement : SpacedArrangement() { override fun getStartOffset(parent: UIComponent, spacing: Float): Float { return spacing / 2 } + + object Factory : Arrangement { + override fun initialize(component: UIComponent, axis: Axis) { + SpaceAroundArrangement(axis).initialize(component) + } + } } -private class EqualWeightArrangement(spacing: Float) : SpacedArrangement(spacing, FloatPosition.CENTER) { +private class EqualWeightArrangement(axis: Axis, spacing: Float) : SpacedArrangement(axis, spacing, FloatPosition.CENTER) { override fun conformChild(child: UIComponent) { super.conformChild(child) when (mainAxis) { @@ -168,9 +201,15 @@ private class EqualWeightArrangement(spacing: Float) : SpacedArrangement(spacing lastSizeValues[it] = childSize } } + + data class Factory(val spacing: Float) : Arrangement { + override fun initialize(component: UIComponent, axis: Axis) { + EqualWeightArrangement(axis, spacing).initialize(component) + } + } } -private class ArrangementControlledPositionConstraint(private val arrangement: Arrangement) : PositionConstraint, PaddingConstraint { +private class ArrangementControlledPositionConstraint(private val arrangement: ArrangementInstance) : PositionConstraint, PaddingConstraint { override var cachedValue = 0f override var recalculate = true set(value) { @@ -203,7 +242,7 @@ private class ArrangementControlledPositionConstraint(private val arrangement: A } } -private class ArrangementControlledSizeConstraint(private val arrangement: Arrangement) : SizeConstraint { +private class ArrangementControlledSizeConstraint(private val arrangement: ArrangementInstance) : SizeConstraint { override var cachedValue = 0f override var recalculate = true set(value) { diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt index c0fce169..5e4841a9 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt @@ -58,8 +58,7 @@ fun LayoutScope.row(modifier: Modifier, horizontalArrangement: Arrangement = Arr rowContainer.addChildModifier(Modifier.alignVertical(verticalAlignment)) rowContainer(modifier = modifier, block = block) - horizontalArrangement.mainAxis = Axis.HORIZONTAL - horizontalArrangement.initialize(rowContainer) + horizontalArrangement.initialize(rowContainer, Axis.HORIZONTAL) return rowContainer } @@ -81,8 +80,7 @@ fun LayoutScope.column(modifier: Modifier, verticalArrangement: Arrangement = Ar columnContainer.addChildModifier(Modifier.alignHorizontal(horizontalAlignment)) columnContainer(modifier = modifier, block = block) - verticalArrangement.mainAxis = Axis.VERTICAL - verticalArrangement.initialize(columnContainer) + verticalArrangement.initialize(columnContainer, Axis.VERTICAL) return columnContainer } diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt index cd56411f..ebc21f65 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt @@ -298,8 +298,7 @@ fun UIComponent.layoutAsRow(modifier: Modifier, horizontalArrangement: Arrangeme } addChildModifier(Modifier.alignVertical(verticalAlignment)) layout(modifier, block) - horizontalArrangement.mainAxis = Axis.HORIZONTAL - horizontalArrangement.initialize(this) + horizontalArrangement.initialize(this, Axis.HORIZONTAL) return this } @@ -316,8 +315,7 @@ fun UIComponent.layoutAsColumn(modifier: Modifier, verticalArrangement: Arrangem } addChildModifier(Modifier.alignHorizontal(horizontalAlignment)) layout(modifier, block) - verticalArrangement.mainAxis = Axis.VERTICAL - verticalArrangement.initialize(this) + verticalArrangement.initialize(this, Axis.VERTICAL) return this } From 77d19000846e4c5835313d9bdb9a20edeb3ec27e Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Fri, 22 Mar 2024 10:30:17 +0100 Subject: [PATCH 29/66] LayoutDSL: Remove `Modifier.color(ColorConstraint)` Constraints are not reusable but modifiers are meant to be. As such, this modifier is inherently broken. A constraint factory should be passed instead. Additionally, direct use of constraints is discouraged by LayoutDSL where possible. If required, `BasicColorModifier` already exists as an escape hatch. Source-Commit: 5620049de1ab1ac8840a2292579087059fcade35 --- .../main/kotlin/gg/essential/elementa/layoutdsl/color.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt index 68745546..739b3d9e 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt @@ -15,14 +15,12 @@ import gg.essential.elementa.util.hasWindow import java.awt.Color import gg.essential.elementa.state.v2.State as StateV2 -fun Modifier.color(color: Color) = color(color.toConstraint()) +fun Modifier.color(color: Color) = this then BasicColorModifier { color.toConstraint() } @Deprecated("Using StateV1 is discouraged, use StateV2 instead") -fun Modifier.color(color: State) = color(color.toConstraint()) +fun Modifier.color(color: State) = this then BasicColorModifier { color.toConstraint() } -fun Modifier.color(color: ColorConstraint) = this then BasicColorModifier { color } - -fun Modifier.color(color: StateV2) = color(color.toConstraint()) +fun Modifier.color(color: StateV2) = this then BasicColorModifier { color.toConstraint() } fun Modifier.hoverColor(color: Color, duration: Float = 0f) = hoverColor(BasicState(color), duration) From e8bf77b9543bd022a97ecd37939287fdd8e4eaf0 Mon Sep 17 00:00:00 2001 From: Callum Bugajski <11320476+CallumBugajski@users.noreply.github.com> Date: Thu, 29 Feb 2024 17:33:03 +0000 Subject: [PATCH 30/66] StateV2: Add `MutableListState.removeAll` Source-Commit: d9e3ec3291e0482e9665ea05f11f7c6f482df6e3 --- .../src/main/kotlin/gg/essential/elementa/state/v2/list.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt index 2234acaa..f5925f17 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt @@ -51,4 +51,5 @@ fun MutableListState.add(index: Int, element: T) = set { it.add(index, el fun MutableListState.addAll(elements: List) = set { it.addAll(elements) } fun MutableListState.remove(element: T) = set { it.remove(element) } fun MutableListState.removeAt(index: Int) = set { it.removeAt(index) } +fun MutableListState.removeAll(predicate: (T) -> Boolean) = set { it.removeAll(it.filter(predicate)) } fun MutableListState.clear() = set { it.clear() } From fe1f07a3a78c4fa1b5d6485da8249cfea169f3f4 Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Sat, 30 Mar 2024 18:32:32 +0000 Subject: [PATCH 31/66] FillConstraintIncludingPadding: Include padding after the component Prior to this change, the padding after the component was not included in the calculation. This meant that if the component was not last in the hierarchy, it would have too much width, causing the other components to be pushed out. Source-Commit: 12495ed608807c7b9f14592b35dd6325c9ad0502 --- .../constraints/FillConstraintIncludingPadding.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/FillConstraintIncludingPadding.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/FillConstraintIncludingPadding.kt index edd26d74..8bea02c6 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/FillConstraintIncludingPadding.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/FillConstraintIncludingPadding.kt @@ -18,8 +18,9 @@ class FillConstraintIncludingPadding @JvmOverloads constructor(private val useSi val target = constrainTo ?: component.parent return if (useSiblings) { - target.getWidth() - target.children.filter { it != component }.sumOf { - it.getWidth().toDouble() + ((it.constraints.x as? PaddingConstraint)?.getHorizontalPadding(it) ?: 0f).toDouble() + target.getWidth() - target.children.sumOf { + val width = if (it == component) 0 else it.getWidth() + width.toDouble() + ((it.constraints.x as? PaddingConstraint)?.getHorizontalPadding(it) ?: 0f).toDouble() }.toFloat() } else target.getRight() - component.getLeft() + ((target.constraints.x as? PaddingConstraint)?.getHorizontalPadding(target) ?: 0f) } @@ -28,8 +29,9 @@ class FillConstraintIncludingPadding @JvmOverloads constructor(private val useSi val target = constrainTo ?: component.parent return if (useSiblings) { - target.getHeight() - target.children.filter { it != component }.sumOf { - it.getHeight().toDouble() + ((it.constraints.y as? PaddingConstraint)?.getVerticalPadding(it) ?: 0f).toDouble() + target.getHeight() - target.children.sumOf { + val height = if (it == component) 0 else it.getHeight() + height.toDouble() + ((it.constraints.y as? PaddingConstraint)?.getVerticalPadding(it) ?: 0f).toDouble() }.toFloat() } else target.getBottom() - component.getTop() + ((target.constraints.y as? PaddingConstraint)?.getVerticalPadding(target) ?: 0f) } From f52596ba80fdae87cd76259fc16aee8298a6841b Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Thu, 29 Feb 2024 15:30:44 +0000 Subject: [PATCH 32/66] elementaExtensions: Add `UIComponent.getTag()` This adds the ability for someone to get the instance of a Tag of T from a component. Source-Commit: 675afdc6ad6a008639105274513ff252749f50ac Source-Commit: 66f13a728d2b1a5a49b1b82bbcad2bf6daaf9a76 Source-Commit: 9f4edd0b540d98f50a39167e59620b29e9c77994 --- .../gg/essential/elementa/util/elementaExtensions.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt index f764ea99..1aafd1c2 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt @@ -283,6 +283,18 @@ fun UIComponent.addTag(tag: Tag) = apply { enableEffect(TagEffect(tag)) } /** Removes a [Tag] from this component. */ fun UIComponent.removeTag(tag: Tag) = apply { effects.removeIf { it is TagEffect && it.tag == tag } } +/** Returns a [Tag] of [T] which may or may not be attached to this component. */ +inline fun UIComponent.getTag(): T? = getTag(T::class.java) + +/** Returns a [Tag] of [T] which may or may not be attached to this component. */ +fun UIComponent.getTag(type: Class): T? { + val effect = effects.firstNotNullOfOrNull { + effect -> (effect as? TagEffect)?.takeIf { type.isInstance(it.tag) } + } ?: return null + + return type.cast(effect.tag) +} + /** * Searches for any children which contain a certain [Tag]. * See [addTag] for applying a [Tag] to a component. From 838e72bcbe0494c227f3f2bcc168a60ffff067c6 Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Thu, 29 Feb 2024 15:33:19 +0000 Subject: [PATCH 33/66] elementaExtensions: Refactor `findChildrenByTag` - `findChildrenByTag` now calls an overload which takes a predicate. - This overload behaves slightly differently to the original `findChildrenByTag` implementation. Instead of recursively calling itself and creating a bunch of list, one list is used. - `findChildrenByTag` has been added to find children by a certain Tag type, instead of an instance of a Tag. Source-Commit: d97914b80dc7da246092da5e99a89146424f74a2 --- .../elementa/util/elementaExtensions.kt | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt index 1aafd1c2..7288ec29 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt @@ -299,19 +299,47 @@ fun UIComponent.getTag(type: Class): T? { * Searches for any children which contain a certain [Tag]. * See [addTag] for applying a [Tag] to a component. */ -fun UIComponent.findChildrenByTag(tag: Tag, recursive: Boolean = false): List { +fun UIComponent.findChildrenByTag(tag: Tag, recursive: Boolean = false) = findChildrenByTag(recursive) { it == tag } + +/** + * Finds any children which have a tag which matches the [predicate]. + * By default, this predicate will match any [Tag] of [T]. + * + * See [addTag] for applying a [Tag] to a component. + */ +inline fun UIComponent.findChildrenByTag( + recursive: Boolean = false, + noinline predicate: (T) -> Boolean = { true }, +) = findChildrenByTag(T::class.java, recursive, predicate) + +/** + * Finds any children which have a tag which matches the [predicate]. + * By default, this predicate will match any [Tag] of [T]. + * + * See [addTag] for applying a [Tag] to a component. + */ +fun UIComponent.findChildrenByTag( + type: Class, + recursive: Boolean = false, + predicate: (T) -> Boolean = { true } +): List { val found = mutableListOf() - for (child in children) { - if (child.effects.filterIsInstance().any { it.tag == tag }) { - found.add(child) - } + fun addToFoundIfHasTag(component: UIComponent) { + for (child in component.children) { + val tag = child.getTag(type) + if (tag != null && predicate(tag)) { + found.add(child) + } - if (recursive) { - found.addAll(child.findChildrenByTag(tag, true)) + if (recursive) { + addToFoundIfHasTag(child) + } } } + addToFoundIfHasTag(this) + return found } From 00620b873307de0af3dca4dfc876937bbff5473d Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Sat, 30 Mar 2024 14:32:08 +0000 Subject: [PATCH 34/66] elementaExtensions: Add `UIComponent.findChildrenAndTags` Similar to `findChildrenByTag`, except it will return a `List` of `Pair`s mapping children to their `Tag`s of type `T`. Source-Commit: 3a842096489a74ad6884be855050af2e98695612 Source-Commit: 5043868f83696c95529991067d06b202f7b8ada3 --- .../elementa/util/elementaExtensions.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt index 7288ec29..89bbdb51 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt @@ -312,6 +312,17 @@ inline fun UIComponent.findChildrenByTag( noinline predicate: (T) -> Boolean = { true }, ) = findChildrenByTag(T::class.java, recursive, predicate) +/** + * Returns a map of [UIComponent]s (children) to their [Tag]s of [T]. + * By default, this predicate will match any [Tag] of [T]. + * + * See [addTag] for applying a [Tag] to a component. + */ +inline fun UIComponent.findChildrenAndTags( + recursive: Boolean = false, + noinline predicate: (T) -> Boolean = { true }, +) = findChildrenAndTags(T::class.java, recursive, predicate) + /** * Finds any children which have a tag which matches the [predicate]. * By default, this predicate will match any [Tag] of [T]. @@ -343,6 +354,37 @@ fun UIComponent.findChildrenByTag( return found } +/** + * Returns a map of [UIComponent]s (children) to their [Tag]s of [T]. + * By default, this predicate will match any [Tag] of [T]. + * + * See [addTag] for applying a [Tag] to a component. + */ +fun UIComponent.findChildrenAndTags( + type: Class, + recursive: Boolean = false, + predicate: (T) -> Boolean = { true } +): List> { + val found = mutableListOf>() + + fun addToFoundIfHasTag(component: UIComponent) { + for (child in component.children) { + val tag = child.getTag(type) + if (tag != null && predicate(tag)) { + found.add(child to tag) + } + + if (recursive) { + addToFoundIfHasTag(child) + } + } + } + + addToFoundIfHasTag(this) + + return found +} + /** Returns a [Sequence] consisting of this component and its parents (including the Window) in that order. */ fun UIComponent.selfAndParents() = generateSequence(this) { if (it.parent != it) it.parent else null } From 3f58552e6ad64b7b5598d8472286ed03eaef68ce Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Thu, 25 Apr 2024 12:35:15 +0100 Subject: [PATCH 35/66] LayoutScope: Give uncached for-each child scopes their own `stateScope` Prior to this, these scopes would use the `component` of the LayoutScope that the `forEach` is called on as their `stateScope`. This is incorrect though, as if something in the `block` happens to use said `stateScope`, and the entry gets removed from the `ListState`, the `stateScope` will still be alive. With these changes, the `stateScope` for these entries is now disposed appropriately. `stateScope` was also used as an escape hatch for accessing the `container` of a `LayoutScope`; this is considered bad practice because it is similarly easy to use a component one shouldn't be messing with. However sometimes access to the component is required, so another escape hatch has been added to serve these cases. Source-Commit: 270363df989772e99667359e9eaedb15b916149e --- .../essential/elementa/layoutdsl/containers.kt | 2 +- .../gg/essential/elementa/layoutdsl/layout.kt | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt index 5e4841a9..5662ccb4 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt @@ -154,7 +154,7 @@ fun LayoutScope.scrollable( outer(modifier = modifier) - block(LayoutScope(content, this)) + block(LayoutScope(content, this, content)) return outer } diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt index ebc21f65..b3a21692 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt @@ -3,6 +3,7 @@ package gg.essential.elementa.layoutdsl import gg.essential.elementa.UIComponent import gg.essential.elementa.state.State +import gg.essential.elementa.state.v2.ReferenceHolder import gg.essential.elementa.common.ListState import gg.essential.elementa.common.not import gg.essential.elementa.state.v2.* @@ -16,11 +17,12 @@ import gg.essential.elementa.state.v2.State as StateV2 class LayoutScope( private val component: UIComponent, private val parentScope: LayoutScope?, + val stateScope: ReferenceHolder, ) { /** - * You should only use this for calling `toV1`, or any State-related methods that require a [gg.essential.elementa.state.v2.ReferenceHolder]. + * As the name says, don't use this unless you really have to. */ - val stateScope: UIComponent + val containerDontUseThisUnlessYouReallyHaveTo: UIComponent get() = component private val childrenScopes = mutableListOf() @@ -29,7 +31,7 @@ class LayoutScope( this@LayoutScope.component.getChildModifier().applyToComponent(this) modifier.applyToComponent(this) - val childScope = LayoutScope(this, this@LayoutScope) + val childScope = LayoutScope(this, this@LayoutScope, this) childrenScopes.add(childScope) childScope.block() @@ -106,7 +108,7 @@ class LayoutScope( * This requires that [T] be usable as a key in a HashMap. */ fun forEach(state: ListState, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { - val forEachScope = LayoutScope(component, this@LayoutScope) + val forEachScope = LayoutScope(component, this@LayoutScope, stateScope) childrenScopes.add(forEachScope) val cacheMap = @@ -122,7 +124,11 @@ class LayoutScope( cachedScope.remount() } } else { - val newScope = LayoutScope(component, forEachScope) + // If the `forEach` is not cached, we give each child scope its own reference holder. + // This scope will be dropped once the child scope is removed. + val childStateScope = if (cache) forEachScope.stateScope else ReferenceHolderImpl() + val newScope = LayoutScope(component, forEachScope, childStateScope) + forEachScope.childrenScopes.add(index, newScope) newScope.block(element) if (!forEachScope.isVirtualScopeMounted()) { @@ -269,7 +275,7 @@ inline fun UIComponent.layout(modifier: Modifier = Modifier, block: LayoutScope. callsInPlace(block, InvocationKind.EXACTLY_ONCE) } modifier.applyToComponent(this) - LayoutScope(this, null).block() + LayoutScope(this, null, this).block() } /** From 93e58c046d436d3e9c174d71a7aff5584b47831b Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Thu, 2 May 2024 11:32:27 +0200 Subject: [PATCH 36/66] Build: Run main GHA workflow for unstable/import branch So we don't need to create a dummy PR to make sure it builds before merging into master. --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e6711515..fd936e7d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - unstable/import pull_request: jobs: From 990e24228d00f9c5f6319f20625ecb1ec83877d9 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Fri, 3 May 2024 09:40:41 +0200 Subject: [PATCH 37/66] WindowScreen: Fix background being draw on top of screen This was clearly wrong; just no one ever noticed because the vanilla content drawn by `super.onDrawScreen` (e.g. vanilla buttons) is usually empty for Elementa screens. But now that I've seen it, I can't just keep it like that. GitHub: #139 --- src/main/kotlin/gg/essential/elementa/WindowScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/gg/essential/elementa/WindowScreen.kt b/src/main/kotlin/gg/essential/elementa/WindowScreen.kt index bfacc28e..e4488e5d 100644 --- a/src/main/kotlin/gg/essential/elementa/WindowScreen.kt +++ b/src/main/kotlin/gg/essential/elementa/WindowScreen.kt @@ -48,11 +48,11 @@ abstract class WindowScreen @JvmOverloads constructor( afterInitialization() } - super.onDrawScreen(matrixStack, mouseX, mouseY, partialTicks) - if (drawDefaultBackground) super.onDrawBackground(matrixStack, 0) + super.onDrawScreen(matrixStack, mouseX, mouseY, partialTicks) + // Now, we need to hook up Elementa to this GuiScreen. In practice, Elementa // is not constrained to being used solely inside of a GuiScreen, all the programmer // needs to do is call the [Window] events when appropriate, whenever that may be. From 4d58009e40b850523dbe6fdee36901d339a61fb7 Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Thu, 9 May 2024 15:23:33 +0100 Subject: [PATCH 38/66] UIText: Check if the textWidth is 0 instead of if the string is empty (#143) This fixes an issue on certain versions of Minecraft where the vanilla font provider returns a width of 0 for unsupported characters. This caused the scale to be calculated incorrectly later on, which completely broke rendering. GitHub: #143 --- .../kotlin/gg/essential/elementa/components/UIText.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/gg/essential/elementa/components/UIText.kt b/src/main/kotlin/gg/essential/elementa/components/UIText.kt index 9a3a43fd..7030c4b2 100644 --- a/src/main/kotlin/gg/essential/elementa/components/UIText.kt +++ b/src/main/kotlin/gg/essential/elementa/components/UIText.kt @@ -96,13 +96,18 @@ constructor( } override fun draw(matrixStack: UMatrixStack) { - val text = textState.get() - if (text.isEmpty()) + val textWidth = textWidthState.get() + + // If you're wondering why we check if the text's width is 0 instead of if the string is empty: + // It's better to check the width derived from the font provider, as the string may just be full of characters + // that can't be rendered (as they aren't supported by current font). + // This check prevents issues from occurring later, e.g. when calculating the scale of the text. + if (textWidth == 0f) return beforeDrawCompat(matrixStack) - val scale = getWidth() / textWidthState.get() + val scale = getWidth() / textWidth val x = getLeft() val y = getTop() + (if (verticallyCenteredState.get()) fontProviderState.get().getBelowLineHeight() * scale else 0f) val color = getColor() From 3be76244a77f21dfe1d3fad32710556c3ff13e31 Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Wed, 29 May 2024 13:06:35 +0100 Subject: [PATCH 39/66] Window: Call `super.afterInitialization` This allows effects to be setup correctly if placed on a `Window`. GitHub: #144 --- src/main/kotlin/gg/essential/elementa/components/Window.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/gg/essential/elementa/components/Window.kt b/src/main/kotlin/gg/essential/elementa/components/Window.kt index c96d58f4..c85481b6 100644 --- a/src/main/kotlin/gg/essential/elementa/components/Window.kt +++ b/src/main/kotlin/gg/essential/elementa/components/Window.kt @@ -46,6 +46,8 @@ class Window @JvmOverloads constructor( } override fun afterInitialization() { + super.afterInitialization() + enqueueRenderOperation { FontRenderer.initShaders() UICircle.initShaders() From 590b10f9c7b3cb9d016112f762872246b752d7f9 Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Fri, 28 Jun 2024 11:46:04 +0100 Subject: [PATCH 40/66] Build: Bump Kotlin, EGT, and Gradle Also sets the Kotlin language and API version to keep compatibility with projects using older versions of Kotlin. GitHub: #141 --- api/Elementa.api | 5 +++++ build.gradle.kts | 14 +++++++++++--- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/api/Elementa.api b/api/Elementa.api index 38a24b30..4d2010cc 100644 --- a/api/Elementa.api +++ b/api/Elementa.api @@ -169,6 +169,11 @@ public final class gg/essential/elementa/UIComponent$Companion { public final fun guiHint (FZ)F } +public final class gg/essential/elementa/UIComponent$sam$i$java_util_function_Predicate$0 : java/util/function/Predicate { + public fun (Lkotlin/jvm/functions/Function1;)V + public final synthetic fun test (Ljava/lang/Object;)Z +} + public class gg/essential/elementa/UIConstraints : java/util/Observable { public fun (Lgg/essential/elementa/UIComponent;)V public final fun copy ()Lgg/essential/elementa/UIConstraints; diff --git a/build.gradle.kts b/build.gradle.kts index 211df882..b69e96bf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,18 +1,26 @@ import gg.essential.gradle.multiversion.StripReferencesTransform.Companion.registerStripReferencesAttribute import gg.essential.gradle.util.* import gg.essential.gradle.util.RelocationTransform.Companion.registerRelocationAttribute +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - kotlin("jvm") version "1.6.10" + kotlin("jvm") version "1.9.23" id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.8.0" - id("org.jetbrains.dokka") version "1.6.10" apply false + id("org.jetbrains.dokka") version "1.9.20" apply false id("gg.essential.defaults") } kotlin.jvmToolchain { (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(8)) } -tasks.compileKotlin.setJvmDefault("all-compatibility") + +tasks.withType { + setJvmDefault("all-compatibility") + kotlinOptions { + languageVersion = "1.6" + apiVersion = "1.6" + } +} val internal by configurations.creating { val relocated = registerRelocationAttribute("internal-relocated") { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 15de9024..48c0a02c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index ce43e72e..9fe07369 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,7 +8,7 @@ pluginManagement { maven("https://repo.essential.gg/repository/maven-public") } plugins { - val egtVersion = "0.3.0" + val egtVersion = "0.5.0" id("gg.essential.defaults") version egtVersion id("gg.essential.multi-version.root") version egtVersion id("gg.essential.multi-version.api-validation") version egtVersion From 91c492f7179ba9fe81ad1e2044ed07b04e3f58e0 Mon Sep 17 00:00:00 2001 From: Sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 28 Jun 2024 07:13:45 -0400 Subject: [PATCH 41/66] Build: Fix `FMLModType` being incorrectly set to `LIBRARY` As of ML9, it would have to be `GAMELIBRARY` for it to be able to reference minecraft classes, however since we require shading and relocation on ML9 anyway, we may as well just remove it outright. GitHub: #145 --- versions/build.gradle.kts | 3 --- 1 file changed, 3 deletions(-) diff --git a/versions/build.gradle.kts b/versions/build.gradle.kts index a6f5ee04..9fce3d75 100644 --- a/versions/build.gradle.kts +++ b/versions/build.gradle.kts @@ -83,9 +83,6 @@ tasks.jar { exclude("META-INF/mods.toml") exclude("mcmod.info") exclude("kotlin/**") - manifest { - attributes(mapOf("FMLModType" to "LIBRARY")) - } } tasks.named("sourcesJar") { From b194e7f2d6b9c7992de391dc96cdb7e4b7a721b7 Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Mon, 22 Jul 2024 09:46:36 +0100 Subject: [PATCH 42/66] ScrollComponent: Add a minimum scrollbar grip size Making it easier to grab when there is a lot of content within the ScrollComponent. GitHub: #146 --- api/Elementa.api | 1 + .../gg/essential/elementa/ElementaVersion.kt | 8 +++ .../elementa/components/ScrollComponent.kt | 59 +++++++++++++++++-- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/api/Elementa.api b/api/Elementa.api index 4d2010cc..5d4bc35f 100644 --- a/api/Elementa.api +++ b/api/Elementa.api @@ -6,6 +6,7 @@ public final class gg/essential/elementa/ElementaVersion : java/lang/Enum { public static final field V3 Lgg/essential/elementa/ElementaVersion; public static final field V4 Lgg/essential/elementa/ElementaVersion; public static final field V5 Lgg/essential/elementa/ElementaVersion; + public static final field V6 Lgg/essential/elementa/ElementaVersion; public final fun enableFor (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; public static fun valueOf (Ljava/lang/String;)Lgg/essential/elementa/ElementaVersion; public static fun values ()[Lgg/essential/elementa/ElementaVersion; diff --git a/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt b/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt index 2a0f72a7..3077ccef 100644 --- a/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt +++ b/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt @@ -84,8 +84,14 @@ enum class ElementaVersion { /** * Change the behavior of scroll components to no longer require holding down shift when horizontal is the only possible scrolling direction. */ + @Deprecated(DEPRECATION_MESSAGE) V5, + /** + * [gg.essential.elementa.components.ScrollComponent] now has a minimum size for scrollbar grips. + */ + V6, + ; /** @@ -126,7 +132,9 @@ Be sure to read through all the changes between your current version and your ne internal val v3 = V3 @Suppress("DEPRECATION") internal val v4 = V4 + @Suppress("DEPRECATION") internal val v5 = V5 + internal val v6 = V6 @PublishedApi diff --git a/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt b/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt index de81528e..d67c829c 100644 --- a/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt @@ -32,7 +32,7 @@ class ScrollComponent constructor( private val pixelsPerScroll: Float = 15f, private val scrollAcceleration: Float = 1.0f, customScissorBoundingBox: UIComponent? = null, - private val passthroughScroll: Boolean = true + private val passthroughScroll: Boolean = true, ) : UIContainer() { @JvmOverloads constructor( emptyString: String = "", @@ -44,7 +44,7 @@ class ScrollComponent constructor( verticalScrollOpposite: Boolean = false, pixelsPerScroll: Float = 15f, scrollAcceleration: Float = 1.0f, - customScissorBoundingBox: UIComponent? = null + customScissorBoundingBox: UIComponent? = null, ) : this ( emptyString, innerPadding, @@ -59,7 +59,7 @@ class ScrollComponent constructor( verticalScrollOpposite, pixelsPerScroll, scrollAcceleration, - customScissorBoundingBox + customScissorBoundingBox, ) private val primaryScrollDirection @@ -466,10 +466,17 @@ class ScrollComponent constructor( } } + val relativeConstraint = RelativeConstraint(clampedPercentage) + val desiredSizeConstraint = if (Window.of(this).version >= ElementaVersion.v6) { + ScrollBarGripMinSizeConstraint(relativeConstraint) + } else { + relativeConstraint + } + if (isHorizontal) { - component.setWidth(RelativeConstraint(clampedPercentage)) + component.setWidth(desiredSizeConstraint) } else { - component.setHeight(RelativeConstraint(clampedPercentage)) + component.setHeight(desiredSizeConstraint) } component.animate { @@ -800,6 +807,48 @@ class ScrollComponent constructor( } + /** + * Constraints the scrollbar grip's size to be a certain minimum size, or the [desiredSize]. + * This is the default constraint for horizontal scrollbar grips if [ElementaVersion.V6] is used. + * + * @param desiredSize The intended size for the scrollbar grip. + */ + private class ScrollBarGripMinSizeConstraint( + private val desiredSize: SizeConstraint + ) : SizeConstraint { + override var cachedValue: Float = 0f + override var recalculate: Boolean = true + override var constrainTo: UIComponent? = null + + override fun animationFrame() { + super.animationFrame() + desiredSize.animationFrame() + } + + override fun getWidthImpl(component: UIComponent): Float { + val parent = component.parent + val minimumWidthPercentage = if (parent.getWidth() < 200) { 0.15f } else { 0.10f } + val minimumWidth = parent.getWidth() * minimumWidthPercentage + + return desiredSize.getWidth(component).coerceAtLeast(minimumWidth) + } + + override fun getHeightImpl(component: UIComponent): Float { + val parent = component.parent + val minimumHeightPercentage = if (parent.getHeight() < 200) { 0.15f } else { 0.10f } + val minimumHeight = parent.getHeight() * minimumHeightPercentage + + return desiredSize.getHeight(component).coerceAtLeast(minimumHeight) + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + } + + override fun getRadiusImpl(component: UIComponent): Float { + throw IllegalStateException("`ScrollBarGripMinSizeConstraint` does not support `getRadiusImpl`.") + } + } + enum class Direction { Vertical, Horizontal, From 0b76fa7ce0af077a95903c3834a866afa64fa3d4 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 6 Aug 2024 15:32:53 +0200 Subject: [PATCH 43/66] Build: Publish single universal artifact instead of one per platform * Replace remaining Platform dependencies with newly introduced UC methods * Drop 1.15 version (UC currently does not support it) * Replace per-platform `width` method with hard-coded overloads * Publish from the main `:` project instead of individual platform projects * Update README --- README.md | 139 +++++------------- api/Elementa.api | 8 + build.gradle.kts | 28 +++- gradle/libs.versions.toml | 2 +- mc-stubs/build.gradle.kts | 5 + .../main/java/net/minecraft/class_2561.java | 4 + .../net/minecraft/network/chat/Component.java | 4 + .../net/minecraft/util/IChatComponent.java | 4 + .../minecraft/util/text/ITextComponent.java | 4 + settings.gradle.kts | 12 +- .../com/example/examplemod/ExamplesGui.kt | 4 +- .../elementa/components/SVGComponent.kt | 3 +- .../essential/elementa/components/UIShape.kt | 3 +- .../essential/elementa/components/Window.kt | 5 +- .../components/input/AbstractTextInput.kt | 1 - .../gg/essential/elementa/dsl/utilities.kt | 18 +++ .../elementa/effects/StencilEffect.kt | 8 +- .../gg/essential/elementa/impl/Platform.kt | 21 --- .../essential/elementa/utils/invalidUsage.kt | 4 +- src/main/resources/fabric.mod.json | 12 ++ versions/api/platform.api | 19 --- versions/build.gradle.kts | 44 +----- versions/gradle.properties | 1 - versions/root.gradle.kts | 13 +- .../elementa/dsl/utilities_platform.kt | 10 -- .../essential/elementa/impl/PlatformImpl.java | 59 -------- .../gg.essential.elementa.impl.Platform | 1 - versions/src/main/resources/fabric.mod.json | 11 +- 28 files changed, 152 insertions(+), 295 deletions(-) create mode 100644 mc-stubs/build.gradle.kts create mode 100644 mc-stubs/src/main/java/net/minecraft/class_2561.java create mode 100644 mc-stubs/src/main/java/net/minecraft/network/chat/Component.java create mode 100644 mc-stubs/src/main/java/net/minecraft/util/IChatComponent.java create mode 100644 mc-stubs/src/main/java/net/minecraft/util/text/ITextComponent.java delete mode 100644 src/main/kotlin/gg/essential/elementa/impl/Platform.kt create mode 100644 src/main/resources/fabric.mod.json delete mode 100644 versions/api/platform.api delete mode 100644 versions/gradle.properties delete mode 100644 versions/src/main/java/gg/essential/elementa/dsl/utilities_platform.kt delete mode 100644 versions/src/main/java/gg/essential/elementa/impl/PlatformImpl.java delete mode 100644 versions/src/main/resources/META-INF/services/gg.essential.elementa.impl.Platform diff --git a/README.md b/README.md index bb9927f1..436d2604 100644 --- a/README.md +++ b/README.md @@ -11,110 +11,46 @@ instead you simply have to describe _what_ you want. ## Dependency -It's recommended that you include [Essential](link eventually) instead of adding it yourself. - -In your repository block, add: - -Groovy -```groovy -maven { - url = "https://repo.essential.gg/repository/maven-public" -} -``` -Kotlin -```kotlin -maven(url = "https://repo.essential.gg/repository/maven-public") -``` - -To use the latest builds, use the following dependency: - -
Forge - ```kotlin -implementation("gg.essential:elementa-$mcVersion-$mcPlatform:$buildNumber") -``` -
-
Fabric - -Groovy -```groovy -modImplementation(include("gg.essential:elementa-$mcVersion-$mcPlatform:$buildNumber")) -``` -Kotlin -```kotlin -modImplementation(include("gg.essential:elementa-$mcVersion-$mcPlatform:$buildNumber")!!) +repository { + // All versions of Elementa and UniversalCraft are published to Essential's public maven repository. + // (if you're still using Groovy build scripts, replace `()` with `{}`) + maven(url = "https://repo.essential.gg/repository/maven-public") +} +dependencies { + // Add Elementa dependency. For the latest $elementaVersion, see the badge below this code snippet. + implementation("gg.essential:elementa:$elementaVersion") + + // Optionally, add some of the unstable Elementa features. + // Note that these MUST be relocated to your own package because future versions may contain breaking changes + // and therefore MUST NOT be simply included via Fabric's jar-in-jar mechanism. + implementation("gg.essential:elementa-unstable-layoutdsl:$elementaVersion") + + // Elementa itself is independent of Minecraft versions and mod loaders, instead it depends on UniversalCraft which + // provides bindings to specific Minecraft versions. + // As such, you must include the UniversalCraft version for the Minecraft version + mod loader you're targeting. + // For a list of all available platforms, see https://github.com/EssentialGG/UniversalCraft + // For your convenience, the latest $ucVersion is also included in a badge below this code snippet. + // (Note: if you are not using Loom, replace `modImplementation` with `implementation` or your equivalent) + modImplementation("gg.essential:universalcraft-1.8.9-forge:$ucVersion") + + // If you're using Fabric, you may use its jar-in-jar mechanism to bundle Elementa and UniversalCraft with your + // mod by additionally adding them to the `include` configuration like this (in place of the above): + implementation(include("gg.essential:elementa:$elementaVersion")!!) + modImplementation(include("gg.essential:universalcraft-1.8.9-forge:$ucVersion")) + // If you're using Forge, you must instead include them directly into your jar file and relocate them to your + // own package (this is important! otherwise you will be incompatible with other mods!) + // using e.g. https://gradleup.com/shadow/configuration/relocation/ + // For an example, read the IMPORTANT section below. +} ``` -
- -### Build Reference -
Build Reference - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
mcVersionmcPlatformbuildNumber
1.18.1fabric - 1.18.1-fabric -
1.18.1forge - 1.18.1-forge -
1.17.1fabric - 1.17.1-fabric -
1.17.1forge - 1.17.1-forge -
1.16.2forge - 1.16.2-forge -
1.12.2forge - 1.12.2-forge -
1.8.9forge1.8.9-forge
- -
- -If you were previously using v1.7.1 of Elementa and are now on the v2.0.0 builds, please refer to the -[migration](docs/migration.md) document to know what has changed. - -To learn about all the new features in v2.0.0, please read the [what's new](docs/whatsnew.md) document. +gg.essential:elementa +gg.essential:universalcraft-1.8.9-forge

IMPORTANT!

-If you are using forge, you must also relocate Elementa to avoid potential crashes with other mods. To do this, you will need to use the Shadow Gradle plugin. +If you are using Forge, you must also relocate Elementa to avoid incompatibility with other mods. +To do this, you may use the Shadow Gradle plugin:
Groovy Version @@ -191,6 +127,11 @@ In your dependencies block, add: implementation "club.sk1er:Elementa:1.7.1-$mcVersion" ``` +If you were previously using v1.7.1 of Elementa and are now on the v2.0.0 builds, please refer to the +[migration](docs/migration.md) document to know what has changed. + +To learn about all the new features in v2.0.0, please read the [what's new](docs/whatsnew.md) document. + ## Components All the drawing in Elementa is done via UIComponents. There is a root component named `Window` diff --git a/api/Elementa.api b/api/Elementa.api index 5d4bc35f..c079fd6d 100644 --- a/api/Elementa.api +++ b/api/Elementa.api @@ -2565,8 +2565,16 @@ public final class gg/essential/elementa/dsl/UtilitiesKt { public static final fun toConstraint (Ljava/awt/Color;)Lgg/essential/elementa/constraints/ConstantColorConstraint; public static final fun width (CF)F public static final fun width (Ljava/lang/String;FLgg/essential/elementa/font/FontProvider;)F + public static final synthetic fun width (Lnet/minecraft/class_2561;FLgg/essential/elementa/font/FontProvider;)F + public static final synthetic fun width (Lnet/minecraft/network/chat/Component;FLgg/essential/elementa/font/FontProvider;)F + public static final synthetic fun width (Lnet/minecraft/util/IChatComponent;FLgg/essential/elementa/font/FontProvider;)F + public static final synthetic fun width (Lnet/minecraft/util/text/ITextComponent;FLgg/essential/elementa/font/FontProvider;)F public static synthetic fun width$default (CFILjava/lang/Object;)F public static synthetic fun width$default (Ljava/lang/String;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F + public static synthetic fun width$default (Lnet/minecraft/class_2561;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F + public static synthetic fun width$default (Lnet/minecraft/network/chat/Component;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F + public static synthetic fun width$default (Lnet/minecraft/util/IChatComponent;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F + public static synthetic fun width$default (Lnet/minecraft/util/text/ITextComponent;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F } public abstract class gg/essential/elementa/effects/Effect { diff --git a/build.gradle.kts b/build.gradle.kts index b69e96bf..557b94b4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,10 +6,14 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { kotlin("jvm") version "1.9.23" id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.8.0" - id("org.jetbrains.dokka") version "1.9.20" apply false + id("org.jetbrains.dokka") version "1.9.20" id("gg.essential.defaults") + id("gg.essential.defaults.maven-publish") } +group = "gg.essential" +version = versionFromBuildIdAndBranch() + kotlin.jvmToolchain { (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(8)) } @@ -47,6 +51,7 @@ dependencies { internal(libs.dom4j) implementation(prebundle(internal)) + compileOnly(project(":mc-stubs")) // Depending on LWJGL3 instead of 2 so we can choose opengl bindings only compileOnly("org.lwjgl:lwjgl-opengl:3.3.1") // Depending on 1.8.9 for all of these because that's the oldest version we support @@ -56,8 +61,27 @@ dependencies { compileOnly("com.google.code.gson:gson:2.2.4") } +tasks.processResources { + inputs.property("project.version", project.version) + filesMatching("fabric.mod.json") { + expand("version" to project.version) + } +} + +tasks.jar { + dependsOn(internal) + from({ internal.map { zipTree(it) } }) + + // TODO move into separate project + exclude("com") // `com.example` package +} + apiValidation { - ignoredProjects.addAll(listOf("platform", "statev2", "layoutdsl")) + ignoredProjects.addAll(subprojects.map { it.name }) ignoredPackages.add("com.example") nonPublicMarkers.add("org.jetbrains.annotations.ApiStatus\$Internal") } + +publishing.publications.named("maven") { + artifactId = "elementa" +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0cbe6a10..ab068379 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ kotlin = "1.5.10" kotlinx-coroutines = "1.5.2" jetbrains-annotations = "23.0.0" -universalcraft = "211" +universalcraft = "349" commonmark = "0.17.1" dom4j = "2.1.1" diff --git a/mc-stubs/build.gradle.kts b/mc-stubs/build.gradle.kts new file mode 100644 index 00000000..d03e0f6f --- /dev/null +++ b/mc-stubs/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + `java-library` +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(8) diff --git a/mc-stubs/src/main/java/net/minecraft/class_2561.java b/mc-stubs/src/main/java/net/minecraft/class_2561.java new file mode 100644 index 00000000..3ebbc08a --- /dev/null +++ b/mc-stubs/src/main/java/net/minecraft/class_2561.java @@ -0,0 +1,4 @@ +package net.minecraft; + +public class class_2561 { +} diff --git a/mc-stubs/src/main/java/net/minecraft/network/chat/Component.java b/mc-stubs/src/main/java/net/minecraft/network/chat/Component.java new file mode 100644 index 00000000..a9a957e3 --- /dev/null +++ b/mc-stubs/src/main/java/net/minecraft/network/chat/Component.java @@ -0,0 +1,4 @@ +package net.minecraft.network.chat; + +public class Component { +} diff --git a/mc-stubs/src/main/java/net/minecraft/util/IChatComponent.java b/mc-stubs/src/main/java/net/minecraft/util/IChatComponent.java new file mode 100644 index 00000000..102b0582 --- /dev/null +++ b/mc-stubs/src/main/java/net/minecraft/util/IChatComponent.java @@ -0,0 +1,4 @@ +package net.minecraft.util; + +public class IChatComponent { +} diff --git a/mc-stubs/src/main/java/net/minecraft/util/text/ITextComponent.java b/mc-stubs/src/main/java/net/minecraft/util/text/ITextComponent.java new file mode 100644 index 00000000..d792d8fa --- /dev/null +++ b/mc-stubs/src/main/java/net/minecraft/util/text/ITextComponent.java @@ -0,0 +1,4 @@ +package net.minecraft.util.text; + +public class ITextComponent { +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9fe07369..a6201fb5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,7 @@ pluginManagement { plugins { val egtVersion = "0.5.0" id("gg.essential.defaults") version egtVersion + id("gg.essential.defaults.maven-publish") version egtVersion id("gg.essential.multi-version.root") version egtVersion id("gg.essential.multi-version.api-validation") version egtVersion } @@ -17,18 +18,21 @@ pluginManagement { rootProject.name = "Elementa" + +include(":mc-stubs") + +include(":unstable:statev2") +include(":unstable:layoutdsl") + + include(":platform") project(":platform").apply { projectDir = file("versions/") buildFileName = "root.gradle.kts" } -include(":unstable:statev2") -include(":unstable:layoutdsl") - listOf( "1.8.9-forge", "1.12.2-forge", - "1.15.2-forge", "1.16.2-forge", "1.16.2-fabric", "1.17.1-fabric", diff --git a/src/main/java/com/example/examplemod/ExamplesGui.kt b/src/main/java/com/example/examplemod/ExamplesGui.kt index cd39cb25..d1dfebfb 100644 --- a/src/main/java/com/example/examplemod/ExamplesGui.kt +++ b/src/main/java/com/example/examplemod/ExamplesGui.kt @@ -6,7 +6,7 @@ import gg.essential.elementa.components.* import gg.essential.elementa.constraints.* import gg.essential.elementa.constraints.animation.Animations import gg.essential.elementa.dsl.* -import gg.essential.elementa.impl.Platform.Companion.platform +import gg.essential.universal.UMinecraft import gg.essential.universal.UScreen import java.awt.Color @@ -39,7 +39,7 @@ class ExamplesGui : WindowScreen(ElementaVersion.V2) { } }.onMouseClick { try { - platform.currentScreen = action() + UMinecraft.currentScreenObj = action() } catch (e: Exception) { e.printStackTrace() } diff --git a/src/main/kotlin/gg/essential/elementa/components/SVGComponent.kt b/src/main/kotlin/gg/essential/elementa/components/SVGComponent.kt index d8284482..112b38a9 100644 --- a/src/main/kotlin/gg/essential/elementa/components/SVGComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/components/SVGComponent.kt @@ -2,7 +2,6 @@ package gg.essential.elementa.components import gg.essential.elementa.UIComponent import gg.essential.elementa.components.image.ImageProvider -import gg.essential.elementa.impl.Platform.Companion.platform import gg.essential.elementa.svg.SVGParser import gg.essential.elementa.svg.data.SVG import gg.essential.universal.UGraphics @@ -33,7 +32,7 @@ class SVGComponent(private var svg: SVG) : UIComponent(), ImageProvider { } override fun drawImage(matrixStack: UMatrixStack, x: Double, y: Double, width: Double, height: Double, color: Color) { - if (platform.mcVersion >= 11700) { + if (UGraphics.isCoreProfile()) { // TODO heavily relies on legacy gl, at least need to use per-vertex color and convert lines/points to tris return } diff --git a/src/main/kotlin/gg/essential/elementa/components/UIShape.kt b/src/main/kotlin/gg/essential/elementa/components/UIShape.kt index 63ec770d..f71a69cc 100644 --- a/src/main/kotlin/gg/essential/elementa/components/UIShape.kt +++ b/src/main/kotlin/gg/essential/elementa/components/UIShape.kt @@ -2,7 +2,6 @@ package gg.essential.elementa.components import gg.essential.elementa.UIComponent import gg.essential.elementa.dsl.toConstraint -import gg.essential.elementa.impl.Platform.Companion.platform import gg.essential.universal.UGraphics import gg.essential.universal.UMatrixStack import org.lwjgl.opengl.GL11 @@ -52,7 +51,7 @@ open class UIShape @JvmOverloads constructor(color: Color = Color.WHITE) : UICom val worldRenderer = UGraphics.getFromTessellator() UGraphics.tryBlendFuncSeparate(770, 771, 1, 0) - if (platform.mcVersion >= 11700) { + if (UGraphics.isCoreProfile()) { worldRenderer.beginWithDefaultShader(UGraphics.DrawMode.TRIANGLE_FAN, UGraphics.CommonVertexFormats.POSITION_COLOR) } else { worldRenderer.begin(drawMode, UGraphics.CommonVertexFormats.POSITION_COLOR) diff --git a/src/main/kotlin/gg/essential/elementa/components/Window.kt b/src/main/kotlin/gg/essential/elementa/components/Window.kt index c85481b6..5dc9a471 100644 --- a/src/main/kotlin/gg/essential/elementa/components/Window.kt +++ b/src/main/kotlin/gg/essential/elementa/components/Window.kt @@ -7,7 +7,6 @@ import gg.essential.elementa.constraints.resolution.ConstraintResolver import gg.essential.elementa.constraints.resolution.ConstraintResolverV2 import gg.essential.elementa.effects.ScissorEffect import gg.essential.elementa.font.FontRenderer -import gg.essential.elementa.impl.Platform.Companion.platform import gg.essential.elementa.utils.elementaDev import gg.essential.elementa.utils.requireMainThread import gg.essential.universal.* @@ -111,7 +110,7 @@ class Window @JvmOverloads constructor( } catch (e: Throwable) { cancelDrawing = true - val guiName = platform.currentScreen?.javaClass?.simpleName ?: "" + val guiName = UMinecraft.currentScreenObj?.javaClass?.simpleName ?: "" when (e) { is StackOverflowError -> { println("Elementa: Cyclic constraint structure detected!") @@ -129,7 +128,7 @@ class Window @JvmOverloads constructor( ScissorEffect.currentScissorState = null GL11.glDisable(GL11.GL_SCISSOR_TEST) - platform.currentScreen = when { + UMinecraft.currentScreenObj = when { e is StackOverflowError && elementaDev -> { val cyclicNodes = when (System.getProperty("elementa.dev.cycle_resolver", "2")) { "2" -> ConstraintResolverV2(this).getCyclicNodes() diff --git a/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt b/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt index 83a8cded..7aff6519 100644 --- a/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt +++ b/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt @@ -6,7 +6,6 @@ import gg.essential.elementa.constraints.CenterConstraint import gg.essential.elementa.constraints.animation.Animations import gg.essential.elementa.dsl.* import gg.essential.elementa.effects.ScissorEffect -import gg.essential.elementa.impl.Platform.Companion.platform import gg.essential.elementa.utils.getStringSplitToWidth import gg.essential.universal.UDesktop import gg.essential.universal.UKeyboard diff --git a/src/main/kotlin/gg/essential/elementa/dsl/utilities.kt b/src/main/kotlin/gg/essential/elementa/dsl/utilities.kt index 7ab8563b..43536fdb 100644 --- a/src/main/kotlin/gg/essential/elementa/dsl/utilities.kt +++ b/src/main/kotlin/gg/essential/elementa/dsl/utilities.kt @@ -4,6 +4,7 @@ import gg.essential.elementa.constraints.* import gg.essential.elementa.font.DefaultFonts import gg.essential.elementa.font.FontProvider import gg.essential.universal.UGraphics +import gg.essential.universal.wrappers.message.UTextComponent import java.awt.Color fun Char.width(textScale: Float = 1f) = UGraphics.getCharWidth(this) * textScale @@ -44,3 +45,20 @@ operator fun Color.component1() = red operator fun Color.component2() = green operator fun Color.component3() = blue operator fun Color.component4() = alpha + +// Fabric +@Deprecated("Direct Minecraft dependency", level = DeprecationLevel.HIDDEN) +fun net.minecraft.class_2561.width(textScale: Float = 1f, fontProvider: FontProvider = DefaultFonts.VANILLA_FONT_RENDERER) = + UTextComponent.from(this)!!.text.width(textScale, fontProvider) +// Forge 1.8 +@Deprecated("Direct Minecraft dependency", level = DeprecationLevel.HIDDEN) +fun net.minecraft.util.IChatComponent.width(textScale: Float = 1f, fontProvider: FontProvider = DefaultFonts.VANILLA_FONT_RENDERER) = + UTextComponent.from(this)!!.text.width(textScale, fontProvider) +// Forge 1.12-1.16 +@Deprecated("Direct Minecraft dependency", level = DeprecationLevel.HIDDEN) +fun net.minecraft.util.text.ITextComponent.width(textScale: Float = 1f, fontProvider: FontProvider = DefaultFonts.VANILLA_FONT_RENDERER) = + UTextComponent.from(this)!!.text.width(textScale, fontProvider) +// Forge 1.17+ +@Deprecated("Direct Minecraft dependency", level = DeprecationLevel.HIDDEN) +fun net.minecraft.network.chat.Component.width(textScale: Float = 1f, fontProvider: FontProvider = DefaultFonts.VANILLA_FONT_RENDERER) = + UTextComponent.from(this)!!.text.width(textScale, fontProvider) diff --git a/src/main/kotlin/gg/essential/elementa/effects/StencilEffect.kt b/src/main/kotlin/gg/essential/elementa/effects/StencilEffect.kt index ac74d6bc..fea84db8 100644 --- a/src/main/kotlin/gg/essential/elementa/effects/StencilEffect.kt +++ b/src/main/kotlin/gg/essential/elementa/effects/StencilEffect.kt @@ -1,6 +1,6 @@ package gg.essential.elementa.effects -import gg.essential.elementa.impl.Platform.Companion.platform +import gg.essential.universal.UGraphics import gg.essential.universal.UMatrixStack import org.lwjgl.opengl.GL11.* @@ -9,6 +9,7 @@ import org.lwjgl.opengl.GL11.* * * In order to use, you must call [enableStencil] in mod initialization. */ +@Deprecated("Does not work on 1.14+") class StencilEffect : Effect() { override fun beforeDraw(matrixStack: UMatrixStack) { glEnable(GL_STENCIL_TEST) @@ -32,8 +33,9 @@ class StencilEffect : Effect() { /** * Must be called in mod initialization to use [StencilEffect] */ - @JvmStatic fun enableStencil() { //TODO wait for 1.15 to impl - platform.enableStencil() + @JvmStatic fun enableStencil() { + @Suppress("DEPRECATION") + UGraphics.enableStencil() } } } diff --git a/src/main/kotlin/gg/essential/elementa/impl/Platform.kt b/src/main/kotlin/gg/essential/elementa/impl/Platform.kt deleted file mode 100644 index 1fe62845..00000000 --- a/src/main/kotlin/gg/essential/elementa/impl/Platform.kt +++ /dev/null @@ -1,21 +0,0 @@ -package gg.essential.elementa.impl - -import org.jetbrains.annotations.ApiStatus -import java.util.* - -@ApiStatus.Internal -interface Platform { - val mcVersion: Int - - var currentScreen: Any? - - fun enableStencil() - - fun isCallingFromMinecraftThread(): Boolean - - @ApiStatus.Internal - companion object { - internal val platform: Platform = - ServiceLoader.load(Platform::class.java, Platform::class.java.classLoader).iterator().next() - } -} \ No newline at end of file diff --git a/src/main/kotlin/gg/essential/elementa/utils/invalidUsage.kt b/src/main/kotlin/gg/essential/elementa/utils/invalidUsage.kt index 5142e514..0b115781 100644 --- a/src/main/kotlin/gg/essential/elementa/utils/invalidUsage.kt +++ b/src/main/kotlin/gg/essential/elementa/utils/invalidUsage.kt @@ -1,6 +1,6 @@ package gg.essential.elementa.utils -import gg.essential.elementa.impl.Platform.Companion.platform +import gg.essential.universal.UMinecraft internal enum class InvalidUsageBehavior { IGNORE, @@ -39,5 +39,5 @@ internal fun requireState(state: Boolean, message: String) { /** Ensure a method can only be called from the main thread. Lack of this check does **not** imply thread-safety. */ internal fun requireMainThread(message: String = "This method is not thread-safe and must be called from the main thread. " + "Consider the thread-safety of the calling code and use Window.enqueueRenderOperation if applicable.") { - requireState(platform.isCallingFromMinecraftThread(), message) + requireState(UMinecraft.isCallingFromMinecraftThread(), message) } diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 00000000..4f3cd859 --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "id": "elementa", + "name": "Elementa", + "version": "${version}", + "environment": "client", + "custom": { + "modmenu": { + "badges": [ "library" ] + } + } +} \ No newline at end of file diff --git a/versions/api/platform.api b/versions/api/platform.api deleted file mode 100644 index 6f3cc9cf..00000000 --- a/versions/api/platform.api +++ /dev/null @@ -1,19 +0,0 @@ -public final class gg/essential/elementa/dsl/UtilitiesKt_platform { - @1.8.9-forge - public static final fun width (Lnet/minecraft/util/IChatComponent;FLgg/essential/elementa/font/FontProvider;)F - @1.8.9-forge - public static synthetic fun width$default (Lnet/minecraft/util/IChatComponent;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F - @1.17.1-forge,1.18.1-forge - public static final fun width (Lnet/minecraft/network/chat/Component;FLgg/essential/elementa/font/FontProvider;)F - @1.17.1-forge,1.18.1-forge - public static synthetic fun width$default (Lnet/minecraft/network/chat/Component;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F - @1.16.2-fabric,1.17.1-fabric,1.18.1-fabric - public static final fun width (Lnet/minecraft/text/Text;FLgg/essential/elementa/font/FontProvider;)F - @1.16.2-fabric,1.17.1-fabric,1.18.1-fabric - public static synthetic fun width$default (Lnet/minecraft/text/Text;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F - @1.12.2-forge,1.15.2-forge,1.16.2-forge - public static final fun width (Lnet/minecraft/util/text/ITextComponent;FLgg/essential/elementa/font/FontProvider;)F - @1.12.2-forge,1.15.2-forge,1.16.2-forge - public static synthetic fun width$default (Lnet/minecraft/util/text/ITextComponent;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F -} - diff --git a/versions/build.gradle.kts b/versions/build.gradle.kts index 9fce3d75..a19d6343 100644 --- a/versions/build.gradle.kts +++ b/versions/build.gradle.kts @@ -1,25 +1,14 @@ -import gg.essential.gradle.multiversion.excludeKotlinDefaultImpls -import gg.essential.gradle.multiversion.mergePlatformSpecifics import gg.essential.gradle.util.* plugins { kotlin("jvm") - id("org.jetbrains.dokka") id("gg.essential.multi-version") id("gg.essential.defaults") - id("gg.essential.defaults.maven-publish") } -group = "gg.essential" - java.withSourcesJar() -tasks.compileKotlin.setJvmDefault(if (platform.mcVersion >= 11400) "all" else "all-compatibility") loom.noServerRunConfigs() -val common by configurations.creating -configurations.compileClasspath { extendsFrom(common) } -configurations.runtimeClasspath { extendsFrom(common) } - dependencies { implementation(libs.kotlin.stdlib.jdk8) implementation(libs.kotlin.reflect) @@ -29,7 +18,7 @@ dependencies { exclude(group = "org.jetbrains.kotlin") } - common(project(":")) + implementation(project(":")) if (platform.isFabric) { val fabricApiVersion = when(platform.mcVersion) { @@ -57,34 +46,3 @@ dependencies { } } } - -tasks.processResources { - filesMatching(listOf("fabric.mod.json")) { - filter { it.replace("\"com.example.examplemod.ExampleMod\"", "") } - } -} - -tasks.dokkaHtml { - moduleName.set("Elementa $name") -} - -tasks.jar { - dependsOn(common) - from({ common.map { zipTree(it) } }) - mergePlatformSpecifics() - - // We build the common module with legacy default impl for backwards compatibility, but we only need those for - // 1.12.2 and older. Newer versions have never shipped with legacy default impl. - if (platform.mcVersion >= 11400) { - excludeKotlinDefaultImpls() - } - - exclude("com/example/examplemod/**") - exclude("META-INF/mods.toml") - exclude("mcmod.info") - exclude("kotlin/**") -} - -tasks.named("sourcesJar") { - from(project(":").sourceSets.main.map { it.allSource }) -} diff --git a/versions/gradle.properties b/versions/gradle.properties deleted file mode 100644 index edcf861e..00000000 --- a/versions/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -baseArtifactId=elementa diff --git a/versions/root.gradle.kts b/versions/root.gradle.kts index cf9a6646..dac88a53 100644 --- a/versions/root.gradle.kts +++ b/versions/root.gradle.kts @@ -2,11 +2,8 @@ import gg.essential.gradle.util.* plugins { id("gg.essential.multi-version.root") - id("gg.essential.multi-version.api-validation") } -version = versionFromBuildIdAndBranch() - preprocess { val forge11801 = createNode("1.18.1-forge", 11801, "srg") val fabric11801 = createNode("1.18.1-fabric", 11801, "yarn") @@ -14,7 +11,6 @@ preprocess { val fabric11701 = createNode("1.17.1-fabric", 11701, "yarn") val fabric11602 = createNode("1.16.2-fabric", 11602, "yarn") val forge11602 = createNode("1.16.2-forge", 11602, "srg") - val forge11502 = createNode("1.15.2-forge", 11502, "srg") val forge11202 = createNode("1.12.2-forge", 11202, "srg") val forge10809 = createNode("1.8.9-forge", 10809, "srg") @@ -23,13 +19,6 @@ preprocess { forge11701.link(fabric11701) fabric11701.link(fabric11602) fabric11602.link(forge11602) - forge11602.link(forge11502) - forge11502.link(forge11202, file("1.15.2-1.12.2.txt")) + forge11602.link(forge11202, file("1.15.2-1.12.2.txt")) forge11202.link(forge10809, file("1.12.2-1.8.9.txt")) } - -apiValidation { - ignoredProjects.addAll(subprojects.map { it.name }) - ignoredPackages.add("com.example") - nonPublicMarkers.add("org.jetbrains.annotations.ApiStatus\$Internal") -} diff --git a/versions/src/main/java/gg/essential/elementa/dsl/utilities_platform.kt b/versions/src/main/java/gg/essential/elementa/dsl/utilities_platform.kt deleted file mode 100644 index 2d08f6f1..00000000 --- a/versions/src/main/java/gg/essential/elementa/dsl/utilities_platform.kt +++ /dev/null @@ -1,10 +0,0 @@ -@file:JvmName("UtilitiesKt_platform") -package gg.essential.elementa.dsl - -import gg.essential.elementa.font.DefaultFonts -import gg.essential.elementa.font.FontProvider -import gg.essential.universal.wrappers.message.UTextComponent -import net.minecraft.util.text.ITextComponent - -fun ITextComponent.width(textScale: Float = 1f, fontProvider: FontProvider = DefaultFonts.VANILLA_FONT_RENDERER) = - UTextComponent(this).formattedText.width(textScale, fontProvider) diff --git a/versions/src/main/java/gg/essential/elementa/impl/PlatformImpl.java b/versions/src/main/java/gg/essential/elementa/impl/PlatformImpl.java deleted file mode 100644 index faac816b..00000000 --- a/versions/src/main/java/gg/essential/elementa/impl/PlatformImpl.java +++ /dev/null @@ -1,59 +0,0 @@ -package gg.essential.elementa.impl; - -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiScreen; -import net.minecraft.client.shader.Framebuffer; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.Nullable; - -@ApiStatus.Internal -@SuppressWarnings("unused") // instantiated via reflection from Platform.Companion -public class PlatformImpl implements Platform { - - @Override - public int getMcVersion() { - //#if MC==11801 - //$$ return 11801; - //#elseif MC==11701 - //$$ return 11701; - //#elseif MC==11602 - //$$ return 11602; - //#elseif MC==11502 - //$$ return 11502; - //#elseif MC==11202 - return 11202; - //#elseif MC==10809 - //$$ return 10809; - //#endif - } - - @Nullable - @Override - public Object getCurrentScreen() { - return Minecraft.getMinecraft().currentScreen; - } - - @Override - public void setCurrentScreen(@Nullable Object screen) { - Minecraft.getMinecraft().displayGuiScreen((GuiScreen) screen); - } - - @Override - public void enableStencil() { - //#if MC<11500 - Framebuffer framebuffer = Minecraft.getMinecraft().getFramebuffer(); - if (!framebuffer.isStencilEnabled()) { - framebuffer.enableStencil(); - } - //#endif - } - - @Override - public boolean isCallingFromMinecraftThread() { - //#if MC>=11400 - //$$ return Minecraft.getInstance().isOnExecutionThread(); - //#else - return Minecraft.getMinecraft().isCallingFromMinecraftThread(); - //#endif - } -} diff --git a/versions/src/main/resources/META-INF/services/gg.essential.elementa.impl.Platform b/versions/src/main/resources/META-INF/services/gg.essential.elementa.impl.Platform deleted file mode 100644 index 16cfa331..00000000 --- a/versions/src/main/resources/META-INF/services/gg.essential.elementa.impl.Platform +++ /dev/null @@ -1 +0,0 @@ -gg.essential.elementa.impl.PlatformImpl diff --git a/versions/src/main/resources/fabric.mod.json b/versions/src/main/resources/fabric.mod.json index d00bdd1d..11343b2f 100644 --- a/versions/src/main/resources/fabric.mod.json +++ b/versions/src/main/resources/fabric.mod.json @@ -1,15 +1,10 @@ { "schemaVersion": 1, - "id": "elementa", - "name": "Elementa", - "version": "${version}", + "id": "examplemod", + "name": "Example Mod", + "version": "0", "environment": "client", "entrypoints": { "client": ["com.example.examplemod.ExampleMod"] - }, - "custom": { - "modmenu": { - "badges": [ "library" ] - } } } \ No newline at end of file From 1140bcb1dd0b003fa783980415582a843ca6bcbd Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 6 Aug 2024 15:49:55 +0200 Subject: [PATCH 44/66] Misc: Rename `versions` -> `example` --- .gitignore | 3 --- {versions => example}/1.12.2-1.8.9.txt | 0 {versions => example}/1.15.2-1.12.2.txt | 0 {versions => example}/build.gradle.kts | 0 {versions => example}/mainProject | 0 {versions => example}/root.gradle.kts | 0 .../main/java/com/example/examplemod/ExampleMod.java | 0 .../src/main/resources/META-INF/mods.toml | 0 .../src/main/resources/fabric.mod.json | 0 settings.gradle.kts | 10 ++++------ versions/1.12.2-forge/.gitkeep | 0 versions/1.15.2-forge/.gitkeep | 0 versions/1.16.2-fabric/.gitkeep | 0 versions/1.16.2-forge/.gitkeep | 0 versions/1.8.9-forge/.gitkeep | 0 15 files changed, 4 insertions(+), 9 deletions(-) rename {versions => example}/1.12.2-1.8.9.txt (100%) rename {versions => example}/1.15.2-1.12.2.txt (100%) rename {versions => example}/build.gradle.kts (100%) rename {versions => example}/mainProject (100%) rename {versions => example}/root.gradle.kts (100%) rename {versions => example}/src/main/java/com/example/examplemod/ExampleMod.java (100%) rename {versions => example}/src/main/resources/META-INF/mods.toml (100%) rename {versions => example}/src/main/resources/fabric.mod.json (100%) delete mode 100644 versions/1.12.2-forge/.gitkeep delete mode 100644 versions/1.15.2-forge/.gitkeep delete mode 100644 versions/1.16.2-fabric/.gitkeep delete mode 100644 versions/1.16.2-forge/.gitkeep delete mode 100644 versions/1.8.9-forge/.gitkeep diff --git a/.gitignore b/.gitignore index c9a75934..4965c82d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,3 @@ options.txt usercache.json usernamecache.json *.txt - -versions/*/tmp.srg -versions/*/api/ diff --git a/versions/1.12.2-1.8.9.txt b/example/1.12.2-1.8.9.txt similarity index 100% rename from versions/1.12.2-1.8.9.txt rename to example/1.12.2-1.8.9.txt diff --git a/versions/1.15.2-1.12.2.txt b/example/1.15.2-1.12.2.txt similarity index 100% rename from versions/1.15.2-1.12.2.txt rename to example/1.15.2-1.12.2.txt diff --git a/versions/build.gradle.kts b/example/build.gradle.kts similarity index 100% rename from versions/build.gradle.kts rename to example/build.gradle.kts diff --git a/versions/mainProject b/example/mainProject similarity index 100% rename from versions/mainProject rename to example/mainProject diff --git a/versions/root.gradle.kts b/example/root.gradle.kts similarity index 100% rename from versions/root.gradle.kts rename to example/root.gradle.kts diff --git a/versions/src/main/java/com/example/examplemod/ExampleMod.java b/example/src/main/java/com/example/examplemod/ExampleMod.java similarity index 100% rename from versions/src/main/java/com/example/examplemod/ExampleMod.java rename to example/src/main/java/com/example/examplemod/ExampleMod.java diff --git a/versions/src/main/resources/META-INF/mods.toml b/example/src/main/resources/META-INF/mods.toml similarity index 100% rename from versions/src/main/resources/META-INF/mods.toml rename to example/src/main/resources/META-INF/mods.toml diff --git a/versions/src/main/resources/fabric.mod.json b/example/src/main/resources/fabric.mod.json similarity index 100% rename from versions/src/main/resources/fabric.mod.json rename to example/src/main/resources/fabric.mod.json diff --git a/settings.gradle.kts b/settings.gradle.kts index a6201fb5..7af485c9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,9 +25,8 @@ include(":unstable:statev2") include(":unstable:layoutdsl") -include(":platform") -project(":platform").apply { - projectDir = file("versions/") +include(":example") +project(":example").apply { buildFileName = "root.gradle.kts" } listOf( @@ -40,9 +39,8 @@ listOf( "1.18.1-fabric", "1.18.1-forge", ).forEach { version -> - include(":platform:$version") - project(":platform:$version").apply { - projectDir = file("versions/$version") + include(":example:$version") + project(":example:$version").apply { buildFileName = "../build.gradle.kts" } } diff --git a/versions/1.12.2-forge/.gitkeep b/versions/1.12.2-forge/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/versions/1.15.2-forge/.gitkeep b/versions/1.15.2-forge/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/versions/1.16.2-fabric/.gitkeep b/versions/1.16.2-fabric/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/versions/1.16.2-forge/.gitkeep b/versions/1.16.2-forge/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/versions/1.8.9-forge/.gitkeep b/versions/1.8.9-forge/.gitkeep deleted file mode 100644 index e69de29b..00000000 From 78ad29f0be9c84bc34235cbf7bea910a28e61b3f Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 6 Aug 2024 16:02:06 +0200 Subject: [PATCH 45/66] Misc: Move example gui code into dedicated :example:common project --- build.gradle.kts | 4 ---- example/build.gradle.kts | 2 +- example/common/build.gradle.kts | 23 +++++++++++++++++++ .../com/example/examplemod/ComponentsGui.kt | 0 .../com/example/examplemod/ExampleGui.kt | 0 .../example/examplemod/ExampleServerList.kt | 0 .../com/example/examplemod/ExamplesGui.kt | 0 .../com/example/examplemod/JavaTestGui.java | 0 .../com/example/examplemod/KtTestGui.kt | 0 settings.gradle.kts | 1 + 10 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 example/common/build.gradle.kts rename {src/main/java => example/common/src/main/kotlin}/com/example/examplemod/ComponentsGui.kt (100%) rename {src/main/java => example/common/src/main/kotlin}/com/example/examplemod/ExampleGui.kt (100%) rename {src/main/java => example/common/src/main/kotlin}/com/example/examplemod/ExampleServerList.kt (100%) rename {src/main/java => example/common/src/main/kotlin}/com/example/examplemod/ExamplesGui.kt (100%) rename {src/main/java => example/common/src/main/kotlin}/com/example/examplemod/JavaTestGui.java (100%) rename {src/main/java => example/common/src/main/kotlin}/com/example/examplemod/KtTestGui.kt (100%) diff --git a/build.gradle.kts b/build.gradle.kts index 557b94b4..c9aea799 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -71,14 +71,10 @@ tasks.processResources { tasks.jar { dependsOn(internal) from({ internal.map { zipTree(it) } }) - - // TODO move into separate project - exclude("com") // `com.example` package } apiValidation { ignoredProjects.addAll(subprojects.map { it.name }) - ignoredPackages.add("com.example") nonPublicMarkers.add("org.jetbrains.annotations.ApiStatus\$Internal") } diff --git a/example/build.gradle.kts b/example/build.gradle.kts index a19d6343..b610de4e 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -18,7 +18,7 @@ dependencies { exclude(group = "org.jetbrains.kotlin") } - implementation(project(":")) + implementation(project(":example:common")) if (platform.isFabric) { val fabricApiVersion = when(platform.mcVersion) { diff --git a/example/common/build.gradle.kts b/example/common/build.gradle.kts new file mode 100644 index 00000000..1f228961 --- /dev/null +++ b/example/common/build.gradle.kts @@ -0,0 +1,23 @@ +import gg.essential.gradle.multiversion.StripReferencesTransform.Companion.registerStripReferencesAttribute + +plugins { + kotlin("jvm") + id("gg.essential.defaults") +} +repositories.mavenLocal() + +kotlin.jvmToolchain(8) + +val common = registerStripReferencesAttribute("common") { + excludes.add("net.minecraft") +} + +dependencies { + api(libs.kotlin.stdlib.jdk8) + + compileOnly(libs.versions.universalcraft.map { "gg.essential:universalcraft-1.8.9-forge:$it" }) { + attributes { attribute(common, true) } + } + + api(project(":")) +} diff --git a/src/main/java/com/example/examplemod/ComponentsGui.kt b/example/common/src/main/kotlin/com/example/examplemod/ComponentsGui.kt similarity index 100% rename from src/main/java/com/example/examplemod/ComponentsGui.kt rename to example/common/src/main/kotlin/com/example/examplemod/ComponentsGui.kt diff --git a/src/main/java/com/example/examplemod/ExampleGui.kt b/example/common/src/main/kotlin/com/example/examplemod/ExampleGui.kt similarity index 100% rename from src/main/java/com/example/examplemod/ExampleGui.kt rename to example/common/src/main/kotlin/com/example/examplemod/ExampleGui.kt diff --git a/src/main/java/com/example/examplemod/ExampleServerList.kt b/example/common/src/main/kotlin/com/example/examplemod/ExampleServerList.kt similarity index 100% rename from src/main/java/com/example/examplemod/ExampleServerList.kt rename to example/common/src/main/kotlin/com/example/examplemod/ExampleServerList.kt diff --git a/src/main/java/com/example/examplemod/ExamplesGui.kt b/example/common/src/main/kotlin/com/example/examplemod/ExamplesGui.kt similarity index 100% rename from src/main/java/com/example/examplemod/ExamplesGui.kt rename to example/common/src/main/kotlin/com/example/examplemod/ExamplesGui.kt diff --git a/src/main/java/com/example/examplemod/JavaTestGui.java b/example/common/src/main/kotlin/com/example/examplemod/JavaTestGui.java similarity index 100% rename from src/main/java/com/example/examplemod/JavaTestGui.java rename to example/common/src/main/kotlin/com/example/examplemod/JavaTestGui.java diff --git a/src/main/java/com/example/examplemod/KtTestGui.kt b/example/common/src/main/kotlin/com/example/examplemod/KtTestGui.kt similarity index 100% rename from src/main/java/com/example/examplemod/KtTestGui.kt rename to example/common/src/main/kotlin/com/example/examplemod/KtTestGui.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index 7af485c9..dcfc092c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,6 +29,7 @@ include(":example") project(":example").apply { buildFileName = "root.gradle.kts" } +include(":example:common") listOf( "1.8.9-forge", "1.12.2-forge", From 5c45f4d7773d24c359b10d6ccaaf0cfd24b71ce9 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Wed, 29 May 2024 11:45:24 +0200 Subject: [PATCH 46/66] StateV2: Add `CompletableFuture.toState` util Source-Commit: 658989cefd5090f2db1fad40f3cf58f37262b249 --- .../state/v2/utils/completableFuture.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/utils/completableFuture.kt diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/utils/completableFuture.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/utils/completableFuture.kt new file mode 100644 index 00000000..87e9ad9f --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/utils/completableFuture.kt @@ -0,0 +1,20 @@ +package gg.essential.elementa.state.v2.utils + +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.mutableStateOf +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executor + +fun CompletableFuture.toState(mainThreadExecutor: Executor): State { + if (isDone) { + return State { get() } + } + + val resolved by lazy(LazyThreadSafetyMode.NONE) { + val resolved = mutableStateOf(null) + thenAcceptAsync({ resolved.set(it) }, mainThreadExecutor) + resolved + } + + return State { if (isDone) get() else resolved() } +} From ffb334e83c33fed9b24782ea6f651fdb49cc0659 Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Wed, 5 Jun 2024 08:33:15 +0100 Subject: [PATCH 47/66] StateV2: Add `ObserverImpl` interface & `Observer.observerImpl` `observerImpl` should return the underlying `Observer` instance, allowing it to be cast to a State implementation's internal implementation if needed. This moves the "must not be implemented by user code" requirement from `Observer` to `ObserverImpl`. Source-Commit: 92f0cca3ee096030cab71a4ad8ad13572001aefc --- .../elementa/state/v2/impl/basic/impl.kt | 11 ++++++++--- .../elementa/state/v2/impl/legacy/impl.kt | 12 ++++++++---- .../elementa/state/v2/impl/minimal/impl.kt | 11 ++++++++--- .../gg/essential/elementa/state/v2/state.kt | 16 ++++++++++++---- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt index 57a17071..4bb1efcd 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt @@ -3,6 +3,7 @@ package gg.essential.elementa.state.v2.impl.basic import gg.essential.elementa.state.v2.ReferenceHolder import gg.essential.elementa.state.v2.MutableState import gg.essential.elementa.state.v2.Observer +import gg.essential.elementa.state.v2.ObserverImpl import gg.essential.elementa.state.v2.State import gg.essential.elementa.state.v2.impl.Impl import java.lang.ref.ReferenceQueue @@ -109,7 +110,10 @@ private class Node( private var state: NodeState, private val func: Observer.() -> T, private var value: T?, -) : State, Observer { +) : State, Observer, ObserverImpl { + override val observerImpl: ObserverImpl + get() = this + private val observed = mutableSetOf>() private val dependencies = mutableListOf>() private val dependents: MutableList>> = mutableListOf() @@ -119,8 +123,9 @@ private class Node( } fun getTracked(observer: Observer): T { - if (observer is Node<*>) { - observer.observed.add(this) + val impl = observer.observerImpl + if (impl is Node<*>) { + impl.observed.add(this) } return getUntracked() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt index 9cb8fffa..ae0d36e1 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt @@ -6,6 +6,7 @@ import gg.essential.elementa.state.v2.DelegatingMutableState import gg.essential.elementa.state.v2.DelegatingState import gg.essential.elementa.state.v2.MutableState import gg.essential.elementa.state.v2.Observer +import gg.essential.elementa.state.v2.ObserverImpl import gg.essential.elementa.state.v2.State import gg.essential.elementa.state.v2.impl.Impl import java.lang.ref.ReferenceQueue @@ -18,7 +19,7 @@ internal object LegacyImpl : Impl { override fun memo(func: Observer.() -> T): State { val subscribed = mutableMapOf, () -> Unit>() val observed = mutableSetOf>() - val scope = ObserverImpl(observed) + val scope = LegacyObserverImpl(observed) return derivedState(initialValue = func(scope)) { owner, derivedState -> fun updateSubscriptions() { @@ -69,7 +70,10 @@ internal object LegacyImpl : Impl { ): State = ReferenceHoldingBasicState(initialValue).apply { builder(this, this) } } -private class ObserverImpl(val observed: MutableSet>) : Observer +private class LegacyObserverImpl(val observed: MutableSet>) : Observer, ObserverImpl { + override val observerImpl: ObserverImpl + get() = this +} /** A simple implementation of [MutableState], containing only a backing field */ private open class BasicState(private var valueBacker: T) : MutableState { @@ -89,7 +93,7 @@ private open class BasicState(private var valueBacker: T) : MutableState { private var liveSize = 0 override fun Observer.get(): T { - (this@get as? ObserverImpl)?.observed?.add(this@BasicState) + (this@get.observerImpl as? LegacyObserverImpl)?.observed?.add(this@BasicState) return getUntracked() } @@ -163,7 +167,7 @@ private open class DelegatingStateBase>(protected var delegate: private var listeners = mutableListOf>() override fun Observer.get(): T { - (this@get as? ObserverImpl)?.observed?.add(this@DelegatingStateBase) + (this@get.observerImpl as? LegacyObserverImpl)?.observed?.add(this@DelegatingStateBase) return getUntracked() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt index f7793b15..3fbd5b2a 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt @@ -3,6 +3,7 @@ package gg.essential.elementa.state.v2.impl.minimal import gg.essential.elementa.state.v2.ReferenceHolder import gg.essential.elementa.state.v2.MutableState import gg.essential.elementa.state.v2.Observer +import gg.essential.elementa.state.v2.ObserverImpl import gg.essential.elementa.state.v2.State import gg.essential.elementa.state.v2.impl.Impl import java.lang.ref.ReferenceQueue @@ -102,7 +103,10 @@ private class Node( private var state: NodeState, private val func: Observer.() -> T, private var value: T?, -) : State, Observer { +) : State, Observer, ObserverImpl { + override val observerImpl: ObserverImpl + get() = this + private val observed = mutableSetOf>() private val dependencies = mutableListOf>() private val dependents: MutableList>> = mutableListOf() @@ -112,8 +116,9 @@ private class Node( } fun getTracked(observer: Observer): T { - if (observer is Node<*>) { - observer.observed.add(this) + val impl = observer.observerImpl + if (impl is Node<*>) { + impl.observed.add(this) } return getUntracked() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt index a0b9a58b..46f23e61 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt @@ -6,6 +6,12 @@ import gg.essential.elementa.state.v2.impl.basic.MarkThenPushAndPullImpl private val impl: Impl = MarkThenPushAndPullImpl +/** + * Note: This interface must not be implemented by user code. The State implementation may cast it to its internal + * implementation type without checking. + */ +interface ObserverImpl + /** * A marker interface for an object which may observe which states are being accessed, such that it can then subscribe * to these states to be updated when they change. @@ -13,11 +19,10 @@ private val impl: Impl = MarkThenPushAndPullImpl * Note that the duration during which a given [Observer] can be used is usually limited to the call in which it was * received. * It should not be stored (neither in a field, nor implicitly in an asynchronous lambda) and then used at a later time. - * - * Note: This interface must not be be implemented by user code. The State implementation may cast it to its internal - * implementation type without checking. */ interface Observer { + val observerImpl: ObserverImpl + /** * Get the current value of the State object and subscribe the observer to be re-evaluated when it changes. */ @@ -31,7 +36,10 @@ interface Observer { * about future changes. * To get the current value of a [State], one can also use the [State.getUntracked] shortcut. */ -object Untracked : Observer +object Untracked : Observer, ObserverImpl { + override val observerImpl: ObserverImpl + get() = this +} /** * Creates a [State] which lazily computes its value via the given pure function [func] and caches the result until From 8c19c6d73c8ed22b59367130ccea7772b29a5cb9 Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Mon, 24 Jun 2024 18:13:14 +0100 Subject: [PATCH 48/66] elementaExtensions: Allow access to the child in tag predicates This allows the predicate to filter based on the child. Source-Commit: 0d657eec6901d26efb68ce327b5898bb2c59ed63 --- .../gg/essential/elementa/util/elementaExtensions.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt index 89bbdb51..cc9bf5d3 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt @@ -309,7 +309,7 @@ fun UIComponent.findChildrenByTag(tag: Tag, recursive: Boolean = false) = findCh */ inline fun UIComponent.findChildrenByTag( recursive: Boolean = false, - noinline predicate: (T) -> Boolean = { true }, + noinline predicate: UIComponent.(T) -> Boolean = { true }, ) = findChildrenByTag(T::class.java, recursive, predicate) /** @@ -320,7 +320,7 @@ inline fun UIComponent.findChildrenByTag( */ inline fun UIComponent.findChildrenAndTags( recursive: Boolean = false, - noinline predicate: (T) -> Boolean = { true }, + noinline predicate: UIComponent.(T) -> Boolean = { true }, ) = findChildrenAndTags(T::class.java, recursive, predicate) /** @@ -332,14 +332,14 @@ inline fun UIComponent.findChildrenAndTags( fun UIComponent.findChildrenByTag( type: Class, recursive: Boolean = false, - predicate: (T) -> Boolean = { true } + predicate: UIComponent.(T) -> Boolean = { true } ): List { val found = mutableListOf() fun addToFoundIfHasTag(component: UIComponent) { for (child in component.children) { val tag = child.getTag(type) - if (tag != null && predicate(tag)) { + if (tag != null && child.predicate(tag)) { found.add(child) } @@ -363,14 +363,14 @@ fun UIComponent.findChildrenByTag( fun UIComponent.findChildrenAndTags( type: Class, recursive: Boolean = false, - predicate: (T) -> Boolean = { true } + predicate: UIComponent.(T) -> Boolean = { true } ): List> { val found = mutableListOf>() fun addToFoundIfHasTag(component: UIComponent) { for (child in component.children) { val tag = child.getTag(type) - if (tag != null && predicate(tag)) { + if (tag != null && child.predicate(tag)) { found.add(child to tag) } From ac7453628bf524fb980da7cc4a2d59bfc4ed5912 Mon Sep 17 00:00:00 2001 From: Caoimhe Date: Thu, 20 Jun 2024 20:27:24 +0100 Subject: [PATCH 49/66] LayoutDSL: Add `Modifier.focusable` This allows a component to be navigated to via the keyboard (using the tab key). Put simply, it: - Creates a new hover-scope for the component, which combines the mouse hovered state AND the focused state. - Listens for tab key presses and passes focus to the next component - Listens for enter key presses and simulates a mouse click on the component For this to work properly, a focusable component needs to be given the Window's focus by something else first, there is nothing set-up for the initial tab key-press right now. Source-Commit: ef5a5c9795028d0ab32890e8689d62f5b7b02019 --- .../gg/essential/elementa/util/focusable.kt | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/focusable.kt diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/focusable.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/focusable.kt new file mode 100644 index 00000000..e919c3df --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/focusable.kt @@ -0,0 +1,100 @@ +package gg.essential.elementa.util + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.Window +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.combinators.or +import gg.essential.elementa.state.v2.mutableStateOf +import gg.essential.elementa.state.v2.stateOf +import gg.essential.elementa.state.v2.toV2 +import gg.essential.elementa.layoutdsl.Modifier +import gg.essential.elementa.layoutdsl.tag +import gg.essential.elementa.layoutdsl.then +import gg.essential.universal.UKeyboard + +data class Focusable(val disabled: State) : Tag + +/** Marks this component as [Focusable], meaning that it can be navigated to via the keyboard. */ +fun Modifier.focusable(disabled: State = stateOf(false)): Modifier { + return tag(Focusable(disabled)) + .then { + val keyListener = setupKeyboardNavigation() + return@then { keyTypedListeners.remove(keyListener) } + } + .then { makeFocusOrHoverScope(); { throw NotImplementedError() } } +} + +/** Creates a hover scope for this component based on whether it is hovered by the mouse OR it has the Window's focus. */ +private fun UIComponent.makeFocusOrHoverScope() { + val focused = focusedState() + val hovered = hoveredState().toV2() + + makeHoverScope(focused or hovered) +} + +/** Returns a state indicating whether this component has the [Window]'s focus or not. */ +fun UIComponent.focusedState(): State { + class CachedState(val state: State) : Tag + getTag()?.let { return it.state } + + val state = mutableStateOf(Window.ofOrNull(this)?.focusedComponent == this) + + onFocus { state.set(true) } + onFocusLost { state.set(false) } + addTag(CachedState(state)) + + return state +} +/** + * Reacts to keyboard-navigation related events if the component is focused. + * @return The key listener, mainly intended for removing it at a future point in time. + */ +private fun UIComponent.setupKeyboardNavigation(): UIComponent.(Char, Int) -> Unit { + val keyListener: UIComponent.(Char, Int) -> Unit = keyListener@{ _, keyCode -> + if (!hasFocus()) { + return@keyListener + } + + when (keyCode) { + UKeyboard.KEY_ENTER -> simulateLeftClick() + UKeyboard.KEY_TAB -> passFocusToNextComponent(backwards = UKeyboard.isShiftKeyDown()) + } + } + + onKeyType(keyListener) + + return keyListener +} + +/** Intended for use by keyboard navigation implementations in order to fake a left-click event on a component. */ +fun UIComponent.simulateLeftClick() { + // We need to make sure that we're still in the window, as another key listener which ran before us + // may have already reacted to the event. This function isn't exactly a key-listener, but is most + // likely being called from one. + if (!isInComponentTree()) { + return + } + + mouseClick( + getLeft().toDouble() + (getWidth() / 2), + getTop().toDouble() + (getHeight() / 2), + 0, + ) +} + +private fun UIComponent.passFocusToNextComponent(backwards: Boolean = false) { + val focusable = Window.of(this).findChildrenByTag(recursive = true) { + this == this@passFocusToNextComponent || !it.disabled.getUntracked() + } + + val currentIndex = focusable.indexOf(this) + if (currentIndex == -1) { + return + } + + val direction = if (backwards) -1 else 1 + val nextComponent = focusable[(currentIndex + direction).mod(focusable.size)] + nextComponent.grabWindowFocus() +} \ No newline at end of file From 57f59f368c82ca33c3cb66fa4533745b94f7c463 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Sat, 6 Jul 2024 10:05:27 +0200 Subject: [PATCH 50/66] MutableTrackedList/Set: Fix incorrect changes when unrelated The reverse diffing only applies when the two are related. In the cases where it turns out they aren't related we always want to return the changes from `other` to `this`. Source-Commit: 62b43dc9048f8bbd988593f55041833a40b450d0 --- .../elementa/state/v2/collections/MutableTrackedList.kt | 2 +- .../elementa/state/v2/collections/MutableTrackedSet.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedList.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedList.kt index ad8df114..b6bb88fd 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedList.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedList.kt @@ -88,7 +88,7 @@ class MutableTrackedList private constructor( } else { // Reverse diff val generations = generateSequence(this) { if (it == other) null else it.nextList }.toMutableList() - if (generations.removeLast() != other) return TrackedList.Change.estimate(this, other).asSequence() + if (generations.removeLast() != other) return TrackedList.Change.estimate(other, this).asSequence() return generations.asReversed().asSequence().flatMap { it.nextDiff!!.asInverseChangeSequence() } } } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedSet.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedSet.kt index 839f9288..702bceaf 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedSet.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedSet.kt @@ -89,7 +89,7 @@ class MutableTrackedSet private constructor( } else { // Reverse diff val generations = generateSequence(this) { if (it == other) null else it.nextSet }.toMutableList() - if (generations.removeLast() != other) return TrackedSet.Change.estimate(this, other).asSequence() + if (generations.removeLast() != other) return TrackedSet.Change.estimate(other, this).asSequence() return generations.asReversed().asSequence().flatMap { it.nextDiff!!.asInverseChangeSequence() } } } From 78638c8afe2d9b4face04117e1f7c36b2b1b2819 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Tue, 16 Jul 2024 11:04:38 -0700 Subject: [PATCH 51/66] statev2/utils: create functions for constructing `TrackedList`s and `MutableTrackedList`s Source-Commit: 3a0c15d93bf508d43919a5fa17c5daa41f8117f4 Source-Commit: eff670c9b871d1b59f7013c4a933255e94dca7c9 --- .../gg/essential/elementa/state/v2/collections/utils.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/utils.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/utils.kt index 28cd272d..b97f714b 100644 --- a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/utils.kt +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/utils.kt @@ -29,3 +29,7 @@ fun ListState.asMap(owner: ReferenceHolder, block: (T) -> Pair trackedListOf(vararg elements: T) : TrackedList = MutableTrackedList(elements.toMutableList()) + +fun mutableTrackedListOf(vararg elements: T): MutableTrackedList = MutableTrackedList(elements.toMutableList()) From 0b69779b18fc58249aeba933eccd890bfcbe54bc Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 18 Mar 2024 19:00:50 -0400 Subject: [PATCH 52/66] layoutdsl: migrate `if_` and `ifNotNull` to statev2 Source-Commit: dadfada876b6e6565737fc8a3ca187427e403e4b Source-Commit: 1ad79b18c9d3a47abf48059a8e6adfed298de4d6 Source-Commit: a3e716de1ca73e4932742414a40bef8557f38880 Source-Commit: c1e649e1b79763423d20e805676fa0f02d89961b --- .../gg/essential/elementa/layoutdsl/layout.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt index b3a21692..824e53e5 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt @@ -7,6 +7,9 @@ import gg.essential.elementa.state.v2.ReferenceHolder import gg.essential.elementa.common.ListState import gg.essential.elementa.common.not import gg.essential.elementa.state.v2.* +import gg.essential.elementa.state.v2.collections.trackedListOf +import gg.essential.elementa.state.v2.combinators.map +import gg.essential.elementa.state.v2.combinators.not import gg.essential.elementa.util.hoveredState import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind @@ -49,21 +52,21 @@ class LayoutScope( @Suppress("FunctionName") fun if_(state: State, cache: Boolean = true, block: LayoutScope.() -> Unit): IfDsl { - forEach(ListState.from(state.map { if (it) listOf(Unit) else emptyList() }), cache) { block() } - return IfDsl({ !state }, cache) + return if_(state.toV2(), cache, block) } fun if_(state: StateV2, cache: Boolean = true, block: LayoutScope.() -> Unit): IfDsl { - return if_(state.toV1(component), cache, block) + forEach({ if (state()) trackedListOf(Unit) else trackedListOf() }, cache) { block() } + return IfDsl({ !state() }, cache) } fun ifNotNull(state: State, cache: Boolean = false, block: LayoutScope.(T) -> Unit): IfDsl { - forEach(ListState.from(state.map { listOfNotNull(it) }), cache) { block(it) } - return IfDsl({ state.map { it == null } }, true) + return ifNotNull(state.toV2(), cache, block) } fun ifNotNull(state: StateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit): IfDsl { - return ifNotNull(state.toV1(component), cache, block) + forEach({ state()?.let { trackedListOf(it) } ?: trackedListOf() }, cache) { block(it) } + return IfDsl({ state() == null }, true) } fun if_(condition: StateByScope.() -> Boolean, cache: Boolean = false, block: LayoutScope.() -> Unit): IfDsl { @@ -74,10 +77,10 @@ class LayoutScope( return ifNotNull(stateBy(stateBlock), cache, block) } - class IfDsl(internal val elseState: () -> State, internal var cache: Boolean) + class IfDsl(internal val elseState: StateV2, internal var cache: Boolean) infix fun IfDsl.`else`(block: LayoutScope.() -> Unit) { - if_(elseState(), cache, block) + if_(elseState, cache, block) } /** Makes available to the inner scope the value of the given [state]. */ From cb0d4171938ccc0f8aafdfd34a326fda08ef4c4e Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 18 Mar 2024 19:03:41 -0400 Subject: [PATCH 53/66] layoutdsl: migrate `bind` to statev2 Source-Commit: 693b321d9c2e7bb1b90c3d66ed85df4155183b09 Source-Commit: 7951a7165594c99a76a3c480891129d7e7dbe36b --- .../src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt index 824e53e5..3773439c 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt @@ -85,12 +85,12 @@ class LayoutScope( /** Makes available to the inner scope the value of the given [state]. */ fun bind(state: State, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { - forEach(ListState.from(state.map { listOf(it) }), cache) { block(it) } + bind(state.toV2(), cache, block) } /** Makes available to the inner scope the value of the given [state]. */ fun bind(state: StateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { - bind(state.toV1(component), cache, block) + forEach({ trackedListOf(state()) }, cache) { block(it) } } /** Makes available to the inner scope the value derived from the given [stateBlock]. */ From f2b0b52050932ab6d0459dba05f6678d4b14dc0c Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 5 Jul 2024 14:16:44 -0700 Subject: [PATCH 54/66] layoutdsl: migrate `forEach` to statev2 Source-Commit: 2eeca8b4fa5442b3f9e2a2c25b3112558448299c --- .../gg/essential/elementa/layoutdsl/layout.kt | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt index 3773439c..4c13b78d 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt @@ -7,6 +7,8 @@ import gg.essential.elementa.state.v2.ReferenceHolder import gg.essential.elementa.common.ListState import gg.essential.elementa.common.not import gg.essential.elementa.state.v2.* +import gg.essential.elementa.state.v2.collections.MutableTrackedList +import gg.essential.elementa.state.v2.collections.TrackedList import gg.essential.elementa.state.v2.collections.trackedListOf import gg.essential.elementa.state.v2.combinators.map import gg.essential.elementa.state.v2.combinators.not @@ -111,6 +113,13 @@ class LayoutScope( * This requires that [T] be usable as a key in a HashMap. */ fun forEach(state: ListState, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { + forEach(state.toV2().toListState(), cache, block) + } + + /** + * StateV2 support for forEach + */ + fun forEach(list: ListStateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit) { val forEachScope = LayoutScope(component, this@LayoutScope, stateScope) childrenScopes.add(forEachScope) @@ -154,21 +163,30 @@ class LayoutScope( forEachScope.childrenScopes.clear() } - state.get().forEachIndexed(::add) - state.onAdd(::add) - state.onRemove(::remove) - state.onSet { index, element, oldElement -> - remove(index, oldElement) - add(index, element) + fun update(change: TrackedList.Change) { + when (change) { + is TrackedList.Add -> { + val (index, element) = change.element + add(index, element) + } + is TrackedList.Remove -> { + val (index, element) = change.element + remove(index, element) + } + is TrackedList.Clear -> { + clear(change.oldElements) + } + } } - state.onClear(::clear) - } - /** - * StateV2 support for forEach - */ - fun forEach(list: ListStateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit) = - forEach(ListState.from(list.toV1(component)), cache, block) + var trackedList: TrackedList = MutableTrackedList() + effect(stateScope) { + val newList = list() + val oldList = trackedList + newList.getChangesSince(oldList).forEach { change -> update(change) } + trackedList = newList + } + } /** Whether this scope is a virtual "forEach" scope. These share their target component with their parent scope. */ private fun isVirtual(): Boolean { From 0b3063f244e47cb035ffec871caa6844ae04e930 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 5 Jul 2024 14:30:05 -0700 Subject: [PATCH 55/66] layoutdsl: migrate `color.kt` to statev2 Source-Commit: d5a2236608e3b7edc0ad0abb2cf02266d5482dda --- .../kotlin/gg/essential/elementa/layoutdsl/color.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt index 739b3d9e..14025179 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt @@ -5,11 +5,11 @@ import gg.essential.elementa.constraints.ColorConstraint import gg.essential.elementa.constraints.animation.Animations import gg.essential.elementa.dsl.animate import gg.essential.elementa.dsl.toConstraint -import gg.essential.elementa.state.BasicState import gg.essential.elementa.state.State import gg.essential.elementa.state.toConstraint -import gg.essential.elementa.common.onSetValueAndNow import gg.essential.elementa.state.v2.color.toConstraint +import gg.essential.elementa.state.v2.effect +import gg.essential.elementa.state.v2.stateOf import gg.essential.elementa.state.v2.toV2 import gg.essential.elementa.util.hasWindow import java.awt.Color @@ -22,15 +22,16 @@ fun Modifier.color(color: State) = this then BasicColorModifier { color.t fun Modifier.color(color: StateV2) = this then BasicColorModifier { color.toConstraint() } -fun Modifier.hoverColor(color: Color, duration: Float = 0f) = hoverColor(BasicState(color), duration) +fun Modifier.hoverColor(color: Color, duration: Float = 0f) = hoverColor(stateOf(color), duration) @Deprecated("Using StateV1 is discouraged, use StateV2 instead") fun Modifier.hoverColor(color: State, duration: Float = 0f) = whenHovered(if (duration == 0f) Modifier.color(color) else Modifier.animateColor(color, duration)) fun Modifier.hoverColor(color: StateV2, duration: Float = 0f) = whenHovered(if (duration == 0f) Modifier.color(color) else Modifier.animateColor(color, duration)) -fun Modifier.animateColor(color: Color, duration: Float = .3f) = animateColor(BasicState(color), duration) +fun Modifier.animateColor(color: Color, duration: Float = .3f) = animateColor(stateOf(color), duration) +@Deprecated("Using StateV1 is discouraged, use StateV2 instead") fun Modifier.animateColor(color: State, duration: Float = .3f) = animateColor(color.toV2(), duration) fun Modifier.animateColor(color: StateV2, duration: Float = .3f) = this then AnimateColorModifier(color, duration) @@ -49,8 +50,8 @@ private class AnimateColorModifier(private val colorState: StateV2, priva } } - val removeListenerCallback = colorState.onSetValueAndNow(component) { - animate(it.toConstraint()) + val removeListenerCallback = effect(component) { + animate(colorState().toConstraint()) } return { From 8ab57c4af74c546dc9cee5c4e4afcf154957a5e2 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 5 Jul 2024 15:04:06 -0700 Subject: [PATCH 56/66] layoutdsl: migrate `hoveredState` to statev2 Source-Commit: 2a1a003d571ef0b1e3006e5b6673ba35e8eb3b42 Source-Commit: d6ed9a79e8b4e300b7bafb02d3b14ffddc96bbc1 --- .../essential/elementa/util/elementaExtensions.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt index cc9bf5d3..9801ca2d 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt @@ -131,12 +131,12 @@ fun UIComponent.onAnimationFrame(block: () -> Unit) = * This option will induce an additional delay of one frame because the state is updated during the next * [Window.enqueueRenderOperation] after the hoverState changes. */ -fun UIComponent.hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true): State { +fun UIComponent.hoveredStateV2(hitTest: Boolean = true, layoutSafe: Boolean = true): StateV2 { // "Unsafe" means that it is not safe to depend on this for layout changes - val unsafeHovered = BasicState(false) + val unsafeHovered = mutableStateOf(false) // "Safe" because layout changes can directly happen when this changes (ie in onSetValue) - val safeHovered = BasicState(false) + val safeHovered = mutableStateOf(false) // Performs a hit test based on the current mouse x / y fun hitTestHovered(): Boolean { @@ -201,9 +201,9 @@ fun UIComponent.hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true } return if (layoutSafe) { - unsafeHovered.onSetValue { + unsafeHovered.onChange(this) { hovered -> Window.enqueueRenderOperation { - safeHovered.set(it) + safeHovered.set(hovered) } } safeHovered @@ -212,6 +212,9 @@ fun UIComponent.hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true } } +fun UIComponent.hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true): State = + hoveredStateV2(hitTest, layoutSafe).toV1(this) + /** Marker effect for [makeHoverScope]/[hoverScope]. */ private class HoverScope(val state: State) : Effect() From 8fc7ecdc69c3ebc95cb059c77e807a1c16460057 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 5 Jul 2024 15:07:06 -0700 Subject: [PATCH 57/66] layoutdsl: migrate `makeHoverScope` to statev2 Source-Commit: 552042fc156dd87b828098fa5f8144d050284782 --- .../gg/essential/elementa/util/elementaExtensions.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt index 9801ca2d..a522dc97 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt @@ -216,7 +216,7 @@ fun UIComponent.hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true hoveredStateV2(hitTest, layoutSafe).toV1(this) /** Marker effect for [makeHoverScope]/[hoverScope]. */ -private class HoverScope(val state: State) : Effect() +private class HoverScope(val state: StateV2) : Effect() /** * This method declares this component and its children to be part of one hover scope. @@ -240,13 +240,14 @@ private class HoverScope(val state: State) : Effect() * wasn't declared in the first place). * Note that the same rules about first-time resolving still apply. */ -fun UIComponent.makeHoverScope(state: State? = null) = apply { +fun UIComponent.makeHoverScope(state: State? = null) = + makeHoverScope(state?.toV2() ?: hoveredStateV2()) + +fun UIComponent.makeHoverScope(state: StateV2) = apply { removeEffect() - enableEffect(HoverScope(state ?: hoveredState())) + enableEffect(HoverScope(state)) } -fun UIComponent.makeHoverScope(state: StateV2) = makeHoverScope(state.toV1(this)) - /** * Receives the hover scope which this component is subject to. * From 773b96cd0b2750ab433a6c571e55c1b5ca09997c Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 5 Jul 2024 15:13:58 -0700 Subject: [PATCH 58/66] layoutdsl: migrate `hoverScope` to statev2 Source-Commit: c838ef6bc78bedc4ec5800bdf40ac1da38d428cc Source-Commit: 3fd25a005de1626cd425fc06cd1f0f55f3549287 --- .../gg/essential/elementa/util/elementaExtensions.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt index a522dc97..25a8903e 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt @@ -255,18 +255,19 @@ fun UIComponent.makeHoverScope(state: StateV2) = apply { * * @see [makeHoverScope] */ -fun UIComponent.hoverScope(parentOnly: Boolean = false): State { +fun UIComponent.hoverScopeV2(parentOnly: Boolean = false): StateV2 { class HoverScopeConsumer : Effect() { - val state = BasicState(false) + private val boundTo = mutableStateOf?>(null) + val state = StateV2 { (boundTo.getUntracked() ?: boundTo())?.invoke() ?: false } override fun setup() { val sequence = if (parentOnly) parent.selfAndParents() else selfAndParents() val scope = sequence.firstNotNullOfOrNull { component -> component.effects.firstNotNullOfOrNull { it as? HoverScope } - } ?: throw IllegalStateException("No hover scope found for ${this@hoverScope}.") + } ?: throw IllegalStateException("No hover scope found for ${this@hoverScopeV2}.") Window.enqueueRenderOperation { - scope.state.onSetValueAndNow { state.set(it) } + boundTo.set(scope.state) } } } @@ -275,6 +276,9 @@ fun UIComponent.hoverScope(parentOnly: Boolean = false): State { return consumer.state } +fun UIComponent.hoverScope(parentOnly: Boolean = false): State = + hoverScopeV2(parentOnly).toV1(this) + /** Once inherited, you can apply this to a component via [addTag] to be able to [findChildrenByTag]. */ interface Tag From ec33f4fba0f198151d026fd7f5681668911def39 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 5 Jul 2024 15:16:21 -0700 Subject: [PATCH 59/66] layoutdsl: migrate `events.kt` to statev2 Source-Commit: ea4af9df7f091cef2dcc7b9594ad6eddb41e4e3e --- .../gg/essential/elementa/layoutdsl/events.kt | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt index 05e9594f..f9a96080 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt @@ -2,12 +2,7 @@ package gg.essential.elementa.layoutdsl import gg.essential.elementa.UIComponent import gg.essential.elementa.events.UIClickEvent -import gg.essential.elementa.state.v2.toV2 -import gg.essential.elementa.util.Tag -import gg.essential.elementa.util.hoverScope -import gg.essential.elementa.util.makeHoverScope -import gg.essential.elementa.util.addTag -import gg.essential.elementa.util.removeTag +import gg.essential.elementa.util.* import gg.essential.elementa.state.State as StateV1 import gg.essential.elementa.state.v2.State as StateV2 @@ -27,7 +22,7 @@ fun Modifier.hoverScope(state: StateV1? = null) = then { makeHoverScope(state); { throw NotImplementedError() } } /** Declare this component and its children to be in a hover scope. See [makeHoverScope]. */ -fun Modifier.hoverScope(state: gg.essential.elementa.state.v2.State) = +fun Modifier.hoverScope(state: StateV2) = then { makeHoverScope(state); { throw NotImplementedError() } } /** @@ -48,13 +43,13 @@ fun Modifier.inheritHoverScope() = * A [Modifier.hoverScope] is **require** on the component or one of its parents. */ fun Modifier.whenHovered(hoverModifier: Modifier, noHoverModifier: Modifier = Modifier): Modifier = - then { Modifier.whenTrue(hoverScope(), hoverModifier, noHoverModifier).applyToComponent(this) } + then { Modifier.whenTrue(hoverScopeV2(), hoverModifier, noHoverModifier).applyToComponent(this) } /** * Provides the [hoverScope] to be evaluated in a lambda which returns a modifier */ fun Modifier.withHoverState(func: (StateV2) -> Modifier) = - then { func(hoverScope().toV2()).applyToComponent(this) } + then { func(hoverScopeV2()).applyToComponent(this) } /** Applies a Tag to this component. See [UIComponent.addTag]. */ fun Modifier.tag(tag: Tag) = then { addTag(tag); { removeTag(tag) } } From 84438d146580e9003a61cd4314745ff74d10366b Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 5 Jul 2024 15:24:29 -0700 Subject: [PATCH 60/66] layoutdsl: migrate `lazy.kt` to statev2 Source-Commit: 0e966c76a7c7f49d9f3b77549c26e1ce9a7cb1d0 --- .../main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt index 4641cef1..b38e768c 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt @@ -2,8 +2,8 @@ package gg.essential.elementa.layoutdsl import gg.essential.elementa.components.UIContainer import gg.essential.elementa.components.Window -import gg.essential.elementa.state.BasicState -import gg.essential.elementa.state.State +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.mutableStateOf import gg.essential.universal.UMatrixStack /** @@ -14,7 +14,7 @@ import gg.essential.universal.UMatrixStack * "make it not lag". Properly profiling and fixing initialization performance issues should always be preferred. */ fun LayoutScope.lazyBox(modifier: Modifier = Modifier.fillParent(), block: LayoutScope.() -> Unit) { - val initialized = BasicState(false) + val initialized = mutableStateOf(false) box(modifier) { if_(initialized, cache = false /** don't need it; once initialized, we are never going back */) { block() @@ -24,7 +24,7 @@ fun LayoutScope.lazyBox(modifier: Modifier = Modifier.fillParent(), block: Layou } } -private class LazyComponent(private val initialized: State) : UIContainer() { +private class LazyComponent(private val initialized: MutableState) : UIContainer() { override fun draw(matrixStack: UMatrixStack) { super.draw(matrixStack) From e076bf819de5239e51fb24021b7942428bc1bf7f Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 5 Jul 2024 15:35:23 -0700 Subject: [PATCH 61/66] layoutdsl: migrate `size.kt` to statev2 Source-Commit: 220d821dd9b2e90f182eea3c18907f4474797bb8 --- .../kotlin/gg/essential/elementa/layoutdsl/size.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/size.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/size.kt index 8980568c..74d882b1 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/size.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/size.kt @@ -5,8 +5,8 @@ import gg.essential.elementa.constraints.* import gg.essential.elementa.constraints.animation.* import gg.essential.elementa.dsl.* import gg.essential.elementa.common.constraints.FillConstraintIncludingPadding -import gg.essential.elementa.common.onSetValueAndNow import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.effect import gg.essential.elementa.state.v2.stateOf import gg.essential.elementa.util.hasWindow @@ -95,7 +95,9 @@ private class AnimateWidthModifier(private val newWidth: State<() -> WidthConstr } } - val removeListenerCallback = newWidth.onSetValueAndNow(component) { animate(it()) } + val removeListenerCallback = effect(component) { + animate(newWidth()()) + } return { removeListenerCallback() @@ -118,7 +120,9 @@ private class AnimateHeightModifier(private val newHeight: State<() -> HeightCon } } - val removeListenerCallback = newHeight.onSetValueAndNow(component) { animate(it()) } + val removeListenerCallback = effect(component) { + animate(newHeight()()) + } return { removeListenerCallback() From 9efe1d11fa525796915e1bccfc88417b6c13a0f4 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 11 Jul 2024 13:53:51 -0700 Subject: [PATCH 62/66] layoutdsl: migrate `state.kt` to statev2 Source-Commit: 1a0d46aae2a774b0f9fea758ac20b51f735dfe9d Source-Commit: a4c22b3ca1c4072cc8ee4818fbea0c63f6e7ef2a --- .../kotlin/gg/essential/elementa/layoutdsl/state.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt index bdfe0726..93039702 100644 --- a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt @@ -3,6 +3,8 @@ package gg.essential.elementa.layoutdsl import gg.essential.elementa.state.State import gg.essential.elementa.common.onSetValueAndNow import gg.essential.elementa.state.v2.combinators.map +import gg.essential.elementa.state.v2.effect +import gg.essential.elementa.state.v2.toV2 import gg.essential.elementa.state.v2.State as StateV2 @Deprecated("Using StateV1 is discouraged, use StateV2 instead") @@ -24,12 +26,12 @@ fun Modifier.then(state: State): Modifier { } fun Modifier.then(state: StateV2): Modifier { - return this then { - var reverse: (() -> Unit)? = state.get().applyToComponent(this) + return this then component@{ + var reverse: (() -> Unit)? = null - val cleanupState = state.onSetValue(this) { + val cleanupState = effect(this) { reverse?.invoke() - reverse = it.applyToComponent(this) + reverse = state().applyToComponent(this@component) }; { @@ -43,7 +45,7 @@ fun Modifier.then(state: StateV2): Modifier { @Suppress("DeprecatedCallableAddReplaceWith") @Deprecated("Using StateV1 is discouraged, use StateV2 instead") fun Modifier.whenTrue(state: State, activeModifier: Modifier, inactiveModifier: Modifier = Modifier): Modifier = - then(state.map { if (it) activeModifier else inactiveModifier }) + then(state.toV2().map { if (it) activeModifier else inactiveModifier }) fun Modifier.whenTrue(state: StateV2, activeModifier: Modifier, inactiveModifier: Modifier = Modifier): Modifier = then(state.map { if (it) activeModifier else inactiveModifier }) \ No newline at end of file From 7b764d802d9529d7dcc8a52c9db2f2627ce3b148 Mon Sep 17 00:00:00 2001 From: CallumBugajski <11320476+CallumBugajski@users.noreply.github.com> Date: Mon, 22 Jul 2024 10:48:39 +0100 Subject: [PATCH 63/66] StateV2: Implement animateTransitions Source-Commit: ee90cdfeae49312f87af0c5434ce5c76e6d349bf --- .../gg/essential/elementa/state/v2/animate.kt | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/animate.kt diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/animate.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/animate.kt new file mode 100644 index 00000000..dc536ff1 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/animate.kt @@ -0,0 +1,88 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.animation.AnimationStrategy +import gg.essential.elementa.constraints.animation.Animations +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.state.v2.ReferenceHolder +import java.lang.ref.WeakReference + +fun State.animateTransitions( + driverComponent: UIComponent, + duration: Float, + animationStrategy: AnimationStrategy = Animations.OUT_EXP, +): State { + if (duration <= 0f) { + return this + } + val resultState = mutableStateOf(this.getUntracked()) + driverComponent.enableEffect(AnimationDriver(this, WeakReference(resultState), duration, animationStrategy)) + return resultState +} + +private class AnimationDriver( + private val driver: State, + private val resultStateWeakReference: WeakReference>, + private val duration: Float, + private val animationStrategy: AnimationStrategy +): Effect() { + private val animationEventList = mutableListOf() + private lateinit var driverEffect: () -> Unit + private var durationFrames = 1 + + private var previousDriverStateValue = 0f + private var isDestroying = false + + override fun setup() { + previousDriverStateValue = driver.getUntracked() + durationFrames = (Window.of(boundComponent).animationFPS * duration).toInt().coerceAtLeast(1) + driverEffect = effect(ReferenceHolder.Weak) { + val input = driver() + animationEventList.add(AnimationEvent(previousDriverStateValue, input, durationFrames)) + previousDriverStateValue = input + } + } + + override fun animationFrame() { + val resultState = resultStateWeakReference.get() + if (resultState == null) { + destroy() + } else { + animationEventList.forEach { it.age++ } + animationEventList.removeIf { it.age >= durationFrames } + resultState.set(getAnimationValue()) + } + } + + private fun destroy() { + if (isDestroying) { + return + } + isDestroying = true + driverEffect() + Window.enqueueRenderOperation { + boundComponent.removeEffect(this) + } + } + + private fun getAnimationValue(): Float { + if (animationEventList.isEmpty()) { + return previousDriverStateValue + } + + return animationEventList.fold(animationEventList.first().startValue) { acc, event -> + val linearProgress = event.age.toFloat() / event.duration.toFloat() + val animatedProgress = animationStrategy.getValue(linearProgress) + acc + ((event.endValue - acc) * animatedProgress) + } + } + + private data class AnimationEvent( + val startValue: Float, + val endValue: Float, + val duration: Int, + var age: Int = 0, + ) + +} \ No newline at end of file From 9aa9ded7ce70d845e5252c4a4f91eab30a6f5873 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 13 Aug 2024 14:29:06 +0200 Subject: [PATCH 64/66] Build: Update GitHub Actions Should fix actions/cache spending four minutes just idling after each workflow run. GitHub: #149 --- .github/workflows/main.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fd936e7d..18cfdc44 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,9 +13,9 @@ jobs: env: ORG_GRADLE_PROJECT_branch: ${{ github.head_ref || github.ref_name }} steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: distribution: temurin java-version: | @@ -24,11 +24,11 @@ jobs: 17 # Can't use setup-java for this because https://github.com/actions/setup-java/issues/366 - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: ~/.gradle/wrapper key: gradle-wrapper-${{ hashFiles('**/gradle-wrapper.properties') }} - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | ~/.gradle/caches From f66a554070639eb5acd96507d003529f8cb3f459 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 13 Aug 2024 14:34:06 +0200 Subject: [PATCH 65/66] Build: Publish sources We used to publish these for the main Elementa artifact but accidentally stopped doing so with 0b76fa7c. For the unstable artifacts we had never published them. GitHub: #150 --- build.gradle.kts | 2 ++ unstable/layoutdsl/build.gradle.kts | 2 ++ unstable/statev2/build.gradle.kts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index c9aea799..2a3a109a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -78,6 +78,8 @@ apiValidation { nonPublicMarkers.add("org.jetbrains.annotations.ApiStatus\$Internal") } +java.withSourcesJar() + publishing.publications.named("maven") { artifactId = "elementa" } diff --git a/unstable/layoutdsl/build.gradle.kts b/unstable/layoutdsl/build.gradle.kts index e3db468f..dd6d03de 100644 --- a/unstable/layoutdsl/build.gradle.kts +++ b/unstable/layoutdsl/build.gradle.kts @@ -28,6 +28,8 @@ kotlin.jvmToolchain { (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(8)) } +java.withSourcesJar() + publishing { publications { named("maven") { diff --git a/unstable/statev2/build.gradle.kts b/unstable/statev2/build.gradle.kts index cff1694a..61904140 100644 --- a/unstable/statev2/build.gradle.kts +++ b/unstable/statev2/build.gradle.kts @@ -28,6 +28,8 @@ kotlin.jvmToolchain { (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(8)) } +java.withSourcesJar() + publishing { publications { named("maven") { From ddd0c435994a7e5095c5904f7839491d3208fd9c Mon Sep 17 00:00:00 2001 From: CallumBugajski <11320476+CallumBugajski@users.noreply.github.com> Date: Thu, 29 Aug 2024 09:47:36 +0100 Subject: [PATCH 66/66] unstable: Import AlphaEffect, GradientEffect, and related from Essential Source-Commit: 1ecfce45f56459000f495b98bd4a3308be228e06 Co-authored-by: Jonas Herzig Co-authored-by: DJtheRedstoner <52044242+DJtheRedstoner@users.noreply.github.com> --- unstable/layoutdsl/build.gradle.kts | 2 + .../essential/elementa/effects/AlphaEffect.kt | 187 ++++++++++++++++++ .../elementa/effects/GradientEffect.kt | 110 +++++++++++ .../essential/elementa/layoutdsl/gradient.kt | 32 +++ .../elementa/transitions/FadeInTransition.kt | 41 ++++ .../elementa/transitions/FadeOutTransition.kt | 42 ++++ 6 files changed, 414 insertions(+) create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/AlphaEffect.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/GradientEffect.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/gradient.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeInTransition.kt create mode 100644 unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeOutTransition.kt diff --git a/unstable/layoutdsl/build.gradle.kts b/unstable/layoutdsl/build.gradle.kts index dd6d03de..2b44504c 100644 --- a/unstable/layoutdsl/build.gradle.kts +++ b/unstable/layoutdsl/build.gradle.kts @@ -21,6 +21,8 @@ dependencies { compileOnly(libs.versions.universalcraft.map { "gg.essential:universalcraft-1.8.9-forge:$it" }) { attributes { attribute(common, true) } } + // Depending on LWJGL3 instead of 2 so we can choose opengl bindings only + compileOnly("org.lwjgl:lwjgl-opengl:3.3.1") } tasks.compileKotlin.setJvmDefault("all") diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/AlphaEffect.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/AlphaEffect.kt new file mode 100644 index 00000000..3c1985a2 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/AlphaEffect.kt @@ -0,0 +1,187 @@ +package gg.essential.elementa.effects + +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.state.State +import gg.essential.universal.UGraphics +import gg.essential.universal.UMatrixStack +import gg.essential.universal.UResolution +import gg.essential.universal.shader.BlendState +import gg.essential.universal.shader.SamplerUniform +import gg.essential.universal.shader.UShader +import org.lwjgl.opengl.GL11 +import java.io.Closeable +import java.lang.ref.PhantomReference +import java.lang.ref.ReferenceQueue +import java.nio.ByteBuffer +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Applies an alpha value to a component. This is done by snapshotting the framebuffer behind the component, + * rendering the component, then rendering the snapshot with the inverse of the desired alpha. + */ +class AlphaEffect(private val alphaState: State) : Effect() { + private val resources = Resources(this) + private var textureWidth = -1 + private var textureHeight = -1 + + override fun setup() { + initShader() + Resources.drainCleanupQueue() + resources.textureId = GL11.glGenTextures() + } + + override fun beforeDraw(matrixStack: UMatrixStack) { + if (resources.textureId == -1) error("AlphaEffect has not yet been setup or has already been cleaned up! ElementaVersion.V4 or newer is required for proper operation!") + + val scale = UResolution.scaleFactor + + // Get the coordinates of the component within the bounds of the screen in real pixels + val left = (boundComponent.getLeft() * scale).toInt().coerceIn(0..UResolution.viewportWidth) + val right = (boundComponent.getRight() * scale).toInt().coerceIn(0..UResolution.viewportWidth) + val top = (boundComponent.getTop() * scale).toInt().coerceIn(0..UResolution.viewportHeight) + val bottom = (boundComponent.getBottom() * scale).toInt().coerceIn(0..UResolution.viewportHeight) + + val x = left + val y = UResolution.viewportHeight - bottom // OpenGL screen coordinates start in the bottom left + val width = right - left + val height = bottom - top + + if (width == 0 || height == 0 || !shader.usable) { + return + } + + UGraphics.configureTexture(resources.textureId) { + if (width != textureWidth || height != textureHeight) { + GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA8, width, height, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, null as ByteBuffer?) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST) + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST) + textureWidth = width + textureHeight = height + } + + GL11.glCopyTexSubImage2D(GL11.GL_TEXTURE_2D, 0, 0, 0, x, y, width, height) + } + } + + override fun afterDraw(matrixStack: UMatrixStack) { + // Get the coordinates of the component within the bounds of the screen in fractional MC pixels + val left = boundComponent.getLeft().toDouble().coerceIn(0.0..UResolution.viewportWidth / UResolution.scaleFactor) + val right = boundComponent.getRight().toDouble().coerceIn(0.0..UResolution.viewportWidth / UResolution.scaleFactor) + val top = boundComponent.getTop().toDouble().coerceIn(0.0..UResolution.viewportHeight / UResolution.scaleFactor) + val bottom = boundComponent.getBottom().toDouble().coerceIn(0.0..UResolution.viewportHeight / UResolution.scaleFactor) + + val x = left + val y = top + val width = right - left + val height = bottom - top + + if (width == 0.0 || height == 0.0 || !shader.usable) { + return + } + + val red = 1f + val green = 1f + val blue = 1f + val alpha = 1f - alphaState.get() + + var prevAlphaTestFunc = 0 + var prevAlphaTestRef = 0f + if (!UGraphics.isCoreProfile()) { + prevAlphaTestFunc = GL11.glGetInteger(GL11.GL_ALPHA_TEST_FUNC) + prevAlphaTestRef = GL11.glGetFloat(GL11.GL_ALPHA_TEST_REF) + UGraphics.alphaFunc(GL11.GL_ALWAYS, 0f) + } + + shader.bind() + textureUniform.setValue(resources.textureId) + + val worldRenderer = UGraphics.getFromTessellator() + worldRenderer.beginWithActiveShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_TEXTURE_COLOR) + worldRenderer.pos(matrixStack, x, y + height, 0.0).tex(0.0, 0.0).color(red, green, blue, alpha).endVertex() + worldRenderer.pos(matrixStack, x + width, y + height, 0.0).tex(1.0, 0.0).color(red, green, blue, alpha).endVertex() + worldRenderer.pos(matrixStack, x + width, y, 0.0).tex(1.0, 1.0).color(red, green, blue, alpha).endVertex() + worldRenderer.pos(matrixStack, x, y, 0.0).tex(0.0, 1.0).color(red, green, blue, alpha).endVertex() + worldRenderer.drawDirect() + + shader.unbind() + + if (!UGraphics.isCoreProfile()) { + UGraphics.alphaFunc(prevAlphaTestFunc, prevAlphaTestRef) + } + } + + fun cleanup() { + resources.close() + } + + private class Resources(effect: AlphaEffect) : PhantomReference(effect, referenceQueue), Closeable { + var textureId = -1 + + init { + toBeCleanedUp.add(this) + } + + override fun close() { + toBeCleanedUp.remove(this) + + if (textureId != -1) { + GL11.glDeleteTextures(textureId) + textureId = -1 + } + } + + companion object { + val referenceQueue = ReferenceQueue() + val toBeCleanedUp: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) + + fun drainCleanupQueue() { + while (true) { + ((referenceQueue.poll() ?: break) as Resources).close() + } + } + } + } + + companion object { + private lateinit var shader: UShader + private lateinit var textureUniform: SamplerUniform + + private fun initShader() { + if (::shader.isInitialized) return + + shader = UShader.fromLegacyShader(""" + #version 110 + + varying vec2 f_Position; + varying vec2 f_TexCoord; + + void main() { + f_Position = gl_Vertex.xy; + f_TexCoord = gl_MultiTexCoord0.st; + + gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; + gl_FrontColor = gl_Color; + } + """.trimIndent(), """ + #version 110 + + uniform sampler2D u_Texture; + + varying vec2 f_Position; + varying vec2 f_TexCoord; + + void main() { + gl_FragColor = gl_Color * vec4(texture2D(u_Texture, f_TexCoord).rgb, 1.0); + } + """.trimIndent(), BlendState.NORMAL, UGraphics.CommonVertexFormats.POSITION_TEXTURE_COLOR) + + if (!shader.usable) { + println("Failed to load AlphaEffect shader") + return + } + + textureUniform = shader.getSamplerUniform("u_Texture") + } + } +} \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/GradientEffect.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/GradientEffect.kt new file mode 100644 index 00000000..e1e25030 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/GradientEffect.kt @@ -0,0 +1,110 @@ +package gg.essential.elementa.effects + +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.state.v2.State +import gg.essential.universal.UGraphics +import gg.essential.universal.UMatrixStack +import gg.essential.universal.shader.BlendState +import gg.essential.universal.shader.UShader +import org.intellij.lang.annotations.Language +import org.lwjgl.opengl.GL11 +import java.awt.Color + +/** + * Draws a gradient (smooth color transition) behind the bound component. + * + * Unlike [gg.essential.elementa.components.GradientComponent], this effect also applies dithering to the gradient to + * mitigate color banding artifacts. + * + * Note: The behavior of non-axis-aligned gradients (e.g. more than two colors, or diagonal) is currently undefined. + */ +class GradientEffect( + private val topLeft: State, + private val topRight: State, + private val bottomLeft: State, + private val bottomRight: State, +) : Effect() { + override fun beforeChildrenDraw(matrixStack: UMatrixStack) { + val topLeft = this.topLeft.get() + val topRight = this.topRight.get() + val bottomLeft = this.bottomLeft.get() + val bottomRight = this.bottomRight.get() + + val dither = topLeft != topRight || topLeft != bottomLeft || bottomLeft != bottomRight + if (dither) { + shader.bind() + } + + val buffer = UGraphics.getFromTessellator() + if (dither) { + buffer.beginWithActiveShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_COLOR) + } else { + buffer.beginWithDefaultShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_COLOR) + } + + val x1 = boundComponent.getLeft().toDouble() + val x2 = boundComponent.getRight().toDouble() + val y1 = boundComponent.getTop().toDouble() + val y2 = boundComponent.getBottom().toDouble() + + buffer.pos(matrixStack, x2, y1, 0.0).color(topRight).endVertex() + buffer.pos(matrixStack, x1, y1, 0.0).color(topLeft).endVertex() + buffer.pos(matrixStack, x1, y2, 0.0).color(bottomLeft).endVertex() + buffer.pos(matrixStack, x2, y2, 0.0).color(bottomRight).endVertex() + + var prevAlphaTestFunc = 0 + var prevAlphaTestRef = 0f + if (!UGraphics.isCoreProfile()) { + prevAlphaTestFunc = GL11.glGetInteger(GL11.GL_ALPHA_TEST_FUNC) + prevAlphaTestRef = GL11.glGetFloat(GL11.GL_ALPHA_TEST_REF) + UGraphics.alphaFunc(GL11.GL_ALWAYS, 0f) + } + + // See UIBlock.drawBlock for why we use this depth function + UGraphics.enableDepth() + UGraphics.depthFunc(GL11.GL_ALWAYS) + buffer.drawDirect() + UGraphics.disableDepth() + UGraphics.depthFunc(GL11.GL_LEQUAL) + + if (!UGraphics.isCoreProfile()) { + UGraphics.alphaFunc(prevAlphaTestFunc, prevAlphaTestRef) + } + + if (dither) { + shader.unbind() + } + } + + companion object { + @Language("GLSL") + private val vertSource = """ + varying vec4 vColor; + + void main() { + gl_Position = gl_ProjectionMatrix * gl_ModelViewMatrix * gl_Vertex; + vColor = gl_Color; + } + """.trimIndent() + + @Language("GLSL") + private val fragSource = """ + varying vec4 vColor; + + void main() { + // Generate four pseudo-random values in range [-0.5; 0.5] for the current fragment coords, based on + // Vlachos 2016, "Advanced VR Rendering" + vec4 noise = vec4(dot(vec2(171.0, 231.0), gl_FragCoord.xy)); + noise = fract(noise / vec4(103.0, 71.0, 97.0, 127.0)) - 0.5; + + // Apply dithering, i.e. randomly offset all the values within a color band, so there are no harsh + // edges between different bands after quantization. + gl_FragColor = vColor + noise / 255.0; + } + """.trimIndent() + + private val shader: UShader by lazy { + UShader.fromLegacyShader(vertSource, fragSource, BlendState.NORMAL, UGraphics.CommonVertexFormats.POSITION_COLOR) + } + } +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/gradient.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/gradient.kt new file mode 100644 index 00000000..887c7a5e --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/gradient.kt @@ -0,0 +1,32 @@ +package gg.essential.elementa.layoutdsl + +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.effects.GradientEffect +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.stateOf +import java.awt.Color + +fun Modifier.gradient(top: Color, bottom: Color, _desc: GradientVertDesc = GradientDesc) = gradient(stateOf(top), stateOf(bottom), _desc) +fun Modifier.gradient(left: Color, right: Color, _desc: GradientHorzDesc = GradientDesc) = gradient(stateOf(left), stateOf(right), _desc) + +fun Modifier.gradient(top: State, bottom: State, _desc: GradientVertDesc = GradientDesc) = gradient(top, top, bottom, bottom) +fun Modifier.gradient(left: State, right: State, _desc: GradientHorzDesc = GradientDesc) = gradient(left, right, left, right) + +sealed interface GradientVertDesc +sealed interface GradientHorzDesc +private object GradientDesc : GradientVertDesc, GradientHorzDesc + +fun Modifier.gradient( + topLeft: State, + topRight: State, + bottomLeft: State, + bottomRight: State, +) = effect { GradientEffect(topLeft, topRight, bottomLeft, bottomRight) } + +private fun Modifier.effect(effect: () -> Effect) = this then { + val instance = effect() + enableEffect(instance) + return@then { + removeEffect(instance) + } +} diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeInTransition.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeInTransition.kt new file mode 100644 index 00000000..9768289b --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeInTransition.kt @@ -0,0 +1,41 @@ +package gg.essential.elementa.transitions + +import gg.essential.elementa.constraints.animation.AnimatingConstraints +import gg.essential.elementa.constraints.animation.Animations +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.transitions.BoundTransition +import gg.essential.elementa.effects.AlphaEffect +import kotlin.properties.Delegates + +/** + * Fades a component and all of its children in. This is done using + * [AlphaEffect]. When the transition is finished, the effect is removed. + */ +class FadeInTransition @JvmOverloads constructor( + private val time: Float = 1f, + private val animationType: Animations = Animations.OUT_EXP, +) : BoundTransition() { + + private val alphaState = BasicState(0f) + private var alpha by Delegates.observable(0f) { _, _, newValue -> + alphaState.set(newValue) + } + + private val effect = AlphaEffect(alphaState) + + override fun beforeTransition() { + boundComponent.enableEffect(effect) + } + + override fun doTransition(constraints: AnimatingConstraints) { + constraints.setExtraDelay(time) + boundComponent.apply { + ::alpha.animate(animationType, time, 1f) + } + } + + override fun afterTransition() { + boundComponent.removeEffect(effect) + effect.cleanup() + } +} \ No newline at end of file diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeOutTransition.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeOutTransition.kt new file mode 100644 index 00000000..38852d22 --- /dev/null +++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeOutTransition.kt @@ -0,0 +1,42 @@ +package gg.essential.elementa.transitions + +import gg.essential.elementa.constraints.animation.AnimatingConstraints +import gg.essential.elementa.constraints.animation.Animations +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.transitions.BoundTransition +import gg.essential.elementa.effects.AlphaEffect +import kotlin.properties.Delegates + +/** + * Fades a component and all of its children out. This is done using + * [AlphaEffect]. When the transition is finished, the effect is removed. + * Typically, one would hide the component after this transition is finished. + */ +class FadeOutTransition @JvmOverloads constructor( + private val time: Float = 1f, + private val animationType: Animations = Animations.OUT_EXP, +) : BoundTransition() { + + private val alphaState = BasicState(1f) + private var alpha by Delegates.observable(1f) { _, _, newValue -> + alphaState.set(newValue) + } + + private val effect = AlphaEffect(alphaState) + + override fun beforeTransition() { + boundComponent.enableEffect(effect) + } + + override fun doTransition(constraints: AnimatingConstraints) { + constraints.setExtraDelay(time) + boundComponent.apply { + ::alpha.animate(animationType, time, 0f) + } + } + + override fun afterTransition() { + boundComponent.removeEffect(effect) + effect.cleanup() + } +} \ No newline at end of file